【工程化】面向未來的前端構(gòu)建工具 - Vite 原理分析
1 Vite: 一種新的、更快的 web 開發(fā)工具。
2 特點(diǎn):
快速的冷啟動(dòng)
即時(shí)的模塊熱更新(保留在完全重新加載頁(yè)面時(shí)丟失的應(yīng)用程序狀態(tài)、只更新變更內(nèi)容、調(diào)整樣式更加快速)
真正的按需編譯
3 構(gòu)建項(xiàng)目:
$ yarn create vite-app <project-name>
$ cd <project-name>
$ yarn
$ yarn dev
4 實(shí)現(xiàn)機(jī)制:
vite 使用 koa[1] 作 web server,使用 clmloader 創(chuàng)建了一個(gè)監(jiān)聽文件改動(dòng)的 watcher,同時(shí)實(shí)現(xiàn)了一個(gè)插件機(jī)制,將 koa-app 和 watcher 以及其他必要工具組合成一個(gè) context 對(duì)象注入到每個(gè) plugin 中。
context組成結(jié)構(gòu):

plugin 依次從 context 里獲取上面這些組成部分,有的 plugin 在 koa 實(shí)例添加了幾個(gè) middleware,有的借助 watcher 實(shí)現(xiàn)對(duì)文件的改動(dòng)監(jiān)聽,這種插件機(jī)制帶來的好處是整個(gè)應(yīng)用結(jié)構(gòu)清晰,同時(shí)每個(gè)插件處理不同的事情,職責(zé)分配更清晰。
5 plugin:
用戶注入的 plugins —— 自定義 plugin
hmrPlugin —— 處理 hmr
htmlRewritePlugin —— 重寫 html 內(nèi)的 script 內(nèi)容
moduleRewritePlugin —— 重寫模塊中的 import 導(dǎo)入
moduleResolvePlugin ——獲取模塊內(nèi)容
vuePlugin —— 處理 vue 單文件組件
esbuildPlugin —— 使用 esbuild 處理資源
assetPathPlugin —— 處理靜態(tài)資源
serveStaticPlugin —— 托管靜態(tài)資源
cssPlugin —— 處理 css/less/sass 等引用
...
一個(gè)用來攔截 json 文件 plugin簡(jiǎn)單實(shí)現(xiàn):
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 運(yùn)行依賴原理
Vite 通過在一開始將應(yīng)用中的模塊區(qū)分為 依賴 和 源碼 兩類,改進(jìn)了開發(fā)服務(wù)器啟動(dòng)時(shí)間。
依賴 大多為純 JavaScript 并在開發(fā)時(shí)不會(huì)變動(dòng)。一些較大的依賴(例如有上百個(gè)模塊的組件庫(kù))處理的代價(jià)也很高。依賴也通常會(huì)以某些方式(例如 ESM 或者 CommonJS)被拆分到大量小模塊中。Vite 將會(huì)使用 esbuild[2]預(yù)構(gòu)建依賴[3]。Esbuild 使用 Go 編寫,并且比以 JavaScript 編寫的打包器預(yù)構(gòu)建依賴快 10-100 倍。
源碼 通常包含一些并非直接是 JavaScript 的文件,需要轉(zhuǎn)換(例如 JSX,CSS 或者 Vue/Svelte 組件),時(shí)常會(huì)被編輯。同時(shí),并不是所有的源碼都需要同時(shí)被加載。(例如基于路由拆分的代碼模塊)。Vite 以 原生 ESM[4] 方式服務(wù)源碼。這實(shí)際上是讓瀏覽器接管了打包程序的部分工作:Vite 只需要在瀏覽器請(qǐng)求源碼時(shí)進(jìn)行轉(zhuǎn)換并按需提供源碼。根據(jù)情景動(dòng)態(tài)導(dǎo)入的代碼,即只在當(dāng)前屏幕上實(shí)際使用時(shí)才會(huì)被處理。


6.1 依賴 ES module
要了解 vite 的運(yùn)行原理,首先要知道什么是ES module,參考:JavaScript modules 模塊 - MDN[5]。
目前流覽器對(duì)其的支持如下:主流的瀏覽器(IE11除外)均已經(jīng)支持

其最大的特點(diǎn)是在瀏覽器端使用 export import的方式導(dǎo)入和導(dǎo)出模塊,在 script 標(biāo)簽里設(shè)置 type="module" ,然后使用模塊內(nèi)容。
6.2 示例:
// **module sciprt**允許在瀏覽器中直接運(yùn)行原生支持模塊
<script type="module">
// index.js可以通過export導(dǎo)出模塊,也可以在其中繼續(xù)使用import加載其他依賴
// 當(dāng)遇見import依賴時(shí),會(huì)直接發(fā)起http請(qǐng)求對(duì)應(yīng)的模塊文件。
import { fn } from ./index.js;
fn();
</script>
當(dāng) html 里嵌入上面的 script 標(biāo)簽時(shí)候,瀏覽器會(huì)發(fā)起 http 請(qǐng)求,請(qǐng)求 htttp server 托管 index.js ,在 index.js 里,我們用 export 導(dǎo)出 fn 函數(shù),在上面的 script 中能獲取到 fn 的定義。
export function fn() {
alert('hello world');
};
7 在 vite 中的作用
打開運(yùn)行中的 vite 項(xiàng)目,訪問 view-source 可以發(fā)現(xiàn) html 里有段這樣的代碼:
<script type="module">
import { createApp } from '/@modules/vue'
import App from '/App.vue'
createApp(App).mount('#app')
</script>
從這段代碼中,我們能 get 到以下幾點(diǎn)信息:
從 http://localhost:3000/@modules/vue 中獲取 createApp 這個(gè)方法
從 http://localhost:3000/App.vue 中獲取應(yīng)用入口
使用 createApp 創(chuàng)建應(yīng)用并掛載節(jié)點(diǎn)
createApp 是 vue3.X 的 api,只需知道這是創(chuàng)建了 vue 應(yīng)用即可,vite 利用 ES module,把 “構(gòu)建 vue 應(yīng)用” 這個(gè)本來需要通過 webpack 打包后才能執(zhí)行的代碼直接放在瀏覽器里執(zhí)行,這么做是為了
去掉打包步驟
實(shí)現(xiàn)按需加載
7.1 去掉打包步驟
打包的概念是開發(fā)者利用打包工具將應(yīng)用各個(gè)模塊集合在一起形成 bundle,以一定規(guī)則讀取模塊的代碼——以便在不支持模塊化的瀏覽器里使用。
為了在瀏覽器里加載各模塊,打包工具會(huì)借助膠水代碼用來組裝各模塊,比如 webpack 使用 map 存放模塊 id 和路徑,使用 __webpack_require__ 方法獲取模塊導(dǎo)出。
vite 利用瀏覽器原生支持模塊化導(dǎo)入這一特性,省略了對(duì)模塊的組裝,也就不需要生成 bundle,所以打包這一步就可以省略了。
7.2 實(shí)現(xiàn)按需打包
前面說到,webpack 之類的打包工具會(huì)將各模塊提前打包進(jìn) bundle 里,但打包的過程是靜態(tài)的——不管某個(gè)模塊的代碼是否執(zhí)行到,這個(gè)模塊都要打包到 bundle 里,這樣的壞處就是隨著項(xiàng)目越來越大打包后的 bundle 也越來越大。
開發(fā)者為了減少 bundle 大小,會(huì)使用動(dòng)態(tài)引入 import() 的方式異步的加載模塊( 被引入模塊依然需要提前打包),又或者使用 tree shaking 等方式盡力的去掉未引用的模塊,然而這些方式都不如 vite 的優(yōu)雅,vite 可以只在需要某個(gè)模塊的時(shí)候動(dòng)態(tài)(借助 import() )的引入它,而不需要提前打包。
8 vite 如何處理 ESM
既然 vite 使用 ESM 在瀏覽器里使用模塊,那么這一步究竟是怎么做的?
上文提到過,在瀏覽器里使用 ES module 是使用 http 請(qǐng)求拿到模塊,所以 vite 必須提供一個(gè) web server 去代理這些模塊,上文中提到的 koa 就是負(fù)責(zé)這個(gè)事情,vite 通過對(duì)請(qǐng)求路徑的劫持獲取資源的內(nèi)容返回給瀏覽器,不過 vite 對(duì)于模塊導(dǎo)入做了特殊處理。
8.1 @modules 是什么?
通過工程下的 index.html 和開發(fā)環(huán)境下的 html 源文件對(duì)比,發(fā)現(xiàn) script 標(biāo)簽里的內(nèi)容發(fā)生了改變,由
<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 中間件里獲取請(qǐng)求 body
通過 es-module-lexer 解析資源 ast 拿到 import 的內(nèi)容
判斷 import 的資源是否是絕對(duì)路徑,絕對(duì)視為 npm 模塊
返回處理后的資源路徑:"vue" => "/@modules/vue"
這部分代碼在 serverPluginModuleRewrite 這個(gè) plugin 中,
8.2 為什么需要 @modules?
如果我們?cè)谀K里寫下以下代碼的時(shí)候,瀏覽器中的 esm 是不可能獲取到導(dǎo)入的模塊內(nèi)容的:
import vue from 'vue'
因?yàn)?vue 這個(gè)模塊安裝在 node_modules 里,以往使用 webpack,webpack遇到上面的代碼,會(huì)幫我們做以下幾件事:
獲取這段代碼的內(nèi)容
解析成 AST
遍歷 AST 拿到 import 語(yǔ)句中的包的名稱
使用 enhanced-resolve 拿到包的實(shí)際地址進(jìn)行打包,
但是瀏覽器中 ESM 無(wú)法直接訪問項(xiàng)目下的 node_modules,所以 vite 對(duì)所有 import 都做了處理,用帶有 @modules 的前綴重寫它們。
從另外一個(gè)角度來看這是非常比較巧妙的做法,把文件路徑的 rewrite 都寫在同一個(gè) plugin 里,這樣后續(xù)如果加入更多邏輯,改動(dòng)起來不會(huì)影響其他 plugin,其他 plugin 拿到資源路徑都是 @modules,比如說后續(xù)可能加入 alias 的配置:就像 webpack alias 一樣:可以將項(xiàng)目里的本地文件配置成絕對(duì)路徑的引用。
8.3 怎么返回模塊內(nèi)容
在下一個(gè) koa middleware 中,用正則匹配到路徑上帶有 @modules 的資源,再通過 require('xxx') 拿到 包的導(dǎo)出返回給瀏覽器。
以往使用 webpack 之類的打包工具,它們除了將模塊組裝到一起形成 bundle,還可以讓使用了不同模塊規(guī)范的包互相引用,比如:
ES module (esm) 導(dǎo)入 cjs
CommonJS (cjs) 導(dǎo)入 esm
dynamic import 導(dǎo)入 esm
dynamic import 導(dǎo)入 cjs
關(guān)于 es module 的坑可以看這篇文章(https://zhuanlan.zhihu.com/p/40733281[6])。
起初在 vite 還只是為 vue3.x 設(shè)計(jì)的時(shí)候,對(duì) vue esm 包是經(jīng)過特殊處理的,比如:需要 @vue/runtime-dom 這個(gè)包的內(nèi)容,不能直接通過 require('@vue/runtime-dom')得到,而需要通過 require('@vue/runtime-dom/dist/runtime-dom.esm-bundler.js') 的方式,這樣可以使得 vite 拿到符合 esm 模塊標(biāo)準(zhǔn)的 vue 包。
目前社區(qū)中大部分模塊都沒有設(shè)置默認(rèn)導(dǎo)出 esm,而是導(dǎo)出了 cjs 的包,既然 vue3.0 需要額外處理才能拿到 esm 的包內(nèi)容,那么其他日常使用的 npm 包是不是也同樣需要支持?答案是肯定的,目前在 vite 項(xiàng)目里直接使用 lodash 還是會(huì)報(bào)錯(cuò)的。

不過 vite 在最近的更新中,加入了optimize命令,這個(gè)命令專門為解決模塊引用的坑而開發(fā),例如我們要在 vite 中使用 lodash,只需要在vite.config.js(vite 配置文件)中,配置optimizeDeps對(duì)象,在include數(shù)組中添加 lodash。
// vite.config.js
module.exports = {
optimizeDeps: {
include: ["lodash"]
}
}
這樣 vite 在執(zhí)行 runOptimize 的時(shí)候中會(huì)使用 rollup 對(duì) lodash 包重新編譯,將編譯成符合 esm 模塊規(guī)范的新的包放入 node_modules 下的 .vite_opt_cache 中,然后配合 resolver 對(duì) lodash 的導(dǎo)入進(jìn)行處理:使用編譯后的包內(nèi)容代替原來 lodash 的包的內(nèi)容,這樣就解決了 vite 中不能使用 cjs 包的問題,這部分代碼在 depOptimizer.ts 里。
不過這里還有個(gè)問題,由于在 depOptimizer.ts 中,vite 只會(huì)處理在項(xiàng)目下 package.json 里的 dependencies 里聲明好的包進(jìn)行處理,所以無(wú)法在項(xiàng)目里使用
import pick from 'lodash/pick'
的方式單使用 pick 方法,而要使用
import lodash from 'lodash'
lodash.pick()
的方式,這可能在生產(chǎn)環(huán)境下使用某些包的時(shí)候?qū)?bundle 的體積有影響。
返回模塊的內(nèi)容的代碼在:serverPluginModuleResolve.ts 這個(gè) plugin 中。
9 vite 熱更新的實(shí)現(xiàn)
vite/hmr 是 vite 處理熱更新的關(guān)鍵,在 serverPluginHmr plugin 中,對(duì)于 path 等于 vite/hmr 做了一次判斷:
app.use(async (ctx, next) => {
if (ctx.path === '/vite/hmr') {
ctx.type = 'js'
ctx.status = 200
ctx.body = hmrClient
}
}
hmrClient 是 vite 熱更新的客戶端代碼,需要在瀏覽器里執(zhí)行,這里先來說說通用的熱更新實(shí)現(xiàn),熱更新一般需要四個(gè)部分:
首先需要 web 框架支持模塊的 rerender/reload
通過 watcher 監(jiān)聽文件改動(dòng)
通過 server 端編譯資源,并推送新模塊內(nèi)容給 client 。
client 收到新的模塊內(nèi)容,執(zhí)行 rerender/reload
vite 也不例外同樣有這四個(gè)部分,其中客戶端代碼在:client.ts 里,服務(wù)端代碼在 serverPluginHmr 里,對(duì)于 vue 組件的更新,通過 vue3.x 中的 HMRRuntime 處理的。
10 尤雨溪發(fā)言
Vite,一個(gè)基于瀏覽器原生 ES imports 的開發(fā)服務(wù)器。利用瀏覽器去解析 imports,在服務(wù)器端按需編譯返回,完全跳過了打包這個(gè)概念,服務(wù)器隨起隨用。同時(shí)不僅有 Vue 文件支持,還搞定了熱更新,而且熱更新的速度不會(huì)隨著模塊增多而變慢。針對(duì)生產(chǎn)環(huán)境則可以把同一份代碼用 rollup 打。雖然現(xiàn)在還比較粗糙,但這個(gè)方向我覺得是有潛力的,做得好可以徹底解決改一行代碼等半天熱更新的問題。
參考資料
koa: https://www.npmjs.com/package/koa
[2]esbuild: https://esbuild.github.io/
[3]預(yù)構(gòu)建依賴: 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
