想必作爲前端大佬的你,工作中應該用過 webpack,並且對熱更新的特性也有了解。如果沒有,當然也沒關係。
下面我要講的,是我對 Webpack 熱更新機制的一些認識和理解,不足之處,歡迎指正。
首先:
熱更新是啥?
熱更新,是指 Hot Module Replacement,縮寫爲 HMR。
從名字上解讀,就是把“熱”的模塊進行替換。熱,是指這個模塊已經在運行中。
不知道你有沒有聽過或看過這樣一段話:“在高速公路上將汽車引擎換成波音747飛機引擎”。
雖然有點牽強,但是放在這裏,從某些角度上來說,也還算合適吧。
再扯遠一點,說下我目前工作中的遇到的情況,相信很多人也遇到過。
微信小程序的開發工具,沒有提供類似 Webpack 熱更新的機制,所以在本地開發時,每次修改了代碼,預覽頁面都會刷新,於是之前的路由跳轉狀態、表單中填入的數據,都沒了。
哪怕只是一個文案或屬性配置的修改,都會導致刷新,而要重新進入特定頁面和狀態,有時候很麻煩。對於開發時需要頻繁修改代碼的情況,這樣比較浪費時間。
而如果有類似 Webpack 熱更新的機制存在,則是修改了代碼,不會導致刷新,而是保留現有的數據狀態,只將模塊進行更新替換。也就是說,既保留了現有的數據狀態,又能看到代碼修改後的變化。
很美好,但是想想就覺得是一件肯定不簡單的事情。
所以,熱更新是啥呢?
引用官方文檔,熱更新是:
使得應用在運行狀態下,不重載刷新就能更新、增加、移除模塊的機制
熱更新解決的問題
那麼熱更新要解決的問題,在上面也解釋了。用我的話來闡述,就是 在應用程序的開發環境,方便開發人員在不刷新頁面的情況下,就能修改代碼,並且直觀地在頁面上看到變化的機制。
簡單來說,就是爲了 提升開發效率。
聯想到我在微信小程序上的開發體驗,真心覺得如果有熱更新機制的話,開發效率要高很多。
如果你知道微信小程序已經或計劃支持熱更新,或者有大佬已經做了類似的工作,歡迎告訴我,感謝!
進一步介紹前,我們來看下 Webpack 熱更新如何配置。
熱更新配置
如果你之前做的項目是其他人搭建配置了 Webpack 和熱更新,那麼這裏可以瞭解下熱更新是怎麼配置的。
我的示例採用 Webpack 4,想直接看代碼的話,在這裏:
https://github.com/luobotang/...
除了 Webpack,還需要 webpack-dev-server
(或 webpack-dev-middleware
)。
爲 Webpack 開發環境開啓熱更新,要做兩件事:
- 使用
HotModuleReplacementPlugin
插件 - 打開
webpack-dev-server
的熱更新開關
HotModuleReplacementPlugin
插件是 Webpack 自帶的,在 webpack.config.js
加入就好:
// webpack.config.js
module.exports = {
// ...
plugins: [
webpack.HotModuleReplacementPlugin(),
// ...
]
}
如果直接通過 webpack-dev-server 啓動 Webpack 的開發環境,那麼可以這樣打開 webpack-dev-server 的熱更新開關:
// webpack.config.js
module.exports = {
// ...
devServer: {
hot: true,
// ...
}
}
也很簡單。
熱更新示例
下面通過例子來進一步解釋熱更新機制。如果你之前對 Webpack 熱更新的體驗,是 Vue 通過 vue-loader 提供給你的,也就是說你在自己的代碼中從沒有寫過或者見到過類似:
if (module.hot) {
module.hot.accept(/* ... */)
// ...
}
這樣的代碼,那麼下面的例子就剛好適合看一看了。
這些例子就在上面的 webpack-hmr-demo,如果你對代碼更親切,那直接去看吧,首頁文檔裏有簡單的說明。
示例1:沒有熱更新的情況
這個例子只是把示例頁面的功能簡單介紹下,並且讓你體會下每次修改代碼都要重新刷新頁面的痛苦。
頁面上只有一個元素,用來展示數值:
<div id="root" class="number"></div>
入口模塊(index.js)引用了兩個模塊:
- timer.js:只提供了一個 start 接口,傳入回調函數,然後 timer 會間隔一段時間調用回調函數,並傳入一個每次增加的數值
- foo.js:沒啥功能,就簡單暴露一個 message,引入它單純是區別 timer.js 展示不同的模塊更新處理方法
入口模塊的功能很簡單,調用 timer.start()
,再傳入的回調函數中,每次將得到的數值更新到頁面上顯示:
import { start } from './timer'
import { message } from './foo'
var current = 0
var root = document.getElementById('root')
start(onUpdate, current)
console.log(message)
function onUpdate(i) {
current = i
root.textContent = '#' + i
}
將這個項目運行起來,打開的頁面中就是在一直刷新展示增加的數值而已,類似這樣:
一旦修改任何模塊的代碼,例如改變 timer 中定時器的間隔時間(如從1秒改成3秒),或者 onUpdate 中展示的內容(如 '#' + i
改成 '*' + i
),頁面都會刷新,已經有的狀態清除,重新從0開始計數。
示例2:處理依賴模塊的熱更新
接下來的例子,展示在 index.js 如何處理其他模塊的更新。
依賴的模塊發生更新,要麼是接受變更(頁面不用刷新,模塊替換下就好),要麼不接受(必須得刷新)。
Webpack 將熱更新相關接口以 module.hot
暴露到模塊中,在使用前,最好判斷下當前的環境是否支持熱更新,也就是上面看到的這樣的代碼:
if (module.hot) {
// ...
}
延續上一個例子,選擇接受並處理 timer 的更新,但對於 foo 模塊,不接受:
if (module.hot) {
module.hot.accept('timer', () => {
// ...
})
module.hot.decline('./foo')
}
所以,在熱更新的機制中,其實是以這種“聲明”的方式告知 Webpack,哪些模塊的更新是被處理的,哪些模塊的更新又不被處理。當然對於要處理的模塊的更新,自行在 module.hot.accept() 的第二個參數即回調函數中進行處理,會在聲明的模塊被替換後執行。
下面來看對 timer 模塊更新的處理。
timer 模塊的 start 函數調用後返回一個可以終止定時器的 stop 函數,藉助它我們實現對舊的 timer 模塊的清理,並基於當前狀態重新調用新的 timer 模塊的 start 函數:
var stop = start(onUpdate, current) // 先記錄下返回的 stop 函數
// ...
if (module.hot) {
module.hot.accept('timer', () => {
stop()
stop = start(onUpdate, current)
})
// ...
}
處理邏輯如上所述,先通過之前記錄的 stop 停止舊模塊的定時器,然後調用新模塊的 start 繼續計數,並且傳入當前數值從而不必從0開始重新計數。
看起來還是比較簡單的吧。運行起來的效果是,如果修改 timer 中的定時器間隔時間,立即在頁面上就能看到效果,而且頁面並不會刷新導致重新從0開始計數:
在運行幾秒後,修改 timer 模塊中定時器的間隔時間爲 100ms
修改 foo 中的 message,頁面還是會刷新。
有幾點額外說明下:
- timer 模塊如果修改後不返回 start 接口,那麼上述處理機制顯然會失效,所以這裏的處理是基於模塊的接口不變的情況下
- timer 模塊的 start 調用後顯然必須返回一個 stop 函數,否則在 index.js 是沒法清除 timer 模塊內開啓的定時器的,這也很重要
- 或許你也注意到了,就是對 timer 模塊的 start 函數的引用貌似一直沒有變過,那爲什麼在回調函數中的 start 就是新模塊了呢?這個其實是有 Webpack 在編譯時處理掉的,編譯後的代碼並非當前的樣式,對 start 會進行替換,使得回調中的 start 一定引用到的是新的 timer 模塊的 start。感興趣可以看下 Webpack 文檔中對此的相關描述。
此外,除了聲明其他模塊更新的處理,模塊也可以聲明自身更新的處理,也是同樣的接口,不傳參數即可:
-
module.hot.accept()
告訴 Webpack,當前模塊更新不用刷新 -
module.hot.decline()
告訴 Webpack,當前模塊更新時一定要刷新
而且,依賴同一個模塊的不同模塊,可以有各自不同的聲明,這些聲明可能是衝突的,比如有的允許依賴模塊更新,有的不允許,Webpack 怎麼協調這些呢?
Webpack 的實現機制有點類似 DOM 事件的冒泡機制,更新事件先由模塊自身處理,如果模塊自身沒有任何聲明,纔會向上冒泡,檢查使用方是否有對該模塊更新的聲明,以此類推。如果最終入口模塊也沒有任何聲明,那麼就刷新頁面了。這也就是爲什麼在上一個例子中,雖然開啓了熱更新,但是模塊修改後仍舊刷新頁面的原因,因爲沒有任何模塊對更新進行處理。
示例3:處理自身模塊的熱更新
自身模塊的更新處理與依賴模塊類似,也是要通過 module.hot 的接口向 Webpack 聲明。不過模塊自身的更新,可能需要在模塊被 Webpack 替換之前就做一些處理,更新後的處理則不必通過特別接口來做,直接寫到新模塊代碼裏面就好。
module.hot.dispose()
用於註冊當前模塊被替換前的處理函數,並且回調函數接收一個 data 對象,可以向其寫入需要保存的數據,這樣在新的模塊執行時可以通過 module.hot.data
獲取到:
var current = 0
if (module.hot && module.hot.data) {
current = module.hot.data.current
}
首先,模塊執行時,先檢查有沒有舊模塊留下來的數據,如果有,就恢復。
然後在模塊被替換前的執行處理,這裏就是記錄數據、停掉現有的定時器:
if (module.hot)
module.hot.accept()
module.hot.dispose(data => {
data.current = current
stop()
})
}
做了這些處理之後,修改 index.js 的 onUpdate,使得渲染到頁面的數值改變,也可以在不刷新的情況下體現:
在運行幾秒後,修改 onUpdate() 中的'#' + i
爲'*' + i
總結
看過上面的例子,我們來總結下。
Webpack 的熱更新,其實只是提供一套接口和基礎的模塊替換的實現。作爲開發者,需要在代碼中通過熱更新接口(module.hot.xxx)向 Webpack 聲明依賴模塊和當前模塊是否能夠更新,以及更新的前後進行的處理。
如果接受更新,那麼需要開發者自己來在模塊被替換前清理或保留必要的數據、狀態,並在模塊被替換後恢復之前的數據、狀態。
當然,像我們在使用 Vue 或 React 進行開發時,vue-loder 等插件已經幫我們做了這些事情,並且對於 *.vue 文件在更新時要如果進行處理,很多細節也只有 vue-loader 內部比較清楚,我們就放心使用好了。
但是對於 Webpack 熱更新是怎麼一回事,如果能夠有深入瞭解當然更好,我就遇到過同事在 Vue 組件中自行對 DOM 進行處理(爲了封裝一個直接操作 DOM 的組件),結果由於熱更新的存在,導致一些狀態的清除有問題的情況。
這種情況,只有開發者自己才能處理,vue-loader 可沒法處理這樣的特殊情況。至少知道如何使用 Webpack 的熱更新接口,這種情況下開發者就能自行處理了。
本文對於 Webpack 熱更新機制的介紹還只是在接口使用的層面,或者大體的機制上,沒有深入說明熱更新的實現原理和細節。時間、篇幅有限,那就先放一張圖出來,或許有時間再細說一下。
上圖來源:Webpack & The Hot Module Replacement
https://medium.com/@rajaraodv/webpack-hot-module-replacement-hmr-e756a726a07這篇英文文章對 Webpack 熱更新實現原理方面有深入介紹。