返回博客首页
← 所有帖子

深入探讨 Webpack 中的热模块替换 (第二部分 - 处理更新)

2019 年 2 月 12 日 — 作者:Stanimira Vlaeva

本文是关于 Webpack 热模块替换的“深入探讨”系列的第二部分。

  • 第一部分:基础知识
  • 第二部分:使用 module.hot API 处理热更新

第二部分:使用 module.hot API 处理热更新

在 HMR 系列的第一篇博文中,我们讨论了热模块替换过程的四个阶段。

the hmr process

今天,我们将重点关注最后一个阶段。我们将学习如何在应用中的模块收到热更新时,指示它们刷新自身。

热更新处理程序可以在构建期间由 webpack 加载器注入,也可以由您手动添加。我们将在本文中只讨论第二种方法。Webpack 从 module.hot 对象公开了一个接口。让我们探索一下它!

module.hot API

为了演示,我们将使用一个简单的网页。最好克隆项目并按照说明进行操作,但这并非强制性。您也可以只阅读博文,并相信我,一切都能正常工作。

https://github.com/sis0k0/christmas-tree 克隆仓库。如果您是命令行界面的粉丝,请执行

git clone https://github.com/sis0k0/christmas-tree.git

导航到克隆的文件夹并安装依赖项

cd christmas-tree
npm install

要运行开发服务器,请执行

npm run watch

构建完成后,浏览器中将打开一个新标签页。切换到开发者工具控制台。

devtools console

圣诞快乐!是的,我知道现在是二月。但你还没有收起你的圣诞装饰品,对吧?

我想让你注意到这里有两件事

  1. npm run watch 启动由 webpack-dev-server 包提供的 webpack 开发服务器。
  2. 控制台显示热模块替换已启用。默认情况下,它是禁用的。此项目配置为使用 HMR 运行。我们将在下一节中了解它是如何实现的。

项目中有什么?

下图显示了项目结构(不包括 node_modules

project structure

source 目录是我们将进行更改的地方

  • index.js 导入所有源文件。这是 webpack 的入口模块;
  • lights.js 为圣诞树创建闪烁效果;
  • tree.js 绘制树本身。

dist 目录承载着准备运行的应用。

  • main.js 是 webpack 生成的单个输出包;
  • index.html 是加载 main.js 的网页。

我们不会在这篇文章中讨论 package.jsonpackage-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 的目标是避免完整的页面重新加载。

tree doesn't work

目前,应用不接受热更新,因为我们还没有指示它这样做。因此,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 模块已执行。

tree works

旧模块的处置

我们简单的应用中还有一个模块 - 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 以使灯光变快,并增加它以达到相反的效果。

decrease 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

lights dispose

父接受模块

到目前为止,我们指示 webpack 在更改代码时执行 tree.jslights.js 模块。当更改的数据是内部数据时,热模块替换似乎运行得非常好。但是,如果我们修改了模块的公共接口,会发生什么?依赖于该接口的其他模块会发生什么情况?😱

tree.js 模块导出一个单一对象 - fir

  • fir.draw() 函数在容器 DOM 元素内可视化树。它用 span 元素针来替换容器的内容。每个针的 className 等于 NEEDLE_CLASS 常量的值;
  • fir.getNeedles() 函数返回所有具有上述 className 的 DOM 元素。

lights.js 模块导入 tree.js 并使用 fir.getNeedles() 获取新绘制的 DOM 元素列表。该列表对于照亮圣诞树至关重要。这就是依赖关系图的样子

dependency graph

让我们通过修改 tree.js 中的 NEEDLE_CLASS 值来测试 HMR 过程。

parent tree does not work

我们的灯光熄灭了!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 中的自我接受

如果 tree.js 中存在自我接受,webpack 将执行该模块。导入 tree.js 的模块(其“父模块”)不会收到更改的通知。

self accept

lights.js 中的父接受

如果 tree.js 中不存在自我接受,webpack 将在导入它的模块中查找更新处理程序。

lights.js 模块导入 tree.js。假设它有一个处理程序

module.hot.accept(['./tree.js'], function updateHandler() {
    ...
});

Webpack 将

  • 执行 tree.js
  • 更新 lights.js 中的 tree.js 导入,以指向新模块;
  • 执行 updateHandler()

parent accept

index.js 中的父接受

如果 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()

parent accept index

没有 tree.js 的更新处理程序

Webpack 将继续查找处理程序,直到找到一个“根模块” - 一个没有被其他任何模块导入的模块。在这种情况下,webpack-dev-server 将回退到完整的页面重新加载,而在 NativeScript 的情况下,将重新启动应用。

no handler

选择更新处理程序

回到树 - 我们注意到 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 的值

parent tree full reload

然后...HMR 过程失败。我们得到的是完整的页面重新加载,而不是刷新。

我必须承认,我骗了你。tree.js 模块实际上不仅在 lights.js 中被导入,还在 index.js 中被导入。这是真实的依赖关系图

real dependency graph

更改的模块应该在其依赖关系图的每个分支中都有一个更新处理程序。目前,我们没有在 index.js 中接受 tree.js 的更改,即将到来的热更新将被拒绝。

hot update rejected

请注意,如果我们只有一个根模块,我们可以在其中添加一个应用范围的更新处理程序。目前,我们不会在我们的项目中这样做。例如,如果您使用 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 的热更新将在其所有父模块中被接受。

hot update accepted

让我们最后一次尝试更改NEEDLE_CLASS的值,然后再放弃。

parent tree works

重构

耶!它有效!但是…

我们写的代码对我来说感觉不太好。index.js中的父级接受似乎有点人为——它只是因为我们必须接受即将到来的更新才存在。如果我们添加一个导入tree.js的新模块怎么办?我们也需要在里面添加一个更新处理程序!是时候重构了。

目前,我们需要在index.jslights.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文件来测试我们所做的更改。

final works

编辑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支持。在专门的文章中,我们将探讨每个框架如何解决刷新应用程序并保持其状态完整的问题。