1. 前情提要
  2. 问题分析
  3. webpack server 持久化 bundle
  4. chrome extension 热更新方案
  5. webpack server 怎么才能感知到“编译完成”这个行为?
  6. 如何改造 webpack server 让它能向 chrome extension 发送消息?
  7. chrome extension 监听热更新
  8. 应用效果
  9. 总结

起因是因为某天在家水群,偶然想到了一个技术问题(谷歌插件可以做热更新吗),并提问了出来,然后大佬直接发了俩篇文章,均对我有所启发,就开始了对此方面的研究。
因我使用了Vue配合组件库来开发chrome extension,并且将运行时的逻辑托管到了服务端。本以为万事大吉,从此走上了开发快车道。但是也就坚持使用了一段时间就受不了了,究其原因在于我实现的脚手架工程少了一个比较关键的功能: 热更新 。代码改动以后,需要先敲下 npm run build,然后等待几秒钟,然后跳到 chorem extension 管理界面,刷新插件,然后回到页面,F5 刷新。这么长的流程,你累了吗?懒惰是第一生产力,为了能少敲一些命令,更快更爽的板砖,我决定在之前脚手架的基础上实现热更新功能。

回归正题,本文目的是实现 chrome extension 的热加载(热重载,热更新)。在实现该功能的过程中将涉及如下几个问题。

  1. 为什么说 chrome extension 没有开发环境?
  2. webpack server 是如何实现热更新的,我们可以利用它吗?
  3. 完整的 chrome extension 热更新方案。
  4. 什么是 SSE?和 websocket 有什么区别?
  5. 如何获得 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 接口完成更新,同时,我们可以让页面自动刷新。

这个方案目前有两点比较麻烦。

  1. webpack server 怎么才能感知到“编译完成”这个行为?
  2. 如何改造 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 接口。为了完成以下两点。

  1. 在 compiler.hooks.done 注册事件,这个钩子表示编译结束,关于 compiler 上的钩子可以看这里的文档。在编译结束的时候向请求的发送方传递消息。
  2. 将 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 客户端热更新实现方式如下。

  1. 配置 webpack server,将 bundle 写到磁盘。
  2. 通过 webpack plugin 暴露 compiler 对象。
  3. 为 webpack server 增加中间件,拦截 reload 请求,转化为 SSE,compiler 注册编译完成的钩子,在回调函数中通过 SSE 发送消息。
  4. chrome extension 启动后,background 与 webpack server 建立连接,监听 reload 方法,收到 server 的通知后,执行 chrome 本身的 reload 方法,完成更新。

下期预告。

chrome extension远端资源如何重新注入到每个页面中。

如何实现 chrome extension 的热更新

使用 webpack 构建 chrome 扩展的热更新问题

awesome-chrome-extension-boilerplate

最后修改:2023 年 03 月 24 日
如果觉得我的文章对你有用,请随意赞赏