手把手教你手寫一個 Vite Server(一)
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
```
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/
效果如下:
如果 Network 中有多餘的請求,可能是瀏覽器外掛導致的,可以使用無痕模式進行除錯。
在這個例子中,無論請求的連結是什麼,都會返回 Hello from Connect,因為中介軟體始終返回同樣的內容。
我們這裡再稍微介紹一下 Connect 中介軟體的機制,已經知道的同學也可以跳過。
中介軟體機制
connect
的中介軟體機制,可以用如下圖表示:
當一個請求傳送到 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
,就能顯示出頁面了。
這其實就是個平平無奇的檔案服務,根據請求的訪問路徑,讀取檔案。因為瀏覽器能直接執行 js 的程式碼,因此能正確展示頁面。
如果我們把 JS 改成 TS。
```diff
- ```
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';
}
這下子頁面就出不來了:
因為瀏覽器無法識別 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, }; } ```
主要流程如下:
- 讀取檔案
- 轉換程式碼
訪問頁面,效果如下:
從圖中可以看出,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 {
return
data: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
字串,拼接到程式碼末尾
效果如下:
可以看出,打斷點時,能對映到原始碼。
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
- 首先先從 CDN 引入 React
```diff
```
- 新增 tsx 模組
typescript
// react-component.tsx
export function ReactComponent(){
return (
<div>this is a React Component</div>
);
}
- 引入 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,效果如下:
可以看到,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>
看完效果,我們得把 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 模組引入了。
我們來看看效果:
樣式渲染出來了,但又沒有完全出來。style-imported.css
的字型顏色樣式沒有渲染出來。
可以看出有 style-imported.css
的請求是失敗的,而看看我們寫的 Server,也報錯了,錯誤為找不到檔案。
因為沒有錯誤處理,整個 Server 直接崩了,程序退出。
我們來看看是什麼原因導致的。
可以看出,使用 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();
}; } ```
效果如下:
可以看到,@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 的修仙祕籍,歡迎大家關注~