说说 webpack-dev-server 的原理
简介
在看webpack-dev-server
源码之前,我们要先弄明白webpack-dev-server
是个什么,它能做哪些事情。
我们知道用webpack
可以打包我们的项目文件,然后部署上线,但是在开发过程中,我们想实时看到代码变更后我们的项目效果时,我们就会启动一个服务来监听代码文件变化,并将新的变更及时的展现在我们的浏览器上,极大的提高了我们的开发效率,这就是webpack-dev-server
带给我们的东西。
版本
📢注意
为了方便阅读文章里使用的代码都是精简后主要流程的伪代码。
流程图
为了便于串联起来理解,整理了一份主要步骤的流程图
命令行启动
当我们在命令行敲下npm run start
,一般后面都是运行:
"start": "webpack serve --open",
这里webpack
就会基于我们webpack.config.js
里的配置创建一个compiler
,然后基于compiler
和devServer
相关配置生成一个WepackDevServer
实例,该实例会启动一个express
服务来帮我们监听静态资源变化并更新。
我们以下面这段代码为例开始我们的源码探索:
"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);
const runServer = async () => {
console.log("Starting server...");
await server.start();
};
runServer();
因为我们是在研究wepack-dev-server
,这里我们主要关注server.start()
方法里发生了什么。
start
async start() {
// 这里主要是对我们的配置进行校验和补充(没配置的加默认项)
await this.normalizeOptions();
// 配置devserver服务的域名和端口
this.options.host = await Server.getHostname(this.options.host);
this.options.port = await Server.getFreePort(this.options.port);
// 初始化client和dev-server,以plugin的形式挂到compiler上,添加hooks插件,实例化express服务等
await this.initialize();
const listenOptions = { host: this.options.host, port: this.options.port };
// 启动express服务
await (
new Promise((resolve) => {
(this.server).listen(listenOptions, () => {
resolve();
});
})
);
// websocket长连接
if (this.options.webSocketServer) {
this.createWebSocketServer();
}
this.logStatus();
if (typeof this.options.onListening === "function") {
this.options.onListening(this);
}
}
初始化
上面的await this.initialize();
这里做了很多事情,来详细看下:
// 1. 加载client和dev-server文件,以plugin的形式挂到compiler上
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"));
}
if (typeof webpack.EntryPlugin !== "undefined") {
for (const additionalEntry of additionalEntries) {
new webpack.EntryPlugin(compiler.context, additionalEntry, {
// eslint-disable-next-line no-undefined
name: undefined,
}).apply(compiler);
}
}
// 2. 挂载模块热替换插件
const plugin = new webpack.HotModuleReplacementPlugin();
plugin.apply(compiler);
// 这里主要是在webpack编译完成的done钩子函数中进行消息广播给客户端
this.setupHooks();
// 创建一个express实例
this.setupApp();
// 给express实例添加请求头header检测
this.setupHostHeaderCheck();
// dev中间件,修改webpack打包输出方式,在webpack不同钩子注册回调,启动webpack编译代码,从内存中读取数据流等
this.setupDevMiddleware();
// 处理客户端请求
this.setupBuiltInRoutes();
// 监听文件变化
this.setupWatchFiles();
// 监听静态文件变化
this.setupWatchStaticFiles();
// 根据用户配置添加一些中间件,比如:代理
this.setupMiddlewares();
// 基于express实例创建服务
this.createServer();
setupHooks
setupHooks
主要做的就是在webpack
的done
钩子上挂了个给客户端广播消息的回调,通过这个回调,客户端就能知道项目工程代码有更新,这时候客户端就会发请求给express
服务去获取最新的webpack
打包的代码。
this.compiler.hooks.done.tap(
"webpack-dev-server",
(stats) => {
if (this.webSocketServer) {
// 给客户端发消息,包括更新类型,状态,hash等
this.sendStats(this.webSocketServer.clients, this.getStats(stats));
}
this.stats = stats;
}
);
setupDevMiddleware
setupDevMiddleware
函数返回结果是 express 标准的 middleware 用于处理浏览器静态资源的请求。执行过程中显示初始化了一个 context
对象,默认非 lazy
模式,开启了webpack
的 watch
模式开始启动编译。
然后将 compiler
的原来基于 fs
模块的 outputFileSystem
替换成 memory-fs
模块的实例。memory-fs
是实现了 node
的 fs api
的基于内存的 fileSystem
,这意味着 webpack
编译后的资源不会被输出到硬盘而是内存。最后将真正处理请求的 middleware
返回装载在express
上。
// 启动webpack编译代码
context.compiler.watch(watchOptions, errorHandler);
// 将webpack打包文件改成写入内存
outputFileSystem = memfs.createFsFromVolume(new memfs.Volume());
// 不同钩子注册回调
context.compiler.hooks.watchRun.tap("webpack-dev-middleware", invalid);
context.compiler.hooks.invalid.tap("webpack-dev-middleware", invalid);
context.compiler.hooks.done.tap("webpack-dev-middleware", done);
更新
上面就是npm run start
把项目跑起来经历的过程,接下来就是我们对项目代码进行开发后实现视图更新了。
因为webpack-dev-server
使用的是webpack
的watch
模式进行的编译,当我们更新了代码后,webpack
是能够监听到代码变化的,代码变化后,webpack
会再次将我们的项目代码进行打包编译,编译完成后,就会触发done
钩子函数了。
在上面初始化的时候,我们是在done
钩子上挂载了回调的。
是上面setupHooks
里的websocketServer
对客户端进行消息广播,通知客户端项目代码有更新了。
this.compiler.hooks.done.tap(
"webpack-dev-server",
(stats) => {
if (this.webSocketServer) {
// 给客户端发消息,包括更新类型,状态,hash等
this.sendStats(this.webSocketServer.clients, this.getStats(stats));
}
this.stats = stats;
}
);
当客户端接收到websocket
广播的消息后,会触发reloadApp
方法(webpack
打包时注入进去的),reloadApp
会根据广播消息里的更新类型选择是页面更新liveReload
还是模块更新hotReload
。
在客户端更新页面时,会去请求类似c390bbe0037a0dd079a6.hot-update.json
,main.c390bbe0037a0dd079a6.hot-update.js
这样的两个文件,这两个文件是webpack
使用了 HotModuleReplacementPlugin
编译时,每次增量编译就会多产出的两个文件, 分别是描述 chunk
更新的 manifest
文件和更新过后的 chunk
文件。
拿到这两个增量文件后,再去请求express
服务器去获取最新编译打包的bundle.js
。
根据更新类型,选择是两个增量文件和bundle.js
比对局部更新还是页面更新。