Vite配置文件如何加載深入剖析
一、流程梳理
我們先來梳理整體的流程,Vite 中的配置解析由 resolveConfig 函數(shù)來實現(xiàn),你可以對照源碼一起學習。
1.1 加載配置文件
進行一些必要的變量聲明后,我們進入到解析配置邏輯中,配置文件的源碼如下:
// 這里的 config 是命令行指定的配置,如 vite --configFile=xxx let { configFile } = config if (configFile !== false) { // 默認都會走到下面加載配置文件的邏輯,除非你手動指定 configFile 為 false const loadResult = await loadConfigFromFile( configEnv, configFile, config.root, config.logLevel ) if (loadResult) { // 解析配置文件的內容后,和命令行配置合并 config = mergeConfig(loadResult.config, config) configFile = loadResult.path configFileDependencies = loadResult.dependencies } }
第一步是解析配置文件的內容,然后與命令行配置合并。值得注意的是,后面有一個記錄configFileDependencies的操作。因為配置文件代碼可能會有第三方庫的依賴,所以當?shù)谌綆煲蕾嚨拇a更改時,Vite 可以通過 HMR 處理邏輯中記錄的configFileDependencies檢測到更改,再重啟 DevServer ,來保證當前生效的配置永遠是最新的。
1.2 解析用戶插件
第二個重點環(huán)節(jié)是 解析用戶插件。首先,我們通過 apply 參數(shù) 過濾出需要生效的用戶插件。為什么這么做呢?因為有些插件只在開發(fā)階段生效,或者說只在生產(chǎn)環(huán)境生效,我們可以通過 apply: 'serve' 或 'build' 來指定它們,同時也可以將apply配置為一個函數(shù),來自定義插件生效的條件。解析代碼如下:
// resolve plugins const rawUserPlugins = (config.plugins || []).flat().filter((p) => { if (!p) { return false } else if (!p.apply) { return true } else if (typeof p.apply === 'function') { // apply 為一個函數(shù)的情況 return p.apply({ ...config, mode }, configEnv) } else { return p.apply === command } }) as Plugin[] // 對用戶插件進行排序 const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawUserPlugins)
接著,Vite 會拿到這些過濾且排序完成的插件,依次調用插件 config 鉤子,進行配置合并。
// run config hooks const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins] for (const p of userPlugins) { if (p.config) { const res = await p.config(config, configEnv) if (res) { // mergeConfig 為具體的配置合并函數(shù),大家有興趣可以閱讀一下實現(xiàn) config = mergeConfig(config, res) } } }
然后,解析項目的根目錄即 root 參數(shù),默認取 process.cwd()的結果。
// resolve root const resolvedRoot = normalizePath( config.root ? path.resolve(config.root) : process.cwd() )
緊接著處理 alias ,這里需要加上一些內置的 alias 規(guī)則,如@vite/env、@vite/client這種直接重定向到 Vite 內部的模塊。
// resolve alias with internal client alias const resolvedAlias = mergeAlias( clientAlias, config.resolve?.alias || config.alias || [] ) const resolveOptions: ResolvedConfig['resolve'] = { dedupe: config.dedupe, ...config.resolve, alias: resolvedAlias }
1.3 加載環(huán)境變量
加載環(huán)境變量的實現(xiàn)代碼如下:
// load .env files const envDir = config.envDir ? normalizePath(path.resolve(resolvedRoot, config.envDir)) : resolvedRoot const userEnv = inlineConfig.envFile !== false && loadEnv(mode, envDir, resolveEnvPrefix(config))
loadEnv 其實就是掃描 process.env 與 .env文件,解析出 env 對象,值得注意的是,這個對象的屬性最終會被掛載到import.meta.env 這個全局對象上。解析 env 對象的實現(xiàn)思路如下:
- 遍歷 process.env 的屬性,拿到指定前綴開頭的屬性(默認指定為VITE_),并掛載 env 對象上
- 遍歷 .env 文件,解析文件,然后往 env 對象掛載那些以指定前綴開頭的屬性。遍歷的文件先后順序如下(下面的 mode 開發(fā)階段為 development,生產(chǎn)環(huán)境為production)
特殊情況下,如果中途遇到 NODE_ENV 屬性,則掛到 process.env.VITE_USER_NODE_ENV,Vite 會優(yōu)先通過這個屬性來決定是否走生產(chǎn)環(huán)境的構建。
接下來,是對資源公共路徑即base URL的處理,邏輯集中在 resolveBaseUrl 函數(shù)當中:
// 解析 base url const BASE_URL = resolveBaseUrl(config.base, command === 'build', logger) // 解析生產(chǎn)環(huán)境構建配置 const resolvedBuildOptions = resolveBuildOptions(config.build)
resolveBaseUrl里面有這些處理規(guī)則需要注意:
- 空字符或者 ./ 在開發(fā)階段特殊處理,全部重寫為/
- .開頭的路徑,自動重寫為 /
- 以http(s)://開頭的路徑,在開發(fā)環(huán)境下重寫為對應的 pathname
- 確保路徑開頭和結尾都是/
當然,還有對cacheDir的解析,這個路徑相對于在 Vite 預編譯時寫入依賴產(chǎn)物的路徑:
// resolve cache directory const pkgPath = lookupFile(resolvedRoot, [`package.json`], true /* pathOnly */) // 默認為 node_module/.vite const cacheDir = config.cacheDir ? path.resolve(resolvedRoot, config.cacheDir) : pkgPath && path.join(path.dirname(pkgPath), `node_modules/.vite`)
緊接著處理用戶配置的assetsInclude,將其轉換為一個過濾器函數(shù):
const assetsFilter = config.assetsInclude ? createFilter(config.assetsInclude) : () => false
然后,Vite 后面會將用戶傳入的 assetsInclude 和內置的規(guī)則合并:
assetsInclude(file: string) { return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file) }
這個配置決定是否讓 Vite 將對應的后綴名視為靜態(tài)資源文件(asset)來處理。
1.4 路徑解析器
這里所說的路徑解析器,是指調用插件容器進行路徑解析的函數(shù),代碼結構如下所示:
const createResolver: ResolvedConfig['createResolver'] = (options) => { let aliasContainer: PluginContainer | undefined let resolverContainer: PluginContainer | undefined // 返回的函數(shù)可以理解為一個解析器 return async (id, importer, aliasOnly, ssr) => { let container: PluginContainer if (aliasOnly) { container = aliasContainer || // 新建 aliasContainer } else { container = resolverContainer || // 新建 resolveContainer } return (await container.resolveId(id, importer, undefined, ssr))?.id } }
并且,這個解析器未來會在依賴預構建的時候用上,具體用法如下:
const resolve = config.createResolver() // 調用以拿到 react 路徑 rseolve('react', undefined, undefined, false)
這里有aliasContainer和resolverContainer兩個工具對象,它們都含有resolveId這個專門解析路徑的方法,可以被 Vite 調用來獲取解析結果,本質都是PluginContainer。
接著,會順便處理一個 public 目錄,也就是 Vite 作為靜態(tài)資源服務的目錄:
const { publicDir } = config const resolvedPublicDir = publicDir !== false && publicDir !== '' ? path.resolve( resolvedRoot, typeof publicDir === 'string' ? publicDir : 'public' ) : ''
至此,配置已經(jīng)基本上解析完成,最后通過 resolved 對象來整理一下:
const resolved: ResolvedConfig = { ...config, configFile: configFile ? normalizePath(configFile) : undefined, configFileDependencies, inlineConfig, root: resolvedRoot, base: BASE_URL ... //其他配置 }
1.5 生成插件流水線
生成插件流水線的代碼如下:
(resolved.plugins as Plugin[]) = await resolvePlugins( resolved, prePlugins, normalPlugins, postPlugins ) // call configResolved hooks await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)))
先生成完整插件列表傳給resolve.plugins,而后調用每個插件的 configResolved 鉤子函數(shù)。其中 resolvePlugins 內部細節(jié)比較多,插件數(shù)量比較龐大,我們暫時不去深究具體實現(xiàn),編譯流水線這一小節(jié)再來詳細介紹。
至此,所有核心配置都生成完畢。不過,后面 Vite 還會處理一些邊界情況,在用戶配置不合理的時候,給用戶對應的提示。比如:用戶直接使用alias時,Vite 會提示使用resolve.alias。
最后,resolveConfig 函數(shù)會返回 resolved 對象,也就是最后的配置集合,那么配置解析服務到底也就結束了。
二、加載配置文件詳解
首先,我們來看一下加載配置文件(loadConfigFromFile)的實現(xiàn):
const loadResult = await loadConfigFromFile(/*省略傳參*/)
這里的邏輯稍微有點復雜,很難梳理清楚,所以我們不妨借助剛才梳理的配置解析流程,深入loadConfigFromFile 的細節(jié)中,研究下 Vite 對于配置文件加載的實現(xiàn)思路。
接下來,我們來分析下需要處理的配置文件類型,根據(jù)文件后綴和模塊格式可以分為下面這幾類:
- TS + ESM 格式
- TS + CommonJS 格式
- JS + ESM 格式
- JS + CommonJS 格式
2.1 識別配置文件的類別
首先,Vite 會檢查項目的 package.json文件,如果有type: "module"則打上 isESM 的標識:
try { const pkg = lookupFile(configRoot, ['package.json']) if (pkg && JSON.parse(pkg).type === 'module') { isMjs = true } } catch (e) { }
然后,Vite 會尋找配置文件路徑,代碼簡化后如下:
let isTS = false let isESM = false let dependencies: string[] = [] // 如果命令行有指定配置文件路徑 if (configFile) { resolvedPath = path.resolve(configFile) // 根據(jù)后綴判斷是否為 ts 或者 esm,打上 flag isTS = configFile.endsWith('.ts') if (configFile.endsWith('.mjs')) { isESM = true } } else { // 從項目根目錄尋找配置文件路徑,尋找順序: // - vite.config.js // - vite.config.mjs // - vite.config.ts // - vite.config.cjs const jsconfigFile = path.resolve(configRoot, 'vite.config.js') if (fs.existsSync(jsconfigFile)) { resolvedPath = jsconfigFile } if (!resolvedPath) { const mjsconfigFile = path.resolve(configRoot, 'vite.config.mjs') if (fs.existsSync(mjsconfigFile)) { resolvedPath = mjsconfigFile isESM = true } } if (!resolvedPath) { const tsconfigFile = path.resolve(configRoot, 'vite.config.ts') if (fs.existsSync(tsconfigFile)) { resolvedPath = tsconfigFile isTS = true } } if (!resolvedPath) { const cjsConfigFile = path.resolve(configRoot, 'vite.config.cjs') if (fs.existsSync(cjsConfigFile)) { resolvedPath = cjsConfigFile isESM = false } } }
在尋找路徑的同時, Vite 也會給當前配置文件打上isESM和isTS的標識,方便后續(xù)的解析。
2.2 根據(jù)類別解析配置
2.2.1 ESM 格式
對于 ESM 格式配置的處理代碼如下:
let userConfig: UserConfigExport | undefined if (isESM) { const fileUrl = require('url').pathToFileURL(resolvedPath) // 首先對代碼進行打包 const bundled = await bundleConfigFile(resolvedPath, true) dependencies = bundled.dependencies // TS + ESM if (isTS) { fs.writeFileSync(resolvedPath + '.js', bundled.code) userConfig = (await dynamicImport(`${fileUrl}.js?t=${Date.now()}`)) .default fs.unlinkSync(resolvedPath + '.js') debug(`TS + native esm config loaded in ${getTime()}`, fileUrl) } // JS + ESM else { userConfig = (await dynamicImport(`${fileUrl}?t=${Date.now()}`)).default debug(`native esm config loaded in ${getTime()}`, fileUrl) } }
可以看到,首先通過 Esbuild 將配置文件編譯打包成 js 代碼:
const bundled = await bundleConfigFile(resolvedPath, true) // 記錄依賴 dependencies = bundled.dependencies
對于 TS 配置文件來說,Vite 會將編譯后的 js 代碼寫入臨時文件,通過 Node 原生 ESM Import 來讀取這個臨時的內容,以獲取到配置內容,再直接刪掉臨時文件:
fs.writeFileSync(resolvedPath + '.js', bundled.code) userConfig = (await dynamicImport(`${fileUrl}.js?t=${Date.now()}`)).default fs.unlinkSync(resolvedPath + '.js')
以上這種先編譯配置文件,再將產(chǎn)物寫入臨時目錄,最后加載臨時目錄產(chǎn)物的做法,也是 AOT (Ahead Of Time)編譯技術的一種具體實現(xiàn)。
而對于 JS 配置文件來說,Vite 會直接通過 Node 原生 ESM Import 來讀取,也是使用 dynamicImport 函數(shù)的邏輯,dynamicImport 的實現(xiàn)如下:
export const dynamicImport = new Function('file', 'return import(file)')
你可能會問,為什么要用 new Function 包裹?這是為了避免打包工具處理這段代碼,比如 Rollup 和 TSC,類似的手段還有 eval。你可能還會問,為什么 import 路徑結果要加上時間戳 query?這其實是為了讓 dev server 重啟后仍然讀取最新的配置,避免緩存。
2.2.2 CommonJS 格式
對于 CommonJS 格式的配置文件,Vite 集中進行了解析:
// 對于 js/ts 均生效 // 使用 esbuild 將配置文件編譯成 commonjs 格式的 bundle 文件 const bundled = await bundleConfigFile(resolvedPath) dependencies = bundled.dependencies // 加載編譯后的 bundle 代碼 userConfig = await loadConfigFromBundledFile(resolvedPath, bundled.code)
bundleConfigFile函數(shù)的主要功能是通過 Esbuild 將配置文件打包,拿到打包后的 bundle 代碼以及配置文件的依賴(dependencies)。而接下來的事情就是考慮如何加載 bundle 代碼了,這也是loadConfigFromBundledFile 要做的事情。
async function loadConfigFromBundledFile( fileName: string, bundledCode: string ): Promise<UserConfig> { const extension = path.extname(fileName) const defaultLoader = require.extensions[extension]! require.extensions[extension] = (module: NodeModule, filename: string) => { if (filename === fileName) { ;(module as NodeModuleWithCompile)._compile(bundledCode, filename) } else { defaultLoader(module, filename) } } // 清除 require 緩存 delete require.cache[require.resolve(fileName)] const raw = require(fileName) const config = raw.__esModule ? raw.default : raw require.extensions[extension] = defaultLoader return config }
loadConfigFromBundledFile大體完成的是通過攔截原生 require.extensions 的加載函數(shù)來實現(xiàn)對 bundle 后配置代碼的加載,代碼如下:
// 默認加載器 const defaultLoader = require.extensions[extension]! // 攔截原生 require 對于`.js`或者`.ts`的加載 require.extensions[extension] = (module: NodeModule, filename: string) => { // 針對 vite 配置文件的加載特殊處理 if (filename === fileName) { ;(module as NodeModuleWithCompile)._compile(bundledCode, filename) } else { defaultLoader(module, filename) } }
而原生 require 對于 js 文件的加載代碼如下所示。
Module._extensions['.js'] = function (module, filename) { var content = fs.readFileSync(filename, 'utf8') module._compile(stripBOM(content), filename) }
事實上,Node.js 內部也是先讀取文件內容,然后編譯該模塊。當代碼中調用module._compile 相當于手動編譯一個模塊,該方法在 Node 內部的實現(xiàn)如下:
Module.prototype._compile = function (content, filename) { var self = this var args = [self.exports, require, self, filename, dirname] return compiledWrapper.apply(self.exports, args) }
在調用完 module._compile 編譯完配置代碼后,進行一次手動的 require,即可拿到配置對象:
const raw = require(fileName) const config = raw.__esModule ? raw.default : raw // 恢復原生的加載方法 require.extensions[extension] = defaultLoader // 返回配置 return config
這種運行時加載 TS 配置的方式,也叫做 JIT(即時編譯),這種方式和 AOT 最大的區(qū)別在于不會將內存中計算出來的 js 代碼寫入磁盤再加載,而是通過攔截 Node.js 原生 require.extension 方法實現(xiàn)即時加載。
至此,配置文件的內容已經(jīng)讀取完成,等后處理完成再返回即可:
// 處理是函數(shù)的情況 const config = await (typeof userConfig === 'function' ? userConfig(configEnv) : userConfig) if (!isObject(config)) { throw new Error(`config must export or return an object.`) } // 接下來返回最終的配置信息 return { path: normalizePath(resolvedPath), config, // esbuild 打包過程中搜集的依賴 dependencies }
三、總結
下面我們來總結一下Vite 配置解析的整體流程和加載配置文件的方法:
首先,Vite 配置文件解析的邏輯由 resolveConfig 函數(shù)統(tǒng)一實現(xiàn),其中經(jīng)歷了加載配置文件、解析用戶插件、加載環(huán)境變量、創(chuàng)建路徑解析器工廠和生成插件流水線這幾個主要的流程。
其次,在加載配置文件的過程中,Vite 需要處理四種類型的配置文件,其中對于 ESM 和 CommonJS 兩種格式的 TS 文件,分別采用了AOT和JIT兩種編譯技術實現(xiàn)了配置加載。
以上就是Vite配置文件如何加載深入剖析的詳細內容,更多關于Vite加載配置文件的資料請關注腳本之家其它相關文章!
相關文章
Vue3中結合ElementPlus實現(xiàn)彈窗的封裝方式
這篇文章主要介紹了Vue3中結合ElementPlus實現(xiàn)彈窗的封裝方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-01-01Vue3監(jiān)聽store中數(shù)據(jù)變化的三種方式
這篇文章給大家介紹了Vue3監(jiān)聽store中數(shù)據(jù)變化的三種方法,使用watch和storeToRefs函數(shù),使用計算屬性computed和使用watchEffect函數(shù)這三種方法,文中通過代碼講解非常詳細,需要的朋友可以參考下2024-01-01