下一代前端開發利器——Vite(原理原始碼解析)

語言: CN / TW / HK

本文已參與「新人創作禮」活動,一起開啟掘金創作之路。

前段時間用Vue3搭建專案時看到同時推出的Vite,只當它是一個新打包工具或者vue-cli的升級版,仍然選擇了用Webpack構建專案。最近看了尤雨溪在VueConf上的演講影片:《Vue3 生態進展和計劃》[1],感覺它確實解決了現階段前端工程化的一些痛點,也能體會到尤雨溪對Vite的重視和大力推廣的決心,再加上Vue本身的龐大使用者基數,Vite確實有可能成為下一代前端構建工具的突破口。

本文將討論下Vite出現的背景,解決的痛點,核心功能的實現,存在的意義和預期的未來。Vite本身並不複雜。中文官方文件非常清晰簡潔,建議大家使用前仔細讀下文件。


大綱

  • 背景
  • 什麼是Vite?
  • 基本用法
  • 實現原理
  • 原始碼分析
  • 優勢與不足
  • 與傳統構建工具對比
  • 相容性
  • 未來

背景

這裡的背景介紹會從與Vite緊密相關的兩個概念的發展史說起,一個是JavaScript的模組化標準,另一個是前端構建工具。

共存的模組化標準

為什麼JavaScript會有多種共存的模組化標準?因為js在設計之初並沒有模組化的概念,隨著前端業務複雜度不斷提高,模組化越來越受到開發者的重視,社群開始湧現多種模組化解決方案,它們相互借鑑,也爭議不斷,形成多個派系,從CommonJS開始,到ES6正式推出ES Modules規範結束,所有爭論,終成歷史,ES Modules也成為前端重要的基礎設施。

  • CommonJS:現主要用於Node.js([email protected]開始支援直接使用ES Module)
  • AMDrequire.js 依賴前置,市場存量不建議使用
  • CMDsea.js 就近執行,市場存量不建議使用
  • ES Module:ES語言規範,標準,趨勢,未來

對模組化發展史感興趣的可以看下《前端模組化開發那點歷史》@玉伯[2],而Vite的核心正是依靠瀏覽器對ES Module規範的實現。

發展中的構建工具

近些年前端工程化發展迅速,各種構建工具層出不窮,目前Webpack仍然佔據統治地位,npm 每週下載量達到兩千多萬次。下面是我按 npm 發版時間線列出的開發者比較熟知的一些構建工具。

圖片

當前工程化痛點

現在常用的構建工具如Webpack,主要是通過抓取-編譯-構建整個應用的程式碼(也就是常說的打包過程),生成一份編譯、優化後能良好相容各個瀏覽器的的生產環境程式碼。在開發環境流程也基本相同,需要先將整個應用構建打包後,再把打包後的程式碼交給dev server(開發伺服器)。

Webpack等構建工具的誕生給前端開發帶來了極大的便利,但隨著前端業務的複雜化,js程式碼量呈指數增長,打包構建時間越來越久,dev server(開發伺服器)效能遇到瓶頸:

  • 緩慢的服務啟動:  大型專案中dev server啟動時間達到幾十秒甚至幾分鐘。
  • 緩慢的HMR熱更新:  即使採用了 HMR 模式,其熱更新速度也會隨著應用規模的增長而顯著下降,已達到效能瓶頸,無多少優化空間。

緩慢的開發環境,大大降低了開發者的幸福感,在以上背景下Vite應運而生。


什麼是Vite?

基於esbuild與Rollup,依靠瀏覽器自身ESM編譯功能, 實現極致開發體驗的新一代構建工具!

概念

先介紹以下文中會經常提到的一些基礎概念:

  • 依賴:  指開發不會變動的部分(npm包、UI元件庫),esbuild進行預構建。
  • 原始碼:  瀏覽器不能直接執行的非js程式碼(.jsx、.css、.vue等),vite只在瀏覽器請求相關原始碼的時候進行轉換,以提供ESM原始碼。

開發環境

  • 利用瀏覽器原生的ES Module編譯能力,省略費時的編譯環節,直給瀏覽器開發環境原始碼,dev server只提供輕量服務。
  • 瀏覽器執行ESM的import時,會向dev server發起該模組的ajax請求,伺服器對原始碼做簡單處理後返回給瀏覽器。
  • Vite中HMR是在原生 ESM 上執行的。當編輯一個檔案時,Vite 只需要精確地使已編輯的模組失活,使得無論應用大小如何,HMR 始終能保持快速更新。
  • 使用esbuild處理專案依賴,esbuild使用go編寫,比一般node.js編寫的編譯器快幾個數量級。

生產環境

  • 整合Rollup打包生產環境程式碼,依賴其成熟穩定的生態與更簡潔的外掛機制。

處理流程對比

Webpack通過先將整個應用打包,再將打包後代碼提供給dev server,開發者才能開始開發。

圖片

Vite直接將原始碼交給瀏覽器,實現dev server秒開,瀏覽器顯示頁面需要相關模組時,再向dev server發起請求,伺服器簡單處理後,將該模組返回給瀏覽器,實現真正意義的按需載入。圖片


基本用法

建立vite專案

$ npm create [email protected]

選取模板

Vite 內建6種常用模板與對應的TS版本,可滿足前端大部分開發場景,可以點選下列表格中模板直接在 StackBlitz[3] 中線上試用,還有其他更多的 社群維護模板[4]可以使用。

| JavaScript | TypeScript | | ---------- | ---------- | | vanilla | vanilla-ts | | vue | vue-ts | | react | react-ts | | preact | preact-ts | | lit | lit-ts | | svelte | svelte-ts |

啟動

{   "scripts": {     "dev": "vite", // 啟動開發伺服器,別名:`vite dev`,`vite serve`     "build": "vite build", // 為生產環境構建產物     "preview": "vite preview" // 本地預覽生產構建產物   } }


實現原理

ESbuild 編譯

esbuild 使用go編寫,cpu密集下更具效能優勢,編譯速度更快,以下摘自官網的構建速度對比:\ 瀏覽器:“開始了嗎?”\ 伺服器:“已經結束了。”\ 開發者:“好快,好喜歡!!”

圖片

image.png

依賴預構建

  • 模組化相容:  如開頭背景所寫,現仍共存多種模組化標準程式碼,Vite在預構建階段將依賴中各種其他模組化規範(CommonJS、UMD)轉換 成ESM,以提供給瀏覽器。
  • 效能優化:  npm包中大量的ESM程式碼,大量的import請求,會造成網路擁塞。Vite使用esbuild,將有大量內部模組的ESM關係轉換成單個模組,以減少 import模組請求次數。

按需載入

  • 伺服器只在接受到import請求的時候,才會編譯對應的檔案,將ESM原始碼返回給瀏覽器,實現真正的按需載入。

快取

  • HTTP快取:  充分利用http快取做優化,依賴(不會變動的程式碼)部分用max-age,immutable 強快取,原始碼部分用304協商快取,提升頁面開啟速度。
  • 檔案系統快取:  Vite在預構建階段,將構建後的依賴快取到node_modules/.vite ,相關配置更改時,或手動控制時才會重新構建,以提升預構建速度。

重寫模組路徑

瀏覽器import只能引入相對/絕對路徑,而開發程式碼經常使用npm包名直接引入node_module中的模組,需要做路徑轉換後交給瀏覽器。

  • es-module-lexer 掃描 import 語法
  • magic-string 重寫模組的引入路徑

``` // 開發程式碼 import { createApp } from 'vue'

// 轉換後 import { createApp } from '/node_modules/vue/dist/vue.js' ```


原始碼分析

Webpack-dev-server類似Vite同樣使用WebSocket與客戶端建立連線,實現熱更新,原始碼實現基本可分為兩部分,原始碼位置在:

  • vite/packages/vite/src/client client(用於客戶端)
  • vite/packages/vite/src/node server(用於開發伺服器)

client 程式碼會在啟動服務時注入到客戶端,用於客戶端對於WebSocket訊息的處理(如更新頁面某個模組、重新整理頁面);server 程式碼是服務端邏輯,用於處理程式碼的構建與頁面模組的請求。

簡單看了下原始碼([email protected]),核心功能主要是以下幾個方法(以下為原始碼擷取,部分邏輯做了刪減):

  1. 命令列啟動服務npm run dev後,原始碼執行cli.ts,呼叫createServer方法,建立http服務,監聽開發伺服器埠。

// 原始碼位置 vite/packages/vite/src/node/cli.ts const { createServer } = await import('./server') try {     const server = await createServer({         root,         base: options.base,         ...     })     if (!server.httpServer) {         throw new Error('HTTP server not available')     }     await server.listen() }

  1. createServer方法的執行做了很多工作,如整合配置項、建立http服務(早期通過koa建立)、建立WebSocket服務、建立原始碼的檔案監聽、外掛執行、optimize優化等。下面註釋中標出。

``` // 原始碼位置 vite/packages/vite/src/node/server/index.ts export async function createServer(     inlineConfig: InlineConfig = {} ): Promise {     // Vite 配置整合     const config = await resolveConfig(inlineConfig, 'serve', 'development')     const root = config.root     const serverConfig = config.server

// 建立http服務     const httpServer = await resolveHttpServer(serverConfig, middlewares, httpsOptions)

// 建立ws服務     const ws = createWebSocketServer(httpServer, config, httpsOptions)

// 建立watcher,設定程式碼檔案監聽     const watcher = chokidar.watch(path.resolve(root), {         ignored: [             '/node_modules/',             '/.git/',             ...(Array.isArray(ignored) ? ignored : [ignored])         ],         ...watchOptions     }) as FSWatcher

// 建立server物件     const server: ViteDevServer = {         config,         middlewares,         httpServer,         watcher,         ws,         moduleGraph,         listen,         ...     }

// 檔案監聽變動,websocket向前端通訊     watcher.on('change', async (file) => {         ...         handleHMRUpdate()     })

// 非常多的 middleware     middlewares.use(...)          // optimize     const runOptimize = async () => {...}

return server } ```

  1. 使用chokidar[5]監聽檔案變化,繫結監聽事件。

// 原始碼位置 vite/packages/vite/src/node/server/index.ts   const watcher = chokidar.watch(path.resolve(root), {     ignored: [       '**/node_modules/**',       '**/.git/**',       ...(Array.isArray(ignored) ? ignored : [ignored])     ],     ignoreInitial: true,     ignorePermissionErrors: true,     disableGlobbing: true,     ...watchOptions   }) as FSWatcher

  1. 通過 ws[6] 來建立WebSocket服務,用於監聽到檔案變化時觸發熱更新,向客戶端傳送訊息。

``` // 原始碼位置 vite/packages/vite/src/node/server/ws.ts export function createWebSocketServer(...){     let wss: WebSocket     const hmr = isObject(config.server.hmr) && config.server.hmr     const wsServer = (hmr && hmr.server) || server

if (wsServer) {         wss = new WebSocket({ noServer: true })         wsServer.on('upgrade', (req, socket, head) => {             // 服務就緒             if (req.headers['sec-websocket-protocol'] === HMR_HEADER) {                 wss.handleUpgrade(req, socket as Socket, head, (ws) => {                     wss.emit('connection', ws, req)                 })             }         })     } else {         ...     }     // 服務準備就緒,就能在瀏覽器控制檯看到熟悉的列印 [vite] connected.     wss.on('connection', (socket) => {         socket.send(JSON.stringify({ type: 'connected' }))         ...     })     // 失敗     wss.on('error', (e: Error & { code: string }) => {         ...     })     // 返回ws物件     return {         on: wss.on.bind(wss),         off: wss.off.bind(wss),         // 向客戶端傳送資訊         // 多個客戶端同時觸發         send(payload: HMRPayload) {             const stringified = JSON.stringify(payload)             wss.clients.forEach((client) => {                 // readyState 1 means the connection is open                 client.send(stringified)             })         }     } } ```

  1. 在服務啟動時會向瀏覽器注入程式碼,用於處理客戶端接收到的WebSocket訊息,如重新發起模組請求、重新整理頁面。

//原始碼位置 vite/packages/vite/src/client/client.ts async function handleMessage(payload: HMRPayload) {   switch (payload.type) {     case 'connected':       console.log(`[vite] connected.`)       break     case 'update':       notifyListeners('vite:beforeUpdate', payload)       ...       break     case 'custom': {       notifyListeners(payload.event as CustomEventName<any>, payload.data)       ...       break     }     case 'full-reload':       notifyListeners('vite:beforeFullReload', payload)       ...       break     case 'prune':       notifyListeners('vite:beforePrune', payload)       ...       break     case 'error': {       notifyListeners('vite:error', payload)       ...       break     }     default: {       const check: never = payload       return check     }   } }


優勢

  • 快!快!非常快!!
  • 高度整合,開箱即用。
  • 基於ESM急速熱更新,無需打包編譯。
  • 基於esbuild的依賴預處理,比Webpack等node編寫的編譯器快幾個數量級。
  • 相容Rollup龐大的外掛機制,外掛開發更簡潔。
  • 不與Vue繫結,支援React等其他框架,獨立的構建工具。
  • 內建SSR支援。
  • 天然支援TS。

不足

  • Vue仍為第一優先支援,量身定做的編譯外掛,對React的支援不如Vue強大。
  • 雖然已經推出2.0正式版,已經可以用於正式線上生產,但目前市場上實踐少。
  • 生產環境整合Rollup打包,與開發環境最終執行的程式碼不一致。

與 webpack 對比

由於Vite主打的是開發環境的極致體驗,生產環境整合Rollup,這裡的對比主要是Webpack-dev-serverVite-dev-server的對比:

  • 到目前很長時間以來Webpack在前端工程領域佔統治地位,Vite推出以來備受關注,社群活躍,GitHub star 數量激增,目前達到37.4K圖片
  • Webpack配置豐富使用極為靈活但上手成本高,Vite開箱即用配置高度整合
  • Webpack啟動服務需打包構建,速度慢,Vite免編譯可秒開
  • Webpack熱更新需打包構建,速度慢,Vite毫秒響應
  • Webpack成熟穩定、資源豐富、大量實踐案例,Vite實踐較少
  • Vite使用esbuild編譯,構建速度比webpack快幾個數量級

相容性

  • 預設目標瀏覽器是在script標籤上支援原生 ESM 和 原生 ESM 動態匯入
  • 可使用官方外掛 @vitejs/plugin-legacy,轉義成傳統版本和相對應的polyfill

未來探索

  • 傳統構建工具效能已到瓶頸,主打開發體驗的Vite,可能會受到歡迎。
  • 主流瀏覽器基本支援ESM,ESM將成為主流。
  • ViteVue3.0代替vue-cli,作為官方腳手架,會大大提高使用量。
  • Vite2.0推出後,已可以在實際專案中使用Vite
  • 如果覺得直接使用Vite太冒險,又確實有dev server速度慢的問題需要解決,可以嘗試用Vite單獨搭建一套dev server

相關資源

官方外掛

除了支援現有的Rollup外掛系統外,官方提供了四個最關鍵的外掛

  • @vitejs/plugin-vue 提供 Vue3 單檔案元件支援
  • @vitejs/plugin-vue-jsx  提供 Vue3 JSX 支援(專用的 Babel 轉換外掛)
  • @vitejs/plugin-react 提供完整的 React 支援
  • @vitejs/plugin-legacy 為打包後的檔案提供傳統瀏覽器相容性支援

UI元件庫

  • Element UI[7]:支援 vite 引入