教你怎麼使用 webpack3 的 HMR 模組熱載入

qq20004604發表於2017-12-26

前注:

文件全文請檢視 根目錄的文件說明

如果可以,請給本專案加【Star】和【Fork】持續關注。

有疑義請點選這裡,發【Issues】。

點選這裡檢視DEMO

7、模組熱載入 HMR

7.0、使用說明

安裝:

npm install
複製程式碼

執行(注意,是 dev):

npm run dev
複製程式碼

結論放前面,適合場景:

  1. 當使用 style-loader 時,修改 css 樣式;
  2. 當使用 vue-loader 之類帶 HMR 功能的 loader 時,修改對應的模組;
  3. 當僅僅只是需要修改程式碼後,頁面可以自動重新整理,以保證當前頁面上是最新的程式碼;

7.1、現象和原理

當談到 HMR 的時候,首先要明確一個概念,HMR 到底是什麼?

如果用過帶 HMR 功能的腳手架,例如我分享的這個 Vue的腳手架,大約能給出一個答案:

  1. 修改程式碼後,不需要重新整理頁面,修改後的程式碼效果,就能立刻體現在頁面上;
  2. 已有的效果,比如輸入框裡輸入了一些內容,程式碼更新後,往往內容還在;
  3. 似乎想不到其他的了;

從現象來看,在一定程度上,這個描述問題不大,但不嚴謹。

我們需要分析 HMR 到底是什麼?

  1. webpack 是模組化的,每個 js, css 檔案,或者類似的東西,都是一個模組;
  2. webpack 有 模組依賴圖(Dependency Graph),也就是說,知道每個模組之間的依賴關係;
  3. HMR 是模組熱載入,指模組被修改後,webpack檢測並用新模組更新;
  4. 也就是原來的模組被移除後,用修改後的模組替換;
  5. 表現效果(通常):如果是 js,會重新執行一遍;如果是 css,並用了 style-loader,原有的會被替換;

以上是理論基礎,實際流程如下:

  1. 假如 B 模組的程式碼被更改了,webpack 可以檢測到,並且可以知道是哪個更改了;
  2. 然後根據 依賴圖,發現 A 模組依賴於 B 模組,於是向上冒泡到 A 模組中,判斷 A 模組裡有沒有處理熱載入的程式碼;
  3. 如果沒有,則繼續向上冒泡,直到冒泡到頂層,最後觸發頁面重新整理(如果引用了多個chunk,當一個冒泡到頂層並且沒有被處理的話,整個頁面都會觸發重新整理);
  4. 如果中途被捕獲,那麼將只重新載入冒泡路徑上的模組,並觸發對應 HMR 處理函式的回撥函式;

更具體的內容,請檢視官網的說明,附連結如下:

依賴圖(Dependency Graph)

模組熱替換(Hot Module Replacement)(注:原理)

模組熱替換(注:使用方法)

7.2、應用場景

HMR 的應用場景,最合適的是帶 HMR 功能的loader。

例如 style-loader,或 vue-loader

原因很簡單,自己在頁面裡寫 HMR 冒泡捕獲功能,寫起來很麻煩,也很容易導致遺漏。

最重要的是,這些程式碼並不是業務程式碼,而是 HMR 專用程式碼,這些程式碼會在webpack打包時被打包進去(可以檢視打包好後的原始碼)Z,但這沒有意義。

因此在 loader 裡進行處理,對模組的載入才是更加有效的。

當然,假如你只是希望儲存修改的程式碼,會自動觸發頁面重新整理,以保證頁面上的程式碼是最新的,那麼也是可以的。

這種情況只需要啟用 HMR 功能,不需要寫 HMR 的捕獲程式碼,讓觸發的 update 行為自動冒泡到頂層,觸發頁面重新整理就好了(參照 開發環境中的 6.2)。

具體可以參照下面的示例DEMO。

7.3、使用說明

7.3.1、HMR 的冒泡

先假設引用關係: A -> B -> C

【1】HMR 是向上冒泡的:

  1. C 被更改後,會去找他的父模組 B,檢視 B 中有沒有關於 C 的 HMR 的處理程式碼:
  2. 如果沒有,那麼會繼續向上冒泡到 A,檢視 A 中有沒有關於 B 的 HMR 的處理程式碼;
  3. 如果 A 沒有,因為 A 是入口檔案,所以會重新整理整個頁面;

【2】冒泡過程中只有父子關係:

C 更改,冒泡到 B(B 無 HMR 處理程式碼),然後冒泡到 A。

此時,在 A 這裡,視為 B 被更改(而不是 C),

因此 A 裡面處理 HMR 程式碼,捕獲的模組,應該是 B,而不是 C,

如果 A 的目標是 C,那麼該段程式碼不會響應(雖然冒泡的起點是 C);

【3】HMR 觸發,會執行整個冒泡流程中涉及到的模組中的程式碼:

例如上面的 C 更改,B 捕獲到了,重新執行 C;

B 無捕獲程式碼向上冒泡,A捕獲到了,重新執行 B 和 C;

假如引用關係是:A -> B -> C 和 D,即 B 裡面同時引用 C 和 D 兩個模組,並且 B 沒有處理 HMR 的程式碼,A 有:

  1. 冒泡起點是 B:B 重新執行一遍自己的程式碼,C 和 D 不會執行;
  2. 冒泡起點是 C:B 和 C 重新執行一遍自己的程式碼, D 不會執行;
  3. 冒泡起點是 D:B 和 D 重新執行一遍自己的程式碼, C 不會執行;

【4】冒泡行為起點的子模組,其程式碼不會被重新執行:

先假設引用關係:A -> B -> C -> D,B 沒有 處理 HMR 的程式碼,C 有沒有無所謂,A 有。

冒泡起點是 C,因此冒泡到 A。

從上面我們可以得知,B 和 C 會被重新執行,那麼 D 呢?

答案是不會,因為 D 不在冒泡路線上。

總結:

總結以上四點,得出一個結論:

  1. 從修改的模組開始冒泡,直到被捕獲為止;
  2. 冒泡路徑上的程式碼(不包含捕獲到的模組),都會被重新執行;
  3. 非冒泡路徑上的程式碼,不管是子模組,或者是兄弟模組,都不會被重新執行(除非是整個頁面被重新整理)Z。

7.3.2、HMR 的隱患

以上特點這就可能帶來一些後果(主要是 js 程式碼):

  1. 假如我程式碼裡,有繫結事件,那麼當修改程式碼並重新執行一遍後,顯然會再繫結一次,因此會導致重複繫結的問題(因此要考慮到解綁之前的事件);
  2. 類似的,如果程式碼裡新增了 DOM,那麼當重新執行的時候,原本 DOM 節點還在,重新執行的時候又新增了一次;
  3. 如果有某些一次性操作,比如程式碼裡移除了某個 DOM,那麼很可能 HMR 不能解決你的問題,也許需要重新重新整理後,表現才正常;

7.3.3、HMR 的一個坑

那就是引用時候的名字,和處理的 API,引用的檔名,需要相同;

舉例:

// 引入
import foo from './foo.js';

// 處理
module.hot.accept('./foo.js', callback);
複製程式碼

如果不一樣,會導致第一次響應正常,後面就可能導致無法正常觸發 HMR ,雖然提示模組更新,但不會重新執行模組的程式碼。

7.4、示例

為了說明 HMR 是怎麼使用和生效,這裡將給一個最簡單的示例,包含 html、css、和 js 程式碼,來解釋其的使用方法。

可以直接 fork 本專案參看原始碼,以下是分析作用,以及如何生效的。

需要使用的東西:

  1. 使用 webpack-dev-server,參考上一篇6、開發環境
  2. 兩個HMR外掛:webpack.NamedModulesPluginwebpack.HotModuleReplacementPlugin
  3. 配置一下 package.json,新增一行 scripts :"dev": "webpack-dev-server --open --config webpack.config.js"
  4. style-loader,用於實現 css 的 HMR(使用後預設開啟);

依賴圖:

app.js        入口檔案,在其中配置了 foo.js 和 bar.js 的 HMR 處理函式
├─style.css   樣式檔案
├─img
│  ├─1.jpg    圖片1
│  └─2.jpg    圖片2
├─foo.js      模組foo,配置了 HMR 模組熱替換的介面
│  └─bar.js   模組bar,是foo的子模組
└─DOM.js      抽象出一個創造 DOM,並插入到 body 標籤的函式
複製程式碼

1、先分析 js 部分

app.js

// 引入資源
import './style.css';
import foo from './foo.js';
import createDOM from './DOM.js'

// 建立一個DOM並插入<body>標籤
let el = createDOM({
    id: 'app-box',
    innerHTML: 'app.js<input>'
})
document.body.appendChild(el);

// 本行程式碼表示app.js已經被執行了一遍
console.log('%c%s', 'color:red;', 'app.js is running...')

// 兩個子模組建立DOM並插入<body>標籤
foo()

// 這裡是控制 HMR 的函式
// 注:
// 這裡引用的 foo.js 模組,那麼處理 foo.js HMR 效果的程式碼必須寫在這裡;
// 特別提示:這段程式碼不能抽象封裝到另外一個js檔案中(即使那個js檔案也被 app.js import進來)
// 推測是根據webpack的依賴圖,向上找父模組,然後在父模組的程式碼中,找有沒有處理 HMR 的程式碼
if (module.hot) {
    module.hot.accept('./foo.js', function (url) {
        // 回撥函式只有url一個引數,型別是陣列
        // 執行時機是 foo.js 中的程式碼執行完畢後執行
        console.log('%c%s', 'color:#FF00FF;', `[${url}] is update`)
    })
}
複製程式碼

foo.js

// 引入資源
import createDOM from './DOM'
import bar from "./bar.js";
// bar 中建立的DOM邏輯,在 foo 中執行
bar()

// 執行本段程式碼的時候,表示 foo.js 被重新執行了
console.log('%c%s', 'color:green;', 'foo.js is running...')

function Foo() {
    let el = createDOM({
        id: 'foo-box',
        classList: 'foo',
        innerHTML: 'foo.js<input>'
    })

    document.body.appendChild(el);
}

// 匯出給 app.js 執行
export default Foo

// 這裡寫 bar.js 的 HMR 邏輯
if (module.hot) {
    module.hot.accept('./bar.js', function (args) {
        console.log('%c%s', 'color:#FF00FF', `[${args}] is update`)
    })
}
複製程式碼

bar.js

// 引入資源
import createDOM from './DOM'

// 執行本段程式碼的時候,表示 bar.js 被重新執行了
console.log('%c%s', 'color:blue;', 'bar.js is running...')

function Bar() {
    let el = createDOM({
        id: 'bar-box',
        classList: 'bar',
        innerHTML: 'bar.js<input>'
    })

    document.body.appendChild(el);
}

// 匯出給 foo.js 執行
export default Bar
複製程式碼

簡單總結一下以上程式碼:

  1. app.js 作為入口檔案,他引入了自己的子模組 foo.js,以及 css 資原始檔,並且處理自己子模組的 HMR 行為;
  2. foo.js 作為 app.js 的子模組,他引入了自己的子模組 bar.js ,並且處理自己子模組的 HMR行為;
  3. bar.js 沒做什麼特殊的;
  4. 三個模組裡,都有一行 console.log() 程式碼,當出現在瀏覽器的控制檯裡的時候,表示該模組程式碼被重新執行了一遍;
  5. 父模組處理子模組的 HMR 時,回撥函式裡有一行 console.log() 程式碼,表示該子模組已經重新載入完畢;
  6. 因此,理論上,我們修改 foo.js 或者 bar.js 檔案後,首先會看到該模組的 console.log() 程式碼,其次會看到其父模組處理 HMR 的回撥函式中的 console.log() 程式碼;

首次重新整理頁面後,控制檯先輸出三條 log,和幾行 HMR程式碼,略略略。

修改 foo.js

當我們修改 foo.js 的 log 程式碼:console.log('%c%s', 'color:green;', 'foo.js is running...I change it')

控制檯輸出:

foo.js is running...I change it
[./foo.js] is update
[HMR] Updated modules:
[HMR]  - ./foo.js
[HMR] App is up to date.
複製程式碼

正如我們所料,foo.js 程式碼被重新執行了一遍,然後觸發了 app.js 裡面 module.hot.accept() 的回撥函式(注意,有先後順序)。

並且,頁面上多了一個 DOM 節點(來自 bar.js的,因為在 foo.js 裡面執行了 bar()),這正是我們前面所提出來的,HMR 機制的天生缺陷之一。

另外請注意,所以 bar.js 是 foo.js 的子模組,但由於 bar.js 並沒有被修改,所以 bar.js 裡面的程式碼沒有重新執行一遍(除了他暴露給 foo.js 的介面)。

修改 bar.js

當我們修改 bar.js 的 log 程式碼:console.log('%c%s', 'color:blue;', 'bar.js is running...and bar has been changed')

控制檯輸出:

bar.js is running...and bar has been changed
[./bar.js] is update
[HMR] Updated modules:
[HMR]  - ./bar.js
[HMR] App is up to date.
複製程式碼

bar.js 是 foo.js 的子模組,而且 foo.js 裡面有關於處理 bar.js 的模組 HMR 功能的程式碼。

因此 bar.js 被修改後,冒泡到自己的父模組時就被捕獲到,並沒有繼續向上冒泡。

讓 bar.js 的修改冒泡到 app.js

假如讓 bar.js 的修改冒泡到 app.js 會發生什麼事情呢?先修改程式碼:

app.js 嘗試讓 app.js 同時捕獲 foo.js 和 bar.js 的修改

// from
module.hot.accept('./foo.js', function (url) {

// to
module.hot.accept(['./foo.js', './bar.js'], function (url) {
複製程式碼

foo.js 註釋掉對 bar.js 的 HMR 功能的處理程式碼

// from 
if (module.hot) {
    module.hot.accept('./bar.js', function (args) {
        console.log('%c%s', 'color:#FF00FF', `[${args}] is update`)
    })
}

// to 
// if (module.hot) {
//     module.hot.accept('./bar.js', function (args) {
//         console.log('%c%s', 'color:#FF00FF', `[${args}] is update`)
//     })Z
// }
複製程式碼

恢復之前 foo.js 和 bar.js 的 console.log() 的修改

// foo.js
// from
console.log('%c%s', 'color:green;', 'foo.js is running...I change it')

// to
console.log('%c%s', 'color:green;', 'foo.js is running...')


// bar.js
// from
console.log('%c%s', 'color:blue;', 'bar.js is running...and bar has been changed')

// to
console.log('%c%s', 'color:blue;', 'bar.js is running...')
複製程式碼

修改完畢,此時重新整理一下頁面,重置狀態。然後我們給 bar.js 新增一行程式碼 console.log('bar.js is be modified')

控制檯輸出:

bar.js is running...
bar.js is be modified
foo.js is running...
[./foo.js] is update
[HMR] Updated modules:
[HMR]  - ./bar.js
[HMR]  - ./foo.js
[HMR] App is up to date.
複製程式碼

這說明,webpack 成功捕捉到了 bar.js 的修改,並且更新了 bar.js 和 foo.js 。

並且,雖然在 app.js 裡去嘗試捕獲 bar.js ,然而,因為 bar.js 並不是 app.js 的子模組(而是子模組的子模組),因此是捕獲不到的。

複數監視

module.hot.accept這個函式中,引數一可以接受一個陣列,表示監視的模組可以是複數。

所以不需要寫多個函式來監視多個模組,如果他們之間邏輯是複用的話,那麼一個模組就行了。

總結:

js 檔案被修改,會導致冒泡過程中,涉及到的 js 檔案,都被重新執行一遍。


2、再分析 css 部分

在使用 style-loader 後,我們不需要配置任何東西,就可以實現 HMR 效果。

style.css

#app-box {
    color: red;
}
複製程式碼

預設開啟頁面,會發現頁面上 app.js 那一行的字型顏色是紅色。

修改這個css樣式為:

#app-box {
    color: red;
    font-size: 24px;
}
複製程式碼

在儲存這個css檔案後,會發現頁面在沒有重新整理的情況下,樣式已經改變了。

由於我們開發一般都會採用 style-loader,而且 css 由於是替代效果,也不是可執行程式碼,因此天生適用於 HMR 場景。

7.5、總結

css 檔案沒有什麼好說的,只要使用 style-loader 即可。

因為 HMR 的特性(會重新執行 js 檔案),所以如果沒有 loader 輔助的話,寫在 HMR 下可用的 js 程式碼是很麻煩的。

想象一下,你的js程式碼裡有一個建立並插入 DOM 的操作,然後在你每次修改這個模組裡的程式碼時,都會建立一個新的 DOM,並插入。

例如本 DEMO 裡,修改 foo.js 檔案,會導致重新執行 foo 模組時,執行 bar.js 暴露出來的介面 bar,

於是頁面被重複插入一個 DOM,這顯然不符合我們的預期。

當然了,也有解決辦法,重新整理頁面即可恢復正常。

類似的還有 繫結事件(導致重複繫結),發起非同步請求(導致多次發起非同步請求)等。

那麼有沒有解決辦法呢?

答案是使用相關的 loader,並且寫符合相關格式的程式碼。

例如 vue-loader 可以處理 .vue 結尾的檔案。在你修改 .vue 檔案的時候,就可以自動處理。假如你 .vue 檔案不按要求寫,而是自己亂寫,那麼顯然就不能正常執行。

相關文章