Webpack 熱更新機制

想必作爲前端大佬的你,工作中應該用過 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
}

將這個項目運行起來,打開的頁面中就是在一直刷新展示增加的數值而已,類似這樣:

hmr-demo-1

一旦修改任何模塊的代碼,例如改變 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開始計數:

hmr-demo-2

在運行幾秒後,修改 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,使得渲染到頁面的數值改變,也可以在不刷新的情況下體現:

hmr-demo-3

在運行幾秒後,修改 onUpdate() 中的 '#' + i'*' + i

總結

看過上面的例子,我們來總結下。

Webpack 的熱更新,其實只是提供一套接口和基礎的模塊替換的實現。作爲開發者,需要在代碼中通過熱更新接口(module.hot.xxx)向 Webpack 聲明依賴模塊和當前模塊是否能夠更新,以及更新的前後進行的處理。

如果接受更新,那麼需要開發者自己來在模塊被替換前清理或保留必要的數據、狀態,並在模塊被替換後恢復之前的數據、狀態。

當然,像我們在使用 Vue 或 React 進行開發時,vue-loder 等插件已經幫我們做了這些事情,並且對於 *.vue 文件在更新時要如果進行處理,很多細節也只有 vue-loader 內部比較清楚,我們就放心使用好了。

但是對於 Webpack 熱更新是怎麼一回事,如果能夠有深入瞭解當然更好,我就遇到過同事在 Vue 組件中自行對 DOM 進行處理(爲了封裝一個直接操作 DOM 的組件),結果由於熱更新的存在,導致一些狀態的清除有問題的情況。

這種情況,只有開發者自己才能處理,vue-loader 可沒法處理這樣的特殊情況。至少知道如何使用 Webpack 的熱更新接口,這種情況下開發者就能自行處理了。

本文對於 Webpack 熱更新機制的介紹還只是在接口使用的層面,或者大體的機制上,沒有深入說明熱更新的實現原理和細節。時間、篇幅有限,那就先放一張圖出來,或許有時間再細說一下。

Webpack 熱更新流程

上圖來源:

Webpack & The Hot Module Replacement
https://medium.com/@rajaraodv/webpack-hot-module-replacement-hmr-e756a726a07

這篇英文文章對 Webpack 熱更新實現原理方面有深入介紹。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章