手把手教你手寫一個 Vite Server(一)

語言: CN / TW / HK
ead>

之前寫過幾篇 Vite 的文章,對 Vite 的概念也有一定的理解了,但理解歸理解,仍然覺得很虛,也不知怎麼的,這幾個概念突然就變成一個這麼強大的工具。。。

於是,我決定自己手寫一遍 Vite,這樣才有實在感,而且為了往往要考慮兼容各種情況,源碼往往會非常複雜,不利於理解。那麼這時候,手寫一遍,去掉這些兼容邏輯、邊界判斷等,只關注核心邏輯,就能進一步地加深理解。

本文是這個系列的第一篇文章,在本篇文章中,我們先不關注 Vite 的架構,因為我們得先有個東西出來,對於很多人來説,空談架構是不行的

因此,我們首先把 Vite 開發環境的部分功能模仿出來:實現 Vite Dev Server,並能夠對請求的 ts 文件做編譯。

下篇文章,我們再來講述,如何給這個手寫的 Vite 加入架構相關的內容。

本文用到的倉庫存放在該 GitHub 倉庫,感興趣的可以自行下載

項目約定

我們既然要手寫 Vite,那當然要有一個 my-vite 的項目,我們還需要一個調試 Vite 的前端頁面項目。

我打算把手寫 Vite,做成一個系列,代碼都放到一個倉庫中,因此我使用 monorepo 來管理這些項目

這裏做如下的目錄約定:

└─packages └─ 1. my-vite-xxx ├─playground └─ 2. my-vite-xxx ├─playground └─ ……

  • 所有版本的手寫 Vite 項目都放在 packages 中
  • 每個手寫 Vite 項目中,會有一個 playground 文件夾用來存放調試用的前端頁面項目

本文的用到的例子為 1.my-vite-simple-server 以及該文件夾裏面的 playground

調試用的頁面項目

在手寫 Vite 之前,我們構造一個極其簡單的前端頁面,用最簡單的項目來説明 Vite 的核心流程

index.html 文件:

```html

Title

```

main.js 文件

```javascript // playground/src/main.js import { subModule } from './sub-module.js';

const app = document.getElementById('app'); if (app) { app.innerText = 'Hello World'; } subModule(app); ```

sub-module.js 文件:

javascript // playground/src/sub-module.js export function subModule(app) { console.log('this is a subModule'); app.innerHTML += '<Br/> this is a subModule'; }

如何運行 Vite 命令

當我們使用 Vite 時,在 package.json 使用如下命令,即可在開發環境運行 Vite:

json { "scripts": { "dev": "vite", }, }

要實現 Vite 命令,説實話有點複雜,我們要給 my-vite 做一個 bin 腳本,另外我們用 TS 寫的代碼,還得將代碼編譯成 JS,Vite 還沒寫就整這麼多無關的東西,這多不好鴨。

那我們換個思路, package.json 改成這樣:

json { "scripts": { "dev": "esno ../vite.ts", }, }

我們直接用 esno 運行一個 TS 腳本,這樣即不需要做一個 bin 腳本,也不需要編譯 ts 代碼,這對我們理解核心邏輯是有幫助的。

我們就把 vite.ts 當做是運行了 vite 命令,然後我們vite.ts 腳本中寫 Vite 命令實際執行的內容即可。

開啟一個 Server

Vite 在開發環境下,會創建一個 Server,那我們首先也來創建一個 Server。

創建 Server 用 connect 包(Vite 也是使用它創建 Server),它是一個可擴展的 HTTP 服務器框架,使用方式如下:

```javascript // /src/node/server/index.ts import connect from 'connect'; import http from 'http';

export async function createServer(){ const app = connect();

// 每次請求會經過該中間件的處理
app.use(function(_, res){
    // 響應請求
    res.end('Hello from Connect!\n');
});

http.createServer(app).listen(3000);

console.log('open http://localhost:3000/');

} ```

我們在 vite.ts 進行調用:

typescript // vite.ts import { createServer } from './src/node/server'; createServer();

然後在 playground 中運行:

```shell pnpm run dev

open http://localhost:3000/

```

打開 http://localhost:3000/ 效果如下:

image-20220630204222553

如果 Network 中有多餘的請求,可能是瀏覽器插件導致的,可以使用無痕模式進行調試

在這個例子中,無論請求的鏈接是什麼,都會返回 Hello from Connect,因為中間件始終返回同樣的內容。

我們這裏再稍微介紹一下 Connect 中間件的機制,已經知道的同學也可以跳過。

中間件機制

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

image-20220612104713553

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

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

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

想要實現 Vite Dev Server 的行為,其實就是實現對應能力的中間件

為了先把頁面給展示出來,我們先實現文件服務的中間件。

實現文件服務中間件

這裏我們直接藉助 sirv 這個包,它是一個非常輕量級中間件,用於處理對靜態資源的請求。

```typescript // /src/node/server/middlewares/static.ts import { NextHandleFunction } from 'connect'; import sirv from 'sirv';

export function staticMiddleware(): NextHandleFunction { const serveFromRoot = sirv('./', { dev: true }); return async (req, res, next) => { serveFromRoot(req, res, next); }; } ```

使用中間件的方式:

typescript // vite.ts app.use(staticMiddleware());

然後重新執行 vite.ts重啟 Server (由於我們的 Server 沒有做熱更新機制,每次修改必須手動重啟 Server,代碼才會生效),訪問 http://localhost:3000,就能顯示出頁面了。

image-20220630214001510

這其實就是個平平無奇的文件服務,根據請求的訪問路徑,讀取文件。因為瀏覽器能直接執行 js 的代碼,因此能正確展示頁面。

如果我們把 JS 改成 TS。

```diff

Title

  • ```

main.ts:

```typescript import { subModule } from './sub-module.ts';

const app = document.getElementById('app'); app!.innerText = 'Hello World';

subModule(app!); ```

sub-module.ts

typescript export function subModule(app: HTMLElement) { console.log('this is a subModule'); app.innerHTML += '<Br/> this is a subModule'; }

這下子頁面就出不來了:

image-20220630214547538

因為瀏覽器無法識別 TS 的語法,自然就報錯了。當然這是預期之內的。因為 vite 會在請求中對 TS 進行編譯,而我們這裏並沒有處理。那我們接下來把這個能力補上。

TS 編譯中間件

先來寫一箇中間件的基本結構:

```typescript // /src/node/server/middlewares/transform.ts export function transformMiddleware( ): NextHandleFunction {

return async function viteTransformMiddleware(req, res, next) { if (req.method !== 'GET') { return next(); }

const url: string = cleanUrl(req.url!);

if (
  isTsRequest(url)
) {
  // 編譯 TS 代碼
  const result = await doTransform(url);

  // 設置 header,告訴瀏覽器把這個請求的響應值,當做 js 運行
  res.setHeader('Content-Type', 'application/javascript');
  // 響應請求
  return res.end(result.code);
}

next();

}; } ```

只處理 GET 和 TS 的請求,其他的交給下一個中間件處理。

  • 該中間應該放到文件服務中間件之前,因為 TS 的請求,需要進行轉換,不應該再走到文件服務了,轉換完成後,直接由該中間件進行響應
  • 由於不走文件服務中間件,我們應該自行實現 TS 文件的讀取

接下來我們來實現 doTransform 函數:

```typescript import { transform } from 'esbuild'; import path from 'path'; import { readFile } from 'fs-extra';

export async function doTransform(url: string) { const file = url.startsWith('/') ? '.' + url : url; // 讀取文件 const rawCode = await readFile(file, 'utf-8');

const { code, map } = await transform(rawCode, { target: 'esnext', format: 'esm', sourcemap: true, loader: 'ts', });

return { code, map, }; } ```

主要流程如下:

  • 讀取文件
  • 轉換代碼

訪問頁面,效果如下:

image-20220703231044322

從圖中可以看出,TS 已經被轉換成 JS 了。

由於 TS 文件被轉換了,接下來我們再補一下 sourcemap

```diff export function transformMiddleware( ): NextHandleFunction {

return async function viteTransformMiddleware(req, res, next) { if (req.method !== 'GET') { return next(); }

const url: string = cleanUrl(req.url!);

if (
  isTsRequest(url)
) {
  // 編譯 TS 代碼
  const result = await doTransform(url);
  • const code = getCodeWithSourcemap(result.code, result.map);

    // 設置 header,告訴瀏覽器把這個請求的響應值,當做 js 運行 res.setHeader('Content-Type', 'application/javascript'); // 響應請求 - return res.end(result.code); + return res.end(code); }

    next(); }; } ```

getCodeWithSourcemap 的實現如下:

``typescript // 生成 sourcemap 的 data url export function genSourceMapUrl(map: string): string { returndata:application/json;base64,${Buffer.from(map).toString('base64')}`; }

// 將 sourcemap 拼接到代碼末尾 export function getCodeWithSourcemap(code: string, map: string): string { code += \n//# sourceMappingURL=${genSourceMapUrl(map)};

return code; } ```

主要流程如下:

  • 將 esbuild 轉換時生成的 map,用 base64 編碼後,拼接成 data url。關注 data url 可以看 MDN
  • sourcemap 字符串,拼接到代碼末尾

效果如下:

image-20220703224017482

可以看出,打斷點時,能映射到源碼。

TSX/JSX 編譯

由於 esbuild 也能直接處理 tsx、jsx 等語法,我們只需要稍微修改一下 doTransform ,就能用於 tsx、jsx 的轉換。

```diff export function transformMiddleware( ): NextHandleFunction {

return async function viteTransformMiddleware(req, res, next) { if (req.method !== 'GET') { return next(); }

const url: string = cleanUrl(req.url!);

if (
  • isTsRequest(url)
  • isJsRequest(url) ) { // 編譯 TS 代碼 const result = await doTransform(url); const code = getCodeWithSourcemap(result.code, result.map);

    // 設置 header,告訴瀏覽器把這個請求的響應值,當做 js 運行 res.setHeader('Content-Type', 'application/javascript'); // 響應請求 return res.end(code); }

    next(); }; } ```

isJSRequest 實現如下:

typescript const knownJsSrcRE = /\.((j|t)sx?)$/; export const isJSRequest = (url: string): boolean => { return knownJsSrcRE.test(url); };

doTransform 也需要做相應的修改

```diff import { transform } from 'esbuild'; import path from 'path'; import { readFile } from 'fs-extra';

export async function doTransform(url: string) { + const extname = path.extname(url).slice(1); const file = url.startsWith('/') ? '.' + url : url; // 讀取文件 const rawCode = await readFile(file, 'utf-8');

const { code, map } = await transform(rawCode, { target: 'esnext', format: 'esm', sourcemap: true, - loader: 'ts', + loader: extname as 'js' | 'ts' | 'jsx' | 'tsx', });

return { code, map, }; } ```

那麼我們來嘗試一下使用 tsx

  1. 首先先從 CDN 引入 React

```diff

Title + +

+

```

  1. 新增 tsx 模塊

typescript // react-component.tsx export function ReactComponent(){ return ( <div>this is a React Component</div> ); }

  1. 引入 tsx 模塊

```diff import { subModule } from './sub-module.ts'; + import {ReactComponent} from './react-component.tsx';

const app = document.getElementById('app'); app!.innerText = 'Hello World';

subModule(app!); + const comp = ReactComponent(); + const root = ReactDOM.createRoot( + document.getElementById('react-root') + ); + root.render(comp); ```

重啟 Server,效果如下:

image-20220703231132930

可以看到,tsx 已經被正確編譯,React 組件被渲染出來了。

處理 CSS 的引入

為了演示 CSS 的相關處理,我們先造一些 CSS 文件

style.css

css @import "./style-imported.css"; body{ font-size: 24px; font-weight: 700; }

style-imported.css

css body{ color: #2196f3; }

加入 @import 是為了測試 import style

我們在 index.html 引入,先看看效果:

html <head> <link href="src/style.css" rel="stylesheet"></link> </head>

image-20220704152241102

看完效果,我們得把 html 中的引入刪掉,我們要在 js 中引入,並使這種引入方式能夠正常生效。

```diff import { subModule } from './sub-module.ts'; import {ReactComponent} from './react-component.tsx'; + import './style.css';

const app = document.getElementById('app'); app!.innerText = 'Hello World';

subModule(app!); const comp = ReactComponent(); const root = ReactDOM.createRoot( document.getElementById('react-root') ); root.render(comp); ```

眾所周知,js 中直接引入 css,是不行的。會得到以下錯誤:

js Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/css". Strict MIME type checking is enforced for module scripts per HTML spec.

意思是,用 JS import 的 style.css 請求,它的響應值不是 JS,但瀏覽器期望它是 JS,這樣它才能執行。

那麼我們將 CSS 轉換成 JS 即可,因此我們需要一個 CSS 轉換的中間件。

CSS 中間件

同樣的,我們先寫一箇中間件的基本結構:

```typescript import { NextHandleFunction } from 'connect'; import { isCSSRequest } from '../../utils';

export function cssMiddleware(): NextHandleFunction { return async function viteTransformMiddleware(req, res, next) { if (req.method !== 'GET') { return next(); }

const url: string = req.url!;

if (isCSSRequest(url)) {
  // CSS 文件的讀取和轉換

  res.setHeader('Content-Type', 'application/javascript');
  return res.end(/* 轉換後的代碼 */);
}

next();

}; } ```

接下來補充一下,文件的讀取:

typescript const file = url.startsWith('/') ? '.' + url : url; const rawCode = await readFile(file, 'utf-8');

那如何將 CSS 轉換成 JS 模塊,讓它能夠作為 ES6 module 引入呢?

其實很簡單,用 JS 將 CSS 的內容,插入到頁面即可

```typescript const file = url.startsWith('/') ? '.' + url : url; const rawCode = await readFile(file, 'utf-8');

res.setHeader('Content-Type', 'application/javascript'); return res.end(var style = document.createElement('style') style.setAttribute('type', 'text/css') style.innerHTML = \${rawCode} ` document.head.appendChild(style) `); ```

創建一個 style 標籤,內容為 CSS 的文本,然後加入到 document。這樣就能把 CSS 當做 JS 模塊引入了。

我們來看看效果:

image-20220704153808985

樣式渲染出來了,但又沒有完全出來。style-imported.css 的字體顏色樣式沒有渲染出來。

可以看出有 style-imported.css 的請求是失敗的,而看看我們寫的 Server,也報錯了,錯誤為找不到文件。

image-20220704154203105

因為沒有錯誤處理,整個 Server 直接崩了,進程退出。

我們來看看是什麼原因導致的。

image-20220704154504934

可以看出,使用 style-imported.css 的 src 路徑沒有了,導致讀取文件的時候,讀取文件的目錄不對,找不到 style-imported.css

為什麼直接在 html 中引入 CSS 文件正常,用 JS 引入卻會發生問題?

要理解這個,就要理解 CSS 的相對 url 的行為,在 MDN 中的描述如下:

相對地址相對於 CSS 樣式表的 URL(而不是網頁的 URL)

在 html 引入的 CSS 中,樣式表的 URL 為 src/style.css,則 ./style-imported.css 解析為 src/style-imported.css

而作為 JS 模塊引入的 CSS,是通過 document.head.appendChild(style) 加入到頁面的,不存在 URL,因此不能正確解析相對路徑。

那這個問題該如何處理?

兩個思路:

  • 在請求響應前,將 @import 的 url 修改為相對於項目根目錄的路徑。
  • @import 的內容,通過打包,內聯到一個 CSS 文件

方案一看起來簡單,但實際上需要兼容的情況還是比較多的。

方案二目前其實已經有成熟的方案了,使用 PostCSS 處理即可

在 Vite 內部,實際上是使用了方案二;

最終的實現代碼如下:

```typescript import { NextHandleFunction } from 'connect'; import { cleanUrl, isCSSRequest } from '../../utils'; import { readFile } from 'fs-extra'; import postcss from 'postcss'; import atImport from 'postcss-import';

export function cssMiddleware(): NextHandleFunction { return async function viteTransformMiddleware(req, res, next) { if (req.method !== 'GET') { return next(); }

const url: string = cleanUrl(req.url!);

if (isCSSRequest(url)) {
  const file = url.startsWith('/') ? '.' + url : url;
  const rawCode = await readFile(file, 'utf-8');

  // 使用 PostCSS 進行處理
  const postcssResult = await postcss([atImport()]).process(rawCode,{
    from: file,
    to:file
  });

  res.setHeader('Content-Type', 'application/javascript');
  return res.end(`
    var style = document.createElement('style')
    style.setAttribute('type', 'text/css')
    style.innerHTML = \`${postcssResult.css} \`
    document.head.appendChild(style)
  `);
}

next();

}; } ```

效果如下:

image-20220704190136216

可以看到,@import 的內容,已經被內聯到了 style.css

總結

在該文章中,我們首先構造了一個用於調試的項目,然後用一種巧妙的方式,通過 esno 直接運行 vite.ts 腳本, 替代了 vite 命令的實現,簡化了我們的實現成本,不需要編譯 TS,同時也減少了大家的理解成本。

然後我們開始寫 Server 的內容,寫了如何啟動一個 Server,並簡單的介紹了 Connect 的中間件的機制

接下來,使用 sirv 搭建了一個文件服務,把頁面展示出來了。

然後我們分別對 TS 和 CSS 進行了處理

  • 對於 TS,我們用 esbuild 進行編譯,同時 esbuild 也支持 TSX/JSX 的轉換,因此也對此進行兼容,並做了一個小 Demo 進行展示。
  • 對於 CSS,我們先用 PostCSS 進行轉換,然後將轉換後的代碼,處理成 JS 模塊,通過創建 style 標籤並插入到 document 的方式,將 style 注入到頁面中。這樣就能夠在 JS 代碼中對 CSS 文件進行 import。

至此,我們第一版的 my-vite 就完成了,但其實這距離 Vite,還有非常大的一段距離,我們這次寫的 my-vite只是一個普普通通的服務,只是實現了看起來跟 Vite 差不多功能的一個東西,裏面的邏輯都是寫死的,一點擴展性都沒有,如果要新增能力,就得修改 my-vite 的代碼。

Vite 之所以強大,除了它自身實現的優秀能力外,很大程度是因為其插件式的架構提供設計,提供了極大的可擴展性,可通過插件,對 Vite 能力進行擴展,而不需要對 Vite 自身代碼進行修改,例如: @vite/plugin-vue 插件,通過使用該插件,就能夠獲取到 Vue 文件的編譯能力。

因此,本篇文章的 my-vite ,只是把一些能力做出來了,但是毫無架構可言。下篇文章,將會在這個的基礎上,逐步地加入一些架構的內容,敬請期待

最後

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

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