WebSocket實現簡單的webpack HMR(熱更新)效果

WebSocket實現簡單的webpack HMR(熱更新)效果

在上一篇 一文中,我們大致瞭解了webpack HMR 原理。可以看出以下幾點核心思想:

1、監聽文件變化

2、服務器與客戶端通信

3、替換流程

4、降級操作

當然,由於 webpack 本身有個很成熟的模塊思想和生態,因此整個架構設計會比我們實現的 HMR 複雜很多。在模塊熱替換中,是由 webpack 的全部流程出力來完成這一操作的,而並沒有侷限於 webpack-dev-server 和 webpack 以及業務代碼本身,實際上,起到更重要作用的是各類 loader,它們需要使用 HMR API 來實現 Hot Reload 的邏輯,決定什麼時候註冊模塊、什麼時候卸載模塊;如何註冊和卸載模塊。而 webpack 本身更像是一個調用方的角色,不需要考慮具體的註冊和反註冊邏輯。

HMR 的核心組織

經過了上面的分析,我們基本上確認了一個思路,也就是分析 webpack HMR 得出的結論。但是由於我們只有 runtime,所以實現 Hot Reload 變成了一個下圖的簡單流程:

1、Server 啟動一個 HTTP 服務器,並且註冊和啟動 WebSocket 服務,用於屆時與客戶端通信

2、在啟動 Static 服務器後返回頁面前注入 HMR 的客戶端代碼,業務方無需關心 HMR 的具體實現和添加對應的支持代碼服務端監聽磁盤文件的變更,將文件變更通過 WebSocket 發送給客戶端

3、客戶端收到文件變更消息後進行對應的模塊處理

4、(模塊處理失敗,降級為 Live Reload)

live reload?

在實現 HMR 之前,我們可以先實現一個簡單的 Live Reload 來保證我們 1-3 步的實現沒有異常。

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')

手機看代碼不方便,我把代碼截圖貼這裡了

WebSocket實現簡單的webpack HMR(熱更新)效果

上述代碼中,簡單的使用了 chokidar 這個文件監聽庫,它極大的減輕了我們的工作量;而 WebSocket 和服務器的實現上暫不贅述,之所以不直接使用 koa-static 的原因是因為我們需要對於 HTML 文件進行一些注入操作,以上 Live Reload 的實現非常簡單,基本可以總結為一句話:得知文件變化後向客戶端發送 reload 消息,客戶端收到消息執行頁面刷新操作。

實現了一個 Live Reload 之後,接下來我們只需要變更注入的代碼發送到客戶端的消息兩個部分即可,其實 Hot Reload 和 Live Reload 最大的區別也就是「最小模塊替換」與「刷新頁面」的區別,因此其他部分都是不用變動的。

替換 HTML 和 CSS 則是其中最簡單的兩項任務。

HTML

通常來說,我們要覆蓋 HTML 中的內容,除了刷新這一操作外,還有一個就是 document.write(),實際上我們也是通過這個函數來實現 HTML 的 Hot Reload 的:

// 監聽
.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');
}
WebSocket實現簡單的webpack HMR(熱更新)效果

那麼讀者最大的困惑可能變成了:精度怎麼粗糙的熱更新,好像跟直接刷頁面並沒有什麼區別?

如果我們要進行精度更高的熱更新,那麼帶來的性能差異其實是巨大的,我們來考慮一下如果我們希望儘可能細粒度的熱更新操作,接下來需要哪些操作:

  1. 讀取文件
  2. 構造語法樹
  3. 對比和之前的語法樹的差異
  4. 通信將差異傳給客戶端
  5. 將差異轉換為對應的 DOM 操作

那樣不可避免的,我們就要在內存中緩存每個頁面最初的語法樹,對於模塊化的組件來說,HTML 本身的變更其實是並不太多的,沒有必要進行這麼複雜的操作

CSS

CSS 也比較簡單,只要移除舊的 CSS 文件重新引入就能更新 CSS 了,這次,我們的代碼將會更加精簡。

// 監聽
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', '<link>')
console.log('[HMR] updated CSS');
}
WebSocket實現簡單的webpack HMR(熱更新)效果

相比 HTML 來說,CSS 顯得更加「無公害」——即使是整個文件替換更新,也不會帶來什麼壞處,甚至你都不需要對文件內容進行讀取,只需要重新加載文件內容。

JavaScript

最大的難點在於 JavaScript 熱更新的實現,如果我們參考 HTML 和 CSS 的實現,簡單的進行二次寫入,很快的就會遇到各種各樣的問題。在這裡,我們通過 eval 的方式進行再寫入。

假設我們對按鈕綁定了一個點擊事件,console.log(123),然後變成 console.log(1),使用原本的方法寫入之後,就會響應兩次事件,分別輸出 「123」和「1」。(這裡就不貼代碼了,感興趣的同學可以自己做這個實驗)

但是如同 HTML 的實現部分一樣,我們並不像進行復雜的語法樹構建來感知操作的是哪一個 DOM,那麼這個需求就變的很難處理。

得益於組件化,我們現在並不用太過關心這個問題,當我更新了一個文件的時候,我必然是更新了一個組件,只需要把這個組件的實例化移除並且重新載入即可,那樣與之綁定的相關事件也會被刪除。

整理一下思路,要執行 JS 的熱更新,我們大概會有以下幾個步驟:

  1. 感知每一個熱更新的組件:建立一個 k-v 結構,確保存入每個組件的實例,便於之後更新時刪除 DOM 並且更新
  2. 執行 eval 寫入代碼
  3. 遍歷 k-v 結構,刪除原先創建的 DOM,而實例渲染到 DOM 中的步驟是由框架本身處理的,我們甚至可以不用做任何操作

這裡我們以我最近在使用的那個無需構建即可運行的前端框架為例,從上述步驟中,我們可以知道,最重要的就是要劫持構造函數,在轉換為 DOM 時存入我們的 k-v 結構,方便以後使用。

// 劫持構造函數
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');
}
WebSocket實現簡單的webpack HMR(熱更新)效果

這樣在執行 eval 的過程中就會先記性一遍 DOM 的整理,執行完畢後新的組件就被渲染上去了。

當然,讀者可以發現這裡有一個前提條件,那就是沒有一個內容處於全局作用域,否則就會遇到重複聲明的 error 導致熱更新失敗。

基本上來說是一個非常簡單的 Hot Reload,可以完善的地方還是相當多的:

  1. 沒有維持連接的心跳包
  2. 頻繁對磁盤文件讀
  3. 降級 Live Reload 的操作
  4. 目前這種 Hot Reload 只支持單文件組件
  5. 不支持繼承

那麼,到底能不能有一個通用的支持任意 JS 的 hot reload 呢?目前為止感覺還不能解決重複聲明的問題,實際上,webpack 的由 loader 實現大致也是因為各個模塊會有其自己的風格,需要單獨去處理。

https://zhuanlan.zhihu.com/p/62381114


分享到:


相關文章: