1. 前言
webpack经过增加plugins、loader等流程后,整体的流程已经基本走通,下面我们研究一下webpack的外圈:很重要,但不那么底层的plugins。
今天要研究的是 webpack的热更新
,对应需要研究的plugins是 webpack-dev-middleware
和 webpack-hot-middleware
2. 热更新的好处
热更新的好处显而易见,在开发时,不需要手动的刷新页面,就可以做到局部更新。 这里有两个点: server
和不刷新页面局部更新
。
我们知道有个plugin叫做 webpak-dev-server, 主要作用是两点:为编译好的静态文件提供web服务;提供热更新和热替换。 今天我要分析的其实就是替代webpack-dev-server, 自己实现一个。
3. 要实现一个热更新,我们应该怎么实现。
无论别人的源码写的如何花哨,自己总得找到底层实现逻辑,否则看得多,不一定理解的透。
实现原理,我认为从两个方面来:
1.需要一个服务器,当一个请求过来的时候,能够拿到webpack的最新编译,返回给浏览器(webpack-dev-middleware + express)。
- 服务器和浏览器之间如何通信,如何才能做到局部热更新(webpack-hot-middleware)
直白的说,就是: 如何把webpack、静态服务器、浏览器 三方统一起来。
有目标了,下面我们再通过分析webpack-dev-middleware
和 webpack-hot-middleware
的一部分源码来对照、求证我们上面的分析。
4. 源码逻辑实现
4.1 webpack-dev-middleware 做了什么
这个插件返回一个函数,这个函数支持express的接口。其实也是express的中间件。
当一个请求过来,我们需要能够拿到最新编译结果,那么也需要做以下事:
- 需要启动webpack的watch功能,当业务代码改变时,能够进行自动重新编译;
- 需要把webpack默认的产出放置地址由硬盘改为内存存储,这样速度会加快很多;
- 返回一个express的中间件函数,使之能够在请求时获取内存中存储的数据返回给浏览器;
上面的这三点其实就是这个plugin要做的事。现在我们再回过头来看看源码进行验证。
- webpack-dev-middleware github源码地址 返回一个函数,从代码中可以看到:
// index.js
import middleware from "./middleware";
// 因为是一个plugin, 所以一定会接收 compiler, 也就是webpack的实例
export default function wdm(compiler, options = {}) {
// 设置钩子,为了监听watch等事件
setupHooks(context);
// 对打包产出文件存放路径进行处理
setupOutputFileSystem(context);
// 开启watch监听
if (context.compiler.watching) {
context.watching = context.compiler.watching;
} else {
let watchOptions;
if (Array.isArray(context.compiler.compilers)) {
watchOptions = context.compiler.compilers.map(
(childCompiler) => childCompiler.options.watchOptions || {}
);
} else {
watchOptions = context.compiler.options.watchOptions || {};
}
context.watching = context.compiler.watch(watchOptions, (error) => {
if (error) {
//...
context.logger.error(error);
}
});
}
// instance是向外暴露的express中间件函数
const instance = middleware(context);
instance.getFilenameFromUrl = (url) => getFilenameFromUrl(context, url);
// 向外暴露的API
instance.waitUntilValid = (callback = noop) => {
ready(context, callback);
};
instance.invalidate = (callback = noop) => {
ready(context, callback);
context.watching.invalidate();
};
instance.close = (callback = noop) => {
context.watching.close(callback);
};
instance.context = context;
return instance
}
// middleware.js
import getFilenameFromUrl from "./utils/getFilenameFromUrl";
import handleRangeHeaders from "./utils/handleRangeHeaders";
import ready from "./utils/ready";
export default function wrapper(context) {
// 这里可以看到是一个标准的express中间件
return async function middleware(req, res, next) {
ready(context, processRequest, req);
async function processRequest() {
// ...
try {
//默认从内存中获取文件内容(也可以自己制定磁盘,但存取都会慢)
content = context.outputFileSystem.readFileSync(filename);
} catch (_ignoreError) {
await goNext();
return;
}
}
// 调用express api 发送到浏览器
if (res.send) {
res.send(content);
}
}
}
// 再看看 watch是怎么处理的
// **setupHooks.js**
export default function setupHooks(context) {
// ... 前置逻辑或方法
// 监听watchRun (在监听模式下,一个新的 compilation 触发之后,但在 compilation 实际开始之前执行)
context.compiler.hooks.watchRun.tap("webpack-dev-middleware", invalid);
// 对监听无效情况处理(在一个观察中的 compilation 无效时执行)
context.compiler.hooks.invalid.tap("webpack-dev-middleware", invalid);
// 在 compilation 要完成时以及完成时执行,触发 done这个钩子的时候,说明当次的编译完成
(context.compiler.webpack
? context.compiler.hooks.afterDone
: context.compiler.hooks.done
).tap("webpack-dev-middleware", done);
}
那么经过这么关键的几步,webpack-dev-middleware框架就搭建起来了。 大家可以去源码中仔细看一下。相信大家根据我上面的分析,对这个plugin的认知会事半功倍。
后面我会自己重写一个 webpack-dev-middleware
的轮子, 敬请期待。
4.2 webpack-hot-middleware做了什么
按照上面分析的套路,我们需要先想一下如果我们做完了上面的逻辑,自己实现了一个webpack-> express 间的链路
, 那么怎么实现 express到浏览器间的链路呢? 要实现局部刷新,那么看起来确实使用长链接处理方法比较有效,否则通信一次就断了,还需要重新连接,会比较麻烦,服务器无法自主向浏览器推送。
假如我们做到了长链接,那么剩下的就是浏览器的局部更新。 要做到局部更新其实并不难。不需要把局部更新dom想的太过复杂。 从最常用的ajax来说,我们获取到了新的内容,然后把某个dom的innerHTML更新,那么浏览器就会进行重绘操作,页面就能在不刷新页面的情况下渲染。那么同样,webpack中,一定也是通过某种机制,最后让某个模块的dom进行了更新。
再从某个模块的dom进行更新往前推,那就需要浏览器端知道要更新哪个模块,再往前就需要知道哪些模块变了,浏览器要去拉变化的模块,把原来的删除,把最新的模块进行渲染。
这样看来,我们好像知道了貌似可行的链路: 浏览器和服务器建立长链接
-> 有新的产出时,服务器需要把更新的内容或者标识发给浏览器
-> 浏览器拿到最新的模块
-> 渲染新的模块
-> 局部热更新完成
。
好,目前这个链路我们还有需要细化和明确的点: 服务器把内容还是什么标识发给浏览器?
随之对应的是 浏览器是直接拿到新的产出执行么?
这个问题,我认为都是可以
的。
webpack-hot-middleware
中,是分开处理的。下面我们可以看一下这个plugin的脉络了:
function webpackHotMiddleware(compiler, opts) {
//...
var middleware = function (req, res, next) {
// ...
}
return middleware;
}
跟 webpack-dev-middleware一样,也是传入了webpack的实例,并且返回了一个express的中间件,那么我就就可以在webpack中使用了:
const express = require('express');
const webpackHotMiddleware = require("webpack-Hot-middleware");
const hotMiddleware = webpackHotMiddleware(compiler, {
heartbeat: 2000,
})
express.use(hotMiddleware);
我们继续详细分析一下中间件中的重要脉络:
function webpackHotMiddleware(compiler, opts) {
// 创建eventStram,后面的跟浏览器的通信需要
var eventStream = createEventStream(opts.heartbeat);
var latestStats = null;
var closed = false;
// 同样监听hooks,触发对应的操作
if(compiler.hooks) {
compiler.hooks.invalid.tap('webpack-hot-middleware', onInvalid);
compiler.hooks.done.tap('webpack-hot-middleware', onDone);
} else {
compiler.plugin('invalid', onInvalid);
compiler.plugin('done', onDone);
}
// 请求过来的时候,执行其中逻辑
var middleware = function (req, res, next) {
eventStream.handler(req, res);
if (latestStats) {
// Explicitly not passing in `log` fn as we don't want to log again on
// the server
publishStats('sync', latestStats, eventStream);
}
}
// 静态方法 publish
middleware.publish = function (payload) {
if (closed) return;
eventStream.publish(payload);
};
// 关闭方法
middleware.close = function () {
};
return middleware;
}
下面详细看一下 createEventStream
这个方法很重要。
function createEventStream(heartbeat) {
var clientId = 0;
var clients = {};
function everyClient(fn) {
Object.keys(clients).forEach(function (id) {
fn(clients[id]);
});
}
var interval = setInterval(function heartbeatTick() {
everyClient(function (client) {
// 这里是个💓的符号,在后面会详细讲这个符号
client.write('data: \uD83D\uDC93\n\n');
});
}, heartbeat).unref();
return {
// 关闭连接流
close: function () {
clearInterval(interval);
everyClient(function (client) {
if (!client.finished) client.end();
});
clients = {};
},
handler: function (req, res) {
// 准备的headers,一看是为了跨域处理的
var headers = {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/event-stream;charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
// While behind nginx, event stream should not be buffered:
// http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
'X-Accel-Buffering': 'no',
};
// 如果是http1, 那么需要手动设置keepAlive
var isHttp1 = !(parseInt(req.httpVersion) >= 2);
if (isHttp1) {
req.socket.setKeepAlive(true);
Object.assign(headers, {
Connection: 'keep-alive',
});
}
res.writeHead(200, headers);
res.write('\n');
var id = clientId++;
clients[id] = res;
req.on('close', function () {
if (!res.finished) res.end();
delete clients[id];
});
},
// 向客户端发送数据
publish: function (payload) {
everyClient(function (client) {
// 向客户端发送数据,后面也会讲
client.write('data: ' + JSON.stringify(payload) + '\n\n');
});
},
};
}
再看看对webapck钩子的处理:
function onInvalid() {
if (closed) return;
latestStats = null;
if (opts.log) opts.log('webpack building...');
// 向浏览器发送 正在打包的状态
eventStream.publish({ action: 'building' });
}
function onDone(statsResult) {
if (closed) return;
// Keep hold of latest stats so they can be propagated to new clients
latestStats = statsResult;
// 向浏览器发送模块信息(模块名、hash等)
publishStats('built', latestStats, eventStream, opts.log);
}
上面的这些操作是在做什么?貌似看不懂? 其实,这里就是我们上面讲的步骤中的一步: 当编译出新结果后,发送给浏览器的是什么
。 通过看这部分,可以看到并不是把新的产出结果直接发送,而是通过心跳检测
和监听webpack hooks
的方式向客户端发送 stats
,也就是更改的模块信息,包括模块名称、模块的新hash等。
发送的这些东西有什么用? 其实这就涉及到马上要讲的一环: 浏览器的更新
问题。
浏览器中拿到这些数据后,实际上通过jsonp的形式,再去请求真正的模块数据
这也就是为什么我们在上面看到的对跨域请求的处理增加 Access-Control-Allow-Origin
。
这里就需要去client.js
中仔细看看了。
client.js做了什么
这个js实际上是需要我们手动在webpack中配置,一起打到bundle中的。
module.exports = {
entry: [
'webpack-hot-middleware/client?noInfo=true&reload=true',
'项目入口文件...'
],
}
加上的意义在于,浏览器第一次在解析我们的打包文件时,也会把这个client.js顺道执行了,继而在浏览器和服务器建立长连接。
详细看看client.js做了什么:
时间有点晚了,先写到这。 欢迎关注我, 我会尽快补全~~