从源码角度看,Webpack-Dev-Server 做了哪些事?
前言
本文的 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
原理,比较复杂,感兴趣的可以自己研究
参考 轻松理解热更新原理
转载自:https://juejin.cn/post/7106008968618573861