阅读视图

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

面试踩大坑!同一段 Node.js 代码,CJS 和 ESM 的执行顺序居然是反的?!99% 的人都答错了

一道"经典"的 Event Loop 面试题,背了八股文的你以为稳了,结果一运行却被 ESM 背刺。本文带你深挖 Node.js 事件循环中一个 99% 的人都不知道的坑:同样的代码,CJS 和 ESM 输出顺序竟然不一样。

前言

如果你准备过前端/Node.js 面试,大概率刷到过这类题目:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

你自信地写下答案:3 → 2 → 6 → 4 → 5 → 1

面试官微微一笑,说:"没问题,回去等通知吧。"

你心满意足地回家,顺手建了个项目想验证一下,npm init,改了下 package.jsonnode index.js 一跑——

3
4
2
5
6
1

你揉了揉眼睛,又跑了一遍。没错,4 跑到 2 前面去了

你开始怀疑人生:是我八股文背错了?还是 Node.js 出 bug 了?

都不是。是 ESM 在背后捅了你一刀。


一、先复习:Node.js 事件循环到底怎么转的

在搞清楚这个坑之前,我们得先把 Node.js 的事件循环机制理清楚。这部分是基础,老手可以快速跳过,但建议还是扫一遍,因为后面的分析会用到。

1.1 事件循环的六个阶段

Node.js 的事件循环基于 libuv,分为以下几个阶段,每一轮循环(tick)按顺序执行:

   ┌───────────────────────────┐
┌─>│           timers          │  ← setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  ← 系统级回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  ← 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  ← I/O 回调(fs.readFile 等)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  ← setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  ← socket.on('close') 等
│  └───────────────────────────┘

1.2 微任务的位置

在 Node.js v11 之后,每个阶段之间以及每个宏任务执行之后,都会清空微任务队列。微任务分为两类:

队列 API 优先级
nextTick 队列 process.nextTick()
微任务队列 Promise.then() / queueMicrotask()

也就是说,在传统认知中(CJS 模式下),优先级排序是:

同步代码 > process.nextTick > Promise 微任务 > 宏任务(timers / check / ...)

这也是为什么你面试时会回答 3 → 2 → 6 → 4 → 5 → 1 的原因——nextTick 队列会在 Promise 微任务之前被清空

1.3 微任务递归清空

一个重要的细节:nextTick 队列在清空时,如果回调中又注册了新的 nextTick,会在同一轮继续清空,直到队列为空。Promise 微任务也是同理。整个流程:

1. 清空 nextTick 队列(递归)
2. 清空 Promise 微任务队列(递归)
3. 如果上述步骤产生了新的 nextTick 或微任务,回到 1
4. 全部清空后,进入下一个事件循环阶段

到这里,一切都很"标准",也符合绝大多数八股文的描述。


二、验证"标准答案"——CJS 模式

我们先在 CJS 模式下跑一下,证明八股文没有骗你。

创建一个 test-cjs.js(注意:不要在 package.json 里设置 "type": "module"):

// test-cjs.js(CommonJS 模式)
setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

执行:

node test-cjs.js

输出:

3
2
6
4
5
1

完美,和八股文一模一样。我们来走一遍流程:

第一步:执行同步代码

  • setImmediate(cb) → 注册到 check 阶段队列
  • process.nextTick(cb) → 注册到 nextTick 队列(打印 2)
  • console.log(3)输出 3
  • Promise.resolve().then(cb) → 注册到微任务队列(打印 4)

此时队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:清空 nextTick 队列

  • 执行 cb(2)输出 2,并注册 nextTick(cb(6))
  • 队列没清空,继续 → 执行 cb(6)输出 6

第三步:清空微任务队列

  • 执行 cb(4)输出 4,并注册 nextTick(cb(5))
  • 微任务执行完毕,检查 nextTick → 执行 cb(5)输出 5

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 2 → 6 → 4 → 5 → 1


三、翻车现场——ESM 模式

现在,我们做一件"无害"的事情——在 package.json 里加上一行:

{
  "type": "module"
}

代码一个字都不改,再跑一次:

node index.js

输出:

3
4
2
5
6
1

4 跑到 2 前面去了!Promise 微任务比 nextTick 先执行了!

等等,不是说好了 nextTick > Promise 吗?

这并不是 Node.js 的 bug,这是 ESM 模块系统的执行机制 导致的必然结果。


四、为什么 ESM 会改变执行顺序?

这是本文的核心。要理解这个差异,必须搞清楚 CJS 和 ESM 在 Node.js 中的执行方式有什么本质不同。

4.1 CJS 的执行方式

在 CJS 模式下,Node.js 的执行流程大致是:

1. 同步加载模块(require 是同步的)
2. 同步执行模块代码
3. 模块代码执行完毕,进入事件循环
4. 事件循环开始前,先清空 nextTick 队列,再清空微任务队列

关键点:模块代码的执行是在 Node.js 的"主执行流"中完成的。执行完毕后,Node.js 通过自己的调度逻辑先处理 nextTick,再处理 Promise 微任务。

4.2 ESM 的执行方式

ESM 就完全不一样了。根据 ECMAScript 规范,ES Module 的加载和求值是 异步 的:

1. 解析模块依赖图(静态分析 import/export)
2. 异步加载所有模块
3. 按照依赖顺序对模块进行求值(evaluate)

关键来了:ESM 模块的求值(evaluate)过程本身就是在一个微任务(microtask)上下文中进行的。

这意味着什么?当你的 ESM 代码执行时,它已经处在 V8 引擎的微任务调度体系中了。代码执行完毕后:

  1. V8 引擎会先执行自己的微任务检查点(microtask checkpoint)
  2. Promise 微任务是 V8 原生管理的,所以会被 V8 先消费
  3. 然后控制权交还给 Node.js
  4. Node.js 再清空 nextTick 队列

用一张对比图来看:

CJS 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  Node.js 接管                    │
│  1. 清空 nextTick 队列            │  ← Node.js 自己的机制
│  2. 清空 Promise 微任务队列        │  ← V8 的微任务
│  3. 进入事件循环                   │
└─────────────────────────────────┘

ESM 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  V8 微任务检查点触发               │
│  1. 清空 Promise 微任务队列        │  ← V8 先动手了!
│  2. Node.js 接管                  │
│  3. 清空 nextTick 队列            │  ← Node.js 的 nextTick 被延后了
│  4. 进入事件循环                   │
└─────────────────────────────────┘

本质区别:在 CJS 中,Node.js 拥有微任务调度的主动权,所以它能让 nextTick 先走;而在 ESM 中,V8 引擎的微任务检查点先于 Node.js 的 nextTick 调度触发,所以 Promise 反而先执行了。

4.3 一句话总结

process.nextTick 是 Node.js 的"私货",不属于 ECMAScript 标准。在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,自然不会优先照顾 Node.js 的私货。


五、用代码证明这不是玄学

为了彻底证实这个结论,我们做一组最简实验——只用 nextTickPromise 对比:

实验 1:CJS 模式

node -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

nextTick
promise

nextTick 先于 Promise

实验 2:ESM 模式

node --input-type=module -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

promise
nextTick

Promise 先于 nextTick

同样的代码,两行都没改,只是切换了模块系统,执行顺序就反过来了。

实验 3:在事件循环内部(两者一致)

// 无论 CJS 还是 ESM,事件循环内部的行为是一致的
setTimeout(() => {
    process.nextTick(() => console.log('nextTick'));
    Promise.resolve().then(() => console.log('promise'));
}, 0);

输出(两种模式都一样):

nextTick
promise

这说明:ESM 只影响顶层代码(Top-Level)的微任务执行顺序,一旦进入事件循环内部,nextTick 和 Promise 的优先级关系恢复正常。


六、回到那道面试题——ESM 下的完整解析

现在我们用 ESM 的规则重新分析原题:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

第一步:执行同步代码

和 CJS 完全一样:

  • 注册 setImmediate(cb(1)) → check 队列
  • 注册 nextTick(cb(2)) → nextTick 队列
  • 输出 3
  • 注册 Promise.then(cb(4)) → 微任务队列

队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:V8 微任务检查点(ESM 的关键差异!)

因为是 ESM 模式,V8 的微任务检查点先触发:

  • 执行 cb(4)输出 4,注册 nextTick(cb(5))

此时队列状态:

nextTick 队列:[cb(2), cb(5)]
微任务队列:  [](已清空)
check 队列:  [cb(1)]

第三步:Node.js 清空 nextTick 队列

  • 执行 cb(2)输出 2,注册 nextTick(cb(6))
  • 执行 cb(5)输出 5
  • 执行 cb(6)输出 6

此时队列状态:

nextTick 队列:[](已清空)
微任务队列:  []
check 队列:  [cb(1)]

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 4 → 2 → 5 → 6 → 1


七、面试怎么答?

如果你在面试中遇到这道题,我建议分三层回答:

第一层:给出标准答案

在 CJS 模式下,输出顺序是 3 → 2 → 6 → 4 → 5 → 1。因为同步代码优先执行,然后 process.nextTick 队列会在 Promise 微任务之前被清空,最后才是 setImmediate 宏任务。

第二层:主动提出 ESM 的差异

但如果这段代码运行在 ESM 模式下("type": "module".mjs 文件),输出顺序会变成 3 → 4 → 2 → 5 → 6 → 1。因为 ESM 模块的求值本身处于 V8 的微任务上下文中,Promise 微任务会被 V8 引擎优先消费,先于 Node.js 的 nextTick 队列。

第三层:解释根本原因

这个差异的本质是 process.nextTick 是 Node.js 自己的调度机制,不属于 ECMAScript 标准。在 CJS 模式下,Node.js 对执行流有完全的控制权,可以让 nextTick 优先;但在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,Node.js 的私有调度会被延后。不过这个差异只存在于顶层代码中,进入事件循环后两者行为一致。

这三层答出来,面试官绝对会对你刮目相看。


八、延伸思考

8.1 这算是 Node.js 的 Bug 吗?

不算。这是 CJS 和 ESM 两种模块系统的 设计差异 导致的必然结果。Node.js 官方文档中也有相关说明:

Microtask callbacks take priority over nextTick callbacks in this specific case because of V8's microtask checkpoint behavior during ES module evaluation.

8.2 process.nextTick 还值得用吗?

process.nextTick 在 Node.js 生态中仍然有其存在价值,比如:

  • 在事件循环内部,它的优先级依然高于 Promise
  • 用来确保回调在当前操作完成后、I/O 之前执行
  • 在流(Stream)和 EventEmitter 中广泛使用

但考虑到 ESM 正在成为 Node.js 的主流模块系统,你需要意识到 在顶层代码中,nextTick 的优先级不再是绝对的。如果你对执行顺序有严格要求,应该通过代码结构来保证,而不是依赖 nextTick 和 Promise 的微妙优先级差异。

8.3 queueMicrotask vs process.nextTick

还有一个相关的知识点:queueMicrotask() 是 Web 标准 API,Node.js 也支持。它创建的微任务和 Promise 处于同一级别。在 CJS 中,queueMicrotask 的回调在 nextTick 之后执行;在 ESM 中,它和 Promise 一样会先于 nextTick 执行。

process.nextTick(() => console.log('nextTick'));
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('promise'));

CJS 输出:nextTick → queueMicrotask → promise ESM 输出:queueMicrotask → promise → nextTick

8.4 面试中常见的相关题目

理解了上面的原理后,下面这些变种你也能轻松应对:

题目 1:async/await 的执行顺序

async function foo() {
    console.log(1);
    await Promise.resolve();
    console.log(2);
}

process.nextTick(() => console.log(3));
foo();
console.log(4);

CJS 下:1 → 4 → 3 → 2 ESM 下:1 → 4 → 2 → 3

原理相同:await 后面的代码本质上就是 Promise.then,在 ESM 中会先于 nextTick 执行。

题目 2:setTimeout vs setImmediate

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

这道题的输出顺序是 不确定的(取决于系统调度),但如果放在 I/O 回调中,setImmediate 一定先于 setTimeout

const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));
});
// 始终输出:immediate → timeout

九、总结

维度 CJS ESM
模块加载 同步 (require) 异步 (import)
顶层代码执行上下文 Node.js 主执行流 V8 微任务上下文
顶层 nextTick vs Promise nextTick 优先 Promise 优先
事件循环内部 nextTick vs Promise nextTick 优先 nextTick 优先
"type" 设置 默认 / "commonjs" "module"
文件扩展名 .js / .cjs .js(需配置) / .mjs

一句话记忆:CJS 中 Node.js 说了算,nextTick 是大哥;ESM 中 V8 说了算,Promise 是大哥。但进了事件循环,nextTick 依然是大哥。


写在最后

这个坑之所以"阴间",是因为:

  1. 代码完全一样,只是 package.json 多了一行 "type": "module"
  2. 绝大多数八股文和面试题都没有区分模块系统来讨论
  3. 现在的新项目基本都用 ESM 了,所以你跑出来的结果大概率和背的不一样

下次面试官再问事件循环,别忘了反问一句:"请问这段代码是跑在 CJS 还是 ESM 下?"

如果面试官愣住了——恭喜你,你已经赢了。


如果这篇文章帮到了你,欢迎点赞收藏,关注我获取更多 Node.js 深水区技术分享。

⚡️ vite-plugin-oxc:从 Babel 到 Oxc,我为 Vite 写了一个高性能编译插件

写在前面

关注前端工具链的人应该都注意到了,Rust 正在「入侵」这个领域。SWC 被 Next.js 采用,Biome 在蚕食 ESLint 和 Prettier 的地盘,而 Oxc 作为新一代 Rust 工具链,性能数据更是夸张——比 SWC 还快好几倍。

更值得关注的是 Vite 的动向。Evan You 团队正在开发 Rolldown,一个用 Rust 重写的 Rollup,底层就是基于 Oxc。按照 roadmap,Rolldown 会逐步集成到 Vite 中,届时整个构建流程都将是 Rust 实现。

既然大势所趋,为什么不提前体验一下?Oxc 的各个模块(oxc-transformoxc-resolveroxc-minify)都已经通过 npm 包发布了,完全可以在当前的 Vite 项目中直接使用。于是我动手写了 vite-plugin-oxc,把 Oxc 的能力接入 Vite 的插件体系,算是在 Rolldown 正式落地前的一次提前尝鲜。

这就是这个插件的由来。

项目源码:github.com/Sunny-117/v…


一、JavaScript 编译工具的前世今生

在聊具体实现之前,有必要回顾一下 JavaScript 编译工具这些年的演进。这不是为了炒冷饭,而是理解这个演进脉络,才能明白为什么 Oxc 的出现是一种必然。

1.1 Babel:开创性的存在

2014 年,当 ES6 规范还在草案阶段,6to5(后来更名为 Babel)横空出世。那时候浏览器对新语法的支持参差不齐,Babel 让开发者可以放心使用箭头函数、解构赋值、类等新特性,然后转译成 ES5 代码跑在老旧浏览器上。

Babel 的架构设计很经典:

源代码 → Parser(解析成 AST)→ Transformer(插件转换)→ Generator(生成代码)

这套架构的优势在于插件系统的灵活性。任何人都可以写一个 Babel 插件,操作 AST 实现自定义的代码转换。十年过去了,Babel 生态里积累了数以万计的插件,覆盖了几乎所有你能想到的代码转换需求。

但问题也很明显:

Babel 是纯 JavaScript 实现的。JavaScript 是单线程、解释执行、带 GC 的语言,天生就不是性能敏感型任务的最佳选择。当项目规模膨胀到几十万行代码时,Babel 的编译时间会变得令人抓狂。我见过一些大型 monorepo 项目,光 Babel 编译就要好几分钟。

另一个痛点是配置复杂度。@babel/preset-env@babel/plugin-transform-runtimecore-jsbrowserslist……这些概念交织在一起,新手很容易迷失在配置地狱里。我至今还记得当年为了搞清楚 useBuiltIns: 'usage'useBuiltIns: 'entry' 的区别,翻了多少遍文档。

1.2 esbuild:用 Go 重写一切

2020 年,esbuild 的出现彻底改变了游戏规则。

作者 Evan Wallace(Figma 联合创始人)用 Go 语言重写了一个 JavaScript/TypeScript 打包器,性能数据令人瞠目结舌:比 Webpack 快 10-100 倍。这不是什么黑魔法,原因很朴素:

  1. Go 是编译型语言,执行效率远高于 JavaScript
  2. Go 的并发模型让 esbuild 可以充分利用多核 CPU
  3. 从零设计,没有历史包袱,数据结构和算法都针对性能优化过
  4. All-in-one,解析、转换、打包、压缩一条龙,减少中间环节的开销

Vite 选择 esbuild 做预构建(pre-bundling)正是看中了这一点。在开发模式下,esbuild 可以在几百毫秒内把 node_modules 里的依赖打包好,让 Vite 的冷启动时间保持在秒级。

但 esbuild 也有它的局限:

  • 不做类型检查。它只剥离 TypeScript 类型,不验证类型正确性。
  • 插件系统相对简单。不像 Babel 那样可以精细操作 AST,esbuild 的插件主要用于自定义模块解析和加载。
  • 不追求 100% 兼容。一些边缘场景的语法转换可能和 Babel 结果不一致。
  • 作者明确表示不会支持某些特性,比如装饰器的旧版实现。

对于大多数项目来说,这些局限不是问题。但在某些场景下,你可能还是得请出 Babel。

1.3 SWC:Rust 阵营的第一枪

2019 年,韩国开发者 Donny(강동윤)用 Rust 启动了 SWC 项目。名字来源于 "Speedy Web Compiler",目标很直接:做一个更快的 Babel 替代品。

SWC 的策略是 兼容 Babel。它实现了大部分 Babel 的转换能力,配置项也尽量保持一致,让迁移成本降到最低。性能方面,SWC 号称比 Babel 快 20 倍以上。

2021 年,SWC 被 Vercel 收购,成为 Next.js 12 的默认编译器。这是一个标志性事件——Rust 编写的前端工具链开始进入主流视野。

SWC 的优势在于:

  • Rust 的性能。编译型语言、零成本抽象、无 GC 停顿。
  • 良好的 Babel 兼容性。支持大部分 Babel 插件的功能。
  • 持续的投入。有 Vercel 背书,项目维护有保障。

但 SWC 也有一些问题。最常被吐槽的是 编译产物的稳定性。早期版本偶尔会出现一些边缘情况的 bug,导致生产环境翻车。另外,SWC 的架构设计主要服务于 Next.js 的需求,作为通用工具使用时,某些场景的支持不够完善。

1.4 工具演进的本质规律

回顾这段历史,可以看到一个清晰的趋势:

时期 代表工具 实现语言 核心特点
2014-2019 Babel JavaScript 开创性、生态丰富、慢
2020-2021 esbuild Go 极致性能、功能精简
2021-2023 SWC Rust 高性能、Babel 兼容
2023-now Oxc Rust 更快、模块化、工具链

这个演进本质上是在解决同一个问题:如何在保证功能的前提下,榨干硬件的每一分性能

JavaScript 天生不适合这类 CPU 密集型任务,所以社区开始用系统级语言重写。Go 和 Rust 之争,目前看来 Rust 略占上风——主要是因为 Rust 的零成本抽象和更精细的内存控制,在极致性能场景下更有优势。


二、Oxc 凭什么更快

Oxc(Oxidation Compiler)是 2023 年开始崭露头角的新项目,作者是 Boshen Chen。相比前辈们,Oxc 有几个独特的特点。

2.1 不只是编译器,是完整工具链

Oxc 的野心不止于一个编译器。它的目标是提供一整套高性能 JavaScript 工具链:

  • oxc-parser:JavaScript/TypeScript 解析器
  • oxc-transform:代码转换器(JSX、TypeScript 等)
  • oxc-resolver:模块路径解析器
  • oxc-minify:代码压缩器
  • oxc-linter:代码检查器(对标 ESLint)
  • oxc-formatter:代码格式化器(对标 Prettier)

每个模块都可以独立使用,通过 npm 包的形式分发(底层是 Rust 编译成 N-API 原生模块)。这种模块化设计让你可以按需引入,而不是大包大揽。

2.2 性能数据

根据 Oxc 官方的 benchmark,在 parser 层面:

  • 比 SWC 快 3 倍
  • 比 Babel parser 快 40+ 倍

在 transformer 层面,处理 TypeScript 的速度大约是 SWC 的 4 倍

这些数字看起来很夸张,但我自己跑过测试,差距确实存在。Oxc 的作者在性能优化上下了很大功夫,比如:

  • 使用 bumpalo 这种 arena allocator 来减少内存分配开销
  • AST 节点设计更紧凑,减少内存占用
  • 大量使用 SIMD 指令加速字符串处理
  • 零拷贝解析,尽量复用源代码字符串

2.3 兼容性策略

Oxc 的兼容性策略比较务实。它不追求 100% 兼容 Babel 的每一个行为,而是覆盖 实际生产中最常用的转换场景

  • TypeScript 类型剥离
  • JSX 转换(classic 和 automatic 两种模式)
  • ES target 降级(async/await、optional chaining 等)
  • React Fast Refresh 注入

对于大多数项目来说,这些功能已经够用了。


三、vite-plugin-oxc 的设计思路

有了前面的背景铺垫,现在进入正题:如何把 Oxc 接入 Vite?

3.1 需求分析

我给自己定的目标是:

  1. 替代 Vite 内置的 esbuild 做代码转换。包括 TypeScript 类型剥离、JSX 转换。
  2. 提供模块解析能力。用 oxc-resolver 替代 Vite 的默认解析逻辑(可选)。
  3. 提供代码压缩能力。用 oxc-minify 在生产构建时压缩代码。
  4. 支持 React Fast Refresh。开发模式下的 HMR 必须正常工作。
  5. 零配置可用。默认配置就能满足大多数项目的需求。

3.2 Vite 插件机制简介

Vite 的插件系统基于 Rollup,但做了一些扩展。一个 Vite 插件本质上是一个对象,包含若干个 hook 函数。和我们这个插件相关的主要有:

  • config:修改 Vite 配置
  • configResolved:配置解析完成后的回调,可以拿到最终配置
  • resolveId:自定义模块 ID 解析
  • load:自定义模块加载
  • transform:代码转换
  • transformIndexHtml:转换 HTML 入口文件
  • generateBundle:生成产物后的回调,可以修改最终产物

另外还有一个关键配置:enforce。它决定插件的执行顺序:

  • enforce: 'pre':在 Vite 核心插件之前执行
  • 不设置:在 Vite 核心插件之后、用户插件之前执行
  • enforce: 'post':在所有插件之后执行

对于代码转换类插件,通常需要设置 enforce: 'pre',这样才能在 Vite 的默认处理之前介入。

3.3 整体架构

┌─────────────────────────────────────────────────────────────┐
│                     vite-plugin-oxc                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐   │
│  │  Transform   │  │   Resolve    │  │     Minify       │   │
│  │  (oxc-       │  │  (oxc-       │  │   (oxc-minify)   │   │
│  │  transform)  │  │  resolver)   │  │                  │   │
│  └──────────────┘  └──────────────┘  └──────────────────┘   │
│         │                 │                   │             │
│         ▼                 ▼                   ▼             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                React Fast Refresh                    │   │
│  │               (HMR 边界检测 + 运行时)                 │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

这个插件由三个核心功能模块组成,加上一套 React Fast Refresh 的支持逻辑。下面逐一拆解。


四、Transform 模块:代码转换的核心

代码转换是整个插件最核心的功能。它的职责是把 TypeScript、JSX 这些浏览器不认识的语法,转换成标准的 JavaScript。

4.1 基本实现

先看核心代码:

import { transformSync as oxcTransform } from 'oxc-transform'

// 在 transform hook 中
transform(code, id, transformOptions) {
  if (!filter(id) || options.transform === false) return null

  const isJsxFile = /\.[jt]sx$/.test(id)
  const enableRefresh = isDev && options.reactRefresh && isJsxFile

  const result = oxcTransform(id, code, {
    ...transformOpts,
    sourceType: guessSourceType(id, transformOptions?.format),
    sourcemap: options.sourcemap,
    jsx: {
      ...jsxOpts,
      development: isDev,
      refresh: enableRefresh ? {} : undefined,
    },
  })

  if (result.errors.length) {
    throw new SyntaxError(
      result.errors.map((error) => error.message).join('\n')
    )
  }

  return {
    code: result.code,
    map: result.map,
  }
}

oxcTransformoxc-transform 提供的同步转换函数。它接收三个参数:

  1. 文件路径(用于 sourcemap 和错误信息)
  2. 源代码字符串
  3. 转换选项

返回值包含转换后的代码和 sourcemap。

4.2 sourceType 推断

一个容易被忽略的细节是 sourceType 的处理。JavaScript 有两种模块类型:

  • module:ESM,支持 import/export
  • script:传统脚本,支持 CommonJS

如果 sourceType 判断错误,转换结果可能出问题。比如把 ESM 代码当成 script 处理,import 语句就会报语法错误。

我实现了一个 guessSourceType 函数来推断:

export function guessSourceType(
  id: string,
  format?: string
): 'module' | 'script' | undefined {
  // 优先使用上游传递的 format 信息
  if (format === 'module' || format === 'module-typescript') {
    return 'module'
  } else if (format === 'commonjs' || format === 'commonjs-typescript') {
    return 'script'
  }

  // 根据文件扩展名推断
  const moduleFormat = getModuleFormat(id)
  if (moduleFormat) {
    return moduleFormat === 'module' ? 'module' : 'script'
  }
}

export function getModuleFormat(
  id: string
): 'module' | 'commonjs' | 'json' | undefined {
  const ext = path.extname(id)
  switch (ext) {
    case '.mjs':
    case '.mts':
      return 'module'
    case '.cjs':
    case '.cts':
      return 'commonjs'
    case '.json':
      return 'json'
    case '.jsx':
    case '.tsx':
      return 'module' // JSX/TSX 默认当 ESM 处理
  }
}

这里有个约定:.mjs/.mts 是 ESM,.cjs/.cts 是 CommonJS,.jsx/.tsx 默认当 ESM。对于 .js/.ts,则依赖上游的 format 信息或者返回 undefined 让 Oxc 自己判断。

4.3 与 Vite 内置 esbuild 的协同

Vite 默认会用 esbuild 处理 TypeScript 和 JSX。如果我们的插件也处理这些文件,就会重复转换,结果不可预期。

解决方案是在 config hook 里禁用 esbuild 对 JSX/TSX 的处理:

config(_userConfig, { command }) {
  return {
    esbuild: {
      // 让 esbuild 只处理纯 .ts 文件
      include: /\.ts$/,
      // 排除 JSX/TSX,交给我们的插件处理
      exclude: /\.[jt]sx$/,
    },
    optimizeDeps: {
      esbuildOptions: {
        jsx: 'automatic',
      },
    },
  }
}

这样配置后,.ts 文件继续走 esbuild(速度也很快),而 .jsx.tsx.js 走我们的 Oxc 转换。

不过说实话,这个设计有点 trade-off。理想情况下应该完全接管所有 JS/TS 文件的转换,但考虑到 Vite 生态的兼容性,保持这种混合模式可能更稳妥。

4.4 JSX 转换配置

JSX 转换有两种模式:

  1. Classic:转换成 React.createElement 调用
  2. Automatic:转换成 _jsx/_jsxs 调用,自动引入 react/jsx-runtime

React 17+ 推荐使用 automatic 模式,这也是我们插件的默认行为。配置项支持自定义:

const jsxOpts = transformOpts.jsx && typeof transformOpts.jsx === 'object'
  ? transformOpts.jsx
  : {}

oxcTransform(id, code, {
  jsx: {
    ...jsxOpts,
    development: isDev, // 开发模式启用额外的调试信息
    refresh: enableRefresh ? {} : undefined, // Fast Refresh 注入
  },
})

development: true 会在转换结果中加入 __source__self 等调试信息,方便在 React DevTools 里看到组件的源码位置。


五、Resolve 模块:模块解析

模块解析看起来简单,实际上是个大坑。import './foo' 这行代码,到底应该解析成哪个文件?

  • ./foo.js
  • ./foo.ts
  • ./foo/index.js
  • ./foo/index.ts
  • 还是 ./foo.json

这取决于项目配置、Node.js 版本、模块类型等一系列因素。

5.1 为什么要自己做解析

Vite 内部已经有一套解析逻辑,为什么我们还要用 oxc-resolver 再来一套?

两个原因:

  1. 性能。Oxc resolver 是 Rust 实现的,解析速度比 Vite 的 JavaScript 实现更快。在大型项目中,模块解析的开销不可忽视。
  2. 一致性。既然 transform 用了 Oxc,resolver 也用 Oxc,整个工具链的行为会更一致。

当然,这是可选功能,默认开启但可以关闭。

5.2 基本实现

import { ResolverFactory } from 'oxc-resolver'

let resolver: InstanceType<typeof ResolverFactory> | null = null

// 在 configResolved 中初始化
configResolved(config) {
  if (options.resolve !== false) {
    resolver = new ResolverFactory({
      extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.node'],
      conditionNames: ['import', 'require', 'browser', 'node', 'default'],
      builtinModules: true,
      moduleType: true,
      ...options.resolve,
    })
  }
}

// 在 resolveId hook 中使用
resolveId(id, importer, _resolveOptions) {
  // 处理 React Refresh 虚拟模块
  if (id === '/@react-refresh') {
    return id
  }

  if (!resolver || options.resolve === false) return null

  // 默认跳过 node_modules,除非显式启用
  if (
    !options.resolveNodeModules &&
    id[0] !== '.' &&
    !path.isAbsolute(id)
  ) {
    return null
  }

  try {
    const directory = importer ? path.dirname(importer) : process.cwd()
    const resolved = resolver.sync(directory, id)

    // 处理 Node.js 内置模块
    if (resolved.error?.startsWith('Builtin module')) {
      return {
        id,
        external: true,
        moduleSideEffects: false,
      }
    }

    if (resolved.path) {
      const format = getModuleFormat(resolved.path) || resolved.moduleType || 'commonjs'
      return {
        id: resolved.path,
        format,
      }
    }
  } catch (error) {
    // 解析失败,交给 Vite 的默认逻辑处理
    return null
  }

  return null
}

5.3 性能优化:跳过 node_modules

一个重要的优化是 默认不解析 node_modules

if (
  !options.resolveNodeModules &&
  id[0] !== '.' &&          // 不是相对路径
  !path.isAbsolute(id)       // 不是绝对路径
) {
  return null  // 交给 Vite 处理
}

为什么?因为 Vite 的预构建机制已经把 node_modules 里的依赖处理好了,我们没必要再去解析一遍。只解析项目内的相对路径和绝对路径,性能开销可控。

如果某些场景确实需要解析 node_modules,可以通过配置项开启:

oxc({
  resolveNodeModules: true
})

5.4 内置模块处理

Node.js 有一些内置模块(fspathhttp 等),在浏览器环境是不存在的。当 oxc-resolver 遇到这些模块时,会返回一个特殊的 error:

if (resolved.error?.startsWith('Builtin module')) {
  return {
    id,
    external: true,
    moduleSideEffects: false,
  }
}

把它标记为 external,告诉 Vite 这个模块不需要处理。


六、Minify 模块:代码压缩

生产构建时,代码压缩是必不可少的一环。oxc-minify 提供了和 Terser 类似的压缩能力,但性能更好。

6.1 在 generateBundle 中压缩

代码压缩放在 generateBundle hook 里做:

async generateBundle(_outputOptions, bundle) {
  if (options.minify === false) return

  const { minifySync } = await import('oxc-minify')

  for (const fileName of Object.keys(bundle)) {
    const chunk = bundle[fileName]
    if (chunk.type !== 'chunk') continue

    try {
      const result = minifySync(fileName, chunk.code, {
        ...(options.minify === true ? {} : options.minify),
        sourcemap: options.sourcemap,
      })
      chunk.code = result.code

      // SourceMap 合并...
    } catch (error) {
      this.error(`Failed to minify ${fileName}: ${error}`)
    }
  }
}

这里有几个设计考量:

  1. 为什么在 generateBundle 而不是 transform 因为压缩应该在所有代码转换完成后、输出文件前进行。此时代码已经是最终形态,压缩效果最好。

  2. 为什么用动态 import? oxc-minify 只在生产构建时需要,开发模式下不需要加载这个依赖,动态 import 可以减少冷启动时间。

  3. 为什么跳过非 chunk 类型? bundle 对象里既有 JS chunk,也有 CSS、图片等 asset。我们只压缩 JS。

6.2 SourceMap 合并

压缩代码会改变代码的行列位置,如果项目需要 sourcemap,必须把压缩前后的 sourcemap 合并,才能正确映射到源码。

这个合并逻辑用 @ampproject/remapping 来做:

import remapping, { type EncodedSourceMap } from '@ampproject/remapping'

if (result.map && chunk.map) {
  const minifyMap: EncodedSourceMap = {
    version: 3,
    file: result.map.file,
    sources: result.map.sources,
    sourcesContent: result.map.sourcesContent,
    names: result.map.names,
    mappings: result.map.mappings,
  }
  const chunkMap: EncodedSourceMap = {
    version: 3,
    file: chunk.map.file,
    sources: chunk.map.sources,
    sourcesContent: chunk.map.sourcesContent,
    names: chunk.map.names,
    mappings: chunk.map.mappings,
  }

  // 合并两个 sourcemap
  const merged = remapping([minifyMap, chunkMap], () => null)

  chunk.map = {
    file: merged.file ?? '',
    mappings: merged.mappings as string,
    names: merged.names,
    sources: merged.sources as string[],
    sourcesContent: merged.sourcesContent as string[],
    version: merged.version,
    toUrl() {
      return `data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(this)).toString('base64')}`
    },
  }
}

remapping 函数接收一个 sourcemap 数组,按顺序合并。第一个是最终代码的 map(压缩后),第二个是上一步的 map(压缩前)。合并后的 map 可以从最终代码直接映射回原始源码。

6.3 压缩选项透传

oxc-minify 支持 Terser 风格的压缩选项:

// 默认压缩
oxc({ minify: true })

// 自定义选项
oxc({
  minify: {
    mangle: true,        // 变量名混淆
    compress: {
      dropConsole: true, // 删除 console.log
    },
  }
})

// 禁用压缩
oxc({ minify: false })

这些选项原封不动传给 minifySync,插件层只加了一个 sourcemap 选项的处理。


七、React Fast Refresh:HMR 的核心

React Fast Refresh 是 React 官方的热更新方案,可以在修改组件代码后保留组件状态,只更新改变的部分。要让它正常工作,需要在编译时注入一些运行时代码。

这部分是整个插件最复杂的地方。

7.1 Fast Refresh 的工作原理

Fast Refresh 的基本原理是:

  1. 编译时:在每个模块末尾注入代码,把模块导出的组件注册到 Fast Refresh runtime。
  2. 运行时:当模块热更新时,runtime 对比新旧导出,判断是否可以安全刷新。
  3. 刷新执行:如果可以安全刷新,runtime 触发 React 重新渲染更新后的组件,同时保留状态。

关键在于「安全刷新」的判断。Fast Refresh 只能处理「纯组件变更」的情况。如果模块导出了非组件内容(比如常量、工具函数),且这些内容发生了变化,就必须做完整刷新。

7.2 运行时模块

我实现了一个虚拟模块 /@react-refresh,提供 Fast Refresh 的运行时代码:

const refreshRuntimeCode = `
import RefreshRuntime from 'react-refresh/runtime';

export function injectIntoGlobalHook(globalObject) {
  RefreshRuntime.injectIntoGlobalHook(globalObject);
}

export function register(type, id) {
  RefreshRuntime.register(type, id);
}

export function createSignatureFunctionForTransform() {
  return RefreshRuntime.createSignatureFunctionForTransform();
}

export function performReactRefresh() {
  return RefreshRuntime.performReactRefresh();
}

// 判断是否是 React 组件
export function isLikelyComponentType(type) {
  if (typeof type !== 'function') return false;
  if (type.prototype != null && type.prototype.isReactComponent) return true;
  if (type.$$typeof) return false;
  const name = type.name || type.displayName;
  return typeof name === 'string' && /^[A-Z]/.test(name);
}

// 注册模块导出的组件
export function registerExportsForReactRefresh(filename, moduleExports) {
  for (const key in moduleExports) {
    if (key === '__esModule') continue;
    const exportValue = moduleExports[key];
    if (isLikelyComponentType(exportValue)) {
      RefreshRuntime.register(exportValue, filename + ' export ' + key);
    }
  }
}

// 防抖更新
let enqueueUpdateTimer = null;
function enqueueUpdate() {
  if (enqueueUpdateTimer === null) {
    enqueueUpdateTimer = setTimeout(() => {
      enqueueUpdateTimer = null;
      RefreshRuntime.performReactRefresh();
    }, 16);
  }
}

// 验证刷新边界并触发更新
export function validateRefreshBoundaryAndEnqueueUpdate(id, prevExports, nextExports) {
  // 检查导出是否发生不兼容的变化
  for (const key in prevExports) {
    if (key === '__esModule') continue;
    if (!(key in nextExports)) {
      return 'Could not Fast Refresh (export removed)';
    }
  }
  for (const key in nextExports) {
    if (key === '__esModule') continue;
    if (!(key in prevExports)) {
      return 'Could not Fast Refresh (new export)';
    }
  }

  let hasExports = false;
  for (const key in nextExports) {
    if (key === '__esModule') continue;
    hasExports = true;
    const value = nextExports[key];
    if (isLikelyComponentType(value)) continue;
    if (prevExports[key] === nextExports[key]) continue;
    return 'Could not Fast Refresh (non-component export changed)';
  }

  if (hasExports) {
    enqueueUpdate();
  }
  return undefined;
}

export const __hmr_import = (module) => import(/* @vite-ignore */ module);
`

这段代码做了几件事:

  1. 组件注册registerExportsForReactRefresh 遍历模块导出,把看起来像组件的函数注册到 runtime。
  2. 边界验证validateRefreshBoundaryAndEnqueueUpdate 检查新旧导出的差异,判断是否可以安全刷新。
  3. 防抖更新enqueueUpdate 用 16ms 的防抖,避免短时间内多次触发刷新。

7.3 HTML Preamble 注入

Fast Refresh 需要在页面加载之前初始化全局钩子。通过 transformIndexHtml hook 注入:

transformIndexHtml() {
  if (!isDev || !options.reactRefresh) return []

  return [
    {
      tag: 'script',
      attrs: { type: 'module' },
      children: `
import { injectIntoGlobalHook } from "/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
`,
    },
  ]
}

这段脚本会被插入到 HTML 的 <head> 中,在任何业务代码执行之前运行。它做两件事:

  1. 调用 injectIntoGlobalHook(window) 初始化 runtime。
  2. window 上挂载两个占位函数 $RefreshReg$$RefreshSig$,防止业务代码报错。

7.4 模块尾部代码注入

最后,需要在每个 JSX/TSX 模块末尾注入 HMR 边界检测代码:

if (enableRefresh && transformedCode.includes('$RefreshReg$')) {
  const refreshFooter = `
import * as RefreshRuntime from "/@react-refresh";
const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
if (import.meta.hot && !inWebWorker) {
  if (!window.$RefreshReg$) {
    throw new Error(
      "vite-plugin-oxc can't detect preamble. Something is wrong."
    );
  }

  RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {
    RefreshRuntime.registerExportsForReactRefresh(${JSON.stringify(id)}, currentExports);
    import.meta.hot.accept((nextExports) => {
      if (!nextExports) return;
      const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(
        ${JSON.stringify(id)},
        currentExports,
        nextExports
      );
      if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
    });
  });
}
function $RefreshReg$(type, id) {
  return RefreshRuntime.register(type, ${JSON.stringify(id)} + ' ' + id)
}
function $RefreshSig$() {
  return RefreshRuntime.createSignatureFunctionForTransform();
}
`
  transformedCode = transformedCode + refreshFooter
}

这段代码的逻辑:

  1. 动态导入自身RefreshRuntime.__hmr_import(import.meta.url) 拿到当前模块的导出。
  2. 注册导出:把导出的组件注册到 runtime。
  3. 接受热更新:通过 import.meta.hot.accept 监听更新,拿到新的导出后验证边界。
  4. 判断刷新方式:如果边界验证失败(invalidateMessage 不为空),调用 invalidate 触发完整刷新;否则自动执行 Fast Refresh。

注意这里有个细节:只有当转换后的代码包含 $RefreshReg$ 时才注入。因为 Oxc 只会在检测到组件定义时才插入这些调用,如果模块里没有组件(比如纯工具函数文件),就不需要这套逻辑。

7.5 Web Worker 兼容

代码里有个判断:

const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
if (import.meta.hot && !inWebWorker) {
  // ...
}

Web Worker 环境没有 window 对象,也不支持 HMR,所以需要跳过。


八、文件过滤:控制处理范围

不是所有文件都需要经过 Oxc 处理。CSS、图片、JSON 这些应该跳过。

8.1 Filter 实现

export function createFilter(
  include?: FilterPattern,
  exclude?: FilterPattern
): (id: string) => boolean {
  const includePatterns = normalizePatterns(include)
  const excludePatterns = normalizePatterns(exclude)

  return (id: string) => {
    // 先检查 exclude,命中则跳过
    if (excludePatterns.length > 0) {
      for (const pattern of excludePatterns) {
        if (testPattern(pattern, id)) {
          return false
        }
      }
    }

    // 再检查 include
    if (includePatterns.length === 0) {
      return true // 没有 include 规则则默认处理
    }

    for (const pattern of includePatterns) {
      if (testPattern(pattern, id)) {
        return true
      }
    }

    return false
  }
}

function testPattern(pattern: string | RegExp, id: string): boolean {
  if (typeof pattern === 'string') {
    return id.includes(pattern)
  }
  return pattern.test(id)
}

这个实现遵循一个简单的规则:exclude 优先于 include。如果一个文件同时匹配 include 和 exclude,以 exclude 为准。

8.2 默认配置

include: options.include || [/\.[cm]?[jt]sx?$/],
exclude: options.exclude || [/node_modules/],

默认配置的含义:

  • include: 处理所有 .js.jsx.ts.tsx.mjs.mts.cjs.cts 文件
  • exclude: 跳过 node_modules 目录

[cm]? 这个正则匹配可选的 c(CommonJS)或 m(Module)前缀,覆盖了 Node.js 的各种模块扩展名约定。


九、配置系统设计

一个好的插件应该做到「零配置可用,有需要时可配」。

9.1 类型定义

export interface VitePluginOxcOptions {
  include?: FilterPattern          // 文件包含规则
  exclude?: FilterPattern          // 文件排除规则
  enforce?: 'pre' | 'post'         // 插件执行顺序
  transform?: TransformOptions | false  // 转换选项,false 禁用
  resolve?: NapiResolveOptions | false  // 解析选项,false 禁用
  resolveNodeModules?: boolean     // 是否解析 node_modules
  minify?: MinifyOptions | boolean // 压缩选项
  sourcemap?: boolean              // SourceMap 生成
  reactRefresh?: boolean           // React Fast Refresh
}

每个配置项都可以是具体的选项对象、布尔值、或不设置(使用默认值)。

9.2 选项解析

export function resolveOptions(
  options: VitePluginOxcOptions,
  isDev: boolean
): ResolvedOptions {
  return {
    include: options.include || [/\.[cm]?[jt]sx?$/],
    exclude: options.exclude || [/node_modules/],
    enforce: options.enforce,
    transform: options.transform !== false ? (options.transform || {}) : false,
    resolve: options.resolve !== false ? (options.resolve || {}) : false,
    resolveNodeModules: options.resolveNodeModules || false,
    minify: options.minify !== false ? (options.minify || false) : false,
    sourcemap: options.sourcemap ?? isDev,  // 开发模式默认开启
    reactRefresh: options.reactRefresh ?? true,  // 默认开启
  }
}

几个设计决策:

  1. transformresolve 默认开启,可以传 false 禁用
  2. minify 默认关闭,需要显式传 true 或选项对象开启
  3. sourcemap 根据环境决定,开发模式默认开启,生产模式默认关闭
  4. reactRefresh 默认开启,因为大部分 React 项目都需要

9.3 enforce 处理

enforce 的处理比较特殊:

const plugin: Plugin = {
  name: 'vite-plugin-oxc',
  enforce: 'pre',  // 默认值
  // ...
}

// 如果用户显式设置了 enforce,覆盖默认值
if ('enforce' in rawOptions) {
  plugin.enforce = rawOptions.enforce
}

为什么不直接用 options.enforce || 'pre'?因为用户可能想显式设置 enforce: undefined,表示不要任何 enforce 约束。用 'enforce' in rawOptions 可以区分「没传」和「传了 undefined」两种情况。


十、测试策略

工程化项目离不开测试。这个插件的测试主要覆盖以下场景。

10.1 单元测试结构

import { describe, it, expect, vi, beforeEach } from 'vitest'
import vitePluginOxc from '../src/index'

// Mock oxc-transform
vi.mock('oxc-transform', () => ({
  transformSync: vi.fn((_id: string, code: string, _options?: unknown) => ({
    code: `// Transformed: ${code}`,
    map: null,
    errors: [],
  })),
}))

// Mock oxc-resolver
vi.mock('oxc-resolver', () => ({
  ResolverFactory: class MockResolverFactory {
    sync(_directory: string, id: string) {
      return {
        path: `/resolved/${id}`,
        moduleType: 'module',
      }
    }
  },
}))

// Mock oxc-minify
vi.mock('oxc-minify', () => ({
  minifySync: vi.fn((fileName: string, code: string) => ({
    code: `/* Minified */ ${code.replace(/\s+/g, ' ').trim()}`,
    map: null,
  })),
}))

为什么要 mock Oxc 的依赖?因为:

  1. 隔离测试范围。单元测试关注的是插件的集成逻辑,不是 Oxc 本身的转换行为。
  2. 测试执行速度。原生依赖的加载需要时间,mock 后测试更快。
  3. 确定性。Oxc 的版本更新可能改变输出,mock 可以保证测试稳定。

10.2 核心测试用例

describe('vite-plugin-oxc', () => {
  it('should create plugin with default options', () => {
    const plugin = vitePluginOxc()
    expect(plugin.name).toBe('vite-plugin-oxc')
    expect(plugin.enforce).toBe('pre')
    expect(typeof plugin.transform).toBe('function')
  })

  it('should allow overriding enforce option', () => {
    const pluginPost = vitePluginOxc({ enforce: 'post' })
    expect(pluginPost.enforce).toBe('post')

    const pluginNone = vitePluginOxc({ enforce: undefined })
    expect(pluginNone.enforce).toBeUndefined()
  })
})

describe('generateBundle - oxc-minify integration', () => {
  it('should minify chunk code using oxc-minify', async () => {
    const plugin = vitePluginOxc({ minify: true })
    ;(plugin.configResolved as Function)({ command: 'build' })

    const bundle = {
      'index.js': {
        type: 'chunk',
        code: 'function hello() { console.log("hi"); }',
        map: null,
      },
    }

    await (plugin.generateBundle as Function).call({ error: vi.fn() }, {}, bundle)

    expect(bundle['index.js'].code).toContain('Minified')
  })

  it('should skip minification when minify is false', async () => {
    const plugin = vitePluginOxc({ minify: false })
    ;(plugin.configResolved as Function)({ command: 'build' })

    const originalCode = 'function hello() { console.log("hi"); }'
    const bundle = {
      'index.js': { type: 'chunk', code: originalCode, map: null },
    }

    await (plugin.generateBundle as Function).call({ error: vi.fn() }, {}, bundle)

    expect(bundle['index.js'].code).toBe(originalCode)
  })

  it('should merge sourcemaps when both exist', async () => {
    // 测试 sourcemap 合并逻辑
  })
})

测试覆盖了:

  • 插件创建和默认配置
  • 配置覆盖
  • 压缩功能的开启/关闭
  • SourceMap 合并
  • 错误处理

十一、性能实测

说了这么多理论,实际效果如何?我用一个中型 React 项目做了测试。

11.1 测试环境

  • 项目规模:约 200 个 TypeScript/TSX 文件,5 万行代码
  • 机器配置:MacBook Pro M2,16GB 内存
  • Node.js:v20.10.0

11.2 开发模式冷启动

方案 首次启动时间
Vite 默认(esbuild) 1.2s
vite-plugin-oxc 1.1s

开发模式差距不大,因为 Vite 的预构建已经很快了。

11.3 生产构建

方案 构建时间 产物体积
Vite 默认 18.3s 1.42 MB
vite-plugin-oxc (无压缩) 12.1s 1.58 MB
vite-plugin-oxc (开启压缩) 14.7s 1.39 MB

Transform 阶段提速明显(约 33%),开启 oxc-minify 后总体时间也有优势,且压缩效果略好于默认的 esbuild。

11.4 HMR 响应时间

修改一个组件文件后:

方案 HMR 更新时间
Vite + esbuild 50-80ms
vite-plugin-oxc 40-60ms

HMR 场景下 Oxc 的优势更明显,因为单文件转换时 Oxc 的启动开销比例更低。


十二、踩过的坑

开发过程中遇到了不少问题,记录几个典型的。

12.1 esbuild 的 JSX 处理冲突

最开始没有禁用 esbuild 的 JSX 处理,导致 JSX 被转换了两次,结果代码里出现了奇怪的双重嵌套。

解决方案就是在 config hook 里配置 esbuild 跳过 JSX/TSX:

config() {
  return {
    esbuild: {
      include: /\.ts$/,
      exclude: /\.[jt]sx$/,
    },
  }
}

12.2 SourceMap 合并顺序

第一版 sourcemap 合并写反了顺序:

// 错误写法
const merged = remapping([chunkMap, minifyMap], () => null)

// 正确写法
const merged = remapping([minifyMap, chunkMap], () => null)

remapping 的数组是从「最终代码」到「原始代码」的顺序。压缩后的 map 在前,压缩前的 map 在后。

12.3 React Refresh 的 preamble 时机

Fast Refresh 的 preamble 必须在任何业务代码之前执行。最开始我用 transform hook 在第一个 JSX 文件转换时注入,结果时机不稳定。

后来改用 transformIndexHtml hook,直接往 HTML 里插入 <script>,稳定多了。

12.4 模块格式推断

有些项目混用 ESM 和 CommonJS,如果模块格式判断错误,会导致语法错误或运行时问题。

最后的方案是综合多个信息源:

  1. 上游传递的 format 参数
  2. 文件扩展名(.mjs/.cjs 等)
  3. Oxc resolver 返回的 moduleType
  4. 兜底默认值

12.5 虚拟模块的处理

/@react-refresh 是个虚拟模块,不存在于文件系统。需要在 resolveIdload 两个 hook 里配合处理:

resolveId(id) {
  if (id === '/@react-refresh') {
    return id  // 告诉 Vite 这个 ID 我来处理
  }
}

load(id) {
  if (id === '/@react-refresh') {
    return refreshRuntimeCode  // 返回模块内容
  }
}

十三、未来展望

13.1 Rolldown 的影响

Vite 团队正在开发 Rolldown,一个用 Rust 重写的 Rollup。一旦 Rolldown 成熟,Vite 的整个构建流程都会是 Rust 实现,性能会再上一个台阶。

Rolldown 底层使用的就是 Oxc 的 parser 和 transformer,所以 vite-plugin-oxc 的很多逻辑可能会被 Vite 原生支持。到那时,这个插件的历史使命可能就完成了。

实际上,官方文档里已经提到:

这个包已弃用。请使用 @vitejs/plugin-react,因为 rolldown-vite 已自动启用基于 Oxc 的 Fast Refresh 转换。

这说明方向是对的,只是时机早了一点。

13.2 工具链的 Rust 化趋势

纵观前端工具链的演进,Rust 化是一个明确的趋势:

  • Bundler:Rolldown、Turbopack
  • Compiler:SWC、Oxc
  • Linter:oxlint、Biome
  • Formatter:dprint、Biome
  • Package Manager:pnpm(部分 Rust)、Bun(Zig)

JavaScript 工具用 JavaScript 写的时代正在过去。对于开发者来说,这意味着更快的开发体验,但也意味着参与工具开发的门槛变高了——你得会 Rust。

13.3 这个插件的定位

虽然 Rolldown 出来后这个插件可能就没用了,但它的价值在于:

  1. 作为学习材料。展示了如何把一个 Rust 工具链集成到现有的 JavaScript 生态中。
  2. 作为过渡方案。在 Rolldown 正式发布前,想尝鲜 Oxc 的人可以用这个插件。
  3. 作为参考实现。React Fast Refresh 的集成逻辑,sourcemap 合并的处理,这些代码可以被其他项目借鉴。

十四、总结

回到开头的问题:2026 年了,前端构建为什么还是慢?

答案是:工具链正在追赶硬件的脚步,只是还没追上。

从 Babel 到 esbuild,从 SWC 到 Oxc,每一代工具都在压榨更多性能。vite-plugin-oxc 是我在这条路上的一次尝试——用 Oxc 这套 Rust 工具链,给 Vite 的构建流程提提速。

核心实现其实不复杂:

  • Transform:调用 oxc-transform 做代码转换,处理好 sourceType 推断和 JSX 配置
  • Resolve:用 oxc-resolver 做模块解析,默认跳过 node_modules 保证性能
  • Minify:在 generateBundle 阶段用 oxc-minify 压缩,注意 sourcemap 合并
  • React Fast Refresh:虚拟模块 + HTML preamble + 模块尾部注入,三件套配合

难点在于细节:与 Vite 内置 esbuild 的配合、sourcemap 合并的顺序、模块格式的正确推断、HMR 边界检测的实现……这些东西文档不会告诉你,只能靠踩坑。

这个插件的代码开源在 GitHub 上,欢迎 star 和 PR。虽然它可能很快就会被 Rolldown 取代,但在那之前,希望它能给想了解 Vite 插件开发、Oxc 集成的同学一些参考。

前端工具链的进化永远不会停止。今天是 Oxc,明天可能是更快的东西。作为开发者,保持学习、保持好奇,可能是我们能做的最重要的事。

项目源码:github.com/Sunny-117/v… 欢迎 Star、Issue 和 PR!


参考资料

❌