现代化的前端开发体验中,代码变更后浏览器在维持当前页面状态的同时自动完成代码的更新,这早已成为众多开发工具链中的标配,今天我们讨论的主题就是 Webpack 的 DevServer & HMR 的使用及实现原理。
基本使用
先看下面的例子:
// src/index.css
#app > div {
color:red;
font-size: 20px;
}
// src/app.js
export function setup(initValue = null) {
let appElement = document.getElementById('app');
let nameInputElement = document.createElement('input');
nameInputElement.type = 'text';
nameInputElement.placeholder = '请输入姓名';
appElement.appendChild(nameInputElement);
let nameDisplayElement = document.createElement('div');
nameDisplayElement.innerHTML = '姓名:';
appElement.appendChild(nameDisplayElement);
nameInputElement.addEventListener('keyup', (event) => {
nameDisplayElement.innerHTML = `姓名:${event.target.value}`;
});
if (initValue) {
nameInputElement.value = initValue;
nameDisplayElement.innerHTML = `姓名:${initValue}`;
}
}
// src/index.js
import './index.css';
import { setup } from './app';
setup();
复制代码
上述代码片段中,我们创建了一个文本输入框和一个实时显示输入框内容的 div,效果如下所示:
接下来我们对 Webpack 启用 DevServer & HMR 的两种方式进行简单介绍。
直接配置
由于 webpack-cli 内置了 webpack-dev-server,因此我们直接为 Webpack 配置设置 devServer
属性即可启用 DevServer & HMR:
// webpack.config.js
module.exports = {
// 其它配置信息……
entry: './src/index.js',
devServer: {
static: './dist',
port: 3000,
hot: true,
},
};
复制代码
上述代码片段中,我们为 devServer
设置了 static
(静态资源根路径)、port
(服务端口号)、hot
(是否开启 HMR),然后运行 npx webpack serve --open
,等待浏览器打开后,试着更新 src/index.css
或 src/app.js
并保存,会发现浏览器自动更新了代码,效果如下所示:
Middleware
通过直接配置我们可以轻松启用 DevServer & HMR 功能,由于 webpack-cli 使用了 webpack-dev-server,webpack-dev-server 使用了 webpack-dev-middleware 和 webpack-hot-middleware,因此本小节我们直接使用这两个 middleware 来启用 DevServer & HMR:
首先执行以下命令安装相关依赖:
yarn add webpack-dev-middleware webpack-hot-middleware express --dev
# or npm install --save-dev webpack-dev-middleware webpack-hot-middleware express
复制代码
创建 ./server.js
并输入以下内容:
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);
app.use(webpackDevMiddleware(compiler));
app.use(webpackHotMiddleware(compiler));
app.listen(config.devServer.port, function () {
console.log(`Project is running at: http://localhost:${config.devServer.port}\n`);
});
复制代码
上述代码片段中,我们使用 express 来实现本地服务器,首先实例化 express
,然后加载 webpack.config.js
配置以完成 compiler
的实例化,接着将 webpack-dev-middleware
和 webpack-hot-middleware
注入到 express middleware 中,最后监听 Webpack 配置中的开发服务端口启动服务。
然后修改 webpack.config.js
:
// webpack.config.js
const webpack = require('webpack');
module.exports = {
// 其它配置信息……
entry: [
'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000',
'./src/index.js',
],
devServer: {
static: './dist',
port: 3000,
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
],
};
复制代码
将上述代码片段与上一节的 webpack.config.js
内容进行对比,可以发现多了以下几项配置:
- 在
entry
中添加webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000
; - 删除
devServer
中的hot
属性(此时该属性多余,故此删除); - 在
plugins
中添加webpack.HotModuleReplacementPlugin
。
上面的一切都处理完毕后,运行 node ./server.js
,然后访问 http://localhost:3000,试着更新 src/index.css
或 src/app.js
并保存,会发现效果与上一节中的一模一样。
局部刷新
如果运行上文中的任何一个例子,大家会发现这样一个事实,即更新 JavaScript 代码时,浏览器选择了重新刷新,而不像更新 CSS 时保持了页面的状态。这是因为 CSS 的更新是无状态的,即只需要把相关内容给替换掉即可,但 JavaScript 的更新涉及到各种各样的状态的维护,Webpack HMR 模块不知道如何处理这些状态,因此只能采取最保守的操作(即刷新页面)来完成代码的更新。如果想要实现 JavaScript 的局部刷新,需要我们手动进行状态重置。针对于本文的例子,我们可以在 src/index.js
中添加以下内容:
if (module.hot) {
module.hot.accept('./app.js', function() {
console.log('Accepting the updated from ./app.js');
let initValue = null;
let appElement = document.getElementById('app');
while (appElement.firstChild) {
let child = appElement.lastChild;
if (child.nodeName.toLocaleLowerCase() === 'input') {
initValue = child.value;
}
appElement.removeChild(child);
}
setup(initValue);
});
}
复制代码
上述代码片段中,我们首先判断接口 module.hot
是否可用(由 HotModuleReplacementPlugin
暴露),如果可用,我们则通过 module.hot.accept
方法来监听 ./app.js
模块的更新,在其回调中,我们首先通过 initValue
来缓存当前输入框的值,接着移除 id 为 app
的所有子节点,最后调用 setup
函数重新创建相关节点。
以上文中任何一种方式再次运行该示例,接着在输入框内输入一些内容后修改 ./src/app.js
并保存,此刻页面以局部刷新的形式完成了 JavaScript 代码更新并维护了页面状态,效果如下所示:
当然,除了使用 module.hot
外,也可使用 import.meta.webpackHot
(只能在 strict ESM 中使用),相关详情参见 webpack.docschina.org/api/hot-mod…,此处不再阐述。
原理分析
上文我们介绍了 Webpack DevServer & HMR 的基本用法,本节我们对其依赖的 webpack-dev-middleware
、webpack-hot-middleware
及 HotModuleReplacementPlugin
的实现原理进行简单的介绍。
webpack-dev-middleware
webpack-dev-middleware
的主要职责是监听模块的变化,并且以最新的模块内容响应相关请求,其核心代码如下所示(为便于理解,代码已经过最大程度的简化):
const path = require('path');
const memfs = require('memfs');
const mime = require("mime-types");
function getPaths(context) {
const { stats } = context;
return (stats.stats ? stats.stats : [stats]).map(({ compilation }) => ({
outputPath: compilation.getPath(compilation.outputOptions.path),
publicPath: compilation.getPath(compilation.outputOptions.publicPath),
}));
}
function getFilenameFromRequest(context, req) {
const paths = getPaths(context);
const baseUrl = `${req.protocol}://${req.get('host')}`;
const url = new URL(req.url, baseUrl);
for (const { publicPath, outputPath } of paths) {
const publicPathUrl = new URL(publicPath, baseUrl);
if (url.pathname && url.pathname.startsWith(publicPathUrl.pathname)) {
let filename = outputPath;
const pathname = url.pathname.substr(publicPathUrl.pathname.length);
if (pathname) {
filename = path.join(outputPath, pathname);
}
let fileStats;
try {
fileStats = context.outputFileSystem.statSync(filename);
} catch (_) {
continue;
}
if (fileStats.isFile()) {
return filename;
}
if (fileStats.isDirectory()) {
filename = path.join(filename, 'index.html');
try {
fileStats = context.outputFileSystem.statSync(filename);
} catch (_) {
continue;
}
if (fileStats.isFile()) {
return filename;
}
}
}
}
return undefined;
}
function ready(context, req, callback) {
if (context.isReady) {
callback(context.stats);
return;
}
const name = req && req.url || callback.name;
context.logger.info(`Wait until bundle finished${name ? `: ${name}` : ""}`);
context.callbacks.push(callback);
}
function main(compiler) {
/**
* 上下文环境设置
*/
const context = {
isReady: false,
stats: null,
callbacks: [],
outputFileSystem: null,
logger: compiler.getInfrastructureLogger('webpack-dev-middleware'),
};
/**
* 内存文件系统设置
*/
const memeoryFileSystem = memfs.createFsFromVolume(new memfs.Volume());
memeoryFileSystem.join = path.join.bind(path);
context.outputFileSystem = memeoryFileSystem;
compiler.outputFileSystem = memeoryFileSystem;
/**
* compiler hook 设置
*/
function invalid() {
if (context.isReady) {
context.logger.info('Compilation starting...');
}
context.isReady = false;
context.stats = undefined;
}
compiler.hooks.watchRun.tap('webpack-dev-middleware', invalid);
compiler.hooks.invalid.tap('webpack-dev-middleware', invalid);
compiler.hooks.done.tap('webpack-dev-middleware', (stats) => {
context.stats = stats;
context.isReady = true;
process.nextTick(() => {
if (!context.isReady) {
return;
}
context.logger.info('Compilation finished');
const callbacks = context.callbacks;
context.callbacks = [];
callbacks.forEach(callback => callback(context.stats))
});
});
/**
* 开启监听
*/
const watchOptions = compiler.options.watchOptions || {};
compiler.watch(watchOptions, (error) => {
if (error) {
context.logger.error(error);
}
});
/**
* Express middleware
*/
return async function(req, res, next) {
const method = req.method;
if (['GET', 'HEAD'].indexOf(method) === -1) {
await next();
return;
}
ready(context, req, async () => {
const filename = getFilenameFromRequest(context, req);
if (!filename) {
await next();
return;
}
const contentType = mime.contentType(path.extname(filename));
if (contentType) {
res.setHeader('Content-Type', contentType);
}
res.send(context.outputFileSystem.readFileSync(filename));
});
};
};
module.exports = main;
复制代码
在 main
函数中,我们主要做了以下几件事情:
-
定义变量
context
设置上下文环境; -
在开发环境下,一般使用内存来存储打包后的资源,故此这里我们通过 memfs 模块创建内存文件系统
memeoryFileSystem
,并将其赋值给compiler.outputFileSystem
,以此来实现 Webpack 将打包后的资源存储到内存中的目的; -
接着监听
compiler
的 watchRun、invalid 及 done 钩子,其中:- 在
watchRun
及invalid
的回调中,重置context.isReady
及context.stats
; - 在
done
的回调中,首先设置context.isReady
及context.stats
的值,然后调用process.nextTick
以延迟触发context
回调,这里之所以延迟触发是因为如果资源此时发生了变化,那么context
回调中得到的资源可能是无效的,故在下一个任务中触发context
回调以避免资源无效的情况发生。
- 在
-
设置完
compiler
的钩子函数监听后,通过调用compiler.watch
方法以监听模式开启 Webpack 打包流程; -
最后按照 express 自定义 middleware 的格式要求返回对打包资源请求处理的中间件。
在 express middleware 的具体实现中:
-
如果不是
GET
及HEAD
请求,那么直接调用next
方法将请求交给其它中间件进行处理,否则进入下一步; -
调用
ready
函数,并在回调中通过调用getFilenameFromRequest
函数来获取请求资源的路径(filename
),接着设置Content-Type
头部,并将文件内容发送给请求方。这其中:- 在
ready
函数中,如果context.isReady
的值为true
,则直接调用回调,否则将其添加到context.callbacks
中以便在compiler.done
钩子的回调中触发; - 在
getFilenameFromRequest
函数中,我们首先计算出请求资源的路径,如果该路径存在且为文件,直接返回;如果该路径存在且为目录,则匹配该路径下的index.html
是否存在,存在即返回该路径下index.html
的路径,否则返回undefined
。
- 在
本小节我们对 webpack-dev-middleware
的核心实现进行了简要分析,总结一下共有以下几个流程:
- 将 Webpack 的文件系统设置为内存文件系统;
- 监听
watchRun
、invalid
及done
钩子; - 以监听模式运行 Webpack 的打包流程;
- 通过
express middleware
拦截资源请求并对其进行响应。
webpack-hot-middleware
webpack-hot-middleware
的主要职责是监听模块的变化,然后将发生变化的模块推送给客户端,客户端对模块进行替换。不同于 webpack-dev-middleware
,webpack-hot-middleware
分服务端与客户端两部分,下面我们便就对其核心实现进行解析(为便于理解,代码已经过最大程度的简化)。
服务端
function createEventStream() {
let clientId = 0;
let clients = {};
function everyClient(callback) {
Object.keys(clients).forEach(id => callback(clients[id]));
}
return {
handler: (req, res) => {
const headers = {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/event-stream;charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
// While behind nginx, event stream should not be buffered:
// http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
'X-Accel-Buffering': 'no',
};
const isHttp1 = !(parseInt(req.httpVersion) >= 2);
if (isHttp1) {
req.socket.setKeepAlive(true);
Object.assign(headers, {
'Connection': 'keep-alive',
});
}
res.writeHead(200, headers);
res.write('\n');
const id = clientId++;
clients[id] = res;
req.on('close', function () {
if (!res.finished) {
res.end()
};
delete clients[id];
});
},
publish: (payload) => {
everyClient(client => {
client.write('data: ' + JSON.stringify(payload) + '\n\n');
});
},
};
};
function publishStats(action, context) {
const stats = context.stats.toJson({
all: false,
cached: true,
children: true,
modules: true,
timings: true,
hash: true,
});
[stats.children && stats.children.length ? stats.children: [stats]].forEach(() => {
context.logger.info(`Webpack built ${stats.hash} in ${stats.time} ms`);
context.eventStream.publish({
action,
time: stats.time,
hash: stats.hash,
warnings: stats.warnings || [],
errors: stats.errors || [],
modules: stats.modules.reduce((result, moduleItem) => ({
...result,
[moduleItem.id]: moduleItem.name,
}), {}),
});
});
}
function main(compiler) {
/**
* 上下文环境设置
*/
const context = {
stats: null,
path: '/__webpack_hmr',
eventStream: createEventStream(),
logger: compiler.getInfrastructureLogger('webpack-hot-middleware'),
};
/**
* compiler hook 设置
*/
compiler.hooks.invalid.tap('webpack-hot-middleware', () => {
context.stats = null;
context.logger.info('Webpack building...');
context.eventStream.publish({ action: 'building' });
});
compiler.hooks.done.tap('webpack-hot-middleware', (stats) => {
context.stats = stats;
publishStats('built', context);
});
/**
* Express middleware
*/
return function(req, res, next) {
const url = new URL(req.url, `${req.protocol}://${req.get('host')}`);
if (url.pathname !== context.path) {
return next();
}
context.eventStream.handler(req, res);
if (context.stats) {
publishStats('sync', context);
}
}
}
module.exports = main;
复制代码
在 main
函数中,我们主要做了以下几件事情:
-
定义变量
context
设置上下文环境,注意查看createEventStream
函数的实现,这里使用了 EventSource 进行服务端推送; -
接着监听
compiler
的 invalid 及 done 钩子,其中:- 在
invalid
的回调中,重置context.stats
并给客户端推送building
事件; - 在
done
的回调中,设置context.stats
的值并给客户端推送built
事件。
- 在
-
在
express middleware
的实现中:- 如果请求的
pathname
不为/__webpack_hmr
,那么直接调用next
方法将请求交给其它中间件进行处理,否则进入下一步; - 通过
context.eventStream.handler
调用将当前请求转换为EventSource
长链接以便与客户端保持长久通信; - 接着判断是否设置了
context.stats
的值,满足则给客户端推送sync
事件。
- 如果请求的
客户端
通过分析服务端的实现可知,服务端需要推送事件给客户端,客户端自然需要监听相关事件并进行处理,以下是客户端的核心逻辑:
let lastHash;
function upToDate(hash) {
if (hash) {
lastHash = hash;
}
return lastHash == __webpack_hash__;
}
function applyCallback(err) {
if (err) {
console.warn(`[HMR] Update check failed: ${err.stack || err.message}`);
return;
}
if (!upToDate()) {
checkServer();
}
}
const applyOptions = {
ignoreUnaccepted: true,
ignoreDeclined: true,
ignoreErrored: true,
onUnaccepted: (data) => {
console.warn(`Ignored an update to unaccepted module ${data.chain.join(' -> ')}`);
},
onDeclined: (data) => {
console.warn(`Ignored an update to declined module ${data.chain.join(' -> ')}`);
},
onErrored: (data) => {
console.error(data.error);
console.warn(`Ignored an error while updating module ${data.moduleId} (${data.type})`);
},
};
function checkServer() {
const checkCallback = (err) => {
if (err) {
console.warn(`[HMR] Update check failed: ${err.stack || err.message}`);
return;
}
module.hot.apply(applyOptions, applyCallback)
.then(_ => applyCallback(null))
.catch(applyCallback);
};
module.hot.check(false, checkCallback)
.then(_ => checkCallback(null))
.catch(checkCallback);
}
function parseMessage(message) {
switch (message.action) {
case 'building':
console.log('[HMR] bundle rebuilding');
break;
case 'built':
console.log(`[HMR] bundle ${message.hash} rebuilt in ${message.time} ms`);
case 'sync':
if (!upToDate(message.hash) && module.hot.status() === 'idle') {
console.log('[HMR] Checking for updates on the server...');
checkServer();
}
break;
default:
console.error(`[HMR] unknown message action:${message.action}`);
break;
}
}
function connect() {
const source = new window.EventSource('/__webpack_hmr');
source.onopen = () => {
console.log('[HMR] connected');
};
source.onerror = () => {
source.close();
};
source.onmessage = (event) => {
try {
parseMessage(JSON.parse(event.data));
} catch (error) {
console.warn('Invalid HMR message: ' + event.data + '\n' + error);
}
};
}
connect();
复制代码
- 在
connect
函数中,我们主要使用EventSource
与服务端对路径为/__webpack_hmr
的请求建立长链接,然后在onmessage
的回调中调用parseMessage
函数对服务端发送的信息进行处理; - 在
parseMessage
函数中,如果消息类型为sync
,消息中hash
与当前最新的hash
不一致且module.hot.status
的返回值为idle
时,调用checkServer
函数; - 在
checkServer
函数中,我们通过调用module.hot.check
并在其回调中调用module.hot.apply
来完成模块的更新。
小结
本小节我们对 webpack-hot-middleware
的核心实现进行了简要分析,由于模块更新需要服务端与客户端的配合才能完成,因此它的实现分为服务端与客户端两部分:
- 在服务端中,使用
express middleware
拦截路径为/__webpack_hmr
的请求,将其转换为长链接,以便invalid
、done
钩子触发后能够为客户端推送相关事件; - 在客户端中,使用
EventSource
与服务端建立长链接,并监听onmessage
事件,在得到消息后对消息进行解析以完成模块更新操作。
HotModuleReplacementPlugin
如前所述,在 webpack-hot-middleware
的客户端代码中,我们调用了 module.hot
中的相关方法,这些方法由 HotModuleReplacementPlugin
注入,本节我们便对其实现进行简要分析。
查看 HotModuleReplacementPlugin 的实现,在 apply
方法中, HotModuleReplacementPlugin
通过监听 compiler.hooks.compilation
钩子来完成支撑模块动态替换的逻辑设置。
依赖设置
if (compilation.compiler !== compiler) return;
// 添加 module.hot.accept 接口依赖
compilation.dependencyFactories.set(
ModuleHotAcceptDependency,
normalModuleFactory
);
compilation.dependencyTemplates.set(
ModuleHotAcceptDependency,
new ModuleHotAcceptDependency.Template()
);
// 此处省略其它 module.hot.* 接口依赖设置代码
// 添加 import.meta.webpackHot.accept 接口依赖
compilation.dependencyFactories.set(
ImportMetaHotAcceptDependency,
normalModuleFactory
);
compilation.dependencyTemplates.set(
ImportMetaHotAcceptDependency,
new ImportMetaHotAcceptDependency.Template()
);
// 此处省略其它 import.meta.webpackHot.* 接口依赖设置代码
复制代码
上述代码片段中:
- 首先判断当前
compilation
所属的compiler
是否与apply
方法的compiler
实参相等,如不相等,直接返回,否则继续执行(因为该插件不应该影响子 compilation 的执行); - 然后通过
compilation.dependencyTemplates.set
调用分别设置module.hot.*
及import.meta.webpackHot.*
接口依赖(用于在seal
阶段生成相关代码);
compilation.hooks.record
通过监听 compilation.hooks.record
钩子来更新 records
中的一些属性:
let hotIndex = 0;
const fullHashChunkModuleHashes = {};
const chunkModuleHashes = {};
compilation.hooks.record.tap(
"HotModuleReplacementPlugin",
(compilation, records) => {
if (records.hash === compilation.hash) return;
const chunkGraph = compilation.chunkGraph;
records.hash = compilation.hash;
records.hotIndex = hotIndex;
records.fullHashChunkModuleHashes = fullHashChunkModuleHashes;
records.chunkModuleHashes = chunkModuleHashes;
records.chunkHashes = {};
records.chunkRuntime = {};
for (const chunk of compilation.chunks) {
records.chunkHashes[chunk.id] = chunk.hash;
records.chunkRuntime[chunk.id] = getRuntimeKey(chunk.runtime);
}
records.chunkModuleIds = {};
for (const chunk of compilation.chunks) {
records.chunkModuleIds[chunk.id] = Array.from(
chunkGraph.getOrderedChunkModulesIterable(
chunk,
compareModulesById(chunkGraph)
),
m => chunkGraph.getModuleId(m)
);
}
}
);
复制代码
compilation.hooks.fullHash
通过监听 compilation.hooks.fullHash
钩子(runtime 被添加之后触发)来计算哪些 module
发生了变化并将其存储到 updatedModules
中:
const updatedModules = new TupleSet();
const fullHashModules = new TupleSet();
const nonCodeGeneratedModules = new TupleSet();
compilation.hooks.fullHash.tap("HotModuleReplacementPlugin", hash => {
const chunkGraph = compilation.chunkGraph;
const records = compilation.records;
for (const chunk of compilation.chunks) {
const getModuleHash = module => {
if (compilation.codeGenerationResults.has(module, chunk.runtime)) {
return compilation.codeGenerationResults.getHash(
module,
chunk.runtime
);
} else {
nonCodeGeneratedModules.add(module, chunk.runtime);
return chunkGraph.getModuleHash(module, chunk.runtime);
}
};
const fullHashModulesInThisChunk = chunkGraph.getChunkFullHashModulesSet(chunk);
// 设置 fullHashModules 的值
const modules = chunkGraph.getChunkModulesIterable(chunk);
if (modules !== undefined) {
if (records.chunkModuleHashes) {
if (fullHashModulesInThisChunk !== undefined) {
for (const module of modules) {
const key = `${chunk.id}|${module.identifier()}`;
const hash = getModuleHash(module);
if (fullHashModulesInThisChunk.has(module)) {
if (records.fullHashChunkModuleHashes[key] !== hash) {
updatedModules.add(module, chunk);
}
fullHashChunkModuleHashes[key] = hash;
} else {
if (records.chunkModuleHashes[key] !== hash) {
updatedModules.add(module, chunk);
}
chunkModuleHashes[key] = hash;
}
}
} else {
// 设置 chunkModuleHashes 的值
}
} else {
// 设置 fullHashChunkModuleHashes 及 chunkModuleHashes 的值
}
}
}
hotIndex = records.hotIndex || 0;
if (updatedModules.size > 0) hotIndex++;
hash.update(`${hotIndex}`);
});
复制代码
钩子 compilation.hooks.fullHash
中存在许多干扰逻辑,它们的目的是为了计算 fullHashModules
、fullHashChunkModuleHashes
、chunkModuleHashes
等变量的值,把这些干扰代码去掉后,回调的核心逻辑便是对比 chunk
中 module
的 hash
值是否发生了变化,如果发生了变化,就将相关 module
及 chunk
添加到 updatedModules
中去。
compilation.hooks.processAssets
通过监听 compilation.hooks.processAssets
钩子来生成 [hash].hot-update.js
和 [hash].hot-update.json
文件:
compilation.hooks.processAssets.tap(
{
name: "HotModuleReplacementPlugin",
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
},
() => {
const chunkGraph = compilation.chunkGraph;
const records = compilation.records;
if (records.hash === compilation.hash) return;
if (!records.chunkModuleHashes || !records.chunkHashes || !records.chunkModuleIds) {
return;
}
// 对比 chunk 中 module 的 hash 是否发生了变化,如果发生了变化,将相关 module 及 chunk 添加到 updatedModules 中;
// 更新 chunkModuleHashes 的值。
const hotUpdateMainContentByRuntime = new Map();
let allOldRuntime;
// 通过遍历 records.chunkRuntime 的 key 收集已过时的 runtime,并将其存储到 allOldRuntime 中;
// 通过 forEachRuntime 调用遍历 allOldRuntime,并设置 hotUpdateMainContentByRuntime 的值。
if (hotUpdateMainContentByRuntime.size === 0) return;
const allModules = new Map(); // 所有 module 列表(用于后续验证哪些 module 被完全删除)
// 遍历 compilation.modules 设置 allModules 的值。
const completelyRemovedModules = new Set();
for (const key of Object.keys(records.chunkHashes)) {
const oldRuntime = keyToRuntime(records.chunkRuntime[key]);
const remainingModules = [];
// 遍历 records.chunkModuleIds[key] 的值来设置 remainingModules 和 completelyRemovedModules 的值。
let chunkId;
let newModules;
let newRuntimeModules;
let newFullHashModules;
let newDependentHashModules;
let newRuntime;
let removedFromRuntime;
const currentChunk = find(
compilation.chunks,
chunk => `${chunk.id}` === key
);
if (currentChunk) {
// 设置 newRuntime 的值。
if (newRuntime === undefined) continue;
// 根据 updatedModules 设置 newModules、newRuntimeModules、newFullHashModules、newDependentHashModules 的值;
// 根据 oldRuntime、newRuntime 设置 removedFromRuntime 的值。
} else {
// 由于此时 chunk 已经被删除,将 removedFromRuntime、newRuntime 的值均设置为 oldRuntime
}
if (removedFromRuntime) {
// 根据 remainingModules 及 newRuntime 更新 hotUpdateMainContentByRuntime
}
// 生成 [hash].hot-update.js 文件
if ((newModules && newModules.length > 0) || (newRuntimeModules && newRuntimeModules.length > 0)) {
const hotUpdateChunk = new HotUpdateChunk();
// 检查是否开启了 Webpack 4 API 的向后兼容
if (backCompat)
ChunkGraph.setChunkGraphForChunk(hotUpdateChunk, chunkGraph);
hotUpdateChunk.id = chunkId;
hotUpdateChunk.runtime = newRuntime;
if (currentChunk) {
for (const group of currentChunk.groupsIterable)
hotUpdateChunk.addGroup(group);
}
chunkGraph.attachModules(hotUpdateChunk, newModules || []);
chunkGraph.attachRuntimeModules(
hotUpdateChunk,
newRuntimeModules || []
);
if (newFullHashModules) {
chunkGraph.attachFullHashModules(
hotUpdateChunk,
newFullHashModules
);
}
if (newDependentHashModules) {
chunkGraph.attachDependentHashModules(
hotUpdateChunk,
newDependentHashModules
);
}
const renderManifest = compilation.getRenderManifest({
chunk: hotUpdateChunk,
hash: records.hash,
fullHash: records.hash,
outputOptions: compilation.outputOptions,
moduleTemplates: compilation.moduleTemplates,
dependencyTemplates: compilation.dependencyTemplates,
codeGenerationResults: compilation.codeGenerationResults,
runtimeTemplate: compilation.runtimeTemplate,
moduleGraph: compilation.moduleGraph,
chunkGraph
});
for (const entry of renderManifest) {
let filename;
let assetInfo;
if ("filename" in entry) {
filename = entry.filename;
assetInfo = entry.info;
} else {
({ path: filename, info: assetInfo } =
compilation.getPathWithInfo(
entry.filenameTemplate,
entry.pathOptions
));
}
const source = entry.render();
compilation.additionalChunkAssets.push(filename);
compilation.emitAsset(filename, source, {
hotModuleReplacement: true,
...assetInfo
});
if (currentChunk) {
currentChunk.files.add(filename);
compilation.hooks.chunkAsset.call(currentChunk, filename);
}
}
forEachRuntime(newRuntime, runtime => {
hotUpdateMainContentByRuntime
.get(runtime)
.updatedChunkIds.add(chunkId);
});
}
}
const completelyRemovedModulesArray = Array.from(
completelyRemovedModules
);
const hotUpdateMainContentByFilename = new Map();
// 设置 hotUpdateMainContentByFilename 的值,包括属性:
// removedChunkIds、removedModules、updatedChunkIds 及 assetInfo。
for (const {
removedChunkIds,
removedModules,
updatedChunkIds,
filename,
assetInfo
} of hotUpdateMainContentByRuntime.values()) {
// 设置 hotUpdateMainContentByFilename 的值:
// key 为:filename 的值;属性有:removedChunkIds、removedModules、updatedChunkIds 及 assetInfo。
}
// 生成 [hash].hot-update.json 文件
for (const [
filename,
{ removedChunkIds, removedModules, updatedChunkIds, assetInfo }
] of hotUpdateMainContentByFilename) {
const hotUpdateMainJson = {
c: Array.from(updatedChunkIds),
r: Array.from(removedChunkIds),
m:
removedModules.size === 0
? completelyRemovedModulesArray
: completelyRemovedModulesArray.concat(
Array.from(removedModules, m =>
chunkGraph.getModuleId(m)
)
)
};
const source = new RawSource(JSON.stringify(hotUpdateMainJson));
compilation.emitAsset(filename, source, {
hotModuleReplacement: true,
...assetInfo
});
}
}
);
复制代码
钩子 compilation.hooks.processAssets
逻辑较多,此处已将计算 newModules
、hotUpdateMainContentByFilename
等变量的代码以注释替换,查看简化版的实现可知,该钩子的主要任务就是根据 newModules
、hotUpdateMainContentByFilename
等变量来生成 [hash].hot-update.js
及 [hash].hot-update.json
文件。在介绍 webpack-hot-middleware
客户端的时候我们说过,在接收到服务端类型为 sync
的消息后,客户端会调用 module.hot.check
和 module.hot.apply
,在其内部会请求这两个文件,并据此完成模块的更新操作。
compilation.hooks.additionalTreeRuntimeRequirements
通过监听 compilation.hooks.additionalTreeRuntimeRequirements
钩子,设置并实例化 HMR 需要的运行时,这样才能保证 Webpack 在打包时将相关运行时代码准入到最终生成的代码中(关于运行时的讲解,可参考笔者的:Webpack Runtime 小析):
compilation.hooks.additionalTreeRuntimeRequirements.tap(
"HotModuleReplacementPlugin",
(chunk, runtimeRequirements) => {
runtimeRequirements.add(RuntimeGlobals.hmrDownloadManifest);
runtimeRequirements.add(RuntimeGlobals.hmrDownloadUpdateHandlers);
runtimeRequirements.add(RuntimeGlobals.interceptModuleExecution);
runtimeRequirements.add(RuntimeGlobals.moduleCache);
compilation.addRuntimeModule(
chunk,
new HotModuleReplacementRuntimeModule()
);
}
);
复制代码
代码转换
运行上文的例子,并观察最终生成的代码,会发现我们的热更新代码由:
if (module.hot) {
module.hot.accept('./app.js', function() {
console.log('Accepting the updated from ./app.js');
let initValue = null;
let appElement = document.getElementById('app');
while (appElement.firstChild) {
let child = appElement.lastChild;
if (child.nodeName.toLocaleLowerCase() === 'input') {
initValue = child.value;
}
appElement.removeChild(child);
}
setup(initValue);
});
}
复制代码
变成了:
if (true) {
module.hot.accept('./app.js', function() {
console.log('Accepting the updated from ./app.js');
let initValue = null;
let appElement = document.getElementById('app');
while (appElement.firstChild) {
let child = appElement.lastChild;
if (child.nodeName.toLocaleLowerCase() === 'input') {
initValue = child.value;
}
appElement.removeChild(child);
}
(0, _app__WEBPACK_IMPORTED_MODULE_1__.setup)(initValue);});
}
}
复制代码
这是因为 HotModuleReplacementPlugin
通过设置 JavaScriptParser 对其进行了转换:
const applyModuleHot = parser => {
parser.hooks.evaluateIdentifier.for("module.hot").tap(
{
name: "HotModuleReplacementPlugin",
before: "NodeStuffPlugin"
},
expr => {
return evaluateToIdentifier(
"module.hot",
"module",
() => ["hot"],
true
)(expr);
}
);
parser.hooks.call
.for("module.hot.accept")
.tap(
"HotModuleReplacementPlugin",
createAcceptHandler(parser, ModuleHotAcceptDependency)
);
};
normalModuleFactory.hooks.parser
.for("javascript/auto")
.tap("HotModuleReplacementPlugin", parser => {
applyModuleHot(parser);
// 省略其它逻辑……
});
normalModuleFactory.hooks.parser
.for("javascript/dynamic")
.tap("HotModuleReplacementPlugin", parser => {
applyModuleHot(parser);
});
复制代码
上述代码中,我们通过监听钩子 normalModuleFactory.hooks.parser,并在其回调处理函数 applyModuleHot
中:
- 通过监听钩子
parser.hooks.evaluateIdentifier
匹配module.hot
求值表达式(这里是if (module.hot)
),然后将其转换为true
; - 通过监听钩子
parser.hooks.call
匹配module.hot.accept
调用,然后为其设置必要的依赖,以便在代码生成阶段通过代码生成器(JavaScriptGenerator)配合依赖模板生成最终的代码。
小结
本小节对 HotModuleReplacementPlugin
的实现进行了简单的分析,这里简单总结下主要流程:
- 添加必要的依赖;
- 通过
JavaScriptParser
转换我们业务代码中的模块更新代码; - 通过
compilation.hooks.record
及compilation.hooks.fullHash
钩子,计算并记录module
更新前后的一系列信息; - 根据前面
module
更新信息在compilation.hooks.record
钩子回调中生成[hash].hot-update.js
和[hash].hot-update.json
文件,以便客户端能够根据这些文件动态地更新模块。
这里需要注意的是,在 HarmonyImportDependencyParserPlugin 中,会通过调用 HotModuleReplacementPlugin.getParserHooks
方法获取与 hotAcceptCallback
和 hotAcceptWithoutCallback
相关的 JavaScriptParser
钩子,并分别为其设置 HarmonyAcceptImportDependency
和 HarmonyAcceptDependency
依赖:
// 已删除其它无关代码……
const HotModuleReplacementPlugin = require("../HotModuleReplacementPlugin");
module.exports = class HarmonyImportDependencyParserPlugin {
apply(parser) {
const { hotAcceptCallback, hotAcceptWithoutCallback } = HotModuleReplacementPlugin.getParserHooks(parser);
hotAcceptCallback.tap(
"HarmonyImportDependencyParserPlugin",
(expr, requests) => {
if (!HarmonyExports.isEnabled(parser.state)) {
// This is not a harmony module, skip it
return;
}
const dependencies = requests.map(request => {
const dep = new HarmonyAcceptImportDependency(request);
dep.loc = expr.loc;
parser.state.module.addDependency(dep);
return dep;
});
if (dependencies.length > 0) {
const dep = new HarmonyAcceptDependency(
expr.range,
dependencies,
true
);
dep.loc = expr.loc;
parser.state.module.addDependency(dep);
}
}
);
hotAcceptWithoutCallback.tap(
"HarmonyImportDependencyParserPlugin",
(expr, requests) => {
// 与 hotAcceptCallback 处理逻辑一样,省略……
}
);
}
}
复制代码
HMR 运行时
通过上文可知,HotModuleReplacementPlugin
通过 compilation.hooks.additionalTreeRuntimeRequirements
来设置 HMR 所需的运行时代码,本节我们主要对 module.hot.check
、module.hot.apply
方法进行解析。
module.hot.check
module.hot.check
实际上调用的是 lib/hmr/HotModuleReplacement.runtime.js
中的 hotCheck 函数,其定义如下:
function hotCheck(applyOnUpdate) {
if (currentStatus !== "idle") {
throw new Error("check() is only allowed in idle status");
}
return setStatus("check")
.then($hmrDownloadManifest$)
.then(function (update) {
if (!update) {
return setStatus(applyInvalidatedModules() ? "ready" : "idle").then(
function () {
return null;
}
);
}
return setStatus("prepare").then(function () {
var updatedModules = [];
blockingPromises = [];
currentUpdateApplyHandlers = [];
return Promise.all(
Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
promises,
key
) {
$hmrDownloadUpdateHandlers$[key](
update.c,
update.r,
update.m,
promises,
currentUpdateApplyHandlers,
updatedModules
);
return promises;
},
[])
).then(function () {
return waitForBlockingPromises(function () {
if (applyOnUpdate) {
return internalApply(applyOnUpdate);
} else {
return setStatus("ready").then(function () {
return updatedModules;
});
}
});
});
});
});
}
复制代码
Webpack 在打包时,会替换掉上述代码片段中的几个变量:
$hmrDownloadManifest$
替换为__webpack_require__.hmrM
,用于加载[hash]-hot-update.json
文件;$hmrDownloadUpdateHandlers$
替换为__webpack_require__.hmrC
,用于加载[hash]-hot-update.js
文件;
通过代码片段可知,hotCheck
的主要目的是加载 [hash]-hot-update.json
和 [hash]-hot-update.js
文件,然后根据 applyOnUpdate
的值执行相应逻辑,执行流程如下:
-
如果当前状态不为
idle
,抛出异常,否则进入下一步; -
通过
setStatus
将当前状态设置为check
,并通过__webpack_require__.hmrM
加载[hash]-hot-update.json
文件; -
文件加载成功后,如果回调中参数
update
的值为空,根据applyInvalidatedModules
调用的返回值设置当前状态,并在回调中返回null
,否则进入下一步; -
通过
setStatus
将当前状态设置为prepare
,并将__webpack_require__.hmrC
转换成Promise 数组
以实现并行加载[hash]-hot-update.js
文件的目的; -
文件加载成功后,调用
waitForBlockingPromises
以等待所有未执行完的请求,最后根据参数applyOnUpdate
的值做以下不同处理:- 如果
applyOnUpdate
为true
,执行internalApply
进行依赖替换; - 如果
applyOnUpdate
为false
,通过setStatus
将当前状态设置为ready
,并在回调中返回updatedModules
。
- 如果
module.hot.apply
module.hot.apply
实际上调用的是 lib/hmr/HotModuleReplacement.runtime.js
中的 hotApply 函数,其定义如下:
function hotApply(options) {
if (currentStatus !== "ready") {
return Promise.resolve().then(function () {
throw new Error("apply() is only allowed in ready status");
});
}
return internalApply(options);
}
function internalApply(options) {
options = options || {};
applyInvalidatedModules();
var results = currentUpdateApplyHandlers.map(function (handler) {
return handler(options);
});
currentUpdateApplyHandlers = undefined;
var errors = results
.map(function (r) {
return r.error;
})
.filter(Boolean);
if (errors.length > 0) {
return setStatus("abort").then(function () {
throw errors[0];
});
}
// Now in "dispose" phase
var disposePromise = setStatus("dispose");
results.forEach(function (result) {
if (result.dispose) result.dispose();
});
// Now in "apply" phase
var applyPromise = setStatus("apply");
var error;
var reportError = function (err) {
if (!error) error = err;
};
var outdatedModules = [];
results.forEach(function (result) {
if (result.apply) {
var modules = result.apply(reportError);
if (modules) {
for (var i = 0; i < modules.length; i++) {
outdatedModules.push(modules[i]);
}
}
}
});
return Promise.all([disposePromise, applyPromise]).then(function () {
// handle errors in accept handlers and self accepted module load
if (error) {
return setStatus("fail").then(function () {
throw error;
});
}
if (queuedInvalidatedModules) {
return internalApply(options).then(function (list) {
outdatedModules.forEach(function (moduleId) {
if (list.indexOf(moduleId) < 0) list.push(moduleId);
});
return list;
});
}
return setStatus("idle").then(function () {
return outdatedModules;
});
});
}
复制代码
通过上述代码片段可知,在 hotApply
中,如果当前状态不为 ready
,抛出异常,否则调用 internalApply
函数,函数 internalApply
执行流程如下:
- 调用
applyInvalidatedModules
用于执行客户端在调用module.hot.invalidate
时指定的回调函数; - 然后通过
currentUpdateApplyHandlers
来收集applyHandle
的执行结果(即调用module.hot.apply
时指定的回调函数的处理结果); - 通过
setStatus
将当前状态设置为dispose
,并移除废弃的模块; - 通过
setStatus
将当前状态设置为apply
,并更新相应的模块。
小节
本小节我们对 HMR 运行时中的 module.hot.check
和 module.hot.apply
的实现进行了简要分析,除了这两个接口外,module.hot
也提供了其它的 API(webpack.docschina.org/api/hot-mod…),出于篇幅限制,此处不再一一分析。
总结
本文全面对 Webpack DevServer & HMR 进行了分析介绍:
- 首先我们介绍了 Webpack DevServer & HMR 的使用进行了介绍,我们即可以通过
webpack-cli
来快速启用该功能,也可以借用webpack api
、webpack-dev-middleware
、webpack-hot-middleware
及HotModuleReplacementPlugin
来自行启用; - 接着我们介绍了
webpack-dev-middleware
、webpack-hot-middleware
及HotModuleReplacementPlugin
的实现原理,其中webpack-hot-middleware
包含客户端及服务端两部分,而HotModuleReplacementPlugin
除了自身复杂的逻辑外,它还需要otModuleReplacement.runtime.js
及HarmonyImportDependencyParserPlugin
的辅助支撑。
Webpack 体系过于庞大,本文仅对核心流程、方法的实现进行了简要说明,对于遗漏的部分,衷心希望能够与大家一起研究。最后祝愿大家开心 code 每一天!^_^