普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月12日首页

TanStack Router 实战: 如何设置基础认证和受保护路由

2026年1月12日 10:17

本指南涵盖了在 TanStack Router 应用程序中实现基础认证模式和保护路由的方法。

快速开始

通过创建一个上下文感知(context-aware)的路由器,实现认证状态管理,并使用 beforeLoad 进行路由保护来设置身份验证。本指南侧重于使用 React Context 进行核心认证设置。


创建认证上下文 (Authentication Context)

创建 src/auth.tsx

import React, { createContext, useContext, useState, useEffect } from 'react'

interface User {
  id: string
  username: string
  email: string
}

interface AuthState {
  isAuthenticated: boolean
  user: User | null
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

const AuthContext = createContext<AuthState | undefined>(undefined)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [isLoading, setIsLoading] = useState(true)

  // 在应用加载时恢复认证状态
  useEffect(() => {
    const token = localStorage.getItem('auth-token')
    if (token) {
      // 使用你的 API 验证 token
      fetch('/api/validate-token', {
        headers: { Authorization: `Bearer ${token}` },
      })
        .then((response) => response.json())
        .then((userData) => {
          if (userData.valid) {
            setUser(userData.user)
            setIsAuthenticated(true)
          } else {
            localStorage.removeItem('auth-token')
          }
        })
        .catch(() => {
          localStorage.removeItem('auth-token')
        })
        .finally(() => {
          setIsLoading(false)
        })
    } else {
      setIsLoading(false)
    }
  }, [])

  // 在检查认证时显示加载状态
  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        Loading...
      </div>
    )
  }

  const login = async (username: string, password: string) => {
    // 替换为你的认证逻辑
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password }),
    })

    if (response.ok) {
      const userData = await response.json()
      setUser(userData)
      setIsAuthenticated(true)
      // 存储 token 以便持久化
      localStorage.setItem('auth-token', userData.token)
    } else {
      throw new Error('Authentication failed')
    }
  }

  const logout = () => {
    setUser(null)
    setIsAuthenticated(false)
    localStorage.removeItem('auth-token')
  }

  return (
    <AuthContext.Provider value={{ isAuthenticated, user, login, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

配置路由器上下文 (Router Context)

1. 设置路由器上下文

更新 src/routes/__root.tsx

import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'

interface AuthState {
  isAuthenticated: boolean
  user: { id: string; username: string; email: string } | null
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

interface MyRouterContext {
  auth: AuthState
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: () => (
    <div>
      <Outlet />
      <TanStackRouterDevtools />
    </div>
  ),
})

2. 配置路由器

更新 src/router.tsx

import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export const router = createRouter({
  routeTree,
  context: {
    // auth 将从 App 组件向下传递
    auth: undefined!,
  },
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

3. 连接应用与认证

更新 src/App.tsx

import { RouterProvider } from '@tanstack/react-router'
import { AuthProvider, useAuth } from './auth'
import { router } from './router'

function InnerApp() {
  const auth = useAuth()
  return <RouterProvider router={router} context={{ auth }} />
}

function App() {
  return (
    <AuthProvider>
      <InnerApp />
    </AuthProvider>
  )
}

export default App

创建受保护路由

1. 创建认证布局路由

创建 src/routes/_authenticated.tsx

import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: {
          // 保存当前位置,以便登录后重定向
          redirect: location.href,
        },
      })
    }
  },
  component: () => <Outlet />,
})

2. 创建登录路由

创建 src/routes/login.tsx

import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react'

export const Route = createFileRoute('/login')({
  validateSearch: (search) => ({
    redirect: (search.redirect as string) || '/',
  }),
  beforeLoad: ({ context, search }) => {
    // 如果已认证,则进行重定向
    if (context.auth.isAuthenticated) {
      throw redirect({ to: search.redirect })
    }
  },
  component: LoginComponent,
})

function LoginComponent() {
  const { auth } = Route.useRouteContext()
  const { redirect } = Route.useSearch()
  const navigate = Route.useNavigate()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsLoading(true)
    setError('')

    try {
      await auth.login(username, password)
      // 使用路由器导航跳转到重定向 URL
      navigate({ to: redirect })
    } catch (err) {
      setError('Invalid username or password')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center">
      <form
        onSubmit={handleSubmit}
        className="max-w-md w-full space-y-4 p-6 border rounded-lg"
      >
        <h1 className="text-2xl font-bold text-center">Sign In</h1>

        {error && (
          <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
            {error}
          </div>
        )}

        <div>
          <label htmlFor="username" className="block text-sm font-medium mb-1">
            Username
          </label>
          <input
            id="username"
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            required
          />
        </div>

        <div>
          <label htmlFor="password" className="block text-sm font-medium mb-1">
            Password
          </label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            required
          />
        </div>

        <button
          type="submit"
          disabled={isLoading}
          className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {isLoading ? 'Signing in...' : 'Sign In'}
        </button>
      </form>
    </div>
  )
}

3. 创建受保护的仪表盘 (Dashboard)

创建 src/routes/_authenticated/dashboard.tsx

import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/dashboard')({
  component: DashboardComponent,
})

function DashboardComponent() {
  const { auth } = Route.useRouteContext()

  return (
    <div className="p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">Dashboard</h1>
        <button
          onClick={auth.logout}
          className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
        >
          Sign Out
        </button>
      </div>

      <div className="bg-white p-6 rounded-lg shadow">
        <h2 className="text-xl font-semibold mb-2">Welcome back!</h2>
        <p className="text-gray-600">
          Hello, <strong>{auth.user?.username}</strong>! You are successfully
          authenticated.
        </p>
        <p className="text-sm text-gray-500 mt-2">Email: {auth.user?.email}</p>
      </div>
    </div>
  )
}

添加认证持久化

更新你的 AuthProvider 以在页面刷新时恢复认证状态:

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [isLoading, setIsLoading] = useState(true)

  // 在应用加载时恢复认证状态
  useEffect(() => {
    const token = localStorage.getItem('auth-token')
    if (token) {
      // 使用你的 API 验证 token
      fetch('/api/validate-token', {
        headers: { Authorization: `Bearer ${token}` },
      })
        .then((response) => response.json())
        .then((userData) => {
          if (userData.valid) {
            setUser(userData.user)
            setIsAuthenticated(true)
          } else {
            localStorage.removeItem('auth-token')
          }
        })
        .catch(() => {
          localStorage.removeItem('auth-token')
        })
        .finally(() => {
          setIsLoading(false)
        })
    } else {
      setIsLoading(false)
    }
  }, [])

  // 在检查认证时显示加载状态
  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        Loading...
      </div>
    )
  }

  // ... provider 的其余逻辑
}

生产环境检查清单

在部署认证功能之前,请确保你已经:

  • 使用适当的认证中间件保护了 API 端点
  • 在生产环境中设置了 HTTPS(安全 Cookie 所需)
  • 为 API 端点配置了环境变量
  • 实现了适当的 token 验证和刷新机制
  • 为基于表单的认证添加了 CSRF 保护
  • 测试了认证流程(登录、登出、持久化)
  • 为网络故障添加了适当的错误处理
  • 为认证操作实现了加载状态

常见问题

认证上下文不可用

问题: 出现 useAuth must be used within an AuthProvider 错误。

解决方案: 确保 AuthProvider 包裹了整个应用,且 RouterProvider 位于其内部。

页面刷新后用户登出

问题: 页面刷新时认证状态重置。

解决方案: 如上文持久化部分所示,添加 token 持久化逻辑。

受保护路由在重定向闪烁

问题: 受保护的内容在重定向到登录页面之前短暂显示。

解决方案: 使用 beforeLoad 而不是组件级别的认证检查:

export const Route = createFileRoute('/_authenticated/dashboard')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login' })
    }
  },
  component: DashboardComponent,
})

uniapp 异型无缝轮播图

作者 幸福小宝
2026年1月12日 10:16

上截图

image.png

支持 web ios android

上代码

<template>
<view class="joy-swiper" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd"
@touchcancel="handleTouchEnd">
<!-- 实际数据+填充数据实现无缝循环 -->
<view class="swiper-warap" :style="{ 
        transform: `translate3d(${offsetX}px, 0, 0)`, 
        transition: transitionStyle 
      }">
<view v-for="(item, index) in local_list" :key="index" :id="`item-${index}`" class="swiper-item"
:class="{active: currentIndex == index}" @click.stop="itemClick(item)">
<image class="image" :style="{
            transition: transitionWidth,
            backgroundColor: item.filePath
          }" :src="item.filePath" mode="aspectFill" />
</view>
</view>
</view>
</template>

<script>
export default {
props: {
list: {
type: Array,
default: () => {
return []
}
},
autoplay: {
type: Boolean,
default: false
},
duration: {
type: Number,
default: 3000
}
},
watch: {
list: {
immediate: true,
handler(list) {
this.leng = list.length
if (1 < this.leng) {
// 复制数组 数组1 数组2 数组3
this.local_list = [...list, ...list, ...list]
this.currentIndex = list.length
clearTimeout(this.timeout2)
this.timeout2 = setTimeout(() => {
this.getItemDom().then((res) => {
this.itemWidth = res.width
this.offsetX = -this.currentIndex * this.itemWidth
clearTimeout(this.timeout3)
this.timeout3 = setTimeout(() => {
this.transitionStyle = "transform 0.2s ease-out"
this.transitionWidth = "all ease 0.2s"
clearTimeout(this.timeout2)
clearTimeout(this.timeout3)
}, 50)
})
}, 0)
} else {
this.local_list = list
this.currentIndex = 0
this.offsetX = 0
}
}
},

autoplay: {
immediate: true,
handler(val) {
this.local_autoplay = val
}
},
local_autoplay: {
handler(val) {
if (val) {
this.autoplayHandler()
} else {
this.interval && clearInterval(this.interval)
}
},
immediate: true,
}
},
data() {
return {
itemWidth: 0, // 单项宽度
isDragging: false, // 防止断触
startX: 0,
startY: 0,
distance: 0,
miniDistance: 25, // 最小距离
offsetX: 0,
damping: 0.38, // 阻尼系数
transitionStyle: "none",
transitionWidth: "all ease 0.2s",
leng: 0, // 原始数组length
currentIndex: 0, // 当前选中项索引
local_list: [], // 新的数组数据
local_autoplay: false,
interval: null,
timeout1: null,
timeout2: null,
timeout3: null,
};
},
methods: {
handleTouchStart(e) {
this.distance = 0;
this.local_autoplay = false;
if (this.leng == 1) return;
this.startX = e.touches[0].pageX;
this.startY = e.touches[0].pageY;
this.isDragging = true;
// 拖拽时禁用过渡
this.transitionStyle = "none";
this.transitionWidth = "none";
},
handleTouchMove(e) {
this.local_autoplay = false;
if (this.leng == 1) return;
if (!this.isDragging) return;
// 阻止事件冒泡,上调允许上下滚动的阈值
if (Math.abs(e.touches[0].pageY - this.startY) < 50) {
e.stopPropagation()
}
// 手姿移动的距离
this.distance = e.touches[0].pageX - this.startX;
// 盒子实际移动的距离 = 手势距离 * 阻尼系数
const domDistance = this.distance * this.damping
// X轴方向位移距离,判断允许左右滚动的阈值
if (this.miniDistance < Math.abs(this.distance)) {
this.offsetX = -this.currentIndex * this.itemWidth + domDistance;
}
},
handleTouchEnd() {
this.local_autoplay = this.autoplay;
if (this.leng == 1) return;
if (Math.abs(this.distance) <= this.miniDistance) return;
this.changeHandler()
},
changeHandler(eventType) {
// 开启过渡
this.transitionStyle = "transform 0.2s cubic-bezier(0.2, 0.7, 0.3, 1)";
this.transitionWidth = "all ease 0.2s";
if (eventType === 'autoplayHandler') {
this.currentIndex++;
} else {
// 计算是否超过一个item的宽度,超过则移动一个item宽度的距离
const delta = Math.round(this.distance * this.damping / this.itemWidth);
if (1 <= Math.abs(delta)) {
// 根据 distance 正负判断滑动的方向
if (0 < this.distance) {
this.currentIndex--;
} else {
this.currentIndex++;
}
}
}
// X轴方向位移距离
this.offsetX = -this.currentIndex * (this.itemWidth)
// 过渡动画结束时重置索引,实现无缝滑动效果
this.timeout1 && clearTimeout(this.timeout1)
this.timeout1 = setTimeout(() => {
// 修改数据时禁用过渡动画以实现视觉欺骗,否则盒子和元素会出现跳动
this.transitionStyle = "none";
this.transitionWidth = "none";
// 向右滑到 0 时,截取数组3放在最前面
if (this.currentIndex === 0) {
const temp = this.local_list.splice(this.leng * 2, this.leng)
this.local_list = [...temp, ...this.local_list]
}
// 向右滑到 this.list.length * 2 时,截取数组1放在最后面
if (this.currentIndex === this.leng * 2) {
const temp = this.local_list.splice(0, this.leng)
this.local_list = [...this.local_list, ...temp]
}
// 重置索引为 this.list.length
if (this.currentIndex === 0 || this.currentIndex === this.leng * 2) {
this.currentIndex = this.leng
this.offsetX = -this.currentIndex * this.itemWidth
}
// 恢复
this.isDragging = false;
}, 220)
},
autoplayHandler() {
this.interval && clearInterval(this.interval)
this.interval = setInterval(() => {
this.changeHandler('autoplayHandler')
}, this.duration);
},
getItemDom() {
return new Promise((resolve, reject) => {
let selectorQuery = uni.createSelectorQuery().in(this);
// #ifdef MP-ALIPAY
selectorQuery = uni.createSelectorQuery();
// #endif
selectorQuery
.select("#item-1")
.boundingClientRect()
.exec((res) => {
resolve(res[0])
})
})
},
itemClick(item) {
this.$emit('click', JSON.parse(JSON.stringify(item)))
}
},
destroyed() {
clearTimeout(this.timeout1)
clearTimeout(this.timeout2)
clearTimeout(this.timeout3)
clearInterval(this.interval)
},
};
</script>

<style lang="scss">
.joy-swiper {
padding-top: 100px;
width: 100vw;
overflow: hidden;
position: relative;

.swiper-warap {
display: flex;
flex-wrap: nowrap;
padding: 0 4px;

.swiper-item {
display: flex;
position: relative;
flex-shrink: 0;
padding: 0 4px;

.image {
display: block;
width: 73px;
height: 150px;
border-radius: 5px;
}

&.active .image {
width: calc(100vw - 175px);
border-radius: 5px;
}
}
}
}
</style>

使用姿势

<template>
<view>
<joy-swiper :list="swiper" @click="clickItem" />
</view>
</template>

<script>
    export default {
        data() {
            return {
                // 建议数组长度在3个以上
                // 假数据是用背景色代替图片路径,引入插件后在插件内删除image的backgroundColor属性即可
                swiper: [
                    {
                        filePath: '#815c94'
                    },
                    {
                        filePath: '#2E5A6F'
                    },
                    {
                        filePath: '#ed5126'
                    },
                    {
                        filePath: '#B6D7A8'
                    },
                    {
                        filePath: '#2A52BE'
                    },
                    {
                        filePath: '#96c24e'
                    },
                ]
            }
        },
        methods: {
            clickItem(item) {
                console.log(item)
            }
        }
    }
</script>

<style>

</style>

前端存储与离线应用实战:Cookie、LocalStorage、PWA 及 Service Worker 核心知识点

2026年1月12日 10:02

1. 前言

该文章围绕浏览器存储及相关技术展开,核心涵盖Cookie、LocalStorage、SessionStorage、IndexedDB 四种浏览器存储方式(各有存储大小、使用场景等差异),同时介绍了 PWA(渐进式 Web 应用) 的特性与相关工具,以及 Service Worker 的作用、运行机制和调试方式,最终通过案例分析与实战帮助学习者掌握各类技术的概念、使用及选择逻辑。

2.思维导图(mindmap)

image.png

3.浏览器存储方式详情(核心对比)

存储方式 核心定位 存储大小 关键特性 典型用途
Cookie 维持 HTTP 无状态的客户端状态存储 约 4KB 1. 生成方式:HTTP 响应头 set-cookie、JS 的 document.cookie;2. 关联对应域名(存在 CDN 流量损耗);3. 支持 httponly 属性;4. 可设置 expire 过期时间 辨别用户、记录客户基础信息
LocalStorage HTML5 专用浏览器本地存储 约 5M 1. 仅客户端使用,不与服务端通信;2. 接口封装更友好;3. 持久化存储(除非主动清除) 浏览器本地缓存方案
SessionStorage 会话级浏览器存储 约 5M 1. 仅客户端使用,不与服务端通信;2. 接口封装更友好;3. 会话结束后数据清除 临时维护表单信息
IndexedDB 客户端大容量结构化数据存储 -(无明确限制,支持大量数据) 1. 低级 API,支持索引;2. 高性能数据搜索;3. 弥补 Web Storage 大容量存储短板 为应用创建离线版本

三、PWA(Progressive Web Apps)相关

  1. 定义:并非单一技术,而是通过一系列 Web 新特性 + 优秀 UI 交互设计,渐进式增强 Web App 用户体验的新模型

  2. 核心特性:

    • 可靠:无网络环境下可提供基本页面访问,避免 “未连接到互联网” 提示

    • 快速:针对网页渲染和网络数据访问做了专项优化

    • 融入:可添加到手机桌面,支持全屏显示、推送等原生应用类似特性

  3. 相关工具:lighthouse(下载地址:lavas.baidu.com/doc-assets/…

四、Service Worker 相关

  1. 定义:独立于当前网页,在浏览器后台运行的脚本,为无页面 / 无用户交互场景的特性提供支持
  2. 核心能力:
    • 首要特性:拦截和处理网络请求,编程式管理缓存响应
    • 未来特性:推送消息、背景同步、地理围栏定位(geofencing)
  3. 生命周期:Installing(安装中)→ Activated(激活)→ Idle(闲置)/ Terminated(终止),过程中可能出现 Error(错误)
  4. 调试地址:
    • chrome://serviceworker-internals/
    • chrome://inspect/#service-workers

4. 关键问题

问题 1:Cookie 与 LocalStorage 作为浏览器存储方式,核心差异体现在哪些方面?

答案:两者核心差异集中在 4 点:1. 存储大小:Cookie 约 4KB,LocalStorage 约 5M;2. 通信特性:Cookie 会随 HTTP 请求发送至服务端(关联域名导致 CDN 流量损耗),LocalStorage 仅在客户端使用,不与服务端通信;3. 核心定位:Cookie 侧重维持 HTTP 无状态的客户端状态,LocalStorage 是 HTML5 设计的专用本地缓存方案;4. 附加特性:Cookie 支持 expire 过期时间和 httponly 属性,LocalStorage 无过期时间(需主动清除)且无 httponly 相关设置。

问题 2:PWA 能提供 “可靠、快速、融入” 的用户体验,其背后依赖的关键技术支撑是什么?

答案:PWA 的核心体验依赖两大关键技术:1. Service Worker:通过后台运行的脚本拦截网络请求、管理缓存响应,实现无网络环境下的基本页面访问(支撑 “可靠” 特性),同时优化网络数据访问效率(辅助 “快速” 特性);2. IndexedDB:提供客户端大容量结构化数据存储能力,为 PWA 离线版本提供数据支撑(强化 “可靠” 特性);此外,Web 新特性与优化的 UI 交互设计共同保障了 “快速” 和 “融入”(如桌面添加、全屏显示)特性的实现。

问题 3:在实际开发中,如何根据需求选择合适的浏览器存储方式?

答案:需结合存储数据量、使用场景、是否与服务端交互等需求判断:1. 若需存储少量用户标识、会话状态(需随请求发送至服务端),选择 Cookie(约 4KB,支持过期时间);2. 若需在客户端持久化存储中等容量数据(不与服务端交互),如本地缓存配置、用户偏好,选择 LocalStorage(约 5M);3. 若需临时存储会话期间的表单数据、页面临时状态(会话结束后无需保留),选择 SessionStorage(约 5M);4. 若需存储大量结构化数据(如离线应用的本地数据库),支撑应用离线使用,选择 IndexedDB(无明确容量限制,支持索引和高性能搜索)。

终极指南:在 Flutter 中通过 sign_in_with_apple 实现 Apple 登录

作者 JarvanMo
2026年1月12日 09:56

Apple 登录已成为移动应用中必不可少的身份验证选项,尤其是在你的 App 已经提供了 Google 或 Facebook 等社交登录的情况下。Apple 甚至规定,如果 App 提供了第三方登录,就必须为 iOS 用户同时提供 Apple 登录选项。

在这篇博文中,我们将全方位拆解这一功能——从环境配置代码实现,再到 UI 设计后端校验以及最佳实践

🔥 什么是 Apple 登录? Apple 登录允许用户通过其 Apple ID 安全且私密地登录你的应用。它支持选择性分享邮箱,或者使用 Apple 的“隐藏邮件地址”转发服务。借助 Flutter 插件,这一功能可以完美适配 iOS、macOS、Android 以及 Web 端

📦 为什么要使用 sign_in_with_apple 插件? pub.dev 上的 sign_in_with_apple 是官方社区维护的 Flutter 桥接插件。它是目前集成 Apple 登录的主流方案,支持:

  • ✔ 唤起 Apple 原生身份验证界面
  • ✔ 请求用户信息(如全名和邮箱)
  • ✔ 获取身份令牌 (Identity Tokens) 和授权码 (Authorization Codes)
  • ✔ 跨平台支持(通过 sign_in_with_apple_web 等扩展支持 iOS、macOS、Android 和 Web)

🛠️ 准备工作 在开始之前,请确保你已满足以下条件:

  • ✔ 拥有一个已付费的 Apple Developer 计划账号
  • ✔ 已在 Apple Developer Portal 注册了应用的 Bundle ID
  • ✔ 使用 iOS 13+ 的真机或模拟器进行测试
  • ✔ 已在 Xcode 中配置了“Sign In with Apple”功能权限 (Capability)

👉 1. Apple Developer 后台配置

✅ 第一步:注册 App ID

  1. 登录后台,进入 Certificates, Identifiers & Profiles
  2. 选择 Identifiers → 点击 + 号。
  3. 填写应用描述(Description)和 Bundle ID
  4. 在下方列表勾选 Sign in with Apple
  5. 保存变更。

📌 作用: 这一步是让你的应用获得调用 Apple 身份验证服务的权限。

✅ 第二步:创建 Service ID(可选) 如果你计划支持 Web 端或 Android 端的重定向登录流程,则需要配置 Service ID

  1. 前往 IdentifiersService IDs
  2. 注册一个具有唯一名称的服务。
  3. 为其勾选 Sign in with Apple
  4. 配置用于 Web 验证的 重定向 URI (Redirect URIs)

📌 作用: 这主要用于 Web 端或基于 OAuth 的重定向验证流程。


👉 2. 添加 Flutter 依赖 打开你的 pubspec.yaml 文件,添加以下配置:

dependencies:
  flutter:
    sdk: flutter
  sign_in_with_apple: ^7.0.1

运行:

flutter pub get

✔ 这一步将安装 Apple 登录插件及其相关的依赖项。

👉 3. iOS 平台配置 (Xcode)

  1. 在 Xcode 中打开项目: 路径为 ios/Runner.xcworkspace

  2. 配置 Signing & Capabilities(签名与功能):

    • 点击左上角的 + Capability 按钮。
    • 搜索 Sign in with Apple 并双击添加。
  3. 核对配置,确保你的 App Target 包含:

    • 正确的 Bundle ID
    • 已添加的 Sign in with Apple 功能权限。
  4. (可选)启用 Keychain Sharing(钥匙串共享):

    • 开启此项有助于在用户卸载重装应用后,依然能保持登录状态或找回凭据。

👉 4. 在 Flutter 中添加登录按钮

import 'package:sign_in_with_apple/sign_in_with_apple.dart';

接下来,在你的界面里加入这个按钮:

SignInWithAppleButton(
  onPressed: () async {
    final credential = await SignInWithApple.getAppleIDCredential(
      scopes: [
        AppleIDAuthorizationScopes.email,
        AppleIDAuthorizationScopes.fullName,
      ],
    );

print("Authorization Code: ${credential.authorizationCode}");
    print("User Email: ${credential.email}");
  },
),

🔍 这段代码的作用:

  • ✔ 唤起 Apple 原生登录弹窗

  • ✔ 返回包含以下信息的凭证 (Credential):

    • authorizationCode (授权码)
    • identityToken (身份令牌)
    • emailfullName(可选)
  • ✔ 将这些数据发送至后端进行身份验证。


👉 5. 深入理解返回的凭证 (Credentials) 当用户完成登录时:

  • authorizationCode:一个短效代码,服务器用它向 Apple 换取访问令牌(Access Tokens)。
  • identityToken:一个包含用户信息(邮箱、姓名)的 JWT (JSON Web Token)
  • email & fullName仅在用户第一次登录时显示

📌 敲黑板: Apple 只会返回一次邮箱和姓名,所以请务必在第一次获取时就将其安全地存储到你的数据库中!


👉 6. 安全加固(后端校验) 拿到凭证后,为了确保安全,你需要:

  1. authorizationCode 发送到你的后端。
  2. 后端向 Apple 服务器请求交换访问令牌。
  3. 验证 identityToken 的签名和声明(Claims)。
  4. 在你的系统中创建或验证用户会话。

📌 作用: 这能确保只有合法的 Apple ID 才能进入系统,有效防止令牌伪造或重放攻击。

注意: 如果你使用的是 Firebase Authentication,Firebase 会帮你处理这整套复杂的校验流程,你无需自己手动实现后端验证逻辑。


👉 7. Android 与 Web 端支持 该插件通过额外配置也可支持 Android 和 Web:

  • ✔ Android:需要在 AndroidManifest.xml 中配置重定向 Activity。
  • ✔ Web:需在 Apple Developer 后台配置 Service ID 及其回调 URL。

📌 常见“坑点”提醒

  • 🚫 并非每次登录都能拿到邮箱 出于隐私保护,Apple 仅在首次登录时发送邮箱。之后,你只能拿到用户的唯一 ID (user ID),参看stackoverflow
  • 🚫 必须使用付费 Apple 账号 免费版 Apple ID 无法注册 Apple 登录所需的各项服务。

✨ 完整代码示例

import 'package:flutter/material.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';

class AppleSignInDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: SignInWithAppleButton(
          onPressed: () async {
            try {
              final credential = await SignInWithApple.getAppleIDCredential(
                scopes: [
                  AppleIDAuthorizationScopes.email,
                  AppleIDAuthorizationScopes.fullName,
                ],
              );
              // Send these credentials to the backend
              // and create your user session
              print(credential);
            } catch (e) {
              print("Error: $e");
            }
          },
        ),
      ),
    );
  }
}

📌 最佳实践

  • ✅ 务必在服务器端验证 Token:永远不要只在客户端做判断。
  • ✅ 安全存储用户信息:妥善保存初次获取到的邮箱和姓名。
  • ✅ 做好容错处理:当 Apple 返回的用户信息有限时(非首次登录),确保 App 逻辑依然稳健。
  • ✅ 使用真机测试:只有真机才能模拟最真实的授权行为和交互反馈。

📌 结语 通过使用 sign_in_with_apple 插件,你可以为 Flutter 用户提供快速且隐私优先的身份验证体验。虽然前端 UI 的集成非常简单,但严谨的后端 Token 校验才是保障系统安全的重中之重。

无论你的项目是基于 Firebase、自定义后端还是其他认证服务器,本指南都为你提供了全方位的参考,助你信心满满地在 Flutter 应用中上线 Apple 登录功能。

视频播放弱网提示实现

2026年1月12日 09:55

作者:陈盛靖

一、背景

业务群里面经常反馈,视频播放卡顿,视频播放总是停留在某一时刻就播放不了了。后面经过排查,发现这是因为弱网导致的。然而,用户数量众多,隔三差五总有人在群里反馈,有时问题一时半会好不了,用户就会怀疑不是网络,而是我们的系统问题。因此,我们希望能在弱网的时候展示提示,这样用户体验会更友好,同时也能减少一定的客诉。

二、现状分析

我们使用的播放器是chimee(www.chimee.org/index.html)。遗憾的是,chimee并没有视频播放卡顿自动展示loading的功能,不过我们可以通过其插件能力,来编写一个自定义video-loading的插件。

三、方案设计

使用NetworkInformation

常见的方法就是我们通过设定一个标准,然后检测用户设备的网络速度,在到达一定阈值时展示弱网提示。这里需要确定一个重要的点:什么情况下才算弱网?

我们的应用是h5,这里我们可以使用window对象中的NetworkInformation(developer.mozilla.org/zh-CN/docs/…),我们可以通过浏览器的debug工具,打印window.naviagtor.connection,这个对象内部就存储着网络信息:

其中各个属性含义如下表所示:

属性 含义
downlink 返回以兆比特每秒为单位的有效带宽估计,四舍五入到最接近的 25 千比特每秒的倍数。
downlinkMax 返回底层连接技术的最大下行速度,以兆比特每秒(Mbps)为单位。
effectiveType 返回连接的有效类型(意思是“slow-2g”、“2g”、“3g”或“4g”中的一个)。此值是使用最近观察到的往返时间和下行链路值的组合来确定的。
rtt 返回当前连接的有效往返时间估计,四舍五入到最接近的 25 毫秒的倍数。
saveData 如果用户在用户代理上设置了减少数据使用的选项,则返回 true。
type 返回设备用于网络通信的连接类型。它会是以下值之一:
bluetooth
cellular
ethernet
none
wifi
wimax
other
unknown
onchange 接口的 change 事件在网络连接信息发生变化时被触发,并且该事件由 NetworkInformation(developer.mozilla.org/zh-CN/docs/…) 对象接收。

其中,我们可以通过effectiveType判断当前网络的大体情况,并且可以拿到一个预估的网络带宽(downlink)。我们可以通过监听onchange事件,在网络变差的时候,展示对应的弱网提示。

这个方案的优点是:

  • 浏览器环境原生支持
  • 实现相对简单

但缺点却十分明显:

  • 网络状态变化非实时

effectiveType的变化可能是分钟级别的,对于短暂的网络波动,状态没办法做更精细的把控

  • 存在兼容性问题

对于不同一些主流浏览器不支持,例如Firefox、Safari等

  • 不同设备间存在差异

不同的设备和浏览器,由于其差异,在不同的网络情况下,视频的播放情况是不一样的,如果我们固定一个标准,可能会导致在不同设备下,同一个网络速度,有人明明正常播放视频,但是却提示网络异常,这样用户会感到疑惑。

那有没有更好的方法呢?

监听Video元素事件

chimee底层也是在html video上进行的二次封装,我们可以在插件的生命周期中,拿到对应的video元素节点。而在video标签中,存在这样两个事件:waiting和canplay。

其事件描述如下图所示:

当视频播放卡顿时,会触发waiting事件;而当视频播放恢复正常时,会触发canplay事件。只要监听这两个事件,我们就可以实现对应的功能了。

四、功能拓展

我们知道,现在大多数网站的视频在提示弱网的时候,都会展示当前设备的网络速度是多少。因此我们也希望在展示对应的信息。那么怎么实现网络速度的检测呢?

一个简单的方法是,我们可以通过获取一张固定大小的图片资源(不一定是图片,也可以是别的类型的资源),并统计请求该资源的请求速度,从而计算当前网络的带宽是多少。当然,图片大小要尽可能小一点,一是为了节省用户流量,二是为了避免在网络不好的情况下,图片请求太慢导致一直计算不出来。

具体代码如下:

funtion calculateSpeed() {
  // 图片大小772Byte
  const fileSize = 772;
  // 拼接时间戳,避免缓存
  const imgUrl = `https://xxx.png?timestamp=${new Date().getTime()}`;

  return new Promise((resolve, reject) => {
    let start = 0;
    let end = 1000;
    let img = document.createElement('img');
    start = new Date().getTime();
    img.onload = function (e) {
        end = new Date().getTime();
        // 计算出来的单位为 B/s
        const speed = fileSize / (end > start ? end - start : 1000) * 1000;
        resolve(speed);
    }
    img.src = imgUrl;
  }).catch(err => { throw err });
}
function translateUnit(speed) {
  if(speed === 0) return '0.00 B/s';
  if(speed > 1024 * 1024) return `${(speed / 1024 / 1024).toFixed(2)} MB/s`;
  if(speed > 1024) return `${(speed / 1024).toFixed(2)} KB/s`;
  else return `${speed.toFixed(2)} B/s`;
}

我们可以通过setInterval来轮询调用该函数,从而实时展示当前网络情况。系统流程图如下:

画板

五、总结

我们可以通过Chrome浏览器开发者工具中的Network中的网络配置来模拟弱网情况 具体效果如下:

成功实现视频弱网提示,完结撒花🎉🎉🎉🎉🎉🎉。

情迷服务器驱动 UI:我在 Flutter 开发中的爱与哀愁

作者 JarvanMo
2026年1月12日 09:47

嘿,Flutter 开发者!如果你曾经为了把一个按钮往左挪 20 像素,就不得不苦等三天的 App Store 审核,那你一定听过 Server-Driven UI (SDUI) 的“迷魂曲”。我曾在大型项目中落地过 SDUI,曾为此在凌晨三点崩溃大哭,但也最终学会了爱上它——前提是,你得知道怎么驯服这头怪兽。

这是我的实战心得:它的好,它的丑,以及到底什么时候该用(或绝对别碰)它。

魔法时刻——SDUI 让你像开了挂

  • 瞬间上线: 喝杯早咖啡的功夫,你就换掉了整个首页——不用重新打包,不用发布,更不用等苹果审核。
  • 究极 A/B 测试: 明天上线 15 个不同版本的下单流程,看看哪个转化率最高。这才是真正的 A/B 测试终极形态。
  • 千人千面: 给免费用户、高级用户、巴西用户或是上周弃购的用户展示完全不同的 UI。
  • 运营自由: 让市场部在 12 月 24 日晚上 11:59 发布圣诞活动,而你完全不用起床。
  • 迭代提速: 产品经理和设计师的迭代速度能快 10 倍,因为改个文案这种小事再也不用麻烦移动端同学了。
  • 包体积瘦身: 安装包里“写死”的页面少了,APK 也就苗条了。
  • 一份逻辑,三端共用: 一份 JSON 就能驱动 Flutter App、Web 端,甚至未来的 React Native 版本。
  • 灰度发布易如反掌: 觉得新模块不稳?先只发给 1% 的用户看看情况。
  • 离线友好: 配合好缓存,App 在断网时依然能完美运行——直接展示最后一次加载成功的界面就行。

这种感觉就像在作弊。而有时候……它确实就是在走捷径。


阴暗面——没人预先告诉你的坑

  • 一字之错,全线崩溃: JSON 里写错一个 Key,你就亲手给 200 万用户发了个白屏。而且还是在周日凌晨三点。
  • 调试噩梦: Flutter Inspector?基本没戏。热重载?帮不上忙。你只能对着原始 JSON 疯狂分析,然后在线祈祷。
  • 动画终结者: 你花了两个星期精心调优的自定义动画?祝你能用 JSON 把它描述出来。
  • 性能税: 每个页面都要跑 300-600 KB 的解析逻辑。
  • 层级深渊: 过深嵌套的 JSON 会让首帧卡得像回到了 2018 年。
  • 后端“越权”: 既然逻辑在后端,出事了锅就在后端。恭喜你,你们团队又多了一个互相甩锅的机会。
  • 审美崩坏: 后端同学可能完全不懂 Material 或 Cupertino 规范,分分钟搞出个四不像。
  • 安全漏洞: 如果校验不严,那个“跳转 URL”的动作可能会把用户带进钓鱼网站。
  • 复杂表单: 带自定义校验逻辑的复杂表单,做起来简直是自我折磨。

相信我,这些坑我都踩过。


什么时候用 SDUI 最爽?(闭眼入)

  • 动态页面: 首页、信息流、仪表盘,这类每周都要变的页面。
  • 运营活动: 营销横幅、节日活动、限时秒杀。
  • 内容驱动: 新闻、教育、社交动态、CMS 驱动的产品。
  • 高频实验: 任何需要疯狂做 A/B 测试的地方。
  • 个性化流程: 银行或电商 App 中,不同用户群体需要稍微不同的操作流。
  • 精锐部队: 后端实力极强且监控体系完善的大型团队。

什么时候用 SDUI 会让你怀疑人生?(快跑!)

  • 核心流程: 登录、注册、支付、结账——任何坏了就会丢钱、丢用户的页面。
  • 炫酷交互: 带有复杂动画、Custom Painter、Shader 或 Lottie 神作的页面。
  • 重性能体验: 相机、视频编辑器、游戏、地图、AR。
  • 小团队/初创: 还没有配齐专业的后端和运维力量。
  • 万年不变: App 半年才改一次 UI。
  • 缺乏基建: 团队没有完善的监控、告警和自动回滚机制。

听我一句劝:快跑,跑得越远越好。


如何正确落地 SDUI?(我的“保命”清单)

这是我希望第一天就能拿到的避坑指南:

  1. 拒绝“全量动效”: 永远不要 100% SDUI。目标是 70-90% 原生 Flutter + 10-30% 动态,把复杂的逻辑留在原生。
  2. 核心逻辑写死: 登录、支付、设置、底部导航这些核心链路永远硬编码。
  3. 建立组件库: 打造一套神圣不可侵犯的组件库(15-25 个 Widget)。后端只能通过这些组件来“搭积木”,绝无例外。
  4. Schema 版本化: 给每一版 JSON 加上版本号(比如 home_v17)。
  5. 永不裸奔: 为每一个动态页面准备一个原生兜底页面(Static Fallback)。
  6. 严苛校验: 像防贼一样校验输入的 JSON,只允许白名单内的操作。
  7. 疯狂缓存: 用 Hive 或 Isar 搞好缓存。用户看当前页时,就预取下一页。
  8. 鹰眼监控: 盯着解析错误、渲染时长和崩溃率,一点都不能松懈。
  9. 自动回滚: 设定好阈值。如果发完 JSON 崩溃率翻倍,瞬间撤回。
  10. 从小开始: 先试着动态化一个营销横幅。成功了,庆祝一下,再慢慢扩张。

按这些规矩办,SDUI 就是你最强的武器。如果不按规矩来,一个迭代就能让你老十岁。


最终裁决——你该用它吗?

  • 大厂/大 App,UI 变动频繁,后端强悍冲吧,拥抱它(记得带上上面的规则)。
  • 中型 App,有一些营销需求推荐,混合方案最香。
  • 小团队/个人开发者等等再说,等你有精力搞监控和基建了再来。
  • 核心流/重度动画老老实实写 Flutter,睡个安稳觉。

SDUI 不是魔法,它是一把电锯:双手抓稳并心存敬畏时,它威力无穷;但要是交给一个没见过电锯的人,后果不堪设想。

用得好,你就是全街发版最快的仔;用得烂,你就是那个圣诞节被 Call 起来修 Bug 的倒霉蛋。

言尽于此,好自为之。

祝你构建愉快!

Vben Admin管理系统集成qiankun微服务(二)

作者 go_caipu
2026年1月11日 23:05

继上篇

上篇Vben Admin管理系统集成qiankun微服务(一)遗留的三个问题:

  1. 子应用鉴权使用主应用鉴权,如果系统鉴权过期要跳转到登录页面。
  2. 主应用和子应用保持主题风格一致,主应用调整子应用同步调整。
  3. 支持多个应用动态加载。

下面分步完成以上相关内容

1. 主应用和子应用主题同步

主应用

主应用和子应用的数据传递主要使用props实现,上篇文章已经实现了部分没有详细解释,本篇补充以上内容。 通过props.userInfo和props.token 传递登录信息和授权信息,

vue-vben-admin/apps/web-antd/src/qiankun/config.ts

/**  本地应用测试微服务架构 */
export default {
  subApps: [
    {
      name: 'basic', // 子应用名称,跟package.json一致
      // entry: import.meta.env.VITE_API_BASE_URL, // 子应用入口,本地环境下指定端口
      entry: 'http://localhost:5667', // 子应用入口,本地前端环境下指定端口'http://localhost:5174',发布可以调整为主系统:/app/workflow-app/= /app/插件名称/
      container: '#sub-container', // 挂载子应用的dom
      activeRule: '/app/basic', // 路由匹配规则
      props: {
        userInfo: [],
        token: '',
      }, // 主应用与子应用通信传值
      sandbox: {
        strictStyleIsolation: true, // 启用严格样式隔离
      },
    },
  ],
};

vue-vben-admin/apps/web-antd/src/qiankun/index.ts文件,实现代码主要是在beforeLoad函数

// 参考项目:https://github.com/wstee/qiankun-web
import { useAccessStore, useUserStore } from '@vben/stores';

import { registerMicroApps } from 'qiankun';

import config from './config';

const { subApps } = config;

export async function registerApps() {
  try {
    // 如果子应用是不定的,可以这里定义接口从后台获取赋值给subApps,动态添加

    registerMicroApps(subApps, {
      beforeLoad: [
        (app: any) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeLoad', app.name);
          const useStore = useUserStore();
          const accessStore = useAccessStore();
          app.props.token = accessStore.accessToken;
          app.props.userInfo = useStore.userInfo;
         
        },
      ],
      // 生命周期钩子
      loader: (loading: any) => {
        // 可以在这里处理加载状态
        // eslint-disable-next-line no-console
        console.log('子应用加载状态:', loading);
      },
      beforeMount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeMount', app.name);
          const container = document.querySelector(app.container);
          if (container) container.innerHTML = '';
        },
      ],
      afterUnmount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('count: %s', app);
        },
      ],
    });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.log('count: %s', error);
  }
}

子应用调整

修改代码读取主应用传递的参数,调整mount函数 caipu-vben-admin/apps/app-antd-child/web-demo/src/main.ts

   async mount(props: any) {
      const { container, token, userInfo } = props;
      await initApplication(container);
      const useStore = useUserStore();
      const accessStore = useAccessStore();
      console.log('[子应用]  mounting', props);
      console.log('[子应用]  token:', token);
      console.log('[子应用]  userInfo:', userInfo);

      useStore.setUserInfo(userInfo);
      accessStore.setAccessToken(token);
      // 监听主应用的主题事件
      window.addEventListener('qiankun-theme-update', handleThemeUpdate);
      // 移除并销毁loading
      unmountGlobalLoading();
    }

如果操作子应用时登录信息失效了呢,要让应用跳转到登录,可以修改setupAccessGuard函数,按照如下修改直接跳转到系统登录页。

caipu-vben-admin/apps/app-antd-child/src/router/guard.ts

 // 没有访问权限,跳转登录页面
      if (to.fullPath !== LOGIN_PATH) {
        // return {
        //   path: LOGIN_PATH,
        //   // 如不需要,直接删除 query
        //   query:
        //     to.fullPath === preferences.app.defaultHomePath
        //       ? {}
        //       : { redirect: encodeURIComponent(to.fullPath) },
        //   // 携带当前跳转的页面,登录后重新跳转该页面
        //   replace: true,
        // };
        window.location = 'http://localhost:5666/#/login';
      }

这样就实现主应用和子应用的信息同步了。

2. 主应用与子应用主题同步

vben主题相关配置是在'@vben/preferences'包中,要调整的动态配置主要是在preferences.theme当中,所以实现主题同步只要把配置信息同步到子应用即可。

未通过props传递原因是加载子应用之后再调整偏好设置和主题 子应用不生效,所以考虑只能通另外一种方式实现,最终选择 window.dispatchEvent事件监听的方式实现。

image.png

主应用调整

调整 vue-vben-admin/apps/web-antd/src/layouts/basic.vue

# 引用包
import { preferences } from '@vben/preferences';

# 合适位置增加主题监听
watch(
  () => ({
    theme: preferences.theme,
  }),
  async ({ theme }) => {
    alert('handler qiankun-theme  start', theme);
    // 子应用会监听这个事件并更新响应式对象
    window.dispatchEvent(
      new CustomEvent('qiankun-theme-update', {
        detail: preferences,
      }),
    );
  },
  {
    immediate: true,
  },
);

子应用调整

如果细心的话,在上述子应用调整的main.ts,mount函数要已有说明,主要是增加事件监听qiankun-theme-update 和监听处理事件handleThemeUpdate,完整代码如下 caipu-vben-admin/apps/app-antd-child/web-demo/src/main.ts

import { initPreferences, updatePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import { unmountGlobalLoading } from '@vben/utils';

import {
  qiankunWindow,
  renderWithQiankun,
} from 'vite-plugin-qiankun/dist/helper';

import { bootstrap } from './bootstrap';
import { overridesPreferences } from './preferences';

let app: any = null;
/**
 * 应用初始化完成之后再进行页面加载渲染
 */
async function initApplication(container: any = null) {
  // name用于指定项目唯一标识
  // 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
  const env = import.meta.env.PROD ? 'prod' : 'dev';
  const appVersion = import.meta.env.VITE_APP_VERSION;
  const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;

  // app偏好设置初始化
  await initPreferences({
    namespace,
    overrides: overridesPreferences,
  });
  // 启动应用并挂载
  // vue应用主要逻辑及视图
  app = await bootstrap(namespace, container);
  // 移除并销毁loading
  unmountGlobalLoading();
}

const initQianKun = async () => {
  renderWithQiankun({
    async mount(props: any) {
      const { container, token, userInfo } = props;
      await initApplication(container);
      const useStore = useUserStore();
      const accessStore = useAccessStore();
      console.log('[子应用]  mounting', props);
      console.log('[子应用]  token:', token);
      console.log('[子应用]  userInfo:', userInfo);

      useStore.setUserInfo(userInfo);
      accessStore.setAccessToken(token);

      window.addEventListener('qiankun-theme-update', handleThemeUpdate);
      // 移除并销毁loading
      unmountGlobalLoading();
    },
    bootstrap() {
      return new Promise((resolve, reject) => {
        // eslint-disable-next-line no-console
        console.log('[qiankun] app bootstrap');
        resolve();
      });
    },
    update(props: any) {
      // eslint-disable-next-line no-console
      console.log('[子应用]  update');
      const { container } = props;
      initApplication(container);
    },
    unmount(props) {
      // 移除事件监听
      if (handleThemeUpdate) {
        // eslint-disable-next-line no-console
        console.log('remove sub apps theme handle:', app.name);
        window.removeEventListener('qiankun-theme-update', handleThemeUpdate);
      }
      // eslint-disable-next-line no-console
      console.log('[子应用] unmount', props);
      app?.unmount();
      app = null;
    },
  });
};
// 判断是否为乾坤环境,否则会报错iqiankun]: Target container with #subAppContainerVue3 not existed while subAppVue3 mounting!
qiankunWindow.__POWERED_BY_QIANKUN__
  ? await initQianKun()
  : await initApplication();

const handleThemeUpdate = (event: any) => {
  const newTheme = event.detail;
  if (newTheme) {
    // 更新响应式对象,由于是响应式的,Vue 会自动更新视图
    console.log('子应用主题已更新(通过 props + 事件):', newTheme);
    updatePreferences(newTheme);
  }
};

3. 支持多个应用动态加载

子应用如果不是固定subApps,要从后台加载那如何实现呢,比如我的程序实现子应用动态插拔,后台安装子应用之后前台就要支持展示。 代码逻辑是:本地调试从config.ts获取固定配置,发布环境读取后台配置。主要看registerApps()。 核心代码是下面这段:

 if (import.meta.env.PROD) {
      const data = await GetMicroApp();
      // 将获取的子应用数据转换为qiankun需要的格式
      subApps = data.map((app: MicroApp) => ({
        name: app.name, // 子应用名称
        entry: app.entry, // 子应用入口地址
        container: '#sub-container', // 子应用挂载节点
        activeRule: app.activeRule, // 子应用激活规则
        props: {
          userInfo: [],
          token: '',
        }, // 主应用与子应用通信传值
        sandbox: {
          strictStyleIsolation: true, // 启用严格样式隔离
        },
      }));
    }

完整文件代码是:

import type { MicroApp } from '#/api/apps/model';

import { useAccessStore, useUserStore } from '@vben/stores';

// 参考项目:https://github.com/wstee/qiankun-web
import { registerMicroApps } from 'qiankun';

import { GetMicroApp } from '#/api/apps';

import config from './config';

let { subApps } = config;

export async function registerApps() {
  try {
    // 判断是否是发布环境,发布环境从后台获p取subApps
    if (import.meta.env.PROD) {
      const data = await GetMicroApp();
      // 将获取的子应用数据转换为qiankun需要的格式
      subApps = data.map((app: MicroApp) => ({
        name: app.name, // 子应用名称
        entry: app.entry, // 子应用入口地址
        container: '#sub-container', // 子应用挂载节点
        activeRule: app.activeRule, // 子应用激活规则
        props: {
          userInfo: [],
          token: '',
        }, // 主应用与子应用通信传值
        sandbox: {
          strictStyleIsolation: true, // 启用严格样式隔离
        },
      }));
    }

    registerMicroApps(subApps, {
      beforeLoad: [
        (app: any) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeLoad', app.name);
          const useStore = useUserStore();
          const accessStore = useAccessStore();
          app.props.token = accessStore.accessToken;
          app.props.userInfo = useStore.userInfo;
          // app.props.publicKey = import.meta.env.VITE_PUBLIC_KEY;
        },
      ],
      // 生命周期钩子
      loader: (loading: any) => {
        // 可以在这里处理加载状态
        // eslint-disable-next-line no-console
        console.log('子应用加载状态:', loading);
      },
      beforeMount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeMount', app.name);
          // const container = document.querySelector(app.container);
          // if (container) container.innerHTML = '';
          // 仅隐藏容器,不删除 DOM
          if (app.container.style) {
            app.container.style.display = 'none';
          }
        },
      ],
      beforeUnmount: (app) => {
        // 重新显示容器
        if (app.container.style) {
          app.container.style.display = 'none';
        }
      },
      afterUnmount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('count: %s', app);
        },
      ],
    });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.log('count: %s', error);
  }
}

GetMicroApp()返回数据结构json结果如下,主要是data的内容:

{
    "code": 200,
    "data": [
        {
            "name": "caipu-site",
            "entry": "/app/caipu-site/",
            "activeRule": "/app/caipu-site"
        },
        {
            "name": "email",
            "entry": "/app/email/",
            "activeRule": "/app/email"
        },
        {
            "name": "ip2region",
            "entry": "/app/ip2region/",
            "activeRule": "/app/ip2region"
        },
        {
            "name": "testdata",
            "entry": "/app/testdata/",
            "activeRule": "/app/testdata"
        }
    ],
    "msg": "",
    "success": true,
    "timestamp": 1768140865000
}

最后

  1. 上文有小伙伴回复是否可以支持主应用多页签切换不同子应用的页面状态保持,抱歉多次尝试未在vben实现此功能,作为一名后端人员技术有限如您有实现方案,请不吝指教。

  2. 抽时间也会尝试下wujie微前端方案完善相关功能,基于以上浅显内容,欢迎大积极尝试和分享。 如你有更好的建议内容分享请给评论。

如有幸被转载请注明出处: go-caipu

程序员武学修炼手册(三):融会贯通——从写好代码到架构设计

2026年1月11日 21:01

"小有所成修的是'术',融会贯通修的是'道'。" —— 《程序员修炼心法》

前情回顾

在前两篇中,我们经历了:

  • 初学乍练:从 Hello World 到能跑就行
  • 小有所成:从能跑就行到知其所以然

当你开始思考"系统应该怎么设计"而不只是"代码应该怎么写"的时候,恭喜你,你已经踏入了融会贯通的大门——成为真正的一流高手。


第一章:一流高手的特征

1.1 什么是融会贯通?

融会贯通,是程序员从"写代码的人"到"设计系统的人"的蜕变。

就像武侠小说里,高手从"会使剑"到"懂剑意"的升华。张无忌学太极剑时,张三丰问他忘了多少,他说全忘了——这就是融会贯通的境界,招式已经烂熟于心,开始追求更高层次的武学境界。

融会贯通(一流高手)程序员的典型特征:

  • 能独立负责一个模块或子系统
  • 开始关注架构设计和技术选型
  • 能指导初级开发者
  • 在技术讨论中有自己的见解
  • 开始思考"为什么这样设计"而不只是"怎么实现"

1.2 小有所成 vs 融会贯通

┌─────────────────────────────────────────────────────────────┐
│              小有所成 vs 融会贯通                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   小有所成关注:                    融会贯通关注:           │
│   ├─ 这个函数怎么写?            ├─ 这个系统怎么设计?       │
│   ├─ 代码规范是什么?            ├─ 为什么选这个技术栈?     │
│   ├─ 怎么写单元测试?            ├─ 系统瓶颈在哪里?         │
│   ├─ 这个Bug怎么修?             ├─ 如何保证系统稳定性?     │
│   └─ 怎么让代码更清晰?          └─ 如何应对未来的扩展?     │
│                                                             │
│   小有所成的产出:                  融会贯通的产出:         │
│   ├─ 高质量的代码                ├─ 架构设计文档             │
│   ├─ 单元测试                    ├─ 技术方案评审             │
│   └─ 代码审查意见                └─ 团队技术指导             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

第二章:融会贯通的修炼内容

2.1 第一式:系统设计思维

// 融会贯通的思维方式

// 需求:设计一个用户系统

// 小有所成的思考
// "用户表怎么设计?API怎么写?"

// 融会贯通的思考
/*
 * 1. 需求分析
 *    - 预计用户量?10万?100万?1000万?
 *    - 读写比例?读多写少?还是写多读少?
 *    - 有哪些核心功能?注册、登录、信息修改?
 *    - 有哪些非功能需求?性能、安全、可用性?
 *
 * 2. 架构设计
 *    - 单体还是微服务?
 *    - 数据库选型?MySQL?PostgreSQL?MongoDB?
 *    - 缓存策略?Redis?本地缓存?
 *    - 认证方案?Session?JWT?OAuth?
 *
 * 3. 扩展性考虑
 *    - 未来可能的功能扩展?
 *    - 如何支持水平扩展?
 *    - 数据迁移方案?
 *
 * 4. 风险评估
 *    - 单点故障?
 *    - 数据一致性?
 *    - 安全风险?
 */

2.2 第二式:架构模式

// 融会贯通必修:常见架构模式

// ===== 模式1:分层架构 =====
/*
 * ┌─────────────────────────────────────┐
 * │           表现层 (Controller)        │  处理HTTP请求
 * ├─────────────────────────────────────┤
 * │           业务层 (Service)           │  业务逻辑
 * ├─────────────────────────────────────┤
 * │           数据层 (Repository)        │  数据访问
 * ├─────────────────────────────────────┤
 * │           数据库 (Database)          │  数据存储
 * └─────────────────────────────────────┘
 */

// Controller层:只处理HTTP相关逻辑
class UserController {
  constructor(userService) {
    this.userService = userService
  }

  async createUser(req, res) {
    try {
      const user = await this.userService.createUser(req.body)
      res.status(201).json(user)
    } catch (error) {
      res.status(400).json({ error: error.message })
    }
  }
}

// Service层:业务逻辑
class UserService {
  constructor(userRepository, emailService) {
    this.userRepository = userRepository
    this.emailService = emailService
  }

  async createUser(data) {
    // 业务校验
    await this.validateUserData(data)

    // 创建用户
    const user = await this.userRepository.create(data)

    // 发送欢迎邮件
    await this.emailService.sendWelcomeEmail(user.email)

    return user
  }
}

// Repository层:数据访问
class UserRepository {
  async create(data) {
    return await db.users.create(data)
  }

  async findById(id) {
    return await db.users.findOne({ where: { id } })
  }
}

// ===== 模式2:事件驱动架构 =====
/*
 * ┌─────────┐    事件    ┌─────────┐
 * │ 生产者  │ ────────> │ 消息队列 │
 * └─────────┘           └────┬────┘
 *                            │
 *              ┌─────────────┼─────────────┐
 *              ▼             ▼             ▼
 *         ┌─────────┐  ┌─────────┐  ┌─────────┐
 *         │ 消费者1 │  │ 消费者2 │  │ 消费者3 │
 *         └─────────┘  └─────────┘  └─────────┘
 */

// 事件发布
class OrderService {
  async createOrder(data) {
    const order = await this.orderRepository.create(data)

    // 发布事件,不直接调用其他服务
    await eventBus.publish("order.created", {
      orderId: order.id,
      userId: order.userId,
      amount: order.amount,
    })

    return order
  }
}

// 事件消费
class InventoryService {
  constructor() {
    // 订阅事件
    eventBus.subscribe("order.created", this.handleOrderCreated.bind(this))
  }

  async handleOrderCreated(event) {
    // 扣减库存
    await this.deductInventory(event.orderId)
  }
}

class NotificationService {
  constructor() {
    eventBus.subscribe("order.created", this.handleOrderCreated.bind(this))
  }

  async handleOrderCreated(event) {
    // 发送通知
    await this.sendOrderNotification(event.userId, event.orderId)
  }
}

// ===== 模式3:CQRS(命令查询职责分离)=====
/*
 * 写操作(Command)和读操作(Query)使用不同的模型
 *
 *         ┌─────────────┐
 *         │   客户端    │
 *         └──────┬──────┘
 *                │
 *       ┌────────┴────────┐
 *       ▼                 ▼
 * ┌───────────┐    ┌───────────┐
 * │  Command  │    │   Query   │
 * │  Service  │    │  Service  │
 * └─────┬─────┘    └─────┬─────┘
 *       │                │
 *       ▼                ▼
 * ┌───────────┐    ┌───────────┐
 * │  写数据库  │───>│  读数据库  │
 * │  (MySQL)  │同步│  (Redis)  │
 * └───────────┘    └───────────┘
 */

// 命令服务:处理写操作
class OrderCommandService {
  async createOrder(command) {
    const order = await this.orderRepository.create(command)

    // 同步到读模型
    await this.syncToReadModel(order)

    return order.id
  }
}

// 查询服务:处理读操作
class OrderQueryService {
  async getOrderList(userId, page, pageSize) {
    // 从读优化的数据源查询
    return await this.readCache.getOrders(userId, page, pageSize)
  }
}

2.3 第三式:技术选型

// 融会贯通必修:技术选型的艺术

// 技术选型不是选"最好的",而是选"最合适的"

// ===== 数据库选型 =====
const databaseSelection = {
  // 关系型数据库
  MySQL: {
    适合: ["事务要求高", "数据结构稳定", "复杂查询"],
    不适合: ["海量数据", "频繁schema变更", "高并发写入"],
    场景: "电商订单、用户系统、金融系统",
  },
  PostgreSQL: {
    适合: ["复杂查询", "JSON支持", "地理数据"],
    不适合: ["简单CRUD", "极致性能"],
    场景: "数据分析、GIS系统、复杂业务",
  },

  // NoSQL数据库
  MongoDB: {
    适合: ["文档型数据", "schema灵活", "快速迭代"],
    不适合: ["复杂事务", "强一致性要求"],
    场景: "内容管理、日志存储、原型开发",
  },
  Redis: {
    适合: ["缓存", "会话存储", "排行榜", "计数器"],
    不适合: ["持久化存储", "复杂查询"],
    场景: "缓存层、实时数据、消息队列",
  },
}

// ===== 技术选型决策框架 =====
function evaluateTechnology(options) {
  const criteria = {
    // 功能匹配度
    functionalFit: {
      weight: 0.3,
      questions: [
        "能否满足核心需求?",
        "是否需要大量定制?",
        "有没有现成的解决方案?",
      ],
    },
    // 团队能力
    teamCapability: {
      weight: 0.25,
      questions: [
        "团队是否熟悉这个技术?",
        "学习成本有多高?",
        "能否招到相关人才?",
      ],
    },
    // 生态系统
    ecosystem: {
      weight: 0.2,
      questions: ["社区活跃度如何?", "文档是否完善?", "有没有成熟的工具链?"],
    },
    // 运维成本
    operationalCost: {
      weight: 0.15,
      questions: ["部署复杂度?", "监控和调试是否方便?", "故障恢复难度?"],
    },
    // 未来发展
    futureProof: {
      weight: 0.1,
      questions: ["技术是否在上升期?", "是否有大公司背书?", "是否会被淘汰?"],
    },
  }

  // 评估每个选项
  return options
    .map((option) => ({
      name: option.name,
      score: Object.entries(criteria).reduce((total, [key, { weight }]) => {
        return total + (option.scores[key] || 0) * weight
      }, 0),
    }))
    .sort((a, b) => b.score - a.score)
}

2.4 第四式:性能优化

// 融会贯通必修:系统级性能优化

// ===== 性能优化的层次 =====
/*
 * 1. 架构层面:选择合适的架构
 * 2. 数据库层面:索引、查询优化、读写分离
 * 3. 缓存层面:多级缓存策略
 * 4. 代码层面:算法优化、并发处理
 * 5. 网络层面:CDN、压缩、HTTP/2
 */

// ===== 缓存策略 =====
class CacheService {
  constructor() {
    this.localCache = new Map() // L1: 本地缓存
    this.redis = redisClient // L2: Redis缓存
  }

  async get(key) {
    // L1: 先查本地缓存
    if (this.localCache.has(key)) {
      return this.localCache.get(key)
    }

    // L2: 再查Redis
    const redisValue = await this.redis.get(key)
    if (redisValue) {
      // 回填本地缓存
      this.localCache.set(key, JSON.parse(redisValue))
      return JSON.parse(redisValue)
    }

    return null
  }

  async set(key, value, ttl = 3600) {
    // 同时写入两级缓存
    this.localCache.set(key, value)
    await this.redis.setex(key, ttl, JSON.stringify(value))
  }

  async invalidate(key) {
    // 同时失效两级缓存
    this.localCache.delete(key)
    await this.redis.del(key)
  }
}

// ===== 数据库优化 =====
class QueryOptimizer {
  // 避免N+1查询
  async getUsersWithOrders_bad(userIds) {
    const users = await db.users.findAll({ where: { id: userIds } })

    // N+1问题:每个用户查一次订单
    for (const user of users) {
      user.orders = await db.orders.findAll({ where: { userId: user.id } })
    }

    return users
  }

  async getUsersWithOrders_good(userIds) {
    // 使用JOIN或预加载
    return await db.users.findAll({
      where: { id: userIds },
      include: [{ model: db.orders }],
    })
  }

  // 分页优化
  async getOrderList_bad(page, pageSize) {
    // OFFSET大了会很慢
    return await db.orders.findAll({
      offset: (page - 1) * pageSize,
      limit: pageSize,
    })
  }

  async getOrderList_good(lastId, pageSize) {
    // 使用游标分页
    return await db.orders.findAll({
      where: { id: { [Op.gt]: lastId } },
      limit: pageSize,
      order: [["id", "ASC"]],
    })
  }
}

// ===== 并发处理 =====
class ConcurrencyHandler {
  // 并行处理独立任务
  async processParallel(items) {
    // 不好:串行处理
    // for (const item of items) {
    //   await processItem(item);
    // }

    // 好:并行处理
    await Promise.all(items.map((item) => processItem(item)))
  }

  // 控制并发数
  async processWithLimit(items, limit = 5) {
    const results = []
    const executing = []

    for (const item of items) {
      const promise = processItem(item).then((result) => {
        executing.splice(executing.indexOf(promise), 1)
        return result
      })

      results.push(promise)
      executing.push(promise)

      if (executing.length >= limit) {
        await Promise.race(executing)
      }
    }

    return Promise.all(results)
  }
}

2.5 第五式:系统稳定性

// 融会贯通必修:保障系统稳定性

// ===== 熔断器模式 =====
class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5
    this.resetTimeout = options.resetTimeout || 30000
    this.state = "CLOSED" // CLOSED, OPEN, HALF_OPEN
    this.failureCount = 0
    this.lastFailureTime = null
  }

  async call(fn) {
    if (this.state === "OPEN") {
      if (Date.now() - this.lastFailureTime > this.resetTimeout) {
        this.state = "HALF_OPEN"
      } else {
        throw new Error("Circuit breaker is OPEN")
      }
    }

    try {
      const result = await fn()
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      throw error
    }
  }

  onSuccess() {
    this.failureCount = 0
    this.state = "CLOSED"
  }

  onFailure() {
    this.failureCount++
    this.lastFailureTime = Date.now()

    if (this.failureCount >= this.failureThreshold) {
      this.state = "OPEN"
    }
  }
}

// 使用
const breaker = new CircuitBreaker({ failureThreshold: 3 })

async function callExternalService() {
  return breaker.call(async () => {
    return await fetch("https://external-api.com/data")
  })
}

// ===== 重试机制 =====
async function withRetry(fn, options = {}) {
  const {
    maxRetries = 3,
    delay = 1000,
    backoff = 2, // 指数退避
    shouldRetry = () => true,
  } = options

  let lastError

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error

      if (attempt === maxRetries || !shouldRetry(error)) {
        throw error
      }

      const waitTime = delay * Math.pow(backoff, attempt)
      await new Promise((resolve) => setTimeout(resolve, waitTime))
    }
  }

  throw lastError
}

// 使用
const result = await withRetry(() => fetch("https://api.example.com/data"), {
  maxRetries: 3,
  delay: 1000,
  shouldRetry: (error) => error.status >= 500, // 只重试服务端错误
})

// ===== 限流 =====
class RateLimiter {
  constructor(limit, windowMs) {
    this.limit = limit
    this.windowMs = windowMs
    this.requests = new Map()
  }

  isAllowed(key) {
    const now = Date.now()
    const windowStart = now - this.windowMs

    // 获取该key的请求记录
    let timestamps = this.requests.get(key) || []

    // 清理过期记录
    timestamps = timestamps.filter((t) => t > windowStart)

    if (timestamps.length >= this.limit) {
      return false
    }

    timestamps.push(now)
    this.requests.set(key, timestamps)
    return true
  }
}

// 使用
const limiter = new RateLimiter(100, 60000) // 每分钟100次

app.use((req, res, next) => {
  const key = req.ip

  if (!limiter.isAllowed(key)) {
    return res.status(429).json({ error: "Too many requests" })
  }

  next()
})

// ===== 降级策略 =====
class DegradationService {
  constructor() {
    this.degradationFlags = {
      useCache: false,
      skipNonEssential: false,
      returnDefault: false,
    }
  }

  async getProductDetail(productId) {
    // 正常流程
    if (!this.degradationFlags.useCache) {
      try {
        return await this.fetchFromDatabase(productId)
      } catch (error) {
        // 数据库出问题,自动降级
        this.degradationFlags.useCache = true
      }
    }

    // 降级:使用缓存
    const cached = await this.getFromCache(productId)
    if (cached) {
      return { ...cached, _degraded: true }
    }

    // 再降级:返回默认数据
    if (this.degradationFlags.returnDefault) {
      return {
        id: productId,
        name: "商品信息加载中",
        price: 0,
        _degraded: true,
        _default: true,
      }
    }

    throw new Error("Service unavailable")
  }
}

2.6 第六式:团队协作

// 融会贯通必修:技术领导力

// ===== 技术方案评审 =====
const technicalReviewTemplate = {
  // 1. 背景与目标
  background: {
    问题描述: "当前系统存在什么问题?",
    业务目标: "这个方案要达成什么业务目标?",
    技术目标: "这个方案要达成什么技术目标?",
  },

  // 2. 方案设计
  design: {
    整体架构: "系统架构图",
    核心流程: "关键流程图",
    数据模型: "数据库设计",
    接口设计: "API设计",
  },

  // 3. 技术选型
  techStack: {
    选型理由: "为什么选择这个技术?",
    备选方案: "考虑过哪些其他方案?",
    对比分析: "各方案的优缺点对比",
  },

  // 4. 风险评估
  risks: {
    技术风险: "可能遇到的技术难点",
    业务风险: "可能影响的业务场景",
    缓解措施: "如何降低风险",
  },

  // 5. 实施计划
  plan: {
    里程碑: "关键节点和交付物",
    资源需求: "需要多少人、多长时间",
    依赖项: "依赖哪些其他团队或系统",
  },
}

// ===== 代码审查指导 =====
const codeReviewGuidelines = {
  // 审查重点
  focus: [
    "代码是否符合设计方案?",
    "是否有明显的性能问题?",
    "错误处理是否完善?",
    "是否有安全隐患?",
    "代码是否可测试?",
    "是否有足够的日志?",
  ],

  // 反馈方式
  feedback: {
    必须修改: "🔴 [Must Fix] 这个问题必须修复",
    建议修改: "🟡 [Suggestion] 建议这样改会更好",
    讨论: "🔵 [Discussion] 这里我有个疑问",
    赞赏: "🟢 [Nice] 这个写法很棒",
  },

  // 审查态度
  attitude: [
    "对事不对人",
    "提供具体的改进建议",
    "解释为什么这样更好",
    "承认自己也可能是错的",
  ],
}

// ===== 技术分享 =====
class TechSharingSession {
  constructor(topic) {
    this.topic = topic
    this.outline = []
  }

  // 分享结构
  createOutline() {
    return {
      // 1. 引入(5分钟)
      introduction: {
        hook: "一个引人入胜的问题或故事",
        context: "为什么这个话题重要",
        overview: "今天要讲什么",
      },

      // 2. 主体(20-30分钟)
      body: {
        concept: "核心概念解释",
        demo: "实际演示",
        codeWalkthrough: "代码讲解",
        bestPractices: "最佳实践",
        pitfalls: "常见陷阱",
      },

      // 3. 总结(5分钟)
      conclusion: {
        keyTakeaways: "关键要点回顾",
        resources: "进一步学习资源",
        qa: "问答环节",
      },
    }
  }
}

第三章:融会贯通的常见瓶颈

3.1 过度架构

// 症状:简单问题复杂化

// 需求:一个内部工具,用户量<100

// 过度架构版本
/*
 * ┌─────────────────────────────────────────────────────────┐
 * │                      API Gateway                        │
 * └─────────────────────────────────────────────────────────┘
 *                            │
 *         ┌──────────────────┼──────────────────┐
 *         ▼                  ▼                  ▼
 *   ┌───────────┐     ┌───────────┐     ┌───────────┐
 *   │ User      │     │ Order     │     │ Product   │
 *   │ Service   │     │ Service   │     │ Service   │
 *   └─────┬─────┘     └─────┬─────┘     └─────┬─────┘
 *         │                 │                 │
 *         ▼                 ▼                 ▼
 *   ┌───────────┐     ┌───────────┐     ┌───────────┐
 *   │ User DB   │     │ Order DB  │     │ Product DB│
 *   └───────────┘     └───────────┘     └───────────┘
 *         │                 │                 │
 *         └────────────┬────┴────────────────┘
 *                      ▼
 *              ┌───────────────┐
 *              │ Message Queue │
 *              └───────────────┘
 */

// 合适的架构版本
/*
 * ┌─────────────────────────────────────────────────────────┐
 * │                    单体应用                              │
 * │  ┌─────────┐  ┌─────────┐  ┌─────────┐                 │
 * │  │ User    │  │ Order   │  │ Product │                 │
 * │  │ Module  │  │ Module  │  │ Module  │                 │
 * │  └─────────┘  └─────────┘  └─────────┘                 │
 * └─────────────────────────────────────────────────────────┘
 *                            │
 *                            ▼
 *                    ┌───────────────┐
 *                    │    MySQL      │
 *                    └───────────────┘
 */

// 教训:架构要匹配业务规模
// 小项目用微服务 = 用大炮打蚊子

3.2 技术选型偏见

// 症状:只推荐自己熟悉的技术

// 错误的选型思路
function chooseTechnology(requirements) {
  // "我熟悉React,所以用React"
  // "我们一直用MySQL,所以继续用MySQL"
  // "这个新技术很火,我们也用"

  return myFavoriteTech
}

// 正确的选型思路
function chooseTechnology(requirements) {
  const options = getAllOptions()

  return options
    .filter((tech) => tech.meetsFunctionalRequirements(requirements))
    .map((tech) => ({
      tech,
      score: evaluateTech(tech, {
        teamFamiliarity: 0.3,
        communitySupport: 0.2,
        performanceNeeds: 0.2,
        maintenanceCost: 0.2,
        futureProof: 0.1,
      }),
    }))
    .sort((a, b) => b.score - a.score)[0].tech
}

3.3 沟通障碍

// 症状:技术方案讲不清楚

// 错误的沟通方式
function explainToNonTech() {
  return `
    我们需要用Redis做缓存层,配合MySQL的读写分离,
    通过消息队列实现异步解耦,用熔断器保证系统稳定性...
  `
  // 产品经理:???
}

// 正确的沟通方式
function explainToNonTech() {
  return `
    问题:现在系统在高峰期会变慢
    
    方案:
    1. 加一个"记忆层",常用数据不用每次都去数据库查
       (就像你常用的文件放桌面,不用每次去柜子里找)
    
    2. 把一些不紧急的任务放到后台处理
       (就像餐厅点餐后,你不用站在厨房等,可以先坐下)
    
    3. 加一个"保险丝",某个服务出问题时自动切断
       (就像家里的电闸,短路时自动跳闸保护其他电器)
    
    效果:高峰期响应时间从3秒降到0.5秒
    成本:需要2周开发时间,增加一台服务器
  `
}

第四章:融会贯通的突破契机

4.1 第一次系统设计

// 场景:负责设计一个新系统

// 你的设计过程
const systemDesignProcess = {
  // 第一步:需求分析
  step1_requirements: {
    功能需求: ["用户注册登录", "商品浏览", "下单支付", "订单管理"],
    非功能需求: {
      性能: "QPS 1000,响应时间 < 200ms",
      可用性: "99.9%",
      安全性: "数据加密,防SQL注入",
    },
    约束条件: {
      时间: "3个月",
      人力: "3个后端 + 2个前端",
      预算: "云服务费用 < 5000/月",
    },
  },

  // 第二步:架构设计
  step2_architecture: {
    整体架构: "单体应用 + 读写分离",
    技术栈: {
      后端: "Node.js + Express",
      数据库: "MySQL + Redis",
      前端: "React",
      部署: "Docker + Kubernetes",
    },
  },

  // 第三步:详细设计
  step3_detailedDesign: {
    数据模型: "用户表、商品表、订单表...",
    API设计: "RESTful API",
    缓存策略: "热点数据缓存 + 会话缓存",
  },

  // 第四步:评审与迭代
  step4_review: {
    评审意见: ["考虑分库分表", "增加监控告警", "补充降级方案"],
    迭代优化: "根据反馈调整设计",
  },
}

4.2 第一次处理线上事故

// 场景:凌晨3点,系统崩了

// 事故处理流程
const incidentResponse = {
  // 1. 快速止血(5分钟内)
  step1_stopBleeding: {
    actions: ["确认影响范围", "启动降级方案", "通知相关人员"],
    你的操作: `
      // 发现数据库连接池耗尽
      // 立即重启应用服务器
      // 开启限流,减少请求压力
    `,
  },

  // 2. 定位问题(30分钟内)
  step2_findRoot: {
    actions: ["查看监控指标", "分析日志", "检查最近变更"],
    你的发现: `
      // 发现是新上线的功能有慢查询
      // 一个没加索引的查询,在数据量大时变得很慢
      // 导致连接池被占满
    `,
  },

  // 3. 修复问题
  step3_fix: {
    临时方案: "回滚代码",
    根本方案: "添加索引 + 优化查询",
  },

  // 4. 复盘总结
  step4_postmortem: {
    时间线: "完整的事故时间线",
    根因分析: "为什么会发生?",
    改进措施: [
      "上线前必须进行性能测试",
      "添加慢查询监控告警",
      "完善代码审查checklist",
    ],
  },
}

第五章:融会贯通的修炼心法

5.1 心法一:没有银弹

// 融会贯通的认知
// "没有一种技术或方法能解决所有问题"

// 实践
const noSilverBullet = {
  微服务: {
    不是银弹: "小团队用微服务可能是灾难",
    适用场景: "大团队、复杂业务、需要独立部署",
  },

  缓存: {
    不是银弹: "缓存带来一致性问题",
    适用场景: "读多写少、可以容忍短暂不一致",
  },

  NoSQL: {
    不是银弹: "牺牲了事务和复杂查询能力",
    适用场景: "数据结构灵活、不需要复杂事务",
  },

  异步: {
    不是银弹: "增加了系统复杂度和调试难度",
    适用场景: "耗时操作、不需要立即返回结果",
  },
}

// 选择技术方案时,要问:
// 1. 这个方案解决了什么问题?
// 2. 这个方案带来了什么新问题?
// 3. 新问题是否可以接受?

5.2 心法二:权衡的艺术

// 融会贯通的核心能力:在各种约束下做出最优选择

const tradeoffs = {
  // 一致性 vs 可用性
  consistencyVsAvailability: {
    选择一致性: "金融系统、库存系统",
    选择可用性: "社交媒体、内容系统",
  },

  // 性能 vs 可维护性
  performanceVsMaintainability: {
    选择性能: "核心热点路径",
    选择可维护性: "大部分业务代码",
  },

  // 快速上线 vs 完美设计
  speedVsPerfection: {
    选择速度: "验证业务假设、抢占市场",
    选择完美: "核心系统、长期维护的代码",
  },

  // 自研 vs 采购
  buildVsBuy: {
    选择自研: "核心竞争力、特殊需求",
    选择采购: "通用功能、节省时间",
  },
}

5.3 心法三:系统思维

// 融会贯通要学会从系统角度看问题

// 不只是看代码,还要看:
const systemThinking = {
  // 上下游依赖
  dependencies: {
    上游: "谁调用我?他们的调用模式是什么?",
    下游: "我调用谁?他们的SLA是什么?",
  },

  // 数据流
  dataFlow: {
    输入: "数据从哪里来?格式是什么?",
    处理: "数据如何被处理?",
    输出: "数据到哪里去?谁会使用?",
  },

  // 故障模式
  failureModes: {
    问: "如果这个组件挂了会怎样?",
    问: "如果网络延迟增加10倍会怎样?",
    问: "如果数据量增加100倍会怎样?",
  },

  // 演进路径
  evolution: {
    问: "半年后业务会怎么变?",
    问: "这个设计能支撑多久?",
    问: "什么时候需要重构?",
  },
}

第六章:融会贯通的毕业考核

6.1 毕业标准

┌─────────────────────────────────────────────────────────────┐
│              融会贯通毕业标准 ✓                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   □ 能独立完成中等复杂度系统的架构设计                       │
│   □ 能进行合理的技术选型,并说明理由                         │
│   □ 能识别系统瓶颈,并提出优化方案                           │
│   □ 能处理线上事故,并进行有效复盘                           │
│   □ 能指导初中级开发者,进行有效的代码审查                   │
│   □ 能与产品、测试等角色有效沟通技术方案                     │
│   □ 能在技术方案评审中提出有价值的意见                       │
│   □ 开始形成自己的技术判断力和方法论                         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

结语:融会贯通的意义

融会贯通是程序员从"执行者"到"设计者"的关键转变。

在这个阶段,你会:

  • 开始负责更大范围的技术决策
  • 学会在各种约束下做出权衡
  • 开始影响团队的技术方向
  • 形成自己的技术判断力

融会贯通的核心是:建立系统思维,学会权衡取舍。

下一篇,我们将进入登峰造极——当你开始在更大范围内产生技术影响力,成为团队或公司的技术专家时,你就踏入了绝顶高手的大门。


预告:登峰造极

在登峰造极,你将学习:

  • 技术战略与规划
  • 跨团队技术协调
  • 技术影响力建设
  • 人才培养与团队建设
  • 如何成为绝顶高手

敬请期待!


本文是《程序员武学修炼手册》系列的第三篇。

如果你正处于融会贯通的境界,恭喜你已经成为团队的技术骨干。

继续修炼,登峰造极在向你招手! 🚀

insertAdjacentHTML踩坑实录:AI没搞定的问题,我给搞定啦

2026年1月11日 20:40

今天开发「Todo-List」应用新特性:任务支持配置标签功能。

在做添加标签的美化效果:点击【+标签】按钮,自动转换为可编辑标签名的输入框。

标签功能示例.gif

用AI编写效果,很快就编码完了。

💡提示词:请优化标签在页面的显示方式,要求: 1.创建任务或更新任务时,可以显示数据库现有标签供用户选择; 2.用户在创建或更新任务时,可以通过选择或取消选择的方式来设置任务关联的标签; 3.用户在创建或更新任务时,可以新增标签,在现有标签列最后是一个+标签,点击该标签即可转换为标签编辑模式进行添加新标签,而后添加完新标签,在新标签后一个又出现新的+标签; 4.创建或更新任务中,除+标签外,数据库已有标签的名字后面可以以数字形式显示当前标签关联任务数据; 5.创建或更新任务时,除+标签外,显示数字是0的标签可以通过标签末端的×进行删除。请实现上述功能!

但是尬尴:重复点击【+标签】按钮,监听事件仅首次生效,后续点击都不生效。而且关键是给AI说明了问题场景后,AI没修复!

💡提示词:当前+标签存在bug,点击首次可以生效转变为编辑模式,使用×关闭后,重新点击+标签,无法再次转换为编辑模式。要求实现可以重复点击和转换

(PS:为了证明AI没修复对,此处附上AI修复方式的截图呢)

靠AI有时候确实也是靠不住,还是得靠自己啊。

一波调试定位,发现这行代码: selector.insertAdjacentHTML('beforeend', inputHtml),前后两次执行效果竟然不一样。

这时候,第二个AI大佬就该登场了。(PS:毕竟我前端代码也不熟,还是得仰仗AI啊)

insertAdjacentHTML() 是 DOM 操作 API,用于在指定位置插入 HTML 字符串。如果元素有id,且id重复插入,那么就会出现问题。

<!-- 第一次插入 -->
<input id="myInput" type="text">

<!-- 第二次插入(相同ID) -->
<input id="myInput" type="text"> <!-- 重复ID,事件可能失效 -->

好吧,检查了下代码,确实是这个原因。

最终修复方式也很简单,id动态化。

generateRandomId() {
  // 时间戳确保唯一性,随机数增加安全性
  return `new-tag-input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

// 显示新增标签输入框
showAddTagInput() {
    const addBtn = document.getElementById('add-tag-btn');
    if (!addBtn) return;

    // 生成当前counter值对应的ID
    const currentId = this.generateRandomId();

    const inputHtml = `
        <span class="tag-input-mode" id="tag-input-mode">
            <input type="text" id="${currentId}" placeholder="标签名" maxlength="20">
            <button class="btn-cancel" id="cancel-add-tag">×</button>
        </span>
    `;

    addBtn.replaceWith(document.createElement('span'));
    const inputContainer = document.getElementById('tag-input-mode');
    if (inputContainer) {
        inputContainer.outerHTML = inputHtml;
    } else {
        const selector = document.getElementById('tags-selector');
        selector.insertAdjacentHTML('beforeend', inputHtml);
    }

    // 绑定事件
    // ...
}

所以,前端的开发同学要留意啦,insertAdjacentHTML 这个API使用的时候可以要注意插入的元素的id或class不能重复哦,不然会出现二次点击事件不生效的呢。

今天分享的内容就到这了,感谢阅读,欢迎三连哦!

Promise为什么比回调函数更好

2026年1月11日 19:45

Promise是什么

promise是什么, promise是一种为你等待底层异步值完成的机制,同时promise也是一种异步任务流程控制的机制,以下举例说明这两种理解

Promise等待了未来值

我们先看一个例子, 假设我们有两个函数 fetchX, fetchY,我们需要把这两个函数的结果相加

let x, y;
fetchX(function (x) {
    x = x;
});
fetchY(function (Y) {
    y = y;
});

console.log(x + y);

在上述中,console.log(x + y);的结果是不确定的,当x已经决议(完成),但是y还没有决议,或者是y已经决议,但是x还没有决议,又或者是两者都没有决议,那么我们就完全得不到想要的结果,怎么去解决这个问题呢,我们可以写一个函数去解决这个问题

function add (fetchX, fetchY, cb) {
    let x, y;
    fetchX(function (valX) {
        x = valX;
        
        if (y !== undefined) { //y准备好了?
            cb(x + y);
        }
    });
    
    fetchY(function (valY) {
        y = valY;
        
        if (x !== undefined) {//x准备好了?
            cb(x + y);
        }
    });
    
}

//调用add
add(fetchX, fetchY, function (sum) {
    console.log(sum);
})

在这段代码中,我们使用了一个函数add,这个函数它为我们等待了x或者y决议,并且在全部完成决议的时刻执行打印,这个函数把现在和将来的结果都统一了,它不在会有undefined的成分了,同时你可以发现,我们把console.log的代码放到了将来执行,也就是说,这个函数把现在的代码(指打印操作)和将来的代码(指赋值操作),全部放到了将来,这个也就是promise的概念,请看以下代码

function add (xPromise, yPromise) {
    return Promise.all([xPromise, yPromise])
      .then((value) => {
        return value[0] + value[1];
    })
}

add(fetchX, fetchY)
  .then((sum) => {
    console.log(sum);
}, (err) => {
    console.error(err);
})

这段函数和上述的函数结果是一样的,区别在于上述的代码非常丑陋,所以我们可以说promise是一种为你等待未来值并且把代码全部变成异步的机制

Promise控制异步任务流程

请看以下代码

function foo (x) {
    return new Promise((resolve, reject) => {
        //最终可能调用resolve(...)或者reject(...)
    })
}

function bar () {
    //foo完成,执行bar的任务
}

function oopsBar () {
    //foo执行出错
}

function baz () {
    //foo完成,执行baz的任务
}
function oopsBar() {
    //foo执行出错
}

var p = foo(42);

p.then(bar, oopsBar);
p.then(baz, oopsBaz);

在上述代码中,当p执行成功。则会执行函数bar, baz,如果出错,则执行函数oopsBar,oopsBaz,从这个意义上说,foo在成功的时候才会调用bar, baz,出错则调用oppsBar,oppsBaz,不管是什么结果,都是通过Promise控制接下来的流程,,从这个角度上我们说promise是一种控制异步任务流程的机制

Promise为什么解决了回调函数的问题

回调函数在异步流程中的两个问题,一个是回调函数在异步编程中非线性非顺序,一个是回调函数在异步编程中的信任问题。首先,promise写法是线性的,每一个then都不需要关心上一个then的时间状态,当then的回调执行的时候,上一个then的promise必然是已经决议了,这就解决了嵌套带来的问题。 其次是回调函数的信任问题。

调用过早

调用过早主要是指回调函数是否是有时同步有时异步,回调函数如果有时候是同步,有时候又是异步,这样的问题被称为Zalgo,但是promise天生就避免了这个问题,Promise的then总是会被异步调用,即使你的给Promise传入的函数是立即决议的,类似于new Promise(function () { resolve(42) } ), 它的then也会被异步的调用

调用过晚

Promise的对象在完成的时候(Promise对象调用resolve或reject),这个promise的then注册的回调函数就在下一个异步事件点上一定会被触发, 请你判断下面的例子的顺序

p.then(function () {
   p.then(funcion () {
          console.log("C");
        });
    console.log("A");
});
p.then(function () {
   console.log("B"); 
});

在这个例子中, C无法打断或者抢占B, 执行顺序一定是B,Promise这样的运行方式保证了B不会被后注册的回调函数执行打印了C导致从调用过晚

回调未调用

Promise的then一定会被调用,这是promise机制本身保证的,如果promise永远没有被完成,也可以使用代码解决

function timeoutPromise(delay) {
    return new Promise(function (resolve, reject) {
        setTimeout(function () {
            reject("Timeout");
        }, delay)
    })
}


Promise.race([
    foo(),
    timeoutPromise(3000)
]).then(function () {
    //foo..及时完成
}, function () {
    //foo拒绝或者未按时完成
})

总结

Promise为什么比回调函数好,一点在于可读性,另一点在于Promise把回调函数的控制反转再反转,通过Promise去执行我们的回调,所以Promise解决信任问题的方法可以说是把控制权给了一个绝对可信任的系统(Promise),而不是第三方库

普通前端仔的 2025 : 年终总结与 AI 对我的影响

2026年1月12日 09:30

前言

2025 年已经过去,时间滚滚向前。恍惚间,仿佛上一次过年还在不久之前,转眼一年却已走到尾声。借着掘金这次 # 🏆2025 AI / Vibe Coding 对我的影响|年终征文 活动的机会,我想和大家分享一下自己的年终总结,并聊一聊 2025 年 AI 在工作与学习中对我的实际帮助。

开始坚持写文章分享

在今年年初,我和老婆完成了订婚,年中正式领取了结婚证💗。我的肩上多了一份对家庭的责任,也开始一起规划未来的生活,坚定了一个目标:一定要多赚一些钱,才能更有底气地生活。

后来我想到,之前曾看到有人提到在技术社区持续写文章,有机会接到外包或私活。于是,我决定在自己最常逛的技术社区——掘金,开始发布一些原创技术文章。

最早是在 2024 年 12 月底,因为工作阶段性需求不大,有了一些空闲时间,我便开始动笔。但现实很快给了我反馈:文章写完后几乎没人看。其实这也很正常,就像刚开始做自媒体需要“起号”一样,一个新账号发布的第一篇文章,基本不会有太多曝光。

后来,好朋友韬哥提醒我,文章审核通过后可以让朋友帮忙点点赞,新文章有机会进入周榜,从而获得更多曝光。这里也要感谢我老婆以及几位朋友,对我写作的支持与鼓励和建议。万分感谢🙏

接下来就是不断地写。直到有一篇 # 前端开发又幸福了,Cursor + Figma MCP 快速还原设计稿 意外火了,不仅阅读量明显上涨,还被掘金公众号转发。事实上,这篇文章反而是我写得最随意、耗时最短的一篇,可能正好踩中了 MCP 的热点。当时 MCP 刚出现不久,那段时间我确实非常开心。

或许是因为好奇心比较强——说得直白一点,其实也是想“偷懒”——我一直很愿意尝试新事物😂,所以第一时间体验了 MCP,确实让人眼前一亮。随后我便迫不及待地想把这些体验分享出来,担心同事在实际使用中踩坑,便写下了这篇文章,想着审核通过后可以直接转发给同事参考实践。后面关于 AI 的相关内容,我也会继续深入,具体聊一聊 AI 在工作方式和工作内容上带来的改变。

我在写文章的过程中,也会适当借助一些 AI 辅助。毕竟我的文笔并不算好,容易偏口语化,自己写完再读一遍时,常常都有些读不下去,因此我通常会让 AI 帮我优化一下文案表达。在这里也确实要感谢“AI 老师”,在写作效率和可读性上给了我很大的帮助。

但与此同时,我也非常排斥“AI 味”过重的文章。掘金上有些上周榜的内容,我几乎一眼就能看出是 AI 生成的。或许现在还能分辨,再过两年就未必了。我记得有一次刷到一篇讲“2025 年 JavaScript 新语法糖”的文章,通篇都是 AI 胡编乱造的内容,作者既没有自行验证,也没有标注 AI 生成,就这样直接发布出来。这种行为在我看来完全是在误导新人,想到这里就会感到非常生气。

我始终认为,每个人都应该对自己分享的知识负责。因此,我写的文章尽量都是真人思考、真人实践得出的内容,只是借助 AI 做一些文字层面的润色,而不是让它替我“创作观点”。

随着 AI 能力不断增强,一些常见、零散的编程问题其实已经不太值得单独分享了,比如 JavaScript 时间处理中的各种坑,AI 的回答往往已经足够准确和全面。相比之下,更有价值的内容,反而是系统化、体系化的实践流程与思考总结,这也是我之后更希望持续输出的方向。

跳槽

另一方面,也是想多赚一些钱。成家之前,我的工资养活自己绰绰有余,但成家之后,现实问题就变得具体起来:未来如果有孩子、还没有买房,这些都需要更强的经济支撑。我也很清楚,在中国大部分程序员的职业生命周期大概率只有十几年,到了年龄偏大时,可能就需要考虑转型。2025 年,是我毕业、正式进入社会工作的第三年,因此我做出了一个决定——准备跳槽。

马云曾说过一句话:

跳槽无外乎两个原因,钱给少了,心里受委屈了。

这两点,我可能都占了。在这家公司干了两年,年初时,领导、CTO,以及当初面试我的帆叔,或许是出于生活和前途的考虑,陆续选择了离开。核心人物走后,公司换了新的领导,但我明显感觉到一种“死海效应”。感觉开了很多没有必要的会议,真的像过家家一样,我也感觉到没有效率无头苍蝇一样东一榔头西一棒的做事情。

所谓“死海效应”,是指组织中优秀员工不断流失,如同死海水分蒸发,导致低质量员工比例上升,从而影响整体效率和企业发展。

其实在我第一次提出离职时,公司也给我调了薪。当时我一度以为,自己可能会在这里长期干下去。但后来发生了一些不太方便细说的矛盾,如今回头看,我依然认为自己并没有做错。最终,出于职业发展与前途的考虑我还是选择了离开。

我悄悄提交了离职申请,只提前和一直合作的产品同学说了一声。说实话,我们组的产品在我看来是非常有能力的人才。直到我离职的最后一天,很多同事看到我的签名留言(相遇是缘,祝大家越来越好),才意识到我要走了。那天有十几位同事和我道别,让我非常感动。直到现在,我也还会和前同事们时不时在微信上聊聊天,聊前端,聊 AI。我跟每个同事都很熟悉,可能是我的性格善于把大家链接起来。

提完离职之后,我便立刻开始找工作。我并没有打算 gap 一段时间,因为之前已经 gap 过一次。那次裸辞后玩了两个月,前期确实很爽,像是在过寒暑假;但等旅游结束回到出租屋后,每天不是躺着就是刷手机、玩电脑,生活逐渐失去了目标感。那时我才真正意识到,人是需要劳动的,需要在社会工作中获得价值感。

正因如此,那次我很快重新投入找工作,也正是在那段时间,柯总收留了当时只有一年工作经验的我🙏。

正如马克思所说:

劳动是人类生存的基石,是人自身发展的决定性要素。在共产主义社会高级阶段,“劳动已经不仅仅是谋生的手段,而是本身成了生活的第一需要”。

在跳槽过程中,我也观察到了招聘市场风向的变化:越来越多的公司更倾向于简历中带有 AI 项目经历的候选人。幸运的是,我在 2023 年第一份工作时就参与过一个 AI 相关的生图项目,这让我的简历在市场上颇受欢迎。不过,当时市场对 AI 的重视还有滞后性,真正对 AI 项目经历感兴趣的公司并不多。到了这次跳槽,情况明显不同——AI 相关经历几乎成为必问项,也显著提升了候选人的吸引力。这让我深刻体会到,AI 对程序员带来的不是威胁,而是新的机会。

在面试过程中,我也会主动考察部门的 AI 使用情况。令我震惊的是,很多小公司的团队虽然知道 AI 的存在,但根本没有实际应用,仍然依赖传统的手工编码。显然,我不会选择加入这样的团队,因为对于我而言,高效利用 AI 不只是工具加成,而是能显著提升团队整体效率和技术成长空间的重要指标。

有了上一次裸辞的经历后,这一次在“多赚钱”的前提下,我几乎没有给自己任何休息时间,离职后便立刻投入到找工作中。或许缘分就是这么巧,我很快找到了一份听起来前途还不错的工作。但由于当时没有把工作时长和薪资细节问清楚,也没有在谈薪阶段据理力争到自己真正满意的程度,入职后还是产生了一些后悔的情绪。不过再找一份工作的成本不低,加上自己也有些懒,索性就先在这家公司干了下来。

这是一家总部在北京的做游戏的大公司,在广州新成立的一个部门,部门在 5 月份成立,而我是 8 月份加入的。由于我之前的技术栈和项目经验主要集中在管理后台领域,入职后便从0到1参与并负责了一个内部 BI 后台项目的建设。新公司的领导能力很强,一人同时承担后端开发、产品规划以及与设计师沟通协调等多重角色。

团队规模不大,我们是一个前端、一个后端,再加上一位测试同学,三个人协作完成了这个中台系统的开发,用于支持游戏发行部门的日常业务。

AI

也该聊到 AI 了,不然有点偏题太久了。😂

2022年的 AI

第一次接触 AI 辅助编程,是在 2022 年通过 GitHub Copilot。当时我在写毕业设计,用到的是一个需要发邮件申请试用的 VS Code 插件。印象很深的是,只要写一个诸如“二分查找”的注释,下面很快就能自动补全出完整代码,确实让人觉得相当聪明。

后来在 2022 年 12 月左右,ChatGPT 横空出世。现在回头看,那真的是一件非常幸运的事情——我刚参加工作没多久,大语言模型就已经出现了。那段时间最大的感受是:GPT 写出来的代码,甚至比当时作为初级程序员的我还要规范、完整。

于是后来每次遇到代码重构或优化相关的问题,我都会先问一问它。在不断的使用过程中,也确实从“AI 老师”那里学到了不少编程思路和实践技巧。

2023,2024年的 AI

那时候 ChatGPT 还没有免费开放,基本都是通过国内的镜像站之类的方式在使用,稳定性和体验都谈不上好,但依然挡不住大家的热情。我还记得 Cursor 刚出来的时候,最大的优势就是不需要科学上网就能直接用 GPT,这一点在当时非常有吸引力。谁能想到,后来这个工具不断迭代升级,从一个“能用”的编辑器插件,逐渐成长为 AI IDE 的第一梯队,甚至在某些场景下彻底改变了写代码的方式。

在那个阶段,我的使用方式其实还比较“传统”:写完一段代码,复制出来,粘贴到 GPT 里提问,让它帮我看看有没有优化空间、潜在问题,或者让它补全缺失逻辑,然后再把结果复制回编辑器中。这个流程现在看起来有些笨重,但在当时已经极大提升了效率。很多原本需要翻文档、查 Stack Overflow 的问题,几分钟内就能得到一个相对完整的答案。

那时的 AI 更多还是“辅助工具”的角色,而不是直接参与到编码流程中。它更像是一位随叫随到、耐心十足的高级同事,帮你查资料、给思路、补细节。虽然偶尔也会胡编乱造,需要自己具备判断能力,但不可否认的是,从 2023 年开始,我已经明显感受到:写代码这件事,正在被 AI 悄然重塑。

2025 年的 AI

一直到 2024 年底,Cursor 突然火了起来。我记得好像是某公司的一个大佬的女儿在几乎没有编程经验的情况下,用 Cursor 写了一个小程序,这篇推特被广泛转发后,Cursor 迅速走红。我看到后也下载了最新版,试用后直接被震撼到了——它的补全功能丝滑得让人难以置信,好像能直接理解我脑子里的想法,这是我第一次体验到如此智能又顺手的 AI 编程提示。

当时,我也尝试了 Cursor 的一个竞品 Winsurf,但整体体验还是 Cursor 更佳。有人会说,这不过是把 AI 模型套个壳而已,但我认为“套壳”也有高低之分。作为普通程序员,我们不必去研究模型的理论,但在应用层的交互体验、细节设计做得出色,同样非常了不起。使用 Cursor 后,我明显感受到工作效率提升,甚至可以达到两倍、五倍甚至十倍。

我当时非常积极地向同事推荐,但发现部分同事带有悲观色彩,担心 AI 会替代程序员,因此不愿尝试。对此,我的观点是:AI 是提效工具,它能帮你节省重复劳动,让你有更多时间去学习新技术、思考产品设计和架构优化。AI 的核心意义在于,让程序员从繁琐的 CRUD 工作中解放出来,把时间用在更高价值的工作上,让创意和想象力真正发挥作用。

与此同时,字节跳动推出了 Trae,我也体验过并写过相关征文,但整体体验还是不如 Cursor 顺手。也许是 Trae 的宣传和营销做得比较好,所以在我跳槽面试时,不少团队表示虽然自己没有使用 AI 编程,但知道字节出了 Trae。

后面过春节的时候,国产开源模型之光 DeepSeek 横空出世,连家里的长辈都知道中国出来个 nb 的 AI。太伟大了 DeepSeek 直接选择了开源,给全世界分享他们的成果,respect🫡!!!

在高强度使用了月左右后,我积累了一些经验和方法,也在文章中分享给了大家。

随着 AI 工具的发展,我也开始尝试其他工具,例如 Winsurf 和 Argument Code。特别是 Argument Code,这是一个 VS Code 插件,能够智能寻找代码中相关的影响范围,非常适合进行复杂逻辑分析。背后的 AI 模型 Claude 在这里表现得很聪明,但订阅价格不低,当时约 100 美元/月。

后来我也尝试了 Claude Code 和 Codex 的 CLI,不得不说,Claude 模型确实很强(题外话:但最近对第三方的封禁以及反华的一些魔幻操作,真希望预告新年发布的DeepSeek v4能挫挫这家公司锐气!),尤其在编码和设计相关的理解上非常到位。开源的 Claude-agent-sdk 也很优秀,很多人在它的基础上可以做自己的 CLI 二次开发。不过,我个人还是不太习惯在终端里使用 AI,习惯了有 GUI 界面的 IDE,操作起来更直观、顺手。

谷歌的 Antigravity我也体验了,都是在侧边栏有个对话框,可以试用 Gemini 与 Claude,我经常用 Gemini 写页面,但是写逻辑他很喜欢 any 跳过,很无语😅,写逻辑还是需要用 Claude。每周会限制一些使用额度,不得不说谷歌还是家大业大,想要超车提速就是这么快。但是这个产品名称起的真的不好拼写哈哈。

目前我在试用 Kiro 的 Claude 服务,用的是白嫖的 30 天、500 积分版本。不过这个 IDE 似乎没有智能提示功能(可能是我使用姿势不对?但我理解应该是默认开启的)。

总的来说,虽然 CLI 强大,但对我而言,GUI 界面的交互体验更符合日常编码习惯。我估计下一步还是回到 cursor 了。

对 AI 的思考与想法

写了这么多,我也有些累了。这是我第一次写这么长的文章,可能是因为想表达的内容实在太多了。码了一上午,最后想和大家聊聊我个人对 AI 的理解与思考。

AI 给我的工作效率带来了成倍提升。面试时我也常提到,以前写代码都是一行行敲,现在几乎可以“一片一片”地生成代码。但这并不意味着可以无脑相信 AI 输出的结果。如果每天只是依赖 AI 完成 Vibe Coding,长期下来可能会非常痛苦——-因为你不了解 AI 的实现细节。选用性能差的模型,即便功能实现了,后续改造或迭代可能会非常困难,只能再次依赖 AI 来处理。久而久之,就可能形成“AI 生成的代码屎山”。

因此,我的做法是:每次命令 AI 完成任务后,都会仔细 review 它的代码,再进行提交。如果项目是一次性的小型任务,或许可以不用过于严格,但对于需要长期维护的系统,认真 review 并与 AI 协作至关重要。

同时,AI 目前还无法替代程序员,其根本原因在于缺乏责任感。AI 的上下文长度有限,它无法像人一样,在公司里长期维护多个项目四五年。上下文越长,它遗忘的内容也越多。新建一个窗口,之前的事情就忘记了(可以设置全局 rule) 此外,一些自媒体常吹嘘用 AI 完全不会编程也能完成系统开发,虽然 AI 越来越强,一次性任务看起来很漂亮,但遇到小细节或后续改动时,如果没有懂一点的人去指挥和优化,代码很容易崩溃。

所以,至少需要一个懂技术的人来指导 AI,确保输出可靠。实际上,AI 也可以成为学习的辅助工具:通过它快速学习新的编程语言语法、软件架构最佳实践,再用这些知识高效指挥 AI 完成任务。总结来看,AI 是效率的倍增器,但仍然需要人的经验与判断力来控制风险、保证质量。

我觉得大家应该积极拥抱 AI,面对它、理解它,并善加利用,让 AI 成为让自己如虎添翼的工具。AI 的发展必然会带来产业变革和技术革新,但从更宏观的角度看,它是推动人类文明进步的重要力量。我们正加速步入一个生产力大爆发的时代,AI 将程序员从以往繁琐的搬砖任务中解放出来,让我们有更多精力去思考架构设计、创新功能,以及探索新的技术边界。

更进一步,AI 的真正价值在于它能够让人类在创造力和效率之间找到平衡。以前很多重复性工作占据了大量时间,现在这些工作可以交给 AI 来处理,而程序员可以把精力放在更高层次的思考上:如何设计更优的系统、如何优化用户体验、如何在复杂业务中做出更合理的决策。AI 不仅是工具,也是学习的助力,它能够快速提供信息、分析方案,让我们在短时间内掌握新技术、新方法,从而实现知识和能力的快速积累。

可以说,AI 对程序员而言,是一种能力的放大器,而不是替代品。未来,能够合理运用 AI 的人,将比单纯依赖传统技能的人更具竞争力。在这个过程中,保持学习、理解和掌控 AI 的能力,比单纯追求 AI 生成的结果更重要。真正掌握了这项能力的人,将能够在技术创新和生产力提升的浪潮中站稳脚跟,甚至引领变革。

结语

过去的一年是成长的一年,我也能明显感受到,相比去年的自己,有了不少进步。

希望在新的一年里,AI 能够展现出更惊艳的能力,带来更多创新和可能。期待未来,也祝大家新年快乐,工作顺利,生活愉快,每个人都能不断成长、越来越好。

A股三大指数开盘涨跌不一,传媒股走高

2026年1月12日 09:26
36氪获悉,A股三大指数开盘涨跌不一,沪指高开0.35%,深成指高开0.47%,创业板指低开0.13%;文化传媒、贵金属、航天军工板块领涨,利欧股份涨停,盛达资源、国博电子涨超8%,中文在线涨超6%;软饮料、能源、地产板块走弱,养元饮品、统一股份、绿地控股跌超1%。

🎉TinyPro v1.4.0 正式发布:支持 Spring Boot、移动端适配、新增卡片列表和高级表单页面

2026年1月12日 09:24

你好,我是 Kagol,个人公众号:前端开源星球

TinyPro 是一个基于 TinyVue 打造的前后端分离的后台管理系统,支持在线配置菜单、路由、国际化,支持页签模式、多级菜单,支持丰富的模板类型,支持多种构建工具,功能强大、开箱即用!

我们很高兴地宣布,2026年1月10日,TinyPro 正式发布 v1.4.0 版本,本次发布集中在扩展后端模板、增强移动端体验以及对 NestJS 后端功能的实用增强。

本次 v1.4.0 版本主要有以下重大变更:

  • 增加 Spring Boot 后端
  • 增强移动端适配
  • 增加卡片列表和高级表单页面
  • 支持多设备登录
  • 支持配置预览模式

你可以更新 @opentiny/tiny-toolkit-pro@1.4.0 进行体验!

tiny install @opentiny/tiny-toolkit-pro@1.4.0

详细的 Release Notes 请参考:github.com/opentiny/ti…

1 支持 Spring Boot 后端

之前只有 NestJS 后端,有不少开发者提出需要 Java 版本后端,大家的需求必须安排,所以本次版本新增对 Spring Boot 的支持,使得偏 Java / Spring 的团队可以更快速地用熟悉的后端框架搭建 TinyPro 全栈样板。

该支持包括 Docker 化示例、配置覆盖示例(application.yaml 覆写示例)以及针对 deploy 的说明,便于在容器化环境中直接部署或做二次开发。

如果你或团队偏向 Java 技术栈,这次更新显著降低了启动成本与集成难度。

详细使用指南请参考文档:Spring Boot 后端开发指南

2 移动端响应式与布局优化

本次引入移动端适配方案,包含布局调整、样式优化和若干移动交互逻辑改进。配套增加了端到端测试(E2E),保证常见移动场景(小屏导航、侧边栏收起、页签/页面切换)行为稳定。

适配覆盖了常见断点,页面在手机端的易用性和可读性有明显提升,适合需要同时兼顾桌面与移动管理后台的项目。

效果如下:

移动端效果.png

详细介绍请参考文档:TinyPro 响应式适配指南

3 增加卡片列表页面

之前列表页仅提供单一的查询表格形式,功能相对有限,难以满足日益多样化、复杂化的业务需求。为了提升用户体验、增强系统的灵活性,我们在原有基础上新增了一个卡片列表页面,以更直观、灵活的方式展示数据,满足不同场景下的使用需求。

体验地址:opentiny.design/vue-pro/pag…

效果如下:

卡片列表.png

4 增加高级表单页面

表单页增加了高级表单,在普通表单基础上增加了表格整行输入功能。

体验地址:opentiny.design/vue-pro/pag…

效果如下:

高级表单.png

5 支持多设备登录

之前只能同时一个设备登录,后面登录的用户会“挤”掉前面登录的用户,本次版本为账号登录引入设备限制(Device Limit)策略,可限制单账号并发活跃设备数,有助于减少滥用和提高安全性,适配企业安全合规需求。

可通过 nestJs/.env 中的 DEVICE_LIMIT 进行配置。

比如配置最多 2 人登录:

DEVICE_LIMIT=2

如果不想限制登录设备数,可以设置为 -1:

DEVICE_LIMIT=-1

6 演示模式

由于配置了 RejectRequestGuard,默认情况下,所有接口都只能读,不能写,本次版本增加了演示模式(PREVIEW_MODE),要修改 NestJS 后端代码才能改成可写的模式(nestJs/src/app.module.ts)。

本次版本增加了演示模式的配置,可通过 nestJs/.env 中的 PREVIEW_MODE 进行配置。

PREVIEW_MODE 默认为 true, 会拒绝所有的增加、修改、删除操作,设置为 false,则变成可写模式。

PREVIEW_MODE=false

7 Redis 引入应用安装锁(redis app install lock)

主要用于避免重复安装或初始化时的竞态问题。

默认情况下,第一次运行 NestJS 后端,会生成 Redis 锁,后续重新运行 NestJS 后端,不会再更新 MySQL 数据库的数据。

如果你修改了默认的菜单配置(nestJs/src/menu/init/menuData.ts)或者国际化词条(nestJs/locales.json),希望重新初始化数据库,可以在开发机器 Redis 中运行 FLUSHDB 进行解锁,这样重新运行 NestJS 后端时,会重新初始化 MySQL 数据库的数据。

更多更新,请参考 Release Notes:github.com/opentiny/ti…

8 社区贡献

感谢所有为 v1.4.0 做出贡献的开发者!你们的辛勤付出让 TinyPro 变得更好!

注:排名不分先后,按名字首字母排序。

如果你有任何建议或反馈,欢迎通过 GitHub Issues 与我们联系,也欢迎你一起参与 TinyPro 贡献。

往期推荐文章

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.github.io/tiny-pro

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

恒指开盘涨0.55%,恒生科技指数涨0.88%

2026年1月12日 09:22
36氪获悉,恒指开盘涨0.55%,恒生科技指数涨0.88%;有色金属、耐用消费品、传媒板块领涨,天齐锂业章程4%,泡泡玛特、快手涨超1%;消费者服务、交通运输板块走弱,华住集团跌超2%,中通快递跌超1%。

Skill 真香!5 分钟帮女友制作一款塔罗牌 APP

作者 乘风gg
2026年1月12日 09:14

最近发现一个 AI 提效神器 ——Skills,用它配合 Cursor 开发,我仅用 5 分钟就帮女友做出了一款塔罗牌 H5 APP!在说如何操作之前,我们先大概了解下 Skills 的原理

一、Skills的核心内涵与技术构成

(一)本质界定

Skills 可以理解为给 AI Agent 定制的「专业技能包」,把特定领域的 SOP、操作逻辑封装成可复用的模块,让 AI 能精准掌握某类专业能力,核心目标是实现领域知识与操作流程的标准化传递,使AI Agent按需获取特定场景专业能力。其本质是包含元数据、指令集、辅助资源的结构化知识单元,通过规范化封装将分散专业经验转化为AI Agent可理解执行的“行业SOP能力包”,让 AI 从‘只会调用工具’变成‘懂专业逻辑的执行者

(二)技术构成要素

完整Skill体系由三大核心模块构成,形成闭环能力传递机制:

  1. 元数据模块:以SKILL.md或meta.json为载体,涵盖技能名称、适用场景等关键信息约 100 个字符(Token),核心功能是实现技能快速识别与匹配,为AI Agent任务初始化阶段的加载决策提供依据。
  2. 指令集模块:以instructions.md为核心载体,包含操作标准流程(SOP)、决策逻辑等专业规范,是领域知识的结构化转化成果,明确AI Agent执行任务的步骤与判断依据。
  3. 辅助资源模块:可选扩展组件,涵盖脚本代码、案例库等资源,为AI Agent提供直接技术支撑,实现知识与工具融合,提升执行效率与结果一致性。

和传统的函数调用、API 集成相比,Skills 的核心优势是:不只是 “告诉 AI 能做什么”,更是 “教会 AI 怎么做”,让 AI 理解专业逻辑而非机械执行

二、Skills与传统Prompt Engineering的技术差异

从技术范式看,Skills与传统Prompt Engineering存在本质区别,核心差异体现在知识传递的效率、灵活性与可扩展性上:

  1. 知识封装:传统为“一次性灌输”,冗余且复用性差;Skills为“模块化封装”,一次创建可跨场景复用,降低冗余成本。
  2. 上下文效率:传统一次性加载所有规则,占用大量令牌且易信息过载;Skills按需加载,提升效率并支持多技能集成。
  3. 任务处理:传统面对复杂任务易逻辑断裂,无法整合外部资源;Skills支持多技能组合调用,实现复杂任务全流程转化。
  4. 知识迭代:传统更新需逐一修改提示词,维护成本高;Skills为独立模块设计,更新成本低且关联任务可同步受益。

上述差异决定Skills更适配复杂专业场景,可破解传统Prompt Engineering规模化、标准化应用的瓶颈。

三、渐进式披露:Skills的核心技术创新

(一)技术原理与实现机制

Skills能在不增加上下文负担的前提下支撑多复杂技能掌握,核心在于“按需加载”的渐进式披露(Progressive Disclosure)设计,将技能加载分为三阶段,实现知识传递与上下文消耗的动态平衡:

  1. 发现阶段(启动初始化):仅加载所有Skills元数据(约100个令牌/个),构建“技能清单”明确能力边界,最小化初始化上下文负担。
  2. 激活阶段(任务匹配时):匹配任务后加载对应技能指令集,获取操作规范,实现精准加载并避免无关知识干扰。
  3. 执行阶段(过程按需加载):动态加载辅助资源,进一步优化上下文利用效率。

(二)技术优势与价值

渐进式披露机制使Skills具备三大核心优势:

  1. 降低令牌消耗:分阶段加载避免资源浪费,支持单次对话集成数十个技能,降低运行成本。
  2. 提升执行准确性:聚焦相关知识组件,减少干扰,强化核心逻辑执行精度。
  3. 增强扩展性:模块化设计支持灵活集成新知识,无需重构系统,适配领域知识快速迭代。

四、Cursor Skills

介绍完 Skills 是什么之后,我将使用的是 Cursor 作为我的开发工具。先说明一下,最开始只有 Claude Code 支持 Skills、Codex 紧随其后,口味自己选。

好消息是,Cursor 的 Skills 机制采用了与 Claude Code 几乎完全一致的 SKILL.md 格式。这意味着,你完全不需要从头编写,可以直接将 Claude Code 的生态资源迁移到 Cursor。

(一)Cursor 设置

因为 Cursor 刚支持不久,并且是 Beta 才能使用,所以要进行下面操作

Agent Skills 仅在 Nightly 更新渠道中可用。
要切换更新渠道,打开 Cursor 设置( Cmd+Shift+J ),选择 Beta,然后将更新渠道设置为 Nightly。更新完成后,你可能需要重新启动 Cursor。 如下图所示

要启用或禁用 Agent Skills:

  1. 打开 Cursor Settings → Rules
  2. 找到 Import Settings 部分
  3. 切换 Agent Skills 开关将其开启或关闭 如下图所示

(二)复制 Claude Skills

然后我们直接去 Anthropic 官方维护的开源仓库 anthropics/skills,里面提供了大量经过验证的 Skill 范例,涵盖了创意设计、开发技术、文档处理等多个领域。

你可以访问 github.com/anthropics/… 查看完整列表。以下是这次用到的 Skills

Frontend Design:这是一个专门用于提升前端设计质量的技能。它包含了一套完整的 UI 设计原则(排版、色彩、布局)

然后我们直接把 Skills 里面的 .claude/skills/frontend-design 到当前项目文件下,如图:

模型和模式如下图

提示词如下,不一定非得用我的。

使用 Skill front-design。我要做一个 H5 ,功能是一个塔罗牌。

你是一名经验丰富的产品设计专家和资深前端专家,擅长UI构图与前端页面还原。现在请你帮我完成这个塔罗牌应用的 UI/UX 原型图设计。请输出一个包含所有设计页面的完整HTML文件,用于展示完整UI界面。

注意:生成代码的时候请一步一步执行,避免单步任务过大,时间执行过长

然后 Cursor 会自动学习 Skills,并输出代码

然后就漫长的等待之后,Cursor 会自动做一个需求技术文档,然后会一步一步的实现出来,这时候可以去喝杯茶,再去上个厕所!

最终输出了 5 个页面

  1. 首页 (Home)
  2. 每日抽牌页 (Daily Draw)
  3. 牌阵占卜页 (Spread Reading)
  4. 塔罗百科页 (Encyclopedia)
  5. 占卜历史页 (History)

最终效果如下,整体效果看起来,完全是一个成熟的前端工程师的水准,甚至还带有过渡动画和背景效。因为掘金无法上传视频,欢迎私信我找我要或者关注我:

image.png

扩展阅读

因为 Cursor 目前仅在 Nightly 版本上才可以使用 Skills。如果担心切换此模式会引发意想不到的情况,可以使用另一种方案

OpenSkills 是一个开源的通用技能加载器。

  • 完全兼容:它原生支持 Anthropic 官方 Skill 格式,可以直接使用 Claude 官方市场或社区开发的技能。
  • 桥梁作用:它通过简单的命令行操作,将这些技能转换为 Cursor、Windsurf 等工具可识别的配置(AGENTS.md),从而让 Cursor 具备与 Claude Code 同等的“思考”与“技能调用”能力。

韩国1月前10天出口同比下降2.3%

2026年1月12日 09:09
韩国海关周一公布的数据显示,1月份前10天,韩国出口额下降2.3%,从去年同期的159.2亿美元降至155.5亿美元。数据显示,同期进口同比下降4.5%,至182.1亿美元,导致27亿美元的贸易逆差。(新浪财经)
❌
❌