原稿:语雀 · webpack HMR 原理 · 原目录:Webpack › webpack 知识点 › webpack HMR 原理

参考链接:

webpack HMR 提供了一种无需完全刷新整个页面就能重新加载已更新模块的开发体验。通过 HMR,可以保留当前页面已有状态,且只更新变更内容,节省开发时间

下面介绍 HMR 原理

1. webpack-dev-server 启动本地服务

在 webpack-dev-server.js 中可以看到 dev-server 启动过程的调用,第一步和一般情况下 webpack 构建过程一致:获取初始化参数,用于实例化 compiler 对象

// node_modules/webpack-dev-server/bin/webpack-dev-server.js

// 生成webpack编译主引擎 compiler
let compiler = webpack(config);

// 启动本地服务
let server = new Server(compiler, options, log);
server.listen(options.port, options.host, (err) => {
    if (err) {throw err};
});

之后 dev-server 会创建 server 实例,之后立即调用 server.listen() 建立服务器和浏览器的 websocket 双向通信

// node_modules/webpack-dev-server/lib/Server.js

class Server {
    constructor() {
        // 主要方法节选
        updateCompiler(this.compiler, this.options);
        this.setupApp();
        this.createServer();
        this.setupHooks();
        this.setupDevMiddleware();
    }

    setupApp() {this.app = new express();}
    createServer() {this.listeningApp = http.createServer(this.app);}
    listen(port, hostname, fn) {
        return this.listeningApp.listen(port, hostname, (err) => {
            // 启动express服务后,启动websocket服务
            this.createSocketServer();
        }
    }
}

2. 修改 webpack.config.js 的 entry 配置

updateCompiler() 中会调用 addEntries() 向初始 webpack 配置中额外添加了两个文件作为 Entry Point,其中一个负责 websocket 通信,一个负责检查更新

//node_modules/webpack-dev-server/lib/utils/addEntries.js
const clientEntry = `${require.resolve(
  '../../client/'
)}?${domain}${sockHost}${sockPath}${sockPort}`;
let hotEntry = require.resolve('webpack/hot/dev-server');

3. 监听 done 钩子

// node_modules/webpack-dev-server/lib/Server.js
// 绑定监听事件
setupHooks() {
    const {done} = compiler.hooks;
    // 监听webpack的done钩子,tapable提供的监听方法
    const addHooks = (compiler) => {
    const { compile, invalid, done } = compiler.hooks;

    compile.tap('webpack-dev-server', invalidPlugin);
    invalid.tap('webpack-dev-server', invalidPlugin);
    done.tap('webpack-dev-server', (stats) => {
      this._sendStats(this.sockets, this.getStats(stats));
      this._stats = stats;
    });
  };
};
// 通过websoket给客户端发消息
_sendStats() {
    this.sockWrite(sockets, 'hash', stats.hash);
    this.sockWrite(sockets, 'ok');
}

setupHooks() 用来注册监听事件,监听每次 webpack 编译中的 compile, invalid, done 钩子(webpack compiler 基于tapable 暴露了一系列钩子,会自动在编译的各个阶段触发,具体可见文档:compiler 钩子),done 发生时会触发 _sendStats(),通过 websocket 向浏览器发送消息,分为 hash 和 ok 类型,hash 消息中携带每次编译的哈希值

image.png

4. middleware 监听文件变化

回到服务端,每次修改代码后 webpack 就会重新编译,说明在服务端需要监听本地模块的变化。

这一过程实现依赖于 webpack-dev-middleware,其源码中导出 wdm()(setupDevMiddleware() 即实例化该对象),主要负责监听与修改内容

其中首先调用 compiler.watch(),该方法本身是一个 Node 接口,文档说明如下

调用 watch 方法会触发 webpack 执行,但之后会监听变更, 一旦 webpack 检测到文件变更,就会重新执行编译。 该方法返回一个 Watching 实例

  • 调用 watch() 首先对本地文件代码进行编译打包,也就是 webpack 的一系列编译流程
  • 编译结束后,开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听

toDisk() 和 setFs() 即决定了打包后文件写入磁盘还是内存

// node_modules/webpack-dev-middleware/index.jsmodule.exports = function wdm(compiler, opts) {  // start watching  if (!options.lazy) {    context.watching = compiler.watch(options.watchOptions);  }  if (options.writeToDisk) {    toDisk(context);  }  setFs(context, compiler);}

5. 浏览器接收到热更新的通知

在第3步中得到, server 会在监听到 done 钩子后向浏览器发送 websocket 消息,浏览器端如何更新消息进行重载?这需要回到开始是写入的第一个入口文件 clientEntry,该文件会被打包到生成的 bundle 中,对应源文件如下,其中定义了对于不同类型消息的处理程序

  • hash 事件,更新最新一次打包后的 hash 值
  • ok 事件,进行热更新检查,调用 reloadApp(),但这个方法不会直接更新模块,而是触发一个 webpackHotUpdate 事件
// node_modules/webpack-dev-server/client/index.jsvar socket = require('./socket');var onSocketMessage = {    hash: function hash(_hash) {        status.currentHash = _hash; // 更新currentHash值    },    ok: function ok() {        sendMessage('Ok');        reloadApp(options, status); // 进行更新检查等操作    },};// 连接服务地址socketUrl,http://localhost:8080,本地服务地址socket(socketUrl, onSocketMessage);function reloadApp() {    if (hot) {        log.info('[WDS] App hot update...');        // hotEmitter其实就是EventEmitter的实例        var hotEmitter = require('webpack/hot/emitter');        hotEmitter.emit('webpackHotUpdate', currentHash);    } }

之后进入第二个 hotEntry,该文件实际也会被打包入 bundle 中,和 clientEntry 在一起使用

这里 webpack 会监听 webpackHotUpdate 事件,监听到后 dev-server 会更新 hash 值,执行 module.hot.check(),而这个模块也是由 HotModuleReplacementPlugin 插入到 bundle.js 中的

// node_modules/webpack/hot/dev-server.js
var check = function check() {
    module.hot.check(true)
        .then(function(updatedModules) {
            // 容错,直接刷新页面
            if (!updatedModules) {
                window.location.reload();
                return;
            }

            // 热更新结束,打印信息
            if (upToDate()) {
                log("info", "[HMR] App is up to date.");
            }
    })
        .catch(function(err) {
            window.location.reload();
        });
};

var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function(currentHash) {
    lastHash = currentHash;
    check();
});

6. module.hot.check 开始热更新

该方法做了什么实现热更新? 2. 利用更新后的 lastHash,调用 hotDownloadManifest 请求 xxx/hash.hot-update.json,响应内容是一个 js 对象,给出了需要更新 chunk 的清单(manifest),其中的 chunk 列表会与当前已加载 chunk 列表比较,找出需要重新下载的 chunk

image.png
2. 调用 hotDownloadUpdateChunk 发送 xxx/hash.hot-update.js 请求,响应内容以 jsonp 方式实现,其中包含了最新 chunk
image.png

7. hotApply 热更新模块替换

拿到新的代码块后会执行 HMR 的 hotApply() ,这是热更新模块替换的核心,主要分为三个阶段: 2. 找出过期模块与依赖(check 过程中的需更新 chunk 都会被视为过期) 4. 从缓存删除过期模块与依赖 6. 将新模块添加到 modules,之后通过 __webpack_require__ 执行模块的代码,这一步相当于最终完成新模块替换 8. 最后调用 HMR 的 accept() 方法,接收更新(HMR API

HMR.svg

__webpack_require__ 说明:(参考:webpack 模块化的原理

该函数实际上

(function(modules) {
  function __webpack_require__(moduleId) {
    var module =  {
      i: moduleId,
      l: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    return module.exports;
  }

  return __webpack_require__(0);
})([
  (function (module, __webpack_exports__, __webpack_require__) {}),
  (function (module, __webpack_exports__, __webpack_require__) {})
]);

该函数实现类似 import 和 require 的效果,实际上是一个自执行函数。入参是个数组,包含了所有的模块。

(function(modules) {

})([]);

通过传入的 moduleId 即可调用不同模块

modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
return module.exports

同时在模块0引入时,即入参数组首元素执行modules

00

.call() 时,其函数体内又会调用 __webpack_require__(1) 引入下一个模块


小结

小结 HMR 中的调用关系

HMR-调用.svg