普通视图

发现新文章,点击刷新页面。
昨天以前首页

Vite 炸裂快,Webpack 稳如山,Turbopack 想两头要:谁才是下一个王?

作者 却尘
2025年10月21日 08:36

你改了一行代码,按下保存。
Vite:100 毫秒内浏览器刷新。
Webpack:要等一秒。

为什么差距这么大?
答案在这行哲学级设计选择里:

“文件变化时,要重新编译多少模块?”

  • Vite:只编译这个文件。
  • Webpack / Turbopack:编译这个文件和它影响到的所有模块

这一个决策,让 Vite 快出一倍,也种下了“偶尔翻车”的种子。

⚙️ Vite 为什么这么快

Vite 的核心是极致的最小编译

  1. 检测到改动 → 只用 esbuild 重编译这一个文件(约 20–30ms)。
  2. 找 HMR 边界(比如 React 组件)。
  3. 通过 WebSocket 通知浏览器:import('/src/xxx?t=时间戳')
  4. React Fast Refresh 替换组件函数,保留 state 并重新渲染。

整个过程通常100ms 内完成,开发者几乎感受不到延迟。

⚡ 这是“即时反馈”体验的关键。

Vite 为什么会“翻车”

Vite 快,是因为它只替换组件函数,不会重新执行模块顶层代码。
但正是这点,导致某些情况出错或不一致。

1. 模块顶层常量不更新

export const THEME = 'dark';
const currentTheme = themeMap[THEME]; // 只计算一次

改成 'light' 后,组件渲染新主题,但 currentTheme 依然是旧值。

2. 顶层实例不重建

const store = new Store();

改了 Store 的逻辑,实例还在旧的内存里,不会重建。

3. 副作用重复或缺失

console.log('init');
window.initSomething();

模块被重复执行时,副作用代码可能被多次调用,产生混乱。

4. 循环依赖

A 导入 B,B 又导入 A。Vite 会放弃 HMR,触发整页刷新。

这些问题都源于:模块没重新执行
Vite 的成功率在实践中大约 90–95%

Webpack 的思路:稳中求全

Webpack 在 HMR 时,不仅编译变动的模块,还会编译所有受影响的依赖链,确保顶层逻辑重新执行。

优点:一致性高,不会出现“变量更新了但模块没重跑”的错乱。
缺点:慢。

  • 依赖分析:50ms
  • 多模块重编译:300ms
  • 推送更新 + 执行:150ms
  • 总计约 500ms

成功率约 97–99% ,但牺牲了灵敏度。

Webpack 的哲学是“宁慢勿错”,保证行为一致。

Turbopack:两边都要的野心家

Next.js 团队的 Turbopack 想在Vite 的速度Webpack 的可靠性之间找平衡。

  • 使用 Rust 编译管线,极快的增量编译。
  • 增量图更新 + 响应式依赖追踪机制(动态重计算受影响的子图)。
  • 成功率接近 Webpack,但速度明显更快。

实测数据:

工具 平均更新时间 成功率 特点
Vite ≈100ms 90–95% 极速反馈,偶尔翻车
Webpack ≈500ms 97–99% 稳定保守,延迟明显
Turbopack ≈200ms 96–99% 速度稳定兼顾,未来之选

Turbopack 的哲学是“用增量计算保障正确性”,
目标是在大型项目中维持接近 Vite 的体验,又避免状态不一致。

如何写出不容易“翻车”的 Vite 代码

Vite 快速没问题,关键是你别坑自己。

1. 状态放组件,不放模块顶层

// ❌
const store = new Store();

// ✅
export function App() {
  const [store] = useState(() => new Store());
}

2. 导出对象而不是常量

// ❌
export const MAX_COUNT = 10;

// ✅
export const config = { MAX_COUNT: 10 };
if (import.meta.hot) {
  import.meta.hot.accept(newModule => Object.assign(config, newModule.config));
}

3. 避免模块顶层副作用

// ❌
window.initAnalytics();

// ✅
export function initAnalytics() {
  window.initAnalytics();
}

4. 用函数返回动态值

// ❌
export const theme = themeConfig[THEME];

// ✅
export function getTheme() {
  return themeConfig[THEME];
}

这些习惯能让 Vite 的 HMR 稳定性接近 Turbopack 水平。

结论

Vite 就像一辆赛车:快得离谱,但需要会开。
Webpack 像老式坦克:笨重,却从不出事。
Turbopack 则在造一辆装了防撞系统的超跑

你想要速度、稳定,还是两者兼得?
选谁,其实就是选择开发体验的哲学。

当你敲下 `pnpm run dev`,这台机器到底在背后干了什么?

作者 却尘
2025年10月21日 08:16

每个前端开发者每天都要敲几十上百次 pnpm run dev,但有多少人真正理解这短短四个单词背后,触发了多少进程、编译了多少模块、建立了多少连接?

这不是一篇入门教程。如果你只想知道"它启动了一个开发服务器",那大可不必继续读下去。但如果你想知道为什么 Vite 比 Webpack 快 10 倍,为什么改一行 CSS 不用刷新浏览器,为什么 Next.js 的页面能在 0.1 秒内显示内容——那这篇文章会给你答案。

第一幕:pnpm 的依赖解析黑魔法

命令执行的真实路径

当你在终端输入 pnpm run dev,第一个动作是 pnpm 去读取 package.jsonscripts.dev 字段。假设这里写的是 vite,那么接下来发生的事情并不是直接执行全局的 vite 命令。

pnpm 会做一件至关重要的事:node_modules/.bin 目录塞到环境变量 PATH 的最前面。这意味着当脚本里写 vite 时,系统实际执行的是 node_modules/.bin/vite,而不是你可能全局安装的那个版本。这个机制保证了项目依赖的版本隔离,避免了"我电脑上能跑,你电脑上跑不了"的经典问题。

pnpm 的符号链接架构

打开你的 node_modules 目录,会看到一个神奇的结构:

node_modules/
  .pnpm/
    vite@5.0.0/
      node_modules/
        vite/           ← 真正的 vite 代码在这里
        esbuild/
        rollup/
  vite/                 ← 这只是个软链接
  react/                ← 也是软链接

这种设计让 pnpm 实现了一个壮举:即使你 100 个项目都用了 lodash,磁盘上也只有一份 lodash 的代码。通过符号链接(symlink),pnpm 在 .pnpm 目录里维护了一个全局的依赖存储池,每个项目只是链接到这个池子。

这不仅节省了磁盘空间,更重要的是保证了依赖的扁平化和可预测性。你在代码里 import lodash,Node.js 的模块解析算法会顺着链接找到 .pnpm/lodash@4.17.21/node_modules/lodash,路径明确,不会有幽灵依赖。

第二幕:不同框架的启动策略

Vite:按需编译的革命

image.png

node_modules/.bin/vite 被执行时,实际运行的是一个 Shell 脚本,它最终调用 node node_modules/vite/bin/vite.js。Vite 的启动流程分为几个关键步骤:

依赖预构建。Vite 会扫描你的入口文件(通常是 index.html 里引用的 main.js),分析出所有 npm 依赖。这些依赖会被 esbuild 预先打包到 node_modules/.vite 目录。为什么要这么做?因为像 lodash 这样的库,有几百个小文件,如果按浏览器原生 ESM 的方式加载,会产生几百个 HTTP 请求。esbuild 把它们合并成一个文件,请求数从 300 降到 1。

这个预构建过程用的是 esbuild,用 Go 语言编写,速度是 Babel 的 20-30 倍。一个中型项目的依赖预构建,通常在 1-2 秒内完成。

开发服务器启动。Vite 基于 Connect(一个 Node.js 的 HTTP 中间件框架)启动服务器,默认监听 5173 端口。与此同时,它会启动一个 WebSocket 服务器,这是实现 HMR 的关键通道。

按需编译的核心逻辑。这是 Vite 快的根本原因。当浏览器请求 /src/App.jsx 时,Vite 拦截这个请求,实时把 JSX 转换成浏览器能理解的 JavaScript。这个转换过程同样用 esbuild,耗时通常在几毫秒到几十毫秒。

对比 Webpack 的做法:Webpack 会在启动时构建整个应用的依赖图,把所有模块打包成一个或几个 bundle 文件。一个中型项目,Webpack 的冷启动可能需要 10-30 秒;而 Vite 的冷启动通常在 1-3 秒。这就是为什么用 Vite 开发时,保存文件到看到更新,几乎感觉不到延迟。

image.png

Next.js:服务端渲染的复杂性

next dev 的启动流程比纯前端框架复杂得多,因为它要同时处理服务端渲染(SSR)和客户端交互。

双重编译器。Next.js 会启动两个编译进程:一个处理服务端代码,一个处理客户端代码。在 Next.js 13+ 的 App Router 架构中,这个区分更加明确:服务端组件(Server Components)只在服务器编译和执行,客户端组件(标记了 'use client' 的)会被编译两次——服务端渲染一次生成 HTML,客户端再 hydrate 一次让页面可交互。

路由的动态生成。当你在 pages/app/ 目录里创建文件时,Next.js 会自动生成对应的路由。这个过程在开发时是惰性的:第一次访问 /about 路由时,Next.js 才会编译 app/about/page.jsx。这种策略避免了启动时编译整个项目,但也意味着第一次访问某个页面会稍慢。

Turbopack 的增量编译。Next.js 13+ 引入了 Turbopack(用 Rust 编写),它采用了一种叫做"增量计算"的策略。当你修改一个文件时,Turbopack 会精确计算出受影响的模块,只重新编译这些模块。假设你的项目有 1000 个模块,你修改了一个底层组件,传统方式可能需要重新编译几百个依赖它的模块;而 Turbopack 会缓存之前的编译结果,只处理真正需要更新的部分,速度提升可达 5-10 倍。

其他框架的差异化策略

Nuxt 的架构与 Next.js 类似,但基于 Vue 生态。它使用 Nitro 作为服务器引擎,这个引擎的抽象层次更高,可以无缝切换部署目标——从 Node.js 到 Cloudflare Workers,只需要改配置文件。

Astro 走的是静态优先路线。它的开发服务器基于 Vite,但编译策略完全不同:默认情况下,所有组件都被编译成纯 HTML,没有 JavaScript。只有明确标记了 client:loadclient:idle 的组件,才会打包 JS 到客户端。这种"岛屿架构"(Islands Architecture)让最终产物非常轻量,一个典型的 Astro 站点,首页的 JS 可能只有 2-3KB。

SvelteKit 同样基于 Vite,但 Svelte 的编译器会把组件编译成原生 DOM 操作,没有虚拟 DOM 的运行时开销。一个简单的计数器组件,React 编译后可能是 2KB,Svelte 编译后只有 500 字节。

第三幕:HMR——不刷新浏览器的魔法

什么是 HMR,为什么它改变了开发体验

Hot Module Replacement,热模块替换。在没有 HMR 的年代,改一行 CSS 需要刷新浏览器,表单里填的数据全丢了,弹窗关闭了,滚动位置回到顶部。如果你在调试购物车的第三步,每次改代码都要重新点一遍前面的按钮,这个过程能让人崩溃。

HMR 的本质是:在不刷新页面的前提下,替换掉改变的模块,并且尽可能保留应用的当前状态。这个"尽可能"三个字,是整个 HMR 机制最难的部分。

技术实现的三个层次

第一层:文件监听。所有现代构建工具都使用 chokidar 这个库来监听文件系统。当你按下 Ctrl+S,操作系统会发出一个文件变更事件,chokidar 捕获到这个事件,触发重新编译。

第二层:模块重新编译。假设你修改了 Counter.jsx,构建工具会重新编译这个文件。Vite 的做法是只编译这一个文件,生成新的代码;Webpack 则需要重新走一遍依赖图,找出所有导入了 Counter 的模块,也标记为需要更新。

第三层:通知浏览器。服务器通过 WebSocket 推送一条消息给浏览器:

{
  type: 'update',
  path: '/src/Counter.jsx',
  timestamp: 1699123456789
}

浏览器收到消息后,会重新 import 这个模块。注意这里的时间戳参数,它的作用是绕过浏览器缓存,确保拿到的是最新代码。

React Fast Refresh:状态保留的黑科技

React 的 HMR 实现叫 Fast Refresh,是 Meta 官方维护的。它的核心机制是给每个组件生成一个"签名"(Signature)。

当你写下这样的代码:

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Tom');
  
  useEffect(() => {
    console.log('mounted');
  }, []);
  
  return <button>{count}</button>;
}

Babel 或 SWC 在编译时会注入额外的代码,记录这个组件用了哪些 Hooks,顺序是什么。签名大概长这样:

{
  hooks: ['useState', 'useState', 'useEffect'],
  customHooks: [],
  forceReset: false
}

当你修改代码,把按钮文字从 {count} 改成 次数: {count},编译器重新生成签名,发现 Hooks 的种类和顺序都没变,于是判定可以安全地热更新。

这时候 React 会做一件精妙的事:它找到这个组件在 Fiber 树中的位置,直接替换掉组件函数的引用,但保留 Fiber 节点上的 memoizedState。这个 memoizedState 就是存储 Hooks 状态的地方,是个链表结构:

{
  value: 0,        // 第一个 useState 的值
  next: {
    value: 'Tom',  // 第二个 useState 的值
    next: {
      value: undefined,  // useEffect 没有返回值
      next: null
    }
  }
}

因为链表没动,所以 count 还是之前的值,name 也还在。然后 React 调度一次更新,重新执行组件函数,用新的 JSX 渲染,按钮文字就更新了,但状态完好无损。

Fast Refresh 的边界情况。如果你在 HMR 的过程中改变了 Hooks 的数量或顺序,比如加了一个 useEffect,签名不匹配了,Fast Refresh 会放弃热更新,强制重新挂载组件。这时候状态就丢了。

还有一种情况是组件外部有副作用。假设你在文件顶层写了 window.globalConfig = { theme: 'dark' },HMR 时这行代码会重新执行,但浏览器的 window 对象还是同一个,所以看起来没问题。但如果有其他模块依赖这个全局变量,可能会出现不一致。

Vue 的细粒度 HMR

Vue 的单文件组件(SFC)天然适合做 HMR,因为 template、script、style 三部分是物理隔离的。

当你只修改 template 部分,Vite 的 Vue 插件会只替换组件的 render 函数,不碰 setup 里的逻辑。这意味着响应式数据(refreactive)完全不受影响。

但如果你修改了 <script setup> 里的代码,情况就复杂了。setup 函数会重新执行,里面定义的变量会重新创建。为了保留状态,Vue HMR 会尝试从旧的组件实例里提取响应式对象,注入到新实例里。但这个过程并不是 100% 可靠,某些边界情况下状态还是会丢。

如果你只修改了 <style> 部分,这是最快的:Vite 直接替换页面上的 <style> 标签,连组件都不需要重新渲染。从按下保存到看到颜色变化,延迟通常在 50 毫秒以内。

Svelte 的暴力 HMR

Svelte 的 HMR 相对粗暴,因为 Svelte 把组件编译成了命令式的 DOM 操作代码。这种代码不像 React 的声明式那样容易"热插拔"。

Svelte HMR 的策略是:销毁旧组件,创建新组件,然后尝试恢复状态。这个恢复过程依赖开发者在组件里暴露状态接口,比如用 $$props$$set 方法。实际效果是,大部分情况下状态会丢失,体验不如 React 和 Vue。

但 Svelte 有个优势:因为没有虚拟 DOM,组件的重新挂载非常快,通常在几毫秒内完成。所以即使状态丢了,重新走一遍交互流程的成本也不高。

Solid.js 的完美方案

Solid.js 的 HMR 可能是目前最优雅的实现。它的响应式是基于 Signal 的,而 Signal 是独立于组件生命周期的。

function Counter() {
  const [count, setCount] = createSignal(0);
  return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}

这里的 createSignal 返回的 Signal 对象,存在闭包里,不依附于组件实例。Solid HMR 会在全局维护一个 Map,把每个 Signal 和它的创建位置关联起来。

当组件代码更新时,Counter 函数重新执行,再次调用 createSignal(0)。但这次 createSignal 会检查全局 Map,发现这个位置之前创建过 Signal,直接返回旧的 Signal 对象。于是 count 的值保留了,按钮上显示的数字不会变。

这个机制几乎是完美的:不需要签名比对,不需要状态提取和恢复,Signal 天然就是持久化的。唯一的问题是,如果你的组件结构变化太大(比如从一个 Signal 变成两个),可能会出现对不上号的情况。

第四幕:服务端渲染与 Hydration

SSR 解决了什么问题

纯客户端渲染(CSR)的流程是:浏览器下载 HTML(几乎是空的),下载 JS,执行 JS,渲染页面。在 JS 执行之前,用户看到的是白屏。对于首页来说,这个白屏时间可能是 2-5 秒,在移动网络下更长。

服务端渲染的逻辑是:服务器先执行 React/Vue 代码,生成完整的 HTML,发给浏览器。用户立即看到内容,即使 JS 还没下载完。

这对 SEO 和首屏性能都是巨大提升。Google 的爬虫虽然能执行 JS,但它更倾向于索引 HTML 里的内容。而首屏时间(FCP,First Contentful Paint)直接影响用户留存率,据统计,首屏时间每增加 1 秒,跳出率增加 20%。

Hydration:从静态到动态的过渡

服务端渲染的 HTML 是"死"的,按钮点不了,表单提交不了。Hydration 的任务是把这些 HTML"激活"。

以 React 为例,服务器用 renderToString 生成 HTML:

const html = renderToString(<App />);

这个 HTML 被发送到浏览器,同时还会发送一个 bundle.js。浏览器加载这个 JS 文件后,执行:

hydrateRoot(document.getElementById('root'), <App />);

注意这里用的是 hydrateRoot,不是 createRoot。两者的区别在于,createRoot 会清空容器,从头开始渲染;而 hydrateRoot 会假设容器里已经有正确的 HTML,React 只需要"认领"这些 DOM 节点,给它们绑上事件监听器。

Hydration 的过程:React 会遍历虚拟 DOM 树,和真实 DOM 树做对比。如果发现某个节点的属性或内容不一致,就会打印警告(Hydration Mismatch),并且强制更新那个节点。这种不一致通常是因为服务端和客户端的渲染逻辑有差异,比如:

function BadComponent() {
  const [time] = useState(Date.now());
  return <div>{time}</div>;
}

服务器渲染时,Date.now() 是服务器的时间;客户端 hydrate 时,Date.now() 是客户端的时间,两者必然不同。

正确的做法是在 useEffect 里设置时间,因为 useEffect 只在客户端执行:

function GoodComponent() {
  const [time, setTime] = useState(null);
  
  useEffect(() => {
    setTime(Date.now());
  }, []);
  
  return <div>{time || '加载中...'}</div>;
}

流式 SSR 和选择性 Hydration

传统 SSR 的问题是,整个页面必须等最慢的数据请求完成,才能发送 HTML。如果某个组件需要查询数据库,耗时 2 秒,那整个页面就要等 2 秒。

React 18 引入了流式 SSR:

<Suspense fallback={<Skeleton />}>
  <SlowComponent />
</Suspense>

服务器会先发送 <Skeleton /> 的 HTML,浏览器立即渲染骨架屏。等 SlowComponent 的数据准备好了,服务器再通过同一个 HTTP 连接,流式传输 SlowComponent 的 HTML。浏览器接收到后,用 JavaScript 把骨架屏替换掉。

这个过程用的是 HTTP 的 chunked transfer encoding,允许响应体分块发送。用户的感知是:页面瞬间加载,某些区域稍后填充内容,整体体验比等待 2 秒白屏好得多。

更进一步的是选择性 Hydration。假设页面有 10 个组件,传统 Hydration 需要全部处理完才能交互。选择性 Hydration 的策略是:用户点了哪个组件,就优先 hydrate 哪个。这样即使页面还没完全"激活",关键按钮也能立即响应。

Next.js 13 的 App Router 默认启用了这些特性。开发者不需要写额外代码,只需要用 <Suspense> 包裹异步组件,框架会自动处理流式渲染和选择性 Hydration。

Next.js 的 HMR 特殊情况

Next.js 的 HMR 要同时处理客户端组件和服务端组件。

当你修改一个客户端组件(标记了 'use client'),走的是标准的 React Fast Refresh 流程。

当你修改一个服务端组件,情况就复杂了。服务器需要重新渲染这个组件,生成新的 HTML,然后通过 WebSocket 推送给客户端。客户端接收到 HTML 后,替换掉旧的部分,然后重新 hydrate 其中的客户端组件。

这个过程的性能瓶颈在于服务端渲染。如果组件里有数据库查询或 API 调用,可能需要几百毫秒。所以修改服务端组件时,HMR 的速度会比客户端组件慢一些。

尾声:性能数据与实践建议

不同工具的启动时间对比

以一个包含 500 个模块的中型项目为例:

  • Vite 冷启动:1.2 秒(依赖预构建 0.8 秒 + 服务器启动 0.4 秒)
  • Vite 热启动(有缓存):0.3 秒
  • Next.js (Turbopack) 冷启动:2.5 秒
  • Next.js (Webpack) 冷启动:8-12 秒
  • CRA (Webpack) 冷启动:15-25 秒

HMR 更新延迟

  • 改 CSS 文件:Vite 50ms,Next.js 100ms,Webpack 200-500ms
  • 改 React 组件:Vite 100-200ms,Next.js 200-400ms,Webpack 500-1500ms
  • 改服务端组件:Next.js 300-800ms(取决于是否有异步操作)

实践建议

对于新项目:如果不需要 SSR,Vite 是最佳选择,开发体验无可挑剔。如果需要 SEO 和服务端渲染,Next.js 13+ 的 App Router 是最成熟的方案。

对于已有项目:从 CRA 迁移到 Vite 通常只需要几小时,收益巨大。从 Next.js 的 Pages Router 迁移到 App Router 需要更多时间,但流式 SSR 和服务端组件的性能提升值得投入。

关于 HMR 的坑:避免在组件顶层写副作用代码,避免条件性调用 Hooks,避免动态 import 组件然后在 HMR 时切换。这些模式都会导致 HMR 失败或状态丢失。

关于 pnpm:它的符号链接机制在 Windows 上偶尔会有兼容性问题(需要开启开发者模式),但在 macOS 和 Linux 上完全没问题。磁盘空间的节省和依赖安装速度的提升,让它成为大型 Monorepo 项目的首选。

结语

pnpm run dev 这四个字,触发的是一个精密的工程系统:依赖解析、进程管理、模块编译、网络通信、状态同步。每一个环节都有大量的优化空间,每一个工具都在试图让开发者的反馈循环更短。

从 Webpack 的 10 秒编译到 Vite 的 0.1 秒更新,从整页刷新到精确到像素级的 HMR,这些技术进步不是为了炫技,而是为了让开发者能更专注于创造,而不是等待。

下次当你敲下 pnpm run dev,等待那一两秒钟时,或许可以想象一下,在这短暂的时间里,这台机器为你做了多少工作。

❌
❌