热更新原理解析

2023-09-20
webpack

Before

热更新是开发过程中必不可少的功能,想象一下开发一个表单,好不容易填完了所有信息,提交发现逻辑错误,改完代码发现页面刷新了,要挨个重填一次, 直接原地qushi,而有了热更新,页面的状态就都能保留,极大地提升开发效率。
用了这么多年热更新,是时候来理一理它的原理了。

环境

搭了一个最简单的能热更新的环境

index.js
Copy
if (module.hot) {
  module.hot.accept('./hello.js', function () {
    div.innerHTML = hello();
  });
}
全屏

index.js文件中如果没有这段代码,更新hello.js文件后,页面自动刷新,展示更新后的内容, 如果加上这段代码,更新hello.js文件后,页面不刷新,但是界面的内容会更新。 (可以通过在input框中输入内容来判断是否刷新了页面。)

问题

我们先将热更新拆解为几个部分,逐个分析。
首先是监听文件修改,那么是谁监听,监听到修改后做了什么?
其次是谁?如何通知到浏览器?
最后浏览器做了什么?

先简单解答一下每个问题:
首先是webpack监听到文件修改后,进行编译,打包出新的文件,得到新的hash。
其次是webpack-dev-server通过websocket通知浏览器,告诉浏览器新的hash。
浏览器收到通知后,请求新的文件,拿到新的文件后,更新页面。

文件监听

webpack是如何实现监听文件修改的呢?答案是使用了node的fs.watch方法。

watch.js
Copy
const fs = require("fs");

function watchTest() {
  const filepath = '/Users/ever/Documents/learning/env/node/'
	const watcher = fs.watch(filepath);
	watcher.on('change', (type, filename) => {
		console.log('changed', type, filename);
	})
}

watchTest()
全屏

执行watch.js,当修改了filepath目录下的某个文件后,控制台就会输出对应的文件名称。

webpack在监听到文件变化后,最终会走到Watching.js中对文件重新进行编译打包,然后输出到内存中。

webpack/lib/Watching.js
Copy
this.compiler.hooks.watchRun.callAsync(this.compiler, err => {
  if (err) return this._done(err);
  const onCompiled = (err, compilation) => {
    ...
  };
  this.compiler.compile(onCompiled);
});
全屏

读过webpack源码的小伙伴应该会对这段代码很熟悉吧,在一次正常的编译流程中,会在run钩子中执行Compiler的compile方法, 创建Compilation,开启整个编译过程。忘记了可以看 webpack5源码之旅 - 初始化复习一下。

将文件打包到内存中

通过webpack-dev-server启动后,会发现项目目录下并没有生成新的文件,但是访问页面我们能发现它请求到了bundle.js文件,那么这个文件在哪儿呢?
答案是在内存中。
webpack-dev-server中使用了webpack-dev-middleware作为一个打包器,它内部使用了memfs将文件放入内存。使用express启动服务器读取内存中的文件。
下面写了一个简单的例子,执行node代码,访问localhost:4000,就能看到页面,在network中可以看到请求了bundle.js文件。

Copy
const express = require('express')
const app= express()
const fs = require('memfs')

const port = 4000

fs.writeFileSync('/bundle.js', '"use strict";eval("const div = document.createElement(\'div\');div.innerHTML = \'hello\';document.body.appendChild(div);")')

app.get('/', (req, res) => {
	res.send('<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Document</title></head><body><div>nice</div><input /><script src="/bundle.js"></script></body></html>')
})
app.get('/bundle.js', (req, res) => {
	const fileRes = fs.readFileSync('/bundle.js', 'utf8');
	res.send(fileRes)
})
app.listen(port, () => {
	console.log('4000 started')
})
全屏

通知浏览器

服务器与浏览器之间通过websocket建立长连接。当重新编译完成,服务端就会发送新的hash给浏览器。

ws
ws
webpack/lib/Watching.js
Copy
_done(err, compilation) {
  ...
  this.compiler.hooks.done.callAsync(/** @type {Stats} */ (stats), err => {
    ...
  });
}
全屏
webpack-dev-server/lib/servers/Server.js
Copy
this.compiler.hooks.done.tap(
  "webpack-dev-server",
  (stats) => {
    if (this.webSocketServer) {
      this.sendStats(this.webSocketServer.clients, this.getStats(stats));
    }
    this.stats = stats;
  }
);
sendStats(clients, stats, force) {
  ...
  this.currentHash = stats.hash;
  this.sendMessage(clients, "hash", stats.hash);
  ...
    this.sendMessage(clients, "ok");
}
全屏

websocket

如何建立websocket连接呢?
分两部分,服务端和客户端。服务端使用node+ws,客户端使用浏览器提供的WebSocket。

server.js
Copy
const Websocket = require('ws');

const implementation = new Websocket.Server({path: '/ws', port: '3000'});

implementation.on('connection', (client) => {
	console.log('connection');
	client.on('message', (msg) => {
		console.log('received', msg);
	})
})
全屏

执行server.js就能启动ws

client.js
Copy
const client = new WebSocket('ws://localhost:3000/ws');

client.onopen = () => {
	console.log('open')
	client.send('connected');
}

client.onmessage = (msg) => {
	console.log('msg', msg)
}
全屏

可以在控制台直接执行client.js中的代码,建立ws连接,发送消息。

HMR

当收到ok消息后,会调用reloadApp,如果配置了热更新,就会调用 webpack/hot/emitter 将最新 hash 值发送给 webpack

webpack-dev-server/client/utils/reloadApp.js
Copy
import hotEmitter from "webpack/hot/emitter.js";

function reloadApp(_ref, status) {
  var hot = _ref.hot, liveReload = _ref.liveReload;
  ...
  var currentHash = status.currentHash, previousHash = status.previousHash;
  ...
  var allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;
  if (hot && allowToHot) {
    log.info("App hot update...");
    hotEmitter.emit("webpackHotUpdate", status.currentHash);
    ...
  }
  ...
}
全屏
webpack/hot/dev-server.js
Copy
hotEmitter.on("webpackHotUpdate", function (currentHash) {
  lastHash = currentHash;
  if (!upToDate() && module.hot.status() === "idle") {
    log("info", "[HMR] Checking for updates on the server...");
    check();
  }
});
var check = function check() {
  module.hot
    .check(true)
    .then(function (updatedModules) {
      ...
    })
    .catch(function (err) {
      ...
    });
};
全屏

调用module.hot.check方法,这部分代码被打包在bundle.js中。 module是bundle.js中定义的有一个全局变量

bundle.js
Copy
__webpack_require__.i.push(function (options) {
	var module = options.module;
	...
	module.hot = createModuleHotObject(options.id, module);
});
function createModuleHotObject(moduleId, me) {
  /******/
  var _main = currentChildModule !== moduleId;
  /******/
  var hot = {
      ...
      active: true,
      accept: function(dep, callback, errorHandler) {
        ...
        hot._acceptedDependencies[dep] = callback || function() {}
      },
      check: hotCheck,
      apply: hotApply,
      ...
  };
  ...
  return hot;
}
全屏

module.hot.check也就是hotCheck方法:

bundle.js
Copy
function hotCheck(applyOnUpdate) {
  ...
  return setStatus("check")
  .then(__webpack_require__.hmrM)
  .then(function(update) {
      ...
      return setStatus("prepare").then(function() {
            return Promise.all(
              Object.keys(__webpack_require__.hmrC).reduce(function (
              			promises,
              			key
              		) {
              			__webpack_require__.hmrC[key](
              					update.c,
              					update.r,
              					update.m,
              					promises,
              					currentUpdateApplyHandlers,
              					updatedModules
              				);
              			return promises;
              		},
              	[])
          ).then(function() {
              return waitForBlockingPromises(function() {
                  ...
                  return internalApply(applyOnUpdate);
              });
          });
      });
  });
}
__webpack_require__.hmrC.jsonp = function(
  chunkIds,
  removedChunks,
  removedModules,
  promises,
  applyHandlers,
  updatedModulesList,
) {
    applyHandlers.push(applyHandler);
    ...
    chunkIds.forEach(function(chunkId) {
        if (
        __webpack_require__.o(installedChunks, chunkId) &&
        installedChunks[chunkId] !== undefined
        ) {
            promises.push(loadUpdateChunk(chunkId, updatedModulesList));
        }
    });
}
;
全屏

__webpack_require__.hmrM中fetch到新的[newHash].hot-update.json文件; 执行__webpack_require__.hmrC.jsonP,传入.json文件中返回的key,通过jsonp方式得到[key].[newHash].hot-update.js,并执行。

[key].[newHash].hot-update.js
Copy
"hello.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  eval("下面部分是eval中转成可执行的代码")
})
__webpack_require__.r(__webpack_exports__);
/* harmony export */
__webpack_require__.d(__webpack_exports__, {
  /* harmony export */
  "default": () => (__WEBPACK_DEFAULT_EXPORT__)
  /* harmony export */
});
const hello = () => {
  return 'hello world nice to meet you-2'
}
/* harmony default export */
const __WEBPACK_DEFAULT_EXPORT__ = (hello);
全屏

将模块代码更新,下次调用的时候就能调新的代码了

bundle.js
Copy
function internalApply(options) {
  ...
  var results = currentUpdateApplyHandlers.map(function(handler) {
      return handler(options);
  });
  results.forEach(function(result) {
      if (result.apply) {
          var modules = result.apply(reportError);
          ...
      }
  });
  ...
}
apply: function(reportError) {
    ...
    // call accept handlers
    for (var outdatedModuleId in outdatedDependencies) {
        if (__webpack_require__.o(outdatedDependencies, outdatedModuleId)) {
            var module = __webpack_require__.c[outdatedModuleId];
            if (module) {
                ...
                for (var j = 0; j < moduleOutdatedDependencies.length; j++) {
                    var dependency = moduleOutdatedDependencies[j];
                    var acceptCallback = module.hot._acceptedDependencies[dependency];
                    callbacks.push(acceptCallback);
                }
                for (var k = 0; k < callbacks.length; k++) {
                    /******/
                    try {
                        callbacks[k].call(null, moduleOutdatedDependencies);
                    }
                    ...
                }
            }
        }
    }
    ...
}
全屏

最后执行apply方法,执行module.hot._acceptedDependencies中的回调函数,这个回调函数就是我们在业务代码中定义的回调函数。

业务端配置

上面已经替换为新的模块代码了,但是业务端并不知道,所以需要调用module.hot.accept方法,添加模块更新后的处理函数。
在业务代码中定义需要热更新的模块以及回调函数:

Copy
if (module.hot) {
  module.hot.accept('./hello.js', function () {
    div.innerHTML = hello();
  });
}
全屏

./hello.js作为key,回调函数作为value存放在hot._acceptedDependencies中。
当然开发时不可能每个文件里面都去写module.hot.accept,一般框架都会帮你把这部分代码一起打包到js中。

总结

对整个热更新流程做一个简单的总结:

通过fs.watch监听文件修改,在回调函数中进行再次编译,将新的模块js代码写入内存中,同时生成一个新的hash。
启动阶段,webpack-dev-server会建立一个ws服务。客户端连接ws服务的代码被一起打包在输出js中,在访问界面后执行(可以在network中查看到)。
服务端通过ws将新的hash值发送给客户端(浏览器),最后发送一个ok消息,客户端收到ok消息,执行reloadApp方法。
reloadApp中通过webpack/hot/emitter将新的hash告知webpack,webpack中执行module.hot.check方法,这些也都打包在输出js中。
执行check方法,fetch到[hash].json文件得到一个key,在通过jsonp方式请求[key].[hash].js并执行。这样modules中的模块就是更新后的代码。
后续会通过被修改module的key,在module.hot._acceptedDependencies找到回调函数并执行,从而完成对界面的更新。
而这对key、value是通过module.hot.accept方法进行存储的,一般框架都会把这段代码打包进输出js中,如果没有使用框架, 那么就需要手动在需要热更新的模块中添加这段调用module.hot.accept方法的代码。

Reference

1. 简单聊聊前端开发中的热更新原理
2. 面试官:说说webpack的热更新是如何做到的?原理是什么?
3. Webpack HMR 原理解析
4. 搞懂webpack热更新原理
5. 4-5 使用自动刷新
6. 基于Node.js实现WebSocket 服务器
7. 10-Vite 中的 HMR 热更新 8. Webpack 原理系列十:HMR 原理全解析