本文是关于 Webpack 热模块替换的“深入探讨”系列的第二部分。
module.hot
API 处理热更新
在 HMR 系列的第一篇博文中,我们讨论了热模块替换过程的四个阶段。
今天,我们将重点关注最后一个阶段。我们将学习如何在应用中的模块收到热更新时,指示它们刷新自身。
热更新处理程序可以在构建期间由 webpack 加载器注入,也可以由您手动添加。我们将在本文中只讨论第二种方法。Webpack 从 module.hot
对象公开了一个接口。让我们探索一下它!
为了演示,我们将使用一个简单的网页。最好克隆项目并按照说明进行操作,但这并非强制性。您也可以只阅读博文,并相信我,一切都能正常工作。
从 https://github.com/sis0k0/christmas-tree 克隆仓库。如果您是命令行界面的粉丝,请执行
git clone https://github.com/sis0k0/christmas-tree.git
导航到克隆的文件夹并安装依赖项
cd christmas-tree
npm install
要运行开发服务器,请执行
npm run watch
构建完成后,浏览器中将打开一个新标签页。切换到开发者工具控制台。
圣诞快乐!是的,我知道现在是二月。但你还没有收起你的圣诞装饰品,对吧?
我想让你注意到这里有两件事
npm run watch
启动由 webpack-dev-server
包提供的 webpack 开发服务器。下图显示了项目结构(不包括 node_modules
)
source
目录是我们将进行更改的地方
index.js
导入所有源文件。这是 webpack 的入口模块;lights.js
为圣诞树创建闪烁效果;tree.js
绘制树本身。dist
目录承载着准备运行的应用。
main.js
是 webpack 生成的单个输出包;index.html
是加载 main.js
的网页。我们不会在这篇文章中讨论 package.json
和 package-lock.json
。如果您想了解更多信息,请查看 npm 文档。
最后,webpack.config.js
- 我们在这里指示 webpack 如何打包我们的应用。应用使用 HMR 运行,因为热模块替换插件是配置的一部分
const path = require('path');
const webpack = require('webpack');
module.exports = (env, argv) => {
const config = {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist')
},
devServer: {
contentBase: './dist',
hot: true,
},
plugins: [],
};
if (argv.mode === 'development') {
config.plugins.push(
new webpack.HotModuleReplacementPlugin()
);
}
return config;
};
理论够多了,让我们回到浏览器。开发者工具控制台显示热模块替换正在运行。但它真的有效吗?🤔
打开 src/tree.js
并增加 rowsCount
的值。
我们得到了更大的树,但也进行了完整的页面重新加载。可能很难注意到它。请查看控制台中的消息 - 它们在重新加载开始时会消失。当页面上的脚本重新执行时,消息会再次出现。HMR 的目标是避免完整的页面重新加载。
目前,应用不接受热更新,因为我们还没有指示它这样做。因此,webpack-dev-server
回退到完整的页面重新加载。
处理传入更新的最简单方法是通过自接受更改的模块来接受它。这将导致 webpack 执行模块的新版本。我们只需要添加以下内容
src/tree.js
module.hot.accept();
但是,module.hot
属性仅在启用 HMR 时定义。如果我们在没有 HMR 的情况下构建应用以用于生产,上面的代码将抛出错误。我们需要进行检查
src/tree.js
if (module.hot) {
module.hot.accept();
}
在尝试之前,最后需要注意的一点是 - 当我们构建用于生产时,webpack 知道 module.hot
是未定义的,并且受 if 语句保护的代码块将永远不会执行。webpack 的 UglifyJS/Terser 插件将从包中删除它。我们不必担心我们的开发设置会出现在生产环境中。
让我们再次更改 rowsCount
并查看发生了什么。页面没有完全重新加载,但树仍然更新,因为新的 tree.js
模块已执行。
我们简单的应用中还有一个模块 - src/lights.js
,它为树“照明”。
src/lights.js
import fir from './tree.js';
/**
* Changes the look of
* some 'needles' in the tree
* every 1000ms
*/
function turnOn() {
const blinkRate = 1000;
const rowsCount = fir.rowsCount;
const needles = fir.getNeedles();
setInterval(() =>
blink(rowsCount, needles),
blinkRate
);
}
turnOn();
// ...
要使 lights.js
成为一个自接受模块,我们需要用之前为 tree.js
使用的相同代码来扩展它
if (module.hot) {
module.hot.accept();
}
现在,让我们尝试减少 blinkRate
以使灯光变快,并增加它以达到相反的效果。
我们并没有完全达到我们想要的行为。每次更改时,灯泡都会越来越多。自接受导致 webpack 在需要热更新时执行模块。
src/lights.js
function turnOn() {
...
setInterval(() =>
blink(rowsCount, needles),
blinkRate
);
}
turnOn(); // <-- gets called every time the module is changed
执行上面的代码有一个副作用 - 它会使用 setInterval
调用触发重复操作。我们从未取消已启动的操作,而是不断触发新的操作。幸运的是,webpack 提供了一个机制来在替换旧模块之前处置它们。
首先,我们需要保存已启动操作的 ID
src/lights.js
let lightsInterval;
function turnOn() {
...
lightsInterval = setInterval(() =>
blink(rowsCount, needles),
blinkRate
);
}
然后,我们可以在新模块执行之前清除它
src/lights.js
if (module.hot) {
module.hot.accept();
module.hot.dispose(_data => {
clearInterval(lightsInterval);
});
}
最后,我们可以尝试再次更改 blinkRate
到目前为止,我们指示 webpack 在更改代码时执行 tree.js
和 lights.js
模块。当更改的数据是内部数据时,热模块替换似乎运行得非常好。但是,如果我们修改了模块的公共接口,会发生什么?依赖于该接口的其他模块会发生什么情况?😱
tree.js
模块导出一个单一对象 - fir
fir.draw()
函数在容器 DOM 元素内可视化树。它用 span 元素针来替换容器的内容。每个针的 className
等于 NEEDLE_CLASS
常量的值;fir.getNeedles()
函数返回所有具有上述 className
的 DOM 元素。lights.js
模块导入 tree.js
并使用 fir.getNeedles()
获取新绘制的 DOM 元素列表。该列表对于照亮圣诞树至关重要。这就是依赖关系图的样子
让我们通过修改 tree.js
中的 NEEDLE_CLASS
值来测试 HMR 过程。
我们的灯光熄灭了!HMR 过程惨败。
当我们更改 tree.js
时,Webpack 执行了它。fir.draw()
函数创建了具有与 NEEDLE_CLASS
新值匹配的 classNames
的全新针。它还删除了以前的针。
但是,lights.js
中没有发生任何事情。它的针列表仍然引用旧的、已删除的针。当 tree.js
更改时,我们应该刷新该列表。
父接受 API 允许我们从导入它的其他模块处理模块的热更新。我们可以扩展 lights.js
中的 HMR 逻辑,以便在 tree.js
更改时重新启动灯泡
src/lights.js
if (module.hot) {
module.hot.accept(['./tree.js'], function() {
clearInterval(lightsInterval);
turnOn();
});
...
}
现在,我们在两个地方为 tree.js
处理了更新逻辑
lights.js
中的父接受;tree.js
模块本身的自我接受。但是哪一个将被优先考虑?
让我们将 tree.js
的所有可能的更新处理场景可视化
如果 tree.js
中存在自我接受,webpack 将执行该模块。导入 tree.js
的模块(其“父模块”)不会收到更改的通知。
如果 tree.js
中不存在自我接受,webpack 将在导入它的模块中查找更新处理程序。
lights.js
模块导入 tree.js
。假设它有一个处理程序
module.hot.accept(['./tree.js'], function updateHandler() {
...
});
Webpack 将
tree.js
;lights.js
中的 tree.js
导入,以指向新模块;updateHandler()
。如果 lights.js
中没有 tree.js
的处理程序,webpack 将继续向上查找依赖关系图。
index.js
模块导入 lights.js
。Webpack 将检查它是否包含 lights.js
的处理程序。我想强调这一点 - 它不会检查 tree.js
(更改的模块)的处理程序,而是检查 lights.js
(它实际导入的模块)。让我们想象一下,index.js
确实有一个处理程序
src/index.js
module.hot.accept(['./lights.js'], function updateHandler() {
...
}
Webpack 将
tree.js
;lights.js
(导入的 tree.js
模块已更新)index.js
中对 lights.js
的引用;updateHandler()
。Webpack 将继续查找处理程序,直到找到一个“根模块” - 一个没有被其他任何模块导入的模块。在这种情况下,webpack-dev-server
将回退到完整的页面重新加载,而在 NativeScript 的情况下,将重新启动应用。
回到树 - 我们注意到 tree.js
有两个更新处理程序。我们希望 webpack 使用 lights.js
中的新逻辑。这就是为什么我们必须从 tree.js
中删除自我接受的原因。
src/tree.js
// comment or simply delete the code below
// if (module.hot) {
// module.hot.accept();
// }
让我们再次尝试更改 NEEDLE_CLASS
的值
然后...HMR 过程失败。我们得到的是完整的页面重新加载,而不是刷新。
我必须承认,我骗了你。tree.js
模块实际上不仅在 lights.js
中被导入,还在 index.js
中被导入。这是真实的依赖关系图
更改的模块应该在其依赖关系图的每个分支中都有一个更新处理程序。目前,我们没有在 index.js
中接受 tree.js
的更改,即将到来的热更新将被拒绝。
请注意,如果我们只有一个根模块,我们可以在其中添加一个应用范围的更新处理程序。目前,我们不会在我们的项目中这样做。例如,如果您使用 Angular,您的任务会简单一些,因为大多数 Angular 应用只有一个入口模块 -
main.ts
,它启动应用。如果没有懒加载的 NgModules,main.ts
将是唯一的根模块。在其中添加以下处理程序将捕获应用中的所有热更新
if (module.hot) {
module.hot.accept(["./app/app.module"], function() { ... });
}
import { AppModule } from "./app/app.module";
...
回到我们的项目 - 我们需要 index.js
中的 tree.js
的父接受。我们甚至不需要回调。
src/index.js
if (module.hot) {
module.hot.accept(['./tree.js']);
}
现在,处理程序查找过程将成功,因为 tree.js
的热更新将在其所有父模块中被接受。
让我们最后一次尝试更改NEEDLE_CLASS
的值,然后再放弃。
耶!它有效!但是…
我们写的代码对我来说感觉不太好。index.js
中的父级接受似乎有点人为——它只是因为我们必须接受即将到来的更新才存在。如果我们添加一个导入tree.js
的新模块怎么办?我们也需要在里面添加一个更新处理程序!是时候重构了。
目前,我们需要在index.js
和lights.js
中接受tree.js
的更改,因为这两个模块都导入它。
让我们看看lights.js
如何使用tree.js
。
src/lights.js
import fir from './tree.js';
function turnOn() {
const rowsCount = fir.rowsCount;
const needles = fir.getNeedles();
...
}
turnOn();
我们可以将fir
作为turnOn
函数的参数,而不是导入它。在这种情况下,该函数不应该被调用,而应该被导出。
src/lights.js
// import fir from './tree.js';
export function turnOn(fir) {
const rowsCount = fir.rowsCount;
const needles = fir.getNeedles();
...
}
// turnOn();
我们不再导入tree.js
,我们也可以删除它的处理程序。
src/lights.js
if (module.hot) {
// module.hot.accept(['./tree.js'], function() {
// clearInterval(lightsInterval);
// turnOn();
// });
module.hot.accept();
module.hot.dispose(_data => {
clearInterval(lightsInterval);
});
}
任何使用lights.js
的人都需要导入turnOn
函数,调用它,并提供fir
对象作为参数。在我们这里,导入者是index.js
。在完成必要的修改后,index.js
应该看起来像这样。
src/index.js
import fir from './tree.js';
import { turnOn } from './lights.js';
turnOn(fir);
if (module.hot) {
module.hot.accept(['./tree.js']);
}
现在,我们只在index.js
中有一个用于tree.js
的更新处理程序。我们可以将lights.js
的刷新逻辑移到里面。让我们导出一个“重启”灯光的函数。
src/lights.js
export function restart(fir) {
clearInterval(lightsInterval);
turnOn(fir);
}
并在index.js
中的更新处理程序中调用该函数。
src/index.js
import fir from './tree.js';
import { turnOn, restart } from './lights.js';
turnOn(fir);
if (module.hot) {
module.hot.accept(['./tree.js'], () => {
restart(fir);
};
}
让我们通过修改tree.js
文件来测试我们所做的更改。
编辑tree.js
仍然有效!
但是,现在lights.js
模块是自接受的,不再导入tree.js
。如果我们尝试修改它,树将不会被重新绘制。我们需要将lights.js
的更新处理程序也移到index.js
中。
src/index.js
if (module.hot) {
// add ./lights.js to the list of accepted dependencies
module.hot.accept(['./tree.js', './lights.js'], () => {
restart(fir);
});
}
不要忘记从lights.js
中删除自接受。
src/lights.js
if (module.hot) {
// Comment or remove the line below
// module.hot.accept();
// Keep the disposal logic!
module.hot.dispose(_data => {
clearInterval(lightsInterval);
});
}
就这样!重构完成!让我们停止进行更改,以防我们破坏了一些东西…
你可以在名为finished的分支中找到这个带有HMR功能的演示版本:https://github.com/sis0k0/christmas-tree/tree/finished
应用程序的处置逻辑中(至少)存在一个错误。尝试找到它!欢迎你在Github仓库中打开一个PR。第一个找到的人,可能会(也可能不会)赢得一些好东西🙂。
我们学习了如何使用module.hot
API来手动处理热更新。一些框架,如React、Vue、Angular和NativeScript,提供了内置的HMR支持。在专门的文章中,我们将探讨每个框架如何解决刷新应用程序并保持其状态完整的问题。