前言
本文的 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
中可以知道
- 运行
node server.js
- 更改同级目录下的
app.js
的innerHTML
- 即可在打开的浏览器中查看效果
所以我们可以看看 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");
});
复制代码
这两句就做了两件事
new
了一个WDS
实例- 调用了这个实例的
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()
,见名知义,我们可以知道这是初始化一些 option
(option
是创建 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
-> 初始化 host
和 port
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
,初始化一下 host
和 port
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
中
这里新加的两个 entry
:
../client/index.js
我们知道 websocket
是服务端主动向客户端通信,而客户端也需要有 websocket
来接收信息,这个就是客户端的 websocket
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
能力是 webpack
的 complier
提供的。这里我们需要关心的是,这里挂载了两个监听,分别是 invalid
和 done
事件,每一次我们跟新代码后,如果成功,则会走到 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
挂载了 connection
和 error
两个监听
(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包
接下来我们回到 createWebSocketServer
createWebSocketServer() {
this.webSocketServer = new this.getServerTransport()(this);
(this.webSocketServer).implementation.on(
"connection",
(client, request) => {
this.sendMessage()
}
}
复制代码
可以发现,这里挂载了 webSocketServer
的 connection
监听
在该回调中,会根据不同的情况(不一一展示)大量调用 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. 走入之前的回调
之前的回调有哪些呢?我们来回忆一下
- 普通
server
的connection
回调(在2.3.5),这个回调不涉及websocket
推送,所以不再赘述 - 监听文件编译结束的回调
this.complier.hooks.done
(在2.3.3) webSocketServer
的connection
回调(2.4)
其中两个 server
的回调不必多说,就是监听了 connection
事件。但是第二个文件编译结束的回调,是 webpack
提供的能力,这里我们要暂时回到 2.3.4,根据引入的 webpack-dev-midlleware
简要看一下做了什么(以下步骤用截图展示,仅为简要概述)
首先回到引入 webpack-dev-midlleware
的地方
不难发现 webpack-dev-midlleware
暴露了一个函数,点进去看看做了什么?
函数非常的长,我们这里要着重关心的有两点
start watching
// path: node_modules/webpack-dev-middleware/dist/index.js
可以看到这里有个关键的代码,官方也给了注释 start watching
,这里调用了 setupOutputFileSystem
函数,点进去看看会发现调用了一个 memfs
这个 memfs
是帮助 webpack
把编译结果写到内存 memory
中的,这也是为什么我们在 dev
环境下没有产出文件。当然,通过上一行的 setupWriteToDisk
也可以看到,我们可以通过设置把产出同样写进硬盘中
context.complier.watch
// path: node_modules/webpack-dev-middleware/dist/index.js
再看看这个 watch
// path: node_modules/webpack/lib/Compiler.js
可以看到这里创建了一个 Watching
实例,继续点进去可以发现,这个实例上挂载了一个 _done
方法
// path: node_modules/webpack/lib/Watching.js
而每次编译完成,都会走这个方法。之后,再走回我们挂载到 this.complier.hooks.done
上的回调
4. 服务端推送消息
了解了前文提到的回调函数后,服务端就要开始向客户端推送消息了
复习一下 2.3.2 贴上的代码,可以发现实际上都是调用了 this.sendMessage
和 this.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);
};
}
}
复制代码
接下来,这些监听中,我们重点关注 hash
和 ok
hash
是更改文件后webpack
编译后产生的hash
值会经由服务端推送,到客户端后会更改hash
hash(hash) {
status.previousHash = status.currentHash;
status.currentHash = hash;
},
复制代码
ok
则是每次服务端成功推送消息后都会走到ok
的回调,代表消息推送成功,接下来就要热更新了,将会调用reloadApp
方法,而这个方法,才真正意义上的开始了模块替换
ok() {
sendMessage("Ok");
if (options.overlay) {
hide();
}
reloadApp(options, status);
},
复制代码
再此之前,我们可以先看下控制台的 network
,可以看到 ws 的消息已经传过来了
接下来我们看看 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"
里,就注册了这个事件。并且,符合条件的话,会调用 webpack
的 module.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
createModuleHotObject
里又一个 check
可以看到 check
使用了 hotCheck
方法,置于这个 hotCheck
就涉及 webpack
底层原理了,不在这儿展开
可以尝试把 module.devServer.hot
置为 false
,会发现没有 createModuleHotObject
这个函数
PS:不同版本 createModuleHotObject
的名字可能不同,不要纠结这个
最后看下整个流程图
最后
本文只是热更新的简单流程演示,热更新原理涉及 webpack
原理,比较复杂,感兴趣的可以自己研究
参考 轻松理解热更新原理