原文首發在個人博客,歡迎訪問。javascript
已經很久沒有寫博客了。本文不說 Vue3.0 了,相信已經有不少文章在說它了。而前一段時間尤大開源的 Vite 則是一個更加吸引個人東西,它的整體思路是很不錯的,早期源碼的學習成本也比較低,因而就趁着假期學習一番。css
本文撰寫於 Vite-0.9.1 版本。html
借用做者的原話:vue
Vite,一個基於瀏覽器原生 ES imports 的開發服務器。利用瀏覽器去解析 imports,在服務器端按需編譯返回,徹底跳過了打包這個概念,服務器隨起隨用。同時不只有 Vue 文件支持,還搞定了熱更新,並且熱更新的速度不會隨着模塊增多而變慢。針對生產環境則能夠把同一份代碼用 rollup 打包。雖然如今還比較粗糙,但這個方向我以爲是有潛力的,作得好能夠完全解決改一行代碼等半天熱更新的問題。java
注意到兩個點:node
所以,要實現上述目標,須要要求項目裏只使用原生 ES imports,若是使用了 require 將失效,因此要用它徹底替代掉 Webpack 就目前來講仍是不太現實的。上面也說了,生產模式下的打包不是 Vite 自身提供的,所以生產模式下若是你想要用 Webpack 打包也依然是能夠的。從這個角度來講,Vite 可能更像是替代了 webpack-dev-server 的一個東西。webpack
Vite 的實現離不開現代瀏覽器原生支持的 模塊功能。以下:ios
<script type="module"> import { a } from './a.js' </script>
複製代碼
當聲明一個 script
標籤類型爲 module
時,瀏覽器將對其內部的 import
引用發起 HTTP
請求獲取模塊內容。好比上述,瀏覽器將發起一個對 HOST/a.js
的 HTTP 請求,獲取到內容以後再執行。git
Vite 劫持了這些請求,並在後端進行相應的處理(好比將 Vue 文件拆分紅 template
、style
、script
三個部分),而後再返回給瀏覽器。github
因爲瀏覽器只會對用到的模塊發起 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 項目來實際看看。
按照官網的說明,能夠輸入以下命令(<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
複製代碼
接下來開始說一下 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 內部去訪問真正的模塊,並將獲得的內容再次作一樣的處理後,返回給瀏覽器。
上面說的這步替換來自 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
數組,而後再作的替換。
若是 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
}
}
複製代碼
上面只涉及到了替換的邏輯,解析的邏輯來自 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 是如何運行一個 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 一些參考,有錯誤也歡迎你們指出。