前言
背景
在 ESM 出现之前,Javascript 是没有一个标准的模块方案。
比如说 CJS
是用于 Node 服务端的模块化方案,AMD
是用于浏览器的模块化方案。为了解决这个模块共用性问题,出现了 UMD
用于兼容这两种模块规范。
鉴于上面共用性问题,实际开发中配置的打包方式,采用的还是 UMD 模式。因为这样可以避免打包而产生的规范问题,并且在 ESM 不能使用的情况下也会选择 UMD。
而 ESM
(ES Module)的出现,则为 Javascript 提出了一个标准模块系统的方案。ESM 可以替代 CJS 与 AMD,并且兼备 UMD 特性(任何环境都可使用)。
ESM 自身的静态化特点,在编译时加载,也使得页面加载速度更快,相比 CJS、AMD 与 UMD 更有优势。
ESM 也真正意义上做到了按需使用。使用import
并不会直接执行模块,而是生成一个动态的只读引用,等到真的需要用到时,才会到模块里面去读取。
主流构建工具
目前主流构建工具是先打包生成 Bundle
,然后再启动开发服务器,如 webpack
。同时 HMR 也是需要把改动的模块代码及相关依赖全部编译后,才会更新界面。
这也是为什么项目代码量越来越大的时候,项目启动时间会变的越来越长,而且稍微改一点代码,也会好长时间才热更新界面。
而 Vite 的出现,则解决了这个状况。
Vite介绍
Vite 是一个基于浏览器原生ESM
的构建工具。
Vite 由两个主要部分组成:
一个开发服务器:基于本地搭建的服务器,借助浏览器原生的 ESM 能力。
一套构建指令:采用 Rollup 进行打包,同时继承了 Rollup plugin,可以使用 Rollup 的生态。
Vite 核心的理念目前体现在
开发服务器
,具体原因我们往下看。
开发服务器
相比目前主流的打包工具,无论在启动还是 HMR 都会先 Bundle
,这样就会导致更新界面的速度不如 ESM
。
ESM
Vite 开发环境的服务是直接冷启动的,省略了 Bundle 打包的过程。
使用浏览器原生 ESM 的能力,浏览器直接去解析 imports,省略了开发环境的打包过程,在服务端直接按需编译返回。这里的编译
只是编译当前文件返回给浏览器,不需要管理依赖或者解析整个项目代码的依赖。
Vite 中 HMR 也是在原生 ESM 上执行的,所以 HMR 速度也非常快,且 HMR 速度不会随着模块增多而变慢。
esbuild
对于一些较大的依赖和文件,或者不同的模块规范(如 CJS 、 AMD 、ESM)的处理。
Vite 采用了 esbuild 预构建依赖。esbuild 会统一将文件(如CJS)转换成浏览器支持的 ESM 形式。如下图:
对于这些预构建的文件,vite 会统一放在 node_modules 中的 .vite 文件夹下。
esbuild 的构建速度非常快。它不仅可以编译 JavaScript 代码,而且由于 esbuild 底层是用 Go 编写的,Go 天生具备多线程运行能力,所以比使用 JavaScript 编写的打包器预构建依赖快 10-100 倍。
虽然目前 JavaScript 编写的打包器,也可以实现充分利用 GPU 打包编译,但是 JavaScript 本质上依然是一门解释型语言,每次执行都需要将源码翻译成机器语言执行。相反 Go 是一种编译型语言,在编译阶段就已经将源码转译为机器码,启动时只需要执行即可,所以 Go 相比 JavaScript 少一步编译的过程。
http 缓存
Vite 的另一个特性之一就是使用了 http 缓存的能力。
说到 http 缓存,不得不说一下浏览器缓存过程
。
- 浏览器第一次加载资源,服务器返回200,浏览器此时会将资源从服务器下载,同时将 response header 与返回时间一起缓存。
- 再次请求加载资源的时候,浏览器会比较与上一次下载资源的时间差,如果没有超过 Cache-Control 设置的 max-age,则没有过期,此时就会从本地缓存读取资源。如果浏览器不支持 HTTP1.1,那么则会用 Expires 判断是否过期(这一过程称为强缓存)。
Expires 是 HTTP1.0 的,Cache-Control 优先级高于 Expires,可以理解为 Expires 是处理浏览器兼容的。
- 如果对比时间后,发现已过期。服务器则会查看请求的 header 中 If-None-Match 里值,与该请求资源的 Etag 做比较,如果相同则代表资源没有发生改变,返回304。否则,直接返回新的资源,并返回200(这一过程称为协商缓存)。
- 如果服务器收到的请求 header 中,没有 Etag 值,则会读取 If-Modified-Since 和被请求文件的最后修改时间做对比,如果相同则代表没有发生改变,返回304。否则,直接返回新的资源,并返回200(这一过程称为协商缓存)。
下载资源的同时,在 response header 中会携带 Etag(资源唯一标识,资源发生改变,标识也随之改变)、Last-Modified(资源文件最后一次更改时间),而浏览器会把这两个保存下来。
向服务端发送资源请求时,在 request header 中,会把保存的 ETag 值放到 If-None-Match 中,把保存的 Last-Modified 值放到 If-Modified-Since 中发给服务端。
Vite 使用 http 缓存,对资源文件做了缓存处理:
- 源码模块的请求会根据
304 Not Modified
进行协商缓存。 - 依赖模块请求则会通过
Cache-Control: max-age=31536000,immutable
进行强缓存。
这样就具备一个优势,一旦被缓存它们将不需要再次请求,除非依赖或者代码发生变化。
但是紧接着也会暴露问题出来。
在生产环境中,存在着使用 ESM import
这种大量嵌套的文件,就会产生大量的网络请求。随着代码量的不断增加,也会导致网络请求的不断增加。即使 Vite 采用了最新的 HTTP2.X 中的多路复用与首部压缩,仍不能解决性能低的问题。
所以,这也是其中一个为什么生产环境还需要打包的原因。
当然还有其他原因,我们接着往下看。。。
构建指令
相比开发环境使用 esbuild 构建依赖,生产环境则使用借鉴了更为成熟的 Rollup 来打包。
这里的构建指令,指的是使用 Rollup 预配置指令来构建打包。
目的
鉴于大量嵌套的文件下的 ESM import
,会导致生产环境产生的大量的网络请求。
为了在生产环境中获得最佳的加载性能,所以仍然需要对代码进行tree-shaking、懒加载以及chunk分割,以获得更好的缓存。
说到这里大家可能觉得 Vite 算是个半成品,这样的优势,竟然在生产服不能使用。
但是想一想,每次启动和 HMR 项目代码的时候,相比 Bundle 的漫长等待,Vite 的快速响应,为我们解决了大量等待的开发时间,尤其是代码量特别大的项目,这种优势更明显。
为什么使用 Rollup 构建打包
有的小伙伴可能会有疑问,生产环境为什么用 Rollup
打包,而不用 esbuild 打包?
这是因为 esbuild 目前还不够成熟,虽然 esbuild 预构建速度很快,但针对应用级别的代码分割、CSS 处理仍然不够稳定,同时也未能兼容一些未提供 ESM 的 SDK。
我在项目开发中也遇到过不稳定的情况,每次都是重新启动一下就好了。如果大家遇到这个问题,希望对大家有所借鉴帮助。
而对于这些不稳定因素,所以目前只能放弃使用 esbuild。
有的小伙伴可能会说 esbuild 不成熟,那为什么生产环境使用 Rollup,而不用 webpack 呢?。
这是因为相比其他打包工具,Rollup 能打出更小体积的文件。而且因为 Rollup 基于 ES6 模块,比 webpack 使用的 CommonJS 模块机制更高效,而且毕竟开发环境也是基于 ESM 预构建运行的。
Vite 对于构建指令也做了其他方面的努力。比如说 Vite 兼容了 Rollup 的插件生态,从而使开发人员可以在 Vite 中使用 Rollup 成熟的插件。