- 前情提要
- 问题分析
- webpack server 持久化 bundle
- chrome extension 热更新方案
- webpack server 怎么才能感知到“编译完成”这个行为?
- 如何改造 webpack server 让它能向 chrome extension 发送消息?
- chrome extension 监听热更新
- 应用效果
- 总结
起因是因为某天在家水群,偶然想到了一个技术问题(谷歌插件可以做热更新吗),并提问了出来,然后大佬直接发了俩篇文章,均对我有所启发,就开始了对此方面的研究。
因我使用了Vue配合组件库来开发chrome extension,并且将运行时的逻辑托管到了服务端。本以为万事大吉,从此走上了开发快车道。但是也就坚持使用了一段时间就受不了了,究其原因在于我实现的脚手架工程少了一个比较关键的功能: 热更新 。代码改动以后,需要先敲下 npm run build,然后等待几秒钟,然后跳到 chorem extension 管理界面,刷新插件,然后回到页面,F5 刷新。这么长的流程,你累了吗?懒惰是第一生产力,为了能少敲一些命令,更快更爽的板砖,我决定在之前脚手架的基础上实现热更新功能。
回归正题,本文目的是实现 chrome extension 的热加载(热重载,热更新)。在实现该功能的过程中将涉及如下几个问题。
- 为什么说 chrome extension 没有开发环境?
- webpack server 是如何实现热更新的,我们可以利用它吗?
- 完整的 chrome extension 热更新方案。
- 什么是 SSE?和 websocket 有什么区别?
- 如何获得 webpack 内部事件?或者说是如何写一个简单的 webpack plugin。
chrome extension 的本质是一个由 manifest 文件管理的 html,js,css 的集合。我们开发完成后,将文件上传到 chrome。chrome 本身会启动一个进程来运行这些文件。我们可以简单的认为 chrome 启动了一个服务,我们上传的文件就是服务器上的资源文件。chrome 为了区别这些资源文件和 http 资源文件的区别,它的资源都是以 chrome-extension://xxx 这种方式对外暴露的。这是 chrome 内部定义的一个协议,除了名字不同,还有一些安全策略的不同,其它的和 http 协议一模一样。
这里面临的第一个问题就是,chrome extension 想要生效就必须将文件上传到 chrome 浏览器。因此 chrome 开发中没有所谓的开发环境,想要看到结果,必须打包。这也是 chrome 很难做到做到热更新的原因,没有 webpack server 的参与,就无法复用 webapck server 的能力。
想要 chrome extension 有热更新的能力,自己写不现实,最好还是复用 webpack server 的能力。webpack server 默认情况下,会将代码加载到内存中,所有的变更也是发生在内存中的,这样速度比较快。比较贴心的是,webpack server 同时也提供了将 bundle 写到磁盘的能力
我们需要在 package.json 中添加serve脚本,来执行webpack serve,当然由于我们使用了vue cli搭建的脚手架,这个脚本已经默认为我们配置好了,但是在之前并没有利用上。
|
"scripts": {
"serve": "vue-cli-service serve",
}
然后修改 webpeck 的配置,将bundle写到磁盘。https://github.com/webpack/webpack-dev-middleware https://webpack.js.org/configuration/dev-server/
|
devServer: {
// 将 bundle 写到磁盘而不是内存
writeToDisk: true,
},
但是经过使用,发现server打包的位置一直是默认的dist,而因我们插件是区分77、78的所以在运行时,还需要区分出来打包的位置,而不是77、78公用一个文件夹,根本不现实。
经过多次修改配置,加上询问大佬,得知应该尝试去更改outputDir选项,通过修改outputDir选项,解决了此问题。https://cli.vuejs.org/zh/config/#outputdir
|
const devBasePath = () => {
if (process.env.NODE_ENV === 'development') {
// 开发环境下 78服务器
if (process.env.VUE_APP_TEMA_BUILD_CODE === 'Dev') {
return path.join(__dirname, '[serve]77dist')
}
// 开发环境下 77服务器
if (process.env.VUE_APP_TEMA_BUILD_CODE === 'Pro') {
return path.join(__dirname, '[serve]78dist')
}
}
}
module.exports = {
outputDir: devBasePath(),
}
目前还缺少的另一半功能是,如何将更新的信息推送给 chrome extension?
我们虽然实现了文件的更新,但是 chrome extension 并没有感知到这种变化,我们依旧需要前往管理页面,点击插件的刷新按钮,回到被脚本注入的页面,刷新资源。这个流程还是很长。如何将这个过程也自动化呢?
chrome extension 提供了 chrome.runtime.reload() 接口 ,该接口的作用和点击插件的刷新按钮是一致的,我们可以在 background 中主动调用这个接口,实现当前 extension 的刷新。
那现在的问题就是, 何时触发这个行为呢 ?
我们参考下 webpack server 实现热更新的方式。 我们在访问 webpack server 上的某个页面的时候,浏览器会和 webpack server 建立一个 socket 链接 。我们可以在 Network 中找到这个请求。
|
Request URL: ws://10.5.1.230:8080/sockjs-node/691/qab5i0zt/websocket
Request Method: GET
Status Code: 101 Switching Protocols
Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: vwVEIeyicMUeKQ37nSeN5Q==
Sec-WebSocket-Version: 13
Upgrade: websocket
当代码发生变化的时候,webpack server 会分析当前的这个变化是否会影响到当前显示的页面,如果会的话,server 会主动推送条消息给浏览器,格式类似于这种。
|
{"type":"hash","data":"a4f313f6e36cf04be738"}
浏览器接着就会根据这个 data 的值发送请求到 webpack server 拉取最新的变更数据。
chrome extension 的资源不是通过 webpack server 访问的,因此我们无法使用 server 的热更新的能力。但是我们可以参考这个思路,我们在 webpack server 和 chrome extension 之间搭建一个 socket 连接,由webpack server 向 chrome extension 推送编译完成的消息,chrome extension 在收到这个消息之后,调用本身的 reload 接口完成更新,同时,我们可以让页面自动刷新。
这个方案目前有两点比较麻烦。
- webpack server 怎么才能感知到“编译完成”这个行为?
- 如何改造 webpack server 让它能向 chrome extension 发送消息?
webpack plugin
首先解决第一个问题,只要是涉及到 webpack 生命周期的,那一定需要用到 webpack plugin。webpack 为自己定义了上百个钩子函数,我们可以使用 plugin 向这些钩子上注册自定义的方法,控制 webpack 行为。
这里我们需要获得 webpack 的 compiler 对象,将其保存被静态变量。这样我们就能在 webpack server 中拿到 compiler 对象。
|
class CompilerEmitPlugin {
static innerCompiler
apply(compiler) {
CompilerEmitPlugin.innerCompiler = compiler
}
}
module.exports = CompilerEmitPlugin
webpack server 中间件
webpack server 本质是一个简单的 nodejs express 服务器。这个 express 上集成了 webpack-dev-middleware。webpack server 本身也支持我们扩展整个中间件体系。
before 方法,能够在其它所有的中间件之前执行自定义的中间件 ,类似的还有 after 方法。
|
devServer: {
// 将 bundle 写到磁盘而不是内存
writeToDisk: true,
before: (app, server) => {
// 创建sse 与插件客户端通信
reloadServer(app)
},
我们在 before 中加入我们自定义的中间件。该中间件的监听 reload 接口。为了完成以下两点。
- 在 compiler.hooks.done 注册事件,这个钩子表示编译结束,关于 compiler 上的钩子可以看这里的文档。在编译结束的时候向请求的发送方传递消息。
- 将 request 转换为一个 SSEStream,这样这个请求就会一直保持在 client 和 server 之间, server 可以通过这个通道随时向 client 推送消息。
以下是核心代码;
|
const SSEStream = require('ssestream').default
const ChromeReloadPlugin = require('./plugins/CompilerEmitPlugin')
function ReloadServer(app) {
app.get('/reload', (req, res, next) => {
const sseStream = new SSEStream(req)
const compiler = ChromeReloadPlugin.innerCompiler
sseStream.pipe(res)
let closed = false
const reloadPlugin = () => {
if (!closed) {
sseStream.write(
{
event: 'compiled successfully',
data: {
action: 'reload extension and refresh current page',
},
},
'utf-8',
(err) => {
if (err) {
console.error(err)
}
}
)
setTimeout(() => {
sseStream.unpipe(res)
}, 100)
}
}
compiler.hooks.done.tap('chrome reload plugin', reloadPlugin)
res.on('close', () => {
closed = true
sseStream.unpipe(res)
})
next()
})
}
module.exports = ReloadServer
Websocket 与 SSE
你可能注意到,我们这里并没有使用 Websocket,而是直接使用了 SSE。SSE 全称是 erver-sent Events,是在 HTML 5 中规范和定义的一种服务端推送方式。 SSE 与 WebSocket 作用相似,都是建立浏览器与服务器之间的通信渠道,然后服务器向浏览器推送信息 。
虽然他们作用类似,但是底层完全不同。WebSocket 是一个独立协议,可以双向通信。SSE 使用 HTTP 协议,是单向通道。 SSE的实现类似于浏览器播放视屏,数据不是一次性的,而是以流信息的方式分段获取的 ,SSE 利用这种机制,使用流信息向浏览器推送信息。
这里使用 SSE,完全是因为它使用比较简单,且不需要双向通讯。 比如: 在股票行情、新闻推送的这种只需要服务器发送消息给客户端场景中,使用SSE可能更加合适。
Server-Sent Events 教程
搞懂现代Web端即时通讯技术一文就够:WebSocket、socket.io、SSE
接下来就是在 background 初始化的时候,监听 webpack server 的 reload 接口,收到编译成功的通知后,调用 chrome 的 reload 接口完成插件的更新。
|
const eventSource = new EventSource('http://localhost:<%= port %>/reload/')
eventSource.addEventListener('compiled successfully', () => {
console.log('客户端重新加载')
chrome.runtime.reload()
})
为了让 contentScript 也顺便更新掉,我们让 background 给 contentScript 也发送下更新指令,延迟一段时间后(为了等待 chrome reload 结束),页面刷新。
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.target !== 'contents') {
return;
}
if (request.from === 'popup') {
onFromPopup(request.data);
} else if (request.from === 'background') {
onFromBackground(request.data);
}
});// 监听background发来的信息
const onFromBackground = data => {
switch (data.key) {
case 'refreshPage':
setTimeout(() => {
window.location.reload();
}, 500);
break;
default:
}
}
到此就完成了整个热更新的流程,我在代码中完成修改后,页面会自动刷新,此时 chrome extension 中的代码也是最新的了。
为了让上述的代码只在开发的时候生效,我们需要将上述的代码单独写在 js 文件中,然后让 webpack 仅在开发环境下将该文件打包到制品中。
这里还有一个潜在的问题就是,如果 webpack server 更换了暴露的地址,就得修改 background 中监听的地址了。这里是一个优化点,可以通过 plugin 读到 webpack server 的配置,然后动态的修改这个监听的地址。
chrome extension 客户端热更新实现方式如下。
- 配置 webpack server,将 bundle 写到磁盘。
- 通过 webpack plugin 暴露 compiler 对象。
- 为 webpack server 增加中间件,拦截 reload 请求,转化为 SSE,compiler 注册编译完成的钩子,在回调函数中通过 SSE 发送消息。
- chrome extension 启动后,background 与 webpack server 建立连接,监听 reload 方法,收到 server 的通知后,执行 chrome 本身的 reload 方法,完成更新。
下期预告。
chrome extension远端资源如何重新注入到每个页面中。