普通视图

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

WASM 替代服务端的场景探索

2026年3月26日 15:52

WASM 替代服务端的场景探索:视频处理、加密、数据分析,3 个方向的实战验证

你有没有想过,前端直接处理一个 200MB 的视频文件,不经过服务器?两年前我会觉得这是异想天开,但最近在项目里用 WebAssembly 把三个原本必须走服务端的重计算场景搬到了浏览器里跑,结果不但跑通了,某些场景下体验比服务端还好。这篇文章不是 WASM 入门科普,而是聚焦三个具体方向——视频处理、加密运算、数据分析——逐个拆解:哪些场景真的适合用 WASM 替代服务端,哪些是伪命题,以及我踩过的那些坑。

二、视频处理:最直观的收益场景

2.1 痛点在哪

我们的 B 端系统有个视频裁剪功能,用户上传一段会议录像,截取其中 5 分钟片段。原来的流程是:前端上传到 OSS → 服务端拉下来用 FFmpeg 裁剪 → 结果传回 OSS → 前端拿下载链接。一个 500MB 的视频,光上传就要 2 分钟(按 4MB/s 算),服务端处理 30 秒,下载又 1 分钟。

2.2 WASM 方案:ffmpeg.wasm

ffmpeg.wasm 是 FFmpeg 编译到 WebAssembly 的版本,核心能力和原生 FFmpeg 基本一致。关键代码长这样:

import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

const ffmpeg = new FFmpeg();

// 加载 WASM 核心,这一步大概要下载 25MB 左右的 wasm 文件
await ffmpeg.load({
  coreURL: await toBlobURL('/ffmpeg-core.js', 'text/javascript'),
  wasmURL: await toBlobURL('/ffmpeg-core.wasm', 'application/wasm'),
});

// 把用户选择的视频文件写入虚拟文件系统
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile));

// 执行裁剪:从第 60 秒开始,截取 300 秒
await ffmpeg.exec([
  '-i', 'input.mp4',
  '-ss', '60',
  '-t', '300',
  '-c', 'copy',    // 关键:不重新编码,直接拷贝流
  'output.mp4'
]);

const data = await ffmpeg.readFile('output.mp4');
const blob = new Blob([data], { type: 'video/mp4' });

这里有个关键点:-c copy 参数。它表示不重新编码,只做流拷贝。视频裁剪、拼接这类不需要重新编码的操作,WASM 的性能完全够用。但如果你要做转码(比如 H.265 转 H.264),浏览器里跑 WASM 会比服务端慢 5-10 倍,这种场景不建议迁移。

2.3 踩坑记录

**坑一:SharedArrayBuffer 的安全限制。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

我们在 Nginx 加了这两个头之后,页面里嵌入的第三方统计脚本全挂了,因为 require-corp 会拦截没有 Cross-Origin-Resource-Policy 头的跨域资源。最后的方案是把 ffmpeg 处理逻辑放到一个单独的 iframe 里,主页面不受影响。排查这个问题花了大半天。

坑二:内存限制。 浏览器里 WASM 的内存上限通常是 2GB-4GB。处理超过 1GB 的视频文件时,虚拟文件系统会把整个文件加载到内存,很容易 OOM。我们的解法是对大文件先在 JS 层做分片,每次只处理一个分片。

2.4 效果对比

同一个 500MB 视频裁剪 5 分钟片段:| 指标 | 服务端方案 | WASM 方案 | |------|-----------|----------| | 总耗时 | 3 分 30 秒 | 45 秒 | | 服务器带宽消耗 | 1GB(上传+下载) | 0 | | 用户体验 | 上传等待+轮询结果 | 本地实时进度条 | | 月均成本 | ~5000 元 | 0 |

45 秒主要花在读取本地文件到内存上,裁剪本身 -c copy 模式下只要几秒。

三、加密运算:隐私合规的刚需场景

3.1 痛点在哪

去年接了一个医疗数据平台的项目,有个硬性要求:患者的身份证号、手机号等敏感字段,在离开浏览器之前必须完成加密,服务端只存密文。甲方的安全团队原话是:"明文不能出浏览器"。

JavaScript 本身有 Web Crypto API,但它只支持标准算法(AES、RSA、SHA 系列)。

用纯 JS 实现 SM2?可以是可以,npm 上有 sm-crypto 这个包,但性能非常拉胯。我们测过,批量加密 1000 条记录(每条包含 3 个敏感字段),纯 JS 版要 8.2 秒,用户能明显感知到页面卡顿。

3.2 WASM 方案:C 语言国密库编译到 WASM

我们选了开源的 GmSSL(C 语言实现),用 Emscripten 编译成 WASM 模块。封装后的调用接口大概是这样:

// wasm_sm_crypto.js —— 封装层
import initWasm from './gmssl.wasm.js';

let wasmInstance = null;

export async function init() {
  wasmInstance = await initWasm();
}

export function sm4Encrypt(plaintext, key) {
  // 把 JS 字符串写入 WASM 线性内存
  const encoder = new TextEncoder();
  const data = encoder.encode(plaintext);
  const keyBytes = hexToBytes(key);

  const dataPtr = wasmInstance._malloc(data.length);
  const keyPtr = wasmInstance._malloc(16);
  const outPtr = wasmInstance._malloc(data.length + 16); // 补齐 padding

  wasmInstance.HEAPU8.set(data, dataPtr);
  wasmInstance.HEAPU8.set(keyBytes, keyPtr);

  // 调用 C 函数
  const outLen = wasmInstance._sm4_cbc_encrypt(
    dataPtr, data.length,
    keyPtr,
    outPtr
  );

  const result = new Uint8Array(
    wasmInstance.HEAPU8.buffer, outPtr, outLen
  );
  const encrypted = bytesToHex(result);

  // 释放内存——这一步千万别忘
  wasmInstance._free(dataPtr);
  wasmInstance._free(keyPtr);
  wasmInstance._free(outPtr);

  return encrypted;
}

这段代码有个容易踩的坑:手动内存管理。WASM 没有 GC,_malloc 了必须 _free,不然内存泄漏。我们早期忘了释放 outPtr,跑了一会儿就 OOM 崩了。后来统一封装了一个 withMemory 的 helper 函数,类似 Go 的 defer,确保作用域结束自动释放。

3.3 性能数据

批量加密 1000 条记录(每条 3 个字段,SM4-CBC 模式),三个方案对比:

纯 JS(sm-crypto)      :8200ms
WASM(GmSSL 编译)      :620ms
服务端(Go + GmSSL)    :45ms + 网络 RTT 约 200ms ≈ 245ms

WASM 比纯 JS 快了 13 倍。虽然绝对性能不如服务端,但 620ms 的延迟在用户提交表单时完全可以接受,而且满足了"明文不出浏览器"的合规要求。

四、数据分析:最容易被低估的方向

4.1 痛点在哪

我们有个运营后台,核心功能是让运营同事导入 Excel(通常 10 万-50 万行),做筛选、分组统计、透视表这些操作。原来的方案是把 Excel 传到服务端,用 Python Pandas 处理完把结果返回。问题有两个:一是每次改个筛选条件就要重新请求服务端,交互延迟很明显(平均 3-4 秒);二是运营同事经常在处理还没确认之前反复调整条件,十几次请求打到后端,白白浪费计算资源。

4.2 WASM 方案:DuckDB-WASM

DuckDB 是一个嵌入式分析型数据库(你可以理解为分析场景的 SQLite),它有官方的 WASM 版本,可以直接在浏览器里跑 SQL。

import * as duckdb from '@duckdb/duckdb-wasm';

// 初始化
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
const worker = new Worker(bundle.mainWorker);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

const conn = await db.connect();

// 直接把前端拿到的 Excel 转成的 CSV/Parquet 注册为表
await db.registerFileBuffer(
  'sales.parquet',
  new Uint8Array(parquetBuffer)
);

// 然后就能直接写 SQL 了
const result = await conn.query(`
  SELECT 
    region,
    product_category,
    SUM(amount) as total_sales,
    COUNT(*) as order_count
  FROM 'sales.parquet'
  WHERE order_date >= '2025-01-01'
  GROUP BY region, product_category
  ORDER BY total_sales DESC
`);

这段代码的亮点在于:你在浏览器里获得了一个完整的 SQL 引擎。

4.3 为什么不用纯 JS 方案

你可能会问,直接用 JS 数组操作 filterreduce 不行吗?10 万行数据在 JS 里 reduce 一下也不慢。

小数据量确实可以。但当数据到了 30 万行以上,差距就出来了。DuckDB 底层是列式存储 + 向量化执行引擎,这两个东西是专门为分析型查询设计的。打个比方:JS 数组遍历是逐行扫描,像你拿着清单一行一行找;DuckDB 的列式引擎是直接把"金额"那一列拎出来批量求和,跳过了所有不相关的列。我们在 30 万行、12 列的数据集上做了对比测试——分组聚合(GROUP BY 2 列,SUM 1 列):

JS Array.reduce()        :1850ms
Lodash _.groupBy()       :2100ms
DuckDB-WASM SQL          :180ms
服务端 Pandas             :95ms + 网络 RTT 800ms895ms

DuckDB-WASM 比纯 JS 快了 10 倍,加上省掉的网络开销,实际体验比走服务端还快。

4.4 数据格式的选择很关键

这里有个容易忽略的点:文件格式对性能影响巨大。同样 30 万行数据:

  • 用 CSV 格式加载到 DuckDB-WASM:解析耗时 1200ms
  • 用 Parquet 格式加载:解析耗时 150ms

差了 8 倍。原因是 Parquet 本身就是列式存储格式,DuckDB 读 Parquet 几乎是零解析成本。所以我们的方案是:Excel 上传后,前端先用 SheetJS 解析成 JSON,再用 parquet-wasm(又一个 WASM 工具)转成 Parquet 格式喂给 DuckDB。虽然转换本身要几百毫秒,但后续每次查询都能享受 Parquet 的性能红利,整体算下来非常划算。

七、决策速查表

判断维度 适合 WASM 适合服务端
数据大小 < 1GB > 1GB
单次计算耗时 < 30 秒 > 30 秒
是否涉及数据库 不涉及 涉及
隐私合规要求 数据不能离开客户端 服务端有合规方案
调用频次 用户频繁交互调整 一次性批处理
网络环境 弱网/离线场景 稳定网络
计算类型 CPU 密集 GPU 密集/需要特殊硬件

我的判断流程是:先看数据能不能出浏览器(合规),再看计算量用户端能不能扛住(性能),最后看开发维护成本是否可接受(ROI)。三个条件都满足,就值得用 WASM。

Vite 插件开发入门:从零写一个自动生成路由的插件

2026年3月26日 09:55

Vite 插件开发入门:从零写一个自动生成路由的插件

上周接手了一个中后台项目,200 多个页面,router/index.ts 写了 1800 行。每次新建页面都得手动往路由表里加一条记录,路径拼错了不报错,组件引用写错了要等构建阶段才能发现。整个团队每周至少因为路由配置出一次线上事故。

Nuxt 和 Next.js 都有基于文件系统的自动路由,Vite 生态里也有 vite-plugin-pages 这类方案。但我们项目的路由规则比较特殊:有权限前缀、有多 layout 嵌套、还有一套自定义的路由元信息约定。现成插件的扩展能力撑不住这些需求,硬改源码的成本比自己写还高。

这篇文章是那次从零开发插件的完整复盘,从最小插件结构讲到 HMR 支持和嵌套路由,踩的坑都会具体说明。写完这个插件之后,我对 Vite 的插件机制理解明显深了一截。

自动路由插件的核心思路

动手写代码之前,先把需求拆解清楚。这个插件要做三件事:扫描 src/pages/ 下的所有 .vue 文件,根据文件路径推导出路由配置,然后让业务代码能通过 import routes from 'virtual:auto-routes' 直接使用生成的路由表。

这三件事分别对应 Vite 插件的三个核心能力:文件监听代码生成虚拟模块

虚拟模块是怎么工作的

虚拟模块是 Vite 插件开发中最常用的模式。所谓"虚拟",指的是这个模块不存在于磁盘上,它的内容由插件在运行时动态生成。业务代码写 import routes from 'virtual:auto-routes',其实磁盘上根本没有这个文件——是插件在 resolveIdload 两个钩子里"凭空捏造"了它。

const VIRTUAL_MODULE_ID = 'virtual:auto-routes'
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID

export default function autoRoutes(): Plugin {
  return {
    name: 'vite-plugin-auto-routes',
    resolveId(id) {
      if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
    },
    load(id) {
      if (id === RESOLVED_ID) {
        return `export default [{ path: '/', component: () => import('/src/pages/index.vue') }]`
      }
    }
  }
}

resolveId 负责"认领"模块 ID,load 负责返回模块内容,两者是固定搭配。那个 \0 前缀是 Rollup 的约定:以 \0 开头的模块 ID 不会被文件系统解析,其他插件看到这个前缀也会主动跳过,避免冲突。

我第一次写的时候忘了加 \0 前缀,结果在 vite-plugin-inspect 里死活看不到虚拟模块的输出,排查了大半个小时才在 Rollup 文档里翻到这条约定。

上面这个硬编码的例子只是为了演示虚拟模块的最小结构。接下来要做的事情才是插件的核心——扫描文件、生成路由表、处理嵌套关系。

扫描文件并转换为路由路径

第一步是用 fast-glob 扫描 src/pages/ 目录下所有 .vue 文件,然后把文件路径转换成路由 path。转换规则和 Nuxt 类似:index.vue 对应 /[id].vue 对应 /:id[...all].vue 对应 /:all(.*)*

import fg from 'fast-glob'
import path from 'path'

function scanPages(pagesDir: string) {
  const files = fg.sync('**/*.vue', {
    cwd: pagesDir,
    onlyFiles: true,
    ignore: ['**/components/**', '**/_*'],  // 排除组件目录和下划线前缀文件
  })

  return files.map(file => {
    // 统一为 posix 路径
    const filePath = file.replace(/\\/g, '/')
    // 去掉 .vue 后缀
    let routePath = filePath.replace(/\.vue$/, '')
    // index 文件映射为目录路径
    routePath = routePath.replace(/\/index$/, '') || '/'
    // [param] -> :param(动态路由)
    routePath = routePath.replace(/\[([^\]\.]+)\]/g, ':$1')
    // [...param] -> :param(.*)*(兜底路由)
    routePath = routePath.replace(/\[\.\.\.([^\]]+)\]/g, ':$1(.*)*')
    // 确保以 / 开头
    if (!routePath.startsWith('/')) routePath = '/' + routePath

    return {
      filePath: path.posix.join(pagesDir, filePath),
      routePath,
      rawFile: filePath,
    }
  })
}

举个具体例子,假设 src/pages/ 下有这些文件:

src/pages/
├── index.vue              → /
├── about.vue              → /about
├── users/
│   ├── index.vue          → /users
│   ├── [id].vue           → /users/:id
│   └── [id]/
│       └── settings.vue   → /users/:id/settings
└── [...404].vue           → /:404(.*)*

构建嵌套路由树

扫描得到的是一个扁平列表,但 Vue Router 需要的是树形结构——/users/:id/settings 应该嵌套在 /users/:id 下面,而 /users/:id 又嵌套在 /users 下(前提是 users/ 目录下有对应的 layout 文件)。

嵌套路由的判定规则是:如果一个路径存在同名目录,该目录下的文件就成为它的子路由。比如 users.vueusers/ 目录同时存在时,users/ 下的所有页面就是 users.vuechildren

interface RouteNode {
  path: string
  component?: string
  children: RouteNode[]
  meta?: Record<string, any>
}

function buildRouteTree(pages: ReturnType<typeof scanPages>): RouteNode[] {
  const root: RouteNode[] = []
  // 按路径深度排序,确保父路由先被处理
  const sorted = [...pages].sort((a, b) => {
    const depthA = a.routePath.split('/').length
    const depthB = b.routePath.split('/').length
    return depthA - depthB
  })

  // 用 Map 记录已注册的路由节点,key 是 routePath
  const nodeMap = new Map<string, RouteNode>()

  for (const page of sorted) {
    const node: RouteNode = {
      path: page.routePath,
      component: page.filePath,
      children: [],
    }

    // 查找父路由:逐级向上寻找同名 layout 文件
    const segments = page.routePath.split('/').filter(Boolean)
    let inserted = false

    if (segments.length > 1) {
      // 从最近的父级开始向上查找
      for (let i = segments.length - 1; i >= 1; i--) {
        const parentPath = '/' + segments.slice(0, i).join('/')
        const parentNode = nodeMap.get(parentPath)
        if (parentNode) {
          // 子路由的 path 只保留相对部分
          node.path = segments.slice(i).join('/')
          parentNode.children.push(node)
          inserted = true
          break
        }
      }
    }

    if (!inserted) {
      root.push(node)
    }
    nodeMap.set(page.routePath, node)
  }

  return root
}

这里有一个容易踩的坑:排序必须保证父路由先于子路由被处理,否则子路由找不到父节点,会被错误地挂到根级别。我最初用字母序排序,结果 users.vue 排在 users/index.vue 后面,整棵子树都散架了。

把路由树序列化为模块代码

拿到路由树之后,需要把它序列化成 JavaScript 代码字符串,作为虚拟模块的内容返回:

function generateRouteCode(routes: RouteNode[]): string {
  function serialize(node: RouteNode): string {
    const parts: string[] = []
    parts.push(`path: '${node.path}'`)
    if (node.component) {
      parts.push(`component: () => import('${node.component}')`)
    }
    if (node.meta && Object.keys(node.meta).length > 0) {
      parts.push(`meta: ${JSON.stringify(node.meta)}`)
    }
    if (node.children.length > 0) {
      parts.push(`children: [${node.children.map(serialize).join(',\n')}]`)
    }
    return `{ ${parts.join(', ')} }`
  }

  return `export default [${routes.map(serialize).join(',\n')}]`
}

HMR 支持:文件变化时自动更新路由

开发阶段最重要的体验就是新增或删除页面文件后路由自动更新,不需要手动重启 dev server。这需要用到 configureServerhandleHotUpdate 两个钩子。

export default function autoRoutes(options: { pagesDir?: string } = {}): Plugin {
  const pagesDir = options.pagesDir || 'src/pages'
  let rootDir: string

  // 缓存当前路由代码,用于判断是否真的有变化
  let currentRouteCode: string

  function regenerateRoutes() {
    const pages = scanPages(path.resolve(rootDir, pagesDir))
    const tree = buildRouteTree(pages)
    const sorted = sortRoutes(tree)  // 排序逻辑见下文
    return generateRouteCode(sorted)
  }

  return {
    name: 'vite-plugin-auto-routes',

    configResolved(config) {
      rootDir = config.root
    },

    resolveId(id) {
      if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
    },

    load(id) {
      if (id === RESOLVED_ID) {
        currentRouteCode = regenerateRoutes()
        return currentRouteCode
      }
    },

    // 监听 pages 目录下的文件变化
    configureServer(server) {
      const pagesFullPath = path.resolve(rootDir, pagesDir)

      function handleFileChange(filePath: string) {
        if (!filePath.startsWith(pagesFullPath)) return
        if (!filePath.endsWith('.vue')) return

        const newCode = regenerateRoutes()
        // 只有路由表真正变化时才触发更新,避免无意义的刷新
        if (newCode === currentRouteCode) return
        currentRouteCode = newCode

        const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
        if (mod) {
          server.moduleGraph.invalidateModule(mod)
          server.ws.send({ type: 'full-reload' })
        }
      }

      server.watcher.on('add', handleFileChange)
      server.watcher.on('unlink', handleFileChange)
    },

    // .vue 文件内容变化时,检查 <route> 块是否有修改
    handleHotUpdate({ file, server }) {
      const pagesFullPath = path.resolve(rootDir, pagesDir)
      if (!file.startsWith(pagesFullPath) || !file.endsWith('.vue')) return

      const newCode = regenerateRoutes()
      if (newCode === currentRouteCode) return
      currentRouteCode = newCode

      const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
      if (mod) {
        server.moduleGraph.invalidateModule(mod)
        server.ws.send({ type: 'full-reload' })
      }
    },
  }
}

configureServer 里监听 addunlink 事件处理文件新增和删除;handleHotUpdate 处理文件内容修改(比如 <route> 块里的元信息变了)。两处都做了 newCode === currentRouteCode 的比对——这个判断很关键,没有它的话,任何 .vue 文件的修改都会触发路由模块更新,进而导致全页面 reload,HMR 的细粒度更新优势就全丢了。

解析权限前缀:文件名到路由元信息的映射

前面提到我们项目有一套权限路由命名约定:文件名前缀用 . 分隔,第一段是权限组标识。比如 admin.user-list.vue 表示这个页面属于 admin 权限组,路由路径是 /user-list,同时路由的 meta 里会自动注入 { auth: 'admin' }

这套约定让我们的权限路由完全由文件名驱动,不需要在每个页面里手写 meta,新人建页面时也不容易漏配权限。

function parseFileName(rawFile: string): { routeName: string; meta: Record<string, any> } {
  // rawFile 示例: 'admin.user-list.vue' 或 'dashboard/admin.stats.vue'
  const basename = rawFile.split('/').pop()!.replace(/\.vue$/, '')
  const segments = basename.split('.')

  // 已知的权限组前缀列表,可以从配置文件读取
  const knownAuthGroups = ['admin', 'editor', 'viewer', 'super']

  const meta: Record<string, any> = {}
  let routeName = basename

  if (segments.length > 1 && knownAuthGroups.includes(segments[0])) {
    meta.auth = segments[0]
    // 路由路径只取前缀之后的部分
    routeName = segments.slice(1).join('.')
  }

  return { routeName, meta }
}

实际效果:

文件名 路由路径 meta.auth
admin.user-list.vue /user-list admin
editor.article-edit.vue /article-edit editor
dashboard.vue /dashboard (无前缀,不注入)
admin.settings.vue /settings admin

然后在 scanPages 里调用这个函数,把解析出的 meta 附加到每条路由上:

// 在 scanPages 的 map 回调末尾
const { routeName, meta } = parseFileName(filePath)
return {
  filePath: path.posix.join(pagesDir, filePath),
  routePath: routeName.startsWith('/') ? routeName : '/' + routeName.replace(/\./g, '/'),
  rawFile: filePath,
  meta,
}

路由守卫那边只需要统一检查 to.meta.auth,不用关心权限信息从哪来。整条链路从"建文件"到"鉴权生效"完全自动化。

解析 <route> 自定义块

除了文件名约定,有些路由元信息确实更适合写在 .vue 文件里,比如页面标题、是否缓存、面包屑配置等。我们支持在 .vue 文件中使用 <route> 自定义块来声明这些信息:

<route>
{
  "title": "用户详情",
  "cache": true,
  "breadcrumb": ["用户管理", "用户详情"]
}
</route>

<template>
  <div>用户详情页</div>
</template>

在插件中提取 <route> 块的内容,需要读取 .vue 文件源码并解析:

import fs from 'fs'

function extractRouteBlock(filePath: string): Record<string, any> | null {
  const content = fs.readFileSync(filePath, 'utf-8')
  // 匹配 <route> 块,支持 <route lang="json"> 写法
  const match = content.match(/<route(?:\s[^>]*)?>([^]*?)<\/route>/)
  if (!match) return null

  const raw = match[1].trim()
  if (!raw) return null

  try {
    return JSON.parse(raw)
  } catch (e) {
    console.warn(`[auto-routes] Failed to parse <route> block in ${filePath}:`, e)
    return null
  }
}

然后在路由生成阶段合并两种来源的 meta——文件名前缀提供权限信息,<route> 块提供页面级配置,两者合并后写入路由的 meta 字段:

// 在 buildRouteTree 或 scanPages 中
const fileNameMeta = parseFileName(page.rawFile).meta
const routeBlockMeta = extractRouteBlock(page.filePath)
const mergedMeta = { ...fileNameMeta, ...routeBlockMeta }
// routeBlockMeta 的优先级更高,可以覆盖文件名前缀的约定

最终生成的路由对象类似:

{
  path: '/user-list',
  component: () => import('/src/pages/admin.user-list.vue'),
  meta: { auth: 'admin', title: '用户列表', cache: true }
}

踩坑记录

Windows 路径分隔符

fast-glob 返回的路径统一用 / 分隔,但 path.resolve 在 Windows 上会生成 \ 分隔的路径。如果生成的 import 语句里混入了反斜杠,Vite 直接无法解析模块,页面白屏。

解决方法是所有拼接出来的路径都过一遍 p.replace(/\\\\/g, '/')

路由排序影响匹配优先级

Vue Router 4 的匹配规则是先定义先匹配。如果 /:id 排在 /profile 前面,访问 /profile 时会命中 /:id,参数 id 的值变成字符串 "profile",页面渲染出完全错误的内容。

插件生成路由时的排序逻辑必须保证三个层级:静态路由最先,动态路由其次,兜底路由(包含 (.*) 的)排最后。同一层级内按字母序排列,确保结果稳定可预期。

function sortRoutes(routes: RouteNode[]): RouteNode[] {
  return routes
    .map(route => ({
      ...route,
      children: route.children.length > 0 ? sortRoutes(route.children) : [],
    }))
    .sort((a, b) => {
      const scoreA = getRouteScore(a.path)
      const scoreB = getRouteScore(b.path)
      if (scoreA !== scoreB) return scoreA - scoreB
      // 同级别按字母序,确保排序稳定
      return a.path.localeCompare(b.path)
    })
}

function getRouteScore(path: string): number {
  // 兜底路由排最后
  if (path.includes('(.*)')) return 2
  // 动态路由排中间
  if (path.includes(':')) return 1
  // 静态路由排最前
  return 0
}

实际遇到的一个坑:我们有 /users/profile/users/:id 两个路由,上线后发现所有用户的个人资料页都 404 了——因为最初的排序函数没有递归处理 children,只排了顶层路由,嵌套路由里的顺序完全随机。加上递归排序后问题解决。

开发环境和构建环境的行为差异

开发环境下,虚拟模块的 load 钩子在每次模块请求时都会调用,返回的路由表始终是最新的。构建时 load 只调用一次,结果会被缓存。

这个差异导致了一个隐蔽的 bug:我有一版实现会在 load 里生成一个 .routes.json 缓存文件用于调试,开发环境下每次 HMR 触发都会重写这个文件,文件变化又被 watcher 捕获,再次触发 HMR——形成无限循环,页面疯狂刷新停不下来。把调试文件的输出逻辑从 load 钩子里挪出来,改成手动调用,问题就消失了。

和现有方案的对比

维度 vite-plugin-pages unplugin-vue-router 自己写
路由元信息 <route> 块,YAML/JSON definePage <route> 块,JSON + 文件名前缀
类型安全 需要额外配置 开箱即用,类型自动推导 手动声明 .d.ts
自定义路由规则 有限,靠 extendRoute 回调 较灵活 完全自由
嵌套路由 支持 支持 需要自己实现
维护成本 社区维护 社区维护,迭代更活跃 团队自己维护
包体积影响 ~15KB ~25KB ~3KB(只有核心逻辑)

如果你的项目路由规则比较标准,unplugin-vue-router 是目前社区最推荐的选择,类型推导的开发体验确实好。我们自己写是因为有一套权限路由命名约定——页面文件名的前缀代表权限组(比如 admin.user-list.vue 属于 admin 权限组),这套规则在现有插件里没法直接表达。

落地效果

插件上线两周后做了一次回顾。

指标 改造前 改造后
路由配置文件行数 1800 行 15 行
新建页面耗时 ~3 分钟 ~30 秒
路由相关线上事故(周均) 1.2 次 0 次
路由配置 CR 耗时 每次 ~10 分钟 基本不需要

最直观的反馈来自团队里的新人同事——他入职第一天就按照文件命名规范新建了一个页面,路由自动生成、权限自动挂载,全程没有碰过 router/index.ts。之前的入职文档里有整整一页是在讲"如何正确添加路由配置",现在这页直接删了。

通用经验

从这个插件的开发过程里可以提炼出三个通用模式,覆盖了绝大多数 Vite 插件的使用场景。

虚拟模块模式适合往项目里注入运行时数据——路由表、环境变量、自动导入的模块清单都属于这一类。resolveId + load 是固定搭配,\0 前缀不能省。

代码转换模式transform 钩子,用于修改已有模块的源码,比如给组件自动注入 import 语句、为 JSX 添加编译提示。

开发服务器增强模式configureServer 钩子,适合需要添加自定义中间件或者 WebSocket 通信的场景,mock 服务和组件预览面板都是典型用例。

如果你想动手试试,建议从最简单的虚拟模块入手——写一个把 package.json 的版本号注入到运行时的小插件,十几行代码就能跑通,用来理解钩子的调用流程刚刚好。等虚拟模块的机制摸熟了,再往上叠文件监听和 HMR 支持。路由排序和嵌套路由的树构建放到最后处理,这两块的边界条件最多,一上来就啃容易卡住。

昨天以前首页

AbortController 实战:竞态取消、超时兜底与请求生命周期管理

2026年3月25日 09:54

AbortController 实战:竞态取消、超时兜底与请求生命周期管理

项目越大,请求越多,Bug 越诡异。 你一定见过这些场景:

  • 搜索结果偶尔“闪一下又变回旧数据”
  • 提交按钮点快了,后台多出几条脏数据
  • 页面都切走了,接口还在跑,甚至回来还触发 setState warning

这些问题看起来毫无规律,但本质上只是一件事:

请求没有被正确“结束”。

大部分团队会优化接口、加缓存、做防抖,却很少有人认真思考:

一个请求,什么时候应该继续?什么时候必须终止?

AbortController 应该在项目初期就被当作基础设施来搭建,而不是等线上出了问题才到处打补丁。这篇文章是我踩完所有坑之后的经验沉淀,把竞态取消、超时控制、组件卸载清理这几个场景串起来,给出一套在 React 和 Vue 中都能落地的防御性编排方案。

竞态取消:搜索场景的三种方案对比

竞态问题是异步请求里最常见也最容易被忽视的坑。回到搜索框的例子,用户快速输入,多个请求并发,我们只关心最后一次的结果。怎么确保展示的一定是最新请求的数据?

方案一:标记法(能用,但粗糙)

最朴素的思路——给每次请求打一个版本号,回调里检查是不是最新版本。

let currentRequestId = 0

async function search(keyword: string) {
  const requestId = ++currentRequestId
  const res = await fetch(`/api/search?q=${keyword}`)
  const data = await res.json()

  if (requestId === currentRequestId) {
    setResults(data) // 版本号匹配才更新 UI
  }
}

实现简单、零依赖,但有一个明显的问题:请求并没有被真正取消。"杭"的请求还是跑完了全部流程,占了带宽和连接池,只是回调里没处理结果而已。我们项目初期就是用的这个方案,当时觉得"能用就行"。后来在性能分析里发现,搜索页面在快速输入时,Network 面板里密密麻麻全是 pending 请求,Chrome 的同域 6 连接上限直接被打满,导致其他关键请求(比如用户鉴权、埋点上报)被阻塞。

方案二:纯 AbortController(真正取消请求)

标记法的核心缺陷是请求仍然在跑,只是忽略了结果。AbortController 可以从网络层真正中断请求,释放连接。

let currentController: AbortController | null = null

async function search(keyword: string) {
  currentController?.abort() // 取消上一个请求
  const controller = new AbortController()
  currentController = controller

  try {
    const res = await fetch(`/api/search?q=${keyword}`, {
      signal: controller.signal
    })
    const data = await res.json()
    setResults(data)
  } catch (err) {
    if ((err as DOMException).name !== 'AbortError') throw err
    // AbortError 说明是我们主动取消的,静默忽略
  }
}

相比标记法,abort() 调用后浏览器会立即中断 TCP 连接(或阻止请求发出),被取消的请求在 Network 面板中会显示为 (canceled) 状态,不再占用同域的 6 个并发连接。缺点是在高频输入场景下,每次按键都会发出一个请求然后立即取消上一个,虽然连接被释放了,但请求的绝对数量仍然很多,服务端压力并没有减轻。所以对于搜索框这类场景,还需要配合防抖进一步优化。

方案三:防抖 + AbortController(生产环境的选择)

单纯的 AbortController 取消还不够,还需要配合防抖来减少请求频率。这里有个容易搞错的地方:防抖和取消的职责不一样,不能互相替代。防抖解决的是"减少发出的请求数量",取消解决的是"已经发出的请求不再需要了"。两个要一起用。

function useDebouncedSearch(delay = 300) {
  const controllerRef = useRef<AbortController | null>(null)
  const timerRef = useRef<number>()
  const [results, setResults] = useState([])

  const search = useCallback((keyword: string) => {
    clearTimeout(timerRef.current) // 清掉防抖定时器

    timerRef.current = window.setTimeout(async () => {
      controllerRef.current?.abort() // 取消上一个还在飞的请求
      const controller = new AbortController()
      controllerRef.current = controller

      try {
        const res = await fetch(`/api/search?q=${keyword}`, {
          signal: controller.signal
        })
        setResults(await res.json())
      } catch (err) {
        if ((err as DOMException).name !== 'AbortError') throw err
      }
    }, delay)
  }, [delay])

  useEffect(() => () => {
    clearTimeout(timerRef.current)
    controllerRef.current?.abort()
  }, [])

  return { results, search }
}

防抖定时器和 AbortController 各管各的:防抖控制"什么时候发请求",AbortController 控制"已经发出的请求要不要保留"。组件卸载时两个都要清理,缺一不可。我们项目最终落地的就是这个方案。改完之后,搜索页面的无效请求从平均每次搜索 5-6 个降到了 0-1 个,Network 面板终于清爽了。

三种方案放在一起对比:

方案 请求真正取消 实现复杂度 适用场景
标记法 否,幽灵请求仍占连接 小项目、低频请求
AbortController 是,网络层中断 大多数场景
防抖 + AbortController 是,且减少请求频次 搜索、筛选等高频输入场景

React 中的异步副作用编排

useEffect 中的正确姿势

function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState(null)

  useEffect(() => {
    const controller = new AbortController()

    async function loadProfile() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        })
        setProfile(await res.json())
      } catch (err) {
        if ((err as DOMException).name === 'AbortError') return
        console.error('加载用户信息失败:', err)
      }
    }
    loadProfile()

    return () => controller.abort()
  }, [userId])

  return <div>{profile?.name}</div>
}

这段代码看起来简单,有两个细节容易踩坑。

AbortController 的创建必须在 useEffect 内部。因为每次 effect 执行需要一个独立的 controller 实例,放外面会被多个 effect 共享,取消逻辑就乱了。

async 函数不能直接作为 useEffect 的回调——effect 要求返回 cleanup 函数或 undefined,不能返回 Promise。所以需要在内部定义一个 async 函数再调用。这个限制看起来别扭,但它强制你把"发请求"和"清理"分开思考,反而减少了遗漏 cleanup 的概率。

封装通用的 useAbortableFetch

当团队有十几个页面都需要这种模式时,重复写 AbortController 的样板代码就不合适了。我们封装了一个自定义 Hook:

function useAbortableFetch<T>(url: string | null, options?: { timeout?: number }) {
  const [state, setState] = useState<{
    data: T | null; loading: boolean; error: Error | null
  }>({ data: null, loading: false, error: null })

  useEffect(() => {
    if (!url) return

    const controller = new AbortController()
    const { timeout = 10000 } = options ?? {}
    const signal = AbortSignal.any
      ? AbortSignal.any([controller.signal, AbortSignal.timeout(timeout)])
      : controller.signal

    setState(prev => ({ ...prev, loading: true, error: null }))

    fetch(url, { signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name === 'AbortError' || err.name === 'TimeoutError') return
        setState({ data: null, loading: false, error: err })
      })

    return () => controller.abort()
  }, [url])

  return state
}

url 为 null 时不发请求,方便做条件请求;url 变了自动重新请求,旧请求自动取消;超时和手动取消合并在一个信号里处理。用起来非常干净:

function SearchPage() {
  const [keyword, setKeyword] = useState('')
  const debouncedKeyword = useDebounce(keyword, 300)

  const { data, loading, error } = useAbortableFetch<SearchResult[]>(
    debouncedKeyword ? `/api/search?q=${debouncedKeyword}` : null
  )

  return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}

手动触发场景的处理

表单提交、批量操作这类请求的特殊之处在于:取消的触发时机不是依赖变化,而是"重复操作"或"组件卸载"。

function useAbortableAction<T>() {
  const controllerRef = useRef<AbortController | null>(null)

  const execute = useCallback(async (
    asyncFn: (signal: AbortSignal) => Promise<T>
  ): Promise<T | undefined> => {
    controllerRef.current?.abort() // 新操作来了,取消上一个(防连点)
    const controller = new AbortController()
    controllerRef.current = controller

    try {
      return await asyncFn(controller.signal)
    } catch (err) {
      if ((err as DOMException).name === 'AbortError') return undefined
      throw err
    }
  }, [])

  useEffect(() => () => { controllerRef.current?.abort() }, [])
  return execute
}

使用时,把 signal 透传给请求函数即可:

const executeAction = useAbortableAction()

const handleSubmit = async (formData: OrderData) => {
  const result = await executeAction(signal =>
    fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify(formData),
      signal
    }).then(r => r.json())
  )
  if (result) navigate(`/orders/${result.id}`)
}

这里有个需要权衡的地方:POST 请求真的应该取消吗?连点两次提交按钮,取消第一个 POST 请求——网络层面是中断了,但后端可能已经处理了一半。所以对于写操作,AbortController 更多是解决"组件卸载后不再处理回调"的问题,而不是真的指望后端能回滚。防重复提交还是要靠按钮 loading 状态锁定 + 后端幂等校验。

Vue 中的 Composable 实现

在 Vue 中,watchEffect 提供的 onCleanup 回调天然适合管理请求生命周期,写起来比 React 的 useEffect 更直观。下面是与前文 useAbortableFetch 对等的 Vue Composable 实现:

import { ref, watchEffect, toValue, type Ref, type MaybeRefOrGetter } from 'vue'

function useFetchData<T>(url: MaybeRefOrGetter<string | null>, options?: { timeout?: number }) {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<Error | null>(null)

  watchEffect((onCleanup) => {
    const resolvedUrl = toValue(url)
    if (!resolvedUrl) {
      data.value = null
      loading.value = false
      return
    }

    const controller = new AbortController()
    const { timeout = 10000 } = options ?? {}

    // 注册清理函数:依赖变化或组件卸载时自动调用
    onCleanup(() => controller.abort())

    loading.value = true
    error.value = null

    const timeoutId = setTimeout(() => controller.abort(), timeout)

    fetch(resolvedUrl, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(json => {
        data.value = json
        loading.value = false
      })
      .catch(err => {
        if (err.name === 'AbortError') return // 主动取消,静默忽略
        error.value = err
        loading.value = false
      })
      .finally(() => clearTimeout(timeoutId))
  })

  return { data, loading, error }
}

使用方式同样干净,响应式的 URL 变化会自动触发重新请求并取消旧请求:

<script setup lang="ts">
import { ref, computed } from 'vue'

const keyword = ref('')
const debouncedKeyword = useDebouncedRef(keyword, 300) // 假设已有防抖 ref 工具
const apiUrl = computed(() =>
  debouncedKeyword.value ? `/api/search?q=${debouncedKeyword.value}` : null
)

const { data: results, loading, error } = useFetchData<SearchResult[]>(apiUrl)
</script>

<template>
  <input v-model="keyword" />
  <div v-if="loading">搜索中...</div>
  <ul v-else-if="results">
    <li v-for="item in results" :key="item.id">{{ item.title }}</li>
  </ul>
</template>

Vue 的 onCleanup 和 React 的 useEffect return 做的是同一件事,但 Vue 的写法有个优势:onCleanup 在 effect 函数体内调用,和创建 AbortController 的代码紧挨着,不容易遗漏。React 里 cleanup 写在函数末尾的 return 里,和请求代码隔得比较远,review 时容易看漏。

边界场景与防御性思维

并发请求的批量取消

页面初始化时可能要同时发五六个请求,用户切走了要一次性全取消。核心技巧是共享一个 signal,配合 Promise.allSettled 处理结果:

useEffect(() => {
  const controller = new AbortController()

  Promise.allSettled([
    fetch('/api/user/info', { signal: controller.signal }).then(r => r.json()),
    fetch('/api/user/permissions', { signal: controller.signal }).then(r => r.json()),
    fetch('/api/dashboard/stats', { signal: controller.signal }).then(r => r.json()),
  ]).then(results => {
    const [userResult, permResult, statsResult] = results

    if (userResult.status === 'fulfilled') {
      setUserInfo(userResult.value)
    }
    if (permResult.status === 'fulfilled') {
      setPermissions(permResult.value.permissions)
    }
    if (statsResult.status === 'fulfilled') {
      setDashboardStats(statsResult.value)
    }
  })

  return () => controller.abort()
}, [])

为什么用 Promise.allSettled 而不是 Promise.all?因为 Promise.all 在任何一个请求 reject 时就会整体 reject,而 abort 会导致所有请求同时 reject,你拿不到任何有用信息。allSettled 等所有请求都有结果后才 resolve,让你能精细地处理每个请求——哪些成功了用数据,哪些被取消了忽略,哪些真正失败了需要报错。

SSR 和 Node.js 环境

如果你的项目有 SSR(Next.js、Nuxt),请求取消在服务端同样重要。Node.js 18+ 的 fetch 原生支持 AbortController,但服务端的超时策略需要比客户端更激进——SSR 请求阻塞的是页面渲染,用户在白屏面前的耐心远低于面对 loading 动画:

async function getServerSideProps() {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), 3000) // SSR 超时建议 3-5 秒

  try {
    const res = await fetch('http://internal-api/data', {
      signal: controller.signal
    })
    clearTimeout(timer)
    return { props: { data: await res.json() } }
  } catch {
    clearTimeout(timer)
    return { props: { data: null } } // 超时降级,先渲染页面骨架
  }
}

在 Node.js 环境还要注意一点:没有浏览器的 6 连接上限,但有内存泄漏风险。如果请求没有超时控制,在高并发时挂起的请求会持续占用内存,最终可能 OOM。

不适用的场景

AbortController 不是银弹,有几种场景不适合或者需要特殊处理。

WebSocket 连接有自己的生命周期管理(close()),不需要也不能用 AbortController。写操作的取消要谨慎——POST/PUT/DELETE 请求,前端取消了但后端可能已经处理了,关键写操作的幂等性要在后端保证。**流式响应(SSE / ReadableStream)**虽然技术上可以用 abort() 中断,但要区分场景:AI 对话场景下用户点"停止生成",abort() 是合理的;大文件分片上传中途取消,断点续传的状态恢复逻辑需要额外处理,单靠 abort() 解决不了。

从取消到编排:一个通用模型

回顾全文的内容,请求取消只是一个切入点,背后的通用模型是异步操作的生命周期管理。任何异步操作——请求、定时器、动画、Web Worker 通信——都应该具备三个能力:启动、取消、超时。缺了任何一个,在项目规模变大后都会出问题。这个模型可以这样理解:

异步操作生命周期:

  启动 ──→ 运行中 ──→ 成功 / 失败
    │          │
    │          ├── 手动取消(用户操作 / 依赖变化 / 组件卸载)
    │          │
    │          └── 超时取消(兜底机制)
    │
    └── 创建即取消(signal 已 aborted,立即中止)

在 React 中,这个生命周期对应的是 useEffect 的"执行-清理"周期;在 Vue 中,是 watchEffect 的"执行-onCleanup"周期。框架不同,模型一致。

如果你正在做的项目还没有统一的请求生命周期管理,我的建议是分三步推进。第一步,在请求层封装一个带超时和取消能力的基础函数(类似前面的 createManagedSignal)。第二步,在框架层封装对应的 Hook / Composable(类似 useAbortableFetchuseFetchData),让业务代码不需要直接接触 AbortController。第三步,在 code review 和 CI 中把"有没有处理取消"作为一个检查项。

我们团队落地这套方案之后,做了一次前后数据对比:

指标 改造前 改造后
搜索场景无效请求数(每次搜索) 5-6 个 0-1 个
超时相关客服工单(每周) 10+ 1-2
页面切换后的 setState warning 频繁出现 完全消除
请求层代码重复率 每个页面各写一套 统一收口到 2 个 Hook/Composable
CI 自定义 lint 规则上线首周拦截的遗漏 14 处

现在我们的标准是:每个 useEffect / watchEffect 里如果有 fetch 调用,必须在 cleanup 里调用 abort(),否则 CI 的自定义 lint 规则会报错。这条规则的 ROI 极高——写规则花了半天,上线一周就拦住了 14 处遗漏,每一处都是潜在的线上 bug。

回头看,请求生命周期管理这件事并不复杂。AbortController 的 API 就那么几个,封装成 Hook / Composable 也不超过 30 行代码。真正难的是在项目早期就意识到它的重要性,把它作为基础设施搭好,而不是等线上出了问题才到处救火。

Nginx 反向代理 WebSocket 和 SSE 的踩坑

2026年3月24日 13:49

Nginx 反向代理 WebSocket 和 SSE 的踩坑

项目上了 Nginx 反向代理之后,HTTP 接口全部正常,WebSocket 却连不上,SSE 推送也收不到消息。控制台没有报错,Network 面板看着像是连上了,数据就是不过来。先给结论:WebSocket 和 SSE 都不是标准的 HTTP 请求-响应模型,Nginx 默认配置会把它们当成普通 HTTP 处理,要么握手失败,要么连接被提前关掉。 两者的解法不同,不能混为一谈。

WebSocket 反向代理:三行配置解决 90% 的问题

最小可用配置只需要三行,把 UpgradeConnection 头透传给后端:

location /ws {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

proxy_http_version 1.1 这行容易被忽略。Nginx 代理后端时默认用 HTTP/1.0,而 HTTP/1.0 压根不支持 Upgrade 机制,所以必须显式声明。$http_upgrade 是 Nginx 内置变量,值就是客户端发来的 Upgrade 头内容。

配好之后可以用 wscat 快速验证:通过 Nginx 代理地址连接 wscat -c ws://your-domain.com/ws,能发消息能收消息就说明配置生效了。

超时问题:连上了但过一会儿自动断

大部分人配好 WebSocket 之后会遇到第二个坑——连接建立了,过 60 秒没有数据传输就自动断开。

原因是 Nginx 的 proxy_read_timeout 默认 60 秒。对普通 HTTP 请求来说,60 秒没响应大概率是后端挂了,断开合理。但 WebSocket 连接可能几分钟才有一次消息,60 秒的超时就太短了。一个直接的做法是把 proxy_read_timeoutproxy_send_timeout 调到 3600 秒,但这不是最优解。更靠谱的做法是让应用层做心跳保活——WebSocket 协议本身支持 Ping/Pong 帧,服务端每 30 秒发一个 ws.ping(),超时计时器就会被重置。这样 proxy_read_timeout 保持默认 60 秒都行,还能及时检测到真正的死连接。无脑调大超时反而会让死连接长时间占用资源。

下面是 Node.js 服务端心跳的核心逻辑,每 30 秒向所有活跃连接发送协议级 Ping 帧,客户端会自动回复 Pong,Nginx 感知到数据传输就不会断连:

const wss = new WebSocket.Server({ port: 3000 });
wss.on('connection', (ws) => {
    const heartbeat = setInterval(() => {
        if (ws.readyState === ws.OPEN) ws.ping();
    }, 30000);
    ws.on('close', () => clearInterval(heartbeat));
});

Connection 头的条件判断

有些教程把 Connection 头写死为 "upgrade",如果这个 location 只处理 WebSocket 请求没问题。但如果普通 HTTP 和 WebSocket 请求共用同一个路径前缀,写死就容易出事——我在一个项目里踩过这个坑,前端 fetch 请求和 WebSocket 用了同一个路径前缀 /api,写死 Connection "upgrade" 导致普通接口偶尔返回 502。

解决方案是在 http 块里用 map 做条件判断:当客户端请求携带 Upgrade 头时,Connection 设为 upgrade;普通请求没有该头,则回退为 close。这样同一个 location 就能同时服务 WebSocket 和普通 HTTP 请求:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /api {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

SSE 反向代理:关缓冲、关压缩、调超时

SSE 看起来比 WebSocket 简单——毕竟就是个长连接的 HTTP 响应,不涉及协议升级。但 Nginx 对 SSE 的干扰点更多,也更隐蔽。

第一坑:proxy_buffering 吃掉实时性

这是 SSE 最常见的坑。Nginx 默认开启 proxy_buffering,会把后端的响应数据攒到缓冲区,攒够一定量(默认 4K 或 8K,取决于系统页大小)才发给客户端。普通接口无所谓,SSE 要的就是"服务端写一条、客户端立刻收到一条",缓冲直接破坏了实时性。

表现很有迷惑性:连接建立成功,后端日志显示事件已发送,但前端 EventSourceonmessage 迟迟不触发,过几秒突然一口气收到一堆消息。排查时抓包看 Nginx 到客户端的响应,会发现数据是批量到达的而非逐条到达。

解法很简单,在 SSE 的 location 里关闭代理缓冲,同时关闭 proxy_cache 防止响应被缓存:

location /sse {
    proxy_pass http://backend:3000;
    proxy_buffering off;
    proxy_cache off;
}

第二坑:gzip 压缩阻塞数据流

如果全局开了 gzip on,SSE 的数据流也会被压缩。gzip 算法需要攒够一定量的数据才能输出一个压缩块,效果和 proxy_buffering 一样——消息被攒着了。

这个坑隐蔽得很。我曾经在一个内部监控系统(Nginx 1.22 + Node.js 18)上排查 SSE 延迟,proxy_buffering 早就关了,后端日志确认消息已发出,但前端就是 3-5 秒才收到一批。翻了大半天配置,最后发现是全局 gzip on 藏在一个 include 的公共配置文件里。SSE 消息通常很短,几十到几百字节,压缩收益几乎为零,延迟代价却很大。在 SSE 的 location 里加一行 gzip off 就解决了。

第三坑:超时断连

SSE 和 WebSocket 一样面临超时问题。服务端长时间没有事件要推,Nginx 的 proxy_read_timeout 到了就会断开连接。配置思路类似——可以调大超时,也可以让服务端定时发心跳注释。

SSE 协议规范里约定以冒号开头的行是注释,客户端的 EventSource 不会触发 onmessage,天然适合做保活。

app.get('/sse', (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    const heartbeat = setInterval(() => {
        res.write(': heartbeat\n\n');
    }, 15000);
    req.on('close', () => clearInterval(heartbeat));
});

把上面三个坑点的配置合在一起,就是 SSE 完整的 Nginx 配置。

location /sse {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    chunked_transfer_encoding off;
    proxy_read_timeout 86400s;
}

生产环境的边界情况

连接数限制

每个 WebSocket/SSE 连接都占用一个文件描述符。Nginx 的 worker_connections 默认值是 1024,同时在线 500 个 WebSocket 用户就可能打满(Nginx 自身也需要连接对接后端,一个客户端连接对应一个上游连接,实际容量要折半)。

系统层面需要同步调整,否则 Nginx 配置再大也会被 OS 限制挡住。worker_rlimit_nofile 控制 Nginx worker 进程的文件描述符上限,需要大于等于 worker_connections。系统级的 ulimit 也必须配合调高,否则 Nginx 启动时拿不到足够的文件描述符:

# nginx.conf 主配置
worker_processes auto;
worker_rlimit_nofile 65535;

events {
    worker_connections 65535;
    multi_accept on;
}

系统级文件描述符限制需要在 /etc/security/limits.conf 中设置,确保 Nginx 进程用户有足够的配额:

# /etc/security/limits.conf
nginx soft nofile 65535
nginx hard nofile 65535

改完后用 ulimit -n 确认生效,再 nginx -s reload。可以通过 cat /proc/<nginx_worker_pid>/limits 验证 worker 进程实际拿到的限制值。

多层代理的头丢失

生产环境经常不止一层代理:客户端 → CDN/SLB → Nginx → 后端。经过多层转发,UpgradeConnection 这些逐跳(hop-by-hop)头会被中间层剥掉,WebSocket 握手到了 Nginx 时已经丢失了关键头信息,后端收到的是一个普通 HTTP 请求。

表现为:开发环境直连 Nginx 一切正常,上了生产经过负载均衡器就连不上 WebSocket,返回 400 或 502。

解法分两步。第一步,确认前置代理(SLB/CDN)支持 WebSocket 透传并开启了相关选项,阿里云 SLB 需要在监听配置里勾选"开启 WebSocket",AWS ALB 原生支持但 CLB 需要用 TCP 监听。第二步,在 Nginx 层用 proxy_set_header 显式补上可能丢失的头,而不是依赖客户端传过来的值:

location /ws {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade websocket;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

注意这里 Upgrade 直接写死 websocket 而不是用 $http_upgrade 变量,因为变量值可能已经被前置代理清空了。

Nginx reload 断连接

执行 nginx -s reload 时,Nginx 会优雅关闭旧的 worker 进程。

如果没配 worker_shutdown_timeout,旧 worker 会一直等,直到所有长连接自然断开,导致 reload 后系统里同时跑着新旧两套 worker,内存持续上涨。配了超时(比如 30 秒),reload 时所有 WebSocket/SSE 用户会在 30 秒后被强制断开。

两种策略都不完美,实际操作中建议:

# nginx.conf 主配置
worker_shutdown_timeout 60s;

把超时设为 60 秒,给正在传输数据的连接留够缓冲时间。同时客户端必须实现自动重连——WebSocket 用库自带的 reconnect 机制,SSE 的 EventSource 本身就有自动重连能力(断开后默认 3 秒重试)。这样 reload 造成的断连对用户来说只是一次短暂的闪断,几秒后自动恢复。在需要频繁改配置的场景下,可以考虑用 upstream 的灰度策略,先切走流量再 reload,彻底避免断连。

各配置项速查

配置项 WebSocket SSE 默认值 建议值
proxy_http_version 1.1(必须) 1.1(推荐) 1.0 1.1
proxy_set_header Upgrade $http_upgrade 不需要
proxy_set_header Connection $connection_upgrade ''
proxy_buffering 默认即可 off(必须) on
gzip 默认即可 off(必须) on
proxy_read_timeout 心跳间隔×2 心跳间隔×2 60s 60-3600s
worker_connections 按最大连接数设 按最大连接数设 1024 65535
worker_shutdown_timeout 建议设置 建议设置 无限制 60s

IndexedDB实战:浏览器端离线存储与同步方案

2026年3月24日 13:48

IndexedDB实战:浏览器端离线存储与同步方案

上个月我们组接了个需求:给一个外勤巡检系统做离线支持。巡检员在信号差的工地拍照、填表单,数据先存本地,等有网了再同步上去。听起来不复杂对吧?localStorage 存一下不就完了?

我一开始也是这么想的。

这时候就不得不请出 IndexedDB 了。这东西 API 丑得让人想哭,但在浏览器端做离线存储,它几乎是唯一的正经选择。

离线数据模型设计:别偷懒,状态机是必须的

数据能存下来只是第一步。真正让我掉头发的是:怎么设计数据模型,才能在离线和在线之间无缝切换?

每条记录都要带同步状态

这个原则我们是踩了坑之后才确立的。一开始我们就存原始业务数据,等网络恢复了遍历一遍全量上传。结果发现:已经同步过的数据又传了一遍,同步失败的数据没有重试机制,用户改了已同步的数据不知道该怎么处理。

后来给每条记录加了 syncStatus 字段,整个世界清净了。

const record = {
  id: crypto.randomUUID(),       // 客户端生成 UUID,避免跟服务端 ID 冲突
  title: '5号楼电梯年检',          // 业务字段
  photos: [],                    // 存的是 Blob 引用,实际图片存在单独的 object store
  result: 'passed',
  syncStatus: 'pending',         // pending → syncing → synced / failed
  syncAttempts: 0,               // 重试次数,用于指数退避
  lastSyncError: null,           // 最近一次失败原因,方便排查
  localUpdatedAt: Date.now(),    // 本地最后修改时间
  serverUpdatedAt: null          // 服务端确认时间
}

这里有个容易忽略的细节:idcrypto.randomUUID() 在客户端生成,而不是等服务端返回自增 ID。原因是离线状态下你拿不到服务端 ID,如果用临时 ID 后续还得做一次 ID 映射,非常麻烦。

syncStatus 这个字段其实是个状态机:

  创建/修改
     ↓
  pending ──触发同步──→ syncing ──成功──→ synced
                          │                  ↑
                          失败               │
                          ↓                  │
                        failed ──重试──→ syncing

为什么要用状态机而不是简单的布尔值 isSynced: true/false?因为布尔值无法表达"正在同步中"这个中间态。如果用户在同步过程中又改了数据,你需要知道当前这条记录是"正在传"还是"还没传"。布尔值做不到。我们早期用布尔值时出过一个 bug:同步请求还在飞,用户又改了表单,改完 isSynced 被设回 false,紧接着之前的请求返回成功又把它设成 true,导致用户的最新修改永远没同步上去。状态机彻底解决了这个问题。

同步策略:三种方案,各有各的坑

离线数据攒够了,网络恢复了,怎么把数据送上去?这里有三种常见思路。

方案二:Background Sync API

这是我个人觉得设计得最优雅的方案。通过 Service Worker 注册一个同步任务,浏览器会在"合适的时机"自动触发,哪怕用户关掉了页面。主线程只需要把数据写入 IndexedDB 然后注册一个 sync tag,剩下的交给 Service Worker 在后台完成:

// 主线程:保存数据并注册同步任务
async function saveAndSync(record) {
  const db = await openDB('InspectionDB', 1)
  await db.put('reports', record)
  const registration = await navigator.serviceWorker.ready
  await registration.sync.register('sync-reports')
}

// service-worker.js:监听 sync 事件,执行实际同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-reports') {
    event.waitUntil(doSync()) // 浏览器会等 doSync() 完成
  }
})

doSync() 的内部逻辑和方案一基本一样:从 IndexedDB 捞 pending 记录,逐条推送。

这个方案的坑在于兼容性。截至 2026 年 3 月,Background Sync 基本只有 Chromium 系浏览器(Chrome、Edge)支持,Firefox 和 Safari 都没实现。如果你的用户群里有大量 iOS 用户,这个方案等于白搭。

我们的做法是把 Background Sync 当增强手段:支持就用,不支持就降级到方案一的 online 事件监听。

方案三:定时轮询 + 手动触发

最不"优雅"但最稳的方案。封装一个 SyncScheduler 类,每 30 秒检查一次有没有待同步记录,同时监听 online 事件做即时触发。核心就三件事:定时器轮询、网络恢复即时触发、明确离线时跳过。

class SyncScheduler {
  constructor(db, interval = 30000) {
    this.db = db
    this.interval = interval
    this.timer = null
  }

  start() {
    this.timer = setInterval(() => this.trySync(), this.interval)
    window.addEventListener('online', () => this.trySync())
  }

  async trySync() {
    if (!navigator.onLine) return
    const pending = await this.db.getAllFromIndex('reports', 'by_status', 'pending')
    if (pending.length === 0) return
    // 逐条同步,逻辑同方案一
  }
}

这里 trySync 先检查 navigator.onLine 再查库,避免离线时做无意义的 IndexedDB 查询。stop() 方法用于页面卸载时清理定时器,防止内存泄漏。这个方案没什么花哨的技巧,但在生产环境里反而最让人放心,用户也喜欢看到一个"同步"按钮——给他们确定感。

三个方案我们最终是混着用的:Background Sync 做第一优先级,online 事件做第二优先级,定时轮询做兜底。手动同步按钮作为用户最后的"救命稻草"。

冲突处理:离线同步绕不过的硬骨头

两个巡检员同时离线编辑了同一条报告,回到有网的时候同步上去,服务端收到两个不同版本,听谁的?

策略一:Last Write Wins(最后写入胜出)

最简单——谁的时间戳新,就用谁的。服务端拿 incoming.localUpdatedAt 和已有记录比较,新的覆盖旧的,旧的直接拒绝。实现成本几乎为零。

function handleSync(incoming) {
  const existing = db.findById(incoming.id)
  if (!existing || incoming.localUpdatedAt > existing.localUpdatedAt) {
    db.save(incoming)
    return { status: 'accepted' }
  }
  return { status: 'rejected', reason: 'stale' }
}

问题也很明显:先提交的人的修改会被静默覆盖掉,他完全不知道自己的数据被人"踩"了。对于巡检系统这种场景,一条报告被覆盖可能意味着安全隐患被忽略。所以这个策略只适合数据覆盖后果不严重的场景,比如草稿自动保存。

策略二:服务端仲裁 + 冲突提示

同步的时候带上一个版本号。如果服务端发现版本号对不上,就拒绝写入,把冲突抛给客户端让用户自己决定。客户端在请求体里带上 expectedVersion(即"我修改时基于的版本号"),服务端比对后如果版本不匹配,就返回冲突状态和最新的服务端数据:

async function syncRecord(record) {
  const res = await fetch('/api/reports/sync', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...record, expectedVersion: record.version })
  })
  const result = await res.json()
  if (result.conflict) {
    await db.put('reports', {
      ...record, syncStatus: 'conflict', serverVersion: result.serverData
    })
    showConflictResolver(record, result.serverData) // 弹窗让用户对比选择
  }
}

拿到冲突后,客户端把 syncStatus 设为 conflict,同时把服务端版本缓存到 serverVersion 字段。然后弹一个对比界面,左右两栏分别展示"我的版本"和"服务端版本",让用户逐字段选择保留哪个。用户选完后生成一个合并版本,带着新版本号重新提交。这套流程对用户有一定打扰,但对于巡检报告这类数据准确性要求高的场景,宁可多问一句也不能静默丢数据。

策略三:CRDT(无冲突数据类型)

CRDT 的思路是从数据结构层面消除冲突:每个客户端的操作都设计成可交换、可结合的,合并时不需要协调。举个最简单的例子——G-Counter(增长计数器)。假设要统计某个巡检点的检查次数,两个巡检员 A 和 B 各自离线期间分别检查了 3 次和 2 次:

A 的本地计数器: { A: 3, B: 0 }
B 的本地计数器: { A: 0, B: 2 }

合并规则:对每个节点取 max → { A: 3, B: 2 } → 总数 = 5

不管 A 和 B 的数据以什么顺序到达服务端,合并结果都是 5,不需要冲突处理。这对计数器、集合添加这类操作确实很优雅。

但问题在于,我们的巡检表单是"一个表单一堆字段"——检查结果、备注、整改意见、签名……这些字段是整体覆盖式更新,不是可累加的操作。你没法对"备注从'正常'改成'有裂缝'"和"备注从'正常'改成'需复检'"做 max 合并,因为文本字段没有偏序关系。要让 CRDT 处理这种任意字段的表单编辑,你得把每个字段拆成独立的 Last-Writer-Wins Register,再组合成一个 Map CRDT,实现复杂度直接起飞,而且最终效果和策略二的逐字段冲突对比差不多,还不如让用户自己选。

生产环境的架构拼图

跑了三个月之后整个离线同步系统的架构大致是这样的:

┌──────────────────────────────────────────────────┐
│                  用户操作层                        │
│   表单提交 / 拍照上传 / 列表查看                    │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│               离线数据管理层                       │
│  CRUD API(idb) + 状态机管理 + 配额监控/清理        │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│                IndexedDB                          │
│  reports 表 + attachments 表                      │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│              同步调度层                            │
│  Background Sync → online 事件 → 定时轮询         │
│  并发控制(3路) + 指数退避重试                      │
└────────────────────┬─────────────────────────────┘
                     │
              ┌──────▼──────┐
              │  服务端 API  │
              │ 版本号校验   │
              │ 冲突检测     │
              └─────────────┘

跑了三个月,稳定服务了 200 多个巡检员的日常使用。期间收集到的数据:| 指标 | 数值 | |------|------| | 单用户日均离线记录 | 1530 条 | | 平均单条记录大小(含图片引用) | 28KB(图片 Blob 另算) | | 单用户 IndexedDB 平均占用 | 45MB | | 同步成功率(首次尝试) | 94.7% | | 重试后最终同步成功率 | 99.6% | | 冲突发生率 | 0.3%(大部分是同一巡检点被两人同时检查) | | 清理后平均释放空间 | 每周约 12MB/用户 |

剩下 0.4% 同步始终失败的,基本都是网络极端不稳导致的超时,最后靠用户手动点同步按钮解决。

如果你也在做类似的离线功能,附一下我们实际的迭代过程供参考:

阶段 时间 方案 触发升级的事件
V1 第1~2周 idb + online 事件 + Last Write Wins 能跑通基本流程
V2 第3周 加入定时轮询兜底 发现工地 WiFi 频繁假在线,online 事件不触发同步
V3 第5周 加入版本号冲突检测 两个巡检员覆盖了同一条报告,甲方投诉数据丢失
V4 第7周 加入 Background Sync + 降级策略 用户反馈关掉页面后数据没同步,第二天才发现
V5 第9周 加入配额监控和自动清理 一个巡检员的手机浏览器 IndexedDB 爆了

总共花了大约两个月从最简单的 V1 演进到当前的混合方案。每次升级都是被线上问题推着走的,没有一次是"提前设计"出来的。

Signals 跨框架收敛:TC39 提案、Solid、Angular、Preact 的实现差异与调度策略对比

2026年3月22日 17:42

前端框架搞响应式搞了十年,最后殊途同归——大家都在写 Signals。Solid 从第一天就是 Signals 架构,Preact 半路加了 @preact/signals,Angular 在 v16 直接官宣 signal(),连 TC39 都坐不住了,要把 Signals 塞进语言规范。

它们长得像,骨子里是一回事吗?

Angular Signals:渐进式改造的务实路线

Angular 的 Signals 实现跟 Solid 的哲学截然不同。Solid 是"一切皆 Signal"的激进路线,Angular 走的是"Signal 是一个新选项,跟已有体系共存"的渐进路线。

Angular 的调度:微任务 + 组件树协调

Angular 的 Signal 更新不是同步的,也不是简单的事件循环批量。它走的是微任务批量 + 组件树自上而下协调的路线。

具体流程是:signal.set() 只做脏标记,不立即重新计算 computed。在微任务队列中安排一次变更检测,从根组件开始自上而下遍历组件树,遇到标记为脏的组件才检查其 Signal 依赖、重新计算 computed、更新 DOM。

const name = signal('Alice')
const greeting = computed(() => `Hello, ${name()}`)

name.set('Bob')
name.set('Charlie')
name.set('Dave')
// → 三次 set 合并成一次变更检测,greeting 只算一次 → "Hello, Dave"

这种策略的好处是:无论在事件处理器、setTimeout 还是 Promise 回调中,多次 set 都会被自动合并,不需要手动 batch。代价是更新延迟到微任务——如果你在 set 之后立即读 DOM,拿到的是旧值。Angular 选择这个策略,是因为要兼容已有的组件树生命周期。

effect() 的克制态度

Angular 对 effect() 的态度很谨慎——官方文档明确说这是"逃生舱",能不用就不用。体现在 API 设计上,effect() 必须在注入上下文中创建:

@Component({ /* ... */ })
export class MyComponent {
  count = signal(0)

  constructor() {
    effect(() => console.log('count:', this.count()))  // 构造函数中有注入上下文
  }

  someMethod() {
    // 普通方法中没有注入上下文,直接调 effect() 会报错
    // 需要手动传入 injector:
    // effect(() => { ... }, { injector: this.injector })
  }
}

这是有意为之的摩擦。

三大实现的调度策略对比

调度策略的差异是这三个框架 Signals 实现的核心分水岭。同样一段状态更新代码,在三个框架中执行时机和顺序可能完全不同。

同步、异步、还是混合

事件触发 → set signal

  Solid:同步执行(事件内自动 batch)
    → batch 结束 → 同步 flush 所有 effect

  Angular:异步调度(微任务)
    → set → 标记脏 → queueMicrotask → 变更检测 → 更新

  Preact:混合模式
    → 直接绑定:同步更新文本节点(绕过 VDOM)
    → .value 读取:组件级调度(通过 VDOM diff)

把这三种策略放到同一个场景下看更直观。假设一个表单有 10 个字段,用户触发了一次"全部重置":

Solid:事件处理器内自动 batch,10 次 set 合并,依赖这些字段的 effect 只执行一次。换成 setTimeout 调用就需要手动 batch,否则触发 10 次更新。

Angular:10 次 set 都只是标记脏,在下一个微任务中统一做一次变更检测。

Preact:如果用了直接绑定(JSX 中传 signal 对象),10 个文本节点同步更新,不触发组件 re-render。如果用了 .value,需要 batch 包裹,否则组件可能 re-render 多次。

菱形依赖:Glitch-free 怎么保证

响应式系统有一个经典难题——菱形依赖。当一个 computed 依赖的多个上游共享同一个源头时,更新顺序不对就会出现错误的中间态:

const a = signal(1)
const b = computed(() => a.value * 2)       // b = 2
const c = computed(() => a.value * 3)       // c = 3
const d = computed(() => b.value + c.value) // d = 5

// a 变为 2 时,d 应该等于 4 + 6 = 10
// 但如果 d 在 b 更新后、c 更新前被计算 → d = 4 + 3 = 7(错误的中间态)

依赖关系形成了一个菱形:a 分叉到 b 和 c,再汇聚到 d。三个框架都解决了这个问题,方式不同。

Solid 用拓扑排序——按依赖图的层级顺序执行更新,保证 dbc 都更新后才重新计算。这是 push 模型下的经典解法。

Angular 用 pull-based 惰性求值——d 只在被读取时才重新计算,读取时会先递归检查 bc 是否需要更新。读 d 之前先把上游全拉到最新,天然不会出现中间态。

Preact 也是 pull-based 模型,额外加了版本号机制——每个 signal 有一个单调递增的版本号,computed 在求值时通过比较版本号判断依赖是否已经更新过了。

Push vs Pull 的本质差异

这三个框架表面上都叫"Signals",底层的推拉模型配比其实不一样:Push 模型的特点是"源头变了就主动通知下游"。

Pull 模型反过来,"有人读的时候才去检查上游有没有变"。

实际上三个框架都是混合模型:computed 用 pull(惰性),effect 用 push(主动)。区别在于配比和默认倾向——Solid 更偏 push,它的编译器会生成细粒度的 effect 来驱动 DOM 更新;Angular 更偏 pull,变更检测时才从模板"拉取" signal 的值。

设计权衡:为什么调度无法统一

TC39 提案留白调度的原因

TC39 提案不做调度,这不是疏忽,是刻意为之。设想一下:如果 TC39 强制规定"所有 effect 在微任务中执行",Solid 的同步更新场景就没法做了;如果规定同步执行,Angular 的组件树协调又会被打破;如果规定用 requestAnimationFrame,动画场景合适了,表单交互又会有延迟感。

调度策略跟框架的渲染管线是一体两面。

强行统一调度,就像要求所有快递公司用同一种分拣流程——京东的自营仓和菜鸟的网格仓,底层逻辑根本不一样。

各方案的边界条件

每种实现都有碰壁的地方,了解这些边界在选型时比看 API 有用得多。

Solid 的 async/await 困境:纯运行时依赖收集的固有限制——await 会让 JavaScript 引擎挂起当前函数并清空调用栈,恢复时全局追踪栈上的 observer 已经不在了。

createEffect(async () => {
  const val = count()      // 这里的依赖能追踪到
  await fetch('/api')
  const val2 = other()     // await 之后追踪上下文丢失,other 变化不会触发此 effect
})

这不是 bug,是机制决定的。Solid 官方建议在 effect 中把所有 signal 读取放在第一个 await 之前,或者用 createResource 处理异步场景。

Angular 的双系统心智负担:Signal 和 RxJS Observable 并存。虽然提供了 toSignal()toObservable() 做桥接,但团队中一半人习惯用 Observable 处理异步流、另一半人用 Signal 处理同步状态,代码风格容易分裂。在一个真实的 Angular 16+ 项目中(比如一个中后台系统),你可能会看到同一个 service 里 BehaviorSubjectsignal() 混用,维护起来很头疼。

Preact 的直接绑定局限:直接绑定模式只对文本内容生效。需要根据 signal 值动态切换 CSS 类名、控制元素显隐、或者传递 props 给子组件时,还是得走 .value 路线触发组件 re-render。也就是说,性能最优的路径覆盖面有限,复杂 UI 逻辑中很难全程使用。

从"框架特性"到"语言能力"还有多远

TC39 Signals 提案要真正落地到浏览器,还有几道坎要过。

JS 引擎级优化的想象空间

一旦 Signals 成为语言原语,JS 引擎可以做目前用户态代码做不到的优化。

依赖图可以用引擎内部的数据结构表示,不需要 JavaScript 对象和 Set 的开销。computed 的缓存失效检查可以在 JIT 层面优化,减少属性查找。垃圾回收也可以更智能地处理不再被引用的 signal 和它们的订阅关系——目前框架实现中,忘记清理的 effect 订阅是常见的内存泄漏来源。

这些优化在用户态框架中是不可能实现的。这也是 TC39 提案最大的远期价值——不是统一 API,而是打开引擎级优化的大门。

框架间共享依赖图

如果 TC39 Signals 落地,一个有意思的可能性是:不同框架的组件可以共享同一个响应式依赖图。

// 未来场景:一个页面同时用了 Solid 和 Angular 组件
const sharedState = new Signal.State({ user: null })

// Solid 组件读取 sharedState → 注册 Solid 的调度器
// Angular 组件读取 sharedState → 注册 Angular 的调度器
// sharedState 变化时,两个框架各自按自己的方式更新

这对微前端场景有实际价值。目前不同框架间传递状态要走 CustomEvent、全局变量或者额外的状态管理层。有了标准 Signals,跨框架的响应式状态共享就变成了原生能力,不需要中间层。

对现有框架的迁移成本

三个框架的迁移难度差异明显。

Solid 的 createSignalcreateMemo 跟 TC39 的 Signal.State / Signal.Computed 语义最接近,换成标准 API 的薄封装就行,兼容成本最低。

Angular 需要把 signal()computed() 的底层实现从自研切换到标准 Signals,上层 API 保持不变。工作量集中在框架内部,对应用代码几乎透明。

Preact Signals 的情况最微妙——它的双路径模式(直接绑定 vs .value 读取)是在自己的 signal 实现上做的深度定制。标准 API 没有"把 signal 对象直接当值用"这个能力,Preact 需要在标准 Signals 之上额外包装一层,复杂度比另外两家高。

从多仓到 Monorepo 的渐进式迁移:Git 历史保留、依赖收敛与缓存调优

2026年3月22日 17:35

迁移之前,我们团队的日常是这样的:改一个公共组件,要在 3 个仓库之间反复 npm link;改完之后走 npm publish 发版,再挨个去下游仓库 npm update;结果经常碰到版本范围匹配出错——^1.2.0 悄悄拉到了 1.3.0,类型对不上,排查半天才发现是另一个同事昨天发的 minor 版本搞的。

这是我们团队 8 个前端仓库并行开发两年之后的真实状况。每次跨仓改动,光是 npm link 和版本对齐就能吃掉半天。终于有一天,Tech Lead 在周会上拍板:"我们迁 Monorepo 吧。"

三个月,无数个坑,8 个仓库最终合进了一个 pnpm workspace + Turborepo 的 Monorepo。

Git 历史迁移:git filter-repo 才是正解

迁移 Monorepo 最纠结的一个决定:要不要保留 Git 历史?

直接把代码复制过来建新仓库最省事,但 git blame 就废了。对于一个有两年历史的项目来说,git blame 几乎是排查问题时的第一反应——"这行代码谁在什么场景下写的"。丢掉历史,等于未来排查问题时少了一个重要线索。

方案对比:subtree merge vs filter-repo

最开始我们试了 git subtree add --prefix=packages/shared-components,看起来很美,但踩了两个坑:历史记录是"拍扁"的,所有 commit 混在主仓库时间线里,git log --follow 对重命名的文件跟踪不了;如果子仓库有 merge commit,合进来之后历史图会变成一团乱麻。

最终选了 git filter-repo。这个工具能在保留完整历史的前提下,批量重写文件路径。

迁移脚本的核心流程

每个仓库的迁移分三步:克隆源仓库到临时目录,用 filter-repo 给所有文件路径加上目标前缀(比如 src/Button.tsx 变成 packages/shared-components/src/Button.tsx,commit 历史中的路径也会同步修改),然后在 monorepo 里把改写后的历史 merge 进来。

#!/bin/bash
# migrate-repo.sh — 单个仓库的历史迁移

REPO_URL=$1        # 源仓库地址
TARGET_DIR=$2      # 目标路径,如 packages/shared-components
BRANCH=${3:-main}
TEMP_DIR=$(mktemp -d)

git clone --single-branch --branch "BRANCH""BRANCH""REPO_URL" "$TEMP_DIR"
cd "$TEMP_DIR"

# 重写所有 commit 中的文件路径,加上目标目录前缀
git filter-repo --to-subdirectory-filter "$TARGET_DIR" --force

cd /path/to/monorepo
git remote add temp-migrate "$TEMP_DIR"
git fetch temp-migrate
git merge temp-migrate/"$BRANCH" --allow-unrelated-histories \
  -m "chore: migrate $TARGET_DIR with full git history"
git remote remove temp-migrate
rm -rf "$TEMP_DIR"

这里有个容易忽略的细节:--allow-unrelated-histories 是必须的。每个源仓库的 commit 树和 monorepo 完全独立,没有共同祖先,Git 默认会拒绝这种合并。

迁移顺序决定了过程的平稳度

我们按依赖拓扑排序,从叶子节点开始:design-tokens 和 eslint-config(零依赖)先进,然后是 shared-utilsshared-components,最后是三个应用。

为什么这个顺序很重要?因为每合进一个仓库,我们都会跑一次 pnpm install 和 tsc --build 来验证当前状态是否正常。如果先合应用层,它依赖的 shared-components 还没进来,类型检查和构建都会挂。从叶子节点开始,每一步合进来的仓库都能在当前 monorepo 里正常构建,出了问题也能立刻定位是哪个仓库的迁移引入的。

我们中间有一次没按顺序,把 app-admin 提前合了进来。结果 pnpm install 时它依赖的 @xxx/shared-components 在 workspace 里找不到,pnpm 直接去 npm registry 拉了线上旧版本,构建倒是过了,但类型对不上——线上版本还没有我们本地最新加的几个 props。排查了一个多小时才意识到是顺序的问题。

迁完 8 个仓库后,monorepo 的 commit 数量从 0 涨到了 4000+,用 git log --oneline | wc -l 验证总数,和各仓库之和对得上。随便挑几个文件跑 git blame,能看到原始仓库的 commit hash、作者和日期,说明历史完整保留了。

跨仓依赖收敛:从 npm 包到 workspace 协议

历史搬完了,代码都在一个仓库里了,但各个 package 的 package.json 还在引用 npm 上的包。要把这些改成 pnpm workspace 的内部引用。

workspace 结构和依赖替换

先在根目录建 pnpm-workspace.yaml,声明 packages/* 和 apps/* 两个目录。然后批量把所有内部包的版本号替换为 workspace:*

// 替换前
{ "@xxx/shared-components": "^1.3.0", "@xxx/utils": "^2.1.0" }
// 替换后
{ "@xxx/shared-components": "workspace:*", "@xxx/utils": "workspace:*" }

workspace:* 告诉 pnpm:这个包就在本地 workspace 里,不要去 npm registry 找。开发时直接引用源码或构建产物,改了立刻生效,不需要发版。发布时 pnpm 会自动把 workspace:* 替换成实际版本号。

外部依赖版本不一致——最耗时的部分

8 个仓库各自装了两年依赖,同一个包的版本五花八门。比如 React:shared-components 用的 ^18.2.0app-admin 是 ^18.0.0app-h5 居然还停在 ^17.0.2app-mini 则是 ^18.3.0

pnpm workspace 对这种情况还算宽容——每个 package 可以有自己的依赖版本。但版本一致性直接影响 Turborepo 的缓存命中率(后面会展开讲),所以我们用 pnpm overrides 强制统一了关键依赖:

// monorepo 根目录 package.json
{
  "pnpm": {
    "overrides": {
      "react": "^18.3.1",
      "react-dom": "^18.3.1",
      "typescript": "~5.4.0",
      "lodash": "npm:lodash-es@^4.17.21"
    }
  }
}

pnpm overrides 像一把大锤——不管子 package 声明的是什么版本,最终安装的都是 overrides 指定的。

这里踩了一个坑:app-h5 从 React 17 直接拉到 18.3.1 之后,用了 ReactDOM.render 的入口文件控制台疯狂报 warning。React 18 要求换成 createRoot,连带着一些依赖 ReactDOM.render 的第三方库(我们用的一个老版本富文本编辑器)也得升级。这部分额外花了两天,如果一开始就列出每个仓库的 React 大版本差异,可以提前评估工作量。

TypeScript 项目引用

代码和依赖都在一起了,但 TypeScript 还不知道怎么跨 package 做类型检查。需要给每个子包配 composite: true 和 references,在根目录的 tsconfig.json 里把所有子项目串起来。

// packages/shared-components/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true
  },
  "references": [
    { "path": "../design-tokens" },
    { "path": "../shared-utils" }
  ]
}

配好之后,tsc --build 会按依赖顺序增量编译整个 monorepo,只重新编译有变更的包和它的下游。根目录的 tsconfig.json 自己不编译任何文件("files": []),纯粹用来声明子项目拓扑关系。

远程缓存命中率:从 30% 到 85% 的调优过程

Turborepo 的本地缓存在单人开发时够用,但团队协作时需要远程缓存——我在 A 分支构建过的包,你在 B 分支如果没改过,应该能直接复用。我们接入自建 HTTP 缓存服务器后,初始命中率只有 30%。70% 的构建任务在重复劳动,完全没发挥出缓存的价值。

元凶一:环境变量泄漏(30% → 50%)

Turborepo 默认会把一些环境变量算进 hash。CI 环境有 CI=trueNODE_ENV=production,本地没有,hash 自然不一样,缓存永远命中不了。

解法是在 turbo.json 里显式声明哪些环境变量影响构建:

{
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "env": ["VITE_API_BASE", "VITE_APP_VERSION"]
    }
  }
}

只有这些变量参与 hash 计算,CI 和本地之间 CIGITHUB_SHA 之类的差异不再影响缓存。这一步改完命中率直接从 30% 跳到 50%。

元凶二:生成文件污染 inputs(50% → 65%)

我们的构建流程会从 OpenAPI spec 自动生成 src/generated/api-types.ts。这个文件在 src/** 的 glob 范围内,每次生成即使内容没变,文件时间戳也会更新,Turborepo 就认为 inputs 变了。

解法是把代码生成拆成独立的 Turborepo 任务:

{
  "tasks": {
    "codegen": {
      "inputs": ["openapi.yaml"],
      "outputs": ["src/generated/**"]
    },
    "build": {
      "dependsOn": ["codegen", "^build"],
      "inputs": ["src/**", "!src/generated/**", "tsconfig.json"],
      "outputs": ["dist/**"]
    }
  }
}

codegen 和 build 各管各的缓存。openapi.yaml 没变就不重新生成,src 没变就不重新构建,两者互不干扰。

元凶三:锁文件变动的连锁反应(65% → 80%)

pnpm-lock.yaml 是 Turborepo 默认的全局 input。任何人装了个新依赖,锁文件一变,全部包的缓存全部失效。

这个问题比较棘手。锁文件确实影响构建结果——间接依赖版本变了,构建产物可能不同。但大部分时候,改动只影响一两个包,不应该让整个 monorepo 的缓存全部作废。

我们的妥协方案是把 pnpm-lock.yaml 从 globalDependencies 里拿掉,只保留真正全局的配置(如 tsconfig.base.json)。代价是可能出现间接依赖变化导致的构建差异未被检测到,但我们用 CI 的集成测试兜底——每天凌晨跑一次全量构建,如果有问题第二天早上能看到。

这是一个不完美的 trade-off。

最后 5%:缓存服务的存储策略(80% → 85%)

剩下的 miss 大多来自缓存过期。自建缓存服务用的 S3 存储,默认 TTL 7 天。但像 design-tokenseslint-config 这种几个月都不变的基础包,7 天一过缓存没了又得重新构建。

我们按包的变更频率设了不同的 TTL:design-tokens 和 eslint-config 30 天,shared-utils 14 天,shared-components 7 天(变得比较频繁),其他默认 3 天。再加上 LRU 淘汰策略,S3 bucket 限制在 50GB 以内,命中率稳定在 83%-87%。

迁移之后踩的坑

坑一:monorepo 的 CI 从 5 分钟膨胀到 25 分钟

8 个仓库合成一个之后,CI 从原来每个仓库 3-5 分钟,变成全量跑 25 分钟。原因是 CI 默认 pnpm install 装所有依赖,turbo build 构建所有包——哪怕这次 PR 只改了 app-admin 的一个按钮颜色。

解法是用 Turborepo 的 --filter 配合 Git diff,只跑受影响的包:

bash复制

turbo build test --filter='...[origin/main]'

这行命令的意思是:找出相对于 main 分支有文件变动的包,以及依赖这些包的下游包,只对它们跑 build 和 test。改了 app-admin 的按钮颜色,就只构建 app-admin,3 分钟搞定。改了 shared-components,会自动触发所有引用它的应用一起构建。

改完之后,80% 的 PR 的 CI 时间回到了 3-8 分钟,只有改公共包的 PR 才需要 15 分钟左右。

坑二:IDE 卡到怀疑人生

8 个仓库的代码放到一个 VS Code workspace 里,TypeScript Language Server 直接吃满 4GB 内存,输入一个字符要等 2-3 秒才有自动补全。

两个办法缓解:

第一,在 .vscode/settings.json 里做减法。关掉 typescript.preferences.includePackageJsonAutoImports(这个功能会扫描所有 node_modules 来生成 import 建议),把 node_modulesdist.turbo.next 目录加到 files.watcherExclude 和 search.exclude 里,减少文件系统监听的压力。

第二,靠 TypeScript 的 Project References。开了 composite: true 之后,TS Server 不会一次性加载全部子项目的源码,而是按需加载——打开 app-admin 的文件时只加载它直接依赖的 shared-components 和 shared-utils 的类型声明(.d.ts),不加载其他应用的代码。内存占用从 4GB 降到了 1.5GB 左右,自动补全延迟也回到了可接受的范围。

坑三:新人 onboarding 成本翻倍

仓库有 4000+ commit 历史,pnpm install 要装 2000+ 个包(8 个项目的依赖加起来),turbo build 第一次全量构建要跑 8-10 分钟。新人第一天 clone 下来,面对这个规模会有点懵——"我只负责 admin 后台,为什么要装移动端 H5 的依赖?"

我们最终沉淀了一套 onboarding 流程:

  1. 用 git clone --depth=1 浅克隆,不拉 4000 条历史,clone 时间从 3 分钟降到 20 秒
  2. 根目录放了一个 setup.sh,一键完成 pnpm install + turbo build 全量构建,同时填充本地 Turborepo 缓存
  3. 之后日常开发只需要 pnpm turbo build --filter=@xxx/app-admin 构建自己负责的应用,增量构建通常 10 秒以内

另外在根目录的 README.md 里画了一张包依赖关系图(用 mermaid 生成),新人看一眼就知道 app-admin 依赖了哪些内部包,改了 shared-utils 会影响哪些应用。

迁移三个月后的数据对比

指标 迁移前(8 个仓库) 迁移后(Monorepo)
跨仓改动耗时 半天(npm link + 发版 + 更新) 10 分钟(改完直接引用)
CI 平均时长 3-5 分钟/仓库,但跨仓要手动触发多个 3-8 分钟(单包),15 分钟(公共包)
版本冲突频率 每周 2-3 次 基本消失(workspace 协议 + overrides)
依赖安装时间 每个仓库各装一遍,总计 15 分钟+ 一次 pnpm install,3 分钟
新人上手时间 1 天(配 8 个仓库的开发环境) 半天(一个仓库,一个 setup 脚本)

回头看,最值得的不是构建速度的提升,而是跨仓改动的心理负担没了。以前改公共组件要发版、要通知下游、要确认版本号,现在就是正常提交代码,CI 自动帮你验证所有下游是否兼容。

最坑的部分是 React 17 → 18 的升级,和远程缓存命中率的调优。这两个加起来占了迁移总工作量的一半。如果你的团队也在考虑迁 Monorepo,建议先花一天时间梳理所有仓库的核心依赖版本差异,提前评估升级工作量——这个信息决定了你应该给迁移留多少 buffer。

❌
❌