基于长链接的插件通信方案

407 阅读11分钟

这篇文章会彻底聊明白浏览器插件开发中两个核心概念:

  • 常用的5种脚本的权限与特性
  • 不同脚本之间的消息传递

通过实际例子,你会发现各脚本间的消息传递并不像想象中那样容易,甚至更加繁琐。

通信竞争、长连接中断、跨端通信、PromiseLess、潜在资源浪费、调用方式差异等问题会带来很大心智负担。那么一个统一且简单易用的消息通信工具就显得非常重要了。

于是我爆肝两周,开发了一款基于【长链接和Promise】的 MessageBus,以应对上述问题。

如果想了解浏览器&插件原理,可以移步我的另一篇文章《从浏览器原理出发聊聊Chrome插件》

那么本篇内容即将开始,求点赞求转发,你的一个支持是对我大大的帮助~

Chrome插件是如何工作的

一个插件运行时,会存在三个进程:

  • 扩展进程(Extension Process)

    • 扩展进程中主要运行着 background,popup.html、devtools.html 等
    • background 独立于网页并运行在后台,主要通过调用浏览器提供的 Chrome API 和浏览器进行交互
    • popup.html、devtools.html 是一个真实的页面,和普通的web页面一样,由html、css、Javascript组成,它是按需加载的,需要用户去点击插件入口按钮或者devtools按钮才能展示
  • 页面渲染进程(Render Process)

    • 渲染进程主要运行页面环境,打开页面时,会将 content-script 和 inject-script 加载并注入到该网页的环境中
  • 浏览器进程(Browser Process)

    • 浏览器进程在这里起到桥梁作用,作为中转可以实现 Extension Conetext 和 content-script 之间的消息通信

核心脚本类型

content-script

是 chrome 插件中向页面注入脚本的一种形式(虽然名为script,其实还可以包括css的),借助 content-scripts 我们可以实现通过配置的方式轻松向指定页面注入 js 和 css

content-scripts 和原始页面共享 dom,但不共享 js,访问不了页面中的全局变量

content-scripts 不能访问绝大部分 Chrome API,除了下面这4种:chrome.extensionchrome.i18nchrome.runtimechrome.storage

这些API绝大部分时候都够用了,有需要调用其它API的话,可以通过通信让 background 来帮忙调用

background

后台是一个常驻的页面,它的生命周期是插件中所有类型页面中最长的,它随着浏览器的打开而打开,随着浏览器的关闭而关闭,所以通常把需要一直运行的、启动就运行的、全局的代码放在 background 里面。

background的权限非常高,几乎可以调用所有的 Chrome API(除了devtools),而且它可以无限制跨域,可以跨域访问任何网站而无需要求对方设置CORS。

background 的概念在MV3版本中变为了 Service Worker,区别在于生命周期变短了,Service Worker 是短暂的基于事件的脚本,所以不适合用来保存全局变量。

popup

popup 是点击右上角图标时打开的一个小窗口网页,焦点离开网页就立即关闭,一般用来做一些临时性的交互。权限级别和 background 差不多,就是生命周期比较短。

inject-script

Chrome 插件中其实没有 inject-script 这一概念,这是开发者们在开发过程中衍生出来的一种概念,指的是通过DOM 操作的方式向页面注入的一种JS。

V3 版本后,可以直接在 manifest 中设置 "world": "MAIN"

因为 content-script 无法访问页面中的JS,只能够访问 Dom,但是插件获取页面的全局变量这种能力是很常见的,所以诞生了 inject-script,它就相当于直接在控制台写代码。

devtools

Chrome允许插件在开发者工具(devtools)上开发,主要表现在:

  • 自定义一个和多个和Elements、Console、Sources等同级别的面板
  • 自定义侧边栏(sidebar),目前只能自定义Elements面板的侧边栏

脚本通信

脚本通信分为两种,一种是简单的一次性请求(One-time requests),一种是长连接(Long-lived connections )

  • 一次性请求:一次只能发一条消息,类似于手机发短信,跟 HTTP 请求很像
  • 长连接:允许发送多条消息,类似于手机打电话,跟 Websocket 连接很像

通信大图

一次性请求

潜在资源浪费

通常情况下一次性请求用的是最多的,比如 background 与 content-script 通信

background 向 content 发送 "hello",conetnt 接收并答复 "goodbye",background 收到答复

整个通信过程基于 Promise,消息通道随着消息发送而打开,随着同步代码执行结束而关闭(上图中的 sendResponse 是同步代码),消息通道并不会等待答复。

然而很多时候,答复都是一个耗时动作,如果你在5秒后才给到答复,这时候消息通道其实早就关闭了,你的答复就不能正常发送给 background。我们需要在 onMessage 的回调里返回 true,通知 chrome 这是一个异步任务(耗时任务),chrome 就会一直保留该消息通道,直到完成答复。

聪明的你一定发现问题了,如果我同时发送100个耗时任务,那么浏览器就得保留100个消息通道,这对浏览器资源是一个大大的浪费(敲重点! ),先拿小本本记下了。

通信竞争

如果有多个扩展端点都在监听 onMessage 事件,则只有第一个针对特定事件调用 sendResponse() 的端点才能成功发送响应,系统将忽略针对该事件的所有其他响应。

content 发送请求,background 和 devtools 同时监听该消息,background 立即答复,这条答复会被 content 接收,devtools 5秒后答复,这条答复会被拒绝。

从设计上看这个逻辑是非常合理的,也符合 Promise 规范。但是现实情况是,开发中基本所有扩展脚本都会监听 onMessage,当一端发送消息时,扩展内部的所有端点都会接收到消息,如果在 onMessage 中没有做好消息区分,其中一端提前做了答复,就会产生意料之外的结果,也会增加问题排查难度。

跨端通信 & PromiseLess

上述场景只是有通信权限的两端直接通信,通过上面的通信大图我们会发现,inject-script 是个特殊的存在,inject-script 如果想访问扩展进程里的脚本,必须通过 content-script 来中转,这就是"跨端通信"

inject 通过 postMessage 向 content 发送 "hello background",content 接收并通过 runtime.sendMessage 转发消息给 background,background 接收,5秒后答复 "goodbye",content 收到答复并通过 postMessage 转发给inject,inject 注册的消息监听器收到答复

原本简单的通信由于需要转发,你需要花费很多心思在转发的处理上(图中 content 部分),上面的例子只是一个最小demo,真实情况会比这个复杂的多。并且 postMessage 不返回 Promise,你需要在 inject 里同时处理消息的发送和接收,造成代码冗余,增加维护成本。

长连接通信

Chrome 允许我们创建可重复使用的长期消息传递通道,除 inejct-script 以外的端点都可以和 background 建立连接。建立连接时,系统会为每一端分配一个 runtime.Port 对象,用于通过该连接发送和接收消息。

连接中断

插件从 V2 到 V3 的过渡会发生根本性的变化。在 V2 中,扩展程序位于后台页面中,后台页面用于管理扩展程序与网页之间的通信,V3 改用了 Service Worker。

扩展 Service Worker 和后台页面都是扩展程序的核心脚本,它们在后台独立运行。一般来说,这没什么问题。但在 Service Worker 中,会在满足以下条件时被 chrome 终止运行:

  • 无操作 30 秒后。收到事件或调用扩展程序 API 会重置此计时器
  • 单个请求(例如事件或 API 调用)的处理用时超过 5 分钟
  • 当 fetch 响应的时间超过 30 秒时

这些情况都会导致长连接被中断,扩展程序无法按照预期正常运行。

MessageBus设计

高清架构图地址:excalidraw.com/#json=dmqAo…

核心设计理念

messageBus 的设计是以 background 脚本为核心的,除 inject 外的其他脚本都与 background 建立长连接通道,所有目标端点非 background 的通信都通过 background 进行转发处理。

why?

  • 第一个目的当然是为了消息通道的复用,解决一次性连接潜在的资源浪费问题。
  • 第二个目的是方便开发,如果允许每个脚本之间直接通信,那么在7种脚本中,就存在42种排列组合的通信,你需要单独处理这些情况,这对于抽离统一的 MessageBus 来说,难度是巨大的。但是通过上面的方式,就可以抽象成两个端点的通信,即扩展脚本端口与 background 端口之间的通信,只需要在两个脚本里处理逻辑即可。
  • 第三个目的是,长连接的断开是有感知的,如果 backgound 脚本被终止了,所有连接到端口都会收到中断消息,可以在里面做一些熔断处理,以防止扩展崩溃。

PresistentPort (持久化端口)

为了统一处理所有脚本的连接,设置了一个持久化端口类,每个扩展脚本在初始化的时候,都会实例化这个类。它的主要功能是与 background 建立长连接,同时在 background 被终止时进行重新连接,以确保 background 在插件运行时一直处于激活状态。

该类会暴露出 postMessageonMessageonFailure

  • postMessage:通过长连接端口向 background 发送消息
  • onMessage:接收回调函数并放入 MessageListeners 中,每次 port 接收到消息,都会将 MessageListeners 中注册的回调函数全部执行一遍
  • onFailure:与 onMessage 类似

除了暴露三个核心方法外,该类还负责消息响应、错误处理和重试机制。以 conetnt -> devtools 举例:

  1. content 发送的消息会先被 background 接收,此时 background 会去判断目标端点是否已经和 background 建立连接了(上面有提到,devtools 只有在面板打开时才会初始化,所以 content 发送消息的时候, devtools 有可能未连接)
  2. 如果目标端点未连接,background 会向 content 发送 cannot_transfer 消息,同时 background 内部会进行一次重试,如果依然重试失败,会将请求信息缓存,等待新端口连接时重新尝试发送。content 收到 cannot_transfer 消息后,会将请求信息缓存到 transfer_failed 队列中,以应对后台脚本终止,重新连接时,不丢失之前的失败消息。
  3. 如果目标端点正常连接,background 会将消息继续发送到 devtools,同时发送 transferring 消息给 content,告知消息已经正常转发,content 会将该消息存入等待队列中,如果 devtools 响应成功,会答复 replied 到 background 并转发给 content,随后移除等待队列中的数据,并执行 MessageRuntime 中的消息接收逻辑
  4. background 中断时,会发送 terminated 消息,content 会将等待队列清除,并销毁所有 Promise

MessageRuntime (运行时消息处理)

MessageRuntime 主要处理消息的发送和接收,它与 PresistentPort 的区别是,后者提供原子的消息发送和监听能力,前者利用这个能力进行详细的发送和监听逻辑处理。

还是以 conetnt -> devtools 举例:

  1. conetnt 发送消息会生成一个 Promise,将 taskId 和 Promise 存入任务队列中,随后通过 PresistentPort 提供的端口发送消息到 background
  2. background 转发消息到 devtools
  3. devtools 的 MessageRuntime 接收到消息,从消息监听器中取出回调函数并执行,将结果发送给 content
  4. content 接收到消息,取出任务,最后完成 Promise

通道阻塞问题

当然长连接也有一个弊端,它是全双工的通信模式,允许通信双方同时发送和接收数据,但是不能同一时间发送多条数据。就像车道一样,只能一辆一辆过。

这个问题我之前没考虑清楚,也是在开发引擎插件日志时遇到的。引擎在运行的过程中,由于代码执行非常快,导致大量日志在间隔很短的时间内(< 10ms)同时涌入消息通道,造成部分日志被丢失了。

针对这种情况,MessageBus 设置了一个消息缓冲队列,会将收到的消息以大于10ms的间隔时间一条一条消费,确保通道畅通。

使用方式

消息发送方: 只需要设置统一标识 MessageId,然后指定端口名称,就可以轻松的将消息发送给对应端点,无需关心各种传输异常情况,并且消息的发送是基于 Promise 的,符合前端打工仔的使用习惯。

消息接收方: 监听指定的 MessageId,传入回调函数即可,回调函数的返回结果就是响应数据。

最后

码字和画图真的巨累巨费时间,希望大家多多点赞呀~