一、前言
Webpack热更新是我们开发环境中人人皆知的基本能力,同时也是面试中比较频繁会问到的话题,那里你有没有想过整个过程是怎么实现的?
本文将从概念 -> 手写实现彻底搞懂Webpack HMR
原理。
二、什么是XMR?
当本地前端项目启动后,第一次访问页面,称为应用首次冷启。
之后再次修改了本地编辑器中的代码后,浏览器会自动重新渲染,出现修改后的内容(配置了XMR的情况下)
如果没有XMR,我们需要手动刷新浏览器,看到修改后的效果,这样做极大影响了开发效率。
XMR的核心作用就是这些。
那Webpack
是怎么做到的?
三、实现原理
当我们保存编辑器,触发一次Webpack
热更新,项目中会出现两次请求:

这就是Webpack
热更新的核心原理。
第一次json
是代表变更文件的清单及hash。
第二次js
是变更文件的实际执行JavaScript
脚本,用于触发页面更新。
而这个交互过程,必然会出现两个除去项目本身以外的概念:
- Webpack websocket服务,用于本地文件系统实时发送更新通知给页面;
- Webpack client,运行时代码,用于接收通知,并触发热更新相关的核心代码;
- Webpack http服务,用于执行一个本地项目服务(负责把项目跑起来);
简而言之,本地起一个前端应用,需要2个服务 + 1个client。
四、手写一个Webpack XMR
因此整个启动dev server -> 热更新一次的完整流程应该是这样的:
(构建&内存输出) participant WHM as Webpack Hot Middleware
(WebSocket或EventStream) participant Client as HMR Client participant Page as 浏览器页面 Note over Page: 开发者修改代码,保存文件 Page ->> WDM: (无请求) 文件变动触发构建 WDM ->> WDM: 重新编译代码,生成新的bundle/chunk WDM ->> WHM: 通知构建完成 & 生成新的 hash WHM -->> Client: 通过 WebSocket 发送 "hash"、"ok" 消息 Client ->> Client: 检测当前页面版本 vs 新版本 Client ->> WDM: 请求 hot-update.json / hot-update.js WDM -->> Client: 返回更新模块代码 Client ->> Page: 使用 module.hot 动态替换模块 Note over Page: 无刷新体验地更新页面内容
我们接下来手写一个热更新代码,来实现项目热更新。
4.1 创建一个应用
先创建一个应用,配备一个最基本的入口js文件,用于webpack
打包。
bash
mkdir webpack-dev-demo
cd webpack-dev-demo
npm init -y
然后新建/src/index.js
写上一段代码:
javascript
// 用于启用开发环境热更新,Webpack会自动注入
if (module.hot) {
module.hot.accept();
}
const dom = document.createElement("div");
dom.innerHTML = "123";
document.body.append(dom);
4.2 创建Webpack-dev-server插件
我们不使用官方的webpack-dev-server
插件,因此我们需要实现这个插件,核心是通过Webpack配置生成一个html文件。
我们手搓一个插件:
javascript
class HtmlGeneratePlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync(
"HtmlGeneratePlugin",
(compilation, callback) => {
// 获取所有 chunk 的 js、css 文件
const jsFiles = Object.keys(compilation.assets).filter((f) =>
f.endsWith(".js")
);
const cssFiles = Object.keys(compilation.assets).filter((f) =>
f.endsWith(".css")
);
// 拼接 HTML
const htmlContent = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>My Dev Server</title>
${cssFiles
.map((file) => `<link rel="stylesheet" href="${file}">`)
.join("\n")}
</head>
<body>
<div id="root"></div>
${jsFiles.map((file) => `<script src="${file}"></script>`).join("\n")}
</body>
</html>`;
// 注册到输出资源(内存里)
compilation.assets["index.html"] = {
source: () => htmlContent,
size: () => htmlContent.length,
};
callback();
}
);
}
}
module.exports = HtmlGeneratePlugin;
核心是在webpack编译完成后读取js、css文件,在html模板中引入,最后将html保存到内存中,供后续本地开发服务消费。
这里做的比较暴力,先考虑实现。
4.3 创建webpack http服务
该文件用于创建一个本地应用服务,并且将4.2中内存所创建好的html模板返回,提供最基础的页面访问能力。
javascript
const path = require("path");
const express = require("express");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const WebSocket = require("ws"); // 手动创建 ws
const config = require("./webpack.config.js");
const app = express();
// 1. 创建 webpack 编译器
// 多配置写成数组单元素,可以有 compiler.compilers
const compiler = webpack([config]);
// 2. 声明根路由输出html资源
app.get("/", (req, res) => {
// 从第一个子 compiler 拿 outputFileSystem
const fs = compiler.compilers[0].outputFileSystem;
const filePath = path.join(config.output.path, "index.html");
fs.readFile(filePath, (err, data) => {
if (err) {
res.status(404).send("index.html not found");
return;
}
res.set("content-type", "text/html");
res.send(data);
});
});
// 3. 挂载内存输出中间件
app.use(
webpackDevMiddleware(compiler, {
publicPath: config.output.publicPath,
stats: false,
})
);
// 4. 自定义 WS 服务
const server = require("http").createServer(app);
const wss = new WebSocket.Server({ server });
// 给新连接的客户端发送当前状态
wss.on("connection", (ws) => {
console.log("[WS] client connected");
});
// 5. 注册编译器 hooks,向所有 ws 客户端广播状态
function addHooks(singleCompiler) {
singleCompiler.hooks.invalid.tap("server", (fileName) => {
console.log("[webpack] Recompiling because:", fileName || "changes...");
broadcast({ type: "invalid", file: fileName });
});
singleCompiler.hooks.done.tap("server", (stats) => {
// 打印 build 完成的 hash
console.log(`[webpack] Build done. Hash: ${stats.hash}`);
// 获取构建产物信息
const info = stats.toJson({
all: false,
assets: true,
chunks: true,
chunkModules: false,
colors: true,
});
console.log("\nAssets:");
info.assets.forEach((asset) => {
console.log(` ${asset.name} ${formatSize(asset.size)}`);
});
console.log("\nChunks:");
info.chunks.forEach((chunk) => {
console.log(
` chunk ${chunk.id} (${chunk.names.join(", ")}) - ${formatSize(
chunk.size
)}`
);
});
console.log("\n------------ 构建完成 ------------\n");
// 推送消息给 HMR 客户端
broadcast({ type: "hash", hash: stats.hash });
broadcast({ type: "ok" });
});
}
// 辅助函数:把字节数转成人类可读格式
function formatSize(bytes) {
if (bytes < 1024) return bytes + " bytes";
const kb = bytes / 1024;
if (kb < 1024) return kb.toFixed(2) + " KB";
return (kb / 1024).toFixed(2) + " MB";
}
// 多配置支持
if (compiler.compilers) {
compiler.compilers.forEach(addHooks);
} else {
addHooks(compiler);
}
// 向所有 ws 客户端发消息
function broadcast(msgObj) {
const msg = JSON.stringify(msgObj);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
});
}
// 6. 启动 server
const PORT = 3000;
server.listen(PORT, () => {
console.log(`🚀 Dev Server running at http://localhost:${PORT}`);
});
一共做了这几件事情:
- 创建express 本地http服务;
- 创建webpack编译器,基于webpack配置将整个应用进行打包;
- 声明express根路由,用于提供访问时的页面请求;
- 挂载内存输出中间件(访问输出的html文件);
- 创建websocket服务,用于后续的热更新通知;
- 注册compiler hooks,实时监听webpack构建状态,打印服务日志、广播页面热更新;
4.4 创建webpack client运行时脚本
提供webpack所需要的运行时客户端能力,这里核心是接收websocket通知并调用webpack热更新插件实现页面增量构建后的更新。
javascript
// src/client.js
console.log("[HMR Client] starting...");
function getWSUrl() {
// 根据当前页面构建 ws 地址
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${location.host}`;
}
// 连接到你的 ws 服务
const socket = new WebSocket(getWSUrl());
socket.addEventListener("open", () => {
console.log("[HMR Client] WS connected");
});
socket.addEventListener("message", async (event) => {
const msg = JSON.parse(event.data);
console.log("[HMR Client] received", msg);
switch (msg.type) {
case "invalid":
console.log("[HMR Client] 增量更新完毕");
break;
case "hash":
// 保存最新的编译 hash
console.log("[HMR Client] 获取到热更新hash信息");
window.__webpack_hot_hash__ = msg.hash;
break;
case "ok":
console.log("[HMR Client] 热更新中");
tryApplyUpdates();
break;
default:
console.log("[HMR Client] ws未知消息", msg.type);
}
});
socket.addEventListener("close", () => {
console.warn("[HMR Client] WS disconnected, force reload...");
// location.reload();
});
function isUpdateAvailable() {
// 这里 __webpack_hash__ 是由 webpack runtime 注入的当前构建 hash
return (
window.__webpack_hot_hash__ &&
window.__webpack_hot_hash__ !== __webpack_hash__
);
}
function canApplyUpdates() {
return module.hot && module.hot.status() === "idle";
}
function tryApplyUpdates() {
if (!module.hot) {
console.warn("[HMR Client] module.hot is disabled, reloading...");
// location.reload();
return;
}
if (!isUpdateAvailable() || !canApplyUpdates()) return;
module.hot
.check(true)
.then((updatedModules) => {
console.log("[HMR Client] updated modules:", updatedModules);
})
.catch((err) => {
console.error("[HMR Client] update failed", err);
// location.reload();
});
}
这里主要做了这几件事情:
- 定义websocket实例,连接websocket服务;
- 接收websocket广播,创建自定义监听事件;
- 定义webpack热更新逻辑代码;
- 热更新失败的兜底页面刷新补偿;
4.5 创建webpack.config.js
将4.1~4.4所有的文件注册到webpack配置文件中,不依靠webpack-dev-server
的本地服务+热更新就实现了。
js
const path = require("path");
const webpack = require("webpack");
const HtmlGeneratePlugin = require("./htmlGeneratePlugin.js");
module.exports = {
mode: "development",
entry: [
path.resolve(__dirname, "createClient.js"),
path.resolve(__dirname, "src/index.js"),
],
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
publicPath: "/",
},
plugins: [new webpack.HotModuleReplacementPlugin(), new HtmlGeneratePlugin()],
};
4.6 创建本地启动服务command
在package.json中增加一条dev命令(等价于npm run dev
)
json
{
"name": "test-dev",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "node createServer.js"
},
"dependencies": {
"ws": "^8.18.3"
},
"devDependencies": {
"express": "^5.1.0",
"nodemon": "^3.0.2",
"webpack": "^5.101.3",
"webpack-dev-middleware": "^7.4.5"
},
"keywords": [
"dev-server",
"watcher"
],
"author": "",
"license": "MIT"
}
接下来在终端中执行npm run dev
,把服务跑起来。
效果如下:

访问一次页面看到websocket已经建立连接。
服务端:

客户端:

再修改一下index.js试试热更新:
src/index.js
console.log(5555, module);
if (module.hot) {
module.hot.accept();
}
const dom = document.createElement("div");
dom.innerHTML = "123123123";
document.body.append(dom);
保存文件后,终端自动重新构建:

客户端识别并触发热更新:


实现了。简单么?把Webpack的client
、server
拆解开来,是不是很工程化思想,通俗易懂?再对比build
,就只是少了一步dev server
+ XMR
,然后多了一些生产环境性能优化的配置即可,这些无非只是多了一些Webpack Plugin
而已。
五、结尾
至此,你已经通过手写简单实现Webpack的本地服务 -> 热更新,应该彻底了解了整个本地开发过程。
如果文章对你有帮助,再好不过。