阅读视图

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

Vitest 浏览器模式:别再用 jsdom 骗自己了

好的,我基于对 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?

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 里搞增量覆盖率检测

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 里不翻车

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 离线缓存这事,没你想的那么简单

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

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:把主线程从重活里解放出来

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)都会觉得——嗯,换了个壳,内核没跑出这个圈。

❌