手写一个Webpack HMR插件——彻底搞懂热更新原理

一、前言

Webpack热更新是我们开发环境中人人皆知的基本能力,同时也是面试中比较频繁会问到的话题,那里你有没有想过整个过程是怎么实现的?

本文将从概念 -> 手写实现彻底搞懂Webpack HMR原理。

二、什么是XMR?

当本地前端项目启动后,第一次访问页面,称为应用首次冷启。

之后再次修改了本地编辑器中的代码后,浏览器会自动重新渲染,出现修改后的内容(配置了XMR的情况下)

如果没有XMR,我们需要手动刷新浏览器,看到修改后的效果,这样做极大影响了开发效率。

XMR的核心作用就是这些。

Webpack是怎么做到的?

三、实现原理

当我们保存编辑器,触发一次Webpack热更新,项目中会出现两次请求:

这就是Webpack热更新的核心原理。

第一次json是代表变更文件的清单及hash。

第二次js是变更文件的实际执行JavaScript脚本,用于触发页面更新。

而这个交互过程,必然会出现两个除去项目本身以外的概念:

  • Webpack websocket服务,用于本地文件系统实时发送更新通知给页面;
  • Webpack client,运行时代码,用于接收通知,并触发热更新相关的核心代码;
  • Webpack http服务,用于执行一个本地项目服务(负责把项目跑起来);

简而言之,本地起一个前端应用,需要2个服务 + 1个client。

四、手写一个Webpack XMR

因此整个启动dev server -> 热更新一次的完整流程应该是这样的:

sequenceDiagram participant WDM as Webpack Dev Middleware
(构建&内存输出) participant WHM as Webpack Hot Middleware
(WebSocket或EventStream) participant Client as HMR Client participant Page as 浏览器页面 Note over Page: 开发者修改代码,保存文件 Page ->> WDM: (无请求) 文件变动触发构建 WDM ->> WDM: 重新编译代码,生成新的bundle/chunk WDM ->> WHM: 通知构建完成 & 生成新的 hash WHM -->> Client: 通过 WebSocket 发送 "hash"、"ok" 消息 Client ->> Client: 检测当前页面版本 vs 新版本 Client ->> WDM: 请求 hot-update.json / hot-update.js WDM -->> Client: 返回更新模块代码 Client ->> Page: 使用 module.hot 动态替换模块 Note over Page: 无刷新体验地更新页面内容

我们接下来手写一个热更新代码,来实现项目热更新。

4.1 创建一个应用

先创建一个应用,配备一个最基本的入口js文件,用于webpack打包。

bash 复制代码
mkdir webpack-dev-demo
cd webpack-dev-demo
npm init -y

然后新建/src/index.js

写上一段代码:

javascript 复制代码
// 用于启用开发环境热更新,Webpack会自动注入
if (module.hot) {
  module.hot.accept();
}
const dom = document.createElement("div");
dom.innerHTML = "123";
document.body.append(dom);

4.2 创建Webpack-dev-server插件

我们不使用官方的webpack-dev-server插件,因此我们需要实现这个插件,核心是通过Webpack配置生成一个html文件。

我们手搓一个插件:

javascript 复制代码
class HtmlGeneratePlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync(
      "HtmlGeneratePlugin",
      (compilation, callback) => {
        // 获取所有 chunk 的 js、css 文件
        const jsFiles = Object.keys(compilation.assets).filter((f) =>
          f.endsWith(".js")
        );
        const cssFiles = Object.keys(compilation.assets).filter((f) =>
          f.endsWith(".css")
        );

        // 拼接 HTML
        const htmlContent = `<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>My Dev Server</title>
    ${cssFiles
      .map((file) => `<link rel="stylesheet" href="${file}">`)
      .join("\n")}
  </head>
  <body>
    <div id="root"></div>
    ${jsFiles.map((file) => `<script src="${file}"></script>`).join("\n")}
  </body>
</html>`;
        // 注册到输出资源(内存里)
        compilation.assets["index.html"] = {
          source: () => htmlContent,
          size: () => htmlContent.length,
        };

        callback();
      }
    );
  }
}

module.exports = HtmlGeneratePlugin;

核心是在webpack编译完成后读取js、css文件,在html模板中引入,最后将html保存到内存中,供后续本地开发服务消费。

这里做的比较暴力,先考虑实现。

4.3 创建webpack http服务

该文件用于创建一个本地应用服务,并且将4.2中内存所创建好的html模板返回,提供最基础的页面访问能力。

javascript 复制代码
const path = require("path");
const express = require("express");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const WebSocket = require("ws"); // 手动创建 ws
const config = require("./webpack.config.js");

const app = express();

// 1. 创建 webpack 编译器
// 多配置写成数组单元素,可以有 compiler.compilers
const compiler = webpack([config]);

// 2. 声明根路由输出html资源
app.get("/", (req, res) => {
  // 从第一个子 compiler 拿 outputFileSystem
  const fs = compiler.compilers[0].outputFileSystem;
  const filePath = path.join(config.output.path, "index.html");
  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.status(404).send("index.html not found");
      return;
    }
    res.set("content-type", "text/html");
    res.send(data);
  });
});

// 3. 挂载内存输出中间件
app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath,
    stats: false,
  })
);

// 4. 自定义 WS 服务
const server = require("http").createServer(app);
const wss = new WebSocket.Server({ server });

// 给新连接的客户端发送当前状态
wss.on("connection", (ws) => {
  console.log("[WS] client connected");
});

// 5. 注册编译器 hooks,向所有 ws 客户端广播状态
function addHooks(singleCompiler) {
  singleCompiler.hooks.invalid.tap("server", (fileName) => {
    console.log("[webpack] Recompiling because:", fileName || "changes...");
    broadcast({ type: "invalid", file: fileName });
  });

  singleCompiler.hooks.done.tap("server", (stats) => {
    // 打印 build 完成的 hash
    console.log(`[webpack] Build done. Hash: ${stats.hash}`);

    // 获取构建产物信息
    const info = stats.toJson({
      all: false,
      assets: true,
      chunks: true,
      chunkModules: false,
      colors: true,
    });

    console.log("\nAssets:");
    info.assets.forEach((asset) => {
      console.log(`  ${asset.name}  ${formatSize(asset.size)}`);
    });

    console.log("\nChunks:");
    info.chunks.forEach((chunk) => {
      console.log(
        `  chunk ${chunk.id} (${chunk.names.join(", ")}) - ${formatSize(
          chunk.size
        )}`
      );
    });

    console.log("\n------------ 构建完成 ------------\n");

    // 推送消息给 HMR 客户端
    broadcast({ type: "hash", hash: stats.hash });
    broadcast({ type: "ok" });
  });
}

// 辅助函数:把字节数转成人类可读格式
function formatSize(bytes) {
  if (bytes < 1024) return bytes + " bytes";
  const kb = bytes / 1024;
  if (kb < 1024) return kb.toFixed(2) + " KB";
  return (kb / 1024).toFixed(2) + " MB";
}

// 多配置支持
if (compiler.compilers) {
  compiler.compilers.forEach(addHooks);
} else {
  addHooks(compiler);
}

// 向所有 ws 客户端发消息
function broadcast(msgObj) {
  const msg = JSON.stringify(msgObj);
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(msg);
    }
  });
}

// 6. 启动 server
const PORT = 3000;
server.listen(PORT, () => {
  console.log(`🚀 Dev Server running at http://localhost:${PORT}`);
});

一共做了这几件事情:

  1. 创建express 本地http服务;
  2. 创建webpack编译器,基于webpack配置将整个应用进行打包;
  3. 声明express根路由,用于提供访问时的页面请求;
  4. 挂载内存输出中间件(访问输出的html文件);
  5. 创建websocket服务,用于后续的热更新通知;
  6. 注册compiler hooks,实时监听webpack构建状态,打印服务日志、广播页面热更新;

4.4 创建webpack client运行时脚本

提供webpack所需要的运行时客户端能力,这里核心是接收websocket通知并调用webpack热更新插件实现页面增量构建后的更新。

javascript 复制代码
// src/client.js
console.log("[HMR Client] starting...");

function getWSUrl() {
  // 根据当前页面构建 ws 地址
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
  return `${protocol}//${location.host}`;
}

// 连接到你的 ws 服务
const socket = new WebSocket(getWSUrl());

socket.addEventListener("open", () => {
  console.log("[HMR Client] WS connected");
});

socket.addEventListener("message", async (event) => {
  const msg = JSON.parse(event.data);
  console.log("[HMR Client] received", msg);

  switch (msg.type) {
    case "invalid":
      console.log("[HMR Client] 增量更新完毕");
      break;
    case "hash":
      // 保存最新的编译 hash
      console.log("[HMR Client] 获取到热更新hash信息");
      window.__webpack_hot_hash__ = msg.hash;
      break;
    case "ok":
      console.log("[HMR Client] 热更新中");
      tryApplyUpdates();
      break;
    default:
      console.log("[HMR Client] ws未知消息", msg.type);
  }
});

socket.addEventListener("close", () => {
  console.warn("[HMR Client] WS disconnected, force reload...");
  //   location.reload();
});

function isUpdateAvailable() {
  // 这里 __webpack_hash__ 是由 webpack runtime 注入的当前构建 hash
  return (
    window.__webpack_hot_hash__ &&
    window.__webpack_hot_hash__ !== __webpack_hash__
  );
}

function canApplyUpdates() {
  return module.hot && module.hot.status() === "idle";
}

function tryApplyUpdates() {
  if (!module.hot) {
    console.warn("[HMR Client] module.hot is disabled, reloading...");
    // location.reload();
    return;
  }
  if (!isUpdateAvailable() || !canApplyUpdates()) return;

  module.hot
    .check(true)
    .then((updatedModules) => {
      console.log("[HMR Client] updated modules:", updatedModules);
    })
    .catch((err) => {
      console.error("[HMR Client] update failed", err);
      //   location.reload();
    });
}

这里主要做了这几件事情:

  1. 定义websocket实例,连接websocket服务;
  2. 接收websocket广播,创建自定义监听事件;
  3. 定义webpack热更新逻辑代码;
  4. 热更新失败的兜底页面刷新补偿;

4.5 创建webpack.config.js

将4.1~4.4所有的文件注册到webpack配置文件中,不依靠webpack-dev-server的本地服务+热更新就实现了。

js 复制代码
const path = require("path");
const webpack = require("webpack");
const HtmlGeneratePlugin = require("./htmlGeneratePlugin.js");

module.exports = {
  mode: "development",
  entry: [
    path.resolve(__dirname, "createClient.js"),
    path.resolve(__dirname, "src/index.js"),
  ],
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js",
    publicPath: "/",
  },
  plugins: [new webpack.HotModuleReplacementPlugin(), new HtmlGeneratePlugin()],
};

4.6 创建本地启动服务command

在package.json中增加一条dev命令(等价于npm run dev

json 复制代码
{
  "name": "test-dev",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "dev": "node createServer.js"
  },
  "dependencies": {
    "ws": "^8.18.3"
  },
  "devDependencies": {
    "express": "^5.1.0",
    "nodemon": "^3.0.2",
    "webpack": "^5.101.3",
    "webpack-dev-middleware": "^7.4.5"
  },
  "keywords": [
    "dev-server",
    "watcher"
  ],
  "author": "",
  "license": "MIT"
}

接下来在终端中执行npm run dev,把服务跑起来。

效果如下:

访问一次页面看到websocket已经建立连接。

服务端:

客户端:

再修改一下index.js试试热更新:

src/index.js 复制代码
console.log(5555, module);
if (module.hot) {
  module.hot.accept();
}
const dom = document.createElement("div");
dom.innerHTML = "123123123";
document.body.append(dom);

保存文件后,终端自动重新构建:

客户端识别并触发热更新:

实现了。简单么?把Webpack的clientserver拆解开来,是不是很工程化思想,通俗易懂?再对比build,就只是少了一步dev server + XMR,然后多了一些生产环境性能优化的配置即可,这些无非只是多了一些Webpack Plugin而已。

五、结尾

至此,你已经通过手写简单实现Webpack的本地服务 -> 热更新,应该彻底了解了整个本地开发过程。

如果文章对你有帮助,再好不过。

相关推荐
无双_Joney2 小时前
[更新迭代 - 1] Nestjs 在24年底更新了啥?(bug修复篇)
前端·后端·node.js
xiaoxiao无脸男2 小时前
three.js
开发语言·前端·javascript
木易 士心2 小时前
Vue 自定义指令详解
javascript·vue.js·ecmascript
90后的晨仔2 小时前
Vue 组件注册详解:全局注册 vs 局部注册
前端·vue.js
前端Hardy2 小时前
HTML&CSS:高颜值交互式日历,贴心记录每一天
前端·javascript·css
一只专注做软件的湖南人3 小时前
京东商品评论接口(jingdong.ware.comment.get)技术解析:数据拉取与情感分析优化
前端·后端·api
千码君20163 小时前
React Native:使用vite创建react项目并熟悉react语法
javascript·css·react native·react.js·html·vite·jsx
刺客_Andy3 小时前
React 第三十八节 Router 中useRoutes 的使用详解及注意事项
前端·react.js
刺客_Andy3 小时前
React 第三十六节 Router 中 useParams 的具体使用及详细介绍
前端·react.js