Vite Server 是如何處理頁(yè)面資源的?
我們知道,Vite 在開(kāi)發(fā)環(huán)境下,會(huì)打開(kāi)一個(gè) Dev Server 用于預(yù)覽開(kāi)發(fā)的頁(yè)面,那么這個(gè) Dev Server 到底做了什么呢?它是怎么做到將我們的代碼展示成頁(yè)面的,接下來(lái)我們就來(lái)一探究竟。
構(gòu)造項(xiàng)目
我們構(gòu)造一個(gè)最簡(jiǎn)單的項(xiàng)目,項(xiàng)目中沒(méi)有用到 npm 包、css 等功能,就只有一個(gè) index.html 和一個(gè) typescript 文件。
目的:剝離出復(fù)雜的內(nèi)容,用最簡(jiǎn)單的例子去說(shuō)明最核心的內(nèi)容
代碼放在該GitHub 倉(cāng)庫(kù)鏈接[1]
├─ index.html
├─ index.tsindex.html 代碼如下:
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
<div id="app"></div>
</body>
<script type="module" src="./index.ts"></script>
</html>index.ts 代碼如下:
const app = document.getElementById('app');
app!.innerHTML = 'helloworld';項(xiàng)目有了,接下來(lái)我們從用戶側(cè),看看 Vite Server 做了什么?
用戶側(cè)視覺(jué)
在項(xiàng)目目錄,運(yùn)行 vite 命令,我們會(huì)看到如下輸入:
vite v3.0.0-alpha.0 dev server running at:
> Local: http://localhost:5173/
> Network: use `--host` to expose
ready in 551ms.可以看到 vite 創(chuàng)建了一個(gè) dev server,用于訪問(wèn)頁(yè)面。
訪問(wèn)頁(yè)面,頁(yè)面展示出 helloworld,請(qǐng)求如下:

? 拉取
index.html? Vite 的熱更新相關(guān)腳本:
/@vite/client?
/client/env.mjs?
ws://localhost:5173/? 我們寫(xiě)的 ts 代碼:
/index.ts
為什么我們明明只寫(xiě)了
index.html和index.ts,但這里卻還會(huì)有其他的資源請(qǐng)求?
我們查看 index.html 的代碼:
<!DOCTYPE html>
<html lang="en">
<head>
+ <script type="module" src="/@vite/client"></script>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app"></div>
</body>
<script type="module" src="./index.ts"></script>
</html>這里可以看出,index.html 已經(jīng)被修改了,插入了一段名為 client 代碼,這段代碼其實(shí)是用于 Vite 熱更新的,它開(kāi)啟了一個(gè) websocket。client 還依賴了其他腳本,因此瀏覽器還會(huì)繼續(xù)發(fā)起請(qǐng)求,所以會(huì)看到有多個(gè)請(qǐng)求。
再看看 index.ts :
const app = document.getElementById("app");
- app!.innerHTML = 'helloworld';
+ app.innerHTML = "helloworld";
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkQ6L3RlbmNlbnQvYXBwL3doYXQtdml0ZS1kby9wYWNrYWdlcy9zaW1wbGUvaW5kZXgudHMiXSwic291cmNlc0NvbnRlbnQiOlsiY29uc3QgYXBwID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2FwcCcpO1xuYXBwIS5pbm5lckhUTUwgPSAnaGVsbG93b3JsZCc7XG4iXSwibWFwcGluZ3MiOiJBQUFBLE1BQU0sTUFBTSxTQUFTLGVBQWUsS0FBSztBQUN6QyxJQUFLLFlBQVk7IiwibmFtZXMiOltdfQ==index.ts 的代碼已經(jīng)被編譯成 js 了,并且拼接上了 sourcemap。
瀏覽器是不能運(yùn)行 ts 代碼的,為什么瀏覽器能運(yùn)行 index.ts?
其實(shí)瀏覽器要怎么處理一個(gè)請(qǐng)求,是看它的響應(yīng) Header 中的 Content-Type 的

index.ts,但 Content-Type 卻是 application/javascript,這就代表了,瀏覽器會(huì)將這段代碼,當(dāng)做 JavaScript 腳本去處理。這個(gè)與文件后綴是無(wú)關(guān)的,在我們實(shí)際開(kāi)發(fā)中,很多請(qǐng)求是 ts、tsx、vue,但無(wú)論什么后綴都是沒(méi)有關(guān)系的,它們的 Content-Type 都是 application/javascript,因此瀏覽器能夠正確的運(yùn)行處理。
到目前為止,用戶側(cè)所看到的 Vite Server 的行為,已經(jīng)明確了:
? 修改
index.html,在 head 標(biāo)簽中加入了 client 腳本。? 編譯
index.ts,并拼接上 sourcemap。? 連接 websocket
為了簡(jiǎn)單起見(jiàn),我們本篇文章不講述熱更新的內(nèi)容,如果感興趣,可以查看《Vite 熱更新的主要流程》[2],該文章同樣是用了最簡(jiǎn)單的例子,講述 Vite 熱更新的核心流程,建議閱讀。
Server 的中間件機(jī)制
我們從用戶側(cè)可以看出,Vite Server 對(duì)不同的請(qǐng)求的文件做了特殊的處理,然后進(jìn)行響應(yīng)返回給客戶端
那一個(gè) Server 要如何處理請(qǐng)求的呢?答案是,使用中間件
中間件機(jī)制
Vite 用 connect[3] 包來(lái)創(chuàng)建一個(gè) DevServer。其簡(jiǎn)單的用法如下:
var connect = require('connect');
var http = require('http');
var app = connect();
// 使用一個(gè)中間件
app.use(function(req, res){
res.end('Hello from Connect!\n');
});
// 創(chuàng)建 nodejs http server,并監(jiān)聽(tīng) 3000 端口
http.createServer(app).listen(3000);connect 的中間件機(jī)制,可以用如下圖表示:

當(dāng)一個(gè)請(qǐng)求發(fā)送到 server 時(shí),會(huì)經(jīng)過(guò)一個(gè)個(gè)的中間件,中間件本質(zhì)是一個(gè)回調(diào)函數(shù),每次請(qǐng)求都會(huì)執(zhí)行回調(diào)。
connect 的中間件機(jī)制有如下特點(diǎn):
? 每個(gè)中間件可以分別對(duì)請(qǐng)求進(jìn)行處理,并進(jìn)行響應(yīng)。
? 每個(gè)中間件可以只處理特定的事情,其他事情交給其他中間件處理
? 可以調(diào)用 next 函數(shù),將請(qǐng)求傳遞給下一個(gè)中間件。如果不調(diào)用,則之后的中間件都不會(huì)被執(zhí)行
由于 html 和 TS 文件的處理方式完全不同,因此要做成兩個(gè)不同的中間件。
?
html處理中間件? 代碼轉(zhuǎn)化中間件
html 處理中間件
中間件的部分代碼實(shí)現(xiàn)如下:
async function viteIndexHtmlMiddleware(req, res, next) {
// 去掉 url 中的 hash 和 query
const url = req.url && cleanUrl(req.url)
// 只處理 html 的請(qǐng)求,否則調(diào)用 next 傳遞請(qǐng)求給下個(gè)中間件
if (url?.endsWith('.html')) {
// 從 url 中獲取 html 文件路徑
const filename = getHtmlFilename(url, server)
if (fs.existsSync(filename)) {
try {
// 讀取文件,拿到 html 的代碼字符串
let html = fs.readFileSync(filename, 'utf-8')
// 轉(zhuǎn)換 html 代碼,返回轉(zhuǎn)換后的代碼字符串
html = await server.transformIndexHtml(url, html, req.originalUrl)
// 響應(yīng)請(qǐng)求
return send(req, res, html, 'html', {
headers: server.config.server.headers
})
} catch (e) {
return next(e)
}
}
}
next()
}該中間件只處理 html 請(qǐng)求。如果不是 html 請(qǐng)求,就直接調(diào)用 next,將請(qǐng)求交給后續(xù)的中間件處理了。
中間件核心流程就是:
? 讀取 html 文件
? 執(zhí)行 transform 轉(zhuǎn)換/修改內(nèi)容
? 響應(yīng)請(qǐng)求
我們從用戶側(cè)視覺(jué)中,也可以看出,transform 就是加上了讓的熱更新代碼,但要是認(rèn)為它只有這個(gè)作用,那就小看 Vite 啦!
Vite 有非常高的可擴(kuò)展性,加上熱更新代碼,只不過(guò)是 Vite 一個(gè)小小的內(nèi)部插件實(shí)現(xiàn)的功能。
我們來(lái)看看 Vite 的 transformIndexHtml[4] 插件鉤子,它可以對(duì) index.html 進(jìn)行修改,可以插入任何的內(nèi)容。
通過(guò)在 transformIndexHtml[5] 鉤子中,直接修改 html 代碼,或者設(shè)置 transformIndexHtml 鉤子的返回值的方式,對(duì) html 插入內(nèi)容。
根據(jù) hook 的返回值,做不同的處理,返回結(jié)果的類型如下:
type IndexHtmlTransformResult =
| string
| HtmlTagDescriptor[]
| {
html: string
tags: HtmlTagDescriptor[]
}
interface HtmlTagDescriptor {
tag: string
attrs?: Record<string, string>
children?: string | HtmlTagDescriptor[]
/**
* 默認(rèn): 'head-prepend'
*/
injectTo?: 'head' | 'body' | 'head-prepend' | 'body-prepend'
}可以看出,返回結(jié)果,可以是 string、數(shù)組、對(duì)象
? 字符串 —— 則直接替換成轉(zhuǎn)換后
html代碼? 對(duì)象和數(shù)組 —— 需要注入
html標(biāo)簽,通過(guò)HtmlTagDescriptor進(jìn)行配置
HtmlTagDescriptor 的配置內(nèi)容分為兩類:
? 注入內(nèi)容
? 注入的位置
配置方式如下圖:

{
tag: 'script',
attrs: {
type: 'module',
src: '/@vite/client'
},
injectTo: 'head-prepend'
}就是在 <head> 標(biāo)簽內(nèi)的最前面,拼接上 <script src="/@vite/client" type="module"></script>
代碼轉(zhuǎn)換中間件
transformMiddleware 中間件的實(shí)現(xiàn)如下:
async function viteTransformMiddleware(req, res, next) {
// 只處理 GET 請(qǐng)求,其他不處理
if (req.method !== 'GET') {
return next()
}
const url: string = req.url
// 只處理部分的請(qǐng)求
if (
// 用正則表達(dá)式判斷:/\.((j|t)sx?|mjs|vue|marko|svelte|astro)($|\?)/
// ts、vue 都算作是 js 請(qǐng)求
isJSRequest(url)
) {
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html')
})
if (result) {
return send(req, res, result.code)
}
}
next()
}可以發(fā)現(xiàn),其實(shí)中間件的大致框架/寫(xiě)法,都是差不多的,只處理部分請(qǐng)求,其他的調(diào)用 next 函數(shù),將請(qǐng)求交給下一個(gè)中間件處理。
TS/JS 的 transform 就復(fù)雜一點(diǎn)了,因?yàn)檫@里其實(shí)不僅僅要處理 TS、JS,其實(shí)還可能要處理 Vue、TSX 等組件代碼,那 Vite 是怎么實(shí)現(xiàn)的呢?
答案是:使用 Vite 插件去擴(kuò)展這些轉(zhuǎn)換、編譯代碼的能力。
框架是越來(lái)越多的,Vite 不可能把這些框架的后綴都內(nèi)置到 Vite 中,這時(shí)候就需要插件提供的擴(kuò)展能力了,這又是 Vite 擴(kuò)展性的一大體現(xiàn)。
我們來(lái)看看一個(gè)文件模塊到底經(jīng)歷了哪些的處理過(guò)程?

?
resolveId,輸出是一個(gè)本地的實(shí)際的路徑,npm 包則會(huì)指向 node_modules 中的實(shí)際位置。?
load,輸出是文件模塊的代碼字符串,默認(rèn)就是直接讀取文件內(nèi)容并返回。?
transform,對(duì)代碼進(jìn)行轉(zhuǎn)換。默認(rèn)行為是不處理。
三個(gè)流程分別對(duì)應(yīng)了三個(gè)插件鉤子:resolveId、load、transform[6],這三個(gè)鉤子,在開(kāi)發(fā)環(huán)境中,由 Vite 提供,在生產(chǎn)環(huán)境打包時(shí),則由 Rollup 提供。
模塊的處理代碼如下(有刪減):
async function doTransform(
url: string,
server: ViteDevServer,
options: TransformOptions,
timestamp: number
) {
// 存放代碼字符串
let code: string | null = null
// 存放 sourcemap
let map: SourceDescription['map'] = null
// 解析出本地的實(shí)際路徑
const id = (await pluginContainer.resolveId(url))?.id || url
// 加載出模塊的代碼字符串
const loadResult = await pluginContainer.load(id, { ssr })
code = loadResult.code
// 轉(zhuǎn)換代碼
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ssr
})
code = transformResult.code
map = transformResult.map
return {
code,
map,
}
}我在 《Vite 是如何兼容 Rollup 插件生態(tài)的》[7]中詳細(xì)介紹過(guò) PluginContainer 的作用,感興趣的可以看一下,這里大概總結(jié)一下:
PluginContainer 的作用是在 Vite 中模擬 Rollup 的插件機(jī)制,它在內(nèi)部實(shí)現(xiàn) Rollup 的鉤子,pluginContainer.load 實(shí)際上會(huì)調(diào)用的所有 Vite 插件的 load 鉤子。
我們用戶側(cè)看到的 index.ts 插件被轉(zhuǎn)換,也是 Vite 的內(nèi)置插件,用 transform 鉤子進(jìn)行編譯轉(zhuǎn)換的。實(shí)際上 Vite 是使用了 esbuild,對(duì)單個(gè)文件進(jìn)行轉(zhuǎn)譯:
export function esbuildPlugin(options: ESBuildOptions = {}): Plugin {
const filter = createFilter(
options.include || /\.(tsx?|jsx)$/,
options.exclude || /\.js$/
)
return {
name: 'vite:esbuild',
async transform(code, id) {
// 只處理 ts/tsx/jsx,不處理 js
if (filter(id)) {
const result = await transformWithEsbuild(code, id, options)
return {
code: result.code,
map: result.map
}
}
}
}
}transformWithEsbuild 函數(shù),則是使用 esbuild 對(duì)代碼進(jìn)行轉(zhuǎn)譯。
經(jīng)過(guò)轉(zhuǎn)譯之后,就是我們用戶側(cè)看到的 js 代碼了。
總結(jié)
本篇文章首先構(gòu)造出一個(gè)最簡(jiǎn)單的項(xiàng)目,這樣便于只關(guān)注 Vite 的核心流程;然后簡(jiǎn)單地介紹了 Connect 的中間件機(jī)制,以及說(shuō)明,Vite Server 的請(qǐng)求處理能力,是通過(guò)中間件實(shí)現(xiàn)的;然后我們分別介紹了 html 處理插件和 TS 處理中間件。
?
html處理中間件,通過(guò)調(diào)用插件的transformIndexHtml對(duì)html頁(yè)面進(jìn)行處理。?
TS處理中間件,通過(guò)調(diào)用插件的resolveId、load、transform這三個(gè)鉤子,對(duì)代碼進(jìn)行處理的
從中我們也可以看出,Vite 通過(guò)插件,實(shí)現(xiàn)了非常高的可擴(kuò)展性。
處理過(guò)后的代碼,會(huì)作為請(qǐng)求的響應(yīng)值,返回到瀏覽器,瀏覽器會(huì)根據(jù) Content-type 對(duì)響應(yīng)內(nèi)容,進(jìn)行相應(yīng)的處理。經(jīng)過(guò)這些步驟,一個(gè)簡(jiǎn)單的頁(yè)面就能夠展示出來(lái)了。
可以看出,Vite 的核心流程其實(shí)非常簡(jiǎn)單,當(dāng)然本篇文章,有很多內(nèi)容其實(shí)也是沒(méi)有說(shuō)到的,Vite 內(nèi)部有很多內(nèi)置的中間件、插件沒(méi)有介紹,同時(shí) Vite 有很多內(nèi)部邏輯,也是被忽略的,例如配置的解析、依賴預(yù)構(gòu)建、緩存、優(yōu)化等等,但其實(shí)也不影響我們做出一個(gè)簡(jiǎn)單版本的 Vite。
本篇文章,主要從概念上說(shuō)明 Vite Server 的行為,下篇文章,我會(huì)手寫(xiě)一個(gè)簡(jiǎn)單的 Vite Server,并用它來(lái)跑我們這次構(gòu)造的簡(jiǎn)單項(xiàng)目,敬請(qǐng)期待~
如果這篇文章對(duì)您有所幫助,請(qǐng)幫忙點(diǎn)個(gè)贊??,您的鼓勵(lì)是我創(chuàng)作路上的最大的動(dòng)力。
引用鏈接
[1] 鏈接: https://github.com/candy-Tong/what-vite-do/tree/main/packages/simple[2] 《Vite 熱更新的主要流程》: https://juejin.cn/post/7096103959563075597[3] connect: https://www.npmjs.com/package/connect[4] transformIndexHtml: https://cn.vitejs.dev/guide/api-plugin.html#transformindexhtml[5] transformIndexHtml: https://cn.vitejs.dev/guide/api-plugin.html#transformindexhtml[6] resolveId、load、transform: https://cn.vitejs.dev/guide/api-plugin.html#universal-hooks[7] 《Vite 是如何兼容 Rollup 插件生態(tài)的》: https://juejin.cn/post/7109437324047417357

往期推薦



最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專業(yè)的技術(shù)人...


