大家好,我是小雨小雨,致力於分享有趣的、實用的文章。
內容分為原創和翻譯,如果有問題,歡迎隨時評論或私信,很樂意和大家一起探討,一起進步。
分享不易,希望能夠得到大家的支援和關注。
vite出了好久了,也出了好多相關文章,我也想出,然後我就寫了。?
該文件對應的vite版本:2.0.0-beta.4
vite文件
整體流程
筆者認為,vite是站在巨人肩膀上的一個創新型dev構建工具,分別繼承於:
其中洋蔥模型如果將next()放到函式最底部的話,和rollup的外掛驅動是類似的。
也就是說可插拔架構是vite的整體思想,不僅可以編寫內部外掛,將內部外掛原子化,還可以藉助npm上各種已有的外掛。非常靈活。
為什麼採用es module呢?
vite採用的es module進行模組匯入,這是現代瀏覽器原生支援的,當import一個模組時,會發出一個請求,正因如此,只能在服務中使用es module。而且import的模組路徑發生變化的時候,會重新傳送請求,路徑變化包括query。
下面我們進入整體
vite採用monorepo架構,我們要關心的程式碼主要兩部分:
先從vite cli說起。
這裡是vite的入口:
const { createServer } = await import('./server')
try {
const server = await createServer(
{
root,
mode: options.mode,
logLevel: options.logLevel,
server: cleanOptions(options) as ServerOptions
},
options.config
)
await server.listen()
簡單粗暴,通過createServer建立一個服務,然後開始監聽,我們直接一個瞎子摸葫蘆,開啟createServer看看。
export async function createServer(
inlineConfig: UserConfig & { mode?: string } = {},
configPath?: string | false
): Promise<ViteDevServer> {
// 程式碼太多不放了,放點方便看的,有興趣的話可以開啟程式碼一邊看這裡的註釋一邊看程式碼
// 配置相關,比如記載本地配置檔案、整合外掛,環境變數等等
// 利用connect初始化服務,connect是一個使用中介軟體為node提供可擴充套件服務的http框架,有興趣可以去看看
// 建立webSocket服務
// 利用chokidar進行檔案監聽
// vite繼承rollup實現了一個迷你版的構解析構建工具
// 建立一個圖來維護模組之間的關係
// 當檔案發生變化的時候進行hmr相關操作,後續會介紹
// 接入各種各樣的中介軟體,比如介面代理的、靜態服務的、解析請求資源的、重定向、處理html的等,其中最重要的就是解析請求資源的了,下面具體來扣一下這塊
// 呼叫外掛中的configureServer,這一步可以將vite中所有內容暴露給使用者,比如node服務app,配置,檔案監聽器,socket等等,很大膽,很壞,但是我好喜歡
// 返回node服務,供listen
}
執行完這一堆後,我們就啟動了一個服務,我們發現,vite到目前為止,並沒有任何關於打包的程式碼,那他快在哪裡呢?
其實沒有打包就是vite快的原因之一,而他的打包做到了真正的按需。
啟動服務後,我們訪問頁面會傳送一個個的請求,這些請求會經過中介軟體處理,而中介軟體,就會進行打包,注入等相關操作。
核心內容其實就是上面註釋中寫的解析請求資源
這個中介軟體,vite中叫做transformMiddleware
export function transformMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
const {
config: { root, logger },
moduleGraph
} = server
return async (req, res, next) => {
// 其他程式碼
// Only apply the transform pipeline to:
// - requests that initiate from ESM imports (any extension)
// - CSS (even not from ESM)
// - Source maps (only for resolving)
if (
isJSRequest(url) || // 指定的(j|t)sx?|mjs|vue這類檔案,或者沒有字尾
isImportRequest(url) || // import來的
isCSSRequest(url) || // css
isHTMLProxy(url) || // html-proxy
server.config.transformInclude(withoutQuery) // 命中需要解析的
) {
// 移除import的query,例: (\?|$)import=xxxx
url = removeImportQuery(url)
// 刪調idprefix,importAnalysis生成的不合法的瀏覽器說明符被預先解析id
if (url.startsWith(VALID_ID_PREFIX)) {
url = url.slice(VALID_ID_PREFIX.length)
}
// for CSS, we need to differentiate between normal CSS requests and
// imports
// 處理css連結
if (isCSSRequest(url) && req.headers.accept?.includes('text/css')) {
url = injectQuery(url, 'direct')
}
// check if we can return 304 early
const ifNoneMatch = req.headers['if-none-match']
// 命中瀏覽器快取,利用瀏覽器的特性
if (
ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url))?.transformResult?.etag ===
ifNoneMatch
) {
res.statusCode = 304
return res.end()
}
// 解析vue js css 等檔案的關鍵
const result = await transformRequest(url, server)
if (result) {
const type = isDirectCSSRequest(url) ? 'css' : 'js'
const isDep =
DEP_VERSION_RE.test(url) ||
url.includes(`node_modules/${DEP_CACHE_DIR}`)
return send(
req,
res,
result.code,
type,
result.etag,
// allow browser to cache npm deps!
isDep ? 'max-age=31536000,immutable' : 'no-cache',
result.map
)
}
}
} catch (e) {
return next(e)
}
next()
}
}
其中最重要的是transformRequest,該方法進行了快取,請求資源解析,載入,轉換操作。
export async function transformRequest(
url: string,
{ config: { root }, pluginContainer, moduleGraph, watcher }: ViteDevServer
): Promise<TransformResult | null> {
url = removeTimestampQuery(url)
const prettyUrl = isDebug ? prettifyUrl(url, root) : ''
// 檢查上一次的transformResult,這個東西會在hmr中被主動移除掉
const cached = (await moduleGraph.getModuleByUrl(url))?.transformResult
if (cached) {
isDebug && debugCache(`[memory] ${prettyUrl}`)
return cached
}
// resolve
const id = (await pluginContainer.resolveId(url))?.id || url
const file = cleanUrl(id)
let code = null
let map: SourceDescription['map'] = null
// load
const loadStart = Date.now()
const loadResult = await pluginContainer.load(id)
// 載入失敗,直接讀檔案
if (loadResult == null) {
// try fallback loading it from fs as string
// if the file is a binary, there should be a plugin that already loaded it
// as string
try {
code = await fs.readFile(file, 'utf-8')
isDebug && debugLoad(`${timeFrom(loadStart)} [fs] ${prettyUrl}`)
} catch (e) {
if (e.code !== 'ENOENT') {
throw e
}
}
if (code) {
map = (
convertSourceMap.fromSource(code) ||
convertSourceMap.fromMapFileSource(code, path.dirname(file))
)?.toObject()
}
} else {
isDebug && debugLoad(`${timeFrom(loadStart)} [plugin] ${prettyUrl}`)
if (typeof loadResult === 'object') {
code = loadResult.code
map = loadResult.map
} else {
code = loadResult
}
}
if (code == null) {
throw new Error(`Failed to load url ${url}. Does the file exist?`)
}
// 將當前處理請求地址新增到維護的圖中
const mod = await moduleGraph.ensureEntryFromUrl(url)
// 監聽
if (mod.file && !mod.file.startsWith(root + '/')) {
watcher.add(mod.file)
}
// transform
const transformStart = Date.now()
// 所有的外掛都被閉包儲存了,然後呼叫pluginContainer上的某個鉤子函式,該函式會loop外掛進行具體操作
const transformResult = await pluginContainer.transform(code, id, map)
if (
transformResult == null ||
(typeof transformResult === 'object' && transformResult.code == null)
) {
// no transform applied, keep code as-is
isDebug &&
debugTransform(
timeFrom(transformStart) + chalk.dim(` [skipped] ${prettyUrl}`)
)
} else {
isDebug && debugTransform(`${timeFrom(transformStart)} ${prettyUrl}`)
if (typeof transformResult === 'object') {
code = transformResult.code!
map = transformResult.map
} else {
code = transformResult
}
}
// 返回並快取當前轉換結果
return (mod.transformResult = {
code,
map,
etag: getEtag(code, { weak: true })
} as TransformResult)
}
主要涉及外掛提供的三個鉤子函式:
- pluginContainer.resolveId
- pluginContainer.load
- pluginContainer.transform
resolveId
和load
將請求的url解析成對應檔案中的內容供transform使用
transform
會呼叫外掛提供的transform方法對不同檔案程式碼進行轉換操作,比如vite提供的plugin-vue
,就對vue進行了轉換,提供的plugin-vue-jsx,就對jsx寫法進行了支援。如果要支援其他框架語言,也可以自行新增。
到這裡,vite的大致流程就結束了。
可能光看程式碼不是很直觀,這邊提供一個簡單的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
// main.js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
// app.vue
<template>
<div>hello world</div>
</template>
瀏覽器中看到的app.vue中的內容是這樣的:
除了render相關函式,還有createHotContext、import.meta.hot.accept這類內容,這是和hmr相關的,下面會講到。
hmr
hmr在我們的開發工程中也提到舉足輕重的作用,那vite是怎麼做的呢?
涉及部分:
-
client提供hmr上下文環境,其中包含當前檔案對應的更新方法,ws通知時會呼叫
-
importsAnalysisimport模組的時候對模組進行圖依賴更新、拼接等操作,比如針對hmr模組注入client中提供的hmr api
-
plugin-vue注入vue上下文環境,並且將client中的方法拼接到當前模組中
當我們import一個模組時,會傳送一個請求,當前請求在transformMiddleware
中介軟體處理的時候,當前請求url會被新增到圖中,然後被各種外掛的transform處理,其中就包括importsAnalysis
外掛,importsAnalysis
會通過es-module-lexer
解析import的export,將當前模組插入到模組圖中,並且將當前importe和被引入的importedModules建立依賴關係。
// importsAnalysis.ts
if (!isCSSRequest(importer)) {
const prunedImports = await moduleGraph.updateModuleInfo(
importerModule, // 當前解析的主體
importedUrls, // 被引入的檔案
normalizedAcceptedUrls,
isSelfAccepting
)
if (hasHMR && prunedImports) {
handlePrunedModules(prunedImports, server)
}
}
並且會為當前請求的檔案中加入hmr api。
// importsAnalysis.ts
if (hasHMR) {
// inject hot context
str().prepend(
`import { createHotContext } from "${CLIENT_PUBLIC_PATH}";` +
`import.meta.hot = createHotContext(${JSON.stringify(
importerModule.url
)});`
)
}
除了importsAnalysis
外掛外,還有plugin-vue
外掛的transform,插入的是re-render方法。
// /plugin-vue/src/main.ts
if (devServer && !isProduction) {
output.push(`_sfc_main.__hmrId = ${JSON.stringify(descriptor.id)}`)
output.push(
`__VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main)`
)
// check if the template is the only thing that changed
if (prevDescriptor && isOnlyTemplateChanged(prevDescriptor, descriptor)) {
output.push(`export const _rerender_only = true`)
}
output.push(
`import.meta.hot.accept(({ default: updated, _rerender_only }) => {`,
` if (_rerender_only) {`,
` __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)`,
` } else {`,
` __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)`,
` }`,
`})`
)
}
其中__VUE_HMR_RUNTIME__
為vue runtime暴露的,已經在main.js中引入過了,下面的import.meta.hot.accept
則是client暴露的方法,import.meta為es module當前模組的後設資料。
而client就是瀏覽器端hmr相關的邏輯了,也是上面外掛注入的方法的依賴。
// client.ts
function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
// hotModulesMap被閉包儲存了
// ownerPath是當importsAnalysis例項化hmr上下文的時候傳入的當前模組的id地址
const mod: HotModule = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: []
}
mod.callbacks.push({
deps,
fn: callback
})
hotModulesMap.set(ownerPath, mod)
}
// 通過importsAnalysis新增在檔案中
// plugin-vue外掛會使用該方法新增模組(mod),並且會新增一些vue相關的內容,比如:
// 新增vue render方法,以供hmr呼叫
const hot = {
// 呼叫的時候給callback增加重新整理方法
accept(deps: any, callback?: any) {
if (typeof deps === 'function' || !deps) {
// self-accept: hot.accept(() => {})
acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
} else if (typeof deps === 'string') {
// explicit deps
acceptDeps([deps], ([mod]) => callback && callback(mod))
} else if (Array.isArray(deps)) {
acceptDeps(deps, callback)
} else {
throw new Error(`invalid hot.accept() usage.`)
}
},
// ...
}
我們呼叫import.meta.hot.accept
的時候,比如傳入方法,那麼會以importer模組為key將更新方法新增到一個hotModulesMap
中。記錄當前待更新模組。
接下來,ws會在在檔案變化後傳送message到瀏覽器端。這一步會涉及判斷是否為自更新、(主要是根據accept方法主體內容判斷,具體邏輯可自行檢視)是否有importer等邏輯決定hmr型別。
我們以hmr型別為js-update為例子繼續往下說。
主要是兩個方法,一個是fetchUpdate,用來獲取即將更新的模組,import模組,返回一個呼叫re-render的方法,一個是queueUpdate,用於執行fetchUpdate返回的方法。
進入fetchUpdate後,會判斷是否更新的是當前模組,是的話新增當前模組到modulesToUpdate
,不是的話將依賴的子模組新增到待更新的記錄中modulesToUpdate
,之後過濾出之前收集的待更新的模組,迴圈進行import操作,但是會在import模組的路徑上加上當前時間戳,以強制觸發http請求,用引入的新模組替換之前的舊模組,最後返回plugin-vue
提供的re-render方法。
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
// 當前更新的模組
const mod = hotModulesMap.get(path)
if (!mod) {
return
}
const moduleMap = new Map()
// 自更新
const isSelfUpdate = path === acceptedPath
// make sure we only import each dep once
const modulesToUpdate = new Set<string>()
if (isSelfUpdate) {
// self update - only update self
modulesToUpdate.add(path)
} else {
// dep update
for (const { deps } of mod.callbacks) {
deps.forEach((dep) => {
if (acceptedPath === dep) {
modulesToUpdate.add(dep)
}
})
}
}
// determine the qualified callbacks before we re-import the modules
// 符合標準的更新函式才會留下來
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
return deps.some((dep) => modulesToUpdate.has(dep))
})
// 將modulesToUpdate變成對應模組的更新函式
await Promise.all(
Array.from(modulesToUpdate).map(async (dep) => {
const disposer = disposeMap.get(dep)
if (disposer) await disposer(dataMap.get(dep))
const [path, query] = dep.split(`?`)
try {
// 這裡又會發一個請求,然後新的模組就下來了,但是dom樹還沒變化,下載下來的檔案會有id,對應當前即將被更新的模組
const newMod = await import(
/* @vite-ignore */
path + `?t=${timestamp}${query ? `&${query}` : ''}`
)
moduleMap.set(dep, newMod)
} catch (e) {
warnFailedFetch(e, dep)
}
})
)
// 返回函式,函式內容是plugin-vue中的accept注入的,比如vue檔案就是vue的render更新方法
// 這裡會呼叫新檔案中的render方法,進而在瀏覽器端進行模組更新操作
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
fn(deps.map((dep) => moduleMap.get(dep)))
}
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
console.log(`[vite] hot updated: ${loggedPath}`)
}
}
fetchUpdate的結果會流向queueUpdate,queueUpdate將更新任務放到微任務中,自動收集一定時間內的渲染。
async function queueUpdate(p: Promise<(() => void) | undefined>) {
queued.push(p)
if (!pending) {
pending = true
await Promise.resolve()
pending = false
const loading = [...queued]
queued = []
;(await Promise.all(loading)).forEach((fn) => fn && fn())
}
}
vite簡版流程圖
總結
vite對es module的使用讓人驚豔,一下子解決了大專案build所有內容的痛點,而且與rollup完美集結合,任何rollup外掛都可以在vite中使用。
當然,vite的這種思想不是首例,很早之前snowpack利用es module也是名噪一時。
vite目前主要解決的是dev環境的問題,生產環境還是需要build才能使用,vite使用esbuild進行生產環境打包,esbuild使用go開發,原生到原生,感興趣的朋友可以去看一看,這裡就不班門弄斧了。
最後感謝大家的內心閱讀,如果覺得不錯,可以通過關注,點贊,轉發多多支援~
祝大家工作順利,節節高升