扩大装置、更新、卸载后要求刷新网页甚至重开浏览器,不管对用户还是对开发者,都是不悦的抉择。
在 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
事件可能是最好的抉择。
网页加载时相干事件的具体触发程序,
参阅 Event order - chrome.webNavigation - Google Chrome 和 Life cycle of requests - chrome.webRequest - Google Chrome。
所以后端脚本能够写成这样:
/* 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
事件后执行。
为什么不对立在注入扩大时设定 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"}
测试过了。你也玩玩?