扩展安装、更新、卸载后要求刷新网页甚至重开浏览器,不论对用户仍是对开发者,都是不悦的选择。在开发和生产环境中都应该尽可能避免。javascript
在 manifest.json
里显式声明 content_scripts
,能够轻易地保证每个匹配的标签页都被注入且只注入一次指定的内容脚本。可是,在用户安装或更新扩展后,新的内容脚本不会在网页刷新前载入。java
经过 chrome.tabs.executeScript
编程式注入,则存在多个问题,一是新建立标签页、刷新标签页事件须要须要侦听,二是在用户更新扩展后,已注入的内容脚本与新的内容脚本存在冲突。es6
注入内容脚本的各个方法的共同问题,首先是,更新或卸载前已经注入的内容脚本,不会自动 “消除”,其注入的 DOM 元素也不受影响。此时,若是内容脚本尝试与后端脚本(background scripts)通讯,就会报错。web
Uncaught Error: Extension context invalidated.
其次是,脚本注入的可供选择的时机很少。document_start
在 CSS 加载后、DOM 以及原页面脚本运行前注入,document_end
在 DOM 加载完成后注入,而 document_idle
在 document_end
和 window.onload
之间的某个时刻[1]注入,只有这三个选项,须要加工。chrome
[1] 指 “DOMContentLoaded 触发 200 毫秒” 或 “window.onload 触发” 这两条件中任一条件成立的时刻。
![]()
参阅: (line 176-191) script_injection_manager.cc - Chromium Code Search
声明式注入脚本的改进空间不大、很少,本文改造编程式注入方法,来实现内容脚本的即时更新。请确保在使用本文说起的相关 API 时已经在 manifest.json
中申请了相关权限。编程
首先须要在扩展加载时就将内容脚本注入到可注入的标签页里。这样才能够在扩展安装完成或更新完成后,让新的内容脚本当即开始工做。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
事件。app
不过在做者的实践中,针对在 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" }
测试过了。你也玩玩?