webpack-dev-middleware@1.12.2 源码解读

63次阅读

共计 9800 个字符,预计需要花费 25 分钟才能阅读完成。

​ webpack-dev-middleware 是 express 的一个中间件,它的主要作用是以监听模式启动 webpack,将 webpack 编译后的文件输出到内存里,然后将内存的文件输出到 epxress 服务器上;下面通过一张图片来看一下它的工作原理:

了解了它的工作原理以后我们通过一个例子进行实操一下。
demo1:初始化 webpack-dev-middleware 中间件,启动 webpack 监听模式编译,返回 express 中间件函数
// src/app.js
console.log(‘App.js’);
document.write(‘webpack-dev-middleware’);
// demo1/index.js
const path = require(‘path’);
const express = require(‘express’);
const webpack = require(‘webpack’);
const webpackDevMiddleware = require(‘webpack-dev-middleware’); // webpack 开发中间件
const HtmlWebpackPlugin = require(‘html-webpack-plugin’); // webpack 插件:根据模版生成 html,并且自动注入引用 webpack 编译出来的 css 和 js 文件

/**
* 创建 webpack 编译器
*/
const comoiler = webpack({// webpack 配置
entry: path.resolve(__dirname, ‘src/app.js’), // 入口文件
output: {// 输出配置
path: path.resolve(__dirname, ‘dist’), // 输出路径
filename: ‘bundle.[hash].js’ // 输出文件
},
plugins: [// 插件
new HtmlWebpackPlugin({// 根据模版自动生成 html 文件插件,并将 webpack 打包输出的 js 文件注入到 html 文件中
title: ‘webpack-dev-middleware’
})
]
});

/**
* 执行 webpack-dev-middleware 初始化函数,返回 express 中间件函数
* 这个函数内部以监听模式启动了 webpack 编译,相当于执行 cli 的:webpack –watch 命令
* 也就是说执行到这一步,就已经启动了 webpack 的监听模式编译了,代码执行到这里可以看到控制台已经输出了 webpack 编译成功的相关日志了
* 由于 webpack-dev-middleware 中间件内部使用 memory-fs 替换了 compiler 的 outputFileSystem 对象,将 webpack 打包编译的文件都输出到内存中
* 所以磁盘上看不到任何 webpack 编译输出的文件
*/
const webpackDevMiddlewareInstance = webpackDevMiddleware(comoiler,{
reportTime: true, // webpack 状态日志输出带上时间前缀
stats: {
colors: true, // webpack 编译输出日志带上颜色,相当于命令行 webpack –colors
process: true
}
});
运行结果:

源码链接:https://github.com/Jameswain/…
​ 通过上述例子的运行结果,我们可以发现 webpack-dev-middleware 实际上是一个函数,通过执行它会返回一个 express 风格的中间件函数,并且会以监听模式启动 webpack 编译。由于 webpack-dev-middleware 中间件内部使用 memory-fs 替换了 compiler 的 outputFileSystem 对象,将 webpack 打包编译的文件都输出到内存中,所以虽然我们看到控制台上有 webpack 编译成功的日志,但是并没有看到任何的输出文件,就是这个原因,因为这些文件在内存里。
​ 如果此时我们不想把文件输出到内存里,可以通过修改 webpack-dev-middleware 的源代码来实现。打开 node_modules/webpack-dev-middleware/lib/Shared.js 文件,将该文件的 231 行注视掉后,重新运行 node demo1/index.js 即可看到文件被输出到 demo1/dist 文件夹中。

​ 问:为什么 webpack-dev-middleware 要将 webpack 打包后的文件输出到内存中,而不是直接到磁盘上呢?
​ 答:速度,因为 IO 操作是非常耗资源时间的,直接在内存里操作会比磁盘操作会更加快速和高效。因为即使是 webpack 把文件输出到磁盘,要将磁盘上到文件通过一个服务输出到浏览器,也是需要将磁盘的文件读取到内存里,然后在通过流进行输出,然后浏览器上才能看到,所以中间件这么做其实还是省了一步读取磁盘文件的操作。
​ 下面通过一个例子演示一下如何将本地磁盘上的文件通过 Express 服务输出到 response,在浏览器上进行访问:
//demo3/app.js
const express = require(‘express’);
const path = require(‘path’);
const fs = require(‘fs’);
const app = express();

// 读取 index.html 文件
const htmlIndex = fs.readFileSync(path.resolve(__dirname,’index.html’));
// 读取图片
const img = fs.readFileSync(path.resolve(__dirname, ‘node.jpg’));

app.use((req, res, next) => {
console.log(req.url)
if (req.url === ‘/’ || req.url === ‘/index.html’) {
res.setHeader(“Content-Type”, ‘text/html;charset=UTF-8’);
res.setHeader(“Content-Length”, htmlIndex.length);
res.send(htmlIndex); // 传送 HTTP 响应
// res.end(); // 此方法向服务器发出信号,表明已发送所有响应头和主体,该服务器应该视为此消息已完成。必须在每个响应上调用此 response.end() 方法。
// res.sendFile(path.resolve(__dirname, ‘index.html’)); // 传送指定路径的文件 - 会自动根据文件 extension 设定 Content-Type
} else if (req.url === ‘/node.jpg’) {
res.end(img); // 此方法向服务器发出信号,表明已发送所有响应头和主体,该服务器应该视为此消息已完成。必须在每个响应上调用此 response.end() 方法。
}
});

app.listen(3000, () => console.log(‘express 服务启动成功。。。’));

// 浏览器访问:http://localhost:3000/node.jpg
// 浏览器访问:http://localhost:3000/
项目目录:

运行结果:

通过上述代码我们可以看出不管是输出 html 文件还是图片文件都是需要先将这些文件读取到内存里,然后才能输出到 response 上。

middleware.js
​ 下面我们就来看看 webpack-dev-middleware 这个函数内部是如何实现的,它的运行原理是什么?个人感觉读源码最主要的就是基础 + 耐心 + 流程
​ 首先打开 node_modules/webpack-dev-middleware/middleware.js 文件,注意版本号,我这份代码的版本号是 webpack-dev-middleware@1.12.2。

​ middleware.js 文件就是 webpack-dev-middleware 的入口文件,它主要做以下几件事情:
​ 1、记录 compiler 对象和中间件配置
​ 2、创建 webpack 操作对象 shared
​ 3、创建中间件函数 webpackDevMiddleware
​ 4、将 webpack 的一些常用操作函数暴露到中间件函数上,供外部直接调用
​ 5、返回中间件函数
Shared.js

这个文件对 webpack 的 compiler 这个对象进行封装操作,我们大概先来看看这个文件主要做了哪些事情:

首先设置中间件的一些默认选项配置
使用 memory-fs 对象替换掉 compiler 的文件系统对象,让 webpack 编译后的文件输出到内存中
监听 webpack 的钩子函数

invalid:监听模式下,文件发生变化时调用,同时会传入 2 个参数,分别是文件名和时间戳
watch-run:监听模式下,一个新的编译触发之后,完成编译之前调用
done:编译完成时调用,并传入 webpack 编译日志对象 stats
run:在开始读取记录之前调用,只有调用 compiler.run() 函数时才会触发该钩子函数

以观察者模式启动 webpack 编译
返回 share 对象,该对象封装了很多关于 compiler 的操作函数

通过上面的截图我们大概知道了 Shared.js 文件的运行流程,下面我们再来看看它一些比较重要的细节。
share.setOptions 设置中间件的默认配置

share.setFs(context.compiler) 设置 compiler 的文件操作对象

share.startWatch() 以观察模式启动 webpack

compiler.watch(watchOptions, callback) 这个函数表示以监听模式启动 webpack 并返回一个 watching 对象,这里特别需要注意的是当调用 compiler.watch 函数时会立即执行 watch-run 这个钩子回调函数,直到这个钩子回调函数执行完毕后,才会返回 watching 对象。
share.compilerDone(stats) webpack 编译完成回调处理函数

当 webpack 的一个编译完成时会进入 done 钩子回调函数,然后调用 compilerDone 函数,这个函数内部首先将 context.state 设置为 true 表示 webpack 编译完成,并记录 webpack 的统计信息对象 stats,然后将 webpack 日志输出操作和回调函数执行都放到 process.nextTick() 任务队列执行,就是等主逻辑所有的代码执行完毕后才进行 webpack 的日志输出和中间件回调函数的执行。
context.options.reporter (share.defaultReporter) webpack 默认日志输出函数
context.options.reporter 和 share.defaultReporter 指向的都是同一个函数

​ 通过代码我们可以看出这个函数内部首先是要判断一下 state 这个状态,false 表示 webpack 处于编译中,则直接输出 webpack: Compiling…。true:则表示 webpack 编译完成,则需要判断 webpack-dev-middleware 这个中间件都两个配置,noInfo 和 quiet,noInfo 如果是为 true 则只输出错误和警告,quiet 为 true 则不输出任何内容,默认这俩选项都是 false,这时候会判断 webpack 编译成功后返回的 stats 对象里有没有错误和警告,有错误或警告就输出错误和警告,没有则输出 webpack 的编译日志,并且使用 webpack-dev-middleware 的 options.stats 配置项作为 webpack 日志输出配置,更多 webpack 日志输出配置选项见:https://www.webpackjs.com/con…
handleCompilerCallback() – watch 回调函数

这个是 watch 回调函数,它是在 compiler.plugin(‘done’) 钩子函数执行完毕之后执行,它有两个参数,一个是错误信息,一个是 webpack 编译成功的统计信息对象 stats,可以看到这个回调函数内部只做错误信息的输出。
webpack watch 模式钩子函数执行流程图

使用 webpack-dev-middleware 中间件
​ 之前我介绍的都是 webpack-dev-middleware 中间件初始化阶段主要做了什么事情,而且我的第一个代码例子里也只是调用了 webpack-dev-middleware 中间件的初始化函数而已,并没有和 express 结合使用,当时这么做的主要是为了说明这个中间件的初始化阶段的运行机制,下面我们通过一个完整一点的例子说明 webpack-dev-middleware 中间件如何和 express 进行结合使用以及它的运行流程和原理。
// demo2/index.js
const path = require(‘path’);
const express = require(‘express’);
const webpack = require(‘webpack’);
const webpackDevMiddleware = require(‘webpack-dev-middleware’);
const HtmlWebpackPlugin = require(‘html-webpack-plugin’);

// 创建 webpack 编译器
const compiler = webpack({
entry: path.resolve(__dirname, ‘src/app.js’),
output: {
path: path.resolve(__dirname, ‘dist’),
filename: ‘bundle.[hash].js’
},
plugins: [
new HtmlWebpackPlugin({
title: ‘webpack-dev-middleware’
})
]
});

// webpack 开发中间件:其实这个中间件函数执行完成时,中间件内部就会执行 webpack 的 watch 函数,启动 webpack 的监听模式,相当于执行 cli 的:webpack –watch 命令
const webpackDevMiddlewareInstance = webpackDevMiddleware(compiler, {
reportTime: true, // webpack 状态日志输出带上时间前缀
stats: {
colors: true, // webpack 编译输出日志带上颜色,相当于命令行 webpack –colors
process: true
},
// noInfo: true, // 不输出任何 webpack 编译日志 (只有警告和错误)
// quiet: true, // 不向控制台显示任何内容
// reporter: function (context) {// 提供自定义报告器以更改 webpack 日志的输出方式。
// console.log(context.stats.toString(context.options.stats))
// },
});

/**
* webpack 第一次编译完成并且输出编译日志后调用
* 之后监听到文件变化重新编译不会再执行此函数
*/
webpackDevMiddlewareInstance.waitUntilValid(stats => {
console.log(‘webpack 第一次编译成功回调函数 ’);
});

// 创建 express 对象
const app = express();
app.use(webpackDevMiddlewareInstance); // 使用 webpack-dev-middleware 中间件,每一个 web 请求都会进入该中间件函数
app.listen(3000, () => console.log(‘ 启动 express 服务 …’)); // 启动 express 服务器在 3000 端口上

// for (let i = 0; i < 10000022220; i++) {} // 会阻塞 webpack 的编译操作
​ 源码地址:https://github.com/Jameswain/…
​ 通过 app.use 进行使用中间件,然后我们通过在浏览器访问 localhost:3000,然后就可以看到效果了,此时任何一个 web 请求都会执行 webpack-dev-middleware 的中间件函数,下面我们来看看这个中间件函数内部是如何实现的,到底做了哪些事情。
​ 1、我们先通过一个流程图看一下上面这段代码首次执行 webpack-dev-middleware 的内部运行流程


​ 2、middleware.js 文件中的 webpackDevMiddleware 函数代码解析
// webpack-dev-middleware 中间件函数,每一个 http 请求都会进入次函数
function webpackDevMiddleware(req, res, next) {
/**
* 执行下一个中间件
*/
function goNext() {
// 如果不是服务器端渲染,则直接执行下一个中间件函数
if(!context.options.serverSideRender) return next();
return new Promise(function(resolve) {
shared.ready(function() {
res.locals.webpackStats = context.webpackStats;
resolve(next());
}, req);
});
}

// 如果不是 GET 请求,则直接调用下一个中间件并返回退出函数
if(req.method !== “GET”) {
return goNext();
}

// 根据请求的 URL 获取 webpack 编译输出文件的绝对路径;例如:req.url=”/bundle.492db0756b0d8df3e6dd.js” 获取到的 filename 就是 ”/Users/jameswain/WORK/blog/demo2/dist/bundle.492db0756b0d8df3e6dd.js”
// 可以看到其实就是 webpack 编译输出文件的绝对路径和名称
var filename = getFilenameFromUrl(context.options.publicPath, context.compiler, req.url);
if(filename === false) return goNext();

return new Promise(function(resolve) {
shared.handleRequest(filename, processRequest, req);
function processRequest(stats) {
try {
var stat = context.fs.statSync(filename);
// 处理当前请求是 / 的情况
if(!stat.isFile()) {
if(stat.isDirectory()) {
// 如果请求的 URL 是 /,则将它的文件设置为中间件配置的 index 选项
var index = context.options.index;
// 如果中间件没有设置 index 选项,则默认设置为 index.html
if(index === undefined || index === true) {
index = “index.html”;
} else if(!index) {
throw “next”;
}
// 将 webpack 的输出目录 outputPath 和 index.html 拼接起来
filename = pathJoin(filename, index);
stat = context.fs.statSync(filename);
if(!stat.isFile()) throw “next”;
} else {
throw “next”;
}
}
} catch(e) {
return resolve(goNext());
}

// server content 服务器内容
// 读取文件内容
var content = context.fs.readFileSync(filename);
// console.log(content.toString()) // 输出文件内容
// 处理可接受数据范围的请求头
content = shared.handleRangeHeaders(content, req, res);
// 获取文件的 mime 类型
var contentType = mime.lookup(filename);
// do not add charset to WebAssembly files, otherwise compileStreaming will fail in the client
// 不要将 charset 添加到 WebAssembly 文件中,否则编译流将在客户端失败
if(!/\.wasm$/.test(filename)) {
contentType += “; charset=UTF-8”;
}
res.setHeader(“Content-Type”, contentType);
res.setHeader(“Content-Length”, content.length);
// 中间件自定义请求头配置,如果中间件有配置,则循环设置这些请求头
if(context.options.headers) {
for(var name in context.options.headers) {
res.setHeader(name, context.options.headers[name]);
}
}
// Express automatically sets the statusCode to 200, but not all servers do (Koa).
// Express 自动将 statusCode 设置为 200,但不是所有服务器都这样做 (Koa)。
res.statusCode = res.statusCode || 200;
// 将请求的文件或数据内容输出到客户端(浏览器)
if(res.send) res.send(content);
else res.end(content);
resolve();
}
});
}
​ 这是 webpack-dev-middleware 中间件的源代码,我加了一些注释和个人见解说明这个中间件内部的具体操作,这里我简单总结一下这个中间件函数主要做了哪些事情:

首先判断如果不是 GET 请求,则调用下一个中间件函数,并退出当前中间件函数。
根据请求的 URL,拼接出该资源在 webpack 输出目录的绝对路径。例如:请求的 URL 为“/bundle.js”,那么在我电脑拼接出的绝对路径就为 ”/Users/jameswain/WORK/blog/demo2/dist/bundle.js”,如果请求的 URL 为 /,设置文件为 index.html
读取请求文件的内容,是一个 Buffer 类型,可以立即为流
判断客户端是否设置了 range 请求头,如果设置了,则需要对内容进行截取限制在指定范围之内。
获取请求文件的 mime 类型
设置请求头 Content-Type 和 Content-Length,循环设置中间件配置的自定义请求头
设置状态码为 200
将文件内容输出到客户端

​ 下面通过一个流程图看一下这个中间件函数的执行流程:

总结
​ webpack-dev-middleware 这个中间件内部其实主就是做了两件事,第一就是在中间件函数初始化时,修改 webpack 的文件操作对象,让 webpack 编译后的文件输出到内存里,以监听模式启动 webpack。第二就是当有 http get 请求过来时,中间件函数内部读取 webpack 输出到内存里的文件,然后输出到 response 上,这时候浏览器拿到的就是 webpack 编译后的资源文件了。
​ 最后给出本文所有相关源代码的地址:https://github.com/Jameswain/…
​ 声明:本文纯属个人阅读 webpack-dev-middleware@1.12.2 源码的一些个人理解和感悟,由于本人技术水平有限,如有错误还望各位大神批评指正。

正文完
 0