首页
/ Cypress 单仓库 ESM 迁移实战:TypeScript 化、Vitest 化与 ESM/CJS 双格式构建

Cypress 单仓库 ESM 迁移实战:TypeScript 化、Vitest 化与 ESM/CJS 双格式构建

2026-09-06 09:03:19作者:宣海椒Queenly

本文基于 Cypress 官方维护的迁移指南 guides/esm-migration.md,完整拆解 Cypress monorepo 从 CommonJS 向 ES Module 演进的四阶段路线图。读完后你将了解:为什么 Cypress 内部包要逐一转为 TypeScript 与 Vitest、v8-snapshot 的旧式模块解析如何成为 exports 字段落地的阻碍、以及 scaffold-configsockettelemetry 三个包是如何以“多入口、多 tsconfig”的方式构建出 browser/node 双端产物的。

迁移总览:四个阶段的路线图

Cypress 的 ESM 迁移被划分为四个依次推进的阶段,当前仓库代码与文档中的状态勾选共同反映了这一进度:

  1. Phase 1:将各包转换为 TypeScript——覆盖所有 NPM 包与 binary 包;
  2. Phase 2:将各包的单测从 Mocha 迁移到 Vitest——接近全部完成;
  3. Phase 3:为 NPM 包打包 ESM/CJS 双版本——文档标注“细节将在 Phase 2 结束后明确”;
  4. Phase 4:让 Cypress server 以 ESM 包形式运行——同样标注为待定。

整体思路清晰:先把源码统一到 TypeScript、把测试统一到 Vitest,消除语言与测试框架层面的历史包袱,再谈产物格式(ESM/CJS)的统一。因为一个包的构建产物能否被 v8-snapshot 正确解析,取决于它的源码与配置是否已经完全“模块化”。

Phase 1:包级 TypeScript 转换

验收标准

原文档给出了判断一个包是否完成 Phase 1 的三条硬标准:

  • .ts 文件中不允许再出现 require 语句;
  • 该包内的单元测试必须用 TypeScript 编写;
  • 该包不包含 scripts 或 system test 的迁移(这两类留在仓库顶层统一管理)。

迁移状态清单

以下状态直接继承自 guides/esm-migration.md,并结合当前仓库结构核对。

NPM 包(npm/ 目录与 cli)

状态
cli 已完成
npm/angular 已完成
npm/cypress-schematic 已完成
npm/eslint-plugin-dev 未完成(Phase 2 已迁至 Vitest)
npm/grep 已完成
npm/mount-utils 已完成
npm/puppeteer 已完成
npm/react 已完成
npm/svelte 已完成
npm/vite-dev-server 已完成
npm/vite-plugin-cypress-esm 已完成
npm/vue 已完成
npm/webpack-batteries-included-preprocessor 已完成
npm/webpack-dev-server 已完成
npm/webpack-preprocessor 已完成

Binary 包(packages/ 目录,打进 Cypress 二进制的部分)

状态
packages/app PARTIAL,低优先级(前端包)
packages/config 已完成
packages/data-context PARTIAL,入口仍是 JS
packages/driver 源码已完成,Cypress 测试待迁移
packages/electron 已完成
packages/errors 已完成
packages/eslint-config 已完成
packages/example 未完成
packages/extension 已完成
packages/frontend-shared PARTIAL,入口仍是 JS
packages/https-proxy 已完成
packages/icons 已完成
packages/launcher 已完成
packages/launchpad 已完成
packages/net-stubbing 已完成
packages/network 已完成
packages/network-tools 已完成
packages/packherd-require 已完成
packages/proxy PARTIAL,入口仍是 JS
packages/reporter 已完成
packages/resolve-dist 已完成
packages/root 已完成
packages/runner 已完成
packages/scaffold-config 已完成
packages/server PARTIAL,大量源码/测试仍是 JS,最高优先级
packages/socket 已完成
packages/stderr-filtering 已完成
packages/telemetry 已完成
packages/ts PARTIAL,最终目标是移除该包,转换价值不大
packages/types 已完成
packages/v8-snapshot-require 已完成
packages/web-config 已完成

关键阻碍:v8-snapshot 的旧式模块解析

原文档在 Notes 中点出了本次迁移中最棘手的技术债:从 ts-node 入口迁走的包,很难同时提供 browser 与 node 两个独立入口。根源在于 tooling/v8-snapshot/tsconfig.json 继承了 packages/ts/tsconfig.json,而后者使用的是旧式模块解析:

// packages/ts/tsconfig.json
"module": "commonjs",          // L27
"moduleResolution": "node",    // L29

moduleResolution: "node" 这套老式解析规则不会读取 package.json 里的 exports 字段,只能退而求其次地识别 main / module / browser 等顶层键。这意味着:只要二进制内的打包链(v8-snapshot 生成 V8 字节码快照所用的构建配置)还停留在这套解析下,包就无法用“一份源码 + exports 条件分支”的现代方式同时服务浏览器与 Node。

文档给出的临时方案是:为 browser 端与 node 端分别打包,让两份产物互不包含,从而绕开单一 bundle 混入双端代码的问题。文档明确这是临时措施——等所有包都能以 ESM 构建后,将重新评估 Cypress 二进制与 v8-snapshot 的构建方式,让各包以更规范的方式导出内容。目前采用这一套“双端打包”模式的正是下面三个包。

三个双端构建案例:scaffold-config、socket、telemetry

案例一:scaffold-config(最完整的参考实现)

packages/scaffold-config/package.json 是文档中特别点名“从 ts-node 入口迁出”的示例包,其 package.json 同时声明了三个产物入口:

{
  "main": "cjs/index.js",        // Node/CommonJS 入口
  "module": "esm/index.js",      // ESM 入口
  "types": "cjs/index.d.ts",
  "scripts": {
    "build": "yarn build:cjs && yarn build:esm && yarn build:browser",
    "build:browser": "rimraf browser && tsc -p tsconfig.browser.json",
    "build:cjs": "rimraf cjs && tsc -p tsconfig.cjs.json",
    "build:esm": "rimraf esm && tsc -p tsconfig.esm.json",
    "test": "vitest run"
  }
}

一份 src/ 源码(packages/scaffold-config/src)对应三套 tsconfig,正是文档所说“browser/node 代码不进同一 bundle”的落地方式:

  • tsconfig.cjs.json"module": "CommonJS""outDir": "./cjs",产出 Node 端 CJS 产物;
  • tsconfig.esm.json"module": "ES2022""target": "ES2022""outDir": "./esm",产出 ESM 产物;
  • tsconfig.browser.json:最关键的一处——"include": ["src/dependencies.ts"]只把浏览器端真正需要的 dependencies.ts 单独编进 browser/ 目录,而不是打包整个 src/,从而与 node 产物彻底解耦。该配置还单独指定了 "types": ["cypress"],说明浏览器入口依赖 Cypress 的类型环境。

三套配置均 target: ES2022moduleResolution: node,与 v8-snapshot 工具链的解析习惯保持一致——这正是“临时 workaround”的具体形态:产物格式先行对齐,解析规则随后再升级。

案例二:socket(最典型的 browser/node 分离)

packages/socket/package.json 展示了另一种更直白的双端划分——用目录区分入口,而不只是格式:

{
  "main": "cjs/node/index.js",        // Node 端
  "browser": "browser/client/index.js", // 浏览器端(利用 browser 键)
  "module": "esm/node/index.js",
  "scripts": {
    "build": "yarn build:browser && yarn build:node && yarn build:node:esm"
  }
}

它把 node 端代码放在 cjs/node/esm/node/,浏览器端放在 browser/client/,配合 browser 顶层键,让不理解 exports 的旧解析器也能拿到正确入口。此外该包通过 workspaces.nohoist 锁住了 socket.io 全家桶(socket.ioengine.io 系列)的版本,保证 binary 内打包版本稳定——这与 ESM 迁移无直接关系,但体现了双端构建包对依赖隔离的额外要求。

案例三:telemetry(Node 用 tsc,浏览器用 Rollup)

packages/telemetry/package.json 是第三种变体:

{
  "main": "cjs/node.js",
  "module": "esm/node.js",
  "scripts": {
    "build": "yarn build:esm && yarn build:cjs && yarn build:browser",
    "build:browser": "rimraf browser && rollup -c rollup.config.mjs",
    "build:cjs": "rimraf cjs && tsc -p tsconfig.cjs.json",
    "build:esm": "rimraf esm && tsc -p tsconfig.esm.json"
  }
}

与 scaffold-config 全部用 tsc 不同,telemetry 的浏览器产物交给 rollup.config.mjs 打包——当浏览器端需要把 OpenTelemetry SDK(@opentelemetry/* 系列依赖)收敛成单个 bundle 时,Rollup 比逐文件 tsc 更合适。三个案例合起来展示了 Cypress 在“exports 尚未被 v8-snapshot 支持”这一约束下的三种双端构建姿势:多 tsconfig 分目录输出、browser 键 + 目录隔离、以及 Rollup 打包浏览器产物。

顶层 CLI 的 exports 实践

值得注意的是,已经完成的 cli 包在 cli/package.json 中已经使用了现代的 exports 条件导出(L128-L133):

"exports": {
  ".": {
    "types": "./types/index.d.ts",
    "import": "./dist/index.mjs",
    "require": "./dist/index.js"
  }
  // ... /vue、/react、/svelte 等子路径同样区分 import/require
}

import 指向 .mjsrequire 指向 .js 的写法,正是 Phase 3(NPM 包 ESM/CJS 双版本)要推广到所有 NPM 包的样板。CLI 由 Rollup 统一打包("build": "rollup -c",见 cli/rollup.config.mjs),不再依赖 v8-snapshot 逐包解析,因此可以先行落地 exports。这也印证了迁移的顺序逻辑:不受 v8-snapshot 解析约束的包先走现代格式,受约束的 binary 包先用多入口 workaround 过渡。

Phase 2:单测从 Mocha 迁移到 Vitest

Phase 2 的清单显示绝大多数包已完成迁移,两个例外值得注意:

  • packages/server 未完成——与 Phase 1 一致,server 是剩余工作量最大的包;
  • packages/ts 不迁——文档注明其最终目标就是移除(它只是为整个 monorepo 提供共享的 tsconfig.json 与 tslint 配置),转换没有价值。

一个特殊的分支决策:packages/data-context 选择了 Jest 而非 Vitest,从 mocha/sinon/chai 迁到了 jest(见 packages/data-context/jest.config.ts),文档要求读者查阅其 README 了解选择 Jest 的原因。这说明路线图允许个别包基于自身测试形态(GraphQL schema 生成、快照量等)偏离统一框架,只要迁移动机写清楚。

其余包则统一收敛到 Vitest,例如 packages/scaffold-config/package.jsoncli/package.json 中的测试脚本均为:

"test": "vitest run",
"test-debug": "vitest --inspect-brk --no-file-parallelism --test-timeout=0"

统一的调试参数(--inspect-brk--no-file-parallelism--test-timeout=0)在各包中高度一致,说明 monorepo 层面为 Vitest 迁移定下了标准化的调试姿势。

Phase 3 与 Phase 4:待 Phase 2 收尾后展开

文档对最后两个阶段只留下了原则性描述,这里如实呈现其状态与前提:

  • Phase 3:为 NPM 包打包 ESM/CJS 双版本——“细节将在 Phase 2 结束后更清晰”。从 cli/package.jsonexports 字段与 packages/scaffold-configmain/module 双入口看,目标形态(import/require 条件导出 + 双产物)已有可参照的实现;
  • Phase 4:让 Cypress server 以 ESM 包运行——同样待定。而 packages/server 在 Phase 1/2 中双双处于 PARTIAL/未完成状态且被标为最高优先级,可以推断 server 的 TS 化与测试迁移是解锁 Phase 4 的前置条件。

小结与延伸阅读

这条迁移路线可以概括为:先统一语言(TypeScript)、再统一测试(Vitest)、在解析链升级前用多入口产物规避 exports 不可用的问题、最后统一产物格式(ESM/CJS)并让 server 以 ESM 运行。深入阅读时建议按以下路径对照源码:

登录后查看全文
热门项目推荐
相关项目推荐