您好,登錄后才能下訂單哦!
本篇內容主要講解“Vite依賴掃描怎么實現”,感興趣的朋友不妨來看看。本文介紹的方法操作簡單快捷,實用性強。下面就讓小編來帶大家學習“Vite依賴掃描怎么實現”吧!
當我們首次運行 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 也是這么判斷的。
下面是一個常見的 Vue 項目的模塊依賴樹
依賴掃描的結果如下:
[ "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 的實現方式),是開發者自己寫的代碼,不執行預構建
實現思路
我們再來看看這棵模塊依賴樹:
要掃描出所有的 bare import,就需要遍歷整個依賴樹,這就涉及到了樹的深度遍歷
當我們在討論樹的遍歷時,一般會關注這兩點:
什么時候停止深入?
如何處理葉子節點?
當前葉子節點不需要繼續深入遍歷的情況:
當遇到 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 部分,其他部分不會引入模塊。
具體實現
我們已經知道了依賴掃描的實現思路,思路其實不復雜,復雜的是處理過程,尤其是 HTML、Vue 等模塊的處理。
Vite 這里用了一種比較巧妙的辦法 —— 用 esbuild 工具打包
為什么可以用 esbuild 打包代替深度遍歷的過程?
本質上打包過程也是個深度遍歷模塊的過程,其替代的方式如下:
深度遍歷 | esbuild 打包 |
---|---|
葉子節點的處理 | esbuild 可以對每個模塊(葉子節點)進行解析和加載 可以通過插件對這兩個過程進行擴展,加入一些特殊的邏輯 例如將 html 在加載過程中轉換為 js |
不深入處理模塊 | esbuild 可以在解析過程,指定當前解析的模塊為 external 則 esbuild 不再深入解析和加載該模塊。 |
深入遍歷模塊 | 正常解析模塊(什么都不做,esbuild 默認行為),返回模塊的文件真實路徑 |
這塊暫時看不懂沒有關系,后面會有例子
各類模塊的處理
例子 | 處理 | |
---|---|---|
bare import | vue | 在解析過程中,將裸依賴保存到 deps 對象中,設置為 external |
其他 JS 無關的模塊 | less文件 | 在解析過程中,設置為 external |
JS 模塊 | ./mian.ts | 正常解析和加載即可,esbuild 本身能處理 JS |
html 類型模塊 | index.html 、app.vue | 在加載過程中,將這些模塊加載成 JS |
最后 dep 對象中收集到的依賴就是依賴掃描的結果,而這次 esbuild 的打包產物,其實是沒有任何作用的,在依賴掃描過程中,我們只關心每個模塊的處理過程,不關心構建產物
用 Rollup 處理可以嗎?
其實也可以,打包工具基本上都會有解析和加載的流程,也能對模塊進行 external
但是 esbuild 性能更好
這類文件有 html
、vue
等。之前我們提到了要將它們轉換成 JS,那么到底要如何轉換呢?
由于依賴掃描過程,只關注引入的 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){}
dep
、missing
對象被當做入參傳入,在函數中,這兩個對象的內容會在打包(插件運行)過程中被修改
esbuild 插件
很多同學可能不知道 esbuild 插件是如何編寫的,這里簡單介紹一下:
每個模塊都會經過解析(resolve)和加載(load)的過程:
解析:將模塊路徑,解析成文件真實的路徑。例如 vue
,會解析到實際 node_modules 中的 vue 的入口 js 文件
加載:根據解析的路徑,讀取文件的內容
插件可以定制化解析和加載的過程,下面是一些插件示例代碼:
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' } }) } }
通過 onResolve
、onLoad
定義解析和加載過程
onResolve
的第一個參數為過濾條件,第二個參數為回調函數,解析時調用,返回值可以給模塊做標記,如 external
、namespace
(用于過濾),還需要返回模塊的路徑
每個模塊, 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 import | vue | 在解析過程中,將裸依賴保存到 deps 對象中,設置為 external |
其他 JS 無關的模塊 | less文件 | 在解析過程中,設置為 external |
JS 模塊 | ./mian.ts | 正常解析和加載即可,esbuild 本身能處理 JS |
html 類型模塊 | index.html 、app.vue | 在加載過程中,將這些模塊加載成 JS |
esbuild 本身就能處理 JS 語法,因此 JS 是不需要任何處理的,esbuild 能夠分析出 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 就行了
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
如: index.html
、app.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
真正的處理在加載階段:
// 正則,匹配例子: <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/vue@3.2.37/node_modules/vue/dist/vue.runtime.esm-bundler.js", "vue/dist/vue.d.ts": "D:/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.d.ts", "lodash-es": "D:/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/lodash.js" }
key:模塊名稱
value:模塊的真實路徑
依賴掃描是預構建前的一個非常重要的步驟,這決定了 Vite 需要對哪些依賴進行預構建。
本文介紹了 Vite 會對哪些內容進行依賴預構建,然后分析了實現依賴掃描的基本思路 —— 深度遍歷依賴樹,并對各種類型的模塊進行處理。然后介紹了 Vite 如何巧妙的使用 esbuild 實現這一過程。最后對這部分的源碼進行了解析:
最復雜的就是 html 類型模塊的處理,需要使用虛擬模塊;
當遇到 bare import 時,需要判斷是否在 node_modules 中,在的才記錄依賴,然后 external。
其他 JS 無關的模塊就直接 external
JS 模塊由于 esbuild 本身能處理,不需要做任何的特殊操作
最后獲取到的 depImport 是一個記錄依賴以及其真實路徑的對象
到此,相信大家對“Vite依賴掃描怎么實現”有了更深的了解,不妨來實際操作一番吧!這里是億速云網站,更多相關內容可以進入相關頻道進行查詢,關注我們,繼續學習!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。