原文首發在個人博客,歡迎訪問。css
已經很久沒有寫博客了。本文不說 Vue3.0 了,相信已經有不少文章在說它了。而前一段時間尤大開源的 Vite 則是一個更加吸引個人東西,它的整體思路是很不錯的,早期源碼的學習成本也比較低,因而就趁着假期學習一番。html
本文撰寫於 Vite-0.9.1 版本。vue
什麼是 Vite
借用做者的原話:node
Vite,一個基於瀏覽器原生 ES imports 的開發服務器。利用瀏覽器去解析 imports,在服務器端按需編譯返回,徹底跳過了打包這個概念,服務器隨起隨用。同時不只有 Vue 文件支持,還搞定了熱更新,並且熱更新的速度不會隨着模塊增多而變慢。針對生產環境則能夠把同一份代碼用 rollup 打包。雖然如今還比較粗糙,但這個方向我以爲是有潛力的,作得好能夠完全解決改一行代碼等半天熱更新的問題。webpack
注意到兩個點:ios
- 一個是 Vite 主要對應的場景是開發模式,原理是攔截瀏覽器發出的 ES imports 請求並作相應處理。(生產模式是用 rollup 打包)
- 一個是 Vite 在開發模式下不須要打包,只須要編譯瀏覽器發出的 HTTP 請求對應的文件便可,因此熱更新速度很快。
所以,要實現上述目標,須要要求項目裏只使用原生 ES imports,若是使用了 require 將失效,因此要用它徹底替代掉 Webpack 就目前來講仍是不太現實的。上面也說了,生產模式下的打包不是 Vite 自身提供的,所以生產模式下若是你想要用 Webpack 打包也依然是能夠的。從這個角度來講,Vite 可能更像是替代了 webpack-dev-server 的一個東西。git
modules 模塊
Vite 的實現離不開現代瀏覽器原生支持的 模塊功能。以下:github
<script type="module"> import { a } from './a.js' </script> 複製代碼
當聲明一個 script
標籤類型爲 module
時,瀏覽器將對其內部的 import
引用發起 HTTP
請求獲取模塊內容。好比上述,瀏覽器將發起一個對 HOST/a.js
的 HTTP 請求,獲取到內容以後再執行。web
Vite 劫持了這些請求,並在後端進行相應的處理(好比將 Vue 文件拆分紅 template
、style
、script
三個部分),而後再返回給瀏覽器。npm
因爲瀏覽器只會對用到的模塊發起 HTTP 請求,因此 Vite 不必對項目裏全部的文件先打包後返回,而是隻編譯瀏覽器發起 HTTP 請求的模塊便可。這裏是否是有點按需加載的味道?
編譯和打包的區別
看到這裏,可能有些朋友難免有些疑問,編譯和打包有什麼區別?爲何 Vite 號稱「熱更新的速度不會隨着模塊增多而變慢」?
簡單舉個例子,有三個文件 a.js
、b.js
、c.js
// a.js const a = () => { ... } export { a } // b.js const b = () => { ... } export { b } 複製代碼
// c.js import { a } from './a' import { b } from './b' const c = () => { return a() + b() } export { c } 複製代碼
若是以 c 文件爲入口,那麼打包就會變成以下(結果進行了簡化處理):(假定打包文件名爲 bundle.js
)
// bundle.js const a = () => { ... } const b = () => { ... } const c = () => { return a() + b() } export { c } 複製代碼
值得注意的是,打包也須要有編譯的步驟。
Webpack 的熱更新原理簡單來講就是,一旦發生某個依賴(好比上面的 a.js
)改變,就將這個依賴所處的 module
的更新,並將新的 module
發送給瀏覽器從新執行。因爲咱們只打了一個 bundle.js
,因此熱更新的話也會從新打這個 bundle.js
。試想若是依賴愈來愈多,就算只修改一個文件,理論上熱更新的速度也會愈來愈慢。
而若是是像 Vite 這種只編譯不打包會是什麼狀況呢?
只是編譯的話,最終產出的依然是 a.js
、b.js
、c.js
三個文件,只有編譯耗時。因爲入口是 c.js
,瀏覽器解析到 import { a } from './a'
時,會發起 HTTP 請求 a.js
(b 同理),就算不用打包,也能夠加載到所須要的代碼,所以省去了合併代碼的時間。
在熱更新的時候,若是 a
發生了改變,只須要更新 a
以及用到 a
的 c
。因爲 b
沒有發生改變,因此 Vite 無需從新編譯 b
,能夠從緩存中直接拿編譯的結果。這樣一來,修改一個文件 a
,只會從新編譯這個文件 a
以及瀏覽器當前用到這個文件 a
的文件,而其他文件都無需從新編譯。因此理論上熱更新的速度不會隨着文件增長而變慢。
固然這樣作有沒有很差的地方?有,初始化的時候若是瀏覽器請求的模塊過多,也會帶來初始化的性能問題。不過若是你能遇到初始化過慢的這個問題,相信熱更新的速度會彌補不少。固然我相信之後尤大也會解決這個問題。
Vite 運行 Web 應用的實現
上面說了這麼多的鋪墊,可能還不夠直觀,咱們能夠先跑一個 Vite 項目來實際看看。
按照官網的說明,能夠輸入以下命令(<project-name>
爲本身想要的目錄名便可)
$ npx create-vite-app <project-name> $ cd <project-name> $ npm install $ npm run dev 複製代碼
若是一切都正常你將在 localhost:3000
(Vite 的服務器起的端口) 看到這個界面:
並獲得以下的代碼結構:
.
├── App.vue // 頁面的主要邏輯
├── index.html // 默認打開的頁面以及 Vue 組件掛載
├── node_modules
└── package.json
複製代碼
攔截 HTTP 請求
接下來開始說一下 Vite 實現的核心——攔截瀏覽器對模塊的請求並返回處理後的結果。
咱們知道,因爲是在 localhost:3000
打開的網頁,因此瀏覽器發起的第一個請求天然是請求 localhost:3000/
,這個請求發送到 Vite 後端以後通過靜態資源服務器的處理,會進而請求到 /index.html
,此時 Vite 就開始對這個請求作攔截和處理了。
首先,index.html
裏的源碼是這樣的:
<div id="app"></div> <script type="module"> import { createApp } from 'vue' import App from './App.vue' createApp(App).mount('#app') </script> 複製代碼
可是在瀏覽器裏它是這樣的:
注意到什麼不一樣了嗎?是的, import { createApp } from 'vue'
換成了 import { createApp } from '/@modules/vue
。
這裏就不得不說瀏覽器對 import
的模塊發起請求時的一些侷限了,平時咱們寫代碼,若是不是引用相對路徑的模塊,而是引用 node_modules
的模塊,都是直接 import xxx from 'xxx'
,由 Webpack 等工具來幫咱們找這個模塊的具體路徑。可是瀏覽器不知道你項目裏有 node_modules
,它只能經過相對路徑去尋找模塊。
所以 Vite 在攔截的請求裏,對直接引用 node_modules
的模塊都作了路徑的替換,換成了 /@modules/
並返回回去。然後瀏覽器收到後,會發起對 /@modules/xxx
的請求,而後被 Vite 再次攔截,並由 Vite 內部去訪問真正的模塊,並將獲得的內容再次作一樣的處理後,返回給瀏覽器。
imports 替換
普通 JS import 替換
上面說的這步替換來自 src/node/serverPluginModuleRewrite.ts
:
// 只取關鍵代碼: // Vite 使用 Koa 做爲內置的服務器 // 若是請求的路徑是 /index.html if (ctx.path === '/index.html') { // ... const html = await readBody(ctx.body) ctx.body = html.replace( /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm, // 正則匹配 (_, openTag, script) => { // also inject __DEV__ flag const devFlag = hasInjectedDevFlag ? `` : devInjectionCode hasInjectedDevFlag = true // 替換 html 的 import 路徑 return `${devFlag}${openTag}${rewriteImports( script, '/index.html', resolver )}</script>` } ) // ... } 複製代碼
若是並無在 script
標籤內部直接寫 import
,而是用 src
的形式引用的話以下:
<script type="module" src="/main.js"></script> 複製代碼
那麼就會在瀏覽器發起對 main.js
請求的時候進行處理:
// 只取關鍵代碼: if ( ctx.response.is('js') && // ... ) { // ... const content = await readBody(ctx.body) await initLexer // 重寫 js 文件裏的 import ctx.body = rewriteImports( content, ctx.url.replace(/(&|\?)t=\d+/, ''), resolver, ctx.query.t ) // 寫入緩存,以後能夠從緩存中直接讀取 rewriteCache.set(content, ctx.body) } 複製代碼
替換邏輯 rewriteImports
就不展開了,用的是 es-module-lexer
來進行的語法分析獲取 imports
數組,而後再作的替換。
*.vue 文件的替換
若是 import
的是 .vue
文件,將會作更進一步的替換:
本來的 App.vue
文件長這樣:
<template> <h1>Hello Vite + Vue 3!</h1> <p>Edit ./App.vue to test hot module replacement (HMR).</p> <p> <span>Count is: {{ count }}</span> <button @click="count++">increment</button> </p> </template> <script> export default { data: () => ({ count: 0 }), } </script> <style scoped> h1 { color: #4fc08d; } h1, p { font-family: Arial, Helvetica, sans-serif; } </style> 複製代碼
替換後長這樣:
// localhost:3000/App.vue import { updateStyle } from "/@hmr" // 抽出 script 邏輯 const __script = { data: () => ({ count: 0 }), } // 將 style 拆分紅 /App.vue?type=style 請求,由瀏覽器繼續發起請求獲取樣式 updateStyle("c44b8200-0", "/App.vue?type=style&index=0&t=1588490870523") __script.__scopeId = "data-v-c44b8200" // 樣式的 scopeId // 將 template 拆分紅 /App.vue?type=template 請求,由瀏覽器繼續發起請求獲取 render function import { render as __render } from "/App.vue?type=template&t=1588490870523&t=1588490870523" __script.render = __render // render 方法掛載,用於 createApp 時渲染 __script.__hmrId = "/App.vue" // 記錄 HMR 的 id,用於熱更新 __script.__file = "/XXX/web/vite-test/App.vue" // 記錄文件的原始的路徑,後續熱更新能用到 export default __script 複製代碼
這樣就把本來一個 .vue
的文件拆成了三個請求(分別對應 script
、style
和template
) ,瀏覽器會先收到包含 script
邏輯的 App.vue
的響應,而後解析到 template
和 style
的路徑後,會再次發起 HTTP 請求來請求對應的資源,此時 Vite 對其攔截並再次處理後返回相應的內容。
以下:
不得不說這個思路是很是巧妙的。
這一步的拆分來自 src/node/serverPluginVue.ts
,核心邏輯是根據 URL 的 query 參數來作不一樣的處理(簡化分析以下):
// 若是沒有 query 的 type,好比直接請求的 /App.vue if (!query.type) { ctx.type = 'js' ctx.body = compileSFCMain(descriptor, filePath, publicPath) // 編譯 App.vue,編譯成上面說的帶有 script 內容,以及 template 和 style 連接的形式。 return etagCacheCheck(ctx) // ETAG 緩存檢測相關邏輯 } // 若是 query 的 type 是 template,好比 /App.vue?type=template&xxx if (query.type === 'template') { ctx.type = 'js' ctx.body = compileSFCTemplate( // 編譯 template 生成 render function // ... ) return etagCacheCheck(ctx) } // 若是 query 的 type 是 style,好比 /App.vue?type=style&xxx if (query.type === 'style') { const index = Number(query.index) const styleBlock = descriptor.styles[index] const result = await compileSFCStyle( // 編譯 style // ... ) if (query.module != null) { // 若是是 css module ctx.type = 'js' ctx.body = `export default ${JSON.stringify(result.modules)}` } else { // 正常 css ctx.type = 'css' ctx.body = result.code } } 複製代碼
@modules/* 路徑解析
上面只涉及到了替換的邏輯,解析的邏輯來自 src/node/serverPluginModuleResolve.ts
。這一步就相對簡單了,核心邏輯就是去 node_modules
裏找有沒有對應的模塊,有的話就返回,沒有的話就報 404:(省略了不少邏輯,好比對 web_modules
的處理、緩存的處理等)
// ... try { const file = resolve(root, id) // id 是模塊的名字,好比 axios return serve(id, file, 'node_modules') // 從 node_modules 中找到真正的模塊內容並返回 } catch (e) { console.error( chalk.red(`[vite] Error while resolving node_modules with id "${id}":`) ) console.error(e) ctx.status = 404 // 若是沒找到就 404 } 複製代碼
Vite 熱更新的實現
上面已經說完了 Vite 是如何運行一個 Web 應用的,包括如何攔截請求、替換內容、返回處理後的結果。接下來講一下 Vite 熱更新的實現,一樣實現的很是巧妙。
咱們知道,若是要實現熱更新,那麼就須要瀏覽器和服務器創建某種通訊機制,這樣瀏覽器才能收到通知進行熱更新。Vite 的是經過 WebSocket
來實現的熱更新通訊。
客戶端
客戶端的代碼在 src/client/client.ts
,主要是建立 WebSocket
客戶端,監聽來自服務端的 HMR 消息推送。
Vite 的 WS 客戶端目前監聽這幾種消息:
connected
: WebSocket 鏈接成功vue-reload
: Vue 組件從新加載(當你修改了 script 裏的內容時)vue-rerender
: Vue 組件從新渲染(當你修改了 template 裏的內容時)style-update
: 樣式更新style-remove
: 樣式移除js-update
: js 文件更新full-reload
: fallback 機制,網頁重刷新
其中針對 Vue 組件自己的一些更新,均可以直接調用 HMRRuntime
提供的方法,很是方便。其他的更新邏輯,基本上都是利用了 timestamp
刷新緩存從新執行的方法來達到更新的目的。
核心邏輯以下,我感受很是清晰明瞭:
import { HMRRuntime } from 'vue' // 來自 Vue3.0 的 HMRRuntime console.log('[vite] connecting...') declare var __VUE_HMR_RUNTIME__: HMRRuntime const socket = new WebSocket(`ws://${location.host}`) // Listen for messages socket.addEventListener('message', ({ data }) => { const { type, path, id, index, timestamp, customData } = JSON.parse(data) switch (type) { case 'connected': console.log(`[vite] connected.`) break case 'vue-reload': import(`${path}?t=${timestamp}`).then((m) => { __VUE_HMR_RUNTIME__.reload(path, m.default) console.log(`[vite] ${path} reloaded.`) // 調用 HMRRUNTIME 的方法更新 }) break case 'vue-rerender': import(`${path}?type=template&t=${timestamp}`).then((m) => { __VUE_HMR_RUNTIME__.rerender(path, m.render) console.log(`[vite] ${path} template updated.`) // 調用 HMRRUNTIME 的方法更新 }) break case 'style-update': updateStyle(id, `${path}?type=style&index=${index}&t=${timestamp}`) // 從新加載 style 的 URL console.log( `[vite] ${path} style${index > 0 ? `#${index}` : ``} updated.` ) break case 'style-remove': const link = document.getElementById(`vite-css-${id}`) if (link) { document.head.removeChild(link) // 刪除 style } break case 'js-update': const update = jsUpdateMap.get(path) if (update) { update(timestamp) // 用新的時間戳加載並執行 js,達到更新的目的 console.log(`[vite]: js module reloaded: `, path) } else { console.error( `[vite] got js update notification but no client callback was registered. Something is wrong.` ) } break case 'custom': const cbs = customUpdateMap.get(id) if (cbs) { cbs.forEach((cb) => cb(customData)) } break case 'full-reload': location.reload() } }) 複製代碼
服務端
服務端的實現位於 src/node/serverPluginHmr.ts
。核心是監聽項目文件的變動,而後根據不一樣文件類型(目前只有 vue
和 js
)來作不一樣的處理:
watcher.on('change', async (file) => { const timestamp = Date.now() // 更新時間戳 if (file.endsWith('.vue')) { handleVueReload(file, timestamp) } else if (file.endsWith('.js')) { handleJSReload(file, timestamp) } }) 複製代碼
對於 Vue
文件的熱更新而言,主要是從新編譯 Vue
文件,檢測 template
、script
、style
的改動,若是有改動就經過 WS 服務端發起對應的熱更新請求。
簡單的源碼分析以下:
async function handleVueReload( file: string, timestamp: number = Date.now(), content?: string ) { const publicPath = resolver.fileToRequest(file) // 獲取文件的路徑 const cacheEntry = vueCache.get(file) // 獲取緩存裏的內容 debugHmr(`busting Vue cache for ${file}`) vueCache.del(file) // 發生變更了所以以前的緩存能夠刪除 const descriptor = await parseSFC(root, file, content) // 編譯 Vue 文件 const prevDescriptor = cacheEntry && cacheEntry.descriptor // 獲取前一次的緩存 if (!prevDescriptor) { // 這個文件以前從未被訪問過(本次是第一次訪問),也就不必熱更新 return } // 設置兩個標誌位,用於判斷是須要 reload 仍是 rerender let needReload = false let needRerender = false // 若是 script 部分不一樣則須要 reload if (!isEqual(descriptor.script, prevDescriptor.script)) { needReload = true } // 若是 template 部分不一樣則須要 rerender if (!isEqual(descriptor.template, prevDescriptor.template)) { needRerender = true } const styleId = hash_sum(publicPath) // 獲取以前的 style 以及下一次(或者說熱更新)的 style const prevStyles = prevDescriptor.styles || [] const nextStyles = descriptor.styles || [] // 若是不須要 reload,則查看是否須要更新 style if (!needReload) { nextStyles.forEach((_, i) => { if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) { send({ type: 'style-update', path: publicPath, index: i, id: `${styleId}-${i}`, timestamp }) } }) } // 若是 style 標籤及內容刪掉了,則須要發送 `style-remove` 的通知 prevStyles.slice(nextStyles.length).forEach((_, i) => { send({ type: 'style-remove', path: publicPath, id: `${styleId}-${i + nextStyles.length}`, timestamp }) }) // 若是須要 reload 發送 `vue-reload` 通知 if (needReload) { send({ type: 'vue-reload', path: publicPath, timestamp }) } else if (needRerender) { // 不然發送 `vue-rerender` 通知 send({ type: 'vue-rerender', path: publicPath, timestamp }) } } 複製代碼
對於熱更新 js
文件而言,會遞歸地查找引用這個文件的 importer
。好比是某個 Vue
文件所引用了這個 js
,就會被查找出來。假如最終發現找不到引用者,則會返回 hasDeadEnd: true
。
const vueImporters = new Set<string>() // 查找並存放須要熱更新的 Vue 文件 const jsHotImporters = new Set<string>() // 查找並存放須要熱更新的 js 文件 const hasDeadEnd = walkImportChain( publicPath, importers, vueImporters, jsHotImporters ) 複製代碼
若是 hasDeadEnd
爲 true
,則直接發送 full-reload
。若是 vueImporters
或 jsHotImporters
裏查找到須要熱更新的文件,則發起熱更新通知:
if (hasDeadEnd) { send({ type: 'full-reload', timestamp }) } else { vueImporters.forEach((vueImporter) => { send({ type: 'vue-reload', path: vueImporter, timestamp }) }) jsHotImporters.forEach((jsImporter) => { send({ type: 'js-update', path: jsImporter, timestamp }) }) } 複製代碼
客戶端邏輯的注入
寫到這裏,還有一個問題是,咱們在本身的代碼裏並無引入 HRM
的 client
代碼,Vite 是如何把 client
代碼注入的呢?
回到上面的一張圖,Vite 重寫 App.vue
文件的內容並返回時:
注意這張圖裏的代碼區第一句話 import { updateStyle } from '/@hmr'
,而且在左側請求列表中也有一個對 @hmr
文件的請求。這個請求是啥呢?
能夠發現,這個請求就是上面說的客戶端邏輯的 client.ts
的內容。
在 src/node/serverPluginHmr.ts
裏,有針對 @hmr
文件的解析處理:
export const hmrClientFilePath = path.resolve(__dirname, './client.js') export const hmrClientId = '@hmr' export const hmrClientPublicPath = `/${hmrClientId}` app.use(async (ctx, next) => { if (ctx.path !== hmrClientPublicPath) { // 請求路徑若是不是 @hmr 就跳過 return next() } debugHmr('serving hmr client') ctx.type = 'js' await cachedRead(ctx, hmrClientFilePath) // 返回 client.js 的內容 }) 複製代碼
至此,熱更新的總體流程已經解析完畢。
小結
這個項目最近在以驚人的速度迭代着,所以沒過多久之後再回頭看這篇文章,可能代碼、實現已通過時。不過 Vite 的總體思路是很是棒的,在早期源碼很少的狀況下,能學到更貼近做者原始想法的東西,也算是很不錯的收穫。但願本文能給你學習 Vite 一些參考,有錯誤也歡迎你們指出。