Vite 的實現原理,確實很巧妙

vite 是新興的構建工具,它相比 webpack 最大的特點就是快。

那它是如何做到這麼快的呢?

因爲 vite 在開發環境並不做打包。

我們創建個 vite 項目:

npx create-vite

安裝依賴,然後把服務跑起來:

npm install
npm run dev

瀏覽器訪問下:

本地是 main.tsx 引入了 App.tsx,並且還有 react 和 react-dom/client 的依賴:

用 devtools 看下:

可以看到,main.tsx、App.tsx 還有 react 和 react-dom/client 的依賴都是直接引入的,做了編譯,但是並沒有打包。

這是基於瀏覽器的 type 爲 module 的 script 實現的:

我們加一個 index2.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta  />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="aaa.js"></script>
  </body>
</html>

然後添加 aaa.js

import { add } from './bbb.js'

console.log(add(1, 2));

bbb.js

export function add(a, b) {
    return a + b;
}

起個靜態服務訪問下:

npx http-server

瀏覽器訪問下 http://localhost:8080/index2.html

可以看到,aaa 和 bbb 模塊都被下載並執行了。

當然,我們沒有做編譯,如果有 ts 或者 jsx 的語法,需要做一次編譯。

那我們是不是可以起個服務器,請求的時候根據 url 找到對應的文件,編譯之後返回呢?

沒錯,如果你這樣想了,那你也可以寫一個 vite。

vite 在開發環境下就是起了一個做編譯的服務器,根據請求的 URL 找到對應的模塊做編譯之後返回。

當你執行 npm run dev 的時候:

vite 會跑一個開發服務:

這個開發服務是基於 connect 實現的,vite 給它加了很多中間件來處理請求:

當你請求 index.html 的時候,它會通過 ast 遍歷,找到其中所有的 script:

然後提前對這些文件做編譯:

編譯是通過不同插件完成的:

插件就是一個對象,它導出了 transform 方法的話,就會在 transform 的時候被調用。

比如圖中有 css 插件來編譯 css、esbuild 插件來編譯 ts/js 等。

每個插件都會判斷下,只處理對應的資源:

比如 vite:esbuild 插件,就是對 js/ts 做編譯,然後返回編譯後的 code 和 sourcemap:

還有個 import-anlysis 插件,在 esbuild 完成編譯之後,分析模塊依賴,繼續處理其它模塊的 transform:

這樣,瀏覽器只要訪問了 index.html,那麼你依賴的所有的 js 模塊,就都給你編譯了。

這就是 vite 爲什麼叫 no bundle 方案,它只是基於瀏覽器的 module import,在請求的時候對模塊做下編譯。

但不知道大家有沒有想過一個問題:

瀏覽器支持 es module 的 import,那如果 node_modules 下的依賴有用 commonjs 模塊規範的代碼呢?

是不是就不行了。

這種就需要提前做一些轉換,把 commonjs 轉成 import。

還有一個問題,如果每個模塊都是請求時編譯,那向 lodash-es 這種包,它可是有幾百個模塊的 import 呢:

這樣跑起來,一個 node_modules 下的包就有幾百個請求,依賴多了以後,很容易就幾千個請求。

這誰受的了?

所以我們要提前處理下,不但要把 node_modules 下代碼的 commonjs 提前轉成 es module,還有提前對這些包做一次打包,變成一個 es module 模塊。

所以,vite 加了一個預構建功能 pre bunle。

在啓動完開發服務器的時候,就馬上對 node_modules 下的代碼做打包,這個也叫 deps optimize,依賴優化。

如何優化呢?

首先,掃描出所有的依賴來:

這一步是用 esbuild 做的:

esbuild.context 和 esbuild.build 差不多的功能。

可以看到,用 esbuild 對入口 index.html 開始做打包,輸出格式爲 esm,但是 write 爲 false,不寫入磁盤。

有同學說,esbuild 支持 import html 麼?

這裏用到了一個 esbuild scan plugin:

vite 實現的,用來記錄依賴的:

它會在每種模塊路徑解析的時候做處理,其中支持了 html 的處理。

這樣用 esbuild 處理完一遍,是不是就知道預打包哪些包了?

我們在項目裏引入下 dayjs 和 lodash-es 再試下:

依然給你一個不少的給分析了出來:

接下來調用 esbuild 打包就行。

但打包之前呢,還會對路徑做扁平化,比如 react-dom/client 變成 react-dom_client

效果就是打包之後文件是平級的。

從每個依賴包作爲入口打包,輸出 esm 格式的模塊到 node_modules/.vite 下。

之後還會生成一個 _metadata.json 文件寫入 node_modules/.vite 下:

這樣的:

這個 metadata.json 是幹啥的呢?

看到這幾個 hash 了麼

vite 會在這些預打包的模塊後加一個 query 字符串帶上 hash,然後用 max-age 強緩存:

因爲這些依賴一般不會變,不用每次都請求,強緩存就行。

但是在 lock 文件變化或者 config 有一些變化的時候也需要重新 build:

重新預編譯,然後在資源請求時帶上新的 query,這樣就讓強緩存失效了。

這裏強緩存的用法很典型,面試官們可以記一下作爲考點。

這樣,vite 的開發服務的請求時編譯,再就是預構建就都完成了。

有的同學可能會問,爲啥預構建要用 esbuild 呢?

原因就是快:

vite 在 dev 時的核心原理我們理清了,但是在 build 的時候總要打包的吧。

那肯定,在 build 的時候 vite 會用 rollup 做打包。

那不會導致開發時的代碼和生產環境不一致麼?

不會。

能做到這一點也很巧妙。

看下 build 時的 rollup 插件:

是不是似曾相識?

對比下 dev 時跑的 vite 插件:

沒錯,vite 插件時兼容 rollup 插件的,這樣在開發的時候,在生產環境打包的時候,都可以用同樣的插件對代碼做 transform 等處理。

處理用的插件都一樣,又怎麼會開發和生產不一致呢?

這也是 vite 的巧妙之處。

在 dev 的時候,它實現了一個 PluginContainer,用和 rollup 插件同樣的參數來調用 vite 插件:

然後 build 的時候,可以把這些插件直接作爲 rollup 插件用。

對了,vite 在 dev 的時候還支持熱更新,也就是本地改了代碼能夠自動同步到瀏覽器。

這個就是基於 chokidar 監聽了本地文件變動:

然後在模塊變動的時候通過 websocket 通知瀏覽器端:

瀏覽器端接受之後做相應處理就好了:

我們改下 Aaa.tsx,可以看到瀏覽器端收到了 update 的 ws 消息:

收到消息之後,把模塊換成這個新的,加上 timestamp 重新請求就好了:

總結

今天我們分析了下 rollup 的實現原理。

它是基於瀏覽器的 type 爲 module 的 script 可以直接下載 es module 模塊實現的。

做了一個開發服務,根據請求的 url 來對模塊做編譯,調用 vite 插件來做不同模塊的 transform。

但是 node_modules 下的文件有的包是 commonjs 的,並且可能有很多個模塊,這時 vite 做了預構建也叫 deps optimize。

它用 esbuild 分析依賴,然後用 esbuild 打包成 esm 的包之後輸出到 node_modules/.vite 下,並生成了一個 metadata.json 來記錄 hash。

瀏覽器裏用 max-age 強緩存這些預打包的模塊,但是帶了 hash 的 query。這樣當重新 build 的時候,可以通過修改 query 來觸發更新。

在開發時通過 connect 起了一個服務器,調用 vite 插件來做 transform,並且對 node_modules 下的模塊做了預構建,用 esbuild 打包。

在生產環境用 rollup 來打包,因爲 vite 插件兼容了 rollup 插件,所以也是用同樣的插件來處理,這樣能保證開發和生產環境代碼一致。

此外,vite 還基於 chokidar 和 websocket 來實現了模塊熱更新。

這就是 vite 的實現原理。

回想下,不管是基於瀏覽器 es module import 實現的編譯服務,基於 esbuild 做的依賴預構建,基於 hash query 做的強緩存和緩存更新,還是兼容 rollup 的 vite 插件可以在開發服務和 rollup 裏同時跑,這些功能實現都挺巧妙的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ejkfARh6hlOAUnw5Eadb6Q