一文彻底搞懂webpack热更新原理(概念+实战+源码解读)

666 阅读14分钟

第一部分:基础概念

一、效率瓶颈

我们首先得知道为什么出现了热更新这么一个东西,知其然也要知其所以然,简单来说就是效率的瓶颈。

前端开发如何提升效率?也就是说单位时间内产出的产品更多,质量更好!

当然可以通过学习和培训提高开发人员的编程能力,一个优秀的编程人员效率会比一个普通的编程人员产出更高,质量更好。但是我们都知道成年人只适合淘汰不适合教育,所以这种方式成本过高。

那么其他方式呢!

前端开发的产品是直接面对用户的,基于这个特性,当我们在开发产品的时候,如果对源代码进行了修改,需要立马能够看到这部分的修改对界面造成的影响,从而得到界面上的反馈来继续优化我们的代码,不断重复这个过程,以便调试和优化代码,直至界面达到我们的预期。

所以前端开发大量的时间都是在得到反馈的这个时间中浪费掉的,如果能够尽可能减少这部分的时间的浪费,开发体验和开发效率都会高不少!

我们都知道为了更好的模拟生产环境,开发时我们都会启动一个本地服务来构建一个简单的BS(browser/client)架构。他们的工作流就像下面这个样子。

a.png

当我们修改了源代码之后,服务器是有资源的变化,也能感知到资源的变化,但是客户端并不知道资源变化了,所以还是原来未改变的代码的样子。因此在常规情况下,需要手动的刷新一下页面去请求最新的资源。这无疑降低了开发的效率。

二、如何解决

其实主要就是在源代码发生改变的时候,能够通知客户端,我发生改变了,你也需要重新请求一下资源。

这个过程涉及到服务端主动向客户端发送消息,所以我们需要利用WebSocket来进行全双工的通信。

理想的效果就是当我们的源代码发生更新了,使用socket通知客户端刷新页面,不用程序员手动自己刷新,这就节省了很多时间,增加了开发效率。流程图如下图所示:

截屏2023-04-25 下午5.54.35.png

整个过程如果用文字描述就是这样的:

第一步:服务端提供一个静态服务,源代码放在静态服务里
第二步:服务端提供一个socket服务,用于主动给客户端发送消息
第三步:客户端请求静态资源,执行资源代码的过程中建立了socket的长连接,页面呈现
第四步:服务端源代码变化,向客户端发送一个消息
第五步:客户端收到消息,并重新刷新页面,执行第三步
第六步:重复第四步和第五步

我们可以先在脑海中构建上面这样一个思路,看看有没有什么问题,后续会有代码的实战。

我们很容易想到,在客户端收到消息后直接刷新页面是不是有点过于草率了?

因为刷新页面意味着所有代码重新执行,那么之填好的表单信息就都丢掉了,需要重新填写。

如果更新的粒度还可以再细一些,就会更好。例如一些css的改变,我们就不必刷新整个页面,而是直接覆盖原来的css,这样原来填好的表单信息(也就是状态)就不会丢失。这个过程完全是“趁热”的,也可以叫做“热替换”

所以流程图需要变化一下:

截屏2023-04-25 下午6.12.49.png

可以看到其实就是多了一个判断:

当接收到来自服务端的消息之后,进行一个判断,如果源代码改变的波动比较大,那么依然进行全局的刷新,如果波动范围比较小,那么就进行局部的替换。

额外的话
其实随着前端慢工程化的到来,我们几乎不太可能直接在宿主环境运行的代码上更改了,一般来说我们写的源代码,例如react代码,都是在修改时候,有一个编译的过程,然后再变成浏览器可以执行的代码。所以会多一个编译的过程,如下图所示: 截屏2023-04-25 下午8.11.41.png

以上就是我们通过自己的思考可以想到的一个方案,我们接下来就根据这个方案来手撕一个极其简单的热更新,不进行吹毛求疵,而是主要领会起核心的逻辑。

那么开始吧!

第二部分:代码实战

一、基础准备

首先我们肯定是需要一个npm包来模拟工程化的场景,我们会模拟一个如下的过程:

源代码修改 -> 编译 -> 静态资源 -> socket通知 -> 全局刷新

所以我要介绍以下几个比较实用的工具:

express:一个可以快速搭建静态服务器的库 -- 用于托管静态资源,客户端好访问
rollup:一个资源打包工具 -- 用于模拟编译过程
chokidar:一个监听本地文件变化的库 -- 用于感知源代码的变化
fs-extra:一个方便操作文件的库 -- 用于读写文件
socket.io:一个提供socket服务的库 -- 用于和客户端通信

温馨提示
文章结尾部分有完整代码资源,阅读过程中不用一定一步一步跟着操作。

第一步:我们先搭建环境

// 随便创建一个文件夹:hot-demo
mkdir hot-demo
npm init -y
cd hot-demo
npm i express rollup chokidar fs-extra -S

第二步:开始写源代码

mkdir src
cd src
touch index.js
touch util.js

js文件写入以下内容

// util.js

export const add = (a, b) => {
  return a + "--" + b;
};

//index.js
import { add } from "./util";

function init() {
  const element = document.createElement("div");

  element.innerHTML = add("hello", "story");

  document.body.appendChild(element);
}

init();

以上的代码我们就可以当作程序员直接写的代码,来模拟我们写的vue或者react源代码,但是实际上它是以ESModule模块化的方式去写的,因此我们将package.json中的type字段变为改为“module”。

{
  ...
  "type": "module",
  "scripts": {
    "serve": "node serve.cjs"
  },
  ...
}

并且我们需要将其打包,目的是来模拟我们的编译过程。所以这个时候我们需要用到rollup。

根目录下创建serve.cjs


const { rollup } = require("rollup");

async function build() {
  const bundle = await rollup({
    input: "./src/index.js",
  });

  const outputOptions = {
    format: "umd",
    file: "./dist/bundle.js",
  };

  await bundle.generate(outputOptions);

  await bundle.write(outputOptions);
}

async function start() {
  await build();
}

start();

当我们运行npm run serve的时候,就可以将两个文件打包成为一个文件了,并且生成了一个dist目录。

为了使得打包后的文件生效,我们直接在dist文件夹下新建一个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>Document</title>
  </head>
  <body>
    <script src="./bundle.js"></script>
  </body>
</html>

但这样做也太弱智了,我们需要将这个dist目录托管到一个服务器中,供外部访问。因此express就派上用场了。

// serve.js 
const { rollup } = require("rollup");
+ const express = require("express"); 

+ const app = express(); 

+function listen() { 
+  app.use(express.static("dist"));
+  app.listen(3000, () => console.log("3000 is listen ..."));
+}

async function build() {
  const bundle = await rollup({
    input: "./src/index.js",
  });

  const outputOptions = {
    format: "umd",
    file: "./dist/bundle.js",
  };

  await bundle.generate(outputOptions);

  await bundle.write(outputOptions);
}

async function start() {
  await build();
+  listen()
}

start();

接下来npm run serve的结果就是既能够编译,也提供了一个静态服务,我们只需要访问,localhost:3000/index.html,就可以访问到该页面。

接下来需要做的就是监听一个文件的变化,然后来自动编译而不是手动的npm run serve的编译。

我们需要用到chokidar这个库,在serve.cjs中进行以下新增以下内容:

// serve.js 
const { rollup } = require("rollup");
const express = require("express"); 
+ const chokidar = require("chokidar");

const app = express(); 

function listen() { 
  app.use(express.static("dist"));
  app.listen(3000, () => console.log("3000 is listen ..."));
}

+async function onFileChange(callback) {
+  chokidar.watch(".").on("change", (path) => {
+    console.log(path, "发生了变化");
+    if (path.includes("src")) { // 只监听src下的文件变化
+      build().then(() => { // 发生变化自动编译,则dist下就是最新的代码
+        callback(path);
+      });
+    }
+  });
+}

async function build() {
  const bundle = await rollup({
    input: "./src/index.js",
  });

  const outputOptions = {
    format: "umd",
    file: "./dist/bundle.js",
  };

  await bundle.generate(outputOptions);

  await bundle.write(outputOptions);
}

async function start() {
  await build();
  
+  onFileChange((path) => {
+    console.log(path , 'changed')
+  });
  
  listen()
}

start();

当文件发生变化的时候,dist就会重新编译为最新的代码。这个时候,就要通知客户端了,我这边变了,你这边也得赶紧刷新了(先做一个无脑刷新)。

根据我们之前的设计,需要提供一个socket服务,我们使用sockte.io这个库。

// serve.js 
const { rollup } = require("rollup");
const express = require("express"); 
const chokidar = require("chokidar");
+ const io = require("socket.io");

const app = express(); 
+ const server = http.createServer();

function listen() { 
  app.use(express.static("dist"));
  app.listen(3000, () => console.log("3000 is listen ..."));
+  server.listen(3001, () => console.log("3001 is listen ..."));
}

async function onFileChange(callback) {
  chokidar.watch(".").on("change", (path) => {
    console.log(path, "发生了变化");
    if (path.includes("src")) { // 只监听src下的文件变化
      build().then(() => { // 发生变化自动编译,则dist下就是最新的代码
        callback(path);
      });
    }
  });
}

+function createSocket() {
+  const socketInstance = io(server, { cors: true });
+  socketInstance.on("connection", function (socket) {
+    console.log("连接成功");
+    socket.emit("msg", "你好");
+    socket.on("msg", function (data) {
+      console.log("服务器端接受到数据:%j", data);
+    });
+  });
+  return socketInstance;
+}

async function build() {
  const bundle = await rollup({
    input: "./src/index.js",
  });

  const outputOptions = {
    format: "umd",
    file: "./dist/bundle.js",
  };

  await bundle.generate(outputOptions);

  await bundle.write(outputOptions);
}

async function start() {
  await build();
  
  const socket = createSocket();
  
  onFileChange((path) => {
    console.log(path , 'changed')
  });
  
  listen()
}

start();

上面只是提供了服务端的服务,还需要客户端来连接才可以,所以在src下新建socket.js,并且html中引入socket.min.js(来自socket.io/client-dist/socket.io.min.js)这是它的用法。

<html lang="en">
  ...
  <head>
+    <script src="./socket.js"></script>
  </head>
  ...
</html>

//socket.js
export function createSocket() {
  const socket = io.connect("ws://127.0.0.1:3001/");

  socket.on("msg", function (data) {
    console.log(data);
  });

  socket.on("hotUpdate", function (data) {
    const { type, hash } = data;
    if (type === "reload") {
      self.location.reload();
    } else {
      ...
    }
  });

  socket.emit("msg", "你好");
}


//index.js

import { add } from "./util";
+ import { createSocket } from "./socket";

function init() {
  const element = document.createElement("div");

  element.innerHTML = add("hello", "hot-demo");

  document.body.appendChild(element);

+  createSocket();
}

init();

现在当我们的服务开启后,就会有热更新的效果了。

屏幕录制2023-04-25 下午11.13.37.gif

但是这其实是一个非常粗暴的热更新,因为每次都是强制刷新。我们能不能做到当改变某些代码的时候,部分刷新呢?

其实可以,我们可以写一段逻辑,当监听到css文件的改变时,我们给客户端发一个消息,这个消息是一个hash值,让客户端重新请求一段逻辑,这端逻辑可以覆盖原来的css样式,这样的话,就做到不刷新页面而通过覆盖的方式了。

所以我们增加一个css.js文件

//css.js
export default function addStyle(element) {
  element.style.background = "blue";
}

//index.js
import { add } from "./util";
import { createSocket } from "./socket";
+ import style from "./css";

function init() {
  const element = document.createElement("div");

  element.innerHTML = add("hello", "hot-demo");

+  style(document.body);

  document.body.appendChild(element);

  createSocket();
}

init();

当css.js的内容,发生改变时,我们试图让它发给客户端一个局部更新的消息,并且需要生成一个脚本,这个脚本是用来覆盖css样式的,用该修改样式,而不影响状态。

// serve.cjs
const { rollup } = require("rollup");
const chokidar = require("chokidar");
const express = require("express");
const http = require("http");
+ const fs = require("fs-extra");
const io = require("socket.io");
+ const path = require("path");

var server = http.createServer();

const app = express();

async function build() {
  const bundle = await rollup({
    input: "./src/index.js",
  });

  const outputOptions = {
    format: "umd",
    file: "./dist/bundle.js",
  };

  await bundle.generate(outputOptions);

  await bundle.write(outputOptions);
}

function listen() {
  app.use(express.static("dist"));
  app.listen(3000, () => console.log("3000 is listen ..."));
  server.listen(3001, () => console.log("3001 is listen ..."));
}

async function onFileChange(callback) {
  chokidar.watch(".").on("change", (path) => {
    console.log(path, "发生了变化");
    if (path.includes("src")) {
      build().then(() => {
        callback(path);
      });
    }
  });
}

function createSocket() {
  const socketInstance = io(server, { cors: true });
  socketInstance.on("connection", function (socket) {
    console.log("连接成功");
    socket.emit("msg", "你好");
    socket.on("msg", function (data) {
      console.log("服务器端接受到数据:%j", data);
    });
  });
  return socketInstance;
}

+function genCssFile(hash, filePath) {
+  filePath = path.resolve(__dirname, filePath);
+  const string = fs.readFileSync(filePath);
+  const content =
+    string.toString().replace("export default", "") +
+    "\r\naddStyle(document.body)";
+  fs.outputFileSync(path.resolve(__dirname, `dist/${hash}.js`), content);
}

async function start() {
  console.log("----------------第一次进行构建---------------");

  await build();

  console.log("----------------开始监听文件变化---------------");

  const socket = createSocket();

  onFileChange((path) => {
+    if (path === "src/css.js") {
      // 目前只支持css.js的更新
+      const hash = (Math.random() * 1000000).toString().slice(0, 5);

+      genCssFile(hash, path);

+      socket.emit("hotUpdate", { type: "hot", hash });
+    } else {
+      socket.emit("hotUpdate", { type: "reload" });
+    }
  });

  console.log("----------------开启本地服务---------------");

  listen();
}

start();

那么在客户端我们也不能忘了要接受消息,并做不同的行为。

+ let lasthash = "";
+ let currenthash = "";

+ const loadScript = (src) => {
+   const script = document.createElement("script");
+   script.src = src;
+   document.head.appendChild(script);
+};

export function createSocket() {
  const socket = io.connect("ws://127.0.0.1:3001/");

  socket.on("msg", function (data) {
    console.log(data);
  });

  socket.on("hotUpdate", function (data) {
    const { type, hash } = data;
    if (type === "reload") {
      self.location.reload();
    } else {
  +    lasthash = currenthash;
  +    currenthash = hash;
  +    loadScript(`http://127.0.0.1:3000/${hash}.js`);
    }
  });

  socket.emit("msg", "你好");
}

使用script脚本请求到的js脚本就是专门用来覆盖样式的,我们在主页再加一个input标签用来做状态的存储。

import { add } from "./util";
import { createSocket } from "./socket";
import style from "./css";

function init() {
  const element = document.createElement("div");

  element.innerHTML = add("hello", "hot-demo");

+  const input = document.createElement("input");

  style(document.body);

  document.body.appendChild(element);

+  document.body.appendChild(input);

  createSocket();
}

init();

效果如下:

屏幕录制2023-04-25 下午11.38.45.gif

实际上这个样式的改变是由新请求的js代码导致的。

截屏2023-04-25 下午11.41.08.png

所以并不会对原有的状态造成影响和破坏,因此input里的hello依然不会变。

好了,那么以上就是我们使用自己的思路基本实现了热更新的基本功能,并认识了热更新的基本原理。那么webpack的热更新原理真的是这个样子的么,我们还是需要从源码的角度去看!接下来就进入源码的环节!

第三部分:源码解读

我们主要看webpack-dev-server这个包就好了,因为它是主要实现热更新的包。

截屏2023-04-25 下午11.52.49.png

其中主要的实现就是分为客户端和服务端,我们先来看一下服务端的实现。

一、服务端

位置:lib/Server.js

...
class Server {
   ...
}

module.exports = Server

可以看到整个文件暴露了一个Server类,可以猜测是供外部调用的。他的核心方法有这么几个:

...
class Server {
   ...
   getServerTransport(){
     // 提供socket服务
     return this.options.type === 'sockjs' 
     ? require("./servers/SockJSServer")
     : require("./servers/WebsocketServer");
     // 根据配置项选择使用哪一种Socket服务
   },
   
   setupApp(){
     //创建app用于托管静态资源
     this.app = new express();
   },
   
   setupMiddlewares(){
     // 根据配置情况注册和调用中间件
     let middlewares = [];
     middlewares.push(
       // 中间件1 webpack-dev-middleware
       // 中间件2 http-proxy-middleware
       // 中间件3 compression
       // 中间件4 getExpress().static
     )
     // 其中就包括处理跨域的,托管静态服务等等
     
     middlewares.forEach((middleware) => {
      if (typeof middleware === "function") {
        (this.app).use(middleware);
      } else if (typeof middleware.path !== "undefined") {
        (this.app).use(middleware.path, middleware.middleware);
      } else {
        (this.app).use(middleware.middleware);
      }
    });
   },
   
   watchFiles(){
     const chokidar = require("chokidar");
     const watcher = chokidar.watch(watchPath, watchOptions);

     // disabling refreshing on changing the content
     if (this.options.liveReload) {
       watcher.on("change", (item) => {
         if (this.webSocketServer) { // 文件发生改变,给客户端发送消息
           this.sendMessage(
             this.webSocketServer.clients,
             "static-changed",
             item
           );
         }
       });
     }

     this.staticWatchers.push(watcher);
   },
   
   start(){
     // 统一的开启和执行
   }
}

module.exports = Server

位置:lib/servers/**

在servers下有三个文件:BaseServer.js SockJSServer.js WebsocketServer.js

他们都是对外提供socket服务的,就像我们在实战当中使用socket.io对外提供服务一样只是方式不同而已。

二、客户端

位置:client-src/clients/**
这个位置有两个文件 SockJSClient.js WebSocketClient.js,他们分别对应服务端提供的两种不同的服务的连接方式。

export default class SockJSClient {
  
  constructor(url) {
    // SockJS requires `http` and `https` protocols
    this.sock = new SockJS(
      url.replace(/^ws:/i, "http:").replace(/^wss:/i, "https:")
    );
    this.sock.onerror =
      (error) => {
        log.error(error);
      };
  }

  onOpen(f) {
    this.sock.onopen = f;
  }

  onClose(f) {
    this.sock.onclose = f;
  }

  onMessage(f) {
    this.sock.onmessage =
      (e) => {
        f(e.data);
      };
  }
}


他们都对如何连接socket进行了封装,目的是用于socket通信。重点是下面这个socket.js,包含如何对消息进行处理。

位置:client-src/socket.js

const socket = function initSocket(url, handlers, reconnect){

 client.onClose(() => { // 不断和服务端进行连接直至链接成功
    if (retries === 0) {
      handlers.close();
    }

    client = null;

    // After 10 retries stop trying, to prevent logspam.
    if (retries < maxRetries) {
      const retryInMs = 1000 * Math.pow(2, retries) + Math.random() * 100;
      retries += 1;

      log.info("Trying to reconnect...");

      setTimeout(() => {
        socket(url, handlers, reconnect);
      }, retryInMs);
    }
  });
  client.onMessage(
    /**
     * @param {any} data
     */
    (data) => {
      const message = JSON.parse(data);

      if (handlers[message.type]) { // 监听,服务端发送的消息,使用handlers对应的方式进行处理。
        handlers[message.type](message.data, message.params);
      }
    }
  );
}

export default socket

位置:client-src/index.js 这个属于核心的函数

const onSocketMessage = {
  hot() {
    ..
  },
  liveReload() {
    if (parsedResourceQuery["live-reload"] === "false") {
      return;
    }

    options.liveReload = true;
  },
  invalid() {
    ...
    sendMessage("Invalid");
  },
  hash(hash) {
    status.previousHash = status.currentHash;
    status.currentHash = hash;
  },
  ok() {
    sendMessage("Ok");

    if (options.overlay) {
      overlay.send({ type: "DISMISS" });
    }

    reloadApp(options, status);
  },
  close() {
    log.info("Disconnected!");

    if (options.overlay) {
      overlay.send({ type: "DISMISS" });
    }

    sendMessage("Close");
  },
}

const socketURL = createSocketURL(parsedResourceQuery);

socket(socketURL, onSocketMessage, options.reconnect);

以上只贴了主要的几个消息类型,如果服务端发来不同的消息就执行不同的逻辑。其中最为核心的是ok方法。当服务端代码变更编译完成之后,就会发送ok的消息。客户端就会执行reloadApp方法。

在reloadApp中有这样一段逻辑。

function reloadApp({ hot, liveReload }, status) {
  if (status.isUnloading) {
    return;
  }
  const { currentHash, previousHash } = status;
  const isInitial =
    currentHash.indexOf(/** @type {string} */ (previousHash)) >= 0;

  if (isInitial) {
    return;
  }

  function applyReload(rootWindow, intervalId) {
    clearInterval(intervalId);

    log.info("App updated. Reloading...");

    rootWindow.location.reload();
  }

  const search = self.location.search.toLowerCase();
  const allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;
  const allowToLiveReload =
    search.indexOf("webpack-dev-server-live-reload=false") === -1;

  if (hot && allowToHot) { // 局部更新
    log.info("App hot update...");

    hotEmitter.emit("webpackHotUpdate", status.currentHash);

    if (typeof self !== "undefined" && self.window) {
      // broadcast update to window
      self.postMessage(`webpackHotUpdate${status.currentHash}`, "*"); // 这个是为了通知其他的同源页面也更新,因为有的时候我们会开好几个tab。
    }
  }
  else if (liveReload && allowToLiveReload) { //刷新页面
    let rootWindow = self;

    const intervalId = self.setInterval(() => {
      if (rootWindow.location.protocol !== "about:") {
        // reload immediately if protocol is valid
        applyReload(rootWindow, intervalId);
      } else {
        rootWindow = rootWindow.parent;

        if (rootWindow.parent === rootWindow) {
          // if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
          applyReload(rootWindow, intervalId);
        }
      }
    });
  }
}

那么可以看到,当reloadApp的时候,我们会根据传来的消息做不同的处理,这就特别像我们在实战篇中写的那个不同的逻辑。

好了,这其实就是所有的源码的解读,如果我们真的在实战中演练的比较好,那么再来阅读这个源码就不会那么陌生了,甚至觉得它本应该这样。

三、资源

webpack-dev-server

实战篇完整代码资源

近期好文

保姆级讲解JS精度丢失问题(图文结合)

shell、bash、zsh、powershell、gitbash、cmd这些到底都是啥?

从0到1开发一个浏览器插件(通俗易懂)

用零碎时间个人建站(200+赞)

更多精彩内容请访问我的个人网站 new-story.cn

创作不易,如果您觉得文章有任何帮助到您的地方,或者触碰到了自己的知识盲区,请帮我点赞收藏一下,或者关注我,我会产出更多高质量文章,最后感谢您的阅读,祝愿大家越来越好。