一、前言
本文是 从零到亿系统性的建立前端构建知识体系✨ 中的第九篇。
在现如今,热更新早已成为前端基建中不可或缺的一环,它可以在不刷新整个页面的情况下更新页面中的部分内容,从而提高开发效率,优化开发体验。
然而,在实际面试的过程中,笔者发现 80% 的人并不清楚这其中的设计原理,只有很少一部分人能够表达清楚,原因我认为可能有以下几点:
- 工作中不是必要:由于热更新通常是通过使用工具或框架来实现的,认为热更新的原理并不重要,只需要使用即可
- 学习成本高:热更新的原理涉及到较高级的技术知识,原理过于复杂
- ......
总之,热更新对我们来说,就像是一块难啃的骨头。但在前端基建岗位中,它又是必备的知识体系之一。
因此,本文将一改之前的文风,全文不会手写任何原理相关的代码,尽量通过图文的方式去讲解整个运作流程,旨在帮助大家理解其中的设计思想,以看懂为目的。
回到正文:
在 HMR 之前,应用的加载、更新都是一种页面级别的操作,即使只是单个代码文件发生变更,都需要刷新整个页面,才能将最新代码映射到浏览器上,这会丢失之前在页面执行过的所有交互与状态,例如:
- 对于复杂表单场景,这意味着你可能需要重新填充非常多字段信息
- 弹框消失,你必须重新执行交互动作才会重新弹出
再小的改动,例如更新字体大小,改变备注信息都会需要整个页面重新加载执行,整体开发效率偏低。
而引入 HMR 后,虽然无法覆盖所有场景,但大多数小改动都可以通过模块热替换方式更新到页面上,从而确保连续、顺畅的开发调试体验,极大提升开发效率。
文中所涉及到的代码均放到个人 github 仓库中。
二、基本使用
在正式讲原理之前,先简单过一下热更新的使用方式,照顾一下不太熟悉的同学。足够熟悉的同学可直接定位到第四节 —— 核心思想。
初始化项目:
npm init //初始化一个项目
yarn add webpack webpack-cli webpack-dev-server html-webpack-plugin//安装项目依赖
简单说一下这几个依赖:
- webpack:这个不必说,核心库
- webpack-cli:主要用来处理命令行中的参数,并启动
webpack
编译 - webpack-dev-server:提供开发服务器,用来支持热更新
- html-webpack-plugin:用于将打包后的
css
、js
等代码插入到html
模版中
安装完依赖后,根据以下目录结构来添加对应的目录和文件:
├── node_modules
├── package.json
├── index.html # html模版代码
├── webpack.config.js #配置文件
└── src # 源码目录
|── index.js
└── name.js
在 Webpack 生态下,只需两步即可启动 HMR
功能:
- 设置
devServer.hot
属性为 true - 在代码中调用
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-server 以 watch 模式 来帮助我们启动编译。
效果:当文件保存后,并没有刷新浏览器就自动更新了。
三、框架中使用
上面我们使用 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",
简单介绍一下这几个库:
- react:核心库,不必多说
- react-dom:核心库,不必多说
- @babel/core:Babel 编译器的核心
- @babel/preset-react:所有 React 插件的 Babel 预设
- babel-loader:在 Webpack 中转译 JavaScript 文件
在根目录下新建 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只需两步:
- babel.config.js 中配置相关插件:
module.exports = {
presets: ["@babel/preset-react"],
+ plugins: ["react-refresh/babel"],
};
- 在 webpack.config.js 中配置相关插件:
const HtmlWebpackPlugin = require("html-webpack-plugin");
+ const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
module.exports = {
//省略其他
plugins: [
//省略其他
+ new ReactRefreshWebpackPlugin(),
],
};
效果:可以看到,输入框中的值并没有被重置,说明热更新生效。
四、核心思想
这里均以第二节中的案例来进行讲解。
我们先来看看热更新的核心包 webpack-dev-server 做了哪些事:
当我们运行 webpack serve 后,webpack-dev-server 会先往客户端代码
中添加了两个文件,这两个文件的目的:
- websocket 相关的代码,用来跟服务端通信
- 客户端接收到最新代码后,更新代码
接着还会帮我们启动两个服务:
- 一个本地
HTTP 服务
:这个本地服务会给我们提供编译之后的结果,之后浏览器通过端口请求时,就会请求本地服务中编译之后的内容,默认端口号 8080。 - 一个
websocket 双向通信服务器
:如果有新的模块发生变化,编译成功会以消息的方式通知客户端,让客户端来请求最新代码,并进行客户端的热更新。
然后会以 watch 模式 开始编译,每次编译结束后会生成一个唯一的 hash 值。
watch 模式:使用监控模式开始启动 webpack 编译,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,每次编译都会产生一个唯一的 hash 值
以上就是 webpack-dev-server 的大致的思路,接下来我们对照真实案例来看看。
4.1、首次启动
先简单说一下
代码块(chunk)
和模块(module)
的概念:
chunk
就是若干module
打成的包,一个chunk
包括多个module
,一般来说最终会形成一个 file。而module
就是一个个代码模块。拿本项目举例子:src/index.js 和 src/name.js 他们就组成了一个代码块(
chunk
),因为他们来自于同一个入口文件(entry: "src/index.js"
),或者说他们被同一个入口文件所依赖,因此他们最后会被打包进一个代码块中。而同样的, src/index.js、src/name.js 他们自己也是单独的模块(
module
)。
在初次编译完成(启动项目)后,webpack 内部会生成一个 hash = h1
,并将 hash = h1
通过 websocket 的方式通知给客户端,客户端上有两个变量:lastHash
、currentHash
。
- lastHash:上一次接收到的 hash
- currentHash:这一次接收到的 hash
在接收到服务端通知过来的 hash 时,客户端会进行保存:
lastHash= "之前的hash值"
currentHash = hash
如果是第一次接收到 hash 值,代表是第一次连接,则:
lastHash = currentHash = hash
4.2、二次编译
此时,如果源代码发生变化(name="不要秃头啊123"
),webpack
对源文件重新进行编译,在编译完成后生成 hash = h2
,并将 hash = h2
发送给客户端,客户端接收到消息后,修改自身的变量:
//客户端代码
lastHash = h1
currentHash = h2
接着客户端通过 lastHash = h1
向服务端请求 json 数据
(main.h1.json),目的是为了获得 变更的代码块:
服务端接收到请求后,将传过来的 h1
和 自身最新的 hash = h2
进行对比,找出 变更的代码块(chunk:main) 后返回给客户端。
客户端在收到响应后,知道了哪些代码块(chunk:main)发生了变化,接着会继续通过 lastHash = h1
(main.h1.js
)向服务端去请求 变更代码块(chunk:main)中的变动模块代码:
服务端接收到 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 源码将是一个不错的选择,也希望这篇文章能够对你阅读源码有所帮助,这才是我真正的写作目的。