【工程化】面向未来的前端构建工具 - Vite 原理分析
1 Vite: 一种新的、更快的 web 开发工具。
2 特点:
快速的冷启动
即时的模块热更新(保留在完全重新加载页面时丢失的应用程序状态、只更新变更内容、调整样式更加快速)
真正的按需编译
3 构建项目:
$ yarn create vite-app <project-name>
$ cd <project-name>
$ yarn
$ yarn dev
4 实现机制:
vite 使用 koa[1] 作 web server,使用 clmloader 创建了一个监听文件改动的 watcher,同时实现了一个插件机制,将 koa-app 和 watcher 以及其他必要工具组合成一个 context 对象注入到每个 plugin 中。
context组成结构:
plugin 依次从 context 里获取上面这些组成部分,有的 plugin 在 koa 实例添加了几个 middleware,有的借助 watcher 实现对文件的改动监听,这种插件机制带来的好处是整个应用结构清晰,同时每个插件处理不同的事情,职责分配更清晰。
5 plugin:
用户注入的 plugins —— 自定义 plugin
hmrPlugin —— 处理 hmr
htmlRewritePlugin —— 重写 html 内的 script 内容
moduleRewritePlugin —— 重写模块中的 import 导入
moduleResolvePlugin ——获取模块内容
vuePlugin —— 处理 vue 单文件组件
esbuildPlugin —— 使用 esbuild 处理资源
assetPathPlugin —— 处理静态资源
serveStaticPlugin —— 托管静态资源
cssPlugin —— 处理 css/less/sass 等引用
...
一个用来拦截 json 文件 plugin简单实现:
interface ServerPluginContext {
root: string
app: Koa
server: Server
watcher: HMRWatcher
resolver: InternalResolver
config: ServerConfig
}
type ServerPlugin = (ctx:ServerPluginContext)=> void;
const JsonInterceptPlugin:ServerPlugin = ({app})=>{
app.use(async (ctx, next) => {
await next()
if (ctx.path.endsWith('.json') && ctx.body) {
ctx.type = 'js'
ctx.body = `export default json`
}
})
}
6 运行依赖原理
Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。
依赖 大多为纯 JavaScript 并在开发时不会变动。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。依赖也通常会以某些方式(例如 ESM 或者 CommonJS)被拆分到大量小模块中。Vite 将会使用 esbuild[2]预构建依赖[3]。Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。
源码 通常包含一些并非直接是 JavaScript 的文件,需要转换(例如 JSX,CSS 或者 Vue/Svelte 组件),时常会被编辑。同时,并不是所有的源码都需要同时被加载。(例如基于路由拆分的代码模块)。Vite 以 原生 ESM[4] 方式服务源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入的代码,即只在当前屏幕上实际使用时才会被处理。
6.1 依赖 ES module
要了解 vite 的运行原理,首先要知道什么是ES module,参考:JavaScript modules 模块 - MDN[5]。
目前流览器对其的支持如下:主流的浏览器(IE11除外)均已经支持
其最大的特点是在浏览器端使用 export
import
的方式导入和导出模块,在 script 标签里设置 type="module"
,然后使用模块内容。
6.2 示例:
// **module sciprt**允许在浏览器中直接运行原生支持模块
<script type="module">
// index.js可以通过export导出模块,也可以在其中继续使用import加载其他依赖
// 当遇见import依赖时,会直接发起http请求对应的模块文件。
import { fn } from ./index.js;
fn();
</script>
当 html 里嵌入上面的 script 标签时候,浏览器会发起 http 请求,请求 htttp server 托管 index.js ,在 index.js 里,我们用 export 导出 fn 函数,在上面的 script 中能获取到 fn 的定义。
export function fn() {
alert('hello world');
};
7 在 vite 中的作用
打开运行中的 vite 项目,访问 view-source 可以发现 html 里有段这样的代码:
<script type="module">
import { createApp } from '/@modules/vue'
import App from '/App.vue'
createApp(App).mount('#app')
</script>
从这段代码中,我们能 get 到以下几点信息:
从 http://localhost:3000/@modules/vue 中获取 createApp 这个方法
从 http://localhost:3000/App.vue 中获取应用入口
使用 createApp 创建应用并挂载节点
createApp
是 vue3.X 的 api,只需知道这是创建了 vue 应用即可,vite 利用 ES module
,把 “构建 vue 应用” 这个本来需要通过 webpack 打包后才能执行的代码直接放在浏览器里执行,这么做是为了
去掉打包步骤
实现按需加载
7.1 去掉打包步骤
打包的概念是开发者利用打包工具将应用各个模块集合在一起形成 bundle,以一定规则读取模块的代码——以便在不支持模块化的浏览器里使用。
为了在浏览器里加载各模块,打包工具会借助胶水代码用来组装各模块,比如 webpack 使用 map 存放模块 id 和路径,使用 __webpack_require__
方法获取模块导出。
vite 利用浏览器原生支持模块化导入这一特性,省略了对模块的组装,也就不需要生成 bundle,所以打包这一步就可以省略了。
7.2 实现按需打包
前面说到,webpack 之类的打包工具会将各模块提前打包进 bundle 里,但打包的过程是静态的——不管某个模块的代码是否执行到,这个模块都要打包到 bundle 里,这样的坏处就是随着项目越来越大打包后的 bundle 也越来越大。
开发者为了减少 bundle 大小,会使用动态引入 import()
的方式异步的加载模块( 被引入模块依然需要提前打包),又或者使用 tree shaking
等方式尽力的去掉未引用的模块,然而这些方式都不如 vite 的优雅,vite 可以只在需要某个模块的时候动态(借助 import() )的引入它,而不需要提前打包。
8 vite 如何处理 ESM
既然 vite 使用 ESM 在浏览器里使用模块,那么这一步究竟是怎么做的?
上文提到过,在浏览器里使用 ES module 是使用 http 请求拿到模块,所以 vite 必须提供一个 web server
去代理这些模块,上文中提到的 koa 就是负责这个事情,vite 通过对请求路径的劫持获取资源的内容返回给浏览器,不过 vite 对于模块导入做了特殊处理。
8.1 @modules 是什么?
通过工程下的 index.html 和开发环境下的 html 源文件对比,发现 script 标签里的内容发生了改变,由
<script type="module">
import { createApp } from 'vue'
import App from '/App.vue'
createApp(App).mount('#app')
</script>
变成了
<script type="module">
import { createApp } from '/@modules/vue'
import App from '/App.vue'
createApp(App).mount('#app')
</script>
在 koa 中间件里获取请求 body
通过 es-module-lexer 解析资源 ast 拿到 import 的内容
判断 import 的资源是否是绝对路径,绝对视为 npm 模块
返回处理后的资源路径:"vue" => "/@modules/vue"
这部分代码在 serverPluginModuleRewrite 这个 plugin 中,
8.2 为什么需要 @modules?
如果我们在模块里写下以下代码的时候,浏览器中的 esm 是不可能获取到导入的模块内容的:
import vue from 'vue'
因为 vue 这个模块安装在 node_modules 里,以往使用 webpack,webpack遇到上面的代码,会帮我们做以下几件事:
获取这段代码的内容
解析成 AST
遍历 AST 拿到 import 语句中的包的名称
使用 enhanced-resolve 拿到包的实际地址进行打包,
但是浏览器中 ESM 无法直接访问项目下的 node_modules,所以 vite 对所有 import 都做了处理,用带有 @modules 的前缀重写它们。
从另外一个角度来看这是非常比较巧妙的做法,把文件路径的 rewrite 都写在同一个 plugin 里,这样后续如果加入更多逻辑,改动起来不会影响其他 plugin,其他 plugin 拿到资源路径都是 @modules,比如说后续可能加入 alias 的配置:就像 webpack alias 一样:可以将项目里的本地文件配置成绝对路径的引用。
8.3 怎么返回模块内容
在下一个 koa middleware 中,用正则匹配到路径上带有 @modules 的资源,再通过 require('xxx')
拿到 包的导出返回给浏览器。
以往使用 webpack 之类的打包工具,它们除了将模块组装到一起形成 bundle,还可以让使用了不同模块规范的包互相引用,比如:
ES module (esm) 导入 cjs
CommonJS (cjs) 导入 esm
dynamic import 导入 esm
dynamic import 导入 cjs
关于 es module 的坑可以看这篇文章(https://zhuanlan.zhihu.com/p/40733281[6])。
起初在 vite 还只是为 vue3.x 设计的时候,对 vue esm 包是经过特殊处理的,比如:需要 @vue/runtime-dom
这个包的内容,不能直接通过 require('@vue/runtime-dom')
得到,而需要通过 require('@vue/runtime-dom/dist/runtime-dom.esm-bundler.js')
的方式,这样可以使得 vite 拿到符合 esm 模块标准的 vue 包。
目前社区中大部分模块都没有设置默认导出 esm,而是导出了 cjs 的包,既然 vue3.0 需要额外处理才能拿到 esm 的包内容,那么其他日常使用的 npm 包是不是也同样需要支持?答案是肯定的,目前在 vite 项目里直接使用 lodash 还是会报错的。
不过 vite 在最近的更新中,加入了optimize
命令,这个命令专门为解决模块引用的坑而开发,例如我们要在 vite 中使用 lodash,只需要在vite.config.js(vite 配置文件)中,配置optimizeDeps
对象,在include
数组中添加 lodash。
// vite.config.js
module.exports = {
optimizeDeps: {
include: ["lodash"]
}
}
这样 vite 在执行 runOptimize 的时候中会使用 rollup 对 lodash 包重新编译,将编译成符合 esm 模块规范的新的包放入 node_modules 下的 .vite_opt_cache 中,然后配合 resolver 对 lodash 的导入进行处理:使用编译后的包内容代替原来 lodash 的包的内容,这样就解决了 vite 中不能使用 cjs 包的问题,这部分代码在 depOptimizer.ts 里。
不过这里还有个问题,由于在 depOptimizer.ts 中,vite 只会处理在项目下 package.json 里的 dependencies 里声明好的包进行处理,所以无法在项目里使用
import pick from 'lodash/pick'
的方式单使用 pick 方法,而要使用
import lodash from 'lodash'
lodash.pick()
的方式,这可能在生产环境下使用某些包的时候对 bundle 的体积有影响。
返回模块的内容的代码在:serverPluginModuleResolve.ts 这个 plugin 中。
9 vite 热更新的实现
vite/hmr 是 vite 处理热更新的关键,在 serverPluginHmr plugin 中,对于 path 等于 vite/hmr 做了一次判断:
app.use(async (ctx, next) => {
if (ctx.path === '/vite/hmr') {
ctx.type = 'js'
ctx.status = 200
ctx.body = hmrClient
}
}
hmrClient 是 vite 热更新的客户端代码,需要在浏览器里执行,这里先来说说通用的热更新实现,热更新一般需要四个部分:
首先需要 web 框架支持模块的 rerender/reload
通过 watcher 监听文件改动
通过 server 端编译资源,并推送新模块内容给 client 。
client 收到新的模块内容,执行 rerender/reload
vite 也不例外同样有这四个部分,其中客户端代码在:client.ts 里,服务端代码在 serverPluginHmr 里,对于 vue 组件的更新,通过 vue3.x 中的 HMRRuntime 处理的。
10 尤雨溪发言
Vite,一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。针对生产环境则可以把同一份代码用 rollup 打。虽然现在还比较粗糙,但这个方向我觉得是有潜力的,做得好可以彻底解决改一行代码等半天热更新的问题。
参考资料
koa: https://www.npmjs.com/package/koa
[2]esbuild: https://esbuild.github.io/
[3]预构建依赖: https://cn.vitejs.dev/guide/dep-pre-bundling.html
[4]原生 ESM: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
[5]JavaScript modules 模块 - MDN: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules
[6]https://zhuanlan.zhihu.com/p/40733281: https://zhuanlan.zhihu.com/p/40733281