编程 Vite 8 + Rolldown 深度拆解:Rust 打包器如何终结「dev ≠ prod」,以及 Cloudflare 收购 VoidZero 之后的前端工具链版图

2026-07-30 21:15:54 +0800 CST views 10

Vite 8 + Rolldown 深度拆解:Rust 打包器如何终结「dev ≠ prod」,以及 Cloudflare 收购 VoidZero 之后的前端工具链版图

有个问题,如果你写了几年前端,大概率半夜遇到过:本地 vite dev 跑得好好的,一执行 vite build,页面白屏、import.meta.envundefined、某个 ?raw 导入的字符串莫名其妙变成了模块对象。你翻遍配置找不到原因,最后在某个 GitHub issue 里看到一句轻描淡写的回复:「dev 用 esbuild,build 用 Rollup,这两个东西的行为本来就不一样。」

这不是 bug,这是架构。Vite 从 1.x 到 7.x,心脏一直是两个:开发态 esbuild,生产态 Rollup。这个设计在 2019 年是天才,在 2026 年是债。

Vite 8 干的事情,说白了就一句话:把两颗心脏换成一颗,换成用 Rust 写的 Rolldown。

这篇文章不打算复述发布公告。我们要做的是:把 Rolldown 的内部流水线拆开看,把「快 10-30 倍」这个数字放在显微镜下验证,把 Full Bundle Mode 和模块级持久缓存这些真正解锁的新能力讲透,最后聊聊 Cloudflare 把 VoidZero 收了之后,这条工具链的钱和权力结构会怎么变。


一、背景:Vite 的双打包器架构,是怎么从优势变成负债的

1.1 2019 年那个天才决定

回到 Vite 诞生的那个时间点。当时前端构建的主流是 Webpack,痛点非常明确:

  • 冷启动慢:Webpack 必须把整个依赖图扫一遍、打成 bundle,才能让 dev server 跑起来。中型项目 30 秒起步,大型项目 2-5 分钟。
  • HMR 慢:改一行代码,Webpack 要重新走一遍受影响的模块链路,热更新延迟随项目规模线性增长。

Vite 的解法是 no-bundle dev:既然现代浏览器原生支持 ESM,那开发时根本不需要打包。浏览器请求哪个模块,dev server 就现场转译哪个模块,转译完直接吐给浏览器。冷启动时间从「和项目规模成正比」变成「和项目规模基本无关」。

但这里有两个问题需要解决:

  1. node_modules 里的包不是 ESM(大量 CJS),而且一个 lodash 就有 600 多个文件,浏览器逐个请求会直接卡死。→ 解法:依赖预构建(dependency pre-bundling),用 esbuild 把 CJS 转 ESM 并合并成少数几个文件。
  2. 生产环境不能用 no-bundle。HTTP/2 再快,几千个模块请求的瀑布流也扛不住,而且没有 tree-shaking、没有代码分割。→ 解法:生产构建用 Rollup,成熟稳定,tree-shaking 业界标杆。

于是就有了这个经典架构:

开发态:Vite Dev Server + esbuild(转译 TS/JSX + 依赖预构建)
生产态:Vite Build      + Rollup (打包 + chunk 分割 + tree-shaking + minify)

这个决定让 Vite 团队把精力集中在「开发体验和整体编排」上,而不是从零造一个解析器和打包器。战略上完全正确,Vite 也确实靠这个吃下了整个前端脚手架市场。

1.2 债从哪里开始滚的

问题在于,esbuild 和 Rollup 是两个独立演进、设计哲学不同的项目:

维度esbuildRollup
语言GoJavaScript
定位极速转译 + 简单打包精细打包 + 生态插件
插件 APIesbuild plugin(简化版)Rollup plugin(钩子丰富)
tree-shaking有,但相对保守业界最激进最精细
CJS 互操作自己一套规则自己另一套规则
装饰器/Legacy 语法支持有限靠插件生态

这带来了三类具体的、每天都在咬人的问题:

**第一类:转换流水线分裂。**同一个 .tsx 文件,dev 时走 esbuild 的 transform,build 时走 @vitejs/plugin-react + Rollup 的 transform。两者对 enumnamespace、参数装饰器、useDefineForClassFields 的处理细节不完全一致。你在 dev 里写的某个 TS 特性,build 出来行为变了。

**第二类:插件系统割裂。**写一个 Vite 插件,你得同时考虑:

// Vite 7 时代典型的「双面人」插件
export default function myPlugin() {
  return {
    name: 'my-plugin',

    // Rollup 钩子:build 时生效
    transform(code, id) {
      if (!id.endsWith('.special')) return
      return transformSpecial(code)
    },

    // Vite 专属:dev server 生效
    configureServer(server) {
      server.middlewares.use('/api/special', handler)
    },

    // 还得单独管依赖预构建阶段的 esbuild
    config() {
      return {
        optimizeDeps: {
          esbuildOptions: {
            plugins: [esbuildVersionOfMyPlugin()] // 同样逻辑写第二遍
          }
        }
      }
    }
  }
}

同一个转换逻辑,在依赖预构建(esbuild)、dev transform(Vite 自己的流水线)、prod build(Rollup)三个地方可能要写三次。这不是插件作者矫情,这是架构强加的税。

**第三类:功能天花板。**你想在 dev server 里用 Module Federation?Rollup 生态有对应方案,esbuild 侧没有 → 做不了。你想让 dev 和 prod 的 chunk 策略保持一致做性能验证?dev 根本没有 chunk 概念 → 做不了。

Vite 核心团队为了弥合这些差异,写了大量胶水代码。有维护者在 issue 里说过一句很有画面感的话:「我们感觉像是在同时维护两个不同的构建系统。」

1.3 还有一个被低估的问题:生产构建其实一点都不快

这一点非常容易被忽略。Vite 的宣传语一直是「快」,但那个「快」几乎全部来自 dev server 冷启动。生产构建这块,Vite 用的是 Rollup,而 Rollup 是纯 JavaScript 写的单线程打包器

结果就是:一个中大型项目,vite dev 300ms 启动,vite build 跑 2 分钟。开发爽翻,CI 里排队等构建。项目越大这个反差越荒诞。

有前端工程师(PayFit 团队)公开分享过他们把 Rolldown 集成进一个复杂代码库的测试结果:构建时间从约 120 秒降到约 8 秒,只需要额外加一个 polyfill 插件。这个 15 倍不是实验室数据,是真实业务代码库。

所以 Vite 8 换引擎,解决的不只是「一致性」这个洁癖问题,更是一个实打实的 CI 成本问题。


二、Rolldown 是什么:Rust + Oxc 的双层结构

2.1 定位:不是又一个打包器,是「为 Vite 定制的 Rollup 替代品」

Rolldown 的项目定位写得很清楚:Fast Rust bundler for JavaScript/TypeScript with Rollup-compatible API。三个关键词:

  • Rust:原生速度,多线程,无 GC 停顿。
  • Rollup-compatible API:这是最关键的战略选择。它没有另起炉灶发明一套插件协议,而是直接兼容 Rollup/Vite 的插件 API。这意味着现有的 Vite 插件生态大部分可以直接跑
  • for Vite:它不是一个通用打包器碰巧被 Vite 用了,它从第一行代码开始就是为了当 Vite 的引擎。

这个「兼容优先」的决策,是 Rolldown 和 Turbopack 最大的路线差异。Turbopack 选择了自己一套体系(Webpack 迁移成本高),Rolldown 选择了寄生在 Rollup 生态里再逐步替换内核。从落地速度看,Rolldown 这条路明显更快。

2.2 底座:Oxc 工具链

Rolldown 自己不写解析器、不写压缩器,它站在 Oxc(The JavaScript Oxidation Compiler)上。Oxc 同样是 Rust 写的,同样在 VoidZero 旗下,提供一整套组件:

组件作用对应的 JS 生态老熟人
oxc_parserJS/TS/JSX 解析成 ASTAcorn / SWC parser
oxc_resolver模块路径解析(含 exports/imports 字段)enhanced-resolve
oxc_transformerTS 擦除、JSX 转换、语法降级Babel / esbuild transform
oxc_minifier压缩混淆Terser / esbuild minify
oxc_linter (Oxlint)代码检查ESLint
oxc_formatter格式化Prettier

于是整条链路变成了这样:

Vite        ← 开发服务器、编排、插件宿主
  └─ Rolldown   ← 打包、chunk 分割、tree-shaking
       └─ Oxc      ← parse / resolve / transform / minify

**同一个团队维护、同一套 AST、同一份语义规则。**这就是「统一工具链」的真正含义——不是把三个包塞进一个 monorepo,而是消除三层之间的语义翻译损耗。

举个具体的:以前 esbuild 解析出来的 AST 和 Rollup(Acorn)解析出来的 AST 是两棵完全不同的树,Vite 想在中间做点什么就得反复 parse-print-parse。现在 dev 和 build 共用 Oxc AST,中间环节可以直接传结构而不是传字符串。这块省下来的时间,在大型项目上是数量级的。

2.3 性能定位:和 esbuild 打平,比 Rollup 快 10-30 倍

官方给出的性能定位是:

  • 对比 esbuild:性能水平相当(esbuild 是 Go 写的,本来就极快,Rolldown 做到打平已经是硬指标)
  • 对比 Rollup:快约 10-30 倍

这个 10-30 倍的区间跨度很大,原因也很好理解:Rollup 慢在两处,单线程JS 运行时开销。项目模块数越多、CPU 核数越多,Rolldown 的多线程优势就越明显;小项目上差距会收窄到 5-10 倍。

但这里我要泼一盆冷水:这个倍数说的是打包阶段。你的实际 vite build 时间还包含:

  • 你自己写的 JS 插件的 transform 时间(这部分一点都没变快
  • @vitejs/plugin-vue / plugin-react 的 SFC 编译时间
  • Sass/Less 编译时间
  • 类型检查(如果你在 build 里跑了 vue-tsc
  • 静态资源处理、图片压缩

我见过的真实情况是:一个 Vue 项目 build 从 90s 降到 25s,很好,但没到 15 倍——因为里面有 40 秒是 vue-tsc 在跑类型检查,Rolldown 一点忙都帮不上。

**结论:Rolldown 让打包不再是瓶颈,但它会把你项目里真正的瓶颈暴露出来。**这反而是好事。


三、架构拆解:Rolldown 内部到底发生了什么

要理解 Rolldown 为什么快,得看它的流水线。一个现代打包器的核心流程大致分四段,我们逐段看 Rolldown 的做法。

3.1 Scan 阶段:并行模块图构建

这是最容易并行化、也是收益最大的一段。

入口文件 → resolve → load → parse → 提取 import/export → 递归

Rollup 的做法:JS 单线程,虽然 resolve/load 是异步的(可以并发 I/O),但 parse 这个 CPU 密集操作只能排队。100 个文件就得串行 parse 100 次。

Rolldown 的做法:用 Rust 的线程池(基于 rayon 这类并行原语),把 parse 任务分发到所有 CPU 核心。8 核机器上,理论上 parse 阶段直接 8 倍。

用伪代码表达这个差异:

// Rolldown 侧(概念示意,非真实源码)
let modules: Vec<Module> = pending_ids
    .par_iter()                      // 并行迭代器
    .map(|id| {
        let source = fs::read_to_string(id)?;
        let ast = oxc_parser::parse(&source, source_type)?;
        let deps = extract_imports(&ast);
        Module { id, ast, deps }
    })
    .collect();
// Rollup 侧(概念示意)
for (const id of pendingIds) {
  const source = await load(id)
  const ast = acorn.parse(source)   // 单线程,逐个来
  const deps = extractImports(ast)
  modules.push({ id, ast, deps })
}

一个额外收益:Rust 侧的 AST 用 arena 分配(oxc_allocator),整棵 AST 在一块连续内存里,parse 完直接整块释放,没有 GC 遍历成千上万个小对象的开销。这在超大项目上是显著差异——V8 的 GC 在处理几百万个 AST 节点时会成为主要瓶颈之一。

模块图建好之后,要做符号级的链接:谁 export 了什么、谁 import 了什么、哪些 export 没人用(可以 shake 掉)。

这一步的难点在于 ESM 和 CJS 的互操作。举个经典的坑:

// a.cjs
module.exports = { foo: 1 }

// b.mjs
import a from './a.cjs'          // a 是 { foo: 1 }?
import { foo } from './a.cjs'    // 这个能不能 work?

答案取决于打包器有没有做静态分析识别出这是个「可命名导入的 CJS」。esbuild 和 Rollup 在这里的规则不完全一样,这就是 Vite 7 时代 dev/prod 行为差异最大的重灾区之一。

Rolldown 统一了这套规则,dev 和 build 用同一份互操作逻辑。这是「dev ≠ prod」问题最实质性的修复——不是修了一个 bug,是消除了一整类 bug 的产生条件

tree-shaking 方面,Rolldown 需要对齐 Rollup 的精细度,这块是它最难啃的骨头。Rollup 的 tree-shaking 做得非常激进:它会做副作用分析、纯函数标注(/*#__PURE__*/)识别、类属性副作用推导等。Rolldown 在这块是逐步追赶的状态,早期版本在某些边缘 case 上产物会比 Rollup 略大。升级后务必对比一下产物体积,这是我后面会给的检查清单里的一项。

3.3 Chunk 阶段:这是 Vite 8 真正解锁新能力的地方

模块分组成 chunk,决定哪些代码打进哪个文件。Rollup 时代你能用的主要是 manualChunks

// Vite 7 / Rollup 时代
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules/react')) return 'react-vendor'
          if (id.includes('node_modules/lodash')) return 'lodash'
          if (id.includes('node_modules')) return 'vendor'
        }
      }
    }
  }
}

这个 API 的问题是:它是命令式的、逐模块判断的,你没法表达「把所有小于 20KB 的 vendor 合并成一个」「这组模块最多分成 5 个 chunk」这类约束。想做精细的分包策略,只能自己写一堆 hash 逻辑。

Rolldown 引入了声明式的 Advanced Chunks

// vite.config.ts —— Vite 8 / Rolldown
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        advancedChunks: {
          // 全局约束:太小的 chunk 会被合并回去
          minSize: 20 * 1024,
          // 全局约束:太大的 chunk 会被拆开
          maxSize: 300 * 1024,

          groups: [
            {
              name: 'framework',
              test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
              priority: 100,           // 优先级高,先匹配
            },
            {
              name: 'ui',
              test: /node_modules[\\/](antd|@ant-design)[\\/]/,
              priority: 90,
              minSize: 50 * 1024,      // 组级覆盖全局
            },
            {
              // 动态命名:按 npm 包名拆分
              name(moduleId) {
                const m = moduleId.match(/node_modules[\\/](?:(@[^\\/]+)[\\/])?([^\\/]+)/)
                if (!m) return null
                return `vendor-${(m[1] ?? '').replace('@', '')}${m[2]}`
              },
              test: /node_modules/,
              priority: 10,
              maxModules: 50,          // 单 chunk 最多 50 个模块
            },
          ],
        },
      },
    },
  },
})

注意:Advanced Chunks 的字段名和能力在 Rolldown 迭代中仍有调整,落地前请以你锁定版本的官方文档为准。上面这段展示的是设计思路:从「你自己算」变成「你声明约束,打包器求解」

这个改变在实践中的价值:以前做首屏优化,你要手写脚本分析 stats、算 chunk 大小、反复调 manualChunks 里的 if-else。现在你直接声明「framework 单独一包、UI 库单独一包、剩下的按包名拆但每包不小于 20KB」,打包器帮你求解。

3.4 Codegen 阶段:生成 + 压缩

最后一步是把 chunk 里的模块拼接成最终代码,做 scope hoisting(把模块函数打平进同一个作用域,消除运行时开销),然后 minify。

Rolldown 用 oxc_minifier 做压缩。这块的现状值得说清楚:Oxc minifier 的压缩率目前和 Terser 还有差距,但速度快非常多。Vite 8 默认的选择是速度优先,如果你的场景对包体积极度敏感(比如小程序、嵌入式 H5),可以显式切回 Terser:

export default defineConfig({
  build: {
    minify: 'terser',              // 换回 Terser
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
        pure_funcs: ['console.log', 'console.info'],
      },
      format: { comments: false },
    },
  },
})

权衡很直白:Oxc minifier 快 10 倍以上但产物可能大 2-5%,Terser 慢但压得狠。CI 时间贵就用前者,CDN 流量贵就用后者。这是个可以用钱算清楚的决策。


四、Full Bundle Mode:Vite 最有争议、也最重要的一次自我否定

这是 Vite 8 里我认为最值得单独拿出来讲的部分。

4.1 no-bundle dev 的隐藏代价

Vite 的立身之本是 no-bundle dev。但这个设计有一个规模上限,很多人没意识到:

每个模块 = 一个 HTTP 请求。

小项目:200 个模块,浏览器并发请求,200ms 搞定,爽。
中项目:2000 个模块,浏览器开始排队,首次加载 3-5 秒。
大项目:10000+ 模块,Chrome DevTools 的 Network 面板直接卡住,首屏 10 秒起步,改一行代码触发级联失效后又要重来一遍。

更麻烦的是深层依赖链。假设 A → B → C → D → E,浏览器必须:请求 A → 解析出要 B → 请求 B → 解析出要 C → ……五个串行往返。Vite 有依赖预扫描和 modulepreload 优化来缓解,但缓解不等于消除。

所以出现了一个尴尬事实:Vite 在小项目上快得离谱,在超大项目上反而可能不如打过包的 dev server。

4.2 Rolldown 带来的第三条路

既然现在打包器快到「打包一次只要几百毫秒」,那为什么不在 dev 时也打包呢?

这就是 Full Bundle Mode:开发时也用 Rolldown 打成少量 bundle 送给浏览器。

// vite.config.ts —— 概念性配置,实际字段以版本文档为准
export default defineConfig({
  experimental: {
    // 开发态也走完整打包
    fullBundleMode: true,
  },
})

三种模式的对比:

模式冷启动首屏加载HMR 延迟dev/prod 一致性适用规模
no-bundle(Vite 1-7)极快(<500ms)随模块数线性劣化快(单模块)小到中型
Full Bundle(Vite 8)快(1-3s)稳定、和规模弱相关需增量重打包中到超大型
传统打包(Webpack)慢(30s+)稳定——

Full Bundle Mode 的关键在于增量。全量打包再快,改一行代码重打一次 10000 个模块也不可接受。所以它依赖两件事:

  1. 模块级持久缓存:每个模块的 parse 结果、transform 结果缓存到磁盘,用内容 hash 做 key。改一个文件只重算这一个模块。
  2. 增量 link + 增量 chunk:只重新链接受影响的子图。

这也解释了为什么「模块级持久缓存」会被列为 Rolldown 解锁的核心能力之一——它不只是加速二次构建,它是 Full Bundle Mode 能成立的前提。

4.3 我的判断

Vite 从「no-bundle 是唯一正确」到「no-bundle 是一个选项」,本质上是承认:当打包器足够快时,no-bundle 的架构优势就消失了,剩下的只有一致性劣势。

这是一次很体面的自我否定。尤雨溪当年靠 no-bundle 掀翻 Webpack,现在又亲手把 no-bundle 降级成可选项——因为工具的目的是解决问题,不是捍卫某个设计。

实践建议:

  • 模块数 < 3000 的项目:继续用 no-bundle,冷启动优势明显。
  • 模块数 > 5000,或者你已经在忍受首屏 5 秒的 dev server:试 Full Bundle Mode。
  • 对 dev/prod 一致性有强要求的项目(比如需要在 dev 里验证分包策略、SSR 行为、Module Federation):直接上 Full Bundle Mode。

五、迁移实战:从 Vite 7 到 Vite 8 的完整路径

5.1 阶段一:不升 Vite,先换引擎(推荐所有人先做这一步)

Vite 团队提供了一个非常聪明的过渡包:rolldown-vite。它是 Vite 的一个 fork,保持 Vite 7 的 API,但内核换成 Rolldown。你可以在不改任何业务代码的情况下先验证兼容性。

# 用 npm/pnpm/yarn 的 overrides 把 vite 替换掉
// package.json —— pnpm
{
  "pnpm": {
    "overrides": {
      "vite": "npm:rolldown-vite@latest"
    }
  }
}
// package.json —— npm
{
  "overrides": {
    "vite": "npm:rolldown-vite@latest"
  }
}
// package.json —— yarn (berry)
{
  "resolutions": {
    "vite": "npm:rolldown-vite@latest"
  }
}

然后:

pnpm install
pnpm build

这一步能在 10 分钟内告诉你:你的插件生态能不能跑、产物有没有问题、快了多少。跑通了再考虑升 Vite 8,跑不通就回滚,成本极低。

5.2 阶段二:正式升级到 Vite 8

pnpm add -D vite@^8
# 相关生态一起升
pnpm add -D @vitejs/plugin-vue@latest   # 或 plugin-react
pnpm add -D vitest@latest

一份相对完整的 Vite 8 配置参考:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'

export default defineConfig(({ mode }) => ({
  plugins: [vue()],

  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },

  // 依赖预构建:Vite 8 下也由 Rolldown 接管,和 build 同一套语义
  optimizeDeps: {
    include: ['lodash-es', 'dayjs'],
    exclude: ['@my-org/local-pkg'],
  },

  build: {
    target: 'es2020',
    sourcemap: mode !== 'production',
    // 速度优先用默认(oxc),体积优先切 terser
    minify: 'oxc',
    chunkSizeWarningLimit: 600,

    rollupOptions: {
      output: {
        advancedChunks: {
          minSize: 20 * 1024,
          groups: [
            { name: 'vue-vendor', test: /node_modules[\\/](vue|vue-router|pinia)[\\/]/, priority: 100 },
            { name: 'vendor', test: /node_modules/, priority: 10 },
          ],
        },
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
        assetFileNames: 'assets/[name].[hash][extname]',
      },
    },
  },

  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://127.0.0.1:8080',
        changeOrigin: true,
        rewrite: (p) => p.replace(/^\/api/, ''),
      },
    },
  },
}))

5.3 迁移中最容易踩的六个坑

坑 1:依赖了 Rollup 内部 API 的插件会挂。

有些插件为了实现高级功能,直接引用了 rollup 包的内部类型或方法,比如访问 this.parse() 返回的 ESTree AST 结构、或者读 bundle 对象的私有字段。Rolldown 兼容的是公开 API,不保证内部结构一致。

排查方法

# 看看哪些依赖直接依赖了 rollup
pnpm why rollup
# 或者搜插件源码
grep -rn "from 'rollup'" node_modules/your-plugin/dist

遇到这类插件,优先找它有没有 Rolldown 兼容版本,没有就得找替代或者自己 patch。

坑 2:CommonJS 互操作行为变了。

前面提到 Rolldown 统一了 CJS/ESM 互操作规则。「统一」意味着至少有一边要改变。如果你的代码之前依赖了 esbuild 那套宽松规则,升级后可能报错。典型症状:

// 之前能跑,现在报 "does not provide an export named 'foo'"
import { foo } from 'some-cjs-package'

// 改成
import pkg from 'some-cjs-package'
const { foo } = pkg

坑 3:产物体积变大了。

tree-shaking 精细度和 minifier 压缩率的双重影响。必须量化验证:

# 升级前
git stash && pnpm build && du -sh dist && mv dist dist-before

# 升级后
git stash pop && pnpm build && du -sh dist

# 逐文件对比
ls -la dist-before/assets/*.js | sort -k5 -n > /tmp/before.txt
ls -la dist/assets/*.js       | sort -k5 -n > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt

体积涨了 3% 以内我认为可以接受(换来 10 倍构建速度);涨了 15% 以上就要查原因,通常是某个大依赖没被 shake 干净。

坑 4:define 替换和 import.meta.env 的边缘 case。

define 的实现从「字符串替换」变成了 AST 级替换,行为更正确了,但如果你之前利用了字符串替换的不精确性(比如在注释里、在字符串字面量里做替换),现在会失效。

坑 5:sourcemap 精度。

新 codegen 的 sourcemap 生成路径不同,个别情况下调试断点位置会有偏移。生产环境上 Sentry 之类的错误监控前,务必抽查几个报错的栈还原是否正确。

坑 6:Legacy 浏览器支持。

@vitejs/plugin-legacy 依赖 Babel 和特定的 Rollup 行为。如果你还要支持 IE11 或老 Android WebView,这块要重点测。前面提到的 PayFit 案例说「只需加一个 polyfill 插件」,就是这类问题。

5.4 迁移检查清单

把这个贴到你的迁移 PR 描述里:

[ ] rolldown-vite overrides 方式先跑通 build
[ ] 全量单元测试 / E2E 通过
[ ] 产物体积对比(总体积、单文件 top 10),涨幅 < 5%
[ ] 首屏 LCP / FCP 线上灰度对比无劣化
[ ] sourcemap 抽查 3 个报错栈还原正确
[ ] 目标浏览器矩阵实机验证(含最低支持版本)
[ ] CI 构建时间记录(升级前 / 后)
[ ] 所有自定义插件在 dev 和 build 两侧行为一致
[ ] SSR 场景(如有)单独验证
[ ] 依赖预构建产物(node_modules/.vite)清空后重新验证

六、插件开发:JS 插件的性能陷阱与「原生插件」的未来

6.1 一个反直觉的现实:你的 JS 插件会拖慢 Rust 打包器

这是升级后最容易出现的困惑:「说好的快 20 倍,我这才快了 2 倍?」

原因在于跨语言边界的开销。Rolldown 是 Rust,你的插件是 JS,中间隔着 Node.js 的 N-API。每一次 transform 钩子调用,都要:

Rust 侧 → 序列化 code 字符串 → 跨 N-API → V8 分配 JS 字符串
       → 执行你的 JS transform
       → 返回字符串 → 跨 N-API → Rust 侧反序列化

一次两次没关系,5000 个模块 × 3 个插件 = 15000 次跨界调用,开销就上来了。而且更要命的是:JS 插件是单线程的,Rolldown 的并行扫描到你这里会被强行串行化(或者说,被 Node 主线程的事件循环节流)。

6.2 写插件的四条性能准则

准则一:transform 一定要加精确的 filter。

// ❌ 差:每个模块都会进 JS 侧走一遍
export default function badPlugin() {
  return {
    name: 'bad',
    transform(code, id) {
      if (!id.endsWith('.svg')) return null   // 判断发生在 JS 侧,跨界已经付过费了
      return svgToComponent(code)
    },
  }
}

// ✅ 好:filter 下沉到 Rust 侧,不匹配的模块根本不会跨界
export default function goodPlugin() {
  return {
    name: 'good',
    transform: {
      filter: {
        id: { include: /\.svg$/ },      // Rust 侧预过滤
      },
      handler(code, id) {
        return svgToComponent(code)
      },
    },
  }
}

这个 filter 下沉机制是 Rolldown 时代插件性能的第一优化项。一个 5000 模块的项目,如果你的 SVG 插件只处理 50 个文件,加了 filter 就是 100 倍的跨界调用削减。

准则二:能用 code 字符串判断的,别急着 parse。

transform: {
  filter: { id: { include: /\.[jt]sx?$/ } },
  handler(code, id) {
    // 先用便宜的字符串检查兜底,避免无谓的 AST 解析
    if (!code.includes('__MY_MACRO__')) return null

    const ast = this.parse(code)   // 只有真需要时才 parse
    // ...
  },
},

准则三:resolveId 钩子尤其要克制。

resolveId 的调用频率远高于 transform(每个 import 语句一次,不是每个模块一次)。一个 5000 模块的项目可能有 30000 次 import。在这里做同步文件系统操作(fs.existsSync)是灾难级的。

// ❌ 每次 resolve 都摸一次磁盘
resolveId(source, importer) {
  const p = path.resolve(path.dirname(importer), source + '.custom')
  if (fs.existsSync(p)) return p
}

// ✅ 加 filter + 内存缓存
const cache = new Map()
resolveId: {
  filter: { id: { include: /^~custom\// } },
  handler(source, importer) {
    const key = `${importer}::${source}`
    if (cache.has(key)) return cache.get(key)
    const resolved = doResolve(source, importer)
    cache.set(key, resolved)
    return resolved
  },
},

准则四:CPU 密集的转换,考虑走 worker。

如果你的插件要做重活(比如图片处理、复杂的代码生成),别在主线程干:

import { Worker } from 'node:worker_threads'
import os from 'node:os'

// 简易 worker 池
class WorkerPool {
  constructor(script, size = os.cpus().length) {
    this.workers = Array.from({ length: size }, () => new Worker(script))
    this.idx = 0
  }
  run(payload) {
    const w = this.workers[this.idx++ % this.workers.length]
    return new Promise((resolve, reject) => {
      const onMsg = (m) => { cleanup(); resolve(m) }
      const onErr = (e) => { cleanup(); reject(e) }
      const cleanup = () => { w.off('message', onMsg); w.off('error', onErr) }
      w.on('message', onMsg)
      w.on('error', onErr)
      w.postMessage(payload)
    })
  }
}

const pool = new WorkerPool(new URL('./transform-worker.js', import.meta.url))

export default function heavyPlugin() {
  return {
    name: 'heavy',
    transform: {
      filter: { id: { include: /\.heavy$/ } },
      async handler(code, id) {
        return await pool.run({ code, id })
      },
    },
  }
}

6.3 原生插件:下一步棋

Rolldown 支持用 Rust 写「原生插件」,直接编译进打包器,零跨界开销。目前这块生态还早,但方向很明确:高频、通用的插件会逐步 Rust 化(比如 plugin-vue 的 SFC 解析、plugin-react 的 Fast Refresh 注入),业务侧的定制插件继续用 JS。

对普通开发者的含义:你不需要学 Rust,但你要知道哪些插件已经原生化了,优先选它们。这会成为未来选插件的一个新维度,就像今天我们看「有没有 TypeScript 类型」一样。


七、性能测量方法论:别被 15 倍忽悠,也别被 2 倍劝退

网上各种「提速 30 倍」的截图,大部分测得不严谨。给一套我自己用的方法。

7.1 一个可用的基准测试脚本

// scripts/bench-build.mjs
import { execSync } from 'node:child_process'
import { rmSync, existsSync, statSync, readdirSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import path from 'node:path'

const ROUNDS = Number(process.env.ROUNDS ?? 5)
const WARMUP = 1

function cleanAll() {
  for (const p of ['dist', 'node_modules/.vite', 'node_modules/.cache']) {
    if (existsSync(p)) rmSync(p, { recursive: true, force: true })
  }
}

function dirSize(dir) {
  let total = 0
  for (const f of readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, f.name)
    total += f.isDirectory() ? dirSize(full) : statSync(full).size
  }
  return total
}

function runOnce({ cold }) {
  if (cold) cleanAll()
  else rmSync('dist', { recursive: true, force: true })

  const t0 = performance.now()
  execSync('npx vite build', { stdio: 'ignore' })
  return performance.now() - t0
}

function stats(arr) {
  const s = [...arr].sort((a, b) => a - b)
  const sum = s.reduce((a, b) => a + b, 0)
  return {
    min: s[0],
    max: s[s.length - 1],
    mean: sum / s.length,
    median: s[Math.floor(s.length / 2)],
    p90: s[Math.floor(s.length * 0.9)],
  }
}

const cold = []
const warm = []

for (let i = 0; i < ROUNDS + WARMUP; i++) {
  const c = runOnce({ cold: true })
  const w = runOnce({ cold: false })
  if (i >= WARMUP) { cold.push(c); warm.push(w) }
}

const fmt = (o) => Object.fromEntries(
  Object.entries(o).map(([k, v]) => [k, `${(v / 1000).toFixed(2)}s`])
)

console.table({
  '冷构建(清缓存)': fmt(stats(cold)),
  '热构建(有缓存)': fmt(stats(warm)),
})
console.log('产物体积:', (dirSize('dist') / 1024 / 1024).toFixed(2), 'MB')

跑法:

# Vite 7 基线
ROUNDS=5 node scripts/bench-build.mjs > /tmp/vite7.txt

# 切到 Vite 8 / rolldown-vite
ROUNDS=5 node scripts/bench-build.mjs > /tmp/vite8.txt

diff /tmp/vite7.txt /tmp/vite8.txt

7.2 测量的六条纪律

  1. 区分冷热构建。有持久缓存的热构建才是你日常 CI 的真实场景(如果 CI 配了缓存的话)。
  2. 至少 5 轮取中位数。单次结果受系统噪声影响可能差 30%。
  3. 关掉杀毒软件和实时索引。Windows Defender 扫 node_modules 能让构建慢一倍,这是真的。
  4. CPU 频率锁定。笔记本插不插电源,构建时间能差 40%。测之前插电、关省电模式。
  5. 拆分阶段耗时。总时间没意义,要知道钱花在哪:
DEBUG=vite:* npx vite build 2>&1 | tee /tmp/build.log
# 或者用 Vite 的构建分析插件看各插件耗时
  1. 单独测类型检查。如果你的 build 脚本是 vue-tsc --noEmit && vite build,先把两部分拆开计时,否则你会得出「Rolldown 没啥用」的错误结论。

7.3 真实项目的期望值

基于公开案例和我自己的观察,给一个粗略的期望区间(仅供参考,你的项目一定不一样):

项目规模Vite 7 buildVite 8 build(估计)加速比
小型(<500 模块)5-10s2-4s2-3x
中型(1000-3000 模块)30-60s6-15s4-6x
大型(5000-15000 模块)90-180s10-25s8-15x
超大(>20000 模块,monorepo)5-15min30-90s10-20x

规律很清楚:项目越大,收益越大。因为 Rollup 的单线程劣势是随规模放大的。小项目升级主要图的是一致性,不是速度。


八、生态与权力结构:Cloudflare 收购 VoidZero 意味着什么

技术之外还有一层,很多人没注意。

8.1 VoidZero 是什么

尤雨溪在 2023 年创办了 VoidZero,目标是「为 JavaScript 生态构建统一的、高性能的工具链」。旗下项目:

  • Vite:构建工具 / 开发服务器
  • Vitest:测试框架
  • Rolldown:打包器(Rust)
  • Oxc:底层编译器工具链(Rust)——包含 Oxlint、oxc-parser 等

这套组合的野心非常明确:把前端工具链从「一堆各自为政的 npm 包」整合成「一个厂商维护的完整栈」。类比一下,就是 JS 生态版的 Rust cargo 或 Go 官方工具链。

8.2 2026 年 6 月:Cloudflare 收购 VoidZero

这件事的信号量很大。

对 Cloudflare 而言:它有 Workers(边缘计算)、Pages(静态托管)、R2、D1,缺的是开发侧的入口。收了 VoidZero,等于拿到了几乎所有现代前端项目的构建入口。你 npm create vite@latest 的那一刻,就进入了 Cloudflare 的漏斗。

对 Vite 生态而言,这是把双刃剑:

好的一面:

  • 钱有了。开源基础设施最大的风险是维护者用爱发电然后 burnout。有大厂发工资,Rolldown/Oxc 这种需要长期投入的 Rust 项目才有可能做完。
  • 边缘部署会被打通。可以预见 Vite 8+ 会有非常丝滑的 Cloudflare Workers 适配,SSR/边缘渲染的开箱体验会显著变好。

需要警惕的一面:

  • 中立性问题。当 Vite 的默认 SSR 适配器、默认部署目标都倾向某一家云时,「开源工具」和「厂商漏斗」的边界会变模糊。
  • 优先级问题。对 Cloudflare 有商业价值的特性(边缘运行时适配)和对社区有价值的特性(比如 Legacy 浏览器支持、非 Cloudflare 平台的适配)之间,资源怎么分?

我的看法比较务实:**短期是好事,长期要观察。**开源基础设施被大厂收编不是第一次(想想 npm→GitHub→Microsoft),关键看治理结构有没有保住。Vite 有一个相对独立的核心团队和 RFC 流程,这是缓冲垫。真正的信号要看未来 12-24 个月:有没有 Cloudflare 专属的能力进了核心而不是插件、有没有非 Cloudflare 的适配器被边缘化。

8.3 打包器战争的当前局势

顺便理一下 2026 年这个赛道的格局:

项目语言背后定位状态
RolldownRustVoidZero / CloudflareVite 的引擎,Rollup 兼容上升期,随 Vite 8 全面铺开
TurbopackRustVercelNext.js 的引擎深度绑定 Next
esbuildGo个人 (Evan Wallace)极速转译稳定,逐步退居工具层
RspackRust字节跳动Webpack 兼容替代Webpack 存量迁移的主力
Bun (bundler)Zig→RustOven运行时自带打包一体化路线
RollupJS社区库打包库作者仍在用
WebpackJS社区老项目存量维护

有意思的是分层清晰化了:

  • 应用打包:Rolldown(Vite 系)vs Turbopack(Next 系)vs Rspack(Webpack 存量)
  • 库打包:Rollup 还在守着,但 Rolldown 明确要抢这块
  • 转译层:Oxc vs SWC,两个 Rust 方案在竞争

对普通开发者的选型建议很简单:

  • Next.js 项目:跟 Vercel 走,用 Turbopack,没得选也不用选。
  • Vue/Svelte/Solid/纯 React SPA:Vite 8 + Rolldown,这是主路。
  • Webpack 老项目要提速又不想重写配置:Rspack,兼容性是它最大的卖点。
  • 写 npm 库:Rollup 依然稳,或者 tsdown(Rolldown 上层的库打包工具)。

九、什么时候不该升级 Vite 8

写了这么多好话,得给一个冷静的边界。以下情况建议再等等:

1. 项目重度依赖 Rollup 内部 API 的插件,且这些插件没人维护了。
你会陷入自己 patch 别人代码的泥潭。评估一下:这些插件占你构建流程多大比重?能不能替换?替换成本 vs 构建提速收益,算清楚。

2. 你的构建瓶颈根本不是打包。
前面说过的:如果 build 时间里 70% 是 vue-tsc/tsc 在跑,或者是图片压缩,升级 Rolldown 只能砍掉那 30% 里的大部分。这时候更值得做的是:把类型检查从 build 流程里拆出去并行跑(tsc --noEmitvite build 同时跑),收益比升级大得多。

{
  "scripts": {
    "build": "run-p type-check build-only",
    "type-check": "vue-tsc --noEmit",
    "build-only": "vite build"
  }
}

3. 产物体积对你是硬约束。
小程序分包限制、嵌入式设备、按流量计费的场景。至少要先用 rolldown-vite 验证产物体积,涨了就别升,或者升了之后 minify: 'terser' 兜底。

4. 你的项目在关键交付期。
这条是常识但总有人忘:构建工具是所有环节的地基,换地基不要在赶工期时做。等一个迭代间隙,留出完整的验证时间。

5. Legacy 浏览器矩阵很重的项目。
@vitejs/plugin-legacy 这条链路涉及 Babel、core-js、SystemJS,是兼容性问题的重灾区。要升,先在测试机上把最低支持的浏览器实机跑一遍。


十、总结:这次换的到底是什么

回到开头那个白屏的深夜。

Vite 8 最大的价值,不是「构建快了 10 倍」——虽然这个也很爽,CI 账单能省不少。最大的价值是消除了一整类问题的存在条件。

以前你调试「dev 能跑 build 挂了」,本质上是在两个不同的构建系统之间做考古。现在这个问题类别从根上少了大半——不是因为 bug 被修了,是因为产生 bug 的那个结构性裂缝被焊上了。

从工程哲学上看,这次升级有三层意义:

**第一层,性能。**Rust + 多线程 + arena 内存管理,把打包这个 CPU 密集任务从 JS 单线程的枷锁里放出来。10-30 倍不是营销数字,是架构差异的必然结果。

**第二层,一致性。**Vite → Rolldown → Oxc 三层同一团队维护、共享同一套 AST 和语义规则。这是从「集成多个工具」到「设计一个系统」的转变。

第三层,能力天花板。Full Bundle Mode、Advanced Chunks、模块级持久缓存、Module Federation——这些不是「顺便做的新功能」,是只有在打包器足够快、且 dev/build 共享内核之后才可能存在的能力。以前不做不是不想做,是架构上做不了。

最后说一句关于 no-bundle 的。Vite 靠 no-bundle 起家,现在把它降级成一个可选模式。这种自我否定在开源项目里其实很罕见——大多数项目会死守自己的招牌设计,哪怕它已经不再是最优解。

工具的目的是解决问题。当解决问题的最优路径变了,工具就应该变。这个道理讲起来简单,做起来要有人愿意拆掉自己盖的房子。


附:快速上手清单

# 1. 零成本验证(推荐所有人先做)
# package.json 加 overrides,把 vite 换成 rolldown-vite
pnpm install && pnpm build
# 记录:耗时、产物体积、是否有报错

# 2. 正式升级
pnpm add -D vite@^8 @vitejs/plugin-vue@latest vitest@latest

# 3. 跑基准
ROUNDS=5 node scripts/bench-build.mjs

# 4. 对比产物
du -sh dist && ls -la dist/assets/*.js | sort -k5 -rn | head -20

# 5. 全量测试
pnpm test && pnpm test:e2e

# 6. 目标浏览器实机验证
# 7. 灰度发布,盯 LCP / JS 错误率 48 小时

关键配置速查:

需求配置
构建速度优先build.minify: 'oxc'(默认)
产物体积优先build.minify: 'terser'
大项目 dev 提速experimental.fullBundleMode: true
精细分包build.rollupOptions.output.advancedChunks
插件性能所有钩子加 filter,让过滤下沉到 Rust 侧
排查耗时DEBUG=vite:* vite build

文中涉及的具体 API 字段(尤其是 advancedChunksfullBundleMode 等实验性配置)在 Rolldown / Vite 8 的迭代中仍可能调整,落地前请以你锁定版本的官方文档为准。性能数据均为公开案例与区间估算,不同项目差异很大,务必自己跑基准。

推荐文章

Python 微软邮箱 OAuth2 认证 Demo
2024-11-20 15:42:09 +0800 CST
MySQL用命令行复制表的方法
2024-11-17 05:03:46 +0800 CST
Vue3中如何处理跨域请求?
2024-11-19 08:43:14 +0800 CST
Vue3中如何实现响应式数据?
2024-11-18 10:15:48 +0800 CST
在Rust项目中使用SQLite数据库
2024-11-19 08:48:00 +0800 CST
Rust 中的所有权机制
2024-11-18 20:54:50 +0800 CST
程序员茄子在线接单