theme: cyanosis
highlight: monokai
在之前幾篇文章中我們知道了Vite 的啟動過程。當我們執行
yarn run dev
之後,Vite 會初始化配置項、預建構、注冊中間件,并啟動一個伺服器。之後就不會再進行其他操作,直到我們通路
localhost:3000/
當我們通路
localhost:3000/
時,會通過中間件攔截檔案請求,并處理檔案,最終将處理後的檔案發送給用戶端。來看下具體流程。最後也會放一張流程圖
通路 localhost:3000/
觸發的中間件
localhost:3000/
當我們通路
localhost:3000/
時,會被如下幾個中間件攔截
typescript // main transform middlewaref middlewares.use(transformMiddleware(server)) // spa fallback if (!middlewareMode || middlewareMode === 'html') { middlewares.use(spaFallbackMiddleware(root)) } if (!middlewareMode || middlewareMode === 'html') { // transform index.html middlewares.use(indexHtmlMiddleware(server)) middlewares.use(function vite404Middleware(_, res) { res.statusCode = 404 res.end() }) }
之後會依次解釋下這三個中間件的實作原理
transformMiddleware
中間件
transformMiddleware
首先被
transformMiddleware
攔截,大體代碼如下 ```typescript const knownIgnoreList = new Set(['/', '/favicon.ico'])
export function transformMiddleware( server: ViteDevServer ): Connect.NextHandleFunction { // ...
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
return next()
}
// ...
}
} ```
由于
req.url
包含在
knownIgnoreList
内,是以直接跳過,進入
spaFallbackMiddleware
中間件
transformMiddleware
中間件的具體作用稍後會詳細說明
spaFallbackMiddleware
中間件
spaFallbackMiddleware
```typescript import history from 'connect-history-api-fallback'
export function spaFallbackMiddleware( root: string ): Connect.NextHandleFunction { const historySpaFallbackMiddleware = history({ // support /dir/ without explicit index.html rewrites: [ { from: /\/$/, to({ parsedUrl }: any) { // 如果比對,則重寫路由 const rewritten = parsedUrl.pathname + 'index.html' if (fs.existsSync(path.join(root, rewritten))) { return rewritten } else { return
/index.html
} } } ] })
return function viteSpaFallbackMiddleware(req, res, next) { return historySpaFallbackMiddleware(req, res, next) } } ``
先說一下
connect-history-api-fallback
這個包的作用,每當出現符合條件的請求時,它将把請求定位到指定的索引檔案。這裡就是
/index.html
,一般用于解決單頁面應用程式
(SPA)`重新整理或直接通過輸入位址的方式通路頁面時傳回404的問題。
但是這個中間件隻比對
/
,也就是說如果通路的是
localhost:3000/
,會被比對成
/index.html
繼續向下,進入
indexHtmlMiddleware
中間件
indexHtmlMiddleware
中間件,擷取 HTML
indexHtmlMiddleware
typescript export function indexHtmlMiddleware( server: ViteDevServer ): Connect.NextHandleFunction { return async function viteIndexHtmlMiddleware(req, res, next) { // 擷取url,/ 被 spaFallbackMiddleware 處理成了 /index.html // 是以這裡的 url 就是 /index.html const url = req.url && cleanUrl(req.url) // spa-fallback always redirects to /index.html if (url?.endsWith('.html') && req.headers['sec-fetch-dest'] !== 'script') { // 根據 config.root 擷取 html 檔案的絕對路徑 const filename = getHtmlFilename(url, server) if (fs.existsSync(filename)) { try { // 擷取 html 檔案内容 let html = fs.readFileSync(filename, 'utf-8') html = await server.transformIndexHtml( url, html, req.originalUrl ) return send(req, res, html, 'html') } catch (e) { return next(e) } } } next() } }
indexHtmlMiddleware
這個中間件的作用就是處理
html
檔案,首先擷取
html
檔案的絕對路徑,根據絕對路徑擷取
html
字元串。
擷取的内容如下
html <!DOCTYPE html> <html > <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.ico" target="_blank" rel="external nofollow" target="_blank" rel="external nofollow" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite App</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>
轉換 HTML
接下來調用
server.transformIndexHtml
函數轉換 HTML,最後傳回給用戶端
typescript html = await server.transformIndexHtml( url, html, req.originalUrl ) return send(req, res, html, 'html')
在啟動服務的時候指定了
server.transformIndexHtml
,在
createServer
函數内
typescript server.transformIndexHtml = createDevHtmlTransformFn(server)
createDevHtmlTransformFn
函數定義如下
```typescript export function createDevHtmlTransformFn( server: ViteDevServer ): (url: string, html: string, originalUrl: string) => Promise { // 周遊所有 plugin,擷取 plugin.transformIndexHtml // 如果 plugin.transformIndexHtml 是一個函數,添加到 postHooks中 // 如果 plugin.transformIndexHtml 是一個對象并且 transformIndexHtml.enforce === 'pre',添加到 preHooks 中 // 如果 plugin.transformIndexHtml 是一個對象并且 transformIndexHtml.enforce !== 'pre',添加到 postHooks 中 const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins)
return (url: string, html: string, originalUrl: string): Promise<string> => {/* ... */}
} ``
createDevHtmlTransformFn
函數周遊所有插件,擷取插件中定義的
transformIndexHtml
,并根據規則劃分為
postHooks
和
preHooks`,并傳回一個匿名函數。
這個匿名函數就是
server.transformIndexHtml
的值,看下函數定義
typescript (url: string, html: string, originalUrl: string): Promise<string> => { return applyHtmlTransforms(html, [...preHooks, devHtmlHook, ...postHooks], { path: url, filename: getHtmlFilename(url, server), server, originalUrl }) }
函數内部調用
applyHtmlTransforms
,并傳入
html
、
preHooks
、
devHtmlHook
、
postHooks
和一些配置資訊
```typescript export async function applyHtmlTransforms( html: string, hooks: IndexHtmlTransformHook[], ctx: IndexHtmlTransformContext ): Promise { const headTags: HtmlTagDescriptor[] = [] const headPrependTags: HtmlTagDescriptor[] = [] const bodyTags: HtmlTagDescriptor[] = [] const bodyPrependTags: HtmlTagDescriptor[] = []
for (const hook of hooks) {
const res = await hook(html, ctx)
if (!res) {
continue
}
if (typeof res === 'string') {
html = res
} else {
let tags: HtmlTagDescriptor[]
if (Array.isArray(res)) {
tags = res
} else {
html = res.html || html
tags = res.tags
}
for (const tag of tags) {
if (tag.injectTo === 'body') {
bodyTags.push(tag)
} else if (tag.injectTo === 'body-prepend') {
bodyPrependTags.push(tag)
} else if (tag.injectTo === 'head') {
headTags.push(tag)
} else {
headPrependTags.push(tag)
}
}
}
}
// inject tags
if (headPrependTags.length) {
html = injectToHead(html, headPrependTags, true)
}
if (headTags.length) {
html = injectToHead(html, headTags)
}
if (bodyPrependTags.length) {
html = injectToBody(html, bodyPrependTags, true)
}
if (bodyTags.length) {
html = injectToBody(html, bodyTags)
}
return html
} ``
applyHtmlTransforms
就是按順序調用傳入的函數,如果傳入的函數傳回值中有
tags
屬性,是一個數組;周遊這個數組,根據
injectTo
屬性分類,将這些
tag
分别加入
bodyTags
、
bodyPrependTags
、
headTags
和
headPrependTags
中。所有函數執行完後,再調用
injectToHead
、
injectToBody
插入
html
中,最後傳回轉換後的
html`
傳入的
plugin.transformIndexHtml
函數中,就包含 Vite 内部的一個函數
devHtmlHook
,看下定義 ```typescript const devHtmlHook: IndexHtmlTransformHook = async ( html, { path: htmlPath, server, originalUrl } ) => { const config = server?.config! const base = config.base || '/'
const s = new MagicString(html)
let scriptModuleIndex = -1
const filePath = cleanUrl(htmlPath)
await traverseHtml(html, htmlPath, (node) => {})
html = s.toString()
return {}
}
将傳入的`html`交給`traverseHtml`處理
typescript export async function traverseHtml( html: string, filePath: string, visitor: NodeTransform ): Promise { const { parse, transform } = await import('@vue/compiler-dom') // @vue/compiler-core doesn't like lowercase doctypes html = html.replace(/s/i, ') try { const ast = parse(html, { comments: true }) transform(ast, { nodeTransforms: [visitor], }) } catch (e) {} } ``
通過
@vue/compiler-dom
的
parse
方法将
html
轉換成 AST,然後調用
transform
方法對每層 AST 調用傳入的
visitor
,這個
visitor
(通路器)就是上面
devHtmlHook
傳給
traverseHtml`函數的回調。也就是說每通路一層 AST 就會執行一次這個回調。
看下回調代碼 ```typescript export const assetAttrsConfig: Record
// elements with [href/src] attrs
const assetAttrs = assetAttrsConfig[node.tag]
if (assetAttrs) {}
}) ``
通路器隻處理如下标簽,這些标簽都可以引入檔案 -
script
-
link
-
video
-
source
-
img
-
image
-
use`
先看下怎麼處理
script
标簽
typescript // 處理 script 标簽 if (node.tag === 'script') { // 擷取 src 屬性 // isModule:是一個行内 js,并且有 type='module' 屬性,則為 true const { src, isModule } = getScriptInfo(node) if (isModule) { scriptModuleIndex++ } if (src) { processNodeUrl(src, s, config, htmlPath, originalUrl) } else if (isModule) {} // 處理 type==='module' 的 行内js }
這塊代碼主要是處理行内js和引入js檔案的
script
标簽
對于引入js檔案的
script
标簽,就是調用
processNodeUrl
函數重寫
src
屬性的路徑 - 如果以
/
或者
\
開頭重寫成
config.base + 路徑.slice(1)
- 如果是相對路徑以
.
開頭、
originalUrl
(原始請求的 url) 不是
/
(比如
/a/b
)并且HTML檔案路徑是
/index.html
,則需要将路徑重寫,改成相對于
/
的路徑;這樣做的目的是如果不重寫,最後請求的檔案路徑是
localhost:3000/a/index.js
,會導緻伺服器傳回404
對于其他标簽的處理如下
typescript const assetAttrs = assetAttrsConfig[node.tag] if (assetAttrs) { for (const p of node.props) { if ( p.type === NodeTypes.ATTRIBUTE && p.value && assetAttrs.includes(p.name) ) { processNodeUrl(p, s, config, htmlPath, originalUrl) } } }
周遊目前标簽的所有屬性,如果
type
(屬性類型)為 6,并且屬性名包含在
assetAttrs
中,則調用
processNodeUrl
處理路徑。
當所有AST周遊完成之後,回到
devHtmlHook
中
typescript const devHtmlHook: IndexHtmlTransformHook = async ( html, { path: htmlPath, server, originalUrl } ) => { // ... await traverseHtml(html, htmlPath, (node) => {}) // 擷取最新的 html 字元串 html = s.toString() // 最後傳回 html 和 tags return { html, tags: [ { tag: 'script', attrs: { type: 'module', src: path.posix.join(base, CLIENT_PUBLIC_PATH), }, injectTo: 'head-prepend', }, ], } }
最後傳回
html
和
tags
,這個
tags
會将下面的代碼插入到
head
标簽頭部 ```html
最終`indexHtmlMiddleware`中間件向用戶端發送轉換後的`html`
html
rel="icon" href="/favicon.ico" target="_blank" rel="external nofollow" target="_blank" rel="external nofollow" > Vite App
小結
當浏覽器收到
localhost:3000/
這個請求時,會通過
spaFallbackMiddleware
中間件将其轉換成
/index.html
,然後又被
indexHtmlMiddleware
中間件攔截,執行所有插件中的
transformIndexHtml
鈎子函數和
devHtmlHook
方法去修改發送給用戶端的HTML内容。其中
devHtmlHook
會将HTML轉換成AST;處理引入的檔案路徑和行内js;還會将用戶端接受熱更新的代碼注入。
傳回 HTML 後發生了什麼
上面分析了Vite時怎麼将
/
請求轉換成HTML并傳回給用戶端的。當用戶端接收到HTML後,加載這個HTML,并請求HTML引入的js(
/@vite/client
、
/src/main.ts
)
此時會被
transformMiddleware
中間件攔截
transformMiddleware
中間件
transformMiddleware
transformMiddleware
中間件實作邏輯比較長,一步一步的看,先以
/src/main.ts
為例子 ```typescript return async function viteTransformMiddleware(req, res, next) { if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) { return next() } // ...
// 将 url 的 t=xxx 去掉,并将 url 中的 __x00__ 替換成 \0
// url = /src/main.ts
let url = decodeURI(removeTimestampQuery(req.url!)).replace(
NULL_BYTE_PLACEHOLDER,
'\0'
)
// 去掉 hash 和 query
// withoutQuery = /src/main.ts
const withoutQuery = cleanUrl(url)
try {
// .map 檔案相關
const isSourceMap = withoutQuery.endsWith('.map')
if (isSourceMap) {}
// 檢查公共目錄是否在根目錄内
// ...
if (
isJSRequest(url) || // 加載的是 js 檔案
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)
) {/* ... */}
} catch (e) {
return next(e)
}
next()
} ``
首先是處理URL;然後判斷檔案類型,該中間件将處理下面這4種類型 - js檔案,包括
沒有字尾的檔案、jsx、tsx、mjs、js、ts、vue
等 - css檔案,包括
css、less、sass、scss、styl、stylus、pcss、postcss
- url上挂有
import
參數的,Vite會對圖檔、JSON、用戶端熱更新時請求的檔案等挂上
import
參數 - url 比對
/\?html-proxy&index=(\d+).js$/`的
處理邏輯如下 ```typescript if ( isJSRequest(url) || // 加載的是 js 檔案 isImportRequest(url) || isCSSRequest(url) || isHTMLProxy(url) ) { // 删除 [?|&]import url = removeImportQuery(url) // 如果 url 以 /@id/ 開頭,則去掉 /@id/ url = unwrapId(url)
// ...
// 擷取請求頭中的 if-none-match 值
const ifNoneMatch = req.headers['if-none-match']
// 從建立的 ModuleNode 對象中根據 url 擷取 etag 并和 ifNoneMatch 比較
// 如果相同傳回 304
if (
ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url))?.transformResult
?.etag === ifNoneMatch
) {
res.statusCode = 304
return res.end()
}
// 依次調用所有插件的 resolve、load 和 transform 鈎子函數
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html'),
})
if (result) {
const type = isDirectCSSRequest(url) ? 'css' : 'js'
// true:url 上有 v=xxx 參數的,或者是以 cacheDirPrefix 開頭的url
const isDep =
DEP_VERSION_RE.test(url) ||
(cacheDirPrefix && url.startsWith(cacheDirPrefix))
return send(
req,
res,
result.code,
type,
result.etag,
// 對預構模組化塊添加強緩存
isDep ? 'max-age=31536000,immutable' : 'no-cache',
result.map
)
}
} ```
如果檔案類型符合上面幾種,先判斷能否使用對比緩存傳回304。如果不能使用緩存,則通過
transformRequest
方法擷取檔案源碼。然後設定緩存。對于 url 上有
v=xxx
參數的,或者是以緩存目錄(比如
_vite
)開頭的 url,設定強制緩存,即
Cache-Control: max-age=31536000
;反之設定對比緩存,即每次請求都要到伺服器驗證。
看下
transformRequest
函數的作用
typescript export function transformRequest( url: string, server: ViteDevServer, options: TransformOptions = {} ): Promise<TransformResult | null> { // 是否正在請求 const pending = server._pendingRequests[url] if (pending) { debugTransform( `[reuse pending] for ${prettifyUrl(url, server.config.root)}` ) return pending } // doTransform 傳回一個 Promise 對象 const result = doTransform(url, server, options) // 防止多次請求 server._pendingRequests[url] = result const onDone = () => { server._pendingRequests[url] = null } // 設定回調 result.then(onDone, onDone) return result }
做了一層保障,防止正在請求的檔案再次請求。調用
doTransform
擷取
result
```typescript async function doTransform( url: string, server: ViteDevServer, options: TransformOptions ) { url = removeTimestampQuery(url) const { config, pluginContainer, moduleGraph, watcher } = server const { root, logger } = config const prettyUrl = isDebug ? prettifyUrl(url, root) : '' const ssr = !!options.ssr // 擷取目前檔案對應的 ModuleNode 對象 const module = await server.moduleGraph.getModuleByUrl(url)
// 擷取目前檔案轉換後的代碼,如果有則傳回
const cached =
module && (ssr ? module.ssrTransformResult : module.transformResult)
if (cached) {
return cached
}
// 調用所有插件的 resolveId鈎子函數,擷取請求檔案在項目中的絕對路徑
// /xxx/yyy/zzz/src/main.ts
const id = (await pluginContainer.resolveId(url))?.id || url
// 去掉 id 中的 query 和 hash
const file = cleanUrl(id)
let code: string | null = null
let map: SourceDescription['map'] = null
// 調用所有插件的 load 鈎子函數,如果所有插件的 load 鈎子函數都沒有處理過該檔案,則傳回 null
const loadResult = await pluginContainer.load(id, ssr)
if (loadResult == null) {
// ...
if (options.ssr || isFileServingAllowed(file, server)) {
try {
// 讀取檔案中的代碼
code = await fs.readFile(file, 'utf-8')
} catch (e) {}
}
} else {
// 擷取 code 和 map
if (isObject(loadResult)) {
code = loadResult.code
map = loadResult.map
} else {
code = loadResult
}
}
// ...
// 建立/擷取目前檔案的 ModuleNode 對象
const mod = await moduleGraph.ensureEntryFromUrl(url)
// 如果該檔案的位置不在項目根路徑以内,則添加監聽
ensureWatchedFile(watcher, mod.file, root)
// transform
const transformResult = await pluginContainer.transform(code, id, map, ssr)
if (
transformResult == null ||
(isObject(transformResult) && transformResult.code == null)
) {
// ...
} else {
code = transformResult.code!
map = transformResult.map
}
if (ssr) {
// ...
} else {
return (mod.transformResult = {
code,
map,
etag: getEtag(code, { weak: true }),
} as TransformResult)
}
} ``
doTransform
函數會調用所有插件的
resolveId
鈎子函數擷取檔案的絕對路徑,然後建立/擷取該檔案的
ModuleNode
對象,并調用所有插件的
load
鈎子函數,如果某個鈎子函數有傳回值,則這個傳回值就是該檔案的源碼;如果沒有傳回值就根據絕對路徑讀取檔案内容,最後調用所有插件的
transform
鈎子函數轉換源碼。這個過程中會調用一個很重要的插件
importAnalysis`這個插件的作用主要作用是為該檔案建立子產品對象、設定子產品之間的引用關系、解析代碼中的導入路徑;還會處理熱更新相關邏輯,下一篇就會分析這個插件的具體實作邏輯。
最後,
doTransform
函數傳回轉換後的代碼、map資訊和 etag值。
總結
當我們通路
localhost:3000/
時,會被中間件指向
/index.html
,并向
/index.html
中注入熱更新相關的代碼。最後傳回這個HTML。當浏覽器加載這個HTML時,通過原生ESM的方式請求js檔案;會被
transformMiddleware
中間件攔截,這個中間件做的事就是将這個被請求檔案轉換成浏覽器支援的檔案;并會為該檔案建立子產品對象、設定子產品之前的引用關系。
這也是 Vite 冷啟動快的原因之一,Vite在啟動過程中不會編譯源碼,隻會對依賴進行預建構。當我們通路某個檔案時,會攔截并通過 ESbuild 将資源編譯成浏覽器能夠識别的檔案類型最後傳回給浏覽器。
而且這期間還會設定對比緩存和強制緩存,并緩存編譯過的檔案代碼。