Skip to main content

webpack hot module replacement (HMR) principle

ยท 7 min read

hot module replacement (HMR) is one of the most useful features webpack provides. It allows all types of modules to be updated at run time without a complete refresh.

General refresh we have two kinds:

  • one is to refresh the page without leaving the page state, which is simply window.location.reload().
  • the other is module hot replacement based on WDS (webpack-dev-server), which only needs to partially refresh the changed modules on the page, while retaining the current page state, such as the check box status, input box input, etc.

We can see that compared to the first type, hot updates are of great significance to our development experience and development efficiency.

HMR as a webpack built-in function, we can enable it by config devServer.hot option.

module.exports = {
//...
devServer: {
hot: true,
},
};

Principle of HMR (Hot Module Replacement)โ€‹

HMR mainly implemented by the Webpack-dev-server module. Let's look at the source code to see the implementation.

I'll simplify the source code to make it easier to read.

Start new serverโ€‹

An instance of compiler was first created through webpack, and then a local service was turned on by creating a custom server instance.

// webpack-dev-server/bin/webpack-dev-server.js
const webpack = require("webpack");
const config = require("../../webpack.config");
const Server = require("../lib/Server");
const compiler = webpack(config); // create compiler instance
const server = new Server(compiler); // create server instance
server.listen(8080, "localhost", () => {});

This custom Server not only creates an HTTP service, but also creates a websocket service based on the HTTP service. Meanwhile, it monitors the access of the browser and sends hash value to the browser when the browser successfully accesses, so as to realize the two-way communication between the Server and the browser.

// webpack-dev-server/lib/Server.js
class Server {
constructor() {
this.setupApp();
this.createServer();
}
// create express app
setupApp() {
this.app = express();
}

// create http server
createServer() {
this.server = http.createServer(this.app);
}

// start server
listen(port, host, callback) {
this.server.listen(port, host, callback);
this.createSocketServer();
}

// create websocket server
createSocketServer() {
const io = socketIO(this.server);
io.on("connection", (socket) => {
this.clientSocketList.push(socket);
socket.emit("hash", this.currentHash);
socket.emit("ok");
socket.on("disconnect", () => {
let index = this.clientSocketList.indexOf(socket);
this.clientSocketList.splice(index, 1);
});
});
}
}
module.exports = Server;

Compile completion listeningโ€‹

In the code above, the server sends notifications to the browser about the hash and pull code, and we want the browser to be notified when the code changes. Therefore, you also need to listen for compile completion events before starting the service.

// Listen when compilation is complete and send a broadcast via websocket to the browser when compilation is complete
function setupHooks() {
let { compiler } = this;
compiler.hooks.done.tap("webpack-dev-server", (stats) => {
this.currentHash = stats.hash;
this.clientSocketList.forEach((socket) => {
socket.emit("hash", this.currentHash);
socket.emit("ok");
});
});
}

File modification listeningโ€‹

To trigger recompilation when code changes, you need to listen for code changes. The source code for this is the webpackDevMiddleware library. In webpackDevMiddleware library, compiler.watch is used to monitor the modification of files, and the compiled products are stored in memory through memory-fs, which is why we can't see the changes in the dist directory. The benefit of putting them in memory is to improve the development efficiency by faster reading and writing.

// webpack-dev-middleware/index.js
const MemoryFs = require("memory-fs");
compiler.watch({}, () => {});
let fs = new MemoryFs();
this.fs = compiler.outputFileSystem = fs;

Insert client code into the browserโ€‹

As mentioned above, in order to realize the communication between the browser and the local service, it is necessary for the browser to access the websocket service opened locally. However, the browser itself does not have such ability, which requires us to provide such client code to run it in the browser. so before the custom server starts the http service, it calls the updateCompiler() method, which modifies entry in the webpack configuration so that the code for the two inserted files can be packaged together in main.js to run in the browser.

// webpack-dev-server/lib/utils/updateCompiler.js
const path = require("path");
function updateCompiler(compiler) {
compiler.options.entry = {
main: [
path.resolve(__dirname, "../../client/index.js"),
path.resolve(__dirname, "../../../webpack/hot/dev-server.js"),
config.entry,
],
};
}
module.exports = updateCompiler;

it will insert /webpack-dev-server/client/index.js code to the browser. This code is used to establish a websocket connection, save the hash when the server sends a hash event, and call reloadApp() when the server sends an ok event.

let currentHash;
let hotEmitter = new EventEmitter();
const socket = window.io("/");
socket.on("hash", (hash) => {
currentHash = hash;
});
socket.on("ok", () => {
reloadApp();
});
function reloadApp() {
hotEmitter.emit("webpackHotUpdate", currentHash);
}

webpackHotUpdate event is registered in the webpack/hot/dev-server.js file. and reloadApp will emit webpackHotUpdate event and exec module.hot.check() function.

let lastHash;
hotEmitter.on("webpackHotUpdate", (currentHash) => {
if (!lastHash) {
lastHash = currentHash;
return;
}
module.hot.check();
});

Where does module.hot.check() come from? The answer is HotModuleReplacementPlugin.

create hot module instanceโ€‹

function hotCreateModule() {
let hot = {
_acceptedDependencies: {},
accept(deps, callback) {
deps.forEach((dep) => (hot._acceptedDependencies[dep] = callback));
},
check: hotCheck,
};
return hot;
}

module.hot.check() calls hotCheck, and the browser gets two patch files from the server.

function hotCheck() {
hotDownloadManifest()
.then((update) => {
let chunkIds = Object.keys(update.c); // ['main']
chunkIds.forEach((chunkId) => {
hotDownloadUpdateChunk(chunkId);
});
lastHash = currentHash;
})
.catch(() => {
window.location.reload();
});
}

The first is to request the hot-update.json file using the hash value saved last time using XMLHttpRequest. The purpose of this description file is to provide the chunkId of the modified file.

function hotDownloadManifest() {
return new Promise(function (resolve, reject) {
let xhr = new XMLHttpRequest();

let url = `${lastHash}.hot-update.json`;

xhr.open("get", url);

xhr.responseType = "json";

xhr.onload = function () {
resolve(xhr.response);
};

xhr.send();
});
}

and then use jsonp to get the chunkId returned by hot-update.json file and the hash file name saved last time to get the file content.

module content replacementโ€‹

When the hot-update.js file is loaded, window.webpackHotUpdate is executed and hotApply is invoked. HotApply finds the old module by its ID and removes it, then performs the accept callback registered in the parent module to implement a local update of the module's contents.

window.webpackHotUpdate = function (chunkId, moreModules) {
hotAddUpdateChunk(chunkId, moreModules);
};

let hotUpdate = {};

function hotAddUpdateChunk(chunkId, moreModules) {
for (let moduleId in moreModules) {
modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId];
}
hotApply();
}

function hotApply() {
for (let moduleId in hotUpdate) {
let oldModule = installedModules[moduleId];
delete installedModules[moduleId];
oldModule.parents.forEach((parentModule) => {
let cb = parentModule.hot._acceptedDependencies[moduleId];
cb && cb();
});
}
}

Summaryโ€‹

recompilation is triggered when code is modified, and webpackDevMiddleware stores the compiled artifacts in memory, thanks to the built-in module memory-fs. HotModuleReplacementPlugin will generate two patches at the same time, the two patches a which is used to tell the browser the chunk changed, one is used to tell the browser module and content change.

When the recompilation is complete, the browser will save the current hash, then concatenate the description file path with the hash value of the previous one, and concatenate the patch file for another request based on what the description file returns.

WebpckHotUdate will be executed after the request is successful, and hotApply will be called again.

In fact, the callback event in the second step of configuring the module hot update will be executed, thus achieving a partial refresh of the page content.