Webpack 熱更新機制

luobotang發表於2018-12-15

想必作為前端大佬的你,工作中應該用過 webpack,並且對熱更新的特性也有了解。如果沒有,當然也沒關係。

下面我要講的,是我對 Webpack 熱更新機制的一些認識和理解,不足之處,歡迎指正。

首先:

熱更新是啥?

熱更新,是指 Hot Module Replacement,縮寫為 HMR

從名字上解讀,就是把“熱”的模組進行替換。熱,是指這個模組已經在執行中。

不知道你有沒有聽過或看過這樣一段話:“在高速公路上將汽車引擎換成波音747飛機引擎”。

雖然有點牽強,但是放在這裡,從某些角度上來說,也還算合適吧。

再扯遠一點,說下我目前工作中的遇到的情況,相信很多人也遇到過。

微信小程式的開發工具,沒有提供類似 Webpack 熱更新的機制,所以在本地開發時,每次修改了程式碼,預覽頁面都會重新整理,於是之前的路由跳轉狀態、表單中填入的資料,都沒了。

哪怕只是一個文案或屬性配置的修改,都會導致重新整理,而要重新進入特定頁面和狀態,有時候很麻煩。對於開發時需要頻繁修改程式碼的情況,這樣比較浪費時間。

而如果有類似 Webpack 熱更新的機制存在,則是修改了程式碼,不會導致重新整理,而是保留現有的資料狀態,只將模組進行更新替換。也就是說,既保留了現有的資料狀態,又能看到程式碼修改後的變化。

很美好,但是想想就覺得是一件肯定不簡單的事情。

所以,熱更新是啥呢?

引用官方文件,熱更新是:

使得應用在執行狀態下,不過載重新整理就能更新、增加、移除模組的機制

熱更新解決的問題

那麼熱更新要解決的問題,在上面也解釋了。用我的話來闡述,就是 在應用程式的開發環境,方便開發人員在不重新整理頁面的情況下,就能修改程式碼,並且直觀地在頁面上看到變化的機制

簡單來說,就是為了 提升開發效率

聯想到我在微信小程式上的開發體驗,真心覺得如果有熱更新機制的話,開發效率要高很多。

如果你知道微信小程式已經或計劃支援熱更新,或者有大佬已經做了類似的工作,歡迎告訴我,感謝!

進一步介紹前,我們來看下 Webpack 熱更新如何配置。

熱更新配置

如果你之前做的專案是其他人搭建配置了 Webpack 和熱更新,那麼這裡可以瞭解下熱更新是怎麼配置的。

我的示例採用 Webpack 4,想直接看程式碼的話,在這裡:

github.com/luobotang/w…

除了 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 medium.com/@rajaraodv/…

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

相關文章