手把手带你了解 Webpack HMR 的基本原理

57 阅读7分钟

一、前言

笔者以前的一篇文章手把手带你实现一个自己的简易版 Webpack主要带大家去实现webpack的构建功能,本篇文章主要带大家实现HMR功能,即热更新。

当本地前端项目启动后,第一次访问页面,称为应用首次冷启。

之后再次修改了本地编辑器中的代码后,浏览器会自动重新渲染,出现修改后的内容(配置了 HMR 的情况下)

如果没有 HMR,我们需要手动刷新浏览器,看到修改后的效果,这样做极大影响了开发效率。

HMR 的核心作用就是这些。

Webpack 是怎么做到的?

二、实现原理

当我们保存编辑器,触发一次 Webpack 热更新,项目中会出现两次请求:

image.png

这就是 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}`);
});

一共做了这几件事情:

  1. 创建 express 本地 http 服务;
  2. 创建 webpack 编译器,基于 webpack 配置将整个应用进行打包;
  3. 声明 express 根路由,用于提供访问时的页面请求;
  4. 挂载内存输出中间件(访问输出的 html 文件);
  5. 创建 websocket 服务,用于后续的热更新通知;
  6. 注册 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();
		});
}

这里主要做了这几件事情:

  1. 定义 websocket 实例,连接 websocket 服务;
  2. 接收 websocket 广播,创建自定义监听事件;
  3. 定义 webpack 热更新逻辑代码;
  4. 热更新失败的兜底页面刷新补偿;

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,把服务跑起来。

效果如下: image.png

image.png

再修改一下 index.js 试试热更新:

console.log(5555, module);
if (module.hot) {
	module.hot.accept();
}
const dom = document.createElement('div');
dom.innerHTML = '123123123';
document.body.append(dom);

保存文件后,终端自动重新构建,并且触发热更新: image.png


完整代码已上传至webpack-dev-demo