Vite 依賴預編譯,縮短數倍的冷啓動時間

前言

前段時間,Vite 作了一個優化依賴預編譯(Dependency Pre-Bundling)。簡而言之,它指的是 Vite 會在 DevServer 啓動前對須要預編譯的依賴進行編譯,而後在分析模塊的導入(import)時會動態地應用編譯過的依賴javascript

這麼一說,我想你們可能立馬會拋出一個疑問:Vite 不是 No Bundle 嗎?確實 Vite 是 No Bundle,可是依賴預編譯並非意味着 Vite 要走向 Bundle,咱們不要急着下定義,由於它的存在必然是有着其實際的價值前端

那麼,今天本文將會圍繞如下 3 點來和你們一塊兒從疑問點出發,深刻淺出一番 Vite 的依賴預編譯過程:vue

  • 什麼是依賴預編譯
  • 依賴預編譯的做用
  • 依賴預編譯的實現(源碼分析)

1、什麼是依賴預編譯

當你在項目中引用了 vuelodash-es,那麼你在啓動 Vite 的時候,你會在終端看到這樣的輸出內容:java

而這表示 Vite 將你在項目中引入的 vuelodash-es 進行了依賴預編譯!這裏,咱們經過大白話認識一下 Vite 的依賴預編譯:node

  • 默認狀況下,Vite 會將 package.json 中生產依賴 dependencies 的部分啓用依賴預編譯,即會先對該依賴進行編譯,而後將編譯後的文件緩存在內存中(node_modules/.vite 文件下),在啓動 DevServer 時直接請求該緩存內容。
  • 在 vite.config.js 文件中配置 optimizeDeps 選項能夠選擇須要或不須要進行預編譯的依賴的名稱,Vite 則會根據該選項來肯定是否對該依賴進行預編譯。
  • 在啓動時添加 --force options,能夠用來強制從新進行依賴預編譯。
須要注意,強制從新依賴預編譯指的是忽略以前已編譯的文件,直接從新編譯。

因此,回到文章開始所說的疑問,這裏咱們能夠這樣理解依賴預編譯,它的出現是一種優化,即沒有它其實 No Bundle 也能夠,有它更好(xiang)! 並且,依賴預編譯並不是無米之炊,Vite 也是受 Snowpack 的啓發才提出的。react

那麼,下面咱們就來了解一下依賴預編譯的做用是什麼,即優化的意義~git

2、依賴預編譯的做用

對於依賴預編譯的做用,Vite 官方也作了詳細的介紹。那麼,這裏咱們經過結合圖例的方式來認識一下,具體會是兩點:github

1. 兼容 CommonJS 和 AMD 模塊的依賴面試

由於 Vite 的 DevServer 是基於瀏覽器的 Natvie ES Module 實現的,因此對於使用的依賴若是是 CommonJS 或 AMD 的模塊,則須要進行模塊類型的轉化(ES Module)。json

2. 減小模塊間依賴引用致使過多的請求次數

一般咱們引入的一些依賴,它本身又會一些其餘依賴。官方文檔中舉了一個很經典的例子,當咱們在項目中使用 lodash-es 的時候:

import { debounce } from "lodash-es"

若是在沒用依賴預編譯的狀況下,咱們打開頁面的 Dev Tool 的 Network 面板:

能夠看到此時大概有 600+ 和 lodash-es 相關的請求,而且全部請求加載花了 1.11 s,彷佛還好?如今,咱們來看一下使用依賴預編譯的狀況:

此時,只有 1 個和 lodash-es 相關的請求(通過預編譯),而且全部請求加載才花了 142 ms,縮短了足足 7 倍多的時間! 而這裏節省的時間,就是咱們常說的冷啓動時間。

那麼,到這裏咱們就已經瞭解了 Vite 依賴預編譯概念和做用。我想你們都會好奇這個過程又是怎麼實現的?下面,咱們就深刻 Vite 源碼來更進一步地認識依賴預編譯過程!

3、依賴預編譯的實現

在 Vite 源碼中,默認的依賴預編譯過程會在 DevServer 開啓以前進行。這裏,咱們仍然以在項目中引入了 vuelodash-es 依賴爲例。

須要注意的是如下和源碼相關的函數都是取的 核心邏輯講解(僞代碼)。

3.1 Dev Server 啓動前

首先,Vite 會建立一個 DevServer,也就是咱們日常使用的本地開發服務器,這個過程是由 createServer 函數完成:

// packages/vite/src/node/server/index.ts
async function createServer(
  inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  ...
  // 一般狀況下咱們會命中這個邏輯
  if (!middlewareMode && httpServer) {
    // 重寫 DevServer 的 listen,保證在 DevServer 啓動前進行依賴預編譯
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (port: number, ...args: any[]) => {
      try {
        ...
        // 依賴預編譯相關
        await runOptimize()
      } 
      ...
    }) as any
    ...
  } else {
    await runOptimize()
  }
  ...
}

能夠看到在 DevServer 真正啓動以前,它會先調用 runOptimize 函數,進行依賴預編譯相關的處理(用 bind 進行簡單的重寫)。

runOptimize 函數:

// packages/vite/src/node/server/index.ts
const runOptimize = async () => {
  // config.optimzizeCacheDir 指的是 node_modules/.vite
  if (config.optimizeCacheDir) {
    ..
    try {
      server._optimizeDepsMetadata = await optimizeDeps(config)
    }
    ..
    server._registerMissingImport = createMissingImpoterRegisterFn(server)
  }
}

runOptimize 函數負責的是調用和註冊處理依賴預編譯相關的 optimizeDeps 函數,具體來講會是兩件事:

1. 進行依賴預編譯

optimizeDeps 函數是 Vite 實現依賴預編譯的核心函數,它會根據配置 vite.config.js 的 optimizeDeps 選項和 package.json 的 dependencies 的參數進行第一次預編譯。它會返回解析 node_moduels/.vite/_metadata.json 文件後生成的對象(包含預編譯後的依賴所在的文件位置、原文件所處的文件位置等)。

_metadata.json 文件:

{
  "hash": "bade5e5e",
  "browserHash": "830194d7",
  "optimized": {
    "vue": {
      "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/vue.js",
      "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "needsInterop": false
    },
    "lodash-es": {
      "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/lodash-es.js",
      "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js",
      "needsInterop": false
    }
  }
}

這裏,咱們來分別認識一下這 4 個屬性的含義:

  • hash 由須要進行預編譯的文件內容生成的,用於防止 DevServer 啓動時重複編譯相同的依賴,即依賴並無發生變化,不須要從新編譯。
  • browserHashhash 和在運行時發現的額外的依賴生成的,用於讓預編譯的依賴的瀏覽器請求無效。
  • optimized 包含每一個進行過預編譯的依賴,其對應的屬性會描述依賴源文件路徑 src 和編譯後所在路徑 file
  • needsInterop 主要用於在 Vite 進行依賴性導入分析,這是由 importAnalysisPlugin 插件中的 transformCjsImport 函數負責的,它會對須要預編譯且爲 CommonJS 的依賴導入代碼進行重寫。舉個例子,當咱們在 Vite 項目中使用 react 時:
import React, { useState, createContext } from 'react'

此時 react 它是屬於 needsInteroptrue 的範疇,因此 importAnalysisPlugin 插件的會對導入 react 的代碼進行重寫:

import $viteCjsImport1_react from "/@modules/react.js";
const React = $viteCjsImport1_react;
const useState = $viteCjsImport1_react["useState"];
const createContext = $viteCjsImport1_react["createContext"];

之因此要進行重寫的原因是由於 CommonJS 的模塊並不支持命名方式的導出。因此,若是不通過插件的轉化,則會看到這樣的異常:

Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'
有興趣繼續往這方面瞭解的同窗能夠查看這個 PR https://github.com/vitejs/vit...,這裏就不作過於詳細的介紹了~

2. 註冊依賴預編譯相關函數

調用 createMissingImpoterRegisterFn 函數,它會返回一個函數,其仍然內部會調用 optimizeDeps 函數進行預編譯,只是不一樣於第一次預編譯過程,此時會傳人一個 newDeps,即新的須要進行預編譯的依賴。

那麼,顯然不管是第一次預編譯,仍是後續的預編譯,它們二者的實現都是調用的 optimizeDeps 函數。因此,下面咱們來看一下 optimizeDeps 函數~

3.2 預編譯實現核心 optimizeDeps 函數

optimizeDeps 函數被定義在 packages/vite/node/optimizer/index.ts 中,它負責對依賴進行預編譯過程:

// packages/vite/node/optimizer/index.ts
export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.server.force,
  asCommand = false,
  newDeps?: Record<string, string>
): Promise<DepOptimizationMetadata | null> {
...
}

因爲 optimizeDeps 內部邏輯較爲繁多,這裏咱們拆分爲 5 個步驟講解:

1. 讀取該依賴此時的文件信息

既然是編譯依賴,很顯然的是每次編譯都須要知道此時文件內容對應的 Hash 值,以便於依賴發生變化時能夠從新進行依賴編譯,從而應用最新的依賴內容。

因此,這裏會先調用 getDepHash 函數獲取依賴的 Hash 值:

// 獲取該文件此時的 hash
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
  hash: mainHash,
  browserHash: mainHash,
  optimized: {}
}
而對於 data 中的這三個屬性,咱們在上面已經介紹過了,這裏就不重複論述了~

2. 對比緩存文件的 Hash

前面,咱們也說起了若是啓動 Vite 時使用了 --force Option,則會強制從新進行依賴預編譯。因此,當不是 --force 場景時,則會進行比較新舊依賴的 Hash 值的過程:

// 默認爲 false
if (!force) {
  let prevData
  try {
    // 獲取到此時緩存(本地磁盤)中編譯的文件信息
    prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
  } catch (e) {}
  // 對比此時的 
  if (prevData && prevData.hash === data.hash) {
    log('Hash is consistent. Skipping. Use --force to override.')
    return prevData
  }
}

能夠看到若是新舊依賴的 Hash 值相等的時候,則會直接返回舊的依賴內容。

3. 緩存失效或未緩存

若是上面的 Hash 不等,則表示緩存失效,因此會刪除 cacheDir 文件夾,又或者此時未進行緩存,即第一次依賴預編譯邏輯( cacheDir 文件夾不存在),則建立 cacheDir 文件夾:

if (fs.existsSync(cacheDir)) {
    emptyDir(cacheDir)
  } else {
    fs.mkdirSync(cacheDir, { recursive: true })
  }
須要注意的是,這裏的 cacheDir 則指的是 node_modules/.vite 文件夾

前面在講 DevServer 啓動時,咱們說起預編譯過程會分爲兩種:第一次預編譯和後續的預編譯。二者的區別在於後者會傳入一個 newDeps,它表示新的須要進行預編譯的依賴:

let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
  ;({ deps, missing } = await scanImports(config))
} else {
  // 存在 newDeps 的時候,直接將 newDeps 賦值給 deps
  deps = newDeps
  missing = {}
}

而且,這裏能夠看到對於前者,第一次預編譯,則會調用 scanImports 函數來找出和預編譯相關的依賴 depsdeps 會是一個對象:

{
  lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js'
  vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}

missing 則表示在 node_modules 中沒找到的依賴。因此,當 missing 存在時,你會看到這樣的提示:

scanImports 函數內部則是調用的一個名爲 dep-scan 的內部插件(Plugin)。這裏就不講解 dep-scan 插件的具體實現了,有興趣的同窗能夠自行了解哈~

那麼,回到上面對於後者(newDeps 存在時)的邏輯則較爲簡單,會直接給 deps 賦值爲 newDeps,而且不須要處理 missing。由於,newDeps 只有在後續導入並安裝了新的 dependencies 依賴,纔會傳入的,此時是不存在 missing 的依賴的( Vite 內置的 importAnalysisPlugin 插件會提早過濾掉這些)。

4. 處理 optimizeDeps.include 相關依賴

在前面,咱們也說起了須要進行編譯的依賴也會由 vite.config.js 的 optimizeDeps 選項決定。因此,在處理完 dependencies 以後,接着須要處理 optimizeDeps

此時,會遍歷前面從 dependencies 獲取到的 deps,判斷 optimizeDeps.iclude(數組)所指定的依賴是否存在,不存在則會拋出異常:

const include = config.optimizeDeps?.include
  if (include) {
    const resolve = config.createResolver({ asSrc: false })
    for (const id of include) {
      if (!deps[id]) {
        const entry = await resolve(id)
        if (entry) {
          deps[id] = entry
        } else {
          throw new Error(
            `Failed to resolve force included dependency: ${chalk.cyan(id)}`
          )
        }
      }
    }
  }

5. 使用 esbuild 編譯依賴

那麼,在作好上述和預編譯依賴相關的處理(文件 hash 生成、預編譯依賴肯定等)後。則進入依賴預編譯的最後一步,使用 esbuild 來對相應的依賴進行編譯:

...
  const esbuildService = await ensureService()
  await esbuildService.build({
    entryPoints: Object.keys(flatIdDeps),
    bundle: true,
    format: 'esm',
    ...
  })
  ...

ensureService 函數是 Vite 內部封裝的 util,它的本質是建立一個 esbuildservice,使用 service.build 函數來完成編譯過程。

此時,傳入的 flatIdDeps 參數是一個對象,它是由上面說起的 deps 收集好的依賴建立的,它的做用是爲 esbuild 進行編譯的時候提供多路口(entry),flatIdDeps 對象:

{
  lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js'
  moment:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/moment/dist/moment.js'
  vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}

好了,到此咱們已經分析完了整個依賴預編譯的實現 😲(手動給看到這的你們👍)。

那麼,接下來在 DevServer 啓動後,當模塊須要請求通過預編譯的依賴的時候,Vite 內部的 resolvePlugin 插件會解析該依賴是否存在 seen 中(seen 中會存儲編譯過的依賴映射),是則直接應用 node_modules/.vite 目錄下對應的編譯後的依賴,避免直接去請求編譯前的依賴的狀況出現,從而縮短冷啓動的時間。

結語

經過了解 Vite 依賴預編譯的做用、實現等相關知識,我想你們應該不會再去糾結 Bundle 或者 No Bundle 的問題了,仍然是那句話,存在即有價值。而且,依賴預編譯這個知識點在面試場景下,可能也是一個頗有趣的考題 😎。最後,若是文章中存在表達不當或錯誤的地方,歡迎你們提 Issue~

點贊 👍

經過閱讀本篇文章,若是有收穫的話,能夠點個贊,這將會成爲我持續分享的動力,感謝~

我是五柳,喜歡創新、搗鼓源碼,專一於源碼(Vue 三、Vite)、前端工程化、跨端等技術學習和分享,歡迎關注個人 微信公衆號:Code center

相關文章
相關標籤/搜索