阅读视图

发现新文章,点击刷新页面。

Web Worker 与 OffscreenCanvas:把主线程从重活里解放出来

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 搓一个类型安全的深层路径访问工具

用 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 估计会被打。

微前端沙箱隔离:qiankun 和 wujie 到底在争什么

微前端沙箱隔离:qiankun 和 wujie 到底在争什么

上个月接手一个老项目,四个团队各写各的,技术栈从 Vue2 到 React18 都有。领导一句"用微前端整合一下",我就开始了长达两周的沙箱隔离踩坑之旅。

问题的起点很简单:子应用 A 往 window 上挂了个 globalConfig,子应用 B 也挂了一个,然后就打架了。更离谱的是,子应用 C 的 CSS 里写了个 body { font-size: 14px !important },直接把主应用的样式干碎了。

这两个问题,一个是 JS 沙箱的事,一个是 CSS 隔离的事。qiankun 和 wujie 给出了完全不同的解法,背后的设计哲学也截然不同。

JS 沙箱:快照、代理、还是直接换个 window?

最朴素的思路:快照沙箱

qiankun 最早的沙箱方案简单粗暴——进子应用之前,把 window 上所有属性拍个快照存起来;子应用卸载时,把 window 恢复回去。

class SnapshotSandbox {
  private snapshot: Record<string, any> = {}
  private modifications: Record<string, any> = {}

  activate() {
    // 进场前:把当前 window 拍个照
    for (const key in window) {
      this.snapshot[key] = (window as any)[key]
    }
  }

  deactivate() {
    // 离场时:记录子应用改了啥,然后恢复 window
    for (const key in window) {
      if ((window as any)[key] !== this.snapshot[key]) {
        this.modifications[key] = (window as any)[key] // 存下改动
        ;(window as any)[key] = this.snapshot[key]     // 还原
      }
    }
  }
}

能跑。但问题也明显——同一时间只能激活一个子应用。因为大家共用一个 window,你在上面改,我也在上面改,没法并行。

这就是 qiankun 早期单实例模式的限制来源。

Proxy 代理沙箱:qiankun 的主力方案

为了支持多个子应用同时运行,qiankun 搞了 ProxySandbox。思路是给每个子应用造一个"假 window":

class ProxySandbox {
  private fakeWindow: Record<string, any> = {}

  proxy: WindowProxy

  constructor() {
    const fakeWindow = this.fakeWindow

    this.proxy = new Proxy(fakeWindow, {
      get(target, key) {
        // 先从 fakeWindow 找,找不到再去真 window
        return key in target ? target[key] : (window as any)[key]
      },

      set(target, key, value) {
        target[key] = value // 写操作全部拦截到 fakeWindow
        return true
      },

      has(target, key) {
        return key in target || key in window
      }
    })
  }
}

子应用里写 window.xxx = 123,实际写到了 fakeWindow 上,不会污染真正的 window。读的时候先找 fakeWindow,找不到再降级到真 window

这个方案解决了多实例问题,但有个绕不开的麻烦:子应用的代码怎么让它用 proxy 而不是真 window?

qiankun 的做法是拿到子应用的 JS 代码文本,用 (function(window, self, globalThis) { ... }).call(proxy, proxy, proxy, proxy) 包一层执行。等于在运行时把子应用代码的 window 引用偷梁换柱了。

听着挺巧妙,但实际用起来坑不少。

我在项目里踩过的 Proxy 沙箱的坑

有一次子应用里用了个第三方地图 SDK,它内部用 window.addEventListener 绑了一堆事件。问题是这个 SDK 的代码不是通过 qiankun 的 entry 加载的,而是在 HTML 里用 <script> 标签直接引的 CDN。

结果这部分代码跑在真 window 上,而子应用自己的代码跑在 proxy 上。两边的 window 不是同一个对象,事件监听和业务逻辑之间怎么通信就成了问题。排查了大半天,最后的解法是把 SDK 改成动态 import 的方式加载,让它也走 qiankun 的沙箱。

还有个经典问题:window.location 是不能被 Proxy 完整代理的(涉及到浏览器安全策略),qiankun 对这块做了特殊处理,但偶尔还是会有奇怪的表现。

wujie 的思路:直接用 iframe 的 window

wujie 选了一条完全不同的路——用 iframe 来跑 JS

不是把子应用渲染在 iframe 里(那就回到原始时代了),而是创建一个隐藏的 iframe,让子应用的 JS 在 iframe 的 window 环境下执行,但 DOM 操作代理到主应用的文档上。

// wujie 的核心思路(简化版)
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)

// JS 跑在 iframe 里 → 天然隔离,每个 iframe 有自己的 window
const sandboxWindow = iframe.contentWindow

// 但 DOM 操作要代理出去:
// sandboxWindow.document → 指向主应用中子应用的挂载容器
Object.defineProperty(sandboxWindow, 'document', {
  get: () => proxyDocument // 指向主应用中的 shadow DOM 容器
})

这个设计很聪明。iframe 的 window 是浏览器原生隔离的,不需要自己实现沙箱逻辑。setTimeoutsetInterval、事件监听、location 这些全都是天然独立的。

qiankun 用 Proxy 模拟了一个不完美的 window,wujie 直接拿了一个真的。

CSS 隔离:这块的差距更大

JS 沙箱好歹都有方案,CSS 隔离才是真正让人头疼的地方。

qiankun 的 CSS 隔离:三种模式

动态样式表切换(默认):子应用激活时插入样式,卸载时移除。能防止子应用之间互相影响,但子应用的样式可能影响主应用。

Scoped CSS(experimentalStyleIsolation):运行时给子应用的所有 CSS 选择器加前缀。

/* 原始样式 */
.header { color: red; }
body { font-size: 14px; }

/* 加前缀后 */
div[data-qiankun="app1"] .header { color: red; }
div[data-qiankun="app1"] body { font-size: 14px; } /* body 选择器加前缀后其实没啥用 */

这个方案的问题:运行时解析和改写 CSS 有性能开销,而且有些选择器处理不了——比如 bodyhtml@keyframes 名字冲突。我之前项目里子应用用了 Ant Design,那个全局样式改写出来的效果,一言难尽。

Shadow DOM(strictStyleIsolation):用 Shadow DOM 包裹子应用。理论上完美隔离,但实际上问题更多:

// qiankun 的 Shadow DOM 模式
const container = document.getElementById('app-container')
const shadow = container.attachShadow({ mode: 'open' })
// 子应用渲染到 shadow 内部

// 问题来了:
// 1. 子应用里的弹窗(Modal)通常 append 到 document.body
//    → 跑到 Shadow DOM 外面了 → 样式丢失
// 2. 子应用里用 document.querySelector → 查不到 shadow 内的元素
// 3. React 17 之前的事件委托挂在 document 上 → shadow 内事件冒泡有问题

所以 qiankun 官方文档对 Shadow DOM 模式的态度是"谨慎使用"。很多团队实际上在用的是动态样式表 + BEM 命名约定这种半自动的隔离。

wujie 的 CSS 隔离:Web Component + Shadow DOM

wujie 用的也是 Shadow DOM,但配合它的 iframe JS 执行方案,体验好很多。

子应用的 DOM 渲染在一个 Web Component 的 Shadow DOM 里,JS 跑在 iframe 里。iframe 里的 document 被代理到 Shadow DOM 容器上,所以子应用调 document.querySelector 查到的是 Shadow DOM 内的元素,弹窗也能 append 到正确的位置。

// wujie 的 Web Component(简化)
class WujieApp extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' })
    // 子应用的 HTML/CSS 都渲染在这个 shadow 里
  }
}
customElements.define('wujie-app', WujieApp)

// iframe 里的 document 操作被劫持:
// document.body.appendChild(modal)
// → 实际 append 到 shadow DOM 内的 body 容器
// → 样式不会丢失

弹窗问题解决了吗?大部分场景是的。但也不是完全没坑——有些组件库会往 window.document.body(注意是 window 上取的 document)上挂东西,如果恰好绕过了 proxy,还是会逃逸。

设计哲学对比

两个框架的取舍逻辑,核心就一句话:

qiankun 选择在同一个页面上下文里做隔离,wujie 选择用原生隔离能力再做桥接。

qiankun 的路线:共享 window → Proxy 拦截 → 运行时改写 CSS → 在一个上下文里模拟多个沙箱。好处是子应用和主应用天然在同一个 DOM 树里,通信方便、路由同步简单。代价是隔离不彻底,边界情况多,要处理的 hack 也多。

wujie 的路线:iframe 跑 JS → Shadow DOM 渲染 UI → 通过 proxy 桥接两侧。好处是隔离干净,很多 qiankun 的历史坑天然不存在。代价是架构复杂度高,iframe 和主应用之间的 DOM 代理逻辑如果出 bug,排查成本不低。

画个表可能更清楚:

维度 qiankun wujie
JS 隔离 Proxy 模拟 fakeWindow iframe 原生 window
CSS 隔离 动态样式 / Scoped / Shadow DOM Web Component Shadow DOM
多实例 ProxySandbox 支持 天然支持(每个 iframe 独立)
弹窗逃逸 Shadow DOM 模式下有问题 基本解决(document 被代理)
子应用改造成本 需要导出生命周期钩子 相对较低
通信复杂度 低(同上下文) 中(跨 iframe)
社区生态 成熟,用的人多 较新,踩坑资料少

选型的时候怎么想

我个人的判断标准比较简单粗暴:

子应用技术栈比较统一(比如都是 React 或都是 Vue),团队对微前端有经验,选 qiankun。它的坑多但资料也多,大部分问题都有现成的解法。

子应用技术栈混乱(jQuery、Vue2、React18 啥都有),或者子应用是那种不太可能配合改造的老系统,wujie 的隔离能力会省很多事。iframe 原生隔离把很多脏活揽过去了。

如果是新项目,说实话我会先考虑要不要用微前端。Module Federation 或者简单的 iframe 嵌入能不能解决问题?微前端引入的复杂度不低,别为了用而用。

还有一点——沙箱隔离只是微前端的一个维度。路由同步、应用通信、公共依赖管理、构建部署流程,这些加在一起才是完整的工程决策。只看沙箱就选型,容易后面翻车。

回过头看

沙箱隔离这个问题,本质上是在问:多个独立应用塞到一个页面里,怎么让它们互不干扰?

qiankun 的答案是"我在 JS 层面给你隔开",wujie 的答案是"我让浏览器帮你隔开"。两个思路没有绝对的高下,只有场景的匹配度。

不过有一点是确定的:不管用哪个框架,上线前一定要跑一轮子应用并行加载的测试,重点关注全局变量污染和样式冲突。这两个问题不在开发阶段暴露,就一定会在生产环境暴露。到时候定位起来,比一开始就解决要痛苦十倍。

TypeScript 协变与逆变:你的泛型组件 Props 为什么总是类型报错?

TypeScript 协变与逆变:你的泛型组件 Props 为什么总是类型报错?

上周封装一个通用列表组件,Props 里有个 onSelect 回调,类型大概长这样:

interface ListProps<T> {
  items: T[]
  onSelect: (item: T) => void
}

看着没毛病吧?结果一传具体类型就炸了——Type '(item: Dog) => void' is not assignable to type '(item: Animal) => void'

改了半天,越改越乱。后来才搞明白,这压根不是泛型的问题,是函数参数的逆变在搞事。

先把协变和逆变说人话

这俩词听着唬人,其实就一句话:父子类型在"容器"里的方向问题

假设 Dog extends Animal,那 Dog 是 Animal 的子类型。

interface Animal { name: string }
interface Dog extends Animal { breed: string }

let animal: Animal = { name: '旺财' }
let dog: Dog = { name: '旺财', breed: '柴犬' }

animal = dog // ✅ 子类型赋值给父类型,没问题
// dog = animal // ❌ 反过来不行,animal 上没有 breed

这是最基本的子类型赋值,没啥好说的。问题出在把类型塞进容器之后,方向可能会反。

协变(Covariant)——方向不变:

// Dog 是 Animal 的子类型
// → Dog[] 也是 Animal[] 的子类型(方向一致)
let dogs: Dog[] = [{ name: '旺财', breed: '柴犬' }]
let animals: Animal[] = dogs // ✅ 协变:子类型数组 → 父类型数组

逆变(Contravariant)——方向反了:

type Handler<T> = (arg: T) => void

let handleAnimal: Handler<Animal> = (a) => console.log(a.name)
let handleDog: Handler<Dog> = (d) => console.log(d.breed)

// 注意这里!方向反过来了
handleDog = handleAnimal // ✅ 父类型的 handler 赋值给子类型的 handler
// handleAnimal = handleDog // ❌ 反过来不行

等等,为什么 Handler<Animal> 反而能赋值给 Handler<Dog>?Dog 明明是子类型啊,怎么函数这里反过来了?

为什么函数参数天然逆变

想一下实际调用场景:

// handleDog 的调用方会传入一个 Dog
handleDog({ name: '旺财', breed: '柴犬' })

// 如果 handleDog 的实际实现是 handleAnimal:
// (a) => console.log(a.name)
// 收到一个 Dog,只用了 name → 完全没问题

// 反过来,如果 handleAnimal 的实际实现是 handleDog:
// (d) => console.log(d.breed)
// 收到一个普通 Animal,没有 breed → 运行时爆炸

所以函数参数是逆变的:你承诺能处理子类型,那实际实现必须至少能处理父类型。处理能力越宽泛,才越安全。

用个不太严谨但好记的比喻:你招了个岗位说要"能修柴犬的兽医",来了个"什么动物都能修的全科兽医"——没问题。反过来,岗位要"全科兽医",来了个"只会修柴犬的"——不行。

回到那个组件:问题出在哪

回到开头的 ListProps<T>

interface ListProps<T> {
  items: T[]          // T 在输出位置 → 协变
  onSelect: (item: T) => void  // T 在函数参数位置 → 逆变
}

同一个泛型参数 T,在 items 里是协变的,在 onSelect 的参数里是逆变的。这就导致 T 处于一个既要协变又要逆变的位置——术语叫不变(Invariant)

实际后果:

function renderList<T>(props: ListProps<T>) { /* ... */ }

const dogList: ListProps<Dog> = {
  items: [{ name: '旺财', breed: '柴犬' }],
  onSelect: (dog) => console.log(dog.breed)
}

// 想把 ListProps<Dog> 当 ListProps<Animal> 用?
// 不行。因为 T 既协变又逆变,类型锁死了
const animalList: ListProps<Animal> = dogList // ❌ Type error

这在封装通用组件时特别烦。你想让组件接受各种子类型的 Props,但类型系统不让。

实战解法:拆开读写位置

核心思路:别让同一个泛型参数同时出现在协变和逆变位置

方案一:用 extends 约束代替直接传递

interface ListProps<T extends Animal> {
  items: T[]
  // 回调参数放宽到 Animal,不跟 T 绑定
  onSelect: (item: Animal) => void
}

// 现在可以这样用
function DogList() {
  const props: ListProps<Dog> = {
    items: [{ name: '旺财', breed: '柴犬' }],
    onSelect: (animal) => console.log(animal.name) // 只能访问 Animal 的属性
  }
}

缺点很明显:onSelect 里拿不到 Dog 特有的属性。有时候能接受,有时候不行。

方案二:分离读和写的泛型

interface ListProps<TItem, TSelect = TItem> {
  items: TItem[]                    // TItem 只在协变位置
  onSelect: (item: TSelect) => void // TSelect 只在逆变位置
}

// 精确版:读写都是 Dog
type DogListExact = ListProps<Dog, Dog>

// 宽松版:读 Dog,回调接受 Animal 就行
type DogListLoose = ListProps<Dog, Animal>

这个方案灵活,但两个泛型参数用起来心智负担大。组件泛型参数一多,调用方看着就头疼。

方案三:我个人更倾向的方式

实际项目里我用得最多的是这种——回调用泛型函数签名

interface ListProps<T> {
  items: T[]
  onSelect: <U extends T>(item: U) => void  // 回调本身是泛型的
}

// 或者更常见的做法:直接用 readonly 把数组锁住
interface ListProps<T> {
  items: readonly T[]  // readonly → 去掉数组的"写"能力 → 纯协变
  onSelect: (item: T) => void
  renderItem: (item: T) => React.ReactNode
}

第二种写法虽然没完全解决逆变问题,但 readonly 至少在数组层面消除了一些不安全的操作。真实 React 组件里,items 基本不会在组件内部被修改,加 readonly 是好习惯。

strictFunctionTypes 这个坑必须提

TypeScript 2.6 引入了 strictFunctionTypes,开了之后函数参数才是严格逆变的。没开的话,函数参数是双变的(Bivariant)——既协变又逆变都允许。

// strictFunctionTypes: false(默认在非 strict 模式下)
handleAnimal = handleDog // ✅ 不报错,但运行时可能炸
handleDog = handleAnimal // ✅ 这个本来就是安全的

// strictFunctionTypes: true(推荐)
handleAnimal = handleDog // ❌ 正确地报错了
handleDog = handleAnimal // ✅

之前接手一个老项目,strict 没全开,一堆回调类型赋值都没报错。上线后各种 Cannot read property of undefined,查了半天才发现是函数参数类型不安全赋值导致的。后来开了 strict 一编译,好家伙,200 多个类型错误。

所以新项目一定开 strict。老项目迁移的话,可以先单独开 strictFunctionTypes,影响范围相对可控。

复杂场景:嵌套泛型组件的 Props 传递

真实业务里,组件经常是嵌套的。比如一个 Table 里面用了 Column

interface ColumnProps<T> {
  dataIndex: keyof T
  render: (value: T[keyof T], record: T) => React.ReactNode
  // render 的两个参数都是逆变位置
  // dataIndex 是... 额,keyof T 比较特殊,先不展开
}

interface TableProps<T> {
  data: readonly T[]
  columns: ColumnProps<T>[]
  onRowClick?: (record: T) => void
}

这里 ColumnProps<T> 里的 render 参数是逆变的,而 ColumnProps<T>[] 整体又被放在 TableProps<T> 的协变位置。逆变套协变,结果还是逆变。协变套协变还是协变,逆变套逆变反而变成协变——跟负负得正一个道理。

// 型变的组合规则:
// 协变 × 协变 = 协变  (正 × 正 = 正)
// 协变 × 逆变 = 逆变  (正 × 负 = 负)
// 逆变 × 逆变 = 协变  (负 × 负 = 正)

实际写组件时不需要时刻想着这个公式。但如果碰到类型报错死活想不通,把泛型参数在每一层的位置标出来,按这个规则推一遍,基本就清楚了。

来个实际踩坑场景:

interface FormFieldProps<T> {
  value: T                          // 协变
  onChange: (newValue: T) => void   // 逆变
  validate: (value: T) => string | null  // 逆变
}

// 想做一个高阶组件,给 FormField 加默认校验
function withValidation<T>(
  WrappedField: React.ComponentType<FormFieldProps<T>>,
  defaultValidator: (value: T) => string | null
) {
  // 这里 WrappedField 的泛型参数 T 在 ComponentType 的参数位置
  // ComponentType<P> 中 P 是逆变的(props 是函数参数)
  // 所以 T 经过两层:逆变(ComponentType的P) × 逆变(onChange的参数) = 协变
  // 也就是说对于 onChange 这条链路,T 最终是协变的
  // 但对于 value 这条链路:逆变(ComponentType的P) × 协变(value) = 逆变
  // T 同时协变和逆变 → 不变
  // 所以这个 HOC 的 T 是不变的,不能传子类型替代
  return WrappedField
}

看到没?HOC 里泛型的型变分析能绕晕人。我的经验是:如果高阶组件的类型推导搞得太复杂,换成 hooks 或者 render props 往往更好处理。不是说 HOC 不能用,而是 HOC 天然多一层类型嵌套,在 TS 里确实更容易出问题。

几个判断型变的快速技巧

写了这么多,分享几个我日常用的快速判断方法:

看位置:

  • 函数返回值、属性值、Promise 的 resolve 值 → 协变位置(输出)
  • 函数参数、回调参数 → 逆变位置(输入)

看 readonly:

  • readonly T[]T[] 在型变上更友好,因为去掉了写入操作
  • Readonly<Record<string, T>> 同理

看报错:

  • 如果报错是 Type 'A' is not assignable to type 'B',而你觉得 A 明明是 B 的子类型——大概率是你碰到逆变了,检查一下这个类型是不是在函数参数位置

实在搞不定:

// 最终手段:用 type assertion 或 as unknown as
// 但要确保你真的理解为什么类型不兼容
// 别无脑 as any,那跟写 JavaScript 有什么区别
const handler = dogHandler as unknown as Handler<Animal>

聊到这

协变逆变不是什么高深的类型体操。说到底就一件事:类型安全在"输入"和"输出"两个方向上的要求是相反的。输出可以更具体(协变),输入必须更宽泛(逆变)。

设计泛型组件 Props 的时候,把每个泛型参数的位置标一下,哪些是输出、哪些是输入,型变关系自然就清楚了。碰到实在不兼容的情况,优先考虑拆分泛型参数或者调整 API 设计,而不是上来就 as any

有一点我到现在也没想通:TypeScript 对方法(method)的类型检查默认是双变的,而对函数属性(function property)才是逆变的。比如 interface Foo { bar(x: T): void }interface Foo { bar: (x: T) => void }strictFunctionTypes 下的行为居然不一样。官方说是为了兼容性,但这个设计确实容易让人踩坑。

组合式函数 Composables 的设计模式:如何写出可复用的 Vue3 Hooks

一个真实的崩溃瞬间

你写了第三个页面,发现又在写 onMounted 里请求数据、ref 存状态、watch 做联动——和前两个页面一模一样,只是接口地址不同。

复制粘贴?行,能跑。但等你第十个页面还在粘贴的时候,产品说"加载状态要统一加个骨架屏",你就知道什么叫"技术债的复利效应"了。

Vue3 的 Composition API 给了你一把刀,但刀法得自己练。composable(组合式函数)不是简单地"把逻辑抽到函数里",它是一套设计模式——什么该抽、怎么抽、边界在哪,这才是真正值得聊的。


本质问题:组合式函数到底在解决什么?

Options API 时代,逻辑按"选项类型"组织——data 归 data,methods 归 methods,computed 归 computed。一个"搜索"功能的代码散落在五个选项里,你得上下反复跳着看。

Composition API 把组织维度从"选项类型"变成了"业务关切"。而 composable 就是这个思路的落地单元:一个业务关切 = 一个函数

它的本质是带状态的逻辑复用单元。注意"带状态"三个字——这是它和普通工具函数的根本区别。

// ❌ 普通工具函数:无状态,纯计算 —— 不需要 use 前缀
function formatPrice(price: number): string {
  return ${price.toFixed(2)}`
}

// ✅ composable:有状态,有响应式,每次调用生成独立实例
function usePrice(initialPrice: number) {
  const price = ref(initialPrice)
  const formatted = computed(() => ${price.value.toFixed(2)}`)

  function update(val: number) {
    price.value = val
  }

  return { price, formatted, update }
}

如果你的函数不需要 refcomputedwatch、生命周期钩子中的任何一个,那它就是个工具函数,别硬套 use 前缀。


模式一:状态封装——最基础也最常用

90% 的 composable 都在做一件事:把一坨相关的响应式状态和操作打包

useCounter:教科书级示例之外的思考

所有教程都从 useCounter 开始,但大部分教程没告诉你设计要点:

interface UseCounterOptions {
  min?: number
  max?: number
  initialValue?: number
}

function useCounter(options: UseCounterOptions = {}) {
  const { min = -Infinity, max = Infinity, initialValue = 0 } = options

  // 入参校验放在边界处,内部逻辑就可以无脑信任
  const count = ref(clamp(initialValue, min, max))

  function inc(delta = 1) {
    count.value = clamp(count.value + delta, min, max)
  }

  function dec(delta = 1) {
    inc(-delta) // 复用 inc,不重复写 clamp
  }

  function reset() {
    count.value = clamp(initialValue, min, max)
  }

  // readonly 包装 → 外部只能通过方法修改,不能直接 count.value = 999 绕过 clamp
  return { count: readonly(count), inc, dec, reset }
}

function clamp(val: number, min: number, max: number) {
  return Math.min(max, Math.max(min, val))
}

三个设计决策:

参数用 options 对象而不是位置参数。 当参数超过两个,位置参数就是灾难——useCounter(0, 10, 100) 谁记得住哪个是 min 哪个是 max?

返回值用 readonly 包装。 暴露 ref 本身意味着外部可以 count.value = 999 绕过 clamp 逻辑。单向数据流不是 React 的专利。

返回对象而不是数组。 Vue 的 composable 推荐返回具名对象。不像 React hooks 需要自定义命名(const [count, setCount] = useState(0)),Vue 的解构天然具名,多返回值也不会混乱。


模式二:异步状态管理——真正的高频场景

实际项目里你写得最多的 composable 大概率是"请求数据"。

function useFetch<T>(url: MaybeRefOrGetter<string>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  async function execute() {
    isLoading.value = true
    error.value = null
    try {
      const response = await fetch(toValue(url))
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  }

  // url 是响应式的 → url 变了自动重新请求
  watchEffect(() => {
    toValue(url) // 收集依赖
    execute()
  })

  return { data, error, isLoading, execute }
}
// 用法:url 变了自动重新请求,不用手动 watch
const userId = ref(1)
const { data: user, isLoading } = useFetch<User>(
  () => `/api/users/${userId.value}` // getter 写法,最灵活
)

userId.value = 2 // 切换用户 → url 变化 → 自动重新请求

参数设计的关键:MaybeRefOrGetter

url 参数的类型是 MaybeRefOrGetter<string>——这是 Vue3 composable 的核心范式:入参同时接受普通值、ref 和 getter 函数

useFetch('/api/users')                    // 静态字符串
useFetch(urlRef)                          // ref
useFetch(() => `/api/users/${id.value}`)  // getter
// ☝️ 三种传法都行,内部用 toValue() 统一取值

调用者不用关心"我传的是 ref 还是普通值",心智负担少一半。

如果你的 composable 参数未来可能是动态的,就用 MaybeRefOrGetter。这条规则能省掉很多后期重构。


模式三:副作用管理——最容易写出 bug 的地方

composable 里注册事件监听、定时器、WebSocket 连接,如果不清理,就等着内存泄漏。

// 基础版:target 固定
function useEventListener<K extends keyof WindowEventMap>(
  target: EventTarget,
  event: K,
  handler: (e: WindowEventMap[K]) => void
) {
  onMounted(() => target.addEventListener(event, handler as EventListener))
  onUnmounted(() => target.removeEventListener(event, handler as EventListener))
}

但这个版本有个问题:target 是写死的。如果 target 是个 ref 呢?

// 进阶版:target 支持响应式,自动解绑/重绑
function useEventListener(
  target: MaybeRefOrGetter<EventTarget | null>,
  event: string,
  handler: EventListener
) {
  let cleanup: (() => void) | undefined

  function bindEvent() {
    cleanup?.() // 先解绑旧的
    const el = toValue(target)
    if (!el) return
    el.addEventListener(event, handler)
    cleanup = () => el.removeEventListener(event, handler)
  }

  // target 变了 → 旧的解绑,新的绑上
  watchEffect(() => {
    bindEvent()
  })

  // 用 onScopeDispose 而不是 onUnmounted
  // → 在 Pinia store / effectScope 里也能正确清理
  onScopeDispose(() => cleanup?.())
}

这里用了 onScopeDispose 而不是 onUnmounted——区别在于:onScopeDispose 在当前 effect scope 销毁时触发,不仅限于组件。如果这个 composable 被用在 effectScope() 里(比如 Pinia store),onUnmounted 根本不会触发,而 onScopeDispose 可以。

规则:composable 里的清理用 onScopeDispose,别用 onUnmounted


模式四:组合式的组合——composable 套 composable

composable 最强的能力不是单个函数,而是组合。像乐高一样拼。

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  // 复用 useEventListener,不用重复处理清理逻辑
  useEventListener(window, 'mousemove', (e: MouseEvent) => {
    x.value = e.clientX
    y.value = e.clientY
  })

  return { x, y }
}

function useMouseInElement(target: MaybeRefOrGetter<HTMLElement | null>) {
  const { x, y } = useMouse() // 继续复用

  const elementX = ref(0)
  const elementY = ref(0)

  watchEffect(() => {
    const el = toValue(target)
    if (!el) return
    const rect = el.getBoundingClientRect()
    elementX.value = x.value - rect.left
    elementY.value = y.value - rect.top
  })

  return { elementX, elementY }
}

// 三层复用:useEventListener → useMouse → useMouseInElement
// 每一层只关心自己那件事,上层不需要知道底层怎么绑事件、怎么清理

这就是"组合式"三个字的真正含义——不是把逻辑"提取"出去,而是让逻辑可以搭积木。


设计权衡:那些需要做的选择

粒度:拆多细算合适?

见过有人把三行代码也封装成 composable 的:

// ❌ 过度封装
function useTitle(title: string) {
  document.title = title
}

也见过把整个页面逻辑塞进一个 composable 的,写了 500 行,跟以前的 God Component 一个味。

经验法则:超过 80 行考虑拆分;不到 10 行且只用一次,别抽。

抽取的判断标准不是"这段代码多长",而是:

  1. 它会被复用吗? 至少两处用到再抽。
  2. 它在概念上是独立的吗? "鼠标位置跟踪"是独立的,"按钮文案计算"可能不是。
  3. 抽完之后原来的代码更好读了吗? 如果抽完还得跳来跳去看,不如不抽。

返回值:ref 还是 reactive?

// ✅ 方案 A:返回包含 ref 的对象(推荐)
function useMouse() {
  const x = ref(0)
  const y = ref(0)
  return { x, y }
}
const { x, y } = useMouse() // 解构后仍然是响应式

// ❌ 方案 B:返回 reactive 对象
function useMouse() {
  return reactive({ x: 0, y: 0 })
}
const { x, y } = useMouse() // 解构后响应式丢失!

Vue 官方文档明确推荐方案 A。原因很简单:reactive 对象解构会丢失响应式,而 ref 不会。调用方大概率要解构,你不能假设别人记得用 toRefs

同步 vs 异步初始化

const { count } = useCounter()
console.log(count.value) // 0,确定可用 —— 同步,调用完立刻有值

const { data } = useFetch('/api/user')
console.log(data.value) // null,数据还没回来 —— 异步,需要 v-if 或 watch 兜底

异步 composable 必须暴露 isLoadingerror,不能让调用方猜"数据到了没"。这不是贴心设计,是必需品。


边界与踩坑

踩坑一:在 composable 外面调用

// ❌ 模块顶层调用 → onMounted/onUnmounted 找不到当前组件实例
const { data } = useFetch('/api/config') // 💥 报警告或静默失败

export default {
  setup() {
    const { data } = useFetch('/api/config') // ✅ 在 setup 里调用
  }
}

composable 如果内部用了生命周期钩子,必须在 setup()<script setup> 的同步执行期间调用。await 之后再调用也不行——await 之后当前实例上下文已经丢了。

踩坑二:composable 内部的 watch 没清理

在 composable 里 watch 了一个外部传入的 ref,组件卸载时 watch 会自动停止——这没问题。但如果你的 composable 是在 effectScope 里手动创建的(比如 Pinia),需要手动调 scope.stop()

踩坑三:SSR 中访问浏览器 API

function useLocalStorage<T>(key: string, defaultValue: T) {
  const data = ref<T>(defaultValue)

  if (typeof window !== 'undefined') {
    // SSR 环境下没有 localStorage,必须守卫
    const stored = localStorage.getItem(key)
    if (stored) data.value = JSON.parse(stored)

    watch(data, (val) => {
      localStorage.setItem(key, JSON.stringify(val))
    }, { deep: true })
  }

  return data
}

任何涉及 windowdocumentlocalStorage 的 composable,都要考虑 SSR。不加守卫,Nuxt 项目一上线就炸。


当项目越来越大

目录组织

composables/
├── useAuth.ts          # 业务级:认证相关(别发 npm 包)
├── usePermission.ts    # 业务级:权限相关
├── useFetch.ts         # 通用级:请求封装(跨项目可用)
├── useEventListener.ts # 基础级:事件绑定(可以发包)
└── index.ts            # 统一导出

分三层:基础级(和业务无关)、通用级(跨项目可用)、业务级(和当前项目绑定)。

类型安全

composable 的泛型设计很重要。useFetch<User>useFetch 然后到处 as User 优雅太多。花点时间写好类型签名,TypeScript 会在每一个调用处帮你挡住错误。

测试

composable 天然好测试——它就是个函数,给入参,拿返回值:

import { useFetch } from './useFetch'
import { withSetup } from '../test-utils' // 模拟 setup 上下文

test('useFetch loads data', async () => {
  const { data, isLoading } = withSetup(() => useFetch('/api/test'))

  expect(isLoading.value).toBe(true)
  await flushPromises()
  expect(data.value).toEqual({ id: 1 })
  expect(isLoading.value).toBe(false)
})

不用挂载组件、不用模拟模板渲染,比测 Options API 的 mixin 舒服一百倍。


组合式函数的设计模型

composable 的设计核心就三件事:

封装状态 —— 把相关的 refcomputedwatch 圈在一起,对外暴露干净的接口。信息隐藏原则在响应式系统里的具体表现。

管理副作用 —— 绑定了什么就要清理什么,onScopeDispose 是你的安全网。副作用不清理,就是在给未来的自己埋雷。

保持可组合性 —— 小函数组合成大函数,大函数可以继续被组合。入参用 MaybeRefOrGetter 保持灵活,返回值用 ref 对象保持可解构。

这三条不只适用于 Vue。React hooks、Svelte 的 runes、SolidJS 的 primitives——底层逻辑一样。状态封装 + 副作用管理 + 组合能力,这是所有"带状态的逻辑复用"方案的通用模型。

下次遇到"这段逻辑要不要抽 composable",问自己三个问题:它有独立的状态吗?会被复用吗?抽完之后代码更清晰了吗?三个"是"就抽,否则别动。过度抽象比不抽象更可怕。

浏览器渲染管线深度拆解:从 Parse HTML 到 Composite Layers 的每一帧发生了什么

你写了一行 div.style.left = '100px',屏幕上的盒子就动了。

这中间到底发生了什么?如果你的回答是"浏览器重新渲染了",那基本等于说"电脑帮我算的"——正确但没用。

这篇把"浏览器渲染"这个黑盒拆开,看清里面每一个齿轮怎么咬合。搞明白这条管线,你才能理解为什么有些动画丝滑如德芙,有些卡得像 PPT。


一、全景图:一帧的生命周期

先给一张地图,免得后面迷路。

Parse HTML → DOM Tree
       ↓
Parse CSS → CSSOM Tree
       ↓
DOM + CSSOM → Render Tree
       ↓
Layout(布局)
       ↓
Paint(绘制)
       ↓
Composite(合成)
       ↓
屏幕上的像素

每一步都有明确的输入和输出。跳过任何一步,屏幕上就是一片白。

但重点来了——不是每次更新都要跑完整条线。改个颜色不用重新布局,改个 transform 甚至不用重新绘制。这才是性能优化的核心心智模型。


二、Parse:从字符串到树

HTML → DOM Tree

<div class="container">
  <p>hello</p>
  <img src="cat.jpg" />
</div>

<!--
  词法分析器(Tokenizer)把 HTML 切成 token 流:
  StartTag:div → StartTag:p → Text:"hello" → EndTag:p → SelfClosingTag:img → EndTag:div
  然后按嵌套关系组装成 DOM 树
-->

经典坑:HTML 解析是可被阻塞的

<head>
  <!-- 这个 script 会阻塞 DOM 解析 -->
  <!-- 浏览器:"等等,万一这脚本里有 document.write 呢?我得停下来" -->
  <script src="heavy-bundle.js"></script>
</head>
<body>
  <!-- 上面的 JS 没下载完执行完,这里的 DOM 一个都不会构建 -->
  <div id="app"></div>
</body>

所以 deferasync 不是锦上添花,是必需品。浏览器在等 JS 下载时,整棵 DOM 树的构建就停在那里——用户看到的是白屏。

CSS → CSSOM Tree

CSS 解析独立进行,生成一棵 CSSOM(CSS Object Model)树。但 CSS 会阻塞渲染——浏览器拒绝在 CSSOM 构建完成前渲染任何东西。

为什么?如果先渲染无样式内容,CSS 加载完再闪一下变正常,这就是 FOUC(Flash of Unstyled Content)。浏览器宁愿让你多等 200ms 白屏,也不愿意闪你一脸。


三、Render Tree:真正要画的东西

DOM Tree + CSSOM Tree 合并,生成 Render Tree。

Render Tree 里没有不可见元素:

/* display: none → 从渲染世界蒸发,不参与后续任何步骤 */
.hidden {
  display: none; /* 不出现在 Render Tree 中 */
}

/* visibility: hidden → 隐身,但位置还在,布局还得算 */
.invisible {
  visibility: hidden; /* 仍然出现在 Render Tree 中 */
}

频繁切换显示状态时,visibilitydisplay 便宜得多——后者每次切换都要重建 Render Tree 的一部分。


四、Layout:算位置

Layout(也叫 Reflow)要回答一个问题:每个元素在屏幕上的确切位置和大小是多少?

一个元素的位置取决于它的父元素、兄弟元素、子元素、甚至完全不相关的元素(float 和绝对定位表示有话说)。复杂度远比你想的高。

// 触发 Layout 的属性(部分):
// width, height, padding, margin, border
// top, left, right, bottom
// font-size, line-height
// display, position, float

const el = document.getElementById('box')

el.style.width = '200px'  // 标记:需要 Layout
el.style.height = '100px' // 标记:需要 Layout
el.style.margin = '10px'  // 标记:需要 Layout
// 浏览器会把这三次修改攒成一次 Layout(批处理)

// ❌ 但如果你这样写——
el.style.width = '200px'
const h = el.offsetHeight   // 读取布局信息 → 浏览器被迫立即 Layout!
el.style.height = '100px'
const w = el.offsetWidth     // 又读了 → 又得 Layout 一次!

强制同步布局(Forced Synchronous Layout)

性能杀手 Top 1。

浏览器本来想攒一攒、统一处理,但你在写入之间插了一次读取,浏览器只能被迫立即结算。

类比:你在超市一边往购物车里放东西一边让收银员结账,收银员每放一件就得扫一次码算一次总价。正常人都会买完再结,对吧?

// ❌ 经典反模式:循环中读写交替
const items = document.querySelectorAll('.item')
items.forEach(item => {
  const width = item.offsetWidth       // 读 → 强制 Layout
  item.style.width = (width * 2) + 'px' // 写 → 下次读又得 Layout
  // N 个元素 = N 次 Layout,页面直接卡死
})

// ✅ 读写分离:先统一读,再统一写
const widths = [...items].map(item => item.offsetWidth) // 读(一次 Layout)
items.forEach((item, i) => {
  item.style.width = (widths[i] * 2) + 'px' // 写(攒成一次 Layout)
})

React 或 Vue 的虚拟 DOM batch update 帮你避免了大部分这类问题。但操作 Canvas、做拖拽、写自定义组件时,这个坑随时等着你。


五、Paint:画像素

Layout 算出了"在哪",Paint 决定"画成什么样"。

Paint 把每个元素转换成一组绘制指令(paint records):

1. 在 (10, 20) 画一个 200x100 的矩形,填充 #fff
2. 在 (10, 20) 画一个 1px 边框,颜色 #ccc
3. 在 (20, 35) 绘制文本 "Hello",字号 16px,颜色 #333

// Paint 不直接输出像素
// 真正的像素填充(Rasterize,光栅化)在合成阶段由 GPU 或独立线程完成

哪些属性触发 Paint?

// 只触发 Paint(不触发 Layout)的属性:
// color, background, box-shadow, border-radius, outline, visibility

el.style.backgroundColor = 'red' // ✅ 跳过 Layout,直接 Paint → Composite
el.style.width = '300px'          // ❌ Layout → Paint → Composite 全跑

实用原则:能用只触发 Paint 的属性解决的,别动 Layout 属性。


六、Composite:合成——性能优化的终极武器

什么是合成?

浏览器把页面拆成多个图层(Layers),每个图层独立绘制,最后由 GPU 把所有图层叠在一起——就像 Photoshop 的图层合并。

图层 1: 页面背景 + 文字内容
图层 2: 固定导航栏(position: fixed)
图层 3: 那个正在做动画的弹窗

为什么要分层?弹窗在动,只需要移动它所在的图层,其他图层纹丝不动。 不分层的话,弹窗每动一帧,整个页面都要重新 Paint。

哪些属性只走 Composite?

// transform 和 opacity 是合成层的 VIP

// ❌ 用 left/top 做动画 → 每帧都触发 Layout + Paint
el.style.left = x + 'px'      // Layout → Paint → Composite

// ✅ 用 transform 做动画 → 只触发 Composite
el.style.transform = `translateX(${x}px)` // 直接 Composite,GPU 搞定

// 一个是坐绿皮火车,一个是坐高铁,目的地一样,体验天差地别

所有性能优化指南都在喊"用 transform 代替 left/top",不是玄学,是因为走了完全不同的管线路径。

主动提升合成层

/* 告诉浏览器:这个元素将来会变,提前给它一个独立图层 */
.animate-target {
  will-change: transform;
}

/* 经典 hack(不推荐,但你一定见过) */
.force-layer {
  transform: translateZ(0); /* 骗浏览器创建独立合成层 */
}

但图层不是免费的——每个图层都需要显存。滥用反而更卡。


七、为什么浏览器不把所有元素都放到独立图层?

显存是有限的。

一个 1920x1080 的图层,32 位色深,需要 1920 × 1080 × 4 bytes ≈ 8MB。50 个元素各搞一个图层?400MB 显存没了。手机用户直接白屏。

这就是 Layer Explosion(图层爆炸)

/* ❌ 千万别这么干 */
* {
  will-change: transform; /* 内存爆炸 */
}

/* ✅ 只给真正需要动画的元素提升图层 */
.modal-overlay {
  will-change: opacity;
}
.slider-track {
  will-change: transform;
}

浏览器的策略:默认最少图层,只在有明确理由时才分层。经典的空间换时间——多用一点显存,省掉大量重绘开销。但空间有上限,不能滥用。


八、一帧 16.6ms 内的完整时间线

屏幕刷新率 60fps,每帧预算 16.6ms:

[JS 执行][Style 计算][Layout][Paint][Composite] → 🖥️
  ↑ 你的代码跑在这里

  如果你的 JS 跑了 15ms,留给渲染管线的只有 1.6ms
  大概率——掉帧了

requestAnimationFrame 的正确理解

// rAF 的回调在"Style 计算"之前执行
// 这是浏览器给你的"最后修改机会"

// ❌ 用 setTimeout 做动画
setTimeout(() => {
  el.style.transform = `translateX(${x}px)`
}, 16) // 16ms ≈ 一帧?不,时机完全不可控

// ✅ 用 rAF 做动画
requestAnimationFrame(() => {
  el.style.transform = `translateX(${x}px)`
  // 保证在下一帧渲染前执行,时机精确
})

setTimeout(fn, 16)requestAnimationFrame(fn) 的区别不是精度问题——它们在事件循环中的执行时机完全不同。rAF 是被渲染管线调度的,setTimeout 是被任务队列调度的。


九、实战:一个列表滚动卡顿的排查

// 场景:10000 行的虚拟列表,滚动时明显掉帧
// 打开 DevTools → Performance 面板 → 录制滚动
// 发现:每帧都有大面积紫色(Layout)

// ❌ 原因:滚动事件回调里读取了 offsetTop
window.addEventListener('scroll', () => {
  items.forEach(item => {
    const top = item.offsetTop        // 强制同步布局!
    item.style.display = top > threshold ? 'none' : 'block'
  })
})

// ✅ 用 IntersectionObserver 代替手动计算可见性
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // visibility 不触发 Layout,比 display 便宜
    entry.target.style.visibility = entry.isIntersecting ? 'visible' : 'hidden'
  })
})
items.forEach(item => observer.observe(item))

从"卡成 PPT"到"丝滑如黄油",改的不是算法,改的是对渲染管线的理解。


十、边界与风险

这套心智模型什么时候会失效?

  1. 浏览器实现差异。上面说的主要是 Chromium(Blink 引擎)的行为。Chrome 的合成策略和 Safari 不同,Firefox 又是另一套。
  2. 浏览器的启发式优化。现代浏览器会自动做隐式合成层提升之类的优化,你以为没提升但其实浏览器偷偷帮你做了。
  3. CSS Containment 改变了规则contain: layout 可以限制 Layout 的影响范围,让局部修改不触发全局重排。

容易踩的坑:

  • will-change 用完不清理,图层一直驻留显存
  • 以为 opacity: 0display: none 等价(前者仍在合成层中,仍然占空间)
  • scroll 事件里做同步 Layout 查询

十一、管线思维

浏览器渲染管线的本质是一条分阶段增量处理的流水线。这种模型到处都是:

  • 编译器:词法分析 → 语法分析 → IR → 优化 → 代码生成
  • 网络协议栈:物理层 → 数据链路层 → 网络层 → 传输层 → 应用层
  • CI/CD:Lint → Test → Build → Deploy

共性:每一步只做一件事,输出是下一步的输入;优化的关键是跳过不必要的步骤。

浏览器渲染优化归结为一句话:

能走 Composite 就不走 Paint,能走 Paint 就不走 Layout。管线跑得越短,帧率越高。

下次看到页面卡顿,别对着代码发呆——打开 Performance 面板,看紫色(Layout)、绿色(Paint)、黄色(JS)各占多少,问题就在那里。

不是玄学,是物理。

AI 代码审查工具链搭建:用 AST 解析 + LLM 实现自动化 Code Review 的前端工程方案

AI 代码审查工具链搭建:用 AST 解析 + LLM 实现自动化 Code Review 的前端工程方案

团队到了 15 人以上,Code Review 就开始变味了。

不是没人 review,而是 review 变成了"LGTM 流水线"——打开 PR,滚动两屏,留一句 "looks good to me",合并。真正的逻辑问题、潜在的性能隐患、不符合团队规范的写法,全靠运气。

人工 review 的瓶颈不是态度,是带宽。一个资深工程师一天能认真 review 多少个 PR?3 到 5 个,顶天了。剩下的要么排队,要么糊弄。

所以我们开始想:能不能让机器先过一遍,把"明显有问题"的地方标出来,人再去看真正需要判断力的部分?

这就是这篇文章要聊的事——用 AST 解析做结构化分析,用 LLM 做语义级审查,把两者串成一条自动化 Code Review 工具链。


先搞清楚:人工 Review 到底哪里不行?

不是人不行,是人干了太多不该干的活。

一次典型的 Code Review,reviewer 的注意力大概分布在这几个层面:

层面 举例 能否自动化
格式规范 缩进、命名、import 顺序 ESLint/Prettier 已解决
模式违规 组件里直接调 fetch、没用 hooks 封装 AST 可以搞定
逻辑隐患 useEffect 依赖缺失、竞态条件 AST + 规则引擎可以搞定
业务语义 这个字段不该在这里改、这段逻辑和需求不符 需要 LLM
架构决策 该不该拆微服务、该不该用新方案 需要人

ESLint 覆盖了第一层,但第二到第四层基本是裸奔状态。我们要做的,就是把中间这三层自动化掉。


整体架构:两阶段流水线

核心思路一句话:AST 做确定性分析,LLM 做模糊判断

┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Git Diff    │────▶│  AST 结构化分析   │────▶│  规则引擎    │
│  提取变更文件 │     │  提取函数/组件/依赖│     │  输出确定问题 │
└─────────────┘     └──────────────────┘     └──────┬──────┘
                                                     │
                                              ┌──────▼──────┐
                                              │  LLM 语义审查 │
                                              │  上下文 + Diff │
                                              └──────┬──────┘
                                                     │
                                              ┌──────▼──────┐
                                              │  结果聚合     │
                                              │  发 PR Comment│
                                              └─────────────┘

为什么不直接把代码丢给 LLM?后面讲,先看怎么搭。


第一阶段:AST 结构化分析

拿到 Diff,先别急着分析

第一步不是分析代码,是搞清楚改了什么

import { execSync } from 'child_process'

function getChangedFiles(baseBranch = 'main'): string[] {
  const output = execSync(
    `git diff --name-only --diff-filter=ACMR ${baseBranch}...HEAD`
  ).toString()

  return output
    .split('\n')
    .filter(f => f.endsWith('.ts') || f.endsWith('.tsx')) // 只关心 TS/TSX
    .filter(Boolean)
}

拿到文件列表后,逐个解析 AST。这里用 @typescript-eslint/typescript-estree,因为它对 TSX 的支持最好,而且输出的 AST 和 ESLint 生态兼容。

从 AST 中提取"审查素材"

我们不是要遍历整棵树,而是提取 reviewer 真正关心的结构信息:

import { parse } from '@typescript-eslint/typescript-estree'
import { simpleTraverse } from '@typescript-eslint/typescript-estree'

interface ComponentMeta {
  name: string
  hooks: string[]           // 用了哪些 hooks
  deps: string[]            // import 了什么
  stateCount: number        // 多少个 useState
  effectCount: number       // 多少个 useEffect
  lineCount: number         // 函数体行数
  hasCleanup: boolean[]     // useEffect 是否有清理函数
}

function extractComponentMeta(code: string): ComponentMeta[] {
  const ast = parse(code, { jsx: true, loc: true })
  const components: ComponentMeta[] = []

  simpleTraverse(ast, {
    enter(node) {
      // 找到函数组件(大写开头的函数声明/箭头函数)
      if (
        node.type === 'FunctionDeclaration' &&
        node.id?.name?.[0] === node.id?.name?.[0]?.toUpperCase()
      ) {
        const meta = analyzeComponentBody(node, code)
        components.push(meta)
      }
    },
  })

  return components
}

关键在 analyzeComponentBody 里,我们要识别几个高价值信号:

function analyzeComponentBody(node: any, code: string): ComponentMeta {
  const hooks: string[] = []
  let stateCount = 0
  let effectCount = 0
  const hasCleanup: boolean[] = []

  simpleTraverse(node, {
    enter(child) {
      if (
        child.type === 'CallExpression' &&
        child.callee.type === 'Identifier'
      ) {
        const name = child.callee.name

        if (name.startsWith('use')) hooks.push(name)
        if (name === 'useState') stateCount++
        if (name === 'useEffect') {
          effectCount++
          // 检查回调是否返回了清理函数
          const callback = child.arguments[0]
          if (callback?.type === 'ArrowFunctionExpression') {
            const body = callback.body
            // 简化判断:函数体内是否有 return 语句
            const hasReturn = code
              .slice(body.range![0], body.range![1])
              .includes('return')
            hasCleanup.push(hasReturn)
          }
        }
      }
    },
  })

  return {
    name: node.id?.name ?? 'Anonymous',
    hooks,
    deps: [], // 从 import 声明中单独提取
    stateCount,
    effectCount,
    lineCount: node.loc!.end.line - node.loc!.start.line,
    hasCleanup,
  }
}

规则引擎:把经验变成代码

有了结构化信息,规则就好写了。这不是玄学,就是把资深工程师脑子里的"直觉"翻译成条件判断:

interface ReviewIssue {
  level: 'error' | 'warning' | 'info'
  message: string
  file: string
  component: string
}

function applyRules(meta: ComponentMeta, file: string): ReviewIssue[] {
  const issues: ReviewIssue[] = []

  // 规则 1:组件超过 200 行,大概率该拆了
  if (meta.lineCount > 200) {
    issues.push({
      level: 'warning',
      message: `组件 ${meta.name}${meta.lineCount} 行,考虑拆分`,
      file,
      component: meta.name,
    })
  }

  // 规则 2:useState 超过 5 个 → 该用 useReducer 或抽 custom hook
  if (meta.stateCount > 5) {
    issues.push({
      level: 'warning',
      message: `${meta.name}${meta.stateCount} 个 useState,状态管理可能需要重构`,
      file,
      component: meta.name,
    })
  }

  // 规则 3:useEffect 没有清理函数 → 可能有内存泄漏
  meta.hasCleanup.forEach((has, i) => {
    if (!has) {
      issues.push({
        level: 'info',
        message: `${meta.name} 的第 ${i + 1} 个 useEffect 没有 cleanup,确认是否需要`,
        file,
        component: meta.name,
      })
    }
  })

  return issues
}

这一层的好处是零成本、零延迟、百分百确定性。不调 API,不花钱,跑一遍就是几百毫秒的事。


第二阶段:LLM 语义级审查

AST 能告诉你"这个 useEffect 没有 cleanup",但它没法告诉你"这段逻辑有竞态条件"或者"这个状态更新的时机不对"。

这就是 LLM 上场的地方。

Prompt 工程:别把整个文件丢进去

最常见的错误是把整个文件甚至整个 PR 一股脑扔给 LLM。这样做的问题:

  1. Token 浪费严重——一个 PR 改了 20 个文件,8000 行代码,光 input 就烧掉大量 token
  2. 注意力稀释——LLM 在长上下文里容易"走神",真正的问题反而漏掉
  3. 结果不可控——返回一堆格式/命名建议,全是噪音

正确的做法是只给 LLM 它该看的东西

interface LLMReviewContext {
  diff: string              // 只给变更部分,不给整文件
  componentMeta: ComponentMeta  // AST 阶段提取的结构信息
  astIssues: ReviewIssue[]  // 第一阶段已发现的问题(避免重复)
  projectContext: string    // 项目级约定(简短)
}

function buildPrompt(ctx: LLMReviewContext): string {
  return `你是一个资深前端工程师,正在 review 一个 React + TypeScript 项目的 PR。

## 项目约定
${ctx.projectContext}

## 已知问题(AST 分析已发现,不需要重复指出)
${ctx.astIssues.map(i => `- ${i.message}`).join('\n')}

## 组件结构信息
- 组件名:${ctx.componentMeta.name}
- 使用的 Hooks:${ctx.componentMeta.hooks.join(', ')}
- useState 数量:${ctx.componentMeta.stateCount}
- useEffect 数量:${ctx.componentMeta.effectCount}

## 代码变更(Diff)
\`\`\`diff
${ctx.diff}
\`\`\`

请从以下角度审查,只输出有价值的问题,不要指出格式或命名问题:
1. 是否存在竞态条件或时序问题
2. 状态更新逻辑是否正确
3. 是否有潜在的性能问题(不必要的重渲染等)
4. 错误处理是否完整
5. 是否有安全隐患(XSS、注入等)

输出格式:
- [严重程度: high/medium/low] 问题描述
- 涉及代码行
- 建议修改方式`
}

注意看,我们把 AST 阶段的分析结果也传进去了,明确告诉 LLM"这些我已经知道了,别重复说"。这是减少 LLM 输出噪音的关键手段。

调用层:流式 + 超时 + 降级

生产环境不能像 demo 那样裸调 API:

async function callLLMReview(
  prompt: string,
  options: { timeout?: number; model?: string } = {}
): Promise<string> {
  const { timeout = 30_000, model = 'claude-sonnet-4-6' } = options
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), timeout)

  try {
    const response = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': process.env.ANTHROPIC_API_KEY!,
        'anthropic-version': '2023-06-01',
      },
      body: JSON.stringify({
        model,
        max_tokens: 2000,    // review 结果不需要太长
        messages: [{ role: 'user', content: prompt }],
      }),
      signal: controller.signal,
    })

    const data = await response.json()
    return data.content[0].text
  } catch (err: any) {
    if (err.name === 'AbortError') {
      // 超时降级:只返回 AST 分析结果,LLM 部分跳过
      console.warn('LLM review timeout, falling back to AST-only')
      return ''
    }
    throw err
  } finally {
    clearTimeout(timer)
  }
}

超时不是异常,是常态。LLM 接口抖一下太正常了。降级策略必须在 Day 1 就写好,不是等线上出事再补。


结果聚合:发到 PR 评论里

两个阶段的结果合并后,通过 GitHub API 写回 PR:

async function postReviewComments(
  prNumber: number,
  issues: ReviewIssue[]
): Promise<void> {
  // 按严重程度排序,error 在前
  const sorted = issues.sort((a, b) => {
    const priority = { error: 0, warning: 1, info: 2 }
    return priority[a.level] - priority[b.level]
  })

  // 限制评论数量,超过 10 条就只保留 error 和 warning
  const filtered = sorted.length > 10
    ? sorted.filter(i => i.level !== 'info')
    : sorted

  const body = filtered
    .map(i => {
      const icon = { error: '🔴', warning: '🟡', info: '🔵' }[i.level]
      return `${icon} **[${i.level.toUpperCase()}]** ${i.message}\n> 📍 \`${i.file}\` - \`${i.component}\``
    })
    .join('\n\n---\n\n')

  await octokit.rest.issues.createComment({
    owner: 'your-org',
    repo: 'your-repo',
    issue_number: prNumber,
    body: `## 🤖 Auto Code Review\n\n${body}\n\n---\n*AST 分析 + LLM 审查 | 如有误报请标记 👎*`,
  })
}

为什么限制评论数量?因为一次性抛 30 条 review 意见,等于没说。 没人会看的。


设计权衡:为什么不直接全用 LLM?

这是被问最多的问题。答案很简单——成本、速度、确定性

维度 纯 LLM AST + LLM
单次 PR 成本 0.05 0.05 ~ 0.30 0.01 0.01 ~ 0.08
延迟 15~45 秒 AST < 1秒,LLM 10~30秒
确定性问题检出 可能漏,也可能幻觉 AST 部分 100% 准确
可调试性 黑盒 AST 规则可单步调试

用类比来说:AST 是安检机器,LLM 是安检员。 机器先过一遍,把明确违禁的拦下来;安检员再看机器标记可疑的,做人工判断。你不会让安检员一个一个翻包检查所有人,那队伍排到明年。

还有一个更实际的原因——LLM 会产生幻觉,AST 不会。 当 LLM 告诉你"这里有内存泄漏"的时候,你还得去验证。但 AST 告诉你"这个 useEffect 没有 cleanup",那就是没有,不用验证。


CI 集成:GitHub Actions 实现

# .github/workflows/ai-review.yml
name: AI Code Review
on:
  pull_request:
    types: [opened, synchronize]
    paths:
      - 'src/**/*.ts'
      - 'src/**/*.tsx'

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 需要完整 git 历史来算 diff

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

      - run: npm ci
      - run: npx ts-node scripts/ai-review.ts
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}

有一个细节:fetch-depth: 0。默认 checkout 只拉最新一个 commit,算不了 diff。写到这里我开始怀疑人生——每次都有人忘这个配置然后来问"为什么 git diff 是空的"。


可扩展性:从工具到平台

当这套东西跑稳了之后,自然会有新需求冒出来:

1. 规则可配置化

把 AST 规则从硬编码变成配置文件:

// .ai-review.json
{
  "rules": {
    "max-component-lines": { "level": "warning", "threshold": 200 },
    "max-useState-count": { "level": "warning", "threshold": 5 },
    "require-effect-cleanup": { "level": "info", "enabled": true }
  },
  "llm": {
    "model": "claude-sonnet-4-6",
    "maxTokens": 2000,
    "timeout": 30000,
    "focusAreas": ["race-conditions", "security", "performance"]
  },
  "ignore": ["**/*.test.ts", "**/*.spec.ts"]
}

2. 误报反馈闭环

在 PR 评论里加 👎 按钮,收集误报数据。积累到一定量后:

  • 调整 AST 规则阈值
  • 优化 LLM prompt(few-shot 加入真实误报案例)
  • 对特定模式建立白名单

3. 团队知识沉淀

把高频 review 意见提炼成团队规范文档,反哺到 AST 规则库。这不是一次性工具,是一个持续进化的系统。


边界与风险:这东西不是万能的

几个踩过的坑,提前说:

LLM 输出格式不稳定。 你让它按固定格式输出,它大部分时候听话,偶尔抽风。解析 LLM 返回结果时,必须做容错处理,不能假设格式永远正确。用 JSON mode 或者 structured output 会好很多,但也不是 100%。

跨文件分析是个深坑。 AST 解析天然是单文件粒度的。如果一个 PR 改了 A 文件的接口定义,又改了 B 文件的调用方,要关联分析就需要额外做依赖图。TypeScript 的 Language Service API 能帮上忙,但复杂度直接起飞。

不要试图替代人工 review。 这套工具是过滤器,不是替代品。架构决策、业务逻辑的合理性、代码的"品味"——这些东西目前还是得靠人。工具能做的是把 reviewer 的精力从"找明显问题"释放到"思考设计决策"上

成本控制。 一个活跃项目一天可能有几十个 PR,每个 PR 可能触发多次 review(每次 push 都触发)。按 $0.08/次算,一个月也是一笔钱。可以考虑:只在目标分支是 main/release 时触发、只分析变更超过一定行数的 PR、加缓存避免重复分析同一个 commit。


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

退一步看,这其实是一个"结构化预处理 + 智能判断"的通用模式。

不只是 Code Review,很多场景都是这个套路:

  • 日志分析:正则提取结构 → LLM 判断根因
  • 文档审查:AST/Schema 校验格式 → LLM 检查内容质量
  • 测试生成:AST 提取函数签名 → LLM 生成测试用例

核心原则就一条:能确定性解决的,不要浪费智能;需要判断力的,不要硬编规则。

把确定性的事交给确定性的工具,把模糊的事交给擅长模糊推理的模型。两者的接缝处——也就是"AST 提取出来的结构化信息如何变成 LLM 的上下文"——才是真正考验工程能力的地方。

这不是什么前沿技术,就是把现有的东西用对地方。但往往最难的,就是"用对地方"这四个字。

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

一个让人头秃的 bug

上周组里一个同事来问我:"为什么我给 reactive 对象加了个新属性,页面不更新?"

我看了一眼代码——Vue2 的写法,用的 Vue3 的 API。

const state = reactive({ name: '张三' })

// 他的操作:
state.age = 25 // 页面更新了 ✅(Vue3 没问题)

// 但他之前在 Vue2 项目里被坑过,条件反射写了:
Vue.set(state, 'age', 25) // Vue3 里根本没有 Vue.set 了

这件事让我意识到:很多人用了两三年 Vue3,但对响应式到底怎么工作的,还是停留在"Proxy 比 defineProperty 好"这句话上。

好在哪?为什么好?依赖怎么收集的?什么时候触发更新?

今天咱们把这事彻底说清楚,顺便手写一个能跑的迷你 reactivity。


Vue2 的 defineProperty 到底差在哪

先别急着夸 Proxy,得知道 Vue2 为啥被淘汰。

// Vue2 的响应式核心:逐个属性拦截
Object.defineProperty(obj, 'name', {
  get() {
    // 收集依赖
    return value
  },
  set(newVal) {
    value = newVal
    // 通知更新
  }
})

// ❌ 问题1:新增属性拦截不到,必须用 Vue.set
obj.age = 25 // set 根本不会触发,页面不更新

// ❌ 问题2:数组下标修改拦截不到
arr[0] = 'new' // 没反应

// ❌ 问题3:初始化时要递归遍历整个对象,性能差
// 1000 个属性 → 1000 次 defineProperty

本质问题就一句话:defineProperty 是"属性级别"的拦截,你得提前知道有哪些属性。

这就像安检——defineProperty 是给每个人单独装一个安检门,来一个新人得现装;Proxy 是在大楼入口装一个,谁进来都得过。


Proxy:对象级别的拦截

const raw = { name: '张三', age: 25 }

const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取了 ${key}`)  // 任何属性的读取都能拦截
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置了 ${key} = ${value}`)
    target[key] = value
    return true  // set 必须返回 true,不然严格模式报错
  }
})

proxy.name           // → "读取了 name"
proxy.hobby = '摸鱼'  // → "设置了 hobby = 摸鱼"  ✅ 新增属性也能拦截!
delete proxy.age     // 配合 deleteProperty trap,删除也能拦截

不用提前遍历,不用 Vue.set,天然支持新增/删除属性。这不是"好一点",这是降维打击。


光有 Proxy 还不够

拦截到读写只是第一步。关键问题是:谁在读?读了之后要通知谁?

这就是依赖收集。

想象一个场景:你在公司群里发了条消息"今晚团建",但不是所有人都需要知道——只有你组里的人需要收到通知。响应式系统干的就是这事:精准投递,别群发。

核心流程就三步:

  1. 读取(get) → 谁在读我?记住他(track)
  2. 修改(set) → 值变了,通知所有记住的人(trigger)
  3. effect → "那个在读的人"到底是谁?就是当前正在执行的副作用函数

手撸一个迷你 reactivity

别怕,核心代码不到 80 行。

第一步:全局变量——当前正在执行的 effect

// 全局指针:当前谁在执行?
// 这是整个系统的"指挥棒"
let activeEffect: Function | null = null

function effect(fn: Function) {
  activeEffect = fn  // 先把"当前执行者"挂上
  fn()               // 执行函数 → 函数内部会读取响应式数据 → 触发 get
  activeEffect = null // 执行完了,摘掉
}

这里有个精妙的设计:执行 fn() 的时候,fn 内部读取了什么属性,Proxy 的 get 就知道"当前是谁在读"。

时序上是这样的:

effect(() => console.log(state.name))
│
├─ activeEffect = fn        ← 挂上
├─ fn()                     ← 开始执行
│   └─ 读取 state.name      ← 触发 Proxy get
│       └─ get 里发现 activeEffect 不为 null
│           └─ 记住:name 这个属性被 fn 依赖了!(track)
└─ activeEffect = null       ← 摘掉

第二步:依赖存储结构

// 依赖关系的存储:target → key → Set<effect>
// 用 WeakMap 是为了不阻止对象被垃圾回收
const targetMap = new WeakMap<object, Map<string | symbol, Set<Function>>>()

function track(target: object, key: string | symbol) {
  if (!activeEffect) return // 没人在执行 effect,不用收集

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }

  deps.add(activeEffect) // 把当前 effect 加进去
}

数据结构长这样:

targetMap (WeakMap)
  └─ { name: '张三', age: 25 }  (Map)
       ├─ 'name'Set [ effect1, effect2 ]
       └─ 'age'Set [ effect3 ]

为什么用三层结构? 因为一个应用里有多个响应式对象(target),每个对象有多个属性(key),每个属性可能被多个 effect 依赖。三层刚好,多了浪费,少了不够。

第三步:触发更新

function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const deps = depsMap.get(key)
  if (!deps) return

  // 遍历所有依赖了这个 key 的 effect,逐个执行
  deps.forEach(effect => effect())
}

简单粗暴:找到谁依赖了这个 key,挨个重新执行。

第四步:组装 reactive

function reactive<T extends object>(raw: T): T {
  return new Proxy(raw, {
    get(target, key, receiver) {
      track(target, key)                  // 读取时收集依赖
      const result = Reflect.get(target, key, receiver)

      // 如果值是对象,递归代理(懒代理,用到才包)
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },

    set(target, key, value, receiver) {
      const oldValue = target[key as keyof T]
      const result = Reflect.set(target, key, value, receiver)

      if (oldValue !== value) {
        trigger(target, key)              // 值变了才触发,没变不浪费
      }
      return result
    }
  })
}

注意两个细节:

  • 懒代理:Vue3 不会在初始化时递归代理整个对象,只有 get 到嵌套对象时才代理。对比 Vue2 初始化就递归遍历,这就是性能差距。
  • Reflect.get/set:不直接用 target[key],因为 Reflect 能正确处理 this 指向和继承问题。你可能觉得"直接读不也行吗"——行,但在有 getter/继承的场景会出 bug。

跑一下看看

const state = reactive({ count: 0, msg: 'hello' })

// effect 1:只依赖 count
effect(() => {
  console.log('count changed:', state.count)
})
// 立即输出:count changed: 0

// effect 2:只依赖 msg
effect(() => {
  console.log('msg changed:', state.msg)
})
// 立即输出:msg changed: hello

state.count++
// → "count changed: 1"   ✅ effect1 触发
// → (effect2 没触发)      ✅ 精准更新,不是无脑全刷

state.msg = 'world'
// → "msg changed: world"  ✅ effect2 触发

70 多行代码,一个能跑的响应式系统就出来了。


设计权衡:Vue3 做了哪些取舍

为什么用 WeakMap 而不是 Map?

// WeakMap 的 key 是弱引用,对象没有其他引用时会被 GC 回收
// 如果用 Map → 响应式对象永远被 targetMap 引用 → 内存泄漏
const targetMap = new WeakMap() // ✅
const targetMap = new Map()     // ❌ 内存泄漏风险

为什么 effect 要立即执行一次?

因为不执行就收集不到依赖。依赖收集发生在 get 里,不读一遍属性,系统不知道你依赖了谁。

这也是 watchEffectwatch 的核心区别:

// watchEffect → 立即执行,自动收集依赖
watchEffect(() => {
  console.log(state.count) // 读了 count → 自动依赖 count
})

// watch → 你手动告诉它监听谁
watch(() => state.count, (newVal) => {
  console.log(newVal)
})

懒代理 vs 初始化全量代理

策略 初始化耗时 运行时耗时 适合场景
Vue2 全量递归 高(大对象很慢) 小型对象
Vue3 懒代理 几乎为零 首次访问有微小开销 大型 / 深层嵌套对象

Vue3 选了懒代理,因为实际项目中大部分属性不会在首帧全部读取——你一个 1000 行的 config 对象,首屏可能只用了 5 个字段,全量代理纯属浪费。


我们的迷你版漏了什么

写到这里你可能觉得"就这?挺简单啊"。别急,真实的 Vue3 reactivity 还处理了一堆你想不到的边界情况:

1. effect 嵌套

effect(() => {
  console.log('outer', state.a)
  effect(() => {
    console.log('inner', state.b) // 内层 effect 执行完,activeEffect 被置为 null
  })
  console.log(state.c) // ❌ 这里 activeEffect 已经是 null 了,c 的依赖收集不到!
})

Vue3 用 effectStack(栈结构)解决这个问题——进入 effect 时 push,退出时 pop,恢复上一层的 activeEffect。

2. 无限循环

effect(() => {
  state.count = state.count + 1 // 读 count → 触发 get → 收集依赖
                                 // 写 count → 触发 set → 执行 effect
                                 // effect 又读 count → 又触发 set → 💥 死循环
})

Vue3 的解法:如果当前正在执行的 effect 和要触发的 effect 是同一个,跳过。

3. ref 的存在意义

// reactive 只能代理对象
const count = reactive(0)  // ❌ Proxy 不能代理基本类型

// ref 用对象包一层,把基本类型变成对象
const count = ref(0)       // 内部:{ value: 0 } → 再用 reactive 代理
count.value++               // 通过 .value 触发 get/set

所以 ref.value 不是脱裤子放屁——是基本类型没法直接 Proxy 的无奈之举。

4. 集合类型的处理

const map = reactive(new Map())
map.set('key', 'value') // set 方法不是赋值操作,Proxy 的 set trap 拦不到

// Vue3 对 Map/Set/WeakMap/WeakSet 做了专门的 handler
// 拦截的是 get → 拿到 set 方法 → 返回一个包装后的 set 方法

这部分代码在 Vue3 源码里占了不少篇幅,也是最容易被忽略的。


可扩展性:这套模型能做什么

这套 track → trigger → effect 模型不只是给 Vue 用的,它本质上是一个自动依赖追踪的发布-订阅系统

你完全可以用它来做:

  • 状态管理:Pinia 的底层就是 reactive + 一些封装
  • 计算缓存:computed 就是一个带 dirty 标记的 effect
  • 跨组件通信:provide/inject + reactive = 自动响应式的上下文注入
  • 表单引擎:字段之间的联动关系,天然适合响应式依赖图

如果你的项目需要一套"数据变了自动通知"的机制,不一定要上 Vue,把这 70 行代码抄走改改就能用。


总结:一个通用模型

Vue3 响应式的本质,是解决一个古老的编程问题:状态同步。

A 变了,B 要跟着变。手动同步容易漏、容易错、容易忘。

Vue3 的解法是:

  1. Proxy 拦截读写——知道谁被读了、谁被改了
  2. effect + activeEffect——知道"谁在关心这个数据"
  3. track / trigger——自动建立和触发依赖关系
  4. WeakMap 三层结构——高效存储依赖映射

以后遇到类似的问题,不管是前端状态管理、后端事件驱动、还是 Excel 的单元格公式联动,底层模型都是一样的:观察者模式 + 自动依赖追踪。

区别只在于:谁来当观察者,怎么收集依赖,粒度做到多细。

想明白这一层,你看任何响应式框架(Solid、Svelte 5 runes、Preact signals)都会觉得——嗯,换了个壳,内核没跑出这个圈。

❌