Chrome 擴展 | 如何即時更新內容腳本

擴展安裝、更新、卸載後要求刷新網頁甚至重開瀏覽器,不論對用戶仍是對開發者,都是不的選擇。在開發和生產環境中都應該儘可能避免。javascript

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

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_idledocument_endwindow.onload 之間的某個時刻[1]注入,只有這三個選項,須要加工。chrome

[1] 指 「DOMContentLoaded 觸發 200 毫秒」 或 「window.onload 觸發」 這兩條件中任一條件成立的時刻。
documnet_idle(圖源參閱)
參閱: (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 中 onUpdatedonCreated 的組合,來應對標籤頁的刷新和建立事件。可是發現, onUpdated 事件在一個頁面重載時會被觸發屢次,不加載頁面時也可能會觸發;onCreated 事件也常常和 onUpdated 事件混在一塊兒,很容易致使同一頁面被注入屢次相同腳本。瀏覽器

更爲可靠的,是偵聽 chrome.webNavigationchrome.webRequest 系列事件。參照 Stack Overflow 上 Makyen 的回答webRequest.onHeadersReceived 彷佛是最先能注入內容腳本的事件,在此事件觸發前嘗試注入內容腳本應該不會報錯,但也不會生效;若是想在主 DOM 加載完成後注入,則能夠選擇 webNavigation.onCommitted 事件。app

不過在做者的實踐中,針對在 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"
}

測試過了。你也玩玩?

相關文章
相關標籤/搜索