天天看點

Vite 源碼(五)浏覽器通路 `localhost:3000/` 時,Vite做了什麼

theme: cyanosis

highlight: monokai

在之前幾篇文章中我們知道了Vite 的啟動過程。當我們執行

yarn run dev

之後,Vite 會初始化配置項、預建構、注冊中間件,并啟動一個伺服器。之後就不會再進行其他操作,直到我們通路

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

攔截,大體代碼如下 ```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

中間件

```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

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

)

Vite 源碼(五)浏覽器通路 `localhost:3000/` 時,Vite做了什麼

此時會被

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 将資源編譯成浏覽器能夠識别的檔案類型最後傳回給浏覽器。

而且這期間還會設定對比緩存和強制緩存,并緩存編譯過的檔案代碼。

流程圖

Vite 源碼(五)浏覽器通路 `localhost:3000/` 時,Vite做了什麼