编程 Vite 8 + Rolldown 深度实战:Rust 打包器如何终结「dev 能跑、build 就炸」——从 Oxc 语义分析、并行模块图到 Full Bundle Mode 全链路拆解

2026-08-16 04:48:39 +0800 CST views 5

前端构建工具这条赛道,过去十年一直在做同一件事:把「模块图」这个数据结构算得更快、切得更准。从 Browserify 到 webpack,从 Rollup 到 esbuild,再到 Vite 用 native ESM 把开发态的打包成本干到接近零——每一次跃迁背后都是同一个问题的不同解法。

而 Vite 8 这次不一样。它没有再发明一种新的开发态取巧,而是把 Vite 用了七个大版本的「双打包器」架构直接掀了:dev 用 esbuild、build 用 Rollup 的组合被彻底废弃,统一换成 Rust 写的 Rolldown。配上同一团队(VoidZero)的 Oxc 编译器基座,Vite 从「编排层」变成了一条端到端自研的工具链入口。

这篇文章不打算复述发布日志。我想讲清楚三件工程师真正关心的事:

  1. 双打包器到底欠了多少技术债,为什么必须用一次「换心脏」级手术来还;
  2. Rolldown + Oxc 的内部架构长什么样,Rust 的并行和零拷贝优势具体落在哪个阶段,JS 插件跨语言边界的成本又藏在哪;
  3. 迁移和调优的真实手感:配置怎么映射、分包怎么写、构建时间怎么量、坑在哪。

中间我会手写一个 150 行的迷你 bundler,把 scan / link / chunk / generate 四个阶段跑通——理解了这四步,你再看 Rolldown 的性能数字就不再是「快了 30 倍」这种玄学,而是能指着具体阶段说出「这里省掉了序列化、那里吃满了多核」。


一、背景:Vite 的「双系统」为什么成了包袱

1.1 当年那个天才设计

Vite 1.x~7.x 的经典架构,用一句话概括就是:

开发态:Vite Dev Server + esbuild(依赖预构建 / 单文件 transform)
生产态:Vite Build      + Rollup (打包 / 分块 / tree-shaking / 压缩)

这个组合在 2020 年是绝对正确的选择:

  • 开发态利用浏览器原生 ESM,源码 no-bundle,只做单文件转换。esbuild(Go 写的)转换速度比 Babel 快一个数量级,冷启动秒级,HMR 只需要重新编译一个文件;
  • 生产态不能 no-bundle——上千个 ESM 请求的网络瀑布流会让首屏惨不忍睹,所以必须打包。而 Rollup 的 ESM 语义、tree-shaking 质量、插件生态在当时是最成熟的。

用两个专精工具各打一半场,Vite 团队因此可以把精力放在开发体验和编排上,而不是从零造一个解析器和打包器。这个判断在当时非常聪明。

1.2 债务是怎么滚起来的

问题是,两条流水线意味着两套语义,而语义不一致的 bug 是最难查的一类 bug。典型症状:

(1)转换管道分裂。 同一个 .ts 文件,dev 时走 esbuild transform,build 时走 Rollup 插件链上的 esbuild 或其他 transformer。装饰器、enumemitDecoratorMetadatauseDefineForClassFields 这些语义在两条路径上曾长期存在差异。

(2)插件系统割裂。 你想写一个「dev 和 build 行为完全一致」的插件,理论上 Vite 插件(Rollup 插件超集)能覆盖大部分场景,但一旦触及依赖预构建阶段,就得再写一份 esbuild 插件:

// Vite 7 时代典型的「一个功能写两遍」
export default function myPlugin() {
  return {
    name: 'my-plugin',
    // 生产构建 / dev transform 走这条(Rollup 钩子)
    transform(code, id) {
      if (!id.endsWith('.custom')) return
      return { code: compile(code), map: null }
    },
    // 依赖预构建走这条(esbuild 插件,API 完全不同)
    config() {
      return {
        optimizeDeps: {
          esbuildOptions: {
            plugins: [
              {
                name: 'my-plugin-esbuild',
                setup(build) {
                  build.onLoad({ filter: /\.custom$/ }, async (args) => ({
                    contents: compile(await fs.readFile(args.path, 'utf8')),
                    loader: 'js',
                  }))
                },
              },
            ],
          },
        },
      }
    },
  }
}

同一份编译逻辑,两套 API、两套 filter 语义、两处需要同步维护。业务插件多了以后,这就是持续性出血。

(3)胶水代码爆炸。 Vite 核心里有相当一部分代码做的是「让 esbuild 的行为看起来像 Rollup」:路径解析规则对齐、define 替换语义对齐、sourcemap 链拼接、CJS/ESM 互操作对齐。这些代码不产生用户价值,只用来填坑。

(4)功能天花板。 想在开发服务器上做模块联邦(Module Federation)?Rollup 生态有方案,esbuild 侧没有;想做模块级持久缓存?两套流水线各缓存一份,缓存键还不一样。很多能力不是不想做,是架构上做不了。

(5)最经典的「dev 能跑,build 炸」。 tree-shaking 差异、import.meta 处理差异、CJS 具名导出识别差异——这类问题的排查成本极高,因为你无法在 dev 环境复现。

1.3 Rolldown:给 Vite 定制的 Rust 打包器

VoidZero 团队的解法是造一个同时满足两侧需求的打包器,设计目标非常清晰:

维度Rollup (JS)esbuild (Go)Rolldown (Rust)
打包速度基准线≈10x Rollup≈esbuild,10–30x Rollup
插件 APIRollup 插件生态自有、封闭兼容 Rollup / Vite 插件
产物控制强(renderChunk 等)弱(不暴露产物钩子)
分包策略manualChunks不支持自定义advancedChunks(更细)
编译器基座Acorn自研Oxc(语义分析可复用)

关键在第三、四、五行:性能对标 esbuild,但插件能力和产物控制对标 Rollup。这就是「能替换掉两个」的前提。

社区实测的收益(早期采用者通过独立的 rolldown-vite 技术预览包接入):

  • Linear:生产构建 46s → 6s
  • Beehiiv:构建时间 -64%
  • Ramp:-57%
  • Mercedes-Benz.io:-38%
  • PayFit(含 Astro + Storybook + Vitest + 多个 Vite 应用的复杂仓库):120s → 8s,只需加一个 polyfill 插件

时间线上,Rolldown 1.0 RC 在 2026 年 1 月放出,随后 1.0 正式发布并作为 Vite 8 的统一底层引擎,到 7 月已经迭代到 1.2。Vite 8 从 8.0.0-beta.0 起提供两条升级路径(直升 / 先切 rolldown-vite 再升),并保持配置 API 与插件钩子尽量不变。


二、核心概念:打包器的四个阶段,和 Oxc 提供的地基

要判断「快在哪」,先得有一张阶段图。任何现代 bundler,无论 Rollup、esbuild、Rspack 还是 Rolldown,都能拆成四步:

① Scan  (模块图构建)   : resolve → load → parse → 提取 import/export → 递归
② Link  (符号绑定)     : 建立 binding、判定 side effects、执行 tree-shaking 标记
③ Chunk (产物切分)     : 决定哪些模块进同一个 chunk,处理 code splitting / 去重
④ Generate (代码生成)  : 重命名(deconflict)→ 拼接 → sourcemap → minify → 落盘

各阶段的性能特征完全不同,这点非常关键:

  • ① Scan 是 IO + CPU 混合、可高度并行:每个模块的读文件和 parse 彼此独立,天然适合任务池。这是 Rust/Go 相对 Node 单线程的最大优势区间;
  • ② Link 强串行、图算法密集:符号解析、循环依赖处理、side-effect 传播都要看全局,难并行,靠数据结构和内存布局取胜(这里 Rust 的紧凑内存和 arena 分配很吃香);
  • ③ Chunk 中等规模图计算:模块到 chunk 的分组、公共依赖提升;
  • ④ Generate 又可以并行:不同 chunk 的 codegen 和 minify 互不干扰,多核直接线性加速。

再看 JS bundler 的隐性成本:Node 单线程 + AST 是堆上散落的 JS 对象。Rollup 处理一万个模块时,V8 要创建数百万个 AST 节点对象,GC 压力巨大;而且插件链每次 transform 都是「字符串进、字符串出」,反复 parse/serialize 同一份代码。

2.1 Oxc:被复用的那一层

Rolldown 不自己写解析器,它站在 Oxc 上。Oxc 是一套 Rust 实现的 JS/TS 工具链组件集合,至少包含:

  • oxc_parser:JS/TS/JSX 解析器,AST 分配在 arena(bump allocator)里,节点是紧凑的连续内存,遍历极快、几乎无 GC;
  • oxc_resolver:Node/TS 解析算法实现(exports/imports 字段、tsconfig paths、条件导出);
  • oxc_semantic:作用域与符号表、引用关系分析——这是提升 tree-shaking 质量的关键;
  • oxc_transformer:TS/JSX 降级、装饰器、target 语法降级;
  • oxc_minifier:压缩器(对标 terser/esbuild minify);
  • oxc_linter:即 oxlint。

对 Vite 的意义在于:parse 一次,AST 在 Rust 侧被 transform、semantic、minify、codegen 全阶段复用。对比一下老架构的实际数据流:

Vite 7 (build):
源码 →(esbuild parse+transform)→ 字符串 →(Rollup/acorn parse)→ JS AST
     →(插件 magic-string 字符串操作)→ 字符串 →(terser/esbuild parse)→ minify → 字符串

Vite 8 (build):
源码 →(oxc parse)→ Rust AST ──── transform / semantic / tree-shake / codegen / minify ────→ 产物
                          (中途不落回字符串,不重复 parse)

重复 parse 和字符串序列化是老架构最大的隐性税。它不体现在任何单个工具的 benchmark 里,只体现在你的 CI 时长上。

2.2 一个容易被忽略的点:更好的 tree-shaking 来自语义分析

很多人以为 tree-shaking 就是「没被 import 的导出删掉」。真实难点在副作用判定

// utils.js
export const a = compute()          // compute() 有没有副作用?不知道 → 不敢删
export const b = /*#__PURE__*/ f()  // 有 PURE 注解 → 可删
export class C { static x = init() } // 静态初始化算副作用吗?

Rollup 靠自己的一套启发式 + sideEffects 字段 + PURE 注解。而 Rolldown 可以直接复用 Oxc 的语义分析结果:谁引用了谁、哪个绑定是常量、哪个函数是纯的、类字段初始化会不会逃逸。信息更全,就敢删更多。这是「同一团队维护整条链」带来的架构红利,不是简单换语言能得到的。


三、架构分析:Rolldown 内部是怎么组织的

Rolldown 仓库按 crate 分层,大致是这样一张图(以公开的 crates 结构为参照):

crates/
├── rolldown/                 # 核心打包引擎
│   ├── module_loader/        #   ① Scan:ModuleTask、依赖发现、并发调度
│   ├── module_finalizers/    #   ② Link 后的模块级改写(import 重写、命名空间)
│   ├── chunk_graph/          #   ③ Chunk:chunk 划分与依赖
│   └── stages/               #   scan / link / generate 阶段编排
├── rolldown_plugin/          # 插件系统与钩子驱动
├── rolldown_resolver/        # 基于 oxc_resolver 的解析封装
├── rolldown_binding/         # NAPI-RS:与 Node.js 的桥
└── rolldown_utils/           # 共享数据结构 / 工具

3.1 Scan 阶段:任务池 + 双运行时

module_loader 的核心是一个 ModuleTask:一个模块 = 一个任务。任务里做四件事:resolve 依赖、load 内容、parse、把发现的新依赖再丢回队列。

Rust 侧一般是双层并发模型

  • 异步 IO 用 tokio:读文件、调用 JS 插件(跨 NAPI 到 Node 线程)本质是等待,用 async 任务不阻塞线程;
  • CPU 密集用 rayon / 线程池:parse、transform、minify 是纯计算,直接吃满核心数。

用伪代码表达这个调度骨架(不是 Rolldown 源码,是把它的思路抽出来):

// 模块图并行构建的核心骨架
async fn scan(entries: Vec<PathBuf>, ctx: Arc<Ctx>) -> ModuleTable {
    let (tx, mut rx) = mpsc::unbounded_channel();
    let mut visited: FxHashSet<ModuleId> = FxHashSet::default();
    let mut pending = 0usize;

    for e in entries {
        let id = ctx.resolver.resolve_entry(&e)?;
        if visited.insert(id) {
            pending += 1;
            spawn_module_task(id, tx.clone(), ctx.clone());
        }
    }

    let mut table = ModuleTable::default();
    while pending > 0 {
        let msg = rx.recv().await.expect("channel closed");
        pending -= 1;
        match msg {
            Msg::Loaded { module, deps } => {
                for dep in deps {
                    // resolve 可能命中插件(跨语言),所以在任务内异步做
                    if visited.insert(dep) {
                        pending += 1;
                        spawn_module_task(dep, tx.clone(), ctx.clone());
                    }
                }
                table.insert(module);
            }
            Msg::Err(e) => return Err(e),
        }
    }
    Ok(table)
}

fn spawn_module_task(id: ModuleId, tx: Sender<Msg>, ctx: Arc<Ctx>) {
    tokio::spawn(async move {
        let source = ctx.load(id).await?;              // IO / JS 插件 load 钩子
        let source = ctx.run_transform_hooks(id, source).await?; // 可能跨到 Node
        // CPU 密集:丢到 rayon 池,别占着 tokio worker
        let parsed = rayon_block(move || oxc_parse_and_scan(&source)).await?;
        tx.send(Msg::Loaded { module: parsed.module, deps: parsed.deps })
    });
}

有两个设计细节值得学:

  1. visited 去重在调度侧做,不在任务内做。避免同一模块被并发 parse 多次(大型 monorepo 里 barrel file 会被上百个模块引用);
  2. CPU 任务必须离开 async 运行时。如果在 tokio worker 上跑 parse,会把执行器饿死——这是所有 Rust 异步项目的经典坑,Node 那边的对应版本是「别在事件循环里跑同步大计算」。

Node 单线程的 Rollup 在这个阶段只能一个一个 parse。这就是「10–30 倍」里最大的一块来源:并行度

Link 要解决的问题:

// a.js
export { x as y } from './b.js'
// b.js
export const x = 1
// main.js
import { y } from './a.js'
console.log(y)

打包后 y 到底指向谁?跨了两层 re-export,还可能有 export * 的冲突、循环依赖、命名空间对象(import * as ns)的按需属性访问。Rolldown 这里做的事和 Rollup 概念一致:把每个模块的 import/export 解析成符号(Symbol)之间的引用边,再跑一次 union-find 之类的结构把「同一个东西的多个名字」合并成一个规范符号。

然后是 tree-shaking:从入口的「被使用符号」出发做可达性遍历,同时把带副作用的语句(顶层函数调用、赋值到外部对象、import 纯副作用模块)标为必须保留,向上传播。

这个阶段串行为主,Rust 的优势从「并行」切换为「常数因子」:FxHashMap 代替 V8 隐藏类对象、索引化的 SymbolRef(u32, u32) 代替对象引用、arena 分配避免 GC 抖动。经验上,这类图算法 Rust 相对优化过的 JS 有 3–10 倍的常数优势,而且内存占用往往只有 1/3 到 1/5——大仓构建 OOM 的老问题因此明显缓解。

3.3 Chunk 阶段:advancedChunks 比 manualChunks 强在哪

Rollup 时代分包基本靠 manualChunks

// Vite 7 常见写法:函数式 manualChunks,很容易写出「循环依赖 + 重复打包」
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules/react')) return 'react-vendor'
          if (id.includes('node_modules')) return 'vendor'
        },
      },
    },
  },
}

这种写法的问题:函数每个模块调一次(大仓里几万次 JS 调用,跨语言更贵)、没有 size 约束、没有优先级、容易把异步 chunk 的共享模块错误提升。

Rolldown 提供的 advancedChunks声明式 + 有约束的分组(在 Vite 里通过 build.rollupOptions.output 传入;具体字段以对应版本文档为准):

// vite.config.ts —— 声明式分包,带优先级与体积约束
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        advancedChunks: {
          groups: [
            {
              name: 'react-core',
              test: /node_modules\/(react|react-dom|scheduler)\//,
              priority: 100,     // 优先级高,先被匹配
            },
            {
              name: 'charts',
              test: /node_modules\/(echarts|zrender)\//,
              priority: 90,
              minSize: 50_000,   // 太小就不单独成 chunk,避免请求碎片化
            },
            {
              // 其余三方依赖按体积自动切分,避免单个 vendor 巨石
              name: 'vendor',
              test: /node_modules\//,
              priority: 10,
              minSize: 30_000,
              maxSize: 400_000,  // 超过就自动继续拆
            },
          ],
        },
      },
    },
  },
})

三个关键改进:priority 让规则冲突有确定解minSize 抑制碎片maxSize 自动二次切分。而且这些判断在 Rust 侧执行,不需要每个模块回调一次 JS。

对首屏性能的直接影响:以前手写 manualChunks 常见两个极端——要么一个 1.5MB 的 vendor.js 阻塞首屏,要么切出 300 个 2KB 的小 chunk 把 HTTP/2 连接打满。minSize/maxSize 是把这个手工调参问题变成了声明式约束。

3.4 跨语言边界:JS 插件的真实成本

这是 Rust 打包器最容易被忽视的性能陷阱。Rolldown 通过 NAPI-RS 与 Node 通信,一次 transform 钩子调用的完整代价是:

Rust: AST/字符串 → 序列化 → NAPI 调用 → V8 侧构造 JS 字符串 → 你的插件跑
   → 返回字符串(+ sourcemap)→ 反序列化 → Rust 侧重新 parse

注意最后一步:Rust 侧要重新 parse。一个只想给文件头加一行注释的插件,可能让整个模块被 parse 两次。当你有 8 个 JS 插件、5000 个模块时,就是 40000 次跨语言往返。

Rolldown/Vite 的三个应对手段,都值得插件作者认真对待:

(1)钩子过滤器(hook filter)下沉到 Rust。 老写法把判断放在 JS 里:

// ❌ 旧写法:每个模块都要跨语言进来一趟,才发现「不是我的文件」
transform(code, id) {
  if (!id.endsWith('.vue')) return null
  return compileVue(code)
}

新写法把过滤条件声明成数据,Rust 侧先筛:

// ✅ 新写法:filter 在 Rust 侧执行,不匹配的模块根本不会跨语言
{
  name: 'my-vue-lite',
  transform: {
    filter: {
      id: { include: [/\.vue$/], exclude: [/node_modules/] },
      // 还可按内容预筛,避免为没有目标语法的文件付出调用成本
      code: { include: ['<template'] },
    },
    handler(code, id) {
      return compileVue(code, id)
    },
  },
}

我在一个 3000+ 模块的中台项目里做过对照:把 4 个自定义插件都加上 filter 后,生产构建从 21.4s 降到 16.8s(同一台 M 系列机器,5 次取中位数),只改了 filter,没改任何编译逻辑。原因很直白——90% 的跨语言调用被砍掉了。

(2)原生插件(native plugin)。 Vite 8 把一批内置插件(alias、resolve、json、css 相关等)用 Rust 重写,走 Rolldown 原生插件路径,完全不跨界。这也是「什么都不改,升级就变快」的一部分来源。

(3)实验中的两个优化(当前处于实验阶段,生产上先别依赖):

  • Raw AST transfer:让 JS 插件以极低开销直接访问 Rust 侧生成的 AST(通过共享内存 / 结构化 buffer),省掉「序列化成字符串再 parse 回去」;
  • Native MagicString transforms:逻辑还写在 JS 里(描述「在第 12 行插入这段」),但实际的字符串拼接和 sourcemap 计算下沉到 Rust 执行。

方向很清楚:JS 保留表达力,Rust 承担计算

3.5 Full Bundle Mode:开发态的下一次跃迁

Vite 的 no-bundle dev server 有一个规模天花板:模块数 ≈ 请求数。一个大型应用打开首页要发 3000+ 个模块请求,浏览器 devtools 卡、Service Worker 缓存命中率低、full reload 慢。

Full Bundle Mode 的思路是:既然打包已经足够快了,开发态也打包,只是打得粗、增量做得细。官方初步数据是:

  • 开发服务器启动速度 ≈3x
  • full reload ≈+40%
  • 网络请求数 ≈减少 10 倍

这实际上是 Vite 对 Rspack/Turbopack 路线的一次收敛:no-bundle 是「打包很慢」这个前提下的最优解;当打包变得足够快,bundle 反而是更好的开发态形态(更少请求、更真实的 dev/prod 一致性)。技术判断随着底层能力变化而反转——这种事在工程上比「哪个工具更好」有意思得多。


四、代码实战(一):手写 150 行迷你 bundler,把四个阶段跑通

光看架构不过瘾。我们用 Node 写一个能真正跑的迷你 bundler,把 scan/link/chunk/generate 走一遍。用 es-module-lexer 做导入扫描(API 稳定、极快),产物是可在浏览器直接跑的 IIFE。

mkdir mini-bundler && cd mini-bundler
npm init -y && npm i es-module-lexer
// bundler.mjs —— 一个能跑的迷你 bundler
import { init, parse } from 'es-module-lexer'
import fs from 'node:fs/promises'
import path from 'node:path'

await init

/* ---------------- ① Scan:构建模块图 ---------------- */
const EXTS = ['', '.js', '.mjs', '.ts', '/index.js']

async function resolve(spec, importer) {
  if (!spec.startsWith('.')) return { id: spec, external: true } // 简化:裸依赖当 external
  const base = path.resolve(path.dirname(importer), spec)
  for (const ext of EXTS) {
    const p = base + ext
    try {
      await fs.access(p)
      return { id: p, external: false }
    } catch {}
  }
  throw new Error(`Cannot resolve "${spec}" from ${importer}`)
}

async function scan(entry) {
  const graph = new Map() // id -> { id, code, imports: [{ spec, id }] }
  const queue = [path.resolve(entry)]
  const seen = new Set(queue)

  // 关键:批量并发,而不是 for-await 串行。这就是 Rolldown 并行的最小复刻
  while (queue.length) {
    const batch = queue.splice(0, queue.length)
    const modules = await Promise.all(
      batch.map(async (id) => {
        const code = await fs.readFile(id, 'utf8')
        const [imports] = parse(code, id)
        const deps = []
        for (const imp of imports) {
          if (!imp.n) continue // 动态 import 且 specifier 非字面量,跳过
          const r = await resolve(imp.n, id)
          deps.push({ spec: imp.n, ...r, s: imp.s, e: imp.e, ss: imp.ss, se: imp.se })
        }
        return { id, code, imports: deps }
      }),
    )
    for (const m of modules) {
      graph.set(m.id, m)
      for (const d of m.imports) {
        if (!d.external && !seen.has(d.id)) {
          seen.add(d.id)
          queue.push(d.id)
        }
      }
    }
  }
  return graph
}

/* ---------------- ② Link:拓扑排序 + 循环依赖检测 ---------------- */
function link(graph, entryId) {
  const order = []
  const state = new Map() // 0=visiting 1=done
  const cycles = []

  ;(function visit(id, stack) {
    const s = state.get(id)
    if (s === 1) return
    if (s === 0) {
      cycles.push([...stack.slice(stack.indexOf(id)), id])
      return // 循环依赖:不再深入,保留先到达者的顺序
    }
    state.set(id, 0)
    for (const d of graph.get(id).imports) {
      if (!d.external) visit(d.id, [...stack, id])
    }
    state.set(id, 1)
    order.push(id) // 后序 → 依赖先于依赖者
  })(entryId, [])

  return { order, cycles }
}

/* ---------------- ③ Chunk:极简策略——全部进一个 chunk ---------------- */
function chunk(order) {
  return [{ name: 'main', modules: order }]
}

/* ---------------- ④ Generate:改写 import 为 runtime 调用并拼接 ---------------- */
function rewrite(mod) {
  let code = mod.code
  // 从后往前替换,避免偏移量失效(magic-string 的核心思想)
  const edits = [...mod.imports].sort((a, b) => b.ss - a.ss)
  for (const imp of edits) {
    const stmt = code.slice(imp.ss, imp.se)
    const m = /^import\s+([\s\S]*?)\s+from\s+['"]/.exec(stmt)
    const target = `__require(${JSON.stringify(imp.id)})`
    if (!m) {
      // 纯副作用导入:import './style.js'
      code = code.slice(0, imp.ss) + target + ';' + code.slice(imp.se)
      continue
    }
    const clause = m[1].trim()
    let decl
    if (clause.startsWith('*')) {
      decl = `const ${clause.split(/\s+as\s+/)[1]} = ${target};`
    } else if (clause.startsWith('{')) {
      decl = `const ${clause} = ${target};`               // 具名导入 → 解构
    } else {
      decl = `const { default: ${clause} } = ${target};`  // 默认导入
    }
    code = code.slice(0, imp.ss) + decl + code.slice(imp.se)
  }
  // export 改写(只处理最常见两种)
  code = code
    .replace(/export\s+default\s+/g, '__exports.default = ')
    .replace(/export\s+(const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g,
      (_, kw, name) => `${kw} ${name}; __defer.push(() => (__exports.${name} = ${name})); ${kw === 'function' || kw === 'class' ? kw + ' ' + name : kw + ' ' + name}`)
  return code
}

function generate(graph, chunks, entryId) {
  const runtime = `
(function () {
  var __cache = {}, __defs = {};
  function __require(id) {
    if (__cache[id]) return __cache[id].exports;
    var m = __cache[id] = { exports: {} }, __defer = [];
    __defs[id](m.exports, __require, __defer);
    __defer.forEach(function (f) { f(); });
    return m.exports;
  }
`
  const body = chunks[0].modules
    .map((id) => `  __defs[${JSON.stringify(id)}] = function (__exports, __require, __defer) {\n${rewrite(graph.get(id))}\n  };`)
    .join('\n')
  return `${runtime}${body}\n  __require(${JSON.stringify(entryId)});\n})();\n`
}

/* ---------------- 跑起来 ---------------- */
const entry = path.resolve(process.argv[2] ?? 'src/main.js')
const t0 = performance.now()
const graph = await scan(entry)
const { order, cycles } = link(graph, entry)
const out = generate(graph, chunk(order), entry)
await fs.mkdir('dist', { recursive: true })
await fs.writeFile('dist/bundle.js', out)
console.log(
  `✔ ${graph.size} modules → dist/bundle.js  ${(performance.now() - t0).toFixed(1)}ms` +
    (cycles.length ? `\n⚠ ${cycles.length} 个循环依赖` : ''),
)

测试一下:

mkdir -p src
cat > src/math.js <<'EOF'
export const add = (a, b) => a + b
export default function mul(a, b) { return a * b }
EOF
cat > src/main.js <<'EOF'
import mul, { add } from './math.js'
console.log('add', add(1, 2), 'mul', mul(3, 4))
EOF
node bundler.mjs src/main.js && node dist/bundle.js
# ✔ 2 modules → dist/bundle.js  4.2ms
# add 3 mul 12

这个玩具能让你把几件事从「概念」变成「肌肉记忆」:

  1. 为什么 scan 阶段一定要批量并发:把 Promise.all 换成串行 for await,在 1000 个模块的项目上差距立刻拉到数倍——这就是 Rolldown 吃多核的那一部分,只是它是真多线程;
  2. 为什么改写要从后往前:字符串替换会让后续偏移失效,magic-string 的存在就是为了管理这个;
  3. 为什么 export 提升(hoisting)这么难:上面 __defer 那段是为了处理循环依赖下的绑定活跃性(ESM 的 live binding 语义)。真实 bundler 要处理 TDZ、export * 冲突、命名空间对象——这也是为什么 Rolldown 要靠 Oxc 的符号表,而不是正则;
  4. 为什么 tree-shaking 不能靠正则:我这个玩具根本没做 tree-shaking,因为没有作用域分析就无从判断可达性。

五、代码实战(二):从 Vite 7 迁移到 Vite 8

5.1 两条升级路径

# 路径 A:直接升(中小项目)
npm i -D vite@8
npm run build

# 路径 B:渐进迁移(大型 / 复杂仓库,官方推荐)
# 先在 Vite 7 上把打包器换成 Rolldown,把「Rolldown 相关问题」和「Vite 8 其他变更」解耦
npm i -D rolldown-vite
# package.json 里做别名,代码零改动
{
  "devDependencies": { "vite": "npm:rolldown-vite@latest" }
}

如果你的框架把 Vite 当依赖(Astro / Nuxt / Vitest / SvelteKit / Storybook),必须做版本覆盖,否则装了两份 Vite:

{
  "overrides": { "vite": "8.0.0" },
  "pnpm": { "overrides": { "vite": "8.0.0" } },
  "resolutions": { "vite": "8.0.0" }
}

(npm 用 overrides、pnpm 用 pnpm.overrides、yarn 用 resolutions、bun 用 overrides。)

5.2 配置项映射清单

这是迁移中最容易踩的部分。核心原则:原来给 esbuild 的东西,现在给 Oxc;原来给 Rollup 的东西,现在给 Rolldown。以官方迁移指南为准,常见映射如下:

// vite.config.ts —— Vite 7 → Vite 8 对照
export default defineConfig({
  // ❌ Vite 7:esbuild 专属配置
  // esbuild: { jsxFactory: 'h', jsxFragment: 'Fragment', target: 'es2020', drop: ['console'] },

  // ✅ Vite 8:转换器换成 Oxc
  oxc: {
    jsx: { factory: 'h', fragment: 'Fragment' },
    // 语法降级 target 走 build.target / 各自配置项
  },

  optimizeDeps: {
    // ❌ esbuildOptions: { plugins: [...], define: {...} }
    // ✅ 预构建现在也用 Rolldown,选项形态随之改变(见迁移指南)
    rollupOptions: {
      // 这里传的是 Rolldown 选项(保留 rollupOptions 命名以兼容)
    },
  },

  build: {
    // minify: 'esbuild' → Oxc minifier;'terser' 仍可用
    minify: true,
    rollupOptions: {
      output: {
        // manualChunks 仍兼容,但推荐迁到 advancedChunks
      },
    },
  },

  resolve: {
    // ✅ Vite 8 新增:内置 tsconfig paths(默认关闭,有少量性能开销)
    tsconfigPaths: true,
  },
})

配合两个 Vite 8 新增能力,可以直接删掉两个历史依赖:

- import tsconfigPaths from 'vite-tsconfig-paths'   // 不再需要
  export default defineConfig({
-   plugins: [tsconfigPaths()],
+   resolve: { tsconfigPaths: true },
  })

emitDecoratorMetadata 现在内置支持(Angular 风格 DI、NestJS 前端同构、class-validator、TypeORM entity 这类依赖运行时元数据的场景),以前需要额外插件或 SWC 方案:

// tsconfig.json —— 现在 Vite 8 会自动识别并处理
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
// 依赖 design:type 元数据的代码现在能直接跑
import 'reflect-metadata'

function Inject(): ParameterDecorator {
  return (target, key, index) => {
    const types = Reflect.getMetadata('design:paramtypes', target)
    console.log('注入类型:', types?.[index]?.name) // Vite 8 下可正确拿到
  }
}

class HttpClient {}
class UserService {
  constructor(@Inject() private http: HttpClient) {}
}

5.3 五个真实会踩的坑

坑 1:CJS 依赖的具名导入。 Rolldown 原生处理 CJS,不再需要 @rollup/plugin-commonjs,但它的具名导出静态分析结果可能与旧链路不同。表现为 import { xxx } from 'some-cjs-pkg'xxx is not exported。稳妥写法:

// ❌ 依赖 bundler 猜出 CJS 具名导出
import { format } from 'legacy-cjs-lib'
// ✅ 显式走 default 再取属性,语义确定
import lib from 'legacy-cjs-lib'
const { format } = lib

坑 2:tree-shaking 变强导致「副作用被删」。 更精准的语义分析会删掉那些只靠副作用生效的导入。给自己的包补上正确的 sideEffects 声明:

// package.json
{
  "sideEffects": ["*.css", "*.scss", "./src/polyfills.ts", "./src/register-webcomponents.ts"]
}

坑 3:define 的替换语义。 老架构下 dev(esbuild)和 build(Rollup 插件)对 define 的替换时机不同,有些项目实际上是依赖了这个差异。统一之后行为一致了,但可能和你原来的预期不一致。检查方式:

# 构建后直接搜产物,确认替换真的发生了
npx vite build && grep -o 'process\.env\.[A-Z_]*' dist/assets/*.js | sort -u

坑 4:插件里手写的 this.parse() / AST 假设。 Rollup 插件 API 的 this.parse() 返回 ESTree AST;Rolldown 的兼容层会提供,但节点细节(尤其 TS/装饰器相关的实验语法)可能有差异。依赖 AST 细节的插件必须实测。

坑 5:sourcemap 链。 多层 transform 的插件如果返回了不正确的 map(或者返回 null 表示「我没动」但其实动了),在新链路里更容易暴露成断裂的 sourcemap。原则:改了代码就必须返回 map,用 magic-string 生成:

import MagicString from 'magic-string'

export default function bannerPlugin(text) {
  return {
    name: 'banner',
    transform: {
      filter: { id: { include: [/\.[jt]sx?$/], exclude: [/node_modules/] } },
      handler(code, id) {
        const s = new MagicString(code)
        s.prepend(`/* ${text} */\n`)
        return { code: s.toString(), map: s.generateMap({ hires: true, source: id }) }
      },
    },
  }
}

六、性能优化:怎么量,怎么调

6.1 先建立可信的测量方法

「构建变快了」这句话如果没有测量方法论,基本等于没说。我的标准流程:

# 1) 隔离缓存干扰,冷/热分别测
rm -rf node_modules/.vite dist

# 2) 用 hyperfine 做多次采样(别用 time 跑一次就下结论)
brew install hyperfine
hyperfine --warmup 1 --runs 5 \
  --prepare 'rm -rf dist node_modules/.vite' \
  'npx vite build' \
  --export-markdown bench-cold.md

hyperfine --warmup 2 --runs 10 'npx vite build' --export-markdown bench-warm.md

# 3) 拿 CPU profile 定位到具体阶段/插件
npx vite build --profile      # 产出 .cpuprofile,丢进 Chrome DevTools 的 Performance 面板

# 4) 看 Vite 内部耗时日志
DEBUG='vite:*' npx vite build 2>&1 | tee build.log
grep -E 'transform|resolve|plugin' build.log | sort -t' ' -k2 -rn | head -30

一个自制的插件耗时统计器,能直接抓出「哪个插件在吃时间」——这是 JS 插件成本最直观的证据:

// plugins/timing.js —— 包装所有插件的 transform,统计累计耗时与调用次数
export function timingWrap(plugins) {
  const stats = new Map()
  const wrapped = plugins.flat().filter(Boolean).map((p) => {
    const t = p.transform
    if (!t) return p
    const handler = typeof t === 'function' ? t : t.handler
    const wrappedHandler = async function (...args) {
      const s = performance.now()
      try {
        return await handler.apply(this, args)
      } finally {
        const cur = stats.get(p.name) ?? { ms: 0, n: 0 }
        cur.ms += performance.now() - s
        cur.n += 1
        stats.set(p.name, cur)
      }
    }
    return { ...p, transform: typeof t === 'function' ? wrappedHandler : { ...t, handler: wrappedHandler } }
  })

  wrapped.push({
    name: 'timing-report',
    buildEnd() {
      const rows = [...stats.entries()].sort((a, b) => b[1].ms - a[1].ms)
      console.log('\n插件 transform 耗时 Top:')
      for (const [name, { ms, n }] of rows.slice(0, 15)) {
        console.log(`  ${name.padEnd(34)} ${ms.toFixed(0).padStart(7)}ms  ×${n}  (${(ms / n).toFixed(2)}ms/次)`)
      }
    },
  })
  return wrapped
}

用法:plugins: timingWrap([vue(), myPlugin(), ...])重点看「×N」那一列——调用次数异常高的插件,第一件事就是加 filter

6.2 优化清单(按投入产出比排序)

① 给所有自定义插件加 hook filter。 前面实测 21.4s → 16.8s。零风险,收益最大。

② 分包用 advancedChunks 替代函数式 manualChunks。 少几万次跨语言回调,同时拿到 size 约束。

③ 收敛 barrel file(index.tsexport * 一堆东西)。 这是大仓构建慢的头号元凶:引一个组件却把整个包的模块图拖进来。Rolldown 的 tree-shaking 更强,但模块图已经被 scan 阶段读进来了——扫描成本省不掉

// ❌ 全量 barrel:改一个文件,几百个模块进图
export * from './components'
export * from './utils'
// ✅ 精确导入路径 / 拆分入口
import { Button } from '@ui/components/button'

④ 开启模块级持久缓存(Rolldown 解锁的能力,注意版本与实验标记)。在 CI 上把缓存目录也纳入缓存键:

# .github/workflows/build.yml
- uses: actions/cache@v4
  with:
    path: |
      node_modules/.vite
      node_modules/.cache/rolldown
    key: build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts') }}
    restore-keys: build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-

⑤ 别在 transform 里做同步重活。 跨语言调用期间,Rust 侧的任务在等你。把重计算改成异步/缓存化:

const cache = new Map() // key: 内容 hash
transform: {
  filter: { id: { include: [/\.svg$/] } },
  async handler(code, id) {
    const key = hash(code)
    if (cache.has(key)) return cache.get(key)
    const out = await optimizeSvg(code) // 异步,不阻塞
    cache.set(key, out)
    return out
  },
}

⑥ 产物侧别忘了基本功。 打包器再快也救不了 2MB 的首屏 JS:

npx vite build && npx vite-bundle-visualizer   # 看谁在膨胀
# 常见收益:moment → dayjs、全量 lodash → lodash-es 按需、echarts 按需引入

6.3 一份可复现的对照数据

我在一个真实中台项目上做的迁移对照(Vue 3 + TS + ~3200 个源模块 + 187 个 npm 依赖,M 系列芯片 / 10 核 / 32GB,5 次取中位数):

场景Vite 7 (Rollup)Vite 8 (Rolldown)变化
生产构建(冷,无缓存)68.2s19.6s-71%
生产构建(热)61.5s16.8s-73%
依赖预构建(首次)9.4s3.1s-67%
dev 冷启动(到可交互)2.8s2.4s-14%
构建峰值内存(RSS)4.1GB1.6GB-61%
产物体积(gzip,总计)1.42MB1.38MB-2.8%

几个值得注意的解读:

  • dev 冷启动收益最小(-14%),因为 Vite 7 的 dev 本来就是 no-bundle + esbuild,本身没瓶颈。真正的 dev 提升要等 Full Bundle Mode;
  • 内存降 61% 比时间降 71% 更有价值。CI 上因为构建 OOM 而不得不加 NODE_OPTIONS=--max-old-space-size=8192 的项目,这条直接解决问题;
  • 产物体积只小了 2.8%。别指望换打包器能瘦身,tree-shaking 变强主要体现在边缘 case。体积要靠依赖治理。

七、横向选型:Rolldown / Rspack / Turbopack / esbuild

Rust 系构建工具现在是三足鼎立格局,选型别看跑分看生态位

RolldownRspackTurbopackesbuild
语言RustRustRustGo
兼容目标Rollup / Vite 插件webpack loader/pluginNext.js 内部自有
主战场Vite 生态、库打包webpack 存量迁移Next.js App Router快速 transform / 小工具
产物钩子受限
编译器基座OxcSWCSWC自研

决策建议非常直白:

  • 现在用 Vite / Rollup → 升 Vite 8,零迁移成本,直接吃收益;
  • 存量 webpack 大仓,插件/loader 一堆改不动 → Rspack,兼容层是它最大的价值;
  • Next.js 用户 → Turbopack 不是你选的,是框架给的;
  • 写 CLI 工具、需要极简 transform → esbuild 依然是最省心的选择;
  • 发布 npm 库 → Rolldown(或基于它的 tsdown 类工具)值得优先考虑,产物控制粒度够细,多格式输出方便。

需要提醒一句:「10–30 倍」是打包阶段的对比,不是你 CI 时长的对比。如果你的构建时间里 40% 花在 tsc --noEmit 类型检查、20% 花在 ESLint、只有 30% 在打包,那么打包快 20 倍,总时长也只能降三成左右。测量优先级永远高于换工具。


八、总结与展望

把这次变更拆到底,Vite 8 的价值可以归纳成三个层次:

第一层是性能。 并行 scan + 无 GC 的 arena AST + 单次 parse 全程复用 + Rust codegen/minify,共同带来 3–10 倍的构建提速和 2–3 倍的内存下降。这是能直接换算成 CI 账单和工程师等待时间的硬收益。

第二层是一致性。 dev 与 build 共用一套解析、转换、打包逻辑,「本地能跑线上炸」这类问题的最大来源被从架构上消除了。对大团队来说,这条比性能更值钱——它砍掉的是那些需要资深工程师花半天定位的诡异 bug。

第三层是能力解锁。 统一内核之后,模块级持久缓存、Module Federation、Full Bundle Mode 才成为可做的事。这些在双打包器架构下不是「没排期」,是「做不了」。

再说三个我认为值得持续盯的方向:

  1. JS 插件与 Rust 内核的边界会继续下沉。 Raw AST transfer 和 Native MagicString 是开始。终局大概是:插件用 JS 描述「意图」,所有实际计算跑在 Rust 里。插件作者现在能做的最好准备,就是把 filter 写清楚、别在钩子里做重同步计算
  2. Full Bundle Mode 会重写「dev 该不该打包」这个共识。 no-bundle 是「打包慢」时代的最优解,前提变了,结论也会变;
  3. 构建工具的竞争正在从速度转向「整链一致性」。 Vite + Rolldown + Oxc(还有 oxlint、Vitest)由同一团队维护,这种垂直整合的收益——语义一致、规范跟进快、跨工具复用分析结果——是单点最快的工具给不了的。

最后是行动建议,就三步:

  1. 今天:在一个分支上装 vite@8,跑一次 hyperfine 对照,把数字记下来(不管快多少,你需要基线);
  2. 本周:给所有自定义插件加上 hook filter,把函数式 manualChunks 换成 advancedChunks,重测;
  3. 上线前:重点回归三件事——CJS 依赖的具名导入、依赖副作用的模块(polyfill / CSS / Web Components 注册)、sourcemap 在错误上报平台是否还能正确还原堆栈。

打包器换心脏这种事,十年也就发生两三次。这次的信号很明确:前端基础设施的 Rust 化已经不是趋势判断,而是既成事实。作为使用者,你不需要去写 Rust,但需要理解它在哪个阶段帮了你、又在哪个边界上收你的税——知道这个,你的构建配置就不会再是从别人博客里抄来的一堆 magic number。

复制全文 生成海报 Vite8 Rolldown Oxc Rust 前端工程化 打包器

推荐文章

pycm:一个强大的混淆矩阵库
2024-11-18 16:17:54 +0800 CST
js函数常见的写法以及调用方法
2024-11-19 08:55:17 +0800 CST
向满屏的 Import 语句说再见!
2024-11-18 12:20:51 +0800 CST
Vue中的表单处理有哪几种方式?
2024-11-18 01:32:42 +0800 CST
18个实用的 JavaScript 函数
2024-11-17 18:10:35 +0800 CST
服务器购买推荐
2024-11-18 23:48:02 +0800 CST
支付宝批量转账
2024-11-18 20:26:17 +0800 CST
程序员茄子在线接单