《webpack实现热更新(HMR)原理分析》


2020-12-01 上次更新时间:4/29/2022, 9:34:08 AM 0 javascript

# 简介

模块热替换(HMR - Hot Module Replacement),是指在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面。开启 HMR 能大大提高开发效率。

# 启用HMR

  1. 配置 devServer(webpack-dev-server) 使得支持 HMR
  2. 使用内置插件 HotModuleReplacementPlugin(如果没有设置,则会被自动添加)

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');
  const { CleanWebpackPlugin } = require('clean-webpack-plugin');

  module.exports = {
    entry: {
       app: './src/index.js',
    },
    output: {
        filename: '[name].bundle.js',
        path: path.resolve(__dirname, 'dist'),
    },
    devtool: 'inline-source-map',
    devServer: {
        contentBase: './dist',
        hot: true, // 启动
    },
    plugins: [
      new CleanWebpackPlugin(),
      new HtmlWebpackPlugin({
        title: 'Hot Module Replacement',
      }),
    //   new webpack.HotModuleReplacementPlugin() 设置 hot: true,该插件会被自动添加
    ]
  };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 看得到的热更新

热更新大部分的处理我们是看不到的,但是还是可以从请求中窥得一二。

将任意 vue 项目跑起来,并且开启热更新 devServer{ hot: true}

# 1. 首次请求页面/刷新

可以看到除了资源以外,服务端和浏览器还建立了 websocket 长链接,并且发送了一个 hash 值。这是服务器与客户端通信的关键!

# 2. 第一次修改

服务端通过 websocket 向浏览器发送 hash 值。浏览器拿到 hash 值做对比,发现有不一致则向浏览器请求新资源文件。

  • 12678d3d9e7227e8b774.hot-update.json:已改动模块的 json 文件,用于下次对比。
  • app.12678d3d9e7227e8b774.hot-update.js:已改动模块的 js 文件,用于更新视图。

# 3. 多次修改

从下面截图可以看到,每次服务端发送的消息的 hash 将作为下次 hot-update.json 和 hot-update.js 文件的 hash。

将看到的和没看到的地方补充一下,就能得到下面一个详细的更新流程图

# 热更新流程图示

虽然看起来步骤有点多,实际上关键点只有几个:

  • 服务端和客户端通过 websocket 建立长连接,实现通讯
  • 每个文件都有一个 hash 值标记,并且通过 websocket 传给客户端,便于比较
  • 服务端监听文件变化,每次变化重新编译后生成新 hash 值,并发送给客户端
  • 客户端监听 hash 值变化,如果一致则使用缓存,不一致则使用新 hash 值向服务器请求新的模块文件替换,实现局部刷新

# 从代码看变化

从浏览器请求中可以看到变化,从打包后代码里也能找到热更新的痕迹。

为了演示,在App.vue中引入两个非常简单的组件

test1.vue

<template>
    <div>
        <h2>test1111</h2>
    </div>
</template>
1
2
3
4
5

test2.vue

<template>
    <div>
        <h2>test22222</h2>
    </div>
</template>
1
2
3
4
5

# 打包后的代码

项目打包后输出 app.js, 这里包含了所有的信息。

通过 websocket 我们可以拿到一个 hash 值 1553ffbe6e5f353c4019, 使用这个值在 app.js 中搜索。可以看到,这个被赋值给了一个变量,并且被函数使用。

app.js

// ...

// 记住这个webpackHotUpdate
var parentHotUpdateCallback = (typeof self !== 'undefined' ? self : this)["webpackHotUpdate"];
(typeof self !== 'undefined' ? self : this)["webpackHotUpdate"] = // eslint-disable-next-line no-unused-vars
  function webpackHotUpdateCallback(chunkId, moreModules) {
  hotAddUpdateChunk(chunkId, moreModules);
  if (parentHotUpdateCallback) parentHotUpdateCallback(chunkId, moreModules);
} ;
  
// 通过hash值下载新的模块js
function hotDownloadUpdateChunk(chunkId) {
    var script = document.createElement("script");
    script.charset = "utf-8";
 		script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
 		if (null) script.crossOrigin = null;
 		document.head.appendChild(script);
}

// 通过hash值下载新的json
function hotDownloadManifest(requestTimeout) {
    requestTimeout = requestTimeout || 10000;
    return new Promise(function(resolve, reject) {
        if (typeof XMLHttpRequest === "undefined") {
          return reject(new Error("No browser support"));
        }
        try {
          var request = new XMLHttpRequest();
          var requestPath = __webpack_require__.p + "" + hotCurrentHash + ".hot-update.json";
          request.open("GET", requestPath, true);
          request.timeout = requestTimeout;
          request.send(null);
        } catch (err) {
          return reject(err);
        }
        request.onreadystatechange = function() {
          // ...
        };
    });
}

// ...
var hotCurrentHash = "1553ffbe6e5f353c4019";

// test2.vue 组件代码(篇幅过长,通过关键字查找即可)
(function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    // 记住这个id
    eval("/很多代码/!./src/components/test2.vue?vue&type=template&id=2e742a00&\n");
}),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

# 修改组件

对 test2.vue 进行修改,触发重新编译

<template>
    <div>
        <h2>test22222添加</h2>
    </div>
</template>
1
2
3
4
5

浏览器重新请求新的模块文件

# hot-update.js

// webpackHotUpdate 是不是很眼熟
webpackHotUpdate("app", {
    // 这个id是不是很眼熟
    /***/ "很多代码./src/components/test2.vue?vue&type=template&id=2e742a00&":
    // 这个函数是不是很眼熟
    (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
       eval("/很多代码/!./src/components/test2.vue?vue&type=template&id=2e742a00&\n");
      }
    )
})
1
2
3
4
5
6
7
8
9
10
11

hot-update.js 返回一个 webpackHotUpdate 函数,当接收到这个函数的时候,HotModuleReplacement.runtime 会根据 chunkId 和 组件id 标识去查找,在内存中进行更新对应模块。

# 自定义热更新机制(module.hot)

以上的内容开启热更新后默认的更新机制,我们是可以通过 webpack 提供的接口 module.hot 自定义热更新机制。

为了演示,我们对文件进行调整下:

新增 src/hello.js

console.log("hello -- 11")
1

App.vue

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <test1></test1>
    <test2></test2>
    <h2>
      这里是Hello-- <span id="hello"></span>
    </h2>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10

main.js

import Vue from 'vue'
import App from './App.vue'
require('./hello')

Vue.config.productionTip = false

// 重要
if(module.hot) {
  module.hot.accept('./hello.js', function() {
      console.log('hello 改变了')
      document.getElementById('hello').innerHTML = 'hello 改变了'
  })
}

new Vue({
  render: h => h(App),
}).$mount('#app')

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

项目启动,可以看到控制台输出了 hello -- 11,此时hello.js 文件并没有改变

现在调整下 src/hello.js,再看控制台

console.log("hello -- 22")
1

浏览器并没有刷新,并且控制台输出了 hello -- 22,视图也按预期进行了更新

# 实现原理总结

  • 服务端和客户端使用 websocket 实现长连接
  • webpack 监听源文件的变化,即当开发者保存文件时触发 webpack 的重新编译。
    • 每次编译都会生成 hash 值、已改动模块的 json 文件、已改动模块代码的 js 文件
    • 编译完成后通过 socket 向客户端推送当前编译的 hash 戳
  • 客户端的 websocket 监听到有文件改动推送过来的 hash 戳,会和上一次对比
    • 一致则走缓存
    • 不一致则通过 ajax 和 jsonp 向服务端获取最新资源
  • 使用内存文件系统去替换有修改的内容实现局部刷新

# 最后

想看更详细的介绍和总结可以看以下文章:

参考文章:

上次更新时间: 4/29/2022, 9:34:08 AM