Vite學習之深度解析「依賴掃描」

2022-09-05 22:00:51
本篇文章帶大家深入地講解Vite中的依賴掃描的實現細節,最終的掃描結果是一個包含多個模組的名字的物件,不涉及預構建的過程、預構建產物如何是使用的。

前端(vue)入門到精通課程:進入學習

當我們首次執行 Vite 的時候,Vite 會執行依賴預構建,目的是為了相容 CommonJS 和 UMD,以及提升效能。【相關推薦:】

要對依賴進行預構建,首先要搞清楚這兩個問題:

  • 預構建的內容是什麼?/ 哪些模組需要進行預構建?

  • 如何找到需要預構建的模組?

這兩個問題,其實就是依賴掃描的內容以及實現方式

本文會深入地講解依賴掃描的實現細節,最終的掃描結果是一個包含多個模組的名字的物件,不涉及預構建的過程、預構建產物如何是使用的。如果對該部分內容感興趣,可以關注我,等待後續文章。

依賴預構建的內容

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

什麼是 bare import ?

直接看下面這個例子

// vue 是 bare import
import xxx from "vue"
import xxx from "vue/xxx"

// 以下不是裸依賴
import xxx from "./foo.ts" 
import xxx from "/foo.ts"

可以簡單的劃分一下:

  • 用名稱去存取的模組是裸模組
  • 用路徑去存取的模組,不是 bare import

實際上 Vite 也是這麼判斷的。

下面是一個常見的 專案的模組依賴樹

1.png

依賴掃描的結果如下:

[ "vue", "axios" ]

為什麼只對 bare import 進行預構建?

Node.js 定義了 bare import 的定址機制 —— 在當前目錄下的 node_modules 下尋找,找不到則往上一級目錄的 node_modules,直到目錄為根路徑,不能再往上。

bare import 一般是 npm 安裝的模組,是第三方的模組,不是我們自己寫的程式碼,一般情況下是不會被修改的,因此對這部分的模組提前執行構建,有利於提升效能。

相反,如果對開發者寫的程式碼執行預構建,將專案打包成 chunk 檔案,當開發者修改程式碼時,就需要重新執行構建,再打包成 chunk 檔案,這個過程反而會影響效能。

monorepo 下的模組也會被預構建嗎?

不會。因為 monorepo 的情況下,部分模組雖然是 bare import,但這些模組也是開發者自己寫的,不是第三方模組,因此 Vite 沒有對該部分的模組執行預構建。

實際上,Vite 會判斷模組的實際路徑,是否在 node_modules 中

  • 實際路徑在 node_modules 的模組會被預構建,這是第三方模組
  • 實際路徑不在 node_modules 的模組,證明該模組是通過檔案連結,連結到 node_modules 內的(monorepo 的實現方式),是開發者自己寫的程式碼,不執行預構建

依賴掃描

實現思路

我們再來看看這棵模組依賴樹:

2.png

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

當我們在討論樹的遍歷時,一般會關注這兩點:

  • 什麼時候停止深入?
  • 如何處理葉子節點?

3.png

當前葉子節點不需要繼續深入遍歷的情況:

  • 當遇到 bare import 節點時,記錄下該依賴,就不需要繼續深入遍歷
  • 遇到其他 JS 無關的模組,如 CSS、SVG 等,因為不是 JS 程式碼,因此也不需要繼續深入遍歷

當所有的葉子節點遍歷完成後,記錄的 bare import 物件,就是依賴掃描的結果

依賴掃描的實現思路其實非常容易理解,但實際的處理就不簡單了。

我們來看看葉子節點的處理:

  • bare import

可以通過模組 id 判斷,模組 id 不為路徑的模組,就是 bare import。遇到這些模組則記錄依賴,不再深入遍歷

  • 其他 JS 無關的模組

可以通過模組的字尾名判斷,例如遇到 *.css 的模組,無需任何處理,不再深入遍歷

  • JS 模組

要獲取 JS 程式碼中依賴的子模組,就需要將程式碼轉成 AST,獲取其中 import 語句引入的模組,或者正則匹配出所有 import 的模組,然後繼續深入遍歷這些模組

  • HTML 型別模組

這類模組比較複雜,例如 HTML 或 Vue,裡面有一部分是 JS,需要把這部分 JS 程式碼提取出來,然後按 JS 模組進行分析處理,繼續深入遍歷這些模組。這裡只需要關心 JS 部分,其他部分不會引入模組。

4.png

具體實現

我們已經知道了依賴掃描的實現思路,思路其實不復雜,複雜的是處理過程,尤其是 HTML、Vue 等模組的處理。

Vite 這裡用了一種比較巧妙的辦法 —— 用 esbuild 工具打包

為什麼可以用 esbuild 打包代替深度遍歷的過程?

本質上打包過程也是個深度遍歷模組的過程,其替代的方式如下:

深度遍歷esbuild 打包
葉子節點的處理esbuild 可以對每個模組(葉子節點)進行解析和載入
可以通過外掛對這兩個過程進行擴充套件,加入一些特殊的邏輯
例如將 html 在載入過程中轉換為 js
不深入處理模組esbuild 可以在解析過程,指定當前解析的模組為 external
則 esbuild 不再深入解析和載入該模組
深入遍歷模組正常解析模組(什麼都不做,esbuild 預設行為),返回模組的檔案真實路徑

這塊暫時看不懂沒有關係,後面會有例子

5.png

各類模組的處理


例子處理
bare importvue在解析過程中,將裸依賴儲存到 deps 物件中,設定為 external
其他 JS 無關的模組less檔案在解析過程中,設定為 external
JS 模組./mian.ts正常解析和載入即可,esbuild 本身能處理 JS
html 型別模組index.htmlapp.vue在載入過程中,將這些模組載入成 JS

最後 dep 物件中收集到的依賴就是依賴掃描的結果,而這次 esbuild 的打包產物,其實是沒有任何作用的,在依賴掃描過程中,我們只關心每個模組的處理過程,不關心構建產物

用 Rollup 處理可以嗎?

其實也可以,打包工具基本上都會有解析和載入的流程,也能對模組進行 external

但是 esbuild 效能更好

html 型別的模組處理

這類檔案有 htmlvue 等。之前我們提到了要將它們轉換成 JS,那麼到底要如何轉換呢?

6.png

由於依賴掃描過程,只關注引入的 JS 模組,因此可以直接丟棄掉其他不需要的內容,直接取其中 JS

html 型別檔案(包括 vue)的轉換,有兩種情況:

  • 每個外部 script,會直接轉換為 import 語句,引入外部 script
  • 每個內聯 script,其內容將會作為虛擬模組被引入。

什麼是虛擬模組?

是模組的內容並非直接從磁碟中讀取,而是編譯時生成

舉個例子,src/main.ts 是磁碟中實際存在的檔案,而 virtual-module:D:/project/index.html?id=0 在磁碟中是不存在的,需要藉助打包工具(如 esbuild),在編譯過程生成。

為什麼需要虛擬模組?

因為一個 html 型別檔案中,允許有多個 script 標籤,多個內聯的 script 標籤,其內容無法處理成一個 JS 檔案 (因為可能會有命名衝突等原因)

既然無法將多個內聯 script,就只能將它們分散成多個虛擬模組,然後分別引入了。

原始碼解析

依賴掃描的入口

下面是掃描依賴的入口函數(為了便於理解,有刪減和修改):

import { build } from 'esbuild'
export async function scanImports(config: ResolvedConfig): Promise<{
  deps: Record<string, string>
  missing: Record<string, string>
}> {
    
  // 將專案中所有的 html 檔案作為入口,會排除 node_modules
  let entries: string[] = await globEntries('**/*.html', config)

  // 掃描到的依賴,會放到該物件
  const deps: Record<string, string> = {}
  // 缺少的依賴,用於錯誤提示
  const missing: Record<string, string> = {}
  
  // esbuild 掃描外掛,這個是重點!!!
  const plugin = esbuildScanPlugin(config, container, deps, missing, entries)

  // 獲取使用者設定的 esbuild 自定義設定,沒有設定就是空的
  const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}

  await Promise.all(
    // 入口可能不止一個,分別用 esbuid 打包
    entries.map((entry) =>
      // esbuild 打包
      build({
        absWorkingDir: process.cwd(),
        write: false,
        entryPoints: [entry],
        bundle: true,
        format: 'esm',
        // 使用外掛
        plugins: [...plugins, plugin],
        ...esbuildOptions
      })
    )
  )

  return {
    deps,
    missing
  }
}

主要流程如下:

  • 將專案內所有的 html 作為入口檔案(排除 node_modules)。

  • 將每個入口檔案,用 esbuild 進行打包

這裡的核心其實是 esbuildScanPlugin 外掛的實現,它定義了各類模組(葉子節點)的處理方式。

function esbuildScanPlugin(config, container, deps, missing, entries){}
  • depmissing物件被當做入參傳入,在函數中,這兩個物件的內容會在打包(外掛執行)過程中被修改

esbuild 外掛

很多同學可能不知道 esbuild 外掛是如何編寫的,這裡簡單介紹一下:

每個模組都會經過解析(resolve)和載入(load)的過程:

  • 解析:將模組路徑,解析成檔案真實的路徑。例如 vue,會解析到實際 node_modules 中的 vue 的入口 js 檔案
  • 載入:根據解析的路徑,讀取檔案的內容

7.png

外掛可以客製化化解析和載入的過程,下面是一些外掛範例程式碼:

const plugin = {
    name: 'xxx',
    setup(build) {
        
        // 客製化解析過程,所有的 http/https 的模組,都會被 external
        build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({
            path,
            external: true
        }))
        
        // 客製化解析過程,給所有 less 檔案 namespace: less 標記
        build.onResolve({ filter: /.*\.less/ }, args => ({
            path: args.path,
            namespace: 'less',
        }))

        // 定義載入過程:只處理 namespace 為 less 的模組
        build.onLoad({ filter: /.*/, namespace: 'less' }, () => {
            const raw = fs.readFileSync(path, 'utf-8')
            const content = // 省略 less 處理,將 less 處理成 css
            return {
                contents,
                loader: 'css'
        	}
        })
    }
}
  • 通過 onResolveonLoad 定義解析和載入過程
  • onResolve 的第一個引數為過濾條件,第二個引數為回撥函數,解析時呼叫,返回值可以給模組做標記,如 externalnamespace(用於過濾),還需要返回模組的路徑
  • 每個模組, onResolve 會被依次呼叫,直到回撥函數返回有效的值,後面的不再呼叫。如果都沒有有效返回,則使用預設的解析方式
  • onLoad 的第一個引數為過濾條件,第二個引數為回撥函數,載入時呼叫,可以讀取檔案的內容,然後進行處理,最後返回載入的內容
  • 每個模組,onLoad 會被依次呼叫,直到回撥函數返回有效的值,後面的不再呼叫。如果都沒有有效返回,則使用預設的載入方式。

掃描外掛的實現

function esbuildScanPlugin(
  config: ResolvedConfig,
  container: PluginContainer,
  depImports: Record<string, string>,
  missing: Record<string, string>,
  entries: string[]
): Plugin

部分引數解析:

  • config:Vite 的解析好的使用者設定

  • container:這裡只會用到 container.resolveId 的方法,這個方法能將模組路徑轉成真實路徑

    例如 vue 轉成 xxx/node_modules/dist/vue.esm-bundler.js

  • depImports:用於儲存掃描到的依賴物件,外掛執行過程中會被修改

  • missing:用於儲存缺少的依賴的物件,外掛執行過程中會被修改

  • entries:儲存所有入口檔案的陣列

esbuild 預設能將模組路徑轉成真實路徑,為什麼還要用 container.resolveId

因為 Vite/Rollup 的外掛,也能擴充套件解析的流程,例如 alias 的能力,我們常常會在專案中用 @ 的別名代表專案的 src 路徑。

因此不能用 esbuild 原生的解析流程進行解析。

container(外掛容器)用於相容 Rollup 外掛生態,用於保證 dev 和 production 模式下,Vite 能有一致的表現。感興趣的可檢視《Vite 是如何相容 Rollup 外掛生態的》

這裡 container.resolveId 會被再次包裝一成 resolve 函數(多了快取能力)

const seen = new Map<string, string | undefined>()
const resolve = async (
    id: string,
    importer?: string,
    options?: ResolveIdOptions
) => {
    const key = id + (importer && path.dirname(importer))
    
    // 如果有快取,就直接使用快取
    if (seen.has(key)) {
        return seen.get(key)
    }
    // 將模組路徑轉成真實路徑
    const resolved = await container.resolveId(
        id,
        importer && normalizePath(importer),
        {
            ...options,
            scan: true
        }
    )
    // 快取解析過的路徑,之後可以直接獲取
    const res = resolved?.id
    seen.set(key, res)
    return res
  }

那麼接下來就是外掛的實現了,先回顧一下之前寫的各類模組的處理:


例子處理
bare importvue在解析過程中,將裸依賴儲存到 deps 物件中,設定為 external
其他 JS 無關的模組less檔案在解析過程中,設定為 external
JS 模組./mian.ts正常解析和載入即可,esbuild 本身能處理 JS
html 型別模組index.htmlapp.vue在載入過程中,將這些模組載入成 JS

JS 模組

esbuild 本身就能處理 JS 語法,因此 JS 是不需要任何處理的,esbuild 能夠分析出 JS 檔案中的依賴,並進一步深入處理這些依賴。

其他 JS 無關的模組

// external urls
build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({
    path,
    external: true
}))

// external css 等檔案
build.onResolve(
    {
        filter: /\.(css|less|sass|scss|styl|stylus|pcss|postcss|json|wasm)$/
    },
    ({ path }) => ({
        path,
        external: true
    }
)
    
// 省略其他 JS 無關的模組

這部分處理非常簡單,直接匹配,然後 external 就行了

bare import

build.onResolve(
  {
    // 第一個字串為字母或 @,且第二個字串不是 : 冒號。如 vite、@vite/plugin-vue
    // 目的是:避免匹配 window 路徑,如 D:/xxx 
    filter: /^[\w@][^:]/
  },
  async ({ path: id, importer, pluginData }) => {
    // depImports 為
    if (depImports[id]) {
      return externalUnlessEntry({ path: id })
    }
    // 將模組路徑轉換成真實路徑,實際上呼叫 container.resolveId
    const resolved = await resolve(id, importer, {
      custom: {
        depScan: { loader: pluginData?.htmlType?.loader }
      }
    })
    
    // 如果解析到路徑,證明找得到依賴
    // 如果解析不到路徑,則證明找不到依賴,要記錄下來後面報錯
    if (resolved) {
      if (shouldExternalizeDep(resolved, id)) {
        return externalUnlessEntry({ path: id })
      }
      // 如果模組在 node_modules 中,則記錄 bare import
      if (resolved.includes('node_modules')) {
        // 記錄 bare import
        depImports[id] = resolved

        return {
        	path,
        	external: true
   		}
      } 
      // isScannable 判斷該檔案是否可以掃描,可掃描的檔案有 JS、html、vue 等
      // 因為有可能裸依賴的入口是 css 等非 JS 模組的檔案
      else if (isScannable(resolved)) {
        // 真實路徑不在 node_modules 中,則證明是 monorepo,實際上程式碼還是在使用者的目錄中
        // 是使用者自己寫的程式碼,不應該 external
        return {
          path: path.resolve(resolved)
        }
      } else {
        // 其他模組不可掃描,直接忽略,external
        return {
            path,
            external: true
        }
      }
    } else {
      // 解析不到依賴,則記錄缺少的依賴
      missing[id] = normalizePath(importer)
    }
  }
)
  • 如果檔案在 node_modules 中,才認為是 bare import,記錄當前模組
  • 檔案不在 node_modules 中,則是 monorepo,是使用者自己寫的程式碼
    • 如果這些程式碼 isScanable 可掃描(即含有 JS 程式碼),則繼續深入處理
    • 其他非 JS 模組,external

html 型別模組

如: index.htmlapp.vue

const htmlTypesRE = /\.(html|vue|svelte|astro)$/

// html types: 提取 script 標籤
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
    // 將模組路徑,轉成檔案的真實路徑
    const resolved = await resolve(path, importer)
    if (!resolved) return
    
    // 不處理 node_modules 內的
    if (resolved.includes('node_modules'){
        return
   }

    return {
        path: resolved,
        // 標記 namespace 為 html 
        namespace: 'html'
    }
})

解析過程很簡單,只是用於過濾掉一些不需要的模組,並且標記 namespace 為 html

真正的處理在載入階段:

8.png

// 正則,匹配例子: <script type=module></script>
const scriptModuleRE = /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
// 正則,匹配例子: <script></script>
export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)<\/script>/gims

build.onLoad(
    { filter: htmlTypesRE, namespace: 'html' },
    async ({ path }) => {
        // 讀取原始碼
        let raw = fs.readFileSync(path, 'utf-8')
        // 去掉註釋,避免後面匹配到註釋
        raw = raw.replace(commentRE, '<!---->')

        const isHtml = path.endsWith('.html')
        // scriptModuleRE: <script type=module></script>
        // scriptRE: <script></script>
        // html 模組,需要匹配 module 型別的 script,因為只有 module 型別的 script 才能使用 import
        const regex = isHtml ? scriptModuleRE : scriptRE

        // 重置正規表示式的索引位置,因為同一個正規表示式物件,每次匹配後,lastIndex 都會改變
        // regex 會被重複使用,每次都需要重置為 0,代表從第 0 個字元開始正則匹配
        regex.lastIndex = 0
        // load 勾點返回值,表示載入後的 js 程式碼
        let js = ''
        let scriptId = 0
        let match: RegExpExecArray | null

        // 匹配原始碼的 script 標籤,用 while 迴圈,因為 html 可能有多個 script 標籤
        while ((match = regex.exec(raw))) {
            // openTag: 它的值的例子: <script type="module" src="xxx">
            // content: script 標籤的內容
            const [, openTag, content] = match
            
            // 正則匹配出 openTag 中的 type 和 lang 屬性
            const typeMatch = openTag.match(typeRE)
            const type =
                  typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
            const langMatch = openTag.match(langRE)
            const lang =
                  langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
            
            // 跳過 type="application/ld+json" 和其他非 non-JS 型別
            if (
                type &&
                !(
                    type.includes('javascript') ||
                    type.includes('ecmascript') ||
                    type === 'module'
                )
            ) {
                continue
            }
            
            // esbuild load 勾點可以設定 應的 loader
            let loader: Loader = 'js'
            if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
                loader = lang
            } else if (path.endsWith('.astro')) {
                loader = 'ts'
            }
            
            // 正則匹配出 script src 屬性
            const srcMatch = openTag.match(srcRE)
            // 有 src 屬性,證明是外部 script
            if (srcMatch) {
                
                const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
                // 外部 script,改為用 import 用引入外部 script
                js += `import ${JSON.stringify(src)}\n`
            } else if (content.trim()) {
                // 內聯的 script,它的內容要做成虛擬模組

                // 快取虛擬模組的內容
                // 一個 html 可能有多個 script,用 scriptId 區分
                const key = `${path}?id=${scriptId++}`
                scripts[key] = {
                    loader,
                    content,
                    pluginData: {
                        htmlType: { loader }
                    }
                }

                // 虛擬模組的路徑,如 virtual-module:D:/project/index.html?id=0
                const virtualModulePath = virtualModulePrefix + key
                js += `export * from ${virtualModulePath}\n`
            }
        }

        return {
            loader: 'js',
            contents: js
        }
    }
)

載入階段的主要做有以下流程:

  • 讀取檔案原始碼
  • 正則匹配出所有的 script 標籤,並對每個 script 標籤的內容進行處理
    • 外部 script,改為用 import 引入
    • 內聯 script,改為引入虛擬模組,並將對應的虛擬模組的內容快取到 script 物件。
  • 最後返回轉換後的 js

srcMatch[1] || srcMatch[2] || srcMatch[3] 是幹嘛?

我們來看看匹配的表示式:

const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im

因為 src 可以有以下三種寫法:

  • src="xxx"
  • src='xxx'
  • src=xxx

三種情況會出現其中一種,因此是三個捕獲組

虛擬模組是如何載入成對應的 script 程式碼的?

export const virtualModuleRE = /^virtual-module:.*/

// 匹配所有的虛擬模組,namespace 標記為 script
build.onResolve({ filter: virtualModuleRE }, ({ path }) => {
  return {
    // 去掉 prefix
    // virtual-module:D:/project/index.html?id=0 => D:/project/index.html?id=0
    path: path.replace(virtualModulePrefix, ''),
    namespace: 'script'
  }
})

// 之前的內聯 script 內容,儲存到 script 物件,載入虛擬模組的時候取出來
build.onLoad({ filter: /.*/, namespace: 'script' }, ({ path }) => {
  return scripts[path]
})

虛擬模組的載入很簡單,直接從 script 物件中,讀取之前快取起來的內容即可。

這樣之後,我們就可以把 html 型別的模組,轉換成 JS 了

掃描結果

下面是一個 depImport 物件的例子:

{
  "vue": "D:/app/vite/node_modules/.pnpm/[email protected]/node_modules/vue/dist/vue.runtime.esm-bundler.js",
  "vue/dist/vue.d.ts": "D:/app/vite/node_modules/.pnpm/[email protected]/node_modules/vue/dist/vue.d.ts",
  "lodash-es": "D:/app/vite/node_modules/.pnpm/[email protected]/node_modules/lodash-es/lodash.js"
}
  • key:模組名稱
  • value:模組的真實路徑

總結

依賴掃描是預構建前的一個非常重要的步驟,這決定了 Vite 需要對哪些依賴進行預構建。

本文介紹了 Vite 會對哪些內容進行依賴預構建,然後分析了實現依賴掃描的基本思路 —— 深度遍歷依賴樹,並對各種型別的模組進行處理。然後介紹了 Vite 如何巧妙的使用 esbuild 實現這一過程。最後對這部分的原始碼進行了解析:

  • 最複雜的就是 html 型別模組的處理,需要使用虛擬模組
  • 當遇到 bare import 時,需要判斷是否在 node_modules 中,在的才記錄依賴,然後 external。
  • 其他 JS 無關的模組就直接 external
  • JS 模組由於 esbuild 本身能處理,不需要做任何的特殊操作

最後獲取到的 depImport 是一個記錄依賴以及其真實路徑的物件

(學習視訊分享:、)

以上就是Vite學習之深度解析「依賴掃描」的詳細內容,更多請關注TW511.COM其它相關文章!