Webpack深度进阶:两张图彻底讲明白热更新原理!

10,131 阅读10分钟

一、前言

本文是 从零到亿系统性的建立前端构建知识体系✨ 中的第九篇。

在现如今,热更新早已成为前端基建中不可或缺的一环,它可以在不刷新整个页面的情况下更新页面中的部分内容,从而提高开发效率,优化开发体验。

然而,在实际面试的过程中,笔者发现 80% 的人并不清楚这其中的设计原理,只有很少一部分人能够表达清楚,原因我认为可能有以下几点:

  • 工作中不是必要:由于热更新通常是通过使用工具或框架来实现的,认为热更新的原理并不重要,只需要使用即可
  • 学习成本高:热更新的原理涉及到较高级的技术知识,原理过于复杂
  • ......

总之,热更新对我们来说,就像是一块难啃的骨头。但在前端基建岗位中,它又是必备的知识体系之一。

因此,本文将一改之前的文风,全文不会手写任何原理相关的代码,尽量通过图文的方式去讲解整个运作流程,旨在帮助大家理解其中的设计思想,以看懂为目的。

回到正文:

HMR 之前,应用的加载、更新都是一种页面级别的操作,即使只是单个代码文件发生变更,都需要刷新整个页面,才能将最新代码映射到浏览器上,这会丢失之前在页面执行过的所有交互与状态,例如:

  • 对于复杂表单场景,这意味着你可能需要重新填充非常多字段信息
  • 弹框消失,你必须重新执行交互动作才会重新弹出

再小的改动,例如更新字体大小,改变备注信息都会需要整个页面重新加载执行,整体开发效率偏低。

而引入 HMR 后,虽然无法覆盖所有场景,但大多数小改动都可以通过模块热替换方式更新到页面上,从而确保连续、顺畅的开发调试体验,极大提升开发效率。

文中所涉及到的代码均放到个人 github 仓库中。

二、基本使用

在正式讲原理之前,先简单过一下热更新的使用方式,照顾一下不太熟悉的同学。足够熟悉的同学可直接定位到第四节 —— 核心思想。

初始化项目:

npm init  //初始化一个项目
yarn add webpack webpack-cli webpack-dev-server html-webpack-plugin//安装项目依赖

简单说一下这几个依赖:

安装完依赖后,根据以下目录结构来添加对应的目录和文件:

├── node_modules
├── package.json
├── index.html # html模版代码
├── webpack.config.js #配置文件
└── src # 源码目录
     |── index.js
     └── name.js

在 Webpack 生态下,只需两步即可启动 HMR 功能:

  1. 设置 devServer.hot 属性为 true
  2. 在代码中调用 module.hot.accept 接口,声明模块变化时执行的回调函数

webpack.config.js

const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "development", //开发模式
  entry: "./src/index.js", //入口
  devServer: {
    hot: true, //开启热更新,这个是关键!!!
    port: 8000, //设置端口号
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./index.html", //将打包后的代码插入到html模版中
    }),
  ],
};

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>hmr</title>
  </head>
  <body>
    <div id="root"></div>
    <!-- 可以在里面输入一些东西,方便我们观察热更新的效果 -->
    <input />
  </body>
</html>

src/index.js

import name from "./name";

const render = () => {
  const rootDom = document.getElementById("root");
  rootDom.innerText = name;
};

render();

//要实现热更新,这段代码并不可少,描述当模块被更新后做什么 
if (module.hot) {
  module.hot.accept("./name", function () {
    console.log("name模块发生变化,处理热更新逻辑");
    render();
  });
}

src/name.js

const name = "不要秃头啊";
export default name;

package.json

  "scripts": {
    "start": "webpack serve"
  },

当我们执行 yarn start 命令时,webpack-cli 就会使用 webpack-dev-serverwatch 模式 来帮助我们启动编译。

效果:当文件保存后,并没有刷新浏览器就自动更新了。

peo9u-59dwp.gif

三、框架中使用

上面我们使用 HMR 有一个很大的痛点:在开发项目时,我们经常需要手动去修改 module.hot.accpet 相关的函数,这就比较反人类了。

不过幸好,在社区中已经针对这些有很成熟的解决方案了:

  • 比如 Vue 开发中,我们使用 vue-loader,此 loader 支持 Vue 组件的 HMR,提供开箱即用的体验
  • 比如 React 开发中,有 React Hot Loader,实时调整 React 组件(目前 React 官方已经弃用了,改成使用 react-refresh)

这里以 React 举例,先快速搭建 React 运行环境:

3.1、搭建React开发环境

安装 React 和相关依赖:

    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "@babel/core": "^7.20.5",
    "@babel/preset-react": "^7.18.6",
    "babel-loader": "^9.1.0",

简单介绍一下这几个库:

在根目录下新建 babel.config.js 文件,并配置:

module.exports = {
 presets: ["@babel/preset-react"],
};

修改 webpack.config.js 下配置:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");

module.exports = {
  //省略其他
  entry: "./src/index.jsx", //入口
+ module: {
+   rules: [
+     {
+       test: /\.jsx?$/i,
+       exclude: /node_modules/,
+       use: "babel-loader", 
+     },
+   ],
+ },
};

入口文件 src/index.jsx

import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import App from "./app.jsx";

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

src/app.jsx

import React from "react";

export default function App() {
  return (
    <div>
      作者:不要秃头啊
      <input />
    </div>
  );
}

3.2、React中启用HMR

安装热更新相关依赖:

npm install -D @pmmmwh/react-refresh-webpack-plugin react-refresh
or
yarn add -D @pmmmwh/react-refresh-webpack-plugin react-refresh

react-refresh 是专门用来做 React 热更新的,Redux 作者 Dan 还曾专门讲解过如何使用react-refresh 去替代之前的 React Hot Loader,有需求的自行查阅:github.com/facebook/re…

@pmmmwh/react-refresh-webpack-plugin 则是 react-refresh 在 Webpack 中的插件。

在React中启用HMR只需两步:

  1. babel.config.js 中配置相关插件:
module.exports = {
  presets: ["@babel/preset-react"],
+ plugins: ["react-refresh/babel"],
};
  1. webpack.config.js 中配置相关插件:
const HtmlWebpackPlugin = require("html-webpack-plugin");
+ const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");

module.exports = {
  //省略其他
  plugins: [
    //省略其他
  + new ReactRefreshWebpackPlugin(),
  ],
};

效果:可以看到,输入框中的值并没有被重置,说明热更新生效。

1t0zp-nwfiz.gif

四、核心思想

这里均以第二节中的案例来进行讲解。

我们先来看看热更新的核心包 webpack-dev-server 做了哪些事:

当我们运行 webpack serve 后,webpack-dev-server 会先往客户端代码中添加了两个文件,这两个文件的目的:

  1. websocket 相关的代码,用来跟服务端通信
  2. 客户端接收到最新代码后,更新代码

接着还会帮我们启动两个服务:

  1. 一个本地 HTTP 服务:这个本地服务会给我们提供编译之后的结果,之后浏览器通过端口请求时,就会请求本地服务中编译之后的内容,默认端口号 8080。
  2. 一个 websocket 双向通信服务器:如果有新的模块发生变化,编译成功会以消息的方式通知客户端,让客户端来请求最新代码,并进行客户端的热更新。

然后会以 watch 模式 开始编译,每次编译结束后会生成一个唯一的 hash 值。

watch 模式:使用监控模式开始启动 webpack 编译,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,每次编译都会产生一个唯一的 hash 值

以上就是 webpack-dev-server 的大致的思路,接下来我们对照真实案例来看看。


4.1、首次启动

先简单说一下代码块(chunk)模块(module)的概念:

chunk 就是若干 module 打成的包,一个 chunk 包括多个 module,一般来说最终会形成一个 file。而 module 就是一个个代码模块。

拿本项目举例子:src/index.jssrc/name.js 他们就组成了一个代码块(chunk),因为他们来自于同一个入口文件(entry: "src/index.js"),或者说他们被同一个入口文件所依赖,因此他们最后会被打包进一个代码块中。

而同样的, src/index.jssrc/name.js 他们自己也是单独的模块(module)。

image.png

在初次编译完成(启动项目)后,webpack 内部会生成一个 hash = h1,并将 hash = h1 通过 websocket 的方式通知给客户端,客户端上有两个变量:lastHashcurrentHash

  • lastHash:上一次接收到的 hash
  • currentHash:这一次接收到的 hash

在接收到服务端通知过来的 hash 时,客户端会进行保存:

lastHash= "之前的hash值"
currentHash = hash

如果是第一次接收到 hash 值,代表是第一次连接,则:

lastHash = currentHash = hash

4.2、二次编译

image.png

此时,如果源代码发生变化(name="不要秃头啊123"),webpack 对源文件重新进行编译,在编译完成后生成 hash = h2 ,并将 hash = h2 发送给客户端,客户端接收到消息后,修改自身的变量:

//客户端代码
lastHash = h1
currentHash = h2

接着客户端通过 lastHash = h1 向服务端请求 json 数据main.h1.json),目的是为了获得 变更的代码块

image.png

服务端接收到请求后,将传过来的 h1 和 自身最新的 hash = h2 进行对比,找出 变更的代码块(chunk:main) 后返回给客户端。

客户端在收到响应后,知道了哪些代码块(chunk:main)发生了变化,接着会继续通过 lastHash = h1main.h1.js)向服务端去请求 变更代码块(chunk:main)中的变动模块代码

image.png

服务端接收到 js 请求(main.h1.js)后,将传过来的 h1 和 自身最新的 hash = h2 再次进行对比,找出具体 变更的模块代码(src/name.js) 后返回给客户端。

最后,客户端拿到了变更模块的代码,重新去执行依赖该模块的模块(比如 src/name.js 被修改了,src/index.js 依赖 src/name.js,那就要重新执行 src/index.js 这个模块),达到更新的目的。

这里可能有同学要问了:为什么客户端会有两个 hash 值?

  • lastHash:上一次接收到的 hash
  • currentHash:这一次接收到的 hash

这么设计的用意:服务端不知道现在客户端的 hash 是多少,万一此时又连接一个客户端(多窗口的场景)怎么办?

所以这里需要客户端将上一次的 hash 返回给服务端,服务端通过比较后才返回变更的代码块。

如果每次文件改变都重新编译,那性能跟得上吗?

这里为了提升性能,webpack-dev-server 使用了一个库叫 memfs,它是 Webpack 官方自己写的。

这样每次打包之后的结果并不会进行输出(把文件写入到硬盘上会耗费很长的时间),而是将打包后的文件保留在内存中,以此来提升性能。

五、总结

读完本文,你会发现热更新的核心就是通过 websocket 服务 进行客户端和服务端的同步变更,并没有我们想象中那么复杂。

尽管这篇文章并没有对 Webpack HMR 进行源码级别的解析,很多细节方面也没过多探讨,但它真正起到的是一个抛砖引玉的作用,会大大减少你对 HMR 的陌生感。

如果对 webpack 感兴趣,想了解 Webpack HMR 更多的底层细节,相信阅读 webpack 源码将是一个不错的选择,也希望这篇文章能够对你阅读源码有所帮助,这才是我真正的写作目的。

推荐阅读

  1. 从零到亿系统性的建立前端构建知识体系✨
  2. 我是如何带领团队从零到一建立前端规范的?🎉🎉🎉
  3. 二十张图片彻底讲明白Webpack设计理念,以看懂为目的
  4. 【中级/高级前端】为什么我建议你一定要读一读 Tapable 源码?
  5. 前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用
  6. 线上崩了?一招教你快速定位问题!
  7. 【Webpack Plugin】写了个插件跟喜欢的女生表白,结果.....
  8. 从构建产物洞悉模块化原理
  9. 【万字长文|趣味图解】彻底弄懂Webpack中的Loader机制
  10. Esbuild深度调研:吹了三年,能上生产了吗?