从源码角度看,Webpack-Dev-Server 做了那些事?

750 阅读10分钟

前言

本文的 webpack-dev-server 版本为 4.8.0

先导知识:快速了解 websocket: 阮一峰的博客

Webpack-Dev-Server(下文简称 WDS)究竟做了哪些事,网上已经有很多文章讨论了,在这个基础下,我们带着已有的答案从源码的角度去一探究竟,可以帮助我们更好的理解。

首先,我们要明确两个问题:

Q1. 咱们更改文件后,文件编译开始和结束的监听是 WDS 做的吗?

A1. 不是,这是由 Webpack-Dev-Middle 调用 webpack 自带的 complier.watch 方法去监听的,而 WDS 会调用这个中间件

Q2. 文件编译后的模块比对,是 WDS 做的吗?

A2. 不是,这是由 HotModuleReplacementPlugin 完成的,WDS 会自动引入这个插件,做热更新

(PS: 以上两个过程也会在下文有所提及,但不会深究其中的原理、因其不在本文的讨论范围之内。)

从使用 WDS 开始

那么 WDS 究竟做了什么呢,我们可以从 WDS 里的示例入手(文件路径将在代码块上方标注)

我们先进入 WDS 的基础示例:examples/api/simple,从该示例的 READEME.md 中可以知道

  1. 运行 node server.js
  2. 更改同级目录下的 app.jsinnerHTML
  3. 即可在打开的浏览器中查看效果

所以我们可以看看 server.js 做了什么

// path: examples/api/simple/server.js

"use strict";

const Webpack = require("webpack");
const WebpackDevServer = require("../../../lib/Server");
const webpackConfig = require("./webpack.config");

const compiler = Webpack(webpackConfig);
const devServerOptions = { ...webpackConfig.devServer, open: true };
const server = new WebpackDevServer(devServerOptions, compiler);

server.startCallback(() => {
  console.log("Starting server on http://localhost:8080");
});

代码相信各位都能看明白,其中我们需要关心的、与 WDS 有关的只有这两句

const server = new WebpackDevServer(devServerOptions, compiler);

server.startCallback(() => {
  console.log("Starting server on http://localhost:8080");
});

这两句就做了两个事儿

  1. new 了一个 WDS 实例
  2. 调用了这个实例的 startCallback 方法(老版本是 listen 方法)

接下来我们以上述两行代码为入口,从源码的角度一步步探究 WDS 究竟做了什么

WDS 源码分析

1. new 一个 WDS 实例

这个比较简单,WDS 用的是 Es6 语法,咱们知道 new 一个 Class 实际上就是走了一遍它的 constructor 函数

// path: lib/Server.js
class Server {
  /**
   * @param {Configuration | Compiler | MultiCompiler} options
   * @param {Compiler | MultiCompiler | Configuration} compiler
   */
  constructor(options = {}, compiler) {
  
    this.compiler = /** @type {Compiler | MultiCompiler} */ (compiler);
    this.options = /** @type {Configuration} */ (options);
    // 初始化其他的全局变量
  }
 }

这里做的无非就是初始化一些全局变量,把传入的 complier (由 webpack 创造),和 option 挂载一下。

2. 调用 startCallback 方法

再往下,实际上就走出 Server.js (注意大小写) 了,走回到了 examples/api/simple/server.js

// path: examples/api/simple/server.js
const server = new WebpackDevServer(devServerOptions, compiler);

server.startCallback(() => {
  console.log("Starting server on http://localhost:8080");
});

接下来我们用新建的这个示例 server 调用了其 startCallback 方法,再点进去看看(接下来的路径均为 lib/Server.js

startCallback(callback = () => {}) {
    this.start()
      .then(() => callback(), callback)
      .catch(callback);
}

我们发现实际上调用的是 this.start(),接着往下走进 start 函数

async start() {
    await this.normalizeOptions();

    if (this.options.ipc) {
      // do something...
    } else {
      this.options.host = await Server.getHostname(
        /** @type {Host} */ (this.options.host)
      );
      this.options.port = await Server.getFreePort(
        /** @type {Port} */ (this.options.port)
      );
    }

    await this.initialize();

    const listenOptions = this.options.ipc
      ? { path: this.options.ipc }
      : { host: this.options.host, port: this.options.port };

    await /** @type {Promise<void>} */ (
      new Promise((resolve) => {
        /** @type {import("http").Server} */
        (this.server).listen(listenOptions, () => {
          resolve();
        });
      })
    );

    if (this.options.ipc) {// do something...}

    if (this.options.webSocketServer) {
      this.createWebSocketServer();
    }
    // do something...
    
    this.logStatus();
    if (typeof this.options.onListening === "function") {
      this.options.onListening(this);
    }
  }

这个并不长,我们不关心的的流程就省略了,例如 option.ipc 的判断,这里我们一般不设置这个选项,接下来就对这个函数里的每一步进行探究

2.1. start -> this.normalizeOptions()

首先是 start 函数下的第一行,调用了 this.normalizeOptions(),见名知义,我们可以知道这是初始化一些 optionoption 是创建 WDS 实例的时候传入的)

async normalizeOptions() {
    const { options } = this;
    // do something...
}

这个函数非常的长,但是我们都不要太过关心,我们只需要知道,在我们自己 debug 的时候,如果发现 option 里莫名奇妙的多了一些东西,那么大概率是在这个函数里头挂载的。

有三个初始化是我们需要关注的:

// 1. 初始 websocket 客户端相关参数
options.client.webSocketURL = {};
// 2. 未设置 hot 的情况下默认为 true
options.hot = true;
// 3. 初始 websocket 服务端相关参数
options.webSocketServer = {
    type: defaultWebSocketServerType,
    options: defaultWebSocketServerOptions
};

2.2. start -> 初始化 hostport

if (this.options.ipc) {
    // do something.. 
} else {
      this.options.host = await Server.getHostname(
        /** @type {Host} */ (this.options.host)
      );
      this.options.port = await Server.getFreePort(
        /** @type {Port} */ (this.options.port)
      );
    }

这个比较简单,一般我们不会设置 options.ipc,所以会走 else,初始化一下 hostport

2.3. start -> this.initialize()

这个函数是初始化一些列事物的函数,比较重要,同样的,我们挑选重要的步骤,一步步来看

2.3.1 增加 Entries

async initialize() {
    if (this.options.webSocketServer) {
        this.addAdditionalEntries(compiler);
    }
}
addAdditionalEntries() {
    const additionalEntries = [];
    // ...
    additionalEntries.push(
        `${require.resolve("../client/index.js")}?${webSocketURLStr}`
    );
    if (this.options.hot === "only") {
        additionalEntries.push(require.resolve("webpack/hot/only-dev-server"));
    } else if (this.options.hot) {
        additionalEntries.push(require.resolve("webpack/hot/dev-server"));
    }
    
    for (const additionalEntry of additionalEntries) {
        new webpack.EntryPlugin(compiler.context, additionalEntry, {
          // eslint-disable-next-line no-undefined
          name: undefined,
        }).apply(compiler);
    }
    // ...
}

这里会调用 addAdditionalEntries,该函数定义了一个 additionalEntries,并通过一些判断往里面添加了一些路径,这里我们引入的是:${require.resolve("../client/index.js")}?${webSocketURLStr} 以及 require.resolve("webpack/hot/dev-server")

并最终调用 webpack 自带的 EntryPlugin 打包到最终的 bundle.js

image.png

这里新加的两个 entry:

  1. ../client/index.js

我们知道 websocket 是服务端主动向客户端通信,而客户端也需要有 websocket 来接收信息,这个就是客户端的 websocket

  1. webpack/hot/dev-server

注意,这个文件是 webpack/hot 下的,与 WDS 没有关系。该文件是用来检查热更新的,其调用了 HotModuleReplacementPlugin 功能,具体在后文再讲

2.3.2. 挂载 HotModuleReplacementPlugin

async initialize() {
    if (this.options.webSocketServer) {
        if (this.options.hot) {
            // Apply the HMR plugin
            const plugin = new webpack.HotModuleReplacementPlugin();

            plugin.apply(compiler);
        }
    }
}

注意这个判断一般是必走进来的,因为在 2.1 提到过,没设置 hot 的情况下会自动初始为 option.hot = true

这里我们引入了 HotModuleReplacementPlugin 用来做模块的热替换

2.3.3. 初始 hooks 监听,以及初始化 app

这两者比较简单,代码如下

async initialize() {
    // ...
    this.setupHooks();
    this.setupApp();
    // ...
}

首先是 this.setupHooks()

// path: lib/Server.js
setupHooks() {
    this.compiler.hooks.invalid.tap("webpack-dev-server", () => {
      if (this.webSocketServer) {
        this.sendMessage(this.webSocketServer.clients, "invalid");
      }
    });
    this.compiler.hooks.done.tap(
      "webpack-dev-server",
      (stats) => {
        if (this.webSocketServer) {
          this.sendStats(this.webSocketServer.clients, this.getStats(stats));
        }
        this.stats = stats;
      }
    );
}

注意 hooks 能力是 webpackcomplier 提供的。这里我们需要关心的是,这里挂载了两个监听,分别是 invaliddone 事件,每一次我们跟新代码后,如果成功,则会走到 done 的回调,否则走到 invalid 的回调。(本文最后会演示)

接下来是 this.setupApp()

这个比较简单,就是创建了一个 express

setupApp() {
    this.app = new /** @type {any} */ (express)();
}

2.3.4. 挂载 webpack-dev-middleware

async initialize() {
    // ...
    this.setupDevMiddleware();
    // ...
}

setupDevMiddleware() {
    const webpackDevMiddleware = require("webpack-dev-middleware");

    // middleware for serving webpack bundle
    this.middleware = webpackDevMiddleware(
      this.compiler,
      this.options.devMiddleware
    );
}

这里我们引入了 webpack-dev-middleware 正如源码注释,这个 middleware 才是用来监听 webpack 的打包进程的。里头具体调用了哪些函数,会在后文演示

2.3.5. 初始化一个 server

这个 server 挂载了 connectionerror 两个监听

(this.server).on(
  "connection",
  (socket) => {
    // Add socket to list
    this.sockets.push(socket);

    socket.once("close", () => {
      // Remove socket from list
      this.sockets.splice(this.sockets.indexOf(socket), 1);
    });
  }
);

(this.server).on(
  "error",
  (error) => {
    throw error;
  }
);

注意这里创建的 server 只是一个普通的 server,而 websocket 相关的 server 将在接下来创建

到此,this.initialize 中我们所要关心的步骤就结束了

2.4 start -> 启动 server

注意这里的 server 仍为一个普通的 server,我们接着调用它的 listen 方法,正式开启本地的服务器

async start() {
    // ...
    await /** @type {Promise<void>} */ (
      new Promise((resolve) => {
        /** @type {import("http").Server} */
        (this.server).listen(listenOptions, () => {
          resolve();
        });
      })
    );
    // ...
}

2.5 start -> 创建 webSocketServer

async start() {
    // ...
    this.createWebSocketServer();
    // ...
}

首先我们会在全局也就是 this 上挂载一个 webSocketServer

createWebSocketServer() {
    this.webSocketServer = new this.getServerTransport()(this);
}

可以看到我们调用了 getServerTransport 函数,这个函数里头会做一个 switch 判断

getServerTransport() {
    switch (this.options.webSocketServer).type) {
      // ...
      case "string":
        else if (
            this.options.webSocketServertype === "ws"
        ) {
          implementation = require("./servers/WebsocketServer");
        }
        break;
      // ...
    }
}

由于默认状态下 WDS 会设置 this.options.webSocketServertype = "ws",所以,我们这里创建是 require("./servers/WebsocketServer") 实例。我们可以继续点进去看一下,会发现调用的实际就是一个叫 ws 的npm包

image.png

接下来我们回到 createWebSocketServer

createWebSocketServer() {
    this.webSocketServer = new this.getServerTransport()(this);
    (this.webSocketServer).implementation.on(
      "connection",
      (client, request) => {
          this.sendMessage()
      }
}

可以发现,这里挂载了 webSocketServerconnection 监听

在该回调中,会根据不同的情况(不一一展示)大量调用 sendMessage 主动给客户端发送信息

2.4 start -> this.logStatus()

接下来调用 this.logStatus() 方法

async start() {
    // ...
    this.logStatus();
    // ...
}

这个方法实际上是打印了一系列的参数,之后,打开了我们的浏览器

logStatus() {
    // log info...
    if (/** @type {NormalizedOpen[]} */ (this.options.open).length > 0) {
        const openTarget = prettyPrintURL(this.options.host || "localhost");

        this.openBrowser(openTarget); // 打开浏览器
    }
}

到这,我们的浏览器就打开了,但是还没完,我们还要走之前挂载的回调

3. 走入之前的回调

之前的回调有哪些呢?我们来回忆一下

  1. 普通 serverconnection 回调(在2.3.5),这个回调不涉及 websocket 推送,所以不再赘述
  2. 监听文件编译结束的回调 this.complier.hooks.done (在2.3.3)
  3. webSocketServerconnection 回调(2.4)

其中两个 server 的回调不必多说,就是监听了 connection 事件。但是第二个文件编译结束的回调,是 webpack 提供的能力,这里我们要暂时回到 2.3.4,根据引入的 webpack-dev-midlleware 简要看一下做了什么(以下步骤用截图展示,仅为简要概述)

首先回到引入 webpack-dev-midlleware 的地方

image.png

不难发现 webpack-dev-midlleware 暴露了一个函数,点进去看看做了什么?

函数非常的长,我们这里要着重关心的有两点

  1. start watching

// path: node_modules/webpack-dev-middleware/dist/index.js image.png

可以看到这里有个关键的代码,官方也给了注释 start watching,这里调用了 setupOutputFileSystem 函数,点进去看看会发现调用了一个 memfs

image.png

这个 memfs 是帮助 webpack 把编译结果写到内存 memory 中的,这也是为什么我们在 dev 环境下没有产出文件。当然,通过上一行的 setupWriteToDisk 也可以看到,我们可以通过设置把产出同样写进硬盘中

  1. context.complier.watch

// path: node_modules/webpack-dev-middleware/dist/index.js image.png

再看看这个 watch

// path: node_modules/webpack/lib/Compiler.js image.png

可以看到这里创建了一个 Watching 实例,继续点进去可以发现,这个实例上挂载了一个 _done 方法

// path: node_modules/webpack/lib/Watching.js image.png

而每次编译完成,都会走这个方法。之后,再走回我们挂载到 this.complier.hooks.done 上的回调

image.png

4. 服务端推送消息

了解了前文提到的回调函数后,服务端就要开始向客户端推送消息了

复习一下 2.3.2 和 2.4 贴上的代码,可以发现实际上都是调用了 this.sendMessagethis.sendStats 函数进行推送

5. 客户端接受信息

那么客户端是如何接受到信息,并进行热更新的呢?

还记得 2.3.1 吗,WDS 为我们增加了两个 Entry,其中一个就是 ../client/index.js

简单看下代码

const onSocketMessage = {
  //...
  hash(hash) {
    status.previousHash = status.currentHash;
    status.currentHash = hash;
  },
  ok() {
    sendMessage("Ok");

    if (options.overlay) {
      hide();
    }

    reloadApp(options, status);
  },
  //...
};

const socketURL = createSocketURL(parsedResourceQuery);

socket(socketURL, onSocketMessage, options.reconnect);

这里做了大量的监听,并传给 socket 函数。

首先,顺着这个 socket 函数一路点下去,可以发现在 client-src/clients/WebSocketClient.js 中,调用了原生的 WebSocket

// path: client-src/clients/WebSocketClient.js
export default class WebSocketClient 
  constructor(url) {
    this.client = new WebSocket(url);
    this.client.onerror = (error) => {
      log.error(error);
    };
  }
}

接下来,这些监听中,我们重点关注 hashok

  1. hash 是更改文件后 webpack 编译后产生的 hash 值会经由服务端推送,到客户端后会更改 hash
hash(hash) {
    status.previousHash = status.currentHash;
    status.currentHash = hash;
},
  1. ok 则是每次服务端成功推送消息后都会走到 ok 的回调,代表消息推送成功,接下来就要热更新了,将会调用 reloadApp 方法,而这个方法,才真正意义上的开始了模块替换
ok() {
    sendMessage("Ok");
    if (options.overlay) {
      hide();
    }
    reloadApp(options, status);
},

再次之前,我们可以先看下控制台的 network,可以看到 ws 的消息已经传过来了

image.png

接下来我们看看 reloadApp 函数

6. reloadApp 进行热更新

// path: client-src/utils/reloadApp.js

function reloadApp({ hot, liveReload }, status) {
  if (status.isUnloading) {
    return;
  }
  // ...
  if (isInitial) {
    return;
  }
}

首先是做一些判断,『加载中』(status.isUnloading)和『首次加载』(isInitial)自然是不用热更新的

接下来如果符合条件,则会走进下面这个判断

import hotEmitter from "webpack/hot/emitter.js";
function reloadApp({ hot, liveReload }, status) {
  // ...
  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}`, "*");
    }
  }
  // ...

可以看到这里利用 hotEmitter 触发了 "webpackHotUpdate" 这个事件。hotEmitter 实际上是引用了 node 自带的 Event 库,进行事件的注册和触发,不再赘述。那么这个 "webpackHotUpdate" 事件是什么时候注册的呢?

回到 2.3.1 WDS 为我们增加的两个 Entry 中,另外一个 "webpack/hot/dev-server" 里,就注册了这个事件。并且,符合条件的话,会调用 webpackmodule.hot.check 方法进行模块的热替换。

if (module.hot) {
    // ...
    var check = function check() {
        module.hot
            .check(true)
            .then(function (updatedModules) {
                // ...
            })
            .catch(function (err) {
                // ...
            });
    };
    var hotEmitter = require("./emitter");
    hotEmitter.on("webpackHotUpdate", function (currentHash) {
            lastHash = currentHash;
            if (!upToDate() && module.hot.status() === "idle") {
                    log("info", "[HMR] Checking for updates on the server...");
                    check();
            }
    });
    // ...
} else {
    throw new Error("[HMR] Hot Module Replacement is disabled.");
}

而这个 module.hot.check 方法就是由 HotModuleReplacementPlugin 插入的。可以在 bundle.js 里查看

module.hot 上挂载了一个 createModuleHotObject image.png

createModuleHotObject 里又一个 check image.png

可以看到 check 使用了 hotCheck 方法,置于这个 hotCheck 就涉及 webpack 底层原理了,不在这儿展开

可以尝试把 module.devServer.hot 置为 false,会发现没有 createModuleHotObject 这个函数

PS:不同版本 createModuleHotObject 的名字可能不同,不要纠结这个

最后看下整个流程图

wds 流程图.png

最后

本文只是热更新的简单流程演示,热更新原理涉及 webpack 原理,比较复杂,感兴趣的可以自己研究

参考 轻松理解热更新原理