快速理解 Vite 的依賴預構建

當我們使用 Vite 進行開發時,會進行依賴預構建,即將第三方依賴進行打包,並在開發環境下使用這些打包過的第三方依賴。

那這個過程中,Vite 到底做了哪些事情呢?這就是本篇文章要講述的內容

本文爲了降低理解難度,把核心內容講清楚,會把一些非必要的流程省略,例如緩存、用戶配置對預構建過程的影響等等,都會被忽略。對這方面感興趣的同學,可以看完文章後,自行查看 Vite 源碼

預構建的發生了什麼

我們直接拿一個項目來運行一下,這裏我們直接使用 Vite 倉庫源碼的 Vue example

我們運行 vite 命令前設置 DEBUG 環境變量,這樣可以打印出依賴預構建相關的構建信息:

# window 系統臨時設置環境變量方式如下
set DEBUG=vite:deps && vite

運行效果如圖:

從 DEBUG 信息中可以看出:

每一條 DEBUG 信息最後會有一個時間,爲前後兩條 DEBUG 信息相差的時間,一些行沒有時間,則證明該 DEBUG 信息是多行的。不過這個時間在我們這裏暫時沒有太大的作用

然後訪問頁面,我們會看到 html 文件的 script 已經被修改:

- import { createApp, defineCustomElement } from 'vue'
+ import { createApp, defineCustomElement } from '/node_modules/.vite/deps/vue.js?v=b92a21b7'

由於 import vue 這種模塊引入方式,使用的是 Nodejs 特有的模塊查找算法(到 node_modules 中取查找),瀏覽器無法使用,因此 Vite 會將 vue 替換成一個另一個路徑,當瀏覽器解析到這行 import 語句時,會發送一個  /node_modules/.vite/deps/vue.js?v=b92a21b7, Vite Server 會到該目錄下,拿到 vue 預構建之後的產物代碼。

可以看到 node_module 下會多了一個 .vite 文件,依賴預構建的產物會放在 deps 目錄下

這裏階段性的總結一下,依賴預構建做了什麼:

爲什麼要預構建

Vite 在官方文檔中,給出了以下的理由:

  1. 1. CommonJS 和 UMD 兼容性: 開發階段中,Vite 的開發服務器將所有代碼視爲原生 ES 模塊。因此,Vite 必須先將作爲 CommonJS 或 UMD 發佈的依賴項轉換爲 ESM。

  2. 2. 性能: Vite 將有許多內部模塊的 ESM 依賴關係轉換爲單個模塊,以提高後續頁面加載性能。

一些包將它們的 ES 模塊構建作爲許多單獨的文件相互導入。例如,lodash-es 有超過 600 個內置模塊!當我們執行 import { debounce } from 'lodash-es' 時,瀏覽器同時發出 600 多個 HTTP 請求!儘管服務器在處理這些請求時沒有問題,但大量的請求會在瀏覽器端造成網絡擁塞,導致頁面的加載速度相當慢。通過預構建 lodash-es 成爲一個模塊,我們就只需要一個 HTTP 請求了!

// 在 Chrome console 運行以下代碼,體驗一次拉取 600+ 個請求
import('https://unpkg.com/lodash-es/lodash.js')

600+ 的請求,單單拉取一個 lodash-es 就耗時 1200ms 了,體驗極差!

依賴掃描

一個項目中,存在非常多的模塊,並不是所有模塊都會被預構建。只有 bare import(裸依賴)會執行依賴預構建

依賴掃描的目的,就是找出所有的這些第三方依賴,依賴掃描的結果如下:

{
  "lodash-es""D:/tencent/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/lodash.js",
  "vue""D:/tencent/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.runtime.esm-bundler.js"
}

依賴掃描函數 discoverProjectDependencies 會返回一個對象:

如果 import 的第三方依賴同時有 lodash 和 lodash-es/merge.js,掃描結果會是怎樣?

{
  "lodash-es""D:/tencent/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/lodash.js",
  "lodash-es/merge.js""D:/tencent/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/merge.js",
  "vue""D:/tencent/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.runtime.esm-bundler.js"
}

掃描結果會多了 lodash-es/merge.js 的內容,Vite 會爲單獨構建出一個不同的產物文件

入口掃描

如果用戶沒有指定入口文件,Vite 會掃描項目目錄下的所有 HTML 文件**/*.html、node_modules 除外)

掃描結果如下:

[
  "D:/tencent/app/vite/playground/vue/index.html",
  "D:/tencent/app/vite/playground/vue/setup-import-template/template.html",
  "D:/tencent/app/vite/playground/vue/src-import/template.html"
]

依賴掃描的核心思路

先看一下項目中模塊的依賴關係:

從入口的 HTML 文件開始,根據模塊的 import 依賴關係,可以連接成一棵模塊依賴樹。

要掃描出所有的 bare import,就需要遍歷整個依賴樹,這就涉及到了樹的深度遍歷

我們只需要深度遍歷所有樹節點,找出所有 import 語句,把 import 的模塊記錄下來即可

思路雖然很簡單,但真正實現起來,會有幾個比較困難的問題

JS 文件中,如何找到 import 語句?

這個可以用正則表達式匹配,也可以先將代碼解析成 AST 抽象語法樹,然後找到 Import 節點

後者更準確。

找到 import 語句後:

下面是一個例子:

import { createApp, defineCustomElement } from 'vue'
import Main from './Main.vue'
import CustomElement from './CustomElement.ce.vue'

HTML 文件如何處理?

因爲 HTML 文件內,可能存在 script 標籤,這部分的代碼,就可能包含 import 語句。且項目本身就是把 HTML 文件當成入口的。因此必須得處理 HTML。

由於不關心 HTML 中其他的部分,我們只需要先把 script 標籤的內容提取出來,然後再按 JS 的處理方式處理即可

Vue 文件,也是類似的處理方式。

CSS、PNG 等非 JS 模塊如何處理?

這些文件不需要任何處理,直接跳過即可,因爲這些文件不可能再引入 JS 模塊

以上這幾個難題,如果全部都要自己實現,是相當困難的,因此 Vite 巧妙的藉助了打包工具進行處理,可以使用打包工具處理的原因如下:

    1. 如何找到 import 語句打包工具本身就會從入口文件開始,找到所有的模塊依賴,然後進行處理。模塊分析 / 打包流程與我們深度遍歷模塊樹的過程完全相同。打包工具能對每個模塊進行處理,因此我們有機會在模塊處理過程中,將第三方依賴記錄下來。例如:當打包工具解析到,現在正在引入的是 vue 模塊,那這時候,我們就把它記錄下來。
  1. 2. HTML 文件的處理

打包工具能對每個模塊進行處理,在模塊加載時,可以把模塊處理成生成新的內容。Vue 文件的 template,就是在模塊加載時,轉換成 JS 的 render 函數。

不過這裏我們就不是生成 render 函數了,而是把 HTML、Vue 等文件,直接加載成 JS,即只保留它們 script 的部分,其他部分丟棄(依賴掃描不關心非 JS 的內容)

  1. 1. CSS、PNG 等非 JS 模塊的處理

打包工具支持將模塊標記爲 external,就是不打包該模塊了。標記之後,打包工具就不會深入分析該模塊內部的依賴。

 對於 CSS、PNG 這種不需要深入分析的模塊,直接 external 即可

如何利用打包工具進行依賴掃描,這個我在《五千字深度解讀 Vite 的依賴掃描》有深入的解析,該文章爲了減少複雜度,專注於核心內容,不再深入,高階一點的同學,可以再進行深入的瞭解。

打包依賴

依賴掃描已經拿到了所有需要預構建的依賴信息,那接下來直接使用 esbuild 進行打包即可。

最終會有如下的調用:

import { build } from 'esbuild'

const result = await build({
    absWorkingDir: process.cwd(),
    entryPoints: [ 'vue''lodash-es' ],
    bundle: true,
    format: 'esm',
    target: [
        "es2020",
        "edge88",
        "firefox78",
        "chrome87",
        "safari13"
    ],
    splitting: true,  // 該參數會自動進行代碼分割
    plugins: [ /* some plugin */ ],
    // 省略其他配置
})

打包的產物如下:

打開 lodash-es.js 文件,可以看到,所有的代碼都被打包到一個文件中了

如果打包的依賴間,存在依賴的關係 / 有公共的依賴,這要如何處理?

例如:

公共依賴的問題,esbuild 會自動處理

當設置了 splitting 爲 true 時,在多個 entry 入口之間共享的代碼,會被分成單獨共享文件(chunk 文件)

因此 vue 和 ant-design-vue 的打包結果會是這樣:

打包產物 vue.js 部分代碼如下:

// 從 vue 公共代碼引入
import {
  reactive,
  readonly,
  ref,
 // 省略其他
} from "./chunk-KVOLGOJY.js";
export {
  reactive,
  readonly,
  ref,
 // 省略其他
};
//# sourceMappingURL=vue.js.map

打包產物 ant-design-vue.js 部分代碼如下:

// 從 lodash-es 公共代碼引入
import {
  cloneDeep_default,
  debounce_default,
  // 省略其他
} from "./chunk-QUQLN3RK.js";

// 從 vue 公共代碼引入
import {
  provide,
  reactive,
  ref,
  // 省略其他
} from "./chunk-KVOLGOJY.js";

vue 和 lodash-es 由於被 ant-design-vue 依賴,它們作爲公共代碼,被拆分到兩個 chunk 文件中,而打包產物 vue.js 和 lodash-es.js 只需要 import chunk 然後再重新導出即可

依賴路徑替換

依賴打包完之後,最後就是路徑替換了。

- import { createApp, defineCustomElement } from 'vue'
+ import { createApp, defineCustomElement } from '/node_modules/.vite/deps/vue.js?v=b92a21b7'

由於 import vue 這種模塊引入方式,使用的是 Nodejs 特有的模塊查找算法(到 node_modules 中取查找),瀏覽器無法使用,因此 Vite 會將 vue 替換成 /node_modules/.vite/deps/vue.js?v=b92a21b7,當瀏覽器解析到這行 import 語句時,會發送一個  /node_modules/.vite/deps/vue.js?v=b92a21b7 的請求。

所有請求都會在 Vite dev server 的中間件處理,而這個請求,會被 static 中間件處理:用於訪問靜態文件,到會到該目錄下,查找文件並返回。

模塊的路徑是在什麼時候被替換的呢?

我們知道,瀏覽器處理 import 時,會發送一個請求到 Vite Dev Server,然後在中間件處理後,返回模塊的內容。

預構建依賴的路徑,正是在 transform 中間件處理過程中被替換的。關於 transform 中間件的內容,我在《Vite Server 是如何處理頁面資源的?》有詳細的敘述。這裏再總結一下:

這裏稍微寫一下路徑替換的插件僞代碼:

import { parse } from 'es-module-lexer'

// 實現一個 Vite 插件,在 transform 鉤子中替換
export default function myPlugin() {
  return {
    // 實現 transform 鉤子,code 爲當前模塊的代碼,需要 return 修改過後的代碼
    transform(code) {
        // 用 es-module-lexer 解析出模塊使用的 import 和 export,裏面的信息包含 import 語句所在的行數,模塊名所在的列數等信息
        // 這些信息可以用來做字符串替換
        let [imports, exports] = parseImports(source)
        // 根據 import 信息,執行路徑替換
        let resCode = /* 路徑替換過後的代碼 */
           return resCode
    }
  }
}

實際上這部分的邏輯,是寫在 importAnalysis 插件的,但該插件過於複雜,包含了非常多的功能,因此不會展開敘述,感興趣的同學也可以自己去查看

總結

本文介紹了 Vite 依賴預構建是什麼、爲什麼要進行預構建,以及預構建的全流程:

爲了降低複雜度,本文去掉了部分複雜的細節,這樣更便於理解。中階的同學,其實理解到這裏,已經是可以的了,如果想追求高階的同學,可以往以下兩個方向去學習:

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