Chrome 扩展 | 如何即时更新内容脚本

扩展安装、更新、卸载后要求刷新网页甚至重开浏览器,不论对用户还是对开发者,都是不的选择。在开发和生产环境中都应该尽量避免。

Chrome Extension Header(图源 developer.google.com)

manifest.json 里显式声明 content_scripts,可以轻易地保证每一个匹配的标签页都被注入且只注入一次指定的内容脚本。但是,在用户安装或更新扩展后,新的内容脚本不会在网页刷新前载入。

通过 chrome.tabs.executeScript 编程式注入,则存在多个问题,一是新创建标签页、刷新标签页事件需要需要侦听,二是在用户更新扩展后,已注入的内容脚本与新的内容脚本存在冲突。

注入内容脚本的各个方法的共同问题,首先是,更新或卸载前已经注入的内容脚本,不会自动 “消除”,其注入的 DOM 元素也不受影响。此时,如果内容脚本尝试与后端脚本(background scripts)通信,就会报错。

Uncaught Error: Extension context invalidated.

其次是,脚本注入的可供选择的时机不多。document_start 在 CSS 加载后、DOM 以及原页面脚本运行前注入,document_end 在 DOM 加载完成后注入,而 document_idledocument_endwindow.onload 之间的某个时刻[1]注入,只有这三个选项,需要加工。

[1] 指 “DOMContentLoaded 触发 200 毫秒” 或 “window.onload 触发” 这两条件中任一条件成立的时刻。
documnet_idle(图源参阅)
参阅:(line 176-191) script_injection_manager.cc - Chromium Code Search

声明式注入脚本的改进空间不大、不多,本文改造编程式注入方法,来实现内容脚本的即时更新。请确保在使用本文提及的相关 API 时已经在 manifest.json 中申请了相关权限。

保证内容脚本的注入

首先需要在扩展加载时就将内容脚本注入到可注入的标签页里。这样才可以在扩展安装完成或更新完成后,让新的内容脚本立即开始工作。

/* background script. */
const scriptList = [ 'foo.js', 'bar.js' ];

const injectScriptsTo = (tabId) => {
  scriptList.forEach((script) => {
    chrome.tabs.executeScript(tabId, {
      file: `${script}`,
      runAt: 'document_start',
    // 如果脚本注入失败(没有该标签页权限之类)且没有在回调中检查 `runtime.lastError`,
    // 就会报错。本例没有其它复杂的逻辑,不需要记录注入成功的标签页,可以这样糊弄一下。
    }, () => void chrome.runtime.lastError);
  });
};
// ...

// 获取全部打开的标签页。
chrome.tabs.query({}, (tabList) => {
  tabList.forEach((tab) => {
    injectScriptsTo(tab.id);
  });
});
// ...
注意,你需要在 manifest.json 中声明 tabs 权限才可以使用 tabs.executeScript 方法将脚本注入非活动标签页。

侦听标签加载事件

太长不看版:侦听 webNavigation.onCommitted 事件。

起初,作者尝试使用 chrome.tabs API 中 onUpdatedonCreated 的组合,来应对标签页的刷新和创建事件。但是发现, onUpdated 事件在一个页面重载时会被触发多次,不加载页面时也可能会触发;onCreated 事件也经常和 onUpdated 事件混在一起,很容易导致同一页面被注入多次相同脚本。

更为可靠的,是侦听 chrome.webNavigationchrome.webRequest 系列事件。参照 Stack Overflow 上 Makyen 的回答webRequest.onHeadersReceived 似乎是最早能注入内容脚本的事件,在此事件触发前尝试注入内容脚本应该不会报错,但也不会生效;如果想在主 DOM 加载完成后注入,则可以选择 webNavigation.onCommitted 事件。

不过在作者的实践中,针对在 webRequest.onHeadersReceived 事件触发时的注入,浏览器会根据该标签页加载之前的网址来判断注入权限。这使得从空白页等不允许注入脚本的网页打开的网站不会被注入脚本,且会报错。即使在稍后触发的 webRequest.onCompleted 事件注入也有概率出现这一情况。还有很多有待测试的地方。

然而,主 window 的 chrome.webNavigation 系列的各事件在标签页刷新、新建时只会运行一次,且 webNavigation.onCommitted 事件触发后就不再存在上述导致注入失败的原因。因此,侦听 webNavigation.onCommitted 事件可能是最好的选择。

网页加载时相关事件的具体触发顺序,webRequest 为:
webNavigation 系列事件触发顺序(图源 MDN)
webNavigation 为:
webRequest 系列事件触发顺序(图源参阅)
注意,这两系列中各事件的触发顺序并不一定,即不能通过 webRequest 系列事件的触发推断出下一个触发的 webNavigation 事件。这两系列事件往往交替进行。参阅 Event order - chrome.webNavigation - Google ChromeLife cycle of requests - chrome.webRequest - Google ChromeStack Overflow 上 Makyen 的回答

所以后端脚本可以写成这样:

/* background script. */
// ...
chrome.webNavigation.onCommitted.addListener(({ tabId, frameId }) => {
  // 过滤掉非主 window 的事件。
  if (frameId !== 0) return;
  injectScriptsTo(tabId);
});
// ...

符合扩展程序的 DOM 事件

对于常见的内容脚本的用途,包括统一增加元素(如:Google 翻译),这一类,都推荐后端脚本侦听 webNavigation.onCommitted 事件。

一是因为,webNavigation.onCommitted 事件在 DOMContentLoaded 事件前触发,包含了最基本的 DOM 元素(至少包含 document.body,具体包含项不固定)。二是因为这些脚本不依赖网页的内容,注入的元素往往是浮动状态,并不在基本文档流中,对于不同的网页没有特异性,把它们注入到 DOM 任何位置都可以。因此越早注入越有利于减少扩展加载相较于原网页加载的延时。

更新扩展的时候呢,如果恰好有网页还没有载入 document.body,就会导致元素注入失败。怎么解决呢?T.J. Crowder 在 Stack Overflow 上给了我们一个很好的方案使用 Mutation Observer 侦听 DOM 的变化。这样,我们的内容脚本,就可以先准备好内存中的新元素,在 document.body ready 后 append 进去。

/* content script. */
// 相当多的事情可以在还没有 DOM 的时候完成。
const eleYouWant = document.createElement('button');
eleYouWant.addEventListener('click', (e) => { console.log(e.target) });

const changePosition = () => {
  eleYouWant.transform = `translate(${Math.floor(Math.random() * 30)}px, 0)`;
};
// ...

const afterBodyReady = () => {
  document.body.append(eleYouWant);
  document.body.addEventListener('click', changePosition);
};

if (document.body) {
  afterBodyReady();
} else {
  const bodyObserver = new MutationObserver((recordList, observer) => {
    // 等待 `document.body` 得到定义。
    if (!document.body) return;

    afterBodyReady();
    observer.disconnect();
  });
  bodyObserver.observe(document.documentElement, { childList: true });
}
注意,你需要在 manifest.json 中声明 webNavigation 权限才可以侦听 webNavigation 系列事件;声明 webRequest 权限才可以侦听 webRequest 系列事件。

对于需要访问原网页具体元素和变量的内容脚本,同样可以选择在 webNavigation.onCommitted 触发时注入,声明好变量、函数,在 DOMContentLoaded 事件后执行。

为什么不统一在注入扩展时设定 RunAtdocument_end 或统一使用 documentDOMContentLoaded 事件呢?

document_end 脚本的加载比 DOMContentLoaded 事件的触发更慢,可以排除。

DOMContentLoaded 事件的触发虽然不等待文档中的其它资源的加载,只与 DOM 文档的解析有关,但仍然比 document.body 的出现、比 webNavigation.onCommitted 的触发要慢上一些。在作者测试的部分设计不(qí)佳(pā)的,可能和广泛使用 <iframe> 有关的网站上,DOMContentLoaded 事件甚至永远不会触发。

为了内容脚本的载入速度,当然是越快注入越好。

在扩展更新后 “自杀”

旧有内容脚本不会在扩展更新后自动退出,使用的变量名、插入的元素、绑定的事件等等仍在,此时如果注入新的脚本,就会重复,容易造成冲突。最佳的方案,是把内容脚本放进块级作用域或者 IIFE(立即执行函数)里,具体做法可以视你有没有使用 var 和函数声明语句而定[2]。同时,需要写好所插入元素、绑定在原有 DOM 上的事件的 “自杀” 代码,响应扩展更新或卸载事件。

[2] 函数声明语句形如 function bar() { ... },函数表达式形如 const bar = function () { ... },参阅:块级作用域与函数声明 - let 和 const 命令 - ECMAScript 6入门
/* content script. */
{
  // ...
  const onExtensionUpdated = () => {
    // ...
    document.body.removeListener('click', changePosition);
    eleYouWant.remove();
    // ...
  };
  // ...
}

侦听扩展刷新事件

目前几乎只有一种方案可以稳定地侦听扩展程序的更新和卸载事件。在 runtime.onInstalled 事件中过滤剩下 OnInstalledReasonupdatechrome_update 的事件是不可行的,onInstalled 事件只存在于后端脚本[3],且眼下根本没有针对扩展自身的 onUninstalled 事件。

扩展更新或卸载后,内容脚本与后端脚本的沟通会中断,当前内容脚本可以利用这一点侦听与后端脚本沟通的 portonDisconnect 事件。

[3] 内容脚本可以使用的 API 十分有限。完整的可使用列表,参阅:Understand Content Script Capabilities - Content Scripts - Google Chrome

同时,你需要确保后端脚本存在处理内容脚本的连接请求的侦听器。存在就行。否则,浏览器会很贴心地给你一个 Receiving end does not exist 错误。如果没有这样的侦听器,可以增加一个空的。

/* background script. */
// ...
// 屏蔽 Receiving end does not exist 错误。
chrome.runtime.onConnect.addListener(() => {});
// ...
/* content script. */
{
  // ...
  const portWithBackground = chrome.runtime.connect();
  portWithBackground.onDisconnect.addListener(onExtensionUpdated);
  // ...
}

整合示例

能够即时更新的内容脚本到这里就完成了。

后端脚本 background.js

/* background.js */
const scriptList = [ 'content.js' ];

const injectScriptsTo = (tabId) => {
  scriptList.forEach((script) => {
    chrome.tabs.executeScript(tabId, {
      file: `${script}`,
      runAt: 'document_start',
    // 如果脚本注入失败(没有该标签页权限之类)且没有在回调中检查 `runtime.lastError`,
    // 就会报错。本例没有其它复杂的逻辑,不需要记录注入成功的标签页,可以这样糊弄一下。
    }, () => void chrome.runtime.lastError);
  });
};

// 屏蔽 Receiving end does not exist 错误。
chrome.runtime.onConnect.addListener(() => {});

// 获取全部打开的标签页。
chrome.tabs.query({}, (tabList) => {
  tabList.forEach((tab) => {
    injectScriptsTo(tab.id);
  });
});

chrome.webNavigation.onCommitted.addListener(({ tabId, frameId }) => {
  // 过滤掉非主 window 的事件。
  if (frameId !== 0) return;
  injectScriptsTo(tabId);
});

内容脚本 content.js

/* content.js */
{
  // 相当多的事情可以在还没有 DOM 的时候完成。
  const eleYouWant = document.createElement('button');
  eleYouWant.addEventListener('click', (e) => { console.log(e.target) });

  const changePosition = () => {
    eleYouWant.style.transform = `translate(${Math.floor(Math.random() * 60)}px, 0)`;
  };

  const onExtensionUpdated = () => {
    document.body.removeEventListener('click', changePosition);
    eleYouWant.remove();
  };

  const portWithBackground = chrome.runtime.connect();
  portWithBackground.onDisconnect.addListener(onExtensionUpdated);

  const afterBodyReady = () => {
    document.body.append(eleYouWant);
    document.body.addEventListener('click', changePosition);
  };

  if (document.body) {
    afterBodyReady();
  } else {
    const bodyObserver = new MutationObserver((recordList, observer) => {
      // 等待 `document.body` 得到定义。
      if (!document.body) return;

      afterBodyReady();
      observer.disconnect();
    });
    bodyObserver.observe(document.documentElement, { childList: true });
  }
}

基本元数据清单 manifest.json

{
   "background": {
      "scripts": [ "background.js" ]
   },
   "description": "栗子,如题。嗯嗯。介绍应该要比标题长,对吧。",
   "manifest_version": 2,
   "name": "会即时更新的内容脚本",
   "permissions": [
      "tabs",
      "webNavigation",
      "<all_urls>"
   ],
   "version": "0.1"
}

测试过了。你也玩玩?

阅读 440

推荐阅读

产生和解决尝鲜过程中的各种奇葩 Bug。有一些 也会 投生产 吧?

3 人关注
5 篇文章
专栏主页