我們大致了解了webpack HMR 原理??梢钥闯鲆韵聨c(diǎn)核心思想:
1、監(jiān)聽(tīng)文件變化
2、服務(wù)器與客戶端通信
3、替換流程
4、降級(jí)操作
當(dāng)然,由于 webpack 本身有個(gè)很成熟的模塊思想和生態(tài),因此整個(gè)架構(gòu)設(shè)計(jì)會(huì)比我們實(shí)現(xiàn)的 HMR 復(fù)雜很多。在模塊熱替換中,是由 webpack 的全部流程出力來(lái)完成這一操作的,而并沒(méi)有局限于 webpack-dev-server 和 webpack 以及業(yè)務(wù)代碼本身,實(shí)際上,起到更重要作用的是各類 loader,它們需要使用 HMR API 來(lái)實(shí)現(xiàn) Hot Reload 的邏輯,決定什么時(shí)候注冊(cè)模塊、什么時(shí)候卸載模塊;如何注冊(cè)和卸載模塊。而 webpack 本身更像是一個(gè)調(diào)用方的角色,不需要考慮具體的注冊(cè)和反注冊(cè)邏輯。
HMR 的核心組織
經(jīng)過(guò)了上面的分析,我們基本上確認(rèn)了一個(gè)思路,也就是分析 webpack HMR 得出的結(jié)論。但是由于我們只有 runtime,所以實(shí)現(xiàn) Hot Reload 變成了一個(gè)下圖的簡(jiǎn)單流程:
1、Server 啟動(dòng)一個(gè) HTTP 服務(wù)器,并且注冊(cè)和啟動(dòng) WebSocket 服務(wù),用于屆時(shí)與客戶端通信
2、在啟動(dòng) Static 服務(wù)器后返回頁(yè)面前注入 HMR 的客戶端代碼,業(yè)務(wù)方無(wú)需關(guān)心 HMR 的具體實(shí)現(xiàn)和添加對(duì)應(yīng)的支持代碼服務(wù)端監(jiān)聽(tīng)磁盤文件的變更,將文件變更通過(guò) WebSocket 發(fā)送給客戶端
3、客戶端收到文件變更消息后進(jìn)行對(duì)應(yīng)的模塊處理
4、(模塊處理失敗,降級(jí)為 Live Reload)
live reload?
在實(shí)現(xiàn) HMR 之前,我們可以先實(shí)現(xiàn)一個(gè)簡(jiǎn)單的 Live Reload 來(lái)保證我們 1-3 步的實(shí)現(xiàn)沒(méi)有異常。
const Koa = require('koa')
const WebSocket = require('ws')
const chokidar = require('chokidar')
const app = new Koa()
const fs = require('fs').promises
const wss = new WebSocket.Server({ port: 8000 })
const dir = './static'
const watcher = chokidar.watch('./static', {
ignored: /node_modules|.git|[/\]./
})
wss.on('connection', (ws) => {
watcher
.on('add', path => console.log(`File ${path} added`))
.on('change', path => console.log(`File ${path} has been changed`))
.on('unlink', path => console.log(`File ${path} has been moved`))
.on('all', async (event, path) => {
// Simple Live Reload
ws.send('reload')
})
ws.on('message', (message) => {
console.log('received: %s', message)
})
ws.send('HMR Client is Ready')
})
const injectedData = ` `
app.use(async (ctx, next) => {
let file = ctx.path
if (ctx.path.endsWith('/')) {
file = ctx.path + 'index.html'
}
let body
try {
body = await fs.readFile(dir + file, {
encoding: 'utf-8'
})
} catch(e) {
ctx.status = 404
return next()
}
if (file.endsWith('.html')) body = body.replace('', `${injectedData}`)
if (file.endsWith('.css')) ctx.type = 'text/css'
ctx.body = body
next()
})
app.listen(3001)
console.log('listen on port 3001')
手機(jī)看代碼不方便,我把代碼截圖貼這里了

上述代碼中,簡(jiǎn)單的使用了 chokidar 這個(gè)文件監(jiān)聽(tīng)?zhēng)欤鼧O大的減輕了我們的工作量;而 WebSocket 和服務(wù)器的實(shí)現(xiàn)上暫不贅述,之所以不直接使用 koa-static 的原因是因?yàn)槲覀冃枰獙?duì)于 HTML 文件進(jìn)行一些注入操作,以上 Live Reload 的實(shí)現(xiàn)非常簡(jiǎn)單,基本可以總結(jié)為一句話:得知文件變化后向客戶端發(fā)送 reload 消息,客戶端收到消息執(zhí)行頁(yè)面刷新操作。
實(shí)現(xiàn)了一個(gè) Live Reload 之后,接下來(lái)我們只需要變更注入的代碼和發(fā)送到客戶端的消息兩個(gè)部分即可,其實(shí) Hot Reload 和 Live Reload 最大的區(qū)別也就是「最小模塊替換」與「刷新頁(yè)面」的區(qū)別,因此其他部分都是不用變動(dòng)的。
替換 HTML 和 CSS 則是其中最簡(jiǎn)單的兩項(xiàng)任務(wù)。
HTML
通常來(lái)說(shuō),我們要覆蓋 HTML 中的內(nèi)容,除了刷新這一操作外,還有一個(gè)就是 document.write(),實(shí)際上我們也是通過(guò)這個(gè)函數(shù)來(lái)實(shí)現(xiàn) HTML 的 Hot Reload 的:
// 監(jiān)聽(tīng)
.on('all', async (event, path) => {
if (path.endsWith('.html')) {
body = await fs.readFile(path, {
encoding: 'utf-8'
})
const message = JSON.stringify({ type: 'html', content: body })
ws.send(message)
}
})
// 注入
let data = {}
try {
data = JSON.parse(event.data)
} catch (e) {
// return
}
console.log(data)
if (data.type === 'html') {
document.write(data.content);
document.close();
console.log('[HMR] updated HTML');
}

那么讀者最大的困惑可能變成了:精度怎么粗糙的熱更新,好像跟直接刷頁(yè)面并沒(méi)有什么區(qū)別?
如果我們要進(jìn)行精度更高的熱更新,那么帶來(lái)的性能差異其實(shí)是巨大的,我們來(lái)考慮一下如果我們希望盡可能細(xì)粒度的熱更新操作,接下來(lái)需要哪些操作:
讀取文件
構(gòu)造語(yǔ)法樹(shù)
對(duì)比和之前的語(yǔ)法樹(shù)的差異
通信將差異傳給客戶端
將差異轉(zhuǎn)換為對(duì)應(yīng)的 DOM 操作
那樣不可避免的,我們就要在內(nèi)存中緩存每個(gè)頁(yè)面最初的語(yǔ)法樹(shù),對(duì)于模塊化的組件來(lái)說(shuō),HTML 本身的變更其實(shí)是并不太多的,沒(méi)有必要進(jìn)行這么復(fù)雜的操作
CSS
CSS 也比較簡(jiǎn)單,只要移除舊的 CSS 文件重新引入就能更新 CSS 了,這次,我們的代碼將會(huì)更加精簡(jiǎn)。
// 監(jiān)聽(tīng)
if (path.endsWith('.css')) {
const message = JSON.stringify({ type: 'css', content: path.split('static/')[1] })
ws.send(message)
}
// 注入
if (data.type === 'css') {
const host = location.host
document.querySelectorAll('link[rel="stylesheet"]').forEach(el => {
const resource = el.href.split(host + '/')[1]
console.log(resource)
if (resource === data.content) el.remove()
})
document.head.insertAdjacentHTML('beforeend', '
')console.log('[HMR] updated CSS');
}

相比 HTML 來(lái)說(shuō),CSS 顯得更加「無(wú)公害」——即使是整個(gè)文件替換更新,也不會(huì)帶來(lái)什么壞處,甚至你都不需要對(duì)文件內(nèi)容進(jìn)行讀取,只需要重新加載文件內(nèi)容。
JavaScript
最大的難點(diǎn)在于 JavaScript 熱更新的實(shí)現(xiàn),如果我們參考 HTML 和 CSS 的實(shí)現(xiàn),簡(jiǎn)單的進(jìn)行二次寫入,很快的就會(huì)遇到各種各樣的問(wèn)題。在這里,我們通過(guò) eval 的方式進(jìn)行再寫入。
假設(shè)我們對(duì)按鈕綁定了一個(gè)點(diǎn)擊事件,console.log(123),然后變成 console.log(1),使用原本的方法寫入之后,就會(huì)響應(yīng)兩次事件,分別輸出 「123」和「1」。(這里就不貼代碼了,感興趣的同學(xué)可以自己做這個(gè)實(shí)驗(yàn))
但是如同 HTML 的實(shí)現(xiàn)部分一樣,我們并不像進(jìn)行復(fù)雜的語(yǔ)法樹(shù)構(gòu)建來(lái)感知操作的是哪一個(gè) DOM,那么這個(gè)需求就變的很難處理。
得益于組件化,我們現(xiàn)在并不用太過(guò)關(guān)心這個(gè)問(wèn)題,當(dāng)我更新了一個(gè)文件的時(shí)候,我必然是更新了一個(gè)組件,只需要把這個(gè)組件的實(shí)例化移除并且重新載入即可,那樣與之綁定的相關(guān)事件也會(huì)被刪除。
整理一下思路,要執(zhí)行 JS 的熱更新,我們大概會(huì)有以下幾個(gè)步驟:
感知每一個(gè)熱更新的組件:建立一個(gè) k-v 結(jié)構(gòu),確保存入每個(gè)組件的實(shí)例,便于之后更新時(shí)刪除 DOM 并且更新
執(zhí)行 eval 寫入代碼
遍歷 k-v 結(jié)構(gòu),刪除原先創(chuàng)建的 DOM,而實(shí)例渲染到 DOM 中的步驟是由框架本身處理的,我們甚至可以不用做任何操作
這里我們以我最近在使用的那個(gè)無(wú)需構(gòu)建即可運(yùn)行的前端框架為例,從上述步驟中,我們可以知道,最重要的就是要劫持構(gòu)造函數(shù),在轉(zhuǎn)換為 DOM 時(shí)存入我們的 k-v 結(jié)構(gòu),方便以后使用。
// 劫持構(gòu)造函數(shù)
const JKL = window.Jinkela
const storage = {}
let latest = true
window.Jinkela = class jkl extends JKL {
constructor(...args) {
super(...args)
const values = storage[this.constructor.name]
if (!latest) {
storage[this.constructor.name].forEach(el => el.remove())
storage[this.constructor.name] = []
latest = true
}
storage[this.constructor.name] = values ? [...values, this.element] : [ this.element ]
}
}
// 注入
if (data.type === 'js') {
latest = false
eval(data.content)
console.log('[HMR] updated JS');
}

這樣在執(zhí)行 eval 的過(guò)程中就會(huì)先記性一遍 DOM 的整理,執(zhí)行完畢后新的組件就被渲染上去了。
當(dāng)然,讀者可以發(fā)現(xiàn)這里有一個(gè)前提條件,那就是沒(méi)有一個(gè)內(nèi)容處于全局作用域,否則就會(huì)遇到重復(fù)聲明的 error 導(dǎo)致熱更新失敗。
基本上來(lái)說(shuō)是一個(gè)非常簡(jiǎn)單的 Hot Reload,可以完善的地方還是相當(dāng)多的:
沒(méi)有維持連接的心跳包
頻繁對(duì)磁盤文件讀
降級(jí) Live Reload 的操作
目前這種 Hot Reload 只支持單文件組件
不支持繼承
那么,到底能不能有一個(gè)通用的支持任意 JS 的 hot reload 呢?目前為止感覺(jué)還不能解決重復(fù)聲明的問(wèn)題,實(shí)際上,webpack 的由 loader 實(shí)現(xiàn)大致也是因?yàn)楦鱾€(gè)模塊會(huì)有其自己的風(fēng)格,需要單獨(dú)去處理。
本文由網(wǎng)上采集發(fā)布,不代表我們立場(chǎng),轉(zhuǎn)載聯(lián)系作者并注明出處:http://m.zltfw.cn/shbk/37598.html