本文首发于政采云前端团队博客: webpack-dev-middleware 源码解读
前言
Webpack 的使用目前已经是前端开发工程师必备技能之一。若是想在本地环境启动一个开发服务,大家只需在 Webpack 的配置中,增加 devServer 的配置来启动。devServer 配置的本质是 webpack-dev-server 这个包提供的功能,而 webpack-dev-middleware 则是这个包的底层依赖。
截至本文发表前,webpack-dev-middleware 的最新版本为 [email protected]
,本文的源码来自于此版本。本文会讲解 webpack-dev-middleware 的核心模块实现,相信大家把这篇文章看完,再去阅读源码,会容易理解很多。
webpack-dev-middleware 是什么?
要回答这个问题,我们先来看看如何使用这个包:
const wdm = require('webpack-dev-middleware');
const express = require('express');
const webpack = require('webpack');
const webpackConf = require('./webapck.conf.js');
const compiler = webpack(webpackConf);
const app = express();
app.use(wdm(compiler));
app.listen(8080);
通过启动一个 Express 服务,将 wdm(compiler)
的结果通过 app.use
方法注册为 Express 服务的中间函数。从这里,我们不难看出 wdm(compiler)
的执行结果返回的是一个 express
的中间件。它作为一个容器,将 webpack
编译后的文件存储到内存中,然后在用户访问 express
服务时,将内存中对应的资源输出返回。
为什么要使用 webpack-dev-middleware
熟悉 webpack
的同学都知道,webpack
可以通过 watch mode 方式启动,那为何我们不直接使用此方式来监听资源变化呢?答案就是,webpack
的 watch mode
虽然能监听文件的变更,并且自动打包,但是每次打包后的结果将会存储到本地硬盘中,而 IO 操作是非常耗资源时间的,无法满足本地开发调试需求。
而 webpack-dev-middleware 拥有以下几点特性:
- 以
watch mode
启动webpack
,监听的资源一旦发生变更,便会自动编译,生产最新的bundle
- 在编译期间,停止提供旧版的
bundle
并且将请求延迟到最新的编译结果完成之后 -
webpack
编译后的资源会存储在内存中,当用户请求资源时,直接于内存中查找对应资源,减少去硬盘中查找的 IO 操作耗时
本文将主要围绕这三个特性和主流程逻辑进行分析。
源码解读
让我们先来看下 webpack-dev-middleware 的源码目录:
...
├── lib
│ ├── DevMiddlewareError.js
│ ├── index.js
│ ├── middleware.js
│ └── utils
│ ├── getFilenameFromUrl.js
│ ├── handleRangeHeaders.js
│ ├── index.js
│ ├── ready.js
│ ├── reporter.js
│ ├── setupHooks.js
│ ├── setupLogger.js
│ ├── setupOutputFileSystem.js
│ ├── setupRebuild.js
│ └── setupWriteToDisk.js
├── package.json
...
其中 lib
目录下为源代码,一眼望去有近 10 多个文件要解读。但刨除 utils
工具集合目录,其核心源码文件其实只有两个 index.js
、middleware.js
下面我们就来分析核心文件 index.js
、middleware.js
的源码实现
入口文件 index.js
从上文我们已经得知 wdm(compiler)
返回的是一个 express
中间件,所以入口文件 index.js
则为一个中间件的容器包装函数。它接收两个参数,一个为 webpack
的 compiler
、另一个为配置对象,经过一系列的处理,最后返回一个中间件函数。下面我将对 index.js
中的核心代码进行讲解:
...
setupHooks(context);
...
// start watching
context.watching = compiler.watch(options.watchOptions, (err) => {
if (err) {
context.log.error(err.stack || err);
if (err.details) {
context.log.error(err.details);
}
}
});
...
setupOutputFileSystem(compiler, context);
index.js
最为核心的是以上 3 个部分的执行,分别完成了我们上文提到的两点特性:
- 以监控的方式启动
webpack
- 将
webpack
的编译内容,输出至内存中
setupHooks
此函数的作用是在 compiler
的 invalid
、run
、done
、watchRun
这 4 个编译生命周期上,注册对应的处理方法
context.compiler.hooks.invalid.tap('WebpackDevMiddleware', invalid);
context.compiler.hooks.run.tap('WebpackDevMiddleware', invalid);
context.compiler.hooks.done.tap('WebpackDevMiddleware', done);
context.compiler.hooks.watchRun.tap(
'WebpackDevMiddleware',
(comp, callback) => {
invalid(callback);
}
);
- 在
done
生命周期上注册done
方法,该方法主要是report
编译的信息以及执行context.callbacks
回调函数 - 在
invalid
、run
、watchRun
等生命周期上注册invalid
方法,该方法主要是report
编译的状态信息
compiler.watch
此部分的作用是,调用 compiler
的 watch 方法,之后 webpack
便会监听文件变更,一旦检测到文件变更,就会重新执行编译。
setupOutputFileSystem
其作用是使用 memory-fs 对象替换掉 compiler
的文件系统对象,让 webpack
编译后的文件输出到内存中。
fileSystem = new MemoryFileSystem();
// eslint-disable-next-line no-param-reassign
compiler.outputFileSystem = fileSystem;
通过以上 3 个部分的执行,我们以 watch mode
的方式启动了 webpack
,一旦监测的文件变更,便会重新进行编译打包,同时我们又将文件的存储方法改为了内存存储,提高了文件的存储读取效率。最后,我们只需要返回 express
的中间件就可以了,而中间件则是调用 middleware(context)
函数得到的。下面,我们来看看 middleware
是如何实现的。
middleware.js
此文件返回的是一个 express
中间件函数的包装函数,其核心处理逻辑主要针对 request
请求,根据各种条件判断,最终返回对应的文件内容:
function goNext() {
if (!context.options.serverSideRender) {
return next();
}
return new Promise((resolve) => {
ready(
context,
() => {
// eslint-disable-next-line no-param-reassign
res.locals.webpackStats = context.webpackStats;
// eslint-disable-next-line no-param-reassign
res.locals.fs = context.fs;
resolve(next());
},
req
);
});
}
首先,middleware
中定义了一个 goNext()
方法,该方法判断是否是服务端渲染。如果是,则调用 ready()
方法(此方法即为 ready.js
文件,作用为根据 context.state
状态判断直接执行回调还是将回调存储 callbacks
队列中)。如果不是,则直接调用 next()
方法,流转至下一个 express
中间件。
const acceptedMethods = context.options.methods || ['GET', 'HEAD'];
if (acceptedMethods.indexOf(req.method) === -1) {
return goNext();
}
接着,判断 HTTP
协议的请求的类型,若请求不包含于配置中(默认 GET
、HEAD
请求),则直接调用 goNext()
方法处理请求:
let filename = getFilenameFromUrl(
context.options.publicPath,
context.compiler,
req.url
);
if (filename === false) {
return goNext();
}
然后,根据请求的 req.url
地址,在 compiler
的内存文件系统中查找对应的文件,若查找不到,则直接调用 goNext()
方法处理请求:
return new Promise((resolve) => {
// eslint-disable-next-line consistent-return
function processRequest() {
...
}
...
ready(context, processRequest, req);
});
最后,中间件返回一个 Promise
实例,而在实例中,先是定义一个 processRequest
方法,此方法的作用是根据上文中找到的 filename
路径获取到对应的文件内容,并构造 response
对象返回,随后调用 ready(context, processRequest, req)
函数,去执行 processRequest
方法。这里我们着重看下 ready
方法的内容:
if (context.state) {
return fn(context.webpackStats);
}
context.log.info(`wait until bundle finished: ${req.url || fn.name}`);
context.callbacks.push(fn);
非常简单的方法,判断 context.state
的状态,将直接执行回调函数 fn
,或在 context.callbacks
中添加回调函数 fn
。这也解释了上文提到的另一个特性 “在编译期间,停止提供旧版的 bundle
并且将请求延迟到最新的编译结果完成之后”。若 webpack
还处于编译状态,context.state
会被设置为 false
,所以当用户发起请求时,并不会直接返回对应的文件内容,而是会将回调函数 processRequest
添加至 context.callbacks
中,而上文中我们说到在 compile.hooks.done
上注册了回调函数 done
,等编译完成之后,将会执行这个函数,并循环调用 context.callbacks
。
总结
源码的阅读是一个非常枯燥的过程,但是它的收益也是巨大的。上文的源码解读主要分析的是 webpack-dev-middleware
它是如何实现它所拥有的特性、如何处理用户的请求等主要功能点,未包括其他分支逻辑处理、容错。还需读者在这篇文章基础之上,再去阅读详细的源码,望这篇文章能对你的阅读过程起到一定的帮助作用。
招贤纳士
政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 50 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。
如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 [email protected]