编程 Vite 8 深度拆解:当前端决定把 esbuild 和 Rollup 一起删掉——Rolldown/Oxc 统一内核、Full Bundle Mode 与 Vite+ 全链路迁移实战

2026-08-08 22:17:41 +0800 CST views 12

Vite 8 深度拆解:当前端决定把 esbuild 和 Rollup 一起删掉——Rolldown/Oxc 统一内核、Full Bundle Mode 与 Vite+ 全链路迁移实战

一句话概括这次变革:Vite 不再是「开发用 esbuild、生产用 Rollup」的双引擎混合动力车,而是换上了一颗用 Rust 打造的统一内核 Rolldown。这不是一次常规的 major 版本升级,这是把发动机拆下来重装的手术。

如果你维护过一个超过 3000 个模块的前端项目,你一定经历过这些荒诞时刻:

  • 本地 vite dev 跑得飞起,一上 CI vite build 就要等 3 分钟,你怀疑人生;
  • 开发环境里 import.meta.env.VITE_XXX 好好的,生产构建出来变成 undefined,你查了两小时发现是某个插件只在 Rollup 阶段生效;
  • 你写了个 Vite 插件,本地开发一切正常,构建时 transform 钩子的入参格式变了,你才想起来开发走的是 esbuild 的预构建管线;
  • 你想给项目上 Module Federation,翻遍文档发现 Vite 的 dev server 阶段根本没有这个概念。

这些不是 bug,是架构债。Vite 从 2020 年那个"给 Vue 用的实验性原型"一路长成 npm 周下载量 2000 万+的基建层,代价就是底层缝合了 esbuild、Rollup、SWC 等一堆职责重叠的工具。Vite 8 干的事情,就是把这笔债一次性还清。

本文会从架构层面把这次变革拆开讲透:Rolldown 凭什么比 Rollup 快 10-30 倍、Oxc 在里面扮演什么角色、Full Bundle Mode 到底改变了什么、迁移过程中哪些配置会炸、以及 Vite+ 这个"统一工具链超集"值不值得跟。全文有大量可直接抄的配置和代码,建议配合你手上的项目一起读。


一、背景:双打包器架构是怎么变成技术债的

1.1 Vite 1-7 的经典架构

先把老架构画清楚,不然后面理解不了为什么非改不可:

                    ┌──────────────────────────────┐
                    │        Vite 1.x ~ 7.x        │
                    └──────────────────────────────┘
                                  │
              ┌───────────────────┴───────────────────┐
              │                                       │
     ┌────────▼────────┐                    ┌─────────▼────────┐
     │   开发阶段 dev   │                    │  生产构建 build   │
     ├─────────────────┤                    ├──────────────────┤
     │ 原生 ESM 直出    │                    │  Rollup 打包      │
     │ esbuild 预构建   │                    │  Rollup 插件链    │
     │ esbuild transform│                    │  Rollup chunking  │
     │ esbuild 插件 API │                    │  Terser/esbuild   │
     └─────────────────┘                    └──────────────────┘
              │                                       │
              └───────────────┬───────────────────────┘
                              │
                    ┌─────────▼─────────┐
                    │   大量 glue code   │
                    │  用来对齐两边行为   │
                    └───────────────────┘

这个设计在 2020 年是极其聪明的:

  • esbuild 用 Go 写,转译速度是 tsc/babel 的几十倍,拿来做 dev 阶段的依赖预构建和 TS 剥离,冷启动直接进入"秒开"时代;
  • Rollup 的产物质量和 tree-shaking 能力当时无人能敌,插件生态庞大,拿来做生产构建稳如老狗;
  • Vite 自己只负责"编排",不重造轮子,团队规模小也能跑得动。

1.2 债务的四个具体形态

但当项目规模上去、当框架层(Nuxt、SvelteKit、Astro、Remix、TanStack Start)全都把 Vite 当底座之后,问题就藏不住了。

债务一:转换管线不一致

esbuild 和 Rollup 各有一套 transform 流水线。同一段 TS 代码,dev 阶段走 esbuild 的 transform,build 阶段走 @rollup/plugin-* 或 Vite 内置的 esbuild transform 插件——两条路径对装饰器、enumimport type、JSX 自动运行时的处理细节存在细微差别。绝大多数时候没事,但一旦踩到就是"只在生产环境复现"的地狱级 bug。

债务二:插件系统割裂

写一个只在 dev 生效的能力,你得写 esbuild 插件;写一个只在 build 生效的能力,你得写 Rollup 插件;要两边都生效,Vite 给你包了一层 Vite Plugin API,但底层仍要分别下沉。插件作者维护成本翻倍。

债务三:性能天花板

Rollup 是 JavaScript 写的,单线程为主。一个中型项目生产构建 40 秒起步,大型 monorepo 3-5 分钟很常见。更糟的是"多次重复解析":Vite 的插件链里,同一个文件可能被 parse 成 AST 三四次(Vite 内部一次、某个插件一次、Rollup 一次),每次都是 JS 侧完整的 parse → transform → codegen,字符串在 JS 和 native 之间反复搬运。

债务四:高阶能力做不了

极致拆包(fine-grained chunking)、模块级增量构建、Module Federation、持久化缓存——这些能力要求打包器暴露底层模块图的可控入口。Rollup 能做一部分,esbuild 基本封闭,两边还对不上,结果就是 Vite 想做也做不了。

1.3 VoidZero 的解法:自己造一整条链

Vite 团队的答案不是"优化 glue code",而是把整条链自己造一遍,全部用 Rust

层次项目职责
构建工具Vite编排、dev server、HMR、配置、生态入口
打包器Rolldown模块图、chunking、tree-shaking、产物生成
编译器Oxcparser / resolver / transformer / minifier / linter / formatter

三层由同一个团队维护,语义完全对齐。这是本次变革最关键的一句话——性能提升只是副产品,"行为一致"才是主目标

时间线大致是这样的(以公开信息为准):

  • 2023 年 ViteConf,Rolldown 项目首次公开;
  • 2024 年 Rolldown 开源;
  • 2025 年 rolldown-vite 作为技术预览包发布,供早期用户试水;
  • 2025 年底 Vite 8 Beta 发布;
  • 2026 年 1 月 Rolldown 1.0 RC;
  • 2026 年 3 月 Vite 8.0 正式发布;
  • 随后 Rolldown 1.0 正式版、Vite+(Vite Plus)以 MIT 协议开源。

二、核心概念:Rolldown、Oxc、Vite+ 到底各是什么

很多人把这三个名字混着用,先分清楚。

2.1 Rolldown:Rust 版的 Rollup,但目标不是"复刻"

Rolldown 常被描述为"Rust 重写的 Rollup",这个说法只对了一半。准确的定位是:

一个兼容 Rollup 插件 API、性能对标 esbuild、专门为 Vite 的使用场景设计的打包器。

三个设计约束:

  1. 性能:Rust 编写,多线程并行,接近原生速度。官方口径是与 esbuild 同级,比 Rollup 快约 10-30 倍。
  2. 兼容性:支持与 Rollup / Vite 相同的插件 API。这意味着绝大多数 Vite 插件在 Vite 8 里开箱即用——这是能推动整个生态迁移的前提。
  3. 能力扩展:Full Bundle Mode、细粒度 chunk 控制、模块级持久缓存、Module Federation。

真实迁移收益(官方公布的早期用户数据):

团队迁移前构建耗时迁移后提升
Linear46s6s~87%
Mercedes-Benz.io最多缩短 38%
Beehiiv缩短 64%

注意 Linear 那个 46s → 6s 不是营销数字,是把整条链换掉之后的结果:省掉的不只是 Rollup 的打包时间,还有 JS/native 边界的字符串搬运、重复 parse、以及 dev/prod 两套管线的 glue 开销。

2.2 Oxc:真正的地基

Oxc(The JavaScript Oxidation Compiler)是这套体系里最容易被忽略、但技术含量最高的一层。它提供:

  • oxc-parser:极快的 JS/TS/JSX parser,产出 ESTree 兼容 AST;
  • oxc-resolver:模块解析(node_modules 查找、exports 字段、tsconfig paths);
  • oxc-transformer:TS 剥离、JSX 转换、装饰器、target 降级;
  • oxc-minifier:压缩器;
  • oxc-linter(Oxlint):600+ 条 ESLint 兼容规则,官方口径比 ESLint 快 50-100 倍;
  • oxc-formatter(Oxfmt):目标是与 Prettier 99% 兼容。

为什么它是关键?因为语义分析(semantic analysis)可以复用

传统链路里,parser 产出 AST 之后就丢掉了作用域信息,打包器 tree-shaking 时要重新分析一遍绑定关系。Rolldown 直接复用 Oxc 的 semantic 结果——变量绑定、引用关系、副作用推断全在一次遍历里搞定。这带来两个直接后果:

  1. Tree-shaking 更准:能识别更复杂的间接引用与副作用边界,产物更小;
  2. 不需要重复 parse:一个文件在整条链路里理论上只 parse 一次。

这就是"统一工具链"最实在的技术红利,不是简单的"Rust 就是快"。

2.3 Vite+:把整个前端工具链塞进一个 CLI

Vite+ 是建立在 Vite 之上的即插即用超集,MIT 协议开源。它整合的东西是这样一张表:

工具用途
Vite + Rolldown开发服务器、应用构建
Vitest测试框架
Oxlint + Oxfmt代码检查与格式化
tsdown库构建、独立可执行文件
Vite Taskmonorepo 任务编排(带智能缓存,类似 turborepo)

对应的命令集:

vp new          # 脚手架,推荐 monorepo 结构,也可用于生成新 package
vp dev          # 开发服务器
vp build        # 生产构建
vp preview      # 预览产物
vp test         # 测试(Vitest 内核,兼容 Jest API,支持浏览器模式与视觉回归)
vp lint         # Oxlint
vp fmt          # Oxfmt
vp check        # 格式化 + lint + 类型检查,一次跑完
vp pack         # 库打包(tsdown + Rolldown)
vp run <task>   # 带缓存的 monorepo 任务运行器
vp install / add / remove   # 代理到 packageManager 声明的包管理器
vp env pin 22.18.0          # Node 版本固定(生成 .node-version)

Vite+ 想解决的是另一个层面的问题:JS 生态的工具碎片化。一个典型项目要管运行时、包管理器、dev server、linter、formatter、test runner、bundler、task runner——每个都有独立配置文件、独立版本、独立升级周期。多团队组织里这些责任分散到没人负责,结果就是依赖版本不同步、构建越来越慢、代码质量滑坡。


三、架构分析:Rolldown 快在哪、Full Bundle Mode 改变了什么

3.1 一次 parse 走天下

用伪代码把新旧链路对比一下最直观。

旧链路(Vite 7):

文件 foo.tsx
  ↓ Vite 插件链(JS):读文件 → parse(acorn)→ 分析 → magic-string 改写 → stringify
  ↓ 字符串跨边界传给 esbuild(Go)
  ↓ esbuild:parse → transform TS/JSX → codegen → 字符串
  ↓ 字符串传回 JS
  ↓ Rollup(JS):parse → 建模块图 → tree-shake → chunk → codegen
  ↓ 字符串传给 minifier(esbuild/terser)
  ↓ 再 parse 一次 → 压缩 → 输出

一个文件被完整 parse 了 4 次,跨语言边界搬运字符串 3 次

新链路(Vite 8 + Rolldown + Oxc):

文件 foo.tsx
  ↓ Rolldown(Rust):oxc-parser 一次 parse → AST + Semantic
  ↓ 同一份 AST 上:transform(TS/JSX)→ 模块图构建 → tree-shake → chunk
  ↓ oxc-minifier 直接吃 AST(无需重新 parse)
  ↓ codegen 输出
  ↓ JS 插件按需通过 Raw AST transfer / MagicString 桥接介入

parse 次数从 4 降到 1,跨边界搬运接近 0。这才是 10-30 倍的来源——不是 Rust 比 JS 快 10 倍,是重复劳动被消除了

3.2 dev 与 prod 语义统一

统一内核最直接的工程价值:import.meta.env?raw?url?worker、CSS Modules、asset 处理这些行为在 dev 和 build 阶段走的是同一套代码路径。

以前你写这样的代码要提心吊胆:

// Vite 7 时代的经典地雷
import shaderSource from './shader.glsl?raw'
import workerUrl from './heavy.worker.ts?worker&url'

// dev 下 shaderSource 是字符串,build 下某些插件组合会变成模块对象
// dev 下 workerUrl 是 blob URL,build 下是打包产物路径,行为不完全一致

Vite 8 之后,这些 query 后缀的语义由 Rolldown 内部统一实现,dev/prod 不再有第二条实现路径。插件作者也解脱了:一套 Rollup 兼容的钩子,走遍 dev 和 build。

3.3 Full Bundle Mode:对原生 ESM 教条的一次修正

这是我认为 Vite 8 时代最有意思的架构反转。

Vite 立身之本是"dev 阶段不打包,直接用浏览器原生 ESM"。这个理念在中小项目上无敌——冷启动毫秒级,HMR 与项目规模解耦。但在超大型项目上,它的代价暴露无遗:

  • 一个页面首次加载可能触发 上万个 HTTP 请求(每个模块一个请求);
  • 浏览器的并发连接数、devtools 的 Network 面板、Service Worker 全部被拖垮;
  • 全量刷新(full reload)慢得离谱,因为要重走整个请求瀑布。

Full Bundle Mode 的做法是:dev 阶段也用 Rolldown 打包,但打得足够快,快到你感觉不出来在打包

官方初步测试数据:

指标提升
开发服务器启动速度~3 倍
完整页面刷新(full reload)~40%
网络请求数量减少约 10 倍

这是一次务实的自我修正:原生 ESM 不是目的,快才是目的。当 Rust 打包器快到可以在 dev 阶段实时打包时,"不打包"这个手段就不再必要了。

架构上大概是这样:

              请求 /src/main.ts
                     │
        ┌────────────┴────────────┐
        │                         │
  [Unbundled 模式]          [Full Bundle 模式]
  按需 transform 单文件      Rolldown 增量打包成少量 chunk
  返回 1 个模块              返回 1 个 chunk(含数百模块)
  → N 个请求                 → N/100 个请求
        │                         │
        └────────────┬────────────┘
                     │
               HMR boundary 计算
            (两种模式共用同一套模块图)

关键在于共用模块图:因为 dev 和 build 都是 Rolldown,HMR 边界计算逻辑不需要两套实现。


四、代码实战:从 Vite 7 迁移到 Vite 8

4.1 两条升级路径

路径 A:直接升级(推荐给中小项目)

pnpm add -D vite@^8
# 然后照常
pnpm dev
pnpm build

路径 B:渐进迁移(推荐给大型/复杂项目)

先切到 rolldown-vite 技术预览包,把「Rolldown 相关的不兼容」和「Vite 8 其他变更」分开暴露:

# 第一步:Vite 7 + rolldown-vite,只换打包器
pnpm add -D rolldown-vite

package.json 里做别名覆盖:

{
  "devDependencies": {
    "vite": "npm:rolldown-vite@latest"
  }
}

跑通、修完插件兼容问题之后,第二步再升到正式的 Vite 8。这样出问题时你至少知道是哪一层的锅。

4.2 框架依赖的版本覆盖(必看)

如果你用的是 Nuxt / Astro / SvelteKit / Vitest 这类把 Vite 当内部依赖的框架,光升级顶层 vite 没用,得覆盖:

// npm
{
  "overrides": {
    "vite": "^8.0.0"
  }
}
// pnpm
{
  "pnpm": {
    "overrides": {
      "vite": "^8.0.0"
    }
  }
}
// yarn
{
  "resolutions": {
    "vite": "^8.0.0"
  }
}

改完之后必须删 lock 文件重装,否则 pnpm 的 peer 解析很可能给你留一个旧版本的幽灵副本:

rm -rf node_modules pnpm-lock.yaml
pnpm install

4.3 配置映射:esbuild 选项没了

这是迁移过程中最高频的报错来源。Vite 7 里控制转译的 esbuild 顶层选项,在 Vite 8 里换成了 oxc

Vite 7:

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

export default defineConfig({
  esbuild: {
    jsxFactory: 'h',
    jsxFragment: 'Fragment',
    target: 'es2020',
    drop: ['console', 'debugger'],
    legalComments: 'none',
  },
})

Vite 8:

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

export default defineConfig({
  oxc: {
    jsx: {
      // Oxc 的 JSX 配置采用结构化写法
      runtime: 'classic',
      pragma: 'h',
      pragmaFrag: 'Fragment',
    },
    target: 'es2020',
    drop: ['console', 'debugger'],
  },
  build: {
    // 压缩器同样切到 oxc
    minify: 'oxc',
    target: 'es2020',
  },
})

具体字段名以官方迁移指南为准,Vite 8 内置了一层兼容映射,很多老写法仍能工作但会打 deprecation 警告。别忽略这些警告,它们是下一个 major 会删的东西。

如果你的插件里出现这样的警告:

warning: `esbuild` option was specified by "vite:xxx-plugin" plugin.
This option is deprecated, please use `oxc` instead.

说明是上游插件还没适配,不影响构建,但要盯着上游更新。

4.4 rollupOptions 还能用吗

能用,但含义变了。Vite 8 保留 build.rollupOptions 作为兼容入口,映射到 Rolldown 的对应能力:

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 手动分包依然支持
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react') || id.includes('scheduler')) return 'react-vendor'
            if (id.includes('lodash')) return 'lodash-vendor'
            return 'vendor'
          }
        },
        chunkFileNames: 'assets/[name]-[hash].js',
        entryFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
      },
      // 外部化
      external: ['vue', 'vue-router'],
    },
  },
})

踩坑预警:部分 Rollup 专有选项在 Rolldown 里没有等价实现,典型的是:

  • output.file(单文件输出)与 output.dir 的校验冲突,会报 [INVALID_OPTION] Warning: Invalid value for option "output.dir",但产物是对的,属于上游校验 bug;
  • preserveModules 的行为细节有差异;
  • 某些 treeshake 的细粒度开关取值不同。

4.5 Rolldown 原生的 chunk 控制

manualChunks 更强的是 Rolldown 提供的高级分包策略。manualChunks 是"给我一个 id,我告诉你去哪个 chunk"的命令式接口,容易写出重复打包和循环依赖。Rolldown 允许声明式地描述分包意图:

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        advancedChunks: {
          groups: [
            {
              name: 'framework',
              test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
              priority: 100,
            },
            {
              name: 'ui',
              test: /node_modules[\\/]@radix-ui[\\/]/,
              priority: 90,
            },
            {
              // 按体积自动切:超过 200KB 的 vendor 自动拆
              name: 'vendor',
              test: /node_modules/,
              minSize: 20_000,
              maxSize: 200_000,
              priority: 10,
            },
          ],
        },
      },
    },
  },
})

priority + minSize / maxSize 的组合能解决 manualChunks 最恶心的两个问题:vendor 巨包小碎片过多。我的经验值:

  • 框架层(react/vue + 路由 + 状态管理)单独一个 chunk,长期缓存命中率最高;
  • UI 组件库单独一个,升级频率中等;
  • 其余 vendor 按 20KB-200KB 自动切,避免一个 800KB 的 vendor.js 拖垮首屏;
  • 业务代码交给路由级 dynamic import,别手动管。

4.6 Vite 8 的新特性

内置 tsconfig paths 支持

以前要装 vite-tsconfig-paths 插件,现在原生支持:

export default defineConfig({
  resolve: {
    tsconfigPaths: true, // 默认关闭,有少量性能开销
  },
})

对应你的 tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@shared/*": ["../shared/src/*"]
    }
  }
}

注意这个开关默认是 false,因为解析 tsconfig 继承链、通配符匹配是有成本的。只在真的用了 paths 的项目里开。

emitDecoratorMetadata 原生支持

用 TypeORM、NestJS 前端 SDK、InversifyJS 这类依赖装饰器元数据的库,以前在 Vite 里是件痛苦的事(要么上 SWC 插件,要么上 babel)。现在 Oxc 原生支持:

// tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

一个实际能跑的依赖注入例子:

// container.ts
import 'reflect-metadata'

type Ctor<T = any> = new (...args: any[]) => T
const registry = new Map<Ctor, any>()

export function Injectable(): ClassDecorator {
  return (target) => {
    // 有了 emitDecoratorMetadata,这里能拿到构造函数参数类型
    const params: Ctor[] = Reflect.getMetadata('design:paramtypes', target) || []
    Reflect.defineMetadata('di:params', params, target)
  }
}

export function resolve<T>(token: Ctor<T>): T {
  if (registry.has(token)) return registry.get(token)
  const params: Ctor[] = Reflect.getMetadata('di:params', token) || []
  const deps = params.map((p) => resolve(p))
  const instance = new token(...deps)
  registry.set(token, instance)
  return instance
}
// service.ts
import { Injectable, resolve } from './container'

@Injectable()
class HttpClient {
  get(url: string) {
    return fetch(url).then((r) => r.json())
  }
}

@Injectable()
class UserService {
  // 参数类型 HttpClient 会被 emitDecoratorMetadata 保留到运行时
  constructor(private http: HttpClient) {}

  getUser(id: string) {
    return this.http.get(`/api/users/${id}`)
  }
}

const userService = resolve(UserService)
export { userService }

在 Vite 7 里,上面这段代码构建后 design:paramtypes 会是空的(esbuild 不生成元数据),运行时直接崩。Vite 8 里开箱即用。

4.7 插件迁移:一个真实的例子

假设你有一个 Vite 7 插件,做的事情是把源码里的 __BUILD_TIME__ 替换成构建时间戳:

// Vite 7 写法
import type { Plugin } from 'vite'
import MagicString from 'magic-string'

export function buildTimePlugin(): Plugin {
  return {
    name: 'build-time',
    enforce: 'pre',
    transform(code, id) {
      if (!/\.[jt]sx?$/.test(id)) return null
      if (!code.includes('__BUILD_TIME__')) return null

      const s = new MagicString(code)
      let index = code.indexOf('__BUILD_TIME__')
      while (index !== -1) {
        s.overwrite(index, index + '__BUILD_TIME__'.length, JSON.stringify(new Date().toISOString()))
        index = code.indexOf('__BUILD_TIME__', index + 1)
      }
      return {
        code: s.toString(),
        map: s.generateMap({ hires: true }),
      }
    },
  }
}

这段代码在 Vite 8 里原样可用——这是 Rolldown 兼容 Rollup 插件 API 的直接价值。但如果你想吃到性能红利,有两个进阶方向。

方向一:用 filter 收窄触发范围

Rolldown 支持在钩子上声明 filter,让 Rust 侧先过滤,避免每个模块都跨边界调一次 JS 函数:

import type { Plugin } from 'vite'

export function buildTimePlugin(): Plugin {
  const BUILD_TIME = JSON.stringify(new Date().toISOString())

  return {
    name: 'build-time',
    enforce: 'pre',
    transform: {
      // 关键:filter 在 Rust 侧执行,命中才回调 JS
      filter: {
        id: /\.[jt]sx?$/,
        code: '__BUILD_TIME__',
      },
      handler(code) {
        return {
          code: code.replaceAll('__BUILD_TIME__', BUILD_TIME),
          map: null,
        }
      },
    },
  }
}

在一个 5000 模块的项目里,这个改动能把插件的 JS 回调次数从 5000 次降到十几次。跨语言边界调用的开销是真实存在的,一次调用大约几十微秒,5000 次就是几百毫秒白烧。

方向二:能用内置能力就别写插件

上面这个需求其实用 define 就够了:

export default defineConfig({
  define: {
    __BUILD_TIME__: JSON.stringify(new Date().toISOString()),
  },
})

define 的替换发生在 Rust 侧,零 JS 回调。Vite 8 时代写插件的第一原则:先确认内置能力搞不定,再动手。

4.8 Environment API 与多环境构建

Vite 6 引入的 Environment API 在 Vite 8 里终于有了配得上它的底层。典型场景是同一份代码同时构建 client / SSR / edge 三份产物:

import { defineConfig } from 'vite'

export default defineConfig({
  environments: {
    client: {
      build: {
        outDir: 'dist/client',
        rollupOptions: {
          input: 'src/entry-client.ts',
        },
      },
    },
    ssr: {
      build: {
        outDir: 'dist/server',
        ssr: true,
        rollupOptions: {
          input: 'src/entry-server.ts',
        },
      },
      resolve: {
        // SSR 环境走 node 条件
        conditions: ['node', 'import'],
        noExternal: ['some-esm-only-pkg'],
      },
    },
    edge: {
      build: {
        outDir: 'dist/edge',
        ssr: true,
        rollupOptions: {
          input: 'src/entry-edge.ts',
        },
      },
      resolve: {
        // Edge Runtime 走 worker 条件,不能用 node 内置模块
        conditions: ['worker', 'browser', 'import'],
        external: [],
      },
    },
  },
})

插件里区分环境:

import type { Plugin } from 'vite'

export function envAwarePlugin(): Plugin {
  return {
    name: 'env-aware',
    transform: {
      filter: { id: /\/src\// },
      handler(code, id) {
        // this.environment 提供当前环境上下文
        const envName = this.environment?.name

        if (envName === 'edge') {
          // Edge 环境下把 node:fs 的调用替换成报错桩
          if (code.includes('node:fs')) {
            this.warn(`[env-aware] ${id} 在 edge 环境引用了 node:fs`)
          }
        }
        return null
      },
    },
  }
}

以前这种需求要靠 process.env.SSR 加一堆 if-else,现在环境是一等公民。


五、性能优化:怎么把 Vite 8 的红利吃满

升级完只是拿到了"免费的那部分"。真正把构建从 40 秒压到 5 秒,还得动手。

5.1 先测量,别猜

不要凭感觉优化。先拿到数据:

# 1. 构建耗时基线(跑三次取中位数,避免冷缓存干扰)
for i in 1 2 3; do
  rm -rf dist node_modules/.vite
  /usr/bin/time -f "run$i: %e s" pnpm build
done
# 2. 打开 Rolldown 的构建剖析
DEBUG=rolldown:* pnpm build 2>&1 | tee build-trace.log
# 3. 产物体积分析
pnpm add -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    visualizer({
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true,
      template: 'treemap', // treemap 最直观
    }),
  ],
})

一个简易的构建耗时插件,用来定位是哪个阶段慢:

import type { Plugin } from 'vite'

export function timingPlugin(): Plugin {
  const marks: Record<string, number> = {}
  const durations: Record<string, number> = {}
  let transformCount = 0
  let transformTotal = 0

  const mark = (k: string) => { marks[k] = performance.now() }
  const measure = (k: string) => {
    durations[k] = performance.now() - (marks[k] ?? performance.now())
  }

  return {
    name: 'timing',
    buildStart() { mark('build') },
    transform(code, id) {
      const t = performance.now()
      transformCount++
      // 这里不做实际转换,只记账
      transformTotal += performance.now() - t
      return null
    },
    renderStart() { measure('build'); mark('render') },
    closeBundle() {
      measure('render')
      console.table({
        'buildStart→renderStart (ms)': Math.round(durations.build),
        'renderStart→closeBundle (ms)': Math.round(durations.render),
        'transform 回调次数': transformCount,
        'transform 累计耗时 (ms)': Math.round(transformTotal),
      })
    },
  }
}

5.2 七条经过验证的调优规律

规律一:插件链是新的瓶颈

Rolldown 快了之后,构建时间的大头往往从"打包"变成了"跑 JS 插件"。用上面那个 timing 插件量一下,如果 transform 回调次数是模块数的好几倍,说明你的插件链有严重的重复触发。逐个给插件加 filter

规律二:干掉 babel

如果你的项目里还有 @vitejs/plugin-react 的 babel 模式、或者独立的 vite-plugin-babel,这是最大的性能黑洞。babel 是纯 JS 单线程,一个文件跑一遍 babel 的开销比 Rolldown 打包整个项目还夸张。

// 差:走 babel
import react from '@vitejs/plugin-react'
export default defineConfig({
  plugins: [react({ babel: { plugins: ['babel-plugin-styled-components'] } })],
})

// 好:走 oxc / swc,或者干脆用编译时替代方案
import react from '@vitejs/plugin-react-oxc'
export default defineConfig({
  plugins: [react()],
})

如果某个 babel 插件实在无法替代,至少用 include 把它限制在最小的文件集合里。

规律三:optimizeDeps 的角色变了

Vite 7 时代 optimizeDeps 是 esbuild 预构建,是 dev 启动慢的主因。Vite 8 里预构建也走 Rolldown,速度大幅提升,但手动 include 依然有价值——尤其是那些 CJS-only 或者深层动态 import 的包:

export default defineConfig({
  optimizeDeps: {
    include: [
      'lodash-es',
      'echarts/core',
      'echarts/charts',
      'echarts/components',
      // 深层路径要显式声明,否则 dev 时会触发二次预构建 + 页面 reload
      '@monaco-editor/react',
    ],
    exclude: ['@my-org/local-linked-pkg'],
  },
})

判断标准:如果你 pnpm dev 之后打开页面,控制台出现 new dependencies optimized: xxx 并伴随一次自动刷新,就把 xxx 加进 include

规律四:sourcemap 是隐形税

生产构建开 sourcemap: true 通常要多付 30%-50% 的时间和大量内存。分环境处理:

export default defineConfig(({ mode }) => ({
  build: {
    // 只在需要上报错误的环境生成,且用 hidden 不暴露给用户
    sourcemap: mode === 'production' ? 'hidden' : true,
  },
}))

hidden 会生成 .map 文件但不在产物里写 //# sourceMappingURL 注释,你可以上传到 Sentry 之后把 map 文件从 CDN 删掉。

规律五:CSS 处理是另一条独立管线

Rolldown 加速的是 JS 链路,Sass/Less 编译不在其中。如果你的项目 CSS 很重:

export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        // 用 modern-compiler API,比 legacy 快数倍
        api: 'modern-compiler',
        // 避免每个文件都 @import 一遍巨大的变量文件
        additionalData: `@use "@/styles/vars" as *;`,
      },
    },
    // 生产环境关掉 devSourcemap
    devSourcemap: false,
  },
})

顺带一提:additionalData 里塞的东西会注入到每一个 scss 文件,塞的是 @use(只解析一次)还是 @import(每次展开)性能差距是数量级的。

规律六:monorepo 用 Vite Task 的缓存

多包构建最大的浪费是"没变的包也重新构建"。vp run build 带内容哈希缓存:

// vite.config.ts 里的 task 配置(示意)
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**"]
    }
  }
}

关键是 inputs 要写准。写太宽(比如包含 README.md)会导致改个文档就全量重建;写太窄会导致漏掉真实依赖,产出脏缓存。

规律七:CI 上把 node_modules/.vite 缓存住

# GitHub Actions 示例
- uses: actions/cache@v4
  with:
    path: |
      node_modules/.vite
      node_modules/.cache
      ~/.cache/rolldown
    key: vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('vite.config.ts') }}
    restore-keys: |
      vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      vite-${{ runner.os }}-

注意 key 里要包含 vite.config.ts 的哈希——配置变了缓存必须失效,否则会出现"本地好好的、CI 上产物是旧的"这种见鬼事件。

5.3 未来的两个性能杀手锏

Vite 团队正在推进的两个实验特性,值得提前了解:

Raw AST transfer:让 JS 插件以极低开销直接访问 Rust 侧生成的 AST,而不是接收字符串再自己 parse。一旦落地,transform 钩子里写 acorn.parse(code) 的插件全部可以省掉一次 parse。

Native MagicString transforms:你依然用 JS 写转换逻辑(s.overwrite()s.append()),但实际的字符串操作和 sourcemap 计算下沉到 Rust 执行。对于那些做大量小改写的插件,这个优化收益极大。

这两个方向共同指向一个目标:让 JS 插件生态不成为 Rust 内核的性能拖累。这比"再快 10%"重要得多——毕竟 Vite 的护城河从来不是性能,是生态。


六、真实迁移踩坑清单

以下是从公开的迁移案例和实践中整理的高频坑,按出现概率排序。

坑 1:类型导入链断裂

在 Vite+ 场景下,从 vite-plus 导入类型可能导致 dts 生成失败(类型定义引用了 vite-plus-test,解析链断掉):

// ❌ 可能导致 dts 生成失败
import type { Plugin, UserConfig } from 'vite-plus'

// ✅ 类型仍从 vite 导入
import type { Plugin, UserConfig } from 'vite'

规律:运行时 API 从新包导,类型从老包导,直到上游修复。

坑 2:库构建的 CSS 文件名变了

tsdown 默认把 CSS 产物命名为 style.css,而你的 package.json exports 里可能写的是 index.css

{
  "exports": {
    ".": {
      "types": "./dist/index.d.mts",
      "import": "./dist/index.mjs"
    },
    // 补上正确的 CSS 路径
    "./dist/style.css": "./dist/style.css"
  }
}

消费侧同步改:

// 旧
import 'my-lib/dist/index.css'
// 新
import 'my-lib/dist/style.css'

坑 3:external 被弃用

// ❌ 会打警告:external is deprecated
{ external: ['preact'] }

// ✅
{ deps: { neverBundle: ['preact'] } }

坑 4:单文件输出的假警告

[INVALID_OPTION] Warning: Invalid value for option "output.dir"

outputOptions.file 做单文件输出时会报这个,但产物是正确的。属于 Rolldown 内部校验的 bug,暂时忽略,盯上游修复。

坑 5:插件还没适配 oxc

warning: `esbuild` option was specified by "vite:preact-jsx" plugin.
This option is deprecated, please use `oxc` instead.

上游插件的锅,无法自行解决。临时方案是降低日志级别:

export default defineConfig({
  logLevel: 'warn', // 或 'error'
})

但别忘了定期回来检查上游是否更新了。

坑 6:CI 脚本检测启动失败

Vite+ 的启动日志格式和 Vite 不同,那些靠正则匹配日志判断"服务起来了"的脚本会挂:

// ❌ 只认 Vite 的输出
if (data.includes('ready in')) isReady = true

// ✅ 两种格式都认
if (data.includes('ready in') || data.includes('Local:')) isReady = true

更稳妥的做法是别匹配日志,改用端口探测:

import net from 'node:net'

function waitPort(port: number, timeout = 60_000): Promise<void> {
  const start = Date.now()
  return new Promise((resolve, reject) => {
    const tick = () => {
      const sock = net.connect(port, '127.0.0.1')
      sock.once('connect', () => { sock.destroy(); resolve() })
      sock.once('error', () => {
        sock.destroy()
        if (Date.now() - start > timeout) return reject(new Error('port timeout'))
        setTimeout(tick, 300)
      })
    }
    tick()
  })
}

坑 7:Oxfmt 用的是 Prettier 命名,不是 Biome 命名

从 Biome 迁过来的项目要做一次映射:

BiomeOxfmt
indentStyle: "space" + indentWidth: 2tabWidth: 2
lineWidth: 80printWidth: 80
quoteStyle: "single"singleQuote: true
semicolons: "asNeeded"semi: false
trailingCommas: "all"trailingComma: 'all'

七、Vite+:要不要跟

Vite+ 把 29 个配置文件压缩到 13 个、把 5 个 devDependencies 压缩到 1 个,听起来很美。但这是一个架构选型决策,不是一个升级决策。

7.1 迁移的实际操作

Vite+ 官方提供了给 coding agent 用的迁移提示词,核心命令就一条:

# 前置:确保 Vite 8+、Vitest 4.1+
vp migrate --no-interactive

它会自动做:

  • 重写 vite 导入为 vite-plus
  • 重写 vitest 导入为 vite-plus/test
  • 更新 package.json 的依赖和 scripts;
  • 更新 workspace 配置。

剩下的手动活是把各工具的独立配置合并进 vite.config.ts

import { defineConfig } from 'vite-plus'

export default defineConfig({
  // 原 vitest.config.ts
  test: {
    environment: 'happy-dom',
    coverage: { provider: 'v8', reporter: ['text', 'lcov'] },
  },

  // 原 .prettierrc / biome.jsonc 的 formatter 部分
  fmt: {
    singleQuote: true,
    semi: false,
    tabWidth: 2,
    printWidth: 80,
    trailingComma: 'all',
    arrowParens: 'always',
    bracketSpacing: true,
  },

  // 原 .eslintrc / biome.jsonc 的 linter 部分
  lint: {
    rules: {
      'no-explicit-any': 'off',
      'no-non-null-assertion': 'off',
    },
  },

  // 原 tsdown.config.ts
  pack: {
    platform: 'browser',
    entry: ['./src/index.ts'],
    format: ['esm'],
    dts: true,
    clean: true,
    deps: { neverBundle: ['preact'] },
  },
})

Git hooks 和 Node 版本也一并接管:

rm lefthook.yml .nvmrc
vp config --hooks        # 自动配 pre-commit → vp staged
vp env pin 22.18.0       # 生成 .node-version,自动装对应 Node

验证四连:

vp install && vp check && vp test && vp build

vp check 的输出长这样,一次跑完格式化+lint+类型检查:

pass: All 42 files are correctly formatted (88ms, 16 threads)
pass: Found no warnings, lint errors, or type errors in 42 files (184ms, 16 threads)

42 个文件、272 毫秒。对比一下 eslint . && tsc --noEmit && prettier --check . 串行跑一遍要多久,你就知道"统一工具链"省下来的不只是配置文件。官方说 vp check 比分开跑「类型感知的 lint 规则 + 类型检查」快约 2 倍,原因是类型信息只算一次。

7.2 决策矩阵

项目特征建议
全新项目✅ 直接上,没有历史包袱
monorepo,多包配置分散✅ 收益最大
工具链复杂(5 个以上独立工具)✅ 值得
依赖冷门 Vite 插件⚠️ 先验证插件兼容性
构建流程高度定制(自研 CI 深度耦合)⚠️ 等 Vite+ 配置面更完整
生产稳定性要求极高、变更窗口小❌ 再等半年
只有一个 app、配置就三行❌ 收益抵不上迁移成本

时间成本参考:中小项目 2-4 小时,大型 monorepo 1-2 天。

7.3 一点冷静的判断

我对 Vite+ 的态度是"看好但不着急"。理由有三条:

第一,"统一"的代价是耦合。 以前 linter 出问题你可以单独降 ESLint 版本,现在 lint 出问题你得动整个 vite-plus。工具链的独立可替换性是有价值的,被统一之后这个价值就没了。

第二,Oxlint 的规则覆盖还不是 ESLint。 600+ 条规则听起来多,但 ESLint 生态有上万条社区规则。如果你重度依赖 eslint-plugin-importeslint-plugin-testing-library、某个公司内部规则集,迁过去大概率有缺口。

第三,MIT 开源不等于永远免费。 Vite+ 由 VoidZero 主导,商业公司做基建,MIT 协议是当下的选择。这不是唱衰,只是提醒:把整条工具链绑在单一供应商上,你至少要知道自己在做这个决策。

但 Vite 8 本身不一样,Vite 8 应该升。 它是 Vite 主线,兼容 Rollup 插件 API,风险可控,收益(构建速度、行为一致性、装饰器元数据、tsconfig paths)是实打实的。


八、更大的图景:前端工具链的"Rust 化"到了什么阶段

把视野拉远一点,2024-2026 这三年,前端工具链发生的事情可以概括成一句话:JavaScript 写的工具链正在被系统语言全面替换

层次老方案新方案语言
打包器Rollup / webpackRolldown / TurbopackRust
转译器BabelOxc / SWCRust
压缩器TerserOxc minifier / esbuildRust / Go
LinterESLintOxlint / BiomeRust
FormatterPrettierOxfmt / BiomeRust
包管理器npm / yarnpnpm (Node) / bun (Zig) / pacquet (Rust)混合
类型检查tsc (TS)tsgo (Go)Go
测试运行器JestVitestNode
运行时Node.jsBun / DenoZig / Rust

有意思的是,这一轮替换里出现了两条不同的路线:

  • VoidZero 路线(Rolldown + Oxc + Vite+):Rust,强调兼容既有生态(Rollup 插件 API、ESLint 规则、Prettier 配置),用"无痛迁移"换采用率;
  • 微软路线(TypeScript 7 用 Go 重写编译器):Go,强调与现有 TS 代码库的结构对应关系,用"逐行移植"降低正确性风险。

两条路线选了不同的语言,但共享同一个判断:JS 写的工具链已经触到了性能天花板,而这个天花板正在成为整个生态的效率瓶颈

对我们写业务的人来说,这轮变革的实际含义很朴素:

  1. 构建时间从"泡杯咖啡"变成"眨个眼",CI 成本下降,反馈循环变紧;
  2. 工具的行为更可预测,因为实现收敛到了同一套语义;
  3. 插件生态会经历一次洗牌,那些用 babel、用 acorn 手写 AST 遍历的插件会逐渐被内置能力或原生实现替代;
  4. 配置的心智负担在降低,但供应商集中度在升高。

第 4 条值得单独琢磨。十年前我们抱怨 webpack 配置地狱,今天我们抱怨的可能是"整条链都在一家手里"。这不是坏事也不是好事,是工程演进的常态——从碎片化到集中化,再从集中化裂变出新的碎片。你要做的不是站队,是搞清楚每一次选择的代价是什么。


九、总结:一份可执行的行动清单

如果你只想带走一份 checklist,就是下面这个。

立刻可以做(半小时):

# 1. 看看你现在多慢,留个基线
rm -rf dist node_modules/.vite && time pnpm build

# 2. 建分支
git checkout -b chore/vite8-migration

# 3. 升级
pnpm add -D vite@^8

升级过程中(1-3 小时):

  • overrides / resolutions 覆盖框架内部的 vite 版本,删 lock 重装
  • esbuild 顶层选项 → oxc
  • build.minify: 'esbuild''oxc'
  • 检查 rollupOptions 里的 Rollup 专有选项,尤其 output.filepreserveModules
  • 跑一遍构建,把所有 deprecation 警告记下来,逐条处理
  • 对比新旧产物体积和 chunk 划分(rollup-plugin-visualizer 存两份 stats.html 对比)
  • 跑完整的 E2E,重点测 ?raw / ?url / ?worker / CSS Modules / 动态 import 的路径

升级之后(持续):

  • 给自研插件加 transform.filter,收窄触发范围
  • 干掉 babel,换 oxc / swc 版本的插件
  • advancedChunks 替代手写 manualChunks
  • sourcemaphidden
  • scss 切 modern-compiler API
  • CI 缓存 node_modules/.vite,key 带上 config 哈希
  • 关注 Full Bundle Mode 的正式发布,大型项目 dev 体验会再上一个台阶

保持观望:

  • Vite+ 的 vp 工具链——新项目和 monorepo 可以试,老项目再等等
  • Raw AST transfer / Native MagicString——插件作者提前关注

最后说句实在的。这些年前端的"版本升级"太多了,多到大家产生了免疫。但 Vite 8 这一次不太一样——它不是加了几个 API,是把地基换了。换地基这种事,早做比晚做痛苦少,因为整个插件生态会跟着 Rolldown 走,你拖得越久,遇到"这个插件已经不维护 Vite 7 分支了"的概率越大。

至于 Vite+,我的建议是:看着,学着,但别急着把整个团队的工具链押上去。基建这东西,快一步是先锋,快三步是先烈。

推荐文章

PHP openssl 生成公私钥匙
2024-11-17 05:00:37 +0800 CST
ElasticSearch 结构
2024-11-18 10:05:24 +0800 CST
MySQL数据库的36条军规
2024-11-18 16:46:25 +0800 CST
rangeSlider进度条滑块
2024-11-19 06:49:50 +0800 CST
Nginx 如何防止 DDoS 攻击
2024-11-18 21:51:48 +0800 CST
如何在Rust中使用UUID?
2024-11-19 06:10:59 +0800 CST
CSS 中的 `scrollbar-width` 属性
2024-11-19 01:32:55 +0800 CST
程序员茄子在线接单