编程 Vite 8 深度拆解:当 Vite 亲手拆掉「esbuild + Rollup」双引擎——Rolldown、Oxc 与打包式开发的范式回收

2026-08-11 04:49:04 +0800 CST views 6

Vite 8 深度拆解:当 Vite 亲手拆掉「esbuild + Rollup」双引擎——Rolldown、Oxc 与打包式开发的一次范式回收

2026 年 3 月 12 日,Vite 8.0 正式发布。这一版没有加什么花哨的语法糖,它做的是一件更狠的事:把用了六年的双打包器架构,一次性拆了

esbuild 负责开发、Rollup 负责生产——这套 2019 年定下来的组合拳,在 Vite 8 里被一个 Rust 写的打包器 Rolldown 全盘接管。官方的原话是「这是 Vite 2 以来最重大的架构变更」。

如果你只把它当成一次「构建变快了」的常规升级,那你会在升级当天被 CJS 互操作、manualChunks 被删、装饰器降级失效这些东西按在地上摩擦。这篇文章要做的,是把 Vite 8 这一刀切下去之后露出来的东西,从架构原理到迁移实战,一层层拆开讲清楚。

本文覆盖:

  • 双打包器架构的历史债到底债在哪
  • Rolldown / Oxc / Lightning CSS 三层栈是怎么咬合的
  • 自动分包与手动分包的完整算法模型(这是 manualChunks 被删的根因)
  • Lazy Barrel 优化:为什么 import 一个 antd 组件能少编译 92% 的模块
  • CJS 互操作语义统一——最容易在生产环境炸的一个变更
  • Vite 8.1 的 Bundled Dev Mode:Vite 亲手推翻了自己的 no-bundle 信仰
  • 完整迁移剧本 + 十六条踩坑清单

一、背景:一个「务实的赌注」欠了六年的债

1.1 双引擎不是设计,是取舍

2019 年 Vite 出生的时候,前端构建的痛点非常明确:Webpack 冷启动要在启动 dev server 之前把整个依赖图扫一遍打一遍包,项目一大就是分钟级等待。

Vite 的解法分两半:

开发态:不打包。利用浏览器原生 ESM,源码按需请求、按需转换。要转 TypeScript / JSX,用 esbuild——Go 写的,快到几乎感知不到。依赖预构建(dep pre-bundling)也交给 esbuild,把 CJS 依赖转成 ESM 并合并请求。

生产态:老老实实打包。用 Rollup——它的 tree-shaking 成熟,产物干净,最重要的是它那套 resolveId / load / transform / renderChunk 插件 API 设计得太好了,好到整个 Vite 插件生态直接建在上面。

官方在 Vite 8 的发布博客里把这称作「a pragmatic bet on two bundlers」——一个务实的赌注。这个赌注赢了六年。Vite 现在每周被下载 6500 万次,Vite 8.1 单版本周下载量就有 4160 万,差不多追平了整个 Vite 7。

1.2 债务清单

但两个打包器就是两套世界观,代价是实打实的:

两条转换流水线。同一个 .tsx 文件,dev 走 esbuild 的 transform,build 走 Rollup 插件链里的 esbuild 调用 + Rollup 自己的解析。两条链路对语法边界、对 sourcemap、对 define 替换的处理都有微妙差异。

两套插件语义。Vite 插件本质是 Rollup 插件的超集,但 dev 阶段很多 Rollup 钩子根本不会被调用(因为 dev 不走打包),插件作者要写 apply: 'build'enforce: 'pre' 这类条件分支来适配。

越来越厚的胶水层。为了让 dev 和 build 表现一致,Vite 核心里堆了大量对齐代码。官方原话是「every alignment fix in one pipeline risked introducing differences in the other」——你在一条流水线上修 bug,很可能在另一条上引入不一致。

这就是典型的架构性技术债:不影响功能,但持续吞噬维护带宽,而且边界情况会随时间累积。

1.3 时间线

  • 2024-2025:VoidZero 团队启动 Rolldown,同时发布 rolldown-vite 包作为技术预览版(面向 Vite 6/7),让早期用户能在不动稳定版的前提下试水
  • 2025 年 12 月:Vite 8 Beta 发布,Rolldown 完整集成
  • 2026 年 3 月 12 日:Vite 8.0 稳定版发布
  • 2026 年 6 月 23 日:Vite 8.1 发布,带来 Bundled Dev Mode 和 Chunk Import Map
  • 当前:v8.2.x

值得注意的是官方的迁移策略:先出一个独立包让社区在真实代码库上跑,同时搭一套专门的 CI 验证主流插件和框架(SvelteKit、React Router、Storybook、Astro、Nuxt 都参与了早期测试)。这个做法值得所有做基础设施的人抄——不要指望在自己的测试用例里能覆盖生态的长尾。


二、核心概念:三层栈是怎么咬合的

Vite 8 之后,你面对的不再是「Vite」这一个东西,而是一个分层明确的工具链:

┌─────────────────────────────────────────┐
│  Vite          构建工具 / 编排层         │
│  - dev server, HMR, 插件容器, 环境 API   │
├─────────────────────────────────────────┤
│  Rolldown      打包器 (Rust)             │
│  - 模块图, 分包, tree-shaking, 产物生成  │
├─────────────────────────────────────────┤
│  Oxc           编译器 (Rust)             │
│  - parser, resolver, transformer,        │
│    minifier, 语义分析                    │
├─────────────────────────────────────────┤
│  Lightning CSS CSS 处理 (Rust)           │
│  - CSS 压缩 (默认), 语法降级             │
└─────────────────────────────────────────┘

2.1 Rolldown:一个「兼容 Rollup API」的 Rust 打包器

Rolldown 的三个设计目标,官方讲得很直白:

性能:Rust 编写,原生速度运行。在 rolldown/benchmarks 的基准里比 Rollup 快 10-30 倍,与 esbuild 处在同一性能量级。

兼容性:支持与 Rollup 和 Vite 相同的插件 API。大多数现有 Vite 插件在 Vite 8 里开箱即用。

这一点是整个迁移能成的关键。如果 Rolldown 另起炉灶设计一套插件 API,那 Vite 生态里几千个插件全要重写,这个迁移根本不可能发生。官方在博客最后专门致谢 Rollup:「Its elegant plugin API design proved so well-conceived that Rolldown adopted it as its own」——插件 API 设计得太好了,好到 Rolldown 直接照搬。

高级能力:单一打包器解锁了双引擎时代做不到的东西:Full Bundle Mode(现改名 Bundled Dev Mode)、更灵活的分包控制、模块级持久化缓存、Module Federation 支持。

2.2 Oxc:被藏在下面的真正主角

很多人只盯着「Rollup → Rolldown」,忽略了另一半:esbuild 的角色被 Oxc 接走了

打包器不是一个单体,它由若干组件构成:

组件Vite 7Vite 8
Parser(语法解析)esbuild / Rollup 内置Oxc
Resolver(模块解析)esbuild / @rollup/plugin-node-resolveOxc resolver
Transformer(TS/JSX 转换)esbuildOxc
Minifier(JS 压缩)esbuildOxc minifier
CSS MinifieresbuildLightning CSS
Bundleresbuild(dev) / Rollup(build)Rolldown

统一带来的不只是「少一份依赖」。官方点出了一个更深的收益:可以利用 Oxc 的语义分析结果做更好的 tree-shaking

这是跨层优化才可能做到的事。在双引擎时代,esbuild 的 AST 和 Rollup 的 AST 是两个互不认识的东西,Rollup 拿不到 esbuild 的语义信息,只能自己再解析一遍。现在 Rolldown 和 Oxc 共享同一套 Rust 数据结构,作用域分析、绑定关系、副作用推断都能直接复用。

2.3 一个容易被忽略的成本:安装体积

官方在发布博客里专门开了一节讲这个,态度很坦诚:

Vite 8 比 Vite 7 大约大 15 MB。

  • 约 10 MB 来自 lightningcss:以前是可选 peer 依赖,现在是普通依赖,为了开箱即用的 CSS 压缩
  • 约 5 MB 来自 Rolldown 二进制:比 esbuild + Rollup 更大,主要因为性能优化牺牲了体积

对 CI 缓存命中率不高、或者要往 Lambda 塞构建环境的团队,这 15MB 是要算进账的。


三、架构分析:模块图是怎么变成 chunk 的

这一节是全文最硬的部分,也是理解「为什么 manualChunks 对象形式被直接删掉」的唯一路径。

3.1 自动分包:两类 chunk

Rolldown 的自动分包(Automatic Code Splitting)不可配置,它按固定规则跑。产出两类 chunk。

Entry Chunks(入口 chunk)

静态连通的模块合并成一个 chunk。「静态」指的是 import ... from '...' 或者 require(...)

Entry chunk 又分两种:

  • Initial chunks:来自用户配置。input: ['./a.js', './b.js'] 定义了两个 initial chunk
  • Dynamic chunks:来自动态导入。import() 的目标要按需加载,所以不能和 importer 放一起

看个例子:

// entry.js(在 input 选项里)
import foo from './foo.js';
import('./dyn-entry.js');

// dyn-entry.js
require('./bar.js');

// foo.js
export default 'foo';

// bar.js
module.exports = 'bar';

这里有两组静态连通的模块:

Group 1 (initial chunk):  entry.js --static--> foo.js
Group 2 (dynamic chunk):  dyn-entry.js --require--> bar.js
        entry.js --import()--> dyn-entry.js  (跨组,虚线)

最终生成两个 chunk。注意 require('./bar.js') 被算作静态连接——这一点和很多人的直觉不一样。

Common Chunks(公共 chunk)

当一个模块被至少两个不同的 entry 静态导入时,它被抽到独立 chunk。

目的有两个,都很本质:

  1. 保证每个 JS 模块在最终产物里是单例(不然模块顶层的副作用会执行多次)
  2. entry 执行时,只执行它真正导入的模块

关键规则来了:模块能否放进同一个 common chunk,取决于它是否被同一组 entry 导入。

// entry-a.js
import 'shared-by-ab.js';
import 'shared-by-abc.js';

// entry-b.js
import 'shared-by-ab.js';
import 'shared-by-bc.js';
import 'shared-by-abc.js';

// entry-c.js
import 'shared-by-bc.js';
import 'shared-by-abc.js';

三个 entry,三个共享模块。产物是六个 chunk

// entry-a.js
import './common-ab.js';
import './common-abc.js';

// entry-b.js
import './common-ab.js';
import './common-bc.js';
import './common-abc.js';

// entry-c.js
import './common-bc.js';
import './common-abc.js';

// common-ab.js   ← 被 {a, b} 导入
// common-bc.js   ← 被 {b, c} 导入
// common-abc.js  ← 被 {a, b, c} 导入

shared-by-abshared-by-abc 不能合并,因为它们的「导入者集合」不同——如果合并了,entry-c 就会被迫加载并执行 shared-by-ab 的顶层代码,违反第 2 条原则。

这个算法的本质是按导入者集合做等价类划分。理解了它,你就理解了为什么自动分包只能这样、也只会这样。

3.2 手动分包:codeSplitting 取代 manualChunks

自动分包只看静态导入关系,完全不考虑加载性能和缓存失效。这就是手动分包存在的理由。

官方文档里的例子很直观。一个 React 应用:

// index.jsx
import * as ReactDom from 'react-dom';
import App from './App.jsx';
ReactDom.createRoot(document.getElementById('root')).render(<App />);

// App.jsx
import * as React from 'react';
import { Button } from 'ui-lib';
export default function App() {
  return <Button onClick={() => alert('Button clicked!')} />;
}

默认产出一个 output-hash0.js,里面塞了 react + react-dom + ui-lib + 你的业务代码。

现在你改了 App.jsx 里一句 alert 文案。文件 hash 变了,浏览器要重新下载整个 bundle——包括那 300KB 一年没动过的 React。

codeSplitting 把库拆出来:

// rolldown.config.js / vite.config.ts 里的 build.rolldownOptions.output
export default {
  output: {
    codeSplitting: {
      groups: [
        {
          test: /node_modules/,
          name: 'libs',
        },
      ],
    },
  },
};

现在改 App.jsxlibs-hash0.js 的 hash 不变,浏览器直接命中缓存。

完整的分组配置模型

codeSplitting 的类型是 boolean | object

  • true:默认,自动分包
  • false:把所有动态导入内联进单个 bundle(等价于已废弃的 inlineDynamicImports: true
  • object:高级手动分包配置

一份生产级的分组配置长这样:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          // 全局兜底:小于这个尺寸的组不单独成 chunk
          minSize: 20000,
          groups: [
            {
              name: 'react-vendor',
              test: /node_modules[\\/]react/,
              priority: 20,
            },
            {
              name: 'ui-vendor',
              test: /node_modules[\\/]antd/,
              priority: 15,
            },
            {
              name: 'vendor',
              test: /node_modules/,
              priority: 10,
            },
            {
              // 业务侧公共模块:被至少 2 个 chunk 共享且够大才抽出来
              name: 'common',
              minShareCount: 2,
              minSize: 10000,
              priority: 5,
            },
          ],
        },
      },
    },
  },
})

关键字段:

字段作用
test正则匹配模块 id
name生成的 chunk 名
priority优先级,数字大的先匹配(决定 react 进 react-vendor 而不是 vendor
minSize / maxSize尺寸下限 / 上限,超过 maxSize 会继续切分
minModuleSize / maxModuleSize单模块尺寸过滤
minShareCount至少被几个 chunk 共享才抽取
includeDependenciesRecursively是否递归把依赖一起拉进这个组

按尺寸切分的用法:

codeSplitting: {
  groups: [
    {
      name: 'large-libs',
      test: /node_modules/,
      minSize: 100000,  // 100KB
      maxSize: 250000,  // 250KB,超了自动继续切
      priority: 10,
    },
  ],
}

maxSize 这个能力在 Rollup 时代要靠自己在 manualChunks 函数里手写哈希分桶,现在是声明式的。

一个必须知道的副作用陷阱

官方文档给了明确警告:

手动分包可能改变应用行为——如果副作用在对应模块真正被使用之前就触发了。

原因很直接:分包改变了模块的执行顺序。原本按依赖图拓扑序执行的模块,被塞进不同 chunk 后,chunk 的加载/执行顺序未必和源码顺序一致。

两个解法:

  1. 调整分组配置,把顺序敏感的模块放在一起
  2. output.strictExecutionOrder 强制保持源码执行顺序

strictExecutionOrder 的实现是把模块包一层,让函数体按源码顺序执行——代价是产物体积增加。Rolldown 还提供了 experimental.onDemandWrapping,用「基于预测的 chunk 执行风险的保守计划」替代全量包裹,只在真正可能出问题的地方加 wrapper。

如果你的项目里有那种「import 一下就注册全局副作用」的模块(polyfill、i18n 注册、CSS-in-JS 主题注入),这一条要重点关注。

为什么对象形式的 manualChunks 被直接删了

现在答案很清楚了:

// ❌ Vite 8 不再支持
build.rollupOptions.output.manualChunks = {
  vendor: ['react', 'react-dom'],
}

// ⚠️ 函数形式已弃用(还能用,但会警告)
build.rollupOptions.output.manualChunks = (id) => {
  if (id.includes('node_modules')) return 'vendor'
}

// ✅ Vite 8 推荐
build.rolldownOptions.output.codeSplitting = {
  groups: [{ name: 'vendor', test: /node_modules/ }],
}

对象形式表达能力太弱(只能列包名,没法表达优先级、尺寸约束、共享次数),函数形式虽然灵活但是JS 回调——在 Rust 打包器里,每个模块都要跨 FFI 边界回调一次 JS,这是性能杀手。声明式的 groups 配置可以整个下沉到 Rust 侧执行,一次 FFI 都不用。

这是一个典型的「架构决定 API」案例:不是设计师觉得声明式更优雅,是 Rust 内核跑 JS 回调太贵。

3.3 Lazy Barrel:让 antd 少编译 92% 的模块

这是 Rolldown 里最实用、也最被低估的一个优化。

问题:大型组件库(antd、lodash-es、@mui/material)大量使用 barrel 模块——一个 index.jsexport * from './xxx' 上千次。你只 import 一个 Button,传统打包器要把这上千个模块全部编译一遍,然后再靠 tree-shaking 摇掉。

编译了再摇掉,等于白干。

Lazy Barrel 的做法:先分析哪些 export 真正被用了,只编译那些模块。未使用的 re-export 模块直接跳过,压根不进编译流水线。

官方给的真实数据(import { Button } from 'antd'):

指标关闭 lazy barrel开启 lazy barrel
编译模块数2986250
构建耗时(macOS)~65ms~28ms
构建耗时(Windows)~210ms~50ms

模块数减少 92%,构建提速 2-4 倍。Windows 上提速更明显——因为文件系统 IO 更贵,少读 2700 个文件的收益被放大了。

支持的模式

// 星号 re-export
export * from './components';

// 具名 re-export
export { Component } from './Component';
export { helper as utils } from './helper';
export { default as Button } from './Button';
export { Button as default } from './Button';

// 命名空间 re-export
export * as ns from './module';

// import-then-export(等价形式,同样被优化)
import { a } from './a';
export { a };

解析策略也很聪明:当一个 import 能在具名 export 里找到时,不再搜索星号 export。只有找不到时才去加载所有 export * 的目标——而如果那些目标也是 barrel 模块,同样只加载对应的 specifier。

一个必须知道的语义坑

文档里专门标了红:

// ❌ 这两个不等价
export { Button as default } from './Button.js'
import { Button } from './Button.js'; export default Button

前者导出的是同一个变量的绑定(live binding),后者 export default ...创建一个新变量,值被快照。

// Button.js
export let Button = 1;
export const increment = () => { Button++; };

// re-exporter.js
import { Button } from './Button.js';
export default Button;              // 快照
export { Button as ReExportedButton } from './Button.js';  // live binding

// main.js
import { Button, increment } from './Button.js';
import ExportDefaultButton, { ReExportedButton } from './re-exporter.js';

console.log(Button);              // 1
console.log(ReExportedButton);    // 1
console.log(ExportDefaultButton); // 1
increment();
console.log(Button);              // 2
console.log(ReExportedButton);    // 2
console.log(ExportDefaultButton); // 1  ← 没变!

因为 export default ... 算作「自有 export」而不是纯 re-export,它会阻止 lazy barrel 优化

所以如果你在维护一个组件库,index.ts 里请老老实实写 export { default as Button } from './Button',不要写 import Button from './Button'; export default Button——前者能被优化,后者不能,而且语义还不一样。

这是一条可以直接写进团队规范的规则。

3.4 CJS 处理:__commonJS 与按需执行

Rolldown 对 CommonJS 是原生支持,不需要 @rollup/plugin-commonjs。这也是为什么 build.commonjsOptions 在 Vite 8 里直接变成 no-op。

更重要的是它保留了 CJS 的按需执行语义

// index.js
import { value } from './foo.js';
const getFooExports = () => require('./foo.js');

// foo.js
module.exports = { value: 'foo' };

打包后:

// #region \0rolldown/runtime.js
// ...runtime code
// #endregion

// #region foo.js
var require_foo = __commonJS({
  'foo.js'(exports, module) {
    module.exports = { value: 'foo' };
  },
});
// #endregion

// #region index.js
const getFooExports = () => require_foo();
// #endregion

foo.js 的模块体被包进一个函数,只有 getFooExports() 被调用时才执行。这是 CJS 的核心语义,很多打包器为了简化会直接求值,Rolldown 保住了。

ESM 导入 CJS 时走 __toESM

// #region index.js
var import_foo = __toESM(require_foo());
console.log(import_foo.value);
// #endregion

四、代码实战:完整迁移剧本

4.1 升级路径:两步走还是一步到位

官方给了两条路:

小项目:直接升。

pnpm add -D vite@^8.0.0

Vite 8 内置了兼容层,会自动把 esbuildrollupOptions 配置转换成 Rolldown / Oxc 等价物,多数项目零配置变更就能跑。

大项目 / 复杂项目:走两步。

// 第一步:Vite 7 上换成 rolldown-vite,隔离打包器变更
{
  "devDependencies": {
    "vite": "npm:rolldown-vite@^7.0.0"
  }
}
// 第二步:确认无 Rolldown 相关问题后,回退 alias 并升到 8
{
  "devDependencies": {
    "vite": "^8.0.0"
  }
}

这个两步法的价值在于问题归因:如果构建挂了,你能立刻知道是「打包器换了」导致的,还是「Vite 8 其他变更」导致的。项目越大,这个信息越值钱。

4.2 配置迁移对照表

依赖预构建

optimizeDeps 现在用 Rolldown 而不是 esbuild。兼容层自动转换:

Vite 7Vite 8
esbuildOptions.minifyrolldownOptions.output.minify
esbuildOptions.treeShakingrolldownOptions.treeshake
esbuildOptions.definerolldownOptions.transform.define
esbuildOptions.loaderrolldownOptions.moduleTypes
esbuildOptions.preserveSymlinks!rolldownOptions.resolve.symlinks(取反!)
esbuildOptions.resolveExtensionsrolldownOptions.resolve.extensions
esbuildOptions.mainFieldsrolldownOptions.resolve.mainFields
esbuildOptions.conditionsrolldownOptions.resolve.conditionNames
esbuildOptions.keepNamesrolldownOptions.output.keepNames
esbuildOptions.platformrolldownOptions.platform
esbuildOptions.pluginsrolldownOptions.plugins(部分支持)

注意 preserveSymlinks取反关系,这种地方最容易在手工迁移时写错。

想确认兼容层到底转成了什么,写个探针插件:

const plugin = {
  name: 'log-config',
  configResolved(config) {
    console.log('optimizeDeps:', config.optimizeDeps.rolldownOptions)
    console.log('oxc:', config.oxc)
  },
}

JS 转换:esbuild → oxc

Vite 7Vite 8
esbuild.jsxInjectoxc.jsxInject
esbuild.include / excludeoxc.include / oxc.exclude
esbuild.jsx: 'preserve'oxc.jsx: 'preserve'
esbuild.jsx: 'automatic'oxc.jsx: { runtime: 'automatic' }
esbuild.jsxImportSourceoxc.jsx.importSource
esbuild.jsx: 'transform'oxc.jsx: { runtime: 'classic' }
esbuild.jsxFactoryoxc.jsx.pragma
esbuild.jsxFragmentoxc.jsx.pragmaFrag
esbuild.jsxDevoxc.jsx.development
esbuild.jsxSideEffectsoxc.jsx.pure
esbuild.defineoxc.define
esbuild.banner / footer自己写 transform 钩子插件

esbuild.supported 选项 Oxc 不支持,暂时没有等价物。

迁移前后的配置:

// Vite 7
export default defineConfig({
  esbuild: {
    jsxFactory: 'h',
    jsxFragment: 'Fragment',
    jsxInject: `import { h, Fragment } from 'preact'`,
    define: { __VERSION__: '"1.0.0"' },
  },
})

// Vite 8
export default defineConfig({
  oxc: {
    jsx: {
      runtime: 'classic',
      pragma: 'h',
      pragmaFrag: 'Fragment',
    },
    jsxInject: `import { h, Fragment } from 'preact'`,
    define: { __VERSION__: '"1.0.0"' },
  },
})

弃用清单

build.rollupOptions        → build.rolldownOptions
worker.rollupOptions       → worker.rolldownOptions
build.commonjsOptions      → no-op(Rolldown 原生支持 CJS)
build.dynamicImportVarsOptions.warnOnError → no-op
resolve.alias[].customResolver → 用 enforce: 'pre' 的 resolveId 插件替代
build.rollupOptions.watch.chokidar → build.rolldownOptions.watch.watcher

4.3 装饰器:目前唯一需要「退回 JS 工具」的场景

Oxc 转换器尚不支持原生装饰器的降级转换(在等规范推进)。如果你在用 Angular、NestJS 风格的装饰器,或者 MobX、TypeORM,需要临时挂回 Babel 或 SWC。

Babel 方案:

pnpm add -D @rolldown/plugin-babel @babel/plugin-proposal-decorators
// vite.config.ts
import { defineConfig } from 'vite'
import babel from '@rolldown/plugin-babel'

function decoratorPreset(options: Record<string, unknown>) {
  return {
    preset: () => ({
      plugins: [['@babel/plugin-proposal-decorators', options]],
    }),
    rolldown: {
      // 关键:只有包含 '@' 的文件才跑这个转换
      filter: {
        code: '@',
      },
    },
  }
}

export default defineConfig({
  plugins: [babel({ presets: [decoratorPreset({ version: '2023-11' })] })],
})

SWC 方案:

pnpm add -D @rollup/plugin-swc @swc/core
import { defineConfig, withFilter } from 'vite'
import swc from '@rollup/plugin-swc'

export default defineConfig({
  plugins: [
    withFilter(
      swc({
        swc: {
          jsc: {
            parser: { decorators: true, decoratorsBeforeExport: true },
            transform: { decoratorVersion: '2023-11' },
          },
        },
      }),
      // 只在包含装饰器的文件上运行
      { transform: { code: '@' } },
    ),
  ],
})

这里的 filter 是性能命门。 把 Babel 挂上去等于在 Rust 流水线里插了一段 JS,如果不加 filter,每个文件都要跨 FFI 走一趟 Babel,你辛苦升上来的性能会瞬间还回去。code: '@' 这个过滤器在 Rust 侧做字符串预筛,只有真正含 @ 的文件才触发 JS 回调。

好消息是 emitDecoratorMetadata 在 Vite 8 里已经内置支持,不再需要外部插件。

4.4 插件作者需要改什么

如果你维护 Vite 插件,有几个点必须处理。

moduleType 自动推断

Rolldown 有实验性的 Module Types 支持(类似 esbuild 的 loader),会根据解析出的 id 的扩展名自动设置 module type

如果你在 loadtransform 里把其他类型的内容转成了 JS,必须显式标注:

const plugin = {
  name: 'txt-loader',
  load(id) {
    if (id.endsWith('.txt')) {
      const content = fs.readFileSync(id, 'utf-8')
      return {
        code: `export default ${JSON.stringify(content)}`,
        moduleType: 'js',   // ← 必须加,否则 Rolldown 按 .txt 处理
      }
    }
  },
}

这是插件迁移里最高频的一个坑。

build()BundleError

JS API 用户注意:build() 现在抛 BundleError 而不是插件里的原始错误。

try {
  await build()
} catch (e) {
  if (e.errors) {
    for (const error of e.errors) {
      console.log(error.code)  // 单个错误
    }
  }
}

类型是 Error & { errors?: RolldownError[] }。如果你有 CI 脚本在解析构建错误,这里要改。

并行钩子变串行

Rollup 里所有的 parallel hooks(buildStartbuildEndrenderStart 等),在 Rolldown 里都按串行执行

如果你的插件依赖多个插件的 buildStart 并发执行来抢占某个资源,或者靠并发来省时间,行为会变。

bundle 对象的变化

generateBundle / writeBundle 拿到的 bundle 对象:

// ❌ 不再支持直接赋值
bundle['extra.js'] = { ... }

// ✅ 用 emitFile
this.emitFile({ type: 'asset', fileName: 'extra.js', source: '...' })

// ❌ 报 DataCloneError
structuredClone(bundle)

// ✅ 先浅展开
structuredClone({ ...bundle })

而且 bundle 的引用在钩子之间不共享了——你在 generateBundle 里改的东西,writeBundle 里未必看得到。

被移除的钩子

以下 Rollup 钩子 Rolldown 不支持,Vite 8 也就不再支持:

  • shouldTransformCachedModule
  • resolveImportMeta
  • renderDynamicImport
  • resolveFileUrl

另外 parseAst / parseAstAsync 被弃用,改用功能更全的 parseSync / parse

还有一个容易漏的:注释现在在 renderChunk 钩子之前被移除,而 Rollup 是之后。如果你的插件在 renderChunk 里靠注释做标记(比如某些 legal comment 处理、某些 banner 注入),逻辑会失效。


五、性能优化:数字和它们的成本

5.1 生产构建:真实项目数据

官方在 rolldown-vite 预览期和 beta 期收集的生产数据:

公司/项目效果
Linear生产构建从 46s → 6s
Ramp构建时间减少 57%
Mercedes-Benz.io最高减少 38%
Beehiiv减少 64%

Linear 那个 46s → 6s 是接近 8 倍的提升,属于「改变工作流」级别——46 秒你会切窗口去刷别的,6 秒你会盯着看完。

要注意提升幅度差异很大(38% 到 87%),这取决于:

  • 项目里 JS 转换 vs 插件逻辑的时间占比。如果你的构建时间大头在某个 JS 写的自定义插件上,换 Rust 打包器提升有限
  • barrel 模块的使用密度(lazy barrel 的收益)
  • 模块总数(Rust 的并行优势在大项目上才充分体现)

升级前先做一次构建 profiling,看看时间到底花在哪。 如果 70% 的时间在你自己写的一个正则替换插件里,那先优化插件比升 Vite 8 收益大。

5.2 Bundled Dev Mode:Vite 推翻了自己

这是 Vite 8.1(2026-06-23)最重磅的东西,也是最有戏剧性的:Vite 开始在开发态打包了。

为什么要打脸自己

Vite 的成名招式就是 no-bundle dev server。官方在 8.1 博客里对此的表述相当坦诚:

这个方案最初是一个实验,看看不用传统打包能把开发服务器性能推到什么边界。

然而,随着项目规模和复杂度增长,Vite 的 unbundled dev 方案会拖累开发性能。因为每个模块都单独请求,浏览器必须处理大量请求,这增加了启动和刷新开销。在大型应用上尤其明显,当开发者处于网络代理之后时更严重

这个诚实值得尊敬。No-bundle 的数学模型很清楚:模块数 N,请求数 O(N)。N = 500 时 no-bundle 完胜打包;N = 10000 时,浏览器要处理一万个 HTTP 请求,每个都有解析、connection 复用、缓存校验的固定开销,这个常数项会把优势吃干净。

实测数据

一个加载 10000 个 React 组件的应用:

  • 启动快约 15 倍
  • 整页刷新快约 10 倍
  • HMR 保持即时,不随应用规模劣化

Linear 团队的真实项目:

  • 冷启动渲染快 3 倍
  • 整页刷新快约 40%
  • 网络请求数减少 10 倍

开启方式

vite --experimental-bundle

或者:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  experimental: {
    bundledDev: true,
  },
})

现状与边界

目前只聚焦浏览器端 + 基础插件 + 主要特性。官方明说:

  • 第三方插件可能不工作
  • 使用小众特性可能异常

它的设计目标是「兼得两者」:打包带来的快速启动 + 减少刷新时的网络开销,同时在 ESM 输出之上保持高效 HMR——这句是关键,它不是退回 Webpack 那种整包重编译,HMR 粒度仍然基于 ESM。

我的判断:如果你的项目模块数在几千以下,别急着开,收益不明显还要冒插件不兼容的风险。如果你在维护一个万级模块的大型 SPA,或者团队里有人天天抱怨「刷新一次要等五秒」,这个值得专门排期试一下。

5.3 Chunk Import Map:干掉 hash 级联

这是 8.1 另一个很聪明的实验特性。

问题:产物 chunk 的 import 语句里包含被导入 chunk 的 hash(为了内容变了能加载新版本)。但这导致——

utils.[e5f6 → 88xx].js   ← 内容真的改了
    ↑ imports (embeds hash)
page.[c3d4 → 77yy].js    ← 被级联重哈希(内容没改!)
    ↑ imports (embeds hash)
entry.[a1b2 → 99zz].js   ← 被级联重哈希(内容没改!)

你改了一个底层工具函数,整条导入链上的所有 chunk hash 全变,用户全量重新下载。

解法:用 import map 把 chunk 名到实际带 hash 文件的映射抽出来,chunk 内部只引用稳定的逻辑名。这样只有真正改动的 chunk 换 hash,上游 chunk 内容不变、hash 不变、缓存命中。

这个特性建立在 Rolldown 的能力之上,Vite 层补了对 Vite 特有特性的支持。

注意:experimental.renderBuiltUrl 目前和这个选项不兼容。

5.4 其他调优点

build.target 默认值提升'baseline-widely-available' 现在对应:

浏览器Vite 7Vite 8
Chrome107111
Edge107111
Firefox104114
Safari16.016.4

对应 Baseline 在 2026-01-01 时的「widely available」集合,都是大约两年半前发布的版本。产物语法更新 = 体积更小、运行更快,但如果你有硬性的老浏览器要求,记得显式锁定 build.target

CSS 压缩默认 Lightning CSS。语法降级做得比 esbuild 好,但产物体积可能略微增大。想切回去:

build: { cssMinify: 'esbuild' }  // 需要自己装 esbuild 作为 devDependency

Vite 8.1 已经和 Lightning CSS 团队补齐了两个 PostCSS 才有的能力(CSS 中导入外部 CSS 文件、插件注册文件依赖),官方在考虑下个大版本把默认 CSS 预处理器也换成 Lightning CSS。可以提前用 css.transformer: 'lightningcss' 试。

resolve.tsconfigPaths 是有代价的。Vite 8 内置了 tsconfig 路径别名解析,但默认不开,因为有小幅性能开销:

resolve: { tsconfigPaths: true }

如果你原来用 vite-tsconfig-paths 插件,可以换成内置的——内置实现在 Rust 侧,比 JS 插件快。

JS 压缩选项迁移

// 旧
esbuild: { minifyIdentifiers: true, drop: ['console'] }

// 新
build: {
  rolldownOptions: {
    output: {
      minify: {
        compress: { drop_console: true },
      },
    },
  },
}

Oxc minifier 不支持属性混淆manglePropsreservePropsmangleQuotedmangleCache)。如果你在做体积极致优化并且依赖属性混淆,暂时留在 esbuild。

esbuild 和 Oxc 压缩器对源码做的假设略有不同。如果你怀疑压缩把代码搞坏了,对比两边的 assumptions 文档(esbuild 的 minify considerations 和 Oxc 的 ASSUMPTIONS.md)。


六、十六条踩坑清单

按「会不会在生产炸」排序。

1. CJS default 导入语义统一了——这是最容易炸的

现在的规则:满足以下任一条件,default 导入拿到的是 CJS 模块的 module.exports 本身;否则拿到 module.exports.default

  • importer 是 .mjs / .mts
  • importer 最近的 package.jsontypemodule
  • 被导入 CJS 模块的 module.exports.__esModule 不是 true

而 Vite 7 里,dev 和 build 的判定条件不一样(dev 多一个「importer 是否在依赖优化范围内」的前置条件,build 多一个「module.exports.default 是否存在」)。统一是好事,但会打破一些现有代码。

临时救急:

legacy: { inconsistentCjsInterop: true }  // 已弃用,只是缓兵之计

正确做法是找到受影响的包,给作者提 issue 或 PR。

2. 移除了基于格式探测的模块解析

以前 package.json 里同时有 browsermodule 字段时,Vite 会读文件内容判断,给浏览器挑 ESM 文件。现在不猜了,严格按 resolve.mainFields 顺序

依赖旧行为的话:用 resolve.alias 映射,或者 pnpm patch / patch-package 打补丁。

3. 外部化模块的 require 调用被保留

不再自动转成 import,为了保住 require 的惰性求值语义。要转的话:

import { defineConfig, esmExternalRequirePlugin } from 'vite'

export default defineConfig({
  plugins: [
    esmExternalRequirePlugin({
      external: ['react', 'vue', /^node:/],
    }),
  ],
})

注意:插件必须拥有它转换的 externals——列在插件自己的 external 选项里,不是顶层的 external

platform: 'node' 时 Rolldown 会用 module.createRequire 生成 require 函数,完整保留语义,但要求运行时支持 module.createRequire,且不适合期望被再次打包的库。

4. UMD / IIFE 里 import.meta.url 不再 polyfill

默认被替换成 undefined。需要的话用 define 配合 build.rolldownOptions.output.intro

5. manualChunks 对象形式被删,函数形式弃用 — 见 3.2 节

6. 手动分包可能改变副作用执行顺序 — 见 3.2 节,用 strictExecutionOrder 兜底

7. export default X 会阻止 lazy barrel 优化 — 见 3.3 节

8. 插件 load/transform 返回非 JS 内容时要加 moduleType: 'js' — 见 4.4 节

9. Oxc 不支持原生装饰器降级 — 用 Babel/SWC 插件 + filter 兜底

10. plugin-legacy 不支持降级到 ES5 及以下

如果你还要支持 IE 或者非常老的移动端 WebView,Vite 8 暂时不适合你。

11. format: 'system'format: 'amd' 不支持

Rolldown 目前没实现。用 SystemJS 做微前端的项目要注意。

12. build.target 传同一浏览器的多个版本现在报错

esbuild 会自动选最新的,这大概率不是你想要的,所以现在直接报错。

13. define 传对象时不共享引用

每个变量会拿到对象的一份独立拷贝。如果你靠 define 注入一个对象然后期望全局单例,行为变了。

14. Extglob 暂不支持

!(a|b).js 这类扩展 glob 语法在 import.meta.glob 等场景暂时不可用。

15. TypeScript legacy namespace 只部分支持

老代码里的 namespace Foo { ... } 可能有边界情况,详见 Oxc Transformer 文档。

16. "use strict" 有时不注入

Rolldown 的策略和 Rollup 不同。如果你的代码依赖严格模式下的行为差异(比如 this 在函数里是 undefined 而不是 globalThis),要验证。

另外:Node.js 要求 20.19+ 或 22.12+(和 Vite 7 一样,保证 require(esm) 无 flag 可用,Vite 才能纯 ESM 分发)。传 URL 给 import.meta.hot.accept 不再支持,改传 id。


七、被顺手带进来的几个小惊喜

Vite 8 除了 Rolldown 还塞了几个不显眼但很实用的东西:

集成 Devtoolsdevtools 选项开启 Vite Devtools,直接从 dev server 拿项目的深度分析数据。

浏览器 console 转发server.forwardConsole 把浏览器的 console 日志和错误转发到 dev server 终端。官方明说这个是为 coding agent 设计的——AI 写代码时看不到浏览器运行时错误,转发到 CLI 输出之后 agent 就能看见了。检测到 coding agent 时自动激活

这是一个信号:构建工具开始把「AI 是使用者之一」当成一等公民需求来设计了。

Wasm SSR 支持.wasm?init 导入现在能在 SSR 环境工作。8.1 进一步支持了 Wasm ESM integration 提案:

import { add } from './add.wasm'
console.log(add(1, 2)) // 3

import.meta.glob 大小写不敏感匹配

const modules = import.meta.glob('./dir/module*.js', {
  caseSensitive: false,   // 能匹配到 ./dir/Module1.js
})

自定义 HTML 元素/属性的资源发现

html: {
  additionalAssetSources: {
    'html-import': { srcAttributes: ['src'] },
    img: { srcAttributes: ['data-src-dark', 'data-src-light'] },
  },
}

暗黑模式双图片方案终于不用写插件了。

@vitejs/plugin-react v6。用 Oxc 做 React Refresh 转换,Babel 不再是依赖,安装体积变小。需要 React Compiler 的项目用 reactCompilerPreset 配合 @rolldown/plugin-babel 显式 opt-in。v5 在 Vite 8 上仍然能用,可以先升 Vite 再升插件。


八、未来:还没做完的四件事

官方明确列了下一步方向:

Raw AST transfer。让 JS 插件能以极小的序列化开销访问 Rust 侧产生的 AST。这是当前最大的性能瓶颈——每次 JS 插件要看 AST,都得把 Rust 的数据结构序列化一遍传过去。做完之后,JS 插件和 Rust 内核之间的性能鸿沟会大幅收窄。

Native MagicString transforms。让自定义转换的逻辑写在 JS、字符串操作跑在 Rust。这个思路很妙:大多数简单转换的逻辑就是「找到位置、替换字符串」,逻辑判断在 JS 里跑没关系(次数少),但字符串拼接是热路径(次数多),下沉到 Rust 收益巨大。

稳定 Environment API。多环境(client / ssr / edge / worker)的一等公民抽象,生态已经开始定期开会协作。

Bundled Dev Mode 扩大支持面。目前只覆盖浏览器端和基础插件,官方在准备一份说明「插件侧需要做什么改动」的文档。


九、总结:这次升级到底值不值

从工程角度看,Vite 8 做对了三件事:

第一,它承认了旧架构的债并且真的还了。双打包器不是不能继续维护,但每年多一层胶水,五年之后就是无法收拾的局面。VoidZero 选择在生态最鼎盛的时候动刀,而不是等到实在维护不动。

第二,它用兼容性换取了迁移可行性。Rolldown 完全照搬 Rollup 的插件 API,Vite 8 内置兼容层自动转换配置。这不是技术上最优雅的选择——重新设计一套面向 Rust 的插件 API 肯定更快——但它是唯一能让几千个插件平滑过渡的选择。基础设施迁移的第一原则是不要让生态重写。

第三,它敢推翻自己的招牌。Bundled Dev Mode 等于承认「no-bundle 在大项目上不成立」。一个项目能公开否定自己最出名的设计决策,说明团队还在解决真问题,而不是在维护叙事。

从使用者角度,我的建议是:

  • 中小项目(模块数 < 3000):可以升,主要收益是构建变快和工具链统一。直接 pnpm add -D vite@^8,跑一遍 CI,重点验证 CJS 依赖的 default 导入
  • 大型项目:走两步迁移法,先 rolldown-vite 再 Vite 8。升级前先 profiling 确认瓶颈在哪
  • 有装饰器 / 需要 ES5 / 用 SystemJS 格式 / 依赖属性混淆:先别升,或者做好挂 Babel 补丁的准备
  • 万级模块的大型 SPA:Bundled Dev Mode 值得专门排期做 POC,15 倍启动提升不是小数

要盯的一个风险:整个栈(Vite / Rolldown / Oxc / Lightning CSS)现在高度集中在 VoidZero 一家手上。好处是跨层优化能做到极致——用 Oxc 的语义分析改善 Rolldown 的 tree-shaking,这种事在多方协作下几乎不可能落地。代价是生态的多样性下降了。

这个权衡目前看是划算的,但值得长期观察。前端工具链在过去十年最健康的时候,恰恰是 Webpack、Rollup、Parcel、esbuild 互相竞争的时候。

最后一句给准备升级的人: 这次升级最大的风险不是性能回退,是 CJS 互操作语义变更。这类问题不会在构建时报错,会在运行时以 undefined is not a function 的形式出现在某个不常走的分支里。升级后请把 E2E 测试完整跑一遍,尤其是那些引用了老 CJS 包的路径。

推荐文章

robots.txt 的写法及用法
2024-11-19 01:44:21 +0800 CST
jQuery中向DOM添加元素的多种方法
2024-11-18 23:19:46 +0800 CST
程序员茄子在线接单