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 的修仙祕籍,也歡迎大家關注~