Vite Server 是如何處理頁面資源的?

語言: CN / TW / HK
ead>

我們知道,Vite 在開發環境下,會打開一個 Dev Server 用於預覽開發的頁面,那麼這個 Dev Server 到底做了什麼呢?它是怎麼做到將我們的代碼展示成頁面的,接下來我們就來一探究竟。

構造項目

我們構造一個最簡單的項目,項目中沒有用到 npm 包、css 等功能,就只有一個 index.html 和一個 typescript 文件。

目的:剝離出複雜的內容,用最簡單的例子去説明最核心的內容

代碼放在該GitHub 倉庫鏈接

├─ index.html ├─ index.ts

index.html 代碼如下:

```html

```

index.ts 代碼如下:

typescript const app = document.getElementById('app'); app!.innerHTML = 'helloworld';

項目有了,接下來我們從用户側,看看 Vite Server 做了什麼?

用户側視覺

在項目目錄,運行 vite 命令,我們會看到如下輸入:

```shell vite v3.0.0-alpha.0 dev server running at:

Local: http://localhost:5173/ Network: use --host to expose

ready in 551ms. ```

可以看到 vite 創建了一個 dev server,用於訪問頁面。

訪問頁面,頁面展示出 helloworld,請求如下:

image-20220620195030837

這裏可以看到有 5 個請求(如果有多的,可能是瀏覽器插件的請求,建議使用無痕模式查看),他們的嵌套關係如下:

  • 拉取 index.html

  • Vite 的熱更新相關腳本:/@vite/client

    • /client/env.mjs
    • ws://localhost:5173/
  • 我們寫的 ts 代碼:/index.ts

為什麼我們明明只寫了 index.htmlindex.ts,但這裏卻還會有其他的資源請求?

我們查看 index.html 的代碼:

```diff

+

<meta charset="UTF-8">
<title>Title</title>

```

這裏可以看出,index.html 已經被修改了,插入了一段名為 client 代碼,這段代碼其實是用於 Vite 熱更新的,它開啟了一個 websocket。client 還依賴了其他腳本,因此瀏覽器還會繼續發起請求,所以會看到有多個請求。

再看看 index.ts :

```diff const app = document.getElementById("app"); - app!.innerHTML = 'helloworld'; + app.innerHTML = "helloworld";

  • //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkQ6L3RlbmNlbnQvYXBwL3doYXQtdml0ZS1kby9wYWNrYWdlcy9zaW1wbGUvaW5kZXgudHMiXSwic291cmNlc0NvbnRlbnQiOlsiY29uc3QgYXBwID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2FwcCcpO1xuYXBwIS5pbm5lckhUTUwgPSAnaGVsbG93b3JsZCc7XG4iXSwibWFwcGluZ3MiOiJBQUFBLE1BQU0sTUFBTSxTQUFTLGVBQWUsS0FBSztBQUN6QyxJQUFLLFlBQVk7IiwibmFtZXMiOltdfQ== ```

index.ts 的代碼已經被編譯成 js 了,並且拼接上了 sourcemap。

瀏覽器是不能運行 ts 代碼的,為什麼瀏覽器能運行 index.ts?

其實瀏覽器要怎麼處理一個請求,是看它的響應 Header 中的 Content-Type 的

image-20220620200050530

我們可以看到,雖然請求的是 index.ts,但 Content-Type 卻是 application/javascript,這就代表了,瀏覽器會將這段代碼,當做 JavaScript 腳本去處理

這個與文件後綴是無關的,在我們實際開發中,很多請求是 ts、tsx、vue,但無論什麼後綴都是沒有關係的,它們的 Content-Type 都是 application/javascript,因此瀏覽器能夠正確的運行處理。

到目前為止,用户側所看到的 Vite Server 的行為,已經明確了:

  • 修改 index.html,在 head 標籤中加入了 client 腳本。
  • 編譯 index.ts,並拼接上 sourcemap。
  • 連接 websocket

為了簡單起見,我們本篇文章不講述熱更新的內容,如果感興趣,可以查看《Vite 熱更新的主要流程》,該文章同樣是用了最簡單的例子,講述 Vite 熱更新的核心流程,建議閲讀。

Server 的中間件機制

我們從用户側可以看出,Vite Server 對不同的請求的文件做了特殊的處理,然後進行響應返回給客户端

那一個 Server 要如何處理請求的呢?答案是,使用中間件

中間件機制

Vite 用 connect 包來創建一個 DevServer。其簡單的用法如下:

```javascript var connect = require('connect'); var http = require('http');

var app = connect();

// 使用一箇中間件 app.use(function(req, res){ res.end('Hello from Connect!\n'); });

// 創建 nodejs http server,並監聽 3000 端口 http.createServer(app).listen(3000); ```

connect 的中間件機制,可以用如下圖表示:

image-20220612104713553

當一個請求發送到 server 時,會經過一個個的中間件,中間件本質是一個回調函數,每次請求都會執行回調。

connect 的中間件機制有如下特點:

  • 每個中間件可以分別對請求進行處理,並進行響應。
  • 每個中間件可以只處理特定的事情,其他事情交給其他中間件處理
  • 可以調用 next 函數,將請求傳遞給下一個中間件。如果不調用,則之後的中間件都不會被執行

由於 htmlTS 文件的處理方式完全不同,因此要做成兩個不同的中間件。

  • html 處理中間件
  • 代碼轉化中間件

html 處理中間件

中間件的部分代碼實現如下:

```typescript async function viteIndexHtmlMiddleware(req, res, next) {

// 去掉 url 中的 hash 和 query
const url = req.url && cleanUrl(req.url)

// 只處理 html 的請求,否則調用 next 傳遞請求給下箇中間件
if (url?.endsWith('.html')) {
    // 從 url 中獲取 html 文件路徑
    const filename = getHtmlFilename(url, server)
    if (fs.existsSync(filename)) {
        try {

            // 讀取文件,拿到 html 的代碼字符串
            let html = fs.readFileSync(filename, 'utf-8')

            // 轉換 html 代碼,返回轉換後的代碼字符串
            html = await server.transformIndexHtml(url, html, req.originalUrl)

            // 響應請求
            return send(req, res, html, 'html', {
                headers: server.config.server.headers
            })
        } catch (e) {
            return next(e)
        }
    }
}
next()

} ```

該中間件只處理 html 請求。如果不是 html 請求,就直接調用 next,將請求交給後續的中間件處理了。

中間件核心流程就是:

  • 讀取 html 文件
  • 執行 transform 轉換/修改內容
  • 響應請求

我們從用户側視覺中,也可以看出,transform 就是加上了讓的熱更新代碼,但要是認為它只有這個作用,那就小看 Vite 啦!

Vite 有非常高的可擴展性,加上熱更新代碼,只不過是 Vite 一個小小的內部插件實現的功能。

我們來看看 Vite 的 transformIndexHtml 插件鈎子,它可以index.html 進行修改,可以插入任何的內容

通過在 transformIndexHtml 鈎子中,直接修改 html 代碼,或者設置 transformIndexHtml 鈎子的返回值的方式,對 html 插入內容。

根據 hook 的返回值,做不同的處理,返回結果的類型如下:

```typescript type IndexHtmlTransformResult = | string | HtmlTagDescriptor[] | { html: string tags: HtmlTagDescriptor[] }

interface HtmlTagDescriptor { tag: string attrs?: Record children?: string | HtmlTagDescriptor[] /* * 默認: 'head-prepend' / injectTo?: 'head' | 'body' | 'head-prepend' | 'body-prepend' } ```

可以看出,返回結果,可以是 string、數組、對象

  • 字符串 —— 則直接替換成轉換後 html 代碼
  • 對象和數組 —— 需要注入 html 標籤,通過 HtmlTagDescriptor 進行配置

HtmlTagDescriptor 的配置內容分為兩類:

  • 注入內容
  • 注入的位置

配置方式如下圖:

image-20220612184809560

例如 Vite 熱更新的返回值為以下配置:

typescript { tag: 'script', attrs: { type: 'module', src: '/@vite/client' }, injectTo: 'head-prepend' }

就是在 <head> 標籤內的最前面,拼接上 <script src="/@vite/client" type="module"></script>

代碼轉換中間件

transformMiddleware 中間件的實現如下:

```typescript async function viteTransformMiddleware(req, res, next) { // 只處理 GET 請求,其他不處理 if (req.method !== 'GET') { return next() }

const url: string = req.url

// 只處理部分的請求
if (
    // 用正則表達式判斷:/\.((j|t)sx?|mjs|vue|marko|svelte|astro)($|\?)/
    // ts、vue 都算作是 js 請求
    isJSRequest(url)
) {

    const result = await transformRequest(url, server, {
        html: req.headers.accept?.includes('text/html')
    })

    if (result) {
        return send(req, res, result.code)
    }
}

next()

} ```

可以發現,其實中間件的大致框架/寫法,都是差不多的,只處理部分請求,其他的調用 next 函數,將請求交給下一個中間件處理。

TS/JStransform 就複雜一點了,因為這裏其實不僅僅要處理 TS、JS,其實還可能要處理 Vue、TSX 等組件代碼,那 Vite 是怎麼實現的呢?

答案是:使用 Vite 插件去擴展這些轉換、編譯代碼的能力

框架是越來越多的,Vite 不可能把這些框架的後綴都內置到 Vite 中,這時候就需要插件提供的擴展能力了,這又是 Vite 擴展性的一大體現

我們來看看一個文件模塊到底經歷了哪些的處理過程?

image-20220620211117393

  • resolveId,輸出是一個本地的實際的路徑,npm 包則會指向 node_modules 中的實際位置。
  • load,輸出是文件模塊的代碼字符串,默認就是直接讀取文件內容並返回。
  • transform,對代碼進行轉換。默認行為是不處理。

三個流程分別對應了三個插件鈎子:resolveIdloadtransform,這三個鈎子,在開發環境中,由 Vite 提供,在生產環境打包時,則由 Rollup 提供。

模塊的處理代碼如下(有刪減):

```typescript async function doTransform( url: string, server: ViteDevServer, options: TransformOptions, timestamp: number ) {

// 存放代碼字符串 let code: string | null = null // 存放 sourcemap let map: SourceDescription['map'] = null

// 解析出本地的實際路徑 const id = (await pluginContainer.resolveId(url))?.id || url

// 加載出模塊的代碼字符串 const loadResult = await pluginContainer.load(id, { ssr })

code = loadResult.code

// 轉換代碼 const transformResult = await pluginContainer.transform(code, id, { inMap: map, ssr })

code = transformResult.code map = transformResult.map

return { code, map, } } ```

我在 《Vite 是如何兼容 Rollup 插件生態的》中詳細介紹過 PluginContainer 的作用,感興趣的可以看一下,這裏大概總結一下:

PluginContainer 的作用是在 Vite 中模擬 Rollup 的插件機制,它在內部實現 Rollup 的鈎子,pluginContainer.load 實際上會調用的所有 Vite 插件的 load 鈎子。

我們用户側看到的 index.ts 插件被轉換,也是 Vite 的內置插件,用 transform 鈎子進行編譯轉換的。實際上 Vite 是使用了 esbuild,對單個文件進行轉譯:

```typescript 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 函數,則是使用 esbuild 對代碼進行轉譯。

經過轉譯之後,就是我們用户側看到的 js 代碼了。

總結

本篇文章首先構造出一個最簡單的項目,這樣便於只關注 Vite 的核心流程;然後簡單地介紹了 Connect 的中間件機制,以及説明,Vite Server 的請求處理能力,是通過中間件實現的;然後我們分別介紹了 html 處理插件和 TS 處理中間件。

  • html 處理中間件,通過調用插件的 transformIndexHtmlhtml 頁面進行處理。
  • TS 處理中間件,通過調用插件的 resolveIdloadtransform 這三個鈎子,對代碼進行處理的

從中我們也可以看出,Vite 通過插件,實現了非常高的可擴展性

處理過後的代碼,會作為請求的響應值,返回到瀏覽器,瀏覽器會根據 Content-type 對響應內容,進行相應的處理。經過這些步驟,一個簡單的頁面就能夠展示出來了。

可以看出,Vite 的核心流程其實非常簡單,當然本篇文章,有很多內容其實也是沒有説到的,Vite 內部有很多內置的中間件、插件沒有介紹,同時 Vite 有很多內部邏輯,也是被忽略的,例如配置的解析、依賴預構建、緩存、優化等等,但其實也不影響我們做出一個簡單版本的 Vite。

本篇文章,主要從概念上説明 Vite Server 的行為,下篇文章,我會手寫一個簡單的 Vite Server,並用它來跑我們這次構造的簡單項目,敬請期待~

關聯閲讀

如果這篇文章對您有所幫助,請幫忙點個贊👍,您的鼓勵是我創作路上的最大的動力。

最近註冊了一個公眾號,剛剛起步,名字叫:Candy 的修仙祕籍,也歡迎大家關注~