一、前言
笔者以前的一篇文章手把手带你实现一个自己的简易版 Webpack主要带大家去实现webpack的构建功能,本篇文章主要带大家实现HMR功能,即热更新。
当本地前端项目启动后,第一次访问页面,称为应用首次冷启。
之后再次修改了本地编辑器中的代码后,浏览器会自动重新渲染,出现修改后的内容(配置了 HMR 的情况下)
如果没有 HMR,我们需要手动刷新浏览器,看到修改后的效果,这样做极大影响了开发效率。
HMR 的核心作用就是这些。
那 Webpack 是怎么做到的?
二、实现原理
当我们保存编辑器,触发一次 Webpack 热更新,项目中会出现两次请求:
这就是 Webpack 热更新的核心原理。
第一次 json 是代表变更文件的清单及 hash。
第二次 js 是变更文件的实际执行 JavaScript 脚本,用于触发页面更新。
而这个交互过程,必然会出现两个除去项目本身以外的概念:
- Webpack websocket 服务,用于本地文件系统实时发送更新通知给页面;
- Webpack client,运行时代码,用于接收通知,并触发热更新相关的核心代码;
- Webpack http 服务,用于执行一个本地项目服务(负责把项目跑起来); 简而言之,本地起一个前端应用,需要 2 个服务 + 1 个 client。
三、手写一个 Webpack HMR
因此整个启动 dev server -> 热更新一次的完整流程应该是这样的:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 开发者操作 │
│ 修改本地编辑器中的代码 │
│ ↓ │
│ 保存文件(触发 Webpack 监听) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Webpack 编译系统 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Webpack 检测到文件变化 │ │
│ │ 2. 开始重新编译 │ │
│ │ 3. 生成新的编译 Hash │ │
│ │ 4. 产出增量更新文件: │ │
│ │ • JSON文件 - 变更文件清单及新hash │ │
│ │ • JS文件 - 变更文件的实际执行脚本 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Dev Server 通知系统 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ WebSocket 服务(端口通常与HTTP服务一致) │ │
│ │ 注册 compiler hooks: │ │
│ │ • compiler.hooks.done.tap - 编译完成时触发 │ │
│ │ • compiler.hooks.invalid.tap - 编译失效时触发 │ │
│ │ │ │
│ │ 向所有已连接的客户端广播消息: │ │
│ │ • { type: 'hash', hash: '新hash值' } │ │
│ │ • { type: 'ok' } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 浏览器客户端 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ WebSocket Client(运行时注入的client.js) │ │
│ │ 1. 连接服务端 WebSocket │ │
│ │ 2. 接收服务端广播消息 │ │
│ │ 3. 消息处理逻辑: │ │
│ │ • type='hash' → 存储hash到 window.__webpack_hot_hash__ │ │
│ │ • type='ok' → 触发 tryApplyUpdates() │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 热更新检查流程 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ tryApplyUpdates() 函数执行: │ │
│ │ 1. 检查 module.hot 是否启用 │ │
│ │ 2. 对比 hash: │ │
│ │ window.__webpack_hot_hash__ vs __webpack_hash__ │ │
│ │ 3. 检查 module.hot.status() 是否为 'idle' │ │
│ │ 4. 调用 module.hot.check(true) 发起更新 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 增量文件获取 │
│ │
│ 浏览器发起两次网络请求: │
│ │
│ 第一次请求: │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 获取 JSON 文件(变更清单) │ │
│ │ 例如:https://localhost:3000/hash.hot-update.json │ │
│ │ 内容:{ h: '新hash值', c: { 'bundle': true } } │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第二次请求: │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 获取 JS 文件(实际更新代码) │ │
│ │ 例如:https://localhost:3000/hash.hot-update.js │ │
│ │ 内容:webpackHotUpdate 函数包裹的更新模块代码 │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 模块热替换执行 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. JSON文件告知哪些模块需要更新 │ │
│ │ 2. JS文件提供更新后的模块代码 │ │
│ │ 3. HMR runtime 执行模块替换 │ │
│ │ 4. 触发 module.hot.accept 回调 │ │
│ │ 5. 页面无刷新更新内容 │ │
│ │ (例如:dom.innerHTML从'123'变成'123123123') │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 失败兜底方案 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 如果热更新过程中出现错误: │ │
│ │ • WebSocket 连接断开 │ │
│ │ • module.hot 未启用 │ │
│ │ • 更新执行失败 │ │
│ │ │ │
│ │ 执行降级策略:location.reload() 页面自动刷新 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ 核心文件对应关系 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • createServer.js → 启动HTTP服务 + WebSocket服务(服务端) │ │
│ │ • createClient.js → 运行时HMR客户端逻辑(浏览器端) │ │
│ │ • webpack.config.js → Webpack配置(启用HMR插件) │ │
│ │ • HtmlGeneratePlugin → 自动生成HTML并引入JS/CSS │ │
│ │ • index.js → 应用入口代码(包含 module.hot.accept) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
我们接下来手写一个热更新代码,来实现项目热更新。
4.1 创建一个应用
先创建一个应用,配备一个最基本的入口 js 文件,用于 webpack 打包。
mkdir webpack-dev-demo
cd webpack-dev-demo
npm init -y
然后新建/src/index.js
写上一段代码:
// 用于启用开发环境热更新,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 文件。
我们手搓一个插件:
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 模板返回,提供最基础的页面访问能力。
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 热更新插件实现页面增量构建后的更新。
// 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 的本地服务+热更新就实现了。
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)
{
"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,把服务跑起来。
效果如下:
再修改一下 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-dev-demo。