扩展安装、更新、卸载后要求刷新网页甚至重开浏览器,不论对用户还是对开发者,都是不悦的选择。
在 manifest.json
里显式声明 content_scripts
,可以轻易地保证每一个匹配的标签页都被注入且只注入一次指定的内容脚本。但它有个明显的缺陷,就是在用户安装或更新扩展后,新的内容脚本无法在网页刷新前载入。
通过 chrome.tabs.executeScript
编程式注入,则存在多个问题,一是新创建标签页、刷新标签页事件需要需要侦听,二是在用户更新扩展后,已注入的内容脚本与新的内容脚本存在冲突。
注入内容脚本的各个方法的共同问题,首先是,更新或卸载前已经注入的内容脚本,不会自动 “消除”,其注入的 DOM 元素也不受影响。此时,如果内容脚本尝试与后端脚本(background scripts)通信,就会报错。
其次是,脚本注入的可供选择的时机不多。document_start
在 CSS 加载后、DOM 以及原页面脚本运行前注入,document_end
在 DOM 加载完成后注入,而 document_idle
在 document_end
和 window.onload
之间的某个时刻[1]注入,只有这三个选项,需要加工。
[1] 指 “DOMContentLoaded 触发 200 毫秒” 或 “window.onload 触发” 这两条件中任一条件成立的时刻。
参阅:(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 中 onUpdated
和 onCreated
的组合,来应对标签页的刷新和创建事件。但是发现, onUpdated
事件在一个页面重载时会被触发多次,不加载页面时也可能会触发;onCreated
事件也经常和 onUpdated
事件混在一起,很容易导致同一页面被注入多次相同脚本。
更为可靠的,是侦听 chrome.webNavigation
和 chrome.webRequest
系列事件。参照 Stack Overflow 上 Makyen 的回答,webRequest.onHeadersReceived
似乎是最早能注入内容脚本的事件,在此事件触发前尝试注入内容脚本应该不会报错,但也不会生效;如果想在主 DOM 加载完成后注入,则可以选择 webNavigation.onCommitted
事件。
不过在作者的实践中,针对在 webRequest.onHeadersReceived
事件触发时的注入,浏览器会根据该标签页加载之前的网址来判断注入权限。这使得从空白页等不允许注入脚本的网页打开的网站不会被注入脚本,且会报错。即使在稍后触发的 webRequest.onCompleted
事件注入也有概率出现这一情况。还有很多有待测试的地方。
然而,主 window 的 chrome.webNavigation
系列的各事件在标签页刷新、新建时只会运行一次,且 webNavigation.onCommitted
事件触发后就不再存在上述导致注入失败的原因。因此,侦听 webNavigation.onCommitted
事件可能是最好的选择。
网页加载时相关事件的具体触发顺序,webRequest
为:webNavigation
为:
注意,这两系列中各事件的触发顺序并不一定,即不能通过 webRequest 系列事件的触发推断出下一个触发的 webNavigation 事件。这两系列事件往往交替进行。参阅 Event order - chrome.webNavigation - Google Chrome、Life cycle of requests - chrome.webRequest - Google Chrome 和 Stack Overflow 上 Makyen 的回答。
所以后端脚本可以写成这样:
/* background script. */ // ... chrome.webNavigation.onCommitted.addListener(({ tabId, frameId }) => { // 过滤掉非主 window 的事件。 if (frameId !== 0) return; injectScriptsTo(tabId); }); // ...
对于常见的内容脚本的用途,包括统一增加元素(如: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
事件后执行。
RunAt
为 document_end
或统一使用 document
的 DOMContentLoaded
事件呢?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
事件中过滤剩下 OnInstalledReason
为 update
和 chrome_update
的事件是不可行的,onInstalled
事件只存在于后端脚本[3],且眼下根本没有针对扩展自身的 onUninstalled
事件。
扩展更新或卸载后,内容脚本与后端脚本的沟通会中断,当前内容脚本可以利用这一点侦听与后端脚本沟通的 port 的 onDisconnect
事件。
[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" }
测试过了。你也玩玩?