擴展安裝、更新、卸載後要求刷新網頁甚至重開瀏覽器,不論對用戶仍是對開發者,都是不悅的選擇。在開發和生產環境中都應該儘可能避免。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" }
測試過了。你也玩玩?