Webpack 的第一次发布是在 2013 年发布,长久以来是主流的前端打包工具。Vite 的第一次发布是在 2021 年,是近两年来前端打包工具中的后起之秀,重点解决 Webpack 在开发阶段的开发痛点。截止 2022.8,Webpack 的 Github Star 数 61.6k
,Vite 的 Github Star 数是 46.6k
。虽然 Vite 刚刚发布 2 年,但是热度可见一斑。
下面我们来对 Webpack 和 Vite 的不同点进行比较,解释 Vite 之于 Webpack 性能优势来源于哪里?并且探讨为什么有人会说Vite快,有人却说慢。
Webpack
Webpack 是一个基于打包器的构建工具,同一个入口文件的代码会打包成一个 Bundle 文件。Webpack 长期来的一个痛点是对于大规模应用的应用启动和热更新速度很慢。
当文件发生变动时,整个 JavaScript Bundle 文件会被 Webpack 重新构建,这也是为什么使用 Webpack 的大规模应用在应用启动和热更新时速度很慢的原因。这给进行大规模 JavaScript 应用的开发者造成了很差的开发体验。
Webpack 如何工作?
Webpack 能大行其道,归功于它划时代的采用了 bundle 机制。通过这种 bundle
机制,Webpack 可以将项目中各种类型的源文件转化供浏览器识别的 js、css、img
等文件,建立源文件之间的依赖关系,并将数量庞大的源文件合并为少量的几个输出文件。
应用启动
bundle
工作机制的核心部分分为两块:构建模块依赖图 - module graph
和将 module graph
分解为最终供浏览器使用的几个输出文件。
构建 module graph
的过程可以简单归纳为:
- 获取配置文件中 entry 对应的 url (这个 url 一般为相对路径);
- resolve - 将 url 解析为绝对路径,找到源文件在本地磁盘的位置,并构建一个 module 对象;
- load - 读取源文件的内容;
- transform - 使用对应的 loader 将源文件内容转化为浏览器可识别的类型;
- parse - 将转化后的源文件内容解析为 AST 对象,分析 AST 对象,找到源文件中的静态依赖(import xxx from ‘xxx’) 和动态依赖(import(‘xx’))对应的 url, 并收集到 module 对象中;
- 遍历第 5 步收集到的静态依赖、动态依赖对应的 url,重复 2 - 6 步骤,直到项目中所有的源文件都遍历完成。
分解 module graph
的过程也可以简单归纳为:
- 预处理 module graph,对 module graph 做 tree shaking;
- 遍历 module graph,根据静态、动态依赖关系,将 module graph 分解为 initial chunk、async chunks;
- 优化 initial chunk、 async chunks 中重复的 module;
- 根据 optimization.splitChunks 进行优化,分离第三方依赖、被多个 chunk 共享的 module 到 common chunks 中;
- 根据 chunk 类型,获取对应的 template;
- 遍历每个 chunk 中收集的 module,结合 template,为每个 chunk 构建最后的输出内容;
- 将最后的构建内容输出到 output 指定位置;
然而成也萧何败萧何,强大的 bundle 机制,也引发了构建速度缓慢的问题,而且项目规模越大,构建速度越是缓慢。其主要原因是构建 module graph 的过程中,涉及到大量的文件 IO、文件 transfrom、文件 parse 操作;以及分解 module graph 的过程中,需要遍历 module graph、文件 transform、文件 IO 等。这些操作,往往需要消耗大量的时间,导致构建速度变得缓慢。
开发模式下,dev server 需要 Webpack 完成整个工作链路才可以启动成功,这就导致构建过程耗时越久,dev server 启动越久。
为了加快构建速度,Webpack 也做了大量的优化,如 loader 的缓存功能、webpack5 的持久化缓存等,但这些都治标不治本,只要 Webpack 的核心工作机制不变,那 dev server 启动优化,依旧是一个任重道远的过程(基本上永远都达不到 Vite 那样的效果)。
热更新
dev server 启动以后,会 watch 源文件的变化。当源文件发生变化后,Webpack 会重新编译打包。这个时候,由于我们只修改了一个文件,因此只需要对这个源文件做 resolve、 load、 transfrom、parse 操作,依赖的文件直接使用缓存,因此 dev server 的响应速度比冷启动要好很多。
dev server 重新编译打包以后,会通过 ws 连接通知浏览器去获取新的打包文件,然后对页面做局部更新。
Vite
Vite 是旨在提升开发者体验的下一代 JavaScript 构建工具,核心借助了浏览器的原生ES Modules
和像 esbuild
这样的将代码编译成 native code 的打包工具。
Vite 主要有两方面组成:
- 一个开发服务器,基于 ESM 提供丰富的内建能力,比如速度快到惊人的
模块热更新
(HMR); - 一套构建指令,使用
rollup
进行代码打包,且零配置即可输出用于生产环境的高度优化的静态代码。
Vite 的核心能力和 webpack
+ webpack-dev-server
相似,但是在开发者体验上有一些提升:
- 无论项目大小有多大,启动应用都只需更少的时间;
- 无论项目大小有多大,HMR(Hot Module Replacing)热更新都可以做到及时响应;
- 按需编译;
- 零配置,开箱即用;
- Esbuild 能力带来的 Typescript/jsx 的原生支持。
大型的 JavaScript 项目在开发和生产环境有比较差的性能表现,往往是因为我们使用的构建工具没有充分做到并行处理、内存优化和缓存。
核心理念:Bundless 开发环境构建
浏览器的原生 ES Modules 能力允许在不将代码打包到一起的情况下运行 JavaScript 应用。Vite 的核心理念很简单,就是借助浏览器原生 ES Modules 能力,当浏览器发出请求时,为浏览器按需提供 ES Module 文件,浏览器获取 ES Module 文件会直接执行。
如何理解 Bundless?
我觉得可以从这几个角度去理解:
- 首先是拆包,弱化传统意义上的打包概念,由单 bundle 拆分为数十或者上百个 bundle,这样可以更好地利用 HTTP2 的
多路复用
优势和提升缓存命中率
。 - 然后是对于项目源代码不进行 bundle(no-bundle),在开发阶段可以省略 bundle 的开销,如 Vite、Snowpack、WMR。
- 再者是依赖产物的模块化分发。对于庞大的外部依赖,一方面打包成本比较高,另一方面文件数量可能非常多,打包几乎是一个必选项,甚至需要多个 NPM 包合并打包。因此,针对依赖的打包也是非常重要的优化点,一般可以通过
预打包 + 模块化缓存
来进行优化,目前也有一些优化案例,如 Vite 中基于 Esbuild 的预打包器、基于 ESM 的 CDN 服务,如 Skypack、esm.sh、jspm 等。
拆多少包更合适?
对于究竟拆多少包这个问题,大家的概念都一直比较模糊,打的包太多或者太少都可能出现加载性能的问题,比如过多的嵌套 import 导致网络瀑布流的产生、bundle 太少不能充分利用 HTTP2 下并发请求的优势。
针对这个问题,我们曾做过一系列的性能测试,最后得出的结论如下:
- 对于总产物资源大小相同的情况,资源加载分成的 chunk 数量在
10 - 25
之间进行并行加载性能最佳。 - 一次资源加载需要的依赖引用深度尽量等于
1
时加载性能最好。
应用启动
Vite 之所以在 dev server 启动方面,如此给力,是因为它采取了与 Webpack 截然不同的 unbundle
机制。
unbundle
机制,顾名思义,不需要做 bundle 操作,即不需要构建、分解 module graph
,源文件之间的依赖关系完全通过浏览器对 ESM 规范的支持来解析。这就使得 dev server 在启动过程中只需做一些初始化的工作,剩下的完全由浏览器支持。
那么源文件的 resolve、load、transform、parse
什么时候做呢 ?
答案是浏览器发起请求以后,dev server 端会通过 middlewares 对请求做拦截,然后对源文件做 resolve、load、transform、parse
操作,然后再将转换以后的内容发送给浏览器。
这样,通过 unbundle 机制, Vite 便可以在 dev server 启动方面获取远超于 Webpack 的优秀体验。
最后再总结一下, unbundle 机制的核心:
- 模块之间的依赖关系的解析由浏览器实现;
- 文件的转换由 dev server 的 middlewares 实现并做缓存;
- 不对源文件做合并捆绑操作;
Vite 将应用中的模块分为依赖
和源码
两类,分别进行服务器启动时间的优化。
- 依赖模块,开发过程中基本不会变化。Vite 对依赖采用了
esbuild 预构建
的方式,esbuild
使用Go
编写,代码直接编译成机器码(不用像 js 那样先解析为字节码,再编译为机器码),比以 JavaScript 编写的打包器预构建依赖快10-100
倍; - 源码模块,是用户自己开发的代码,会经常变动。
Vite 在浏览器请求时按需转换并以原生 ESM 方式提供源码,让浏览器接管了打包程序的部分工作。
Vite 如何工作?
Vite 通过原生 ES Modules 托管源代码,本质上是让浏览器来接管部分打包器的工作。Vite 只会在浏览器请求发生时,按需将源码转成 ES Modules 格式返回给浏览器,由浏览器加载并执行 ES Modules 文件。
热更新
在基于 Bundle 构建的构建器中,当一个文件变动时,重新构建整个 Bundle 文件是非常低效的,且随着应用规模的上升,构建速度会直线下降。
传统的构建器虽然提供了热更新的能力,但是也会存在随着应用规模上升,热更新速度显著下降的问题。
Vite 基于 ESM 按需提供源码文件,当一个文件被编辑后,Vite 只会重新编译并提供该文件。因此,无论项目规模多大,Vite 的热更新都可以保持快速更新。
此外,Vite 合理利用浏览器缓存来加速页面加载,源码模块
请求根据 304 Not Modified
进行协商缓存
;依赖模块
请求通过 Cache-Control: max-age=31536000,immutable
进行强缓存
,因此一旦缓存,不会再次请求。
生产环境仍需打包
在生产环境使用 ESM 会存在大量额外网络请求问题,因此生产环境不太试用 ESM,最好的方式还是代码进行 tree-shaking
、懒加载
、和 chunk 分隔
等。
那么生产环境的构建为什么不直接使用 esbuild,而是使用 rollup 呢?这是因为 esbuild 在代码分隔、css 处理等方面的功能仍在开发中,rollup 在应用打包方面更加的成熟且灵活。
性能提升
Vite 依托支持原生 ESM 模块的现代浏览器,极大的降低了应用的启动和重新构建时间。Vite 本质上是一个在开发环境为浏览器按需提供文件的 Web Server,这些文件包含源码模块和在第一次运行时使用 esbuild 预构建的依赖模块。
Vite 和 Webpack 的主要不同在于开发环境下对于源码如何被托管以及支持哪种模块规范。
依赖预构建
Vite 在首次启动时,会进行依赖预构建
。依赖预构建
有两个目的:
- CommonJs 和 UMD 的兼容性:开发阶段,Vite 的 Dev Server 将所有代码视为原生 ES 模块。因此,Vite 必须将 CommonJS 或 UMD 发布的依赖项转为 ESM。
- 性能:Vite 将有很多内部模块的依赖视为单个模块,以提升页面加载性能。比如,
lodash-es
拥有超过 600 个内部模块,当import {debounce} from 'lodash-es';
时,浏览器会同时发起超过 600 个请求,并行请求过多将会显著影响页面加载性能。因此预构建将lodash-es
视为一个模块,浏览器只需要发起一个请求。
缓存
文件系统缓存
Vite 会将预构建的依赖缓存到 node_modules/.vite
,它根据几个源决定是否需要重新运行预构建
步骤:
package.json
中的dependencies
列表- 包管理的
lockfile
,例如package-lock.json
,yarn.lock
或者pnpm-lock.yaml
- 可能在
vite.config.js
相关字段中配置过的
只有在上述其中一项发生更改时,才需要重新运行预构建
。
如果处于某些原因,你想要强制 Vite 重新构建依赖,你可以用 --force
命令选项启动开发服务器,或者手动删除 node_modules/.vite
目录。
浏览器缓存
解析后的依赖请求会以 HTTP 头 max-age=31536000,immutable
强缓存,以提高开发时的页面重载性能。如果你想通过本地编辑来调试依赖项,可以:
- 通过浏览器调试工具的 Network 选项卡暂时禁用缓存;
- 重启
Vite Dev Server
,并添加--force
命令以重新构建依赖; - 重新载入页面。
Typescript 原生支持
Vite 天然支持引入 .ts
文件,单仅支持 .ts
文件的转译工作,并不执行任何类型检查。
Vite 使用 esbuild 将 TypeScript 转译到 JavaScript,约是 tsc 速度的 20-30 倍,同时 HMR 更新到浏览器的时间小于 50 ms。
对比
简单对 Webpack 和 Vite 进行一个对比:
Webpack
- 支持的模块规范:
ES Modules
,CommonJS
和AMD Modules
; - Dev Server:通过
webpack-dev-server
托管打包好的模块; - 生产环境构建:
webpack
Vite
- 支持的模块规范:
ES Modules
; - Dev Server:原生
ES Modules
; - 生产环境构建:
Rollup
小结
由于浏览器原生 ES Modules 的支持,当浏览器发出请求时,Vite 可以在不将源码打包为一个 Bundle 文件的情况下,将源码文件转化为 ES Modules 文件之后返回给浏览器。这样 Vite 的应用启动和热更新 HMR 时的速度都不会随着应用规模的增加而变慢。
为什么有人会说Vite快,有人却说慢?
Vite 的快,主要体现在两个方面: 快速的冷启动
和快速的热更新
。而 Vite 之所以能有如此优秀的表现,完全归功于 Vite 借助了浏览器对 ESM 规范的支持,采取了与 Webpack 完全不同的 unbundle 机制。
和 bundle 机制有利有弊一样,unbundle 机制给 Vite 在 dev server 方面获得巨大性能提升的同时,也带来一些负面影响,那就是首屏
和懒加载
性能的下降。
首屏性能
Webpack
浏览器向 dev server 发起请求, dev server 接受到请求,然后将已经打包构建好的首屏内容发送给浏览器。整个过程非常普遍,没有什么可说的,不存在什么性能问题。
Vite
在首屏方面的表现就有些差了。由于 unbundle 机制,首屏期间需要额外做以下工作:
- 不对源文件做合并捆绑操作,导致大量的
http
请求; - dev server 运行期间对源文件做
resolve、load、transform、parse
操作; - 预构建、二次预构建操作也会阻塞首屏请求,直到预构建完成为止;
和 Webpack
对比,Vite
把需要在 dev server 启动过程中完成的工作,转移到了 dev server 响应浏览器请求的过程中,不可避免的导致首屏性能下降。
不过首屏性能差只发生在 dev server 启动以后第一次加载页面时发生。之后再 reload 页面时,首屏性能会好很多。原因是 dev server 会将之前已经完成转换的内容缓存起来。
此问题也有解决方案详见:vite性能优化 — 增加业务代码预构建,加快首屏输出
懒加载性能
同样的, Vite
在懒加载方面的性能也比 Webpack
差。
和首屏一样,由于 unbundle 机制,动态加载的文件,需要做 resolve、load、transform、parse
操作,并且还有大量的 http
请求,导致懒加载性能也受到影响。
此外,如果懒加载过程中,发生了二次预构建,页面会 reload,对开发体验也有一定程度的影响。
结束语
尽管在首屏、懒加载性能方面存在一些不足,但瑕不掩瑜,作为目前最 🔥 的构建工具,Vite
可以说是实至名归。而且这些问题并非不可解决,比如我们可以通过 prefetch、持久化缓存等手段做优化,相信 Vite
未来也会做出对应的改进。