一般來說,插件的原理是向頁面中注入 javascript 腳本,對頁面進行處理,比如屏蔽頁面中可能的廣告元素,改變某些元素的樣式,增加一些 UI。
開發(fā)插件需要使用前端技術(shù):html css javascript。
本文就從入門開始講述如何開發(fā)一款 chrome 插件。
注意:chrome 插件機制本身也在更新,本文講述的是目前普遍使用的 V2 插件的開發(fā)。
Manifest V3 is available beginning with Chrome 88, and the Chrome Web Store begins accepting MV3 extensions in January 2021.
插件構(gòu)成
chrome 插件通常由以下幾部分組成:
manifest.json:相當于插件的 meta 信息,包含插件的名稱、版本號、圖標、腳本文件名稱等,這個文件是每個插件都必須提供的,其他幾部分都是可選的。
background script:可以調(diào)用全部的 chrome 插件 API,實現(xiàn)跨域請求、網(wǎng)頁截屏、彈出 chrome 通知消息等功能。相當于在一個隱藏的瀏覽器頁面內(nèi)默默運行。
功能頁面:包括點擊插件圖標彈出的頁面(簡稱 popup)、插件的配置頁面(簡稱 options)。
content script:早期也被稱為 injected script,是插件注入到頁面的腳本,但是不會體現(xiàn)在頁面 DOM 結(jié)構(gòu)里。content script 可以操作 DOM,但是它和頁面其他的腳本是隔離的,訪問不到其他腳本定義的變量、函數(shù)等,相當于運行在單獨的沙盒里。content script 可以調(diào)用有限的 chrome 插件 API,網(wǎng)絡請求收到同源策略限制。
插件的架構(gòu)可以參考:https://developer.chrome.com/docs/extensions/mv2/architecture-overview/
重點說明以下幾點:
- browser action 和 page action:這倆我們可以理解為插件的按鈕。browser action 會固定在 chrome 的工具欄。而 page action 可以設置特定的網(wǎng)頁才顯示圖標,在地址欄的右端,如下圖:
 

大部分插件點擊之后會顯示 UI,也就是上文描述的插件功能頁面部分,一般稱為 popup 頁面,如下圖:

popup 無法通過程序打開,只能由用戶點擊打開。點擊 popup 之外的區(qū)域會導致 popup 收起。
page action 和 browser action 分別由 manifest.json 的 page_action 和 browser_action 字段配置。
- 由于 content script 受到同源策略的限制,所以一般網(wǎng)絡請求都交給 background script 處理。
 - content script、插件功能頁面、background script 之間的通信架構(gòu)如下圖:
 

chrome 可以打開多個瀏覽器窗口,而一個窗口會有多個 tab,所以插件的結(jié)構(gòu)大致如下:

如上圖,功能頁面是每個 window 一份,但是每個 tab 都會注入 content script。
manifest.json
下文簡稱 manifest ,其中有這么幾個字段可以重點說明:
content_scripts
content_scripts 可以使用以下兩種方式注入頁面,這兩種方式并不沖突,可以結(jié)合使用。
聲明式注入
舉例如下:
{
  "content_scripts": [
    {
      "matches": ["http://*/*", "https://*/*"],
      "run_at": "document_idle",
      "js": ["content.js"]
    }
  ]
}在 manifest 中聲明要加載的腳本,各個字段都比較直觀。其中:
- matches 表示頁面 url 匹配時才加載
 - run_at? 表示在什么時機加載,一般是 document_idle,避免 content_scripts 影響頁面加載性能。
 
需要注意的是,如果用戶已經(jīng)打開了 N 個頁面,然后再安裝插件,這 N 個頁面除非重新刷新,否則是不會加載 manifest 聲明的 content_scripts。安裝插件之后新打開的頁面是可以加載 content_scripts 的。
所以需要在用戶點擊插件圖標時,探測頁面中的 content_scripts 是否存在(發(fā)送消息是否有響應/出錯),再提示用戶刷新頁面。
程序注入
還可以使用程序動態(tài)注入腳本,代碼如下:
chrome.tabs.executeScript({
  file: "content.js",
});比如用戶點擊插件圖標時執(zhí)行注入腳本,則無需刷新頁面,代碼如下:
// 監(jiān)聽插件圖標點擊事件
chrome.browserAction.onClicked.addListener(() => {
chrome.tabs.executeScript({
file: 'content.js',
});
});
值得注意的是,采用以上方式,用戶每次點擊插件圖標時,content.js 都會被執(zhí)行,可能會引起錯誤。
// content.js
let loaded = false;
if (!loaded) {
// do something
loaded = true;
}
console.log(loaded);
第一次執(zhí)行 content.js 會打印 false,而第二次執(zhí)行 content.js 則會報錯,提示 loaded 變量已經(jīng)聲明了。
由此可見 content.js 的執(zhí)行會影響其所在的沙盒。
我們可以這么做:
// content.js
if (!window.contentLoaded) {
// do something
window.contentLoaded = true;
}
console.log(window.contentLoaded);
使用沙盒內(nèi)的全局變量則可以避免 content.js 重復執(zhí)行帶來的問題。
綜上所述:聲明式只會注入一次,缺點是可能需要刷新頁面。程序式不需要刷新頁面,缺點是可能會注入多次。
permissions
該字段是一個字符串數(shù)組,用來聲明插件需要的權(quán)限,這樣才能調(diào)用某些 chrome API,常見的有:
- tabs
 - activeTab
 - contextMenus:網(wǎng)頁右鍵菜單,browser_action 右鍵菜單
 - cookies:操作 cookie,和用戶登錄態(tài)相關(guān)的功能可能會用到該權(quán)限
 - storage:插件存儲,不是 localStorage
 - web_accessible_resources:網(wǎng)頁能訪問的插件內(nèi)部資源,比如插件提供 SDK 給頁面使用,如 ethereum 的 metamask 錢包插件。或者是修改 DOM 結(jié)構(gòu)用到了插件的樣式、圖片、字體等資源。
 
permissions 中還可以聲明多個 url patterns,表示插件需要訪問這些 url,比如和 API 通信。
background script
下文簡稱 background,可以理解它是在一個隱藏的 tab 中執(zhí)行,所在的頁面域名為空,這會影響對 document.cookie 的使用。
比如 background 需要和 a.com 通信。首先應該把 *://*.a.com/* 加入到 manifest 的 permissions 數(shù)組中。
當發(fā)送網(wǎng)絡請求時,瀏覽器會自動帶上 a.com 的 cookie,服務器的 set-cookie 也會對瀏覽器生效。這是符合預期的。
但是讀取 document.cookie 時,由于 background 所在的域名為空,a.com 被認為是第三方 cookie,會讀取不到。所以需要使用 chrome.cookies API 來讀取 cookie。
background 設置 document.cookie 時,不能指定域名,否則會設置失敗。比如:
// 會失敗,因為指定的域名和 background 所在的域名不符
document.cookie = `session=xxxxxxx; domain=a.com; max-age=9999999999; path=/`;
// 正確的做法,不要指定域名
document.cookie = `session=xxxxxxx; max-age=9999999999`;
一般不需要這么操作 cookie,但是可能依賴的 npm 包會操作 document.cookie,所以這里說明一下。
background 使用 tabs 接口操作瀏覽器的 tab 窗口,比如:
// 打開新 tab
async function open(url: string): Promise<number> {
return new Promise((resolve) => {
chrome.tabs.create(
{
url,
},
(tab) => resolve(tab.id!)
);
});
}
// 獲取活躍的 tab,通常是用戶正在瀏覽的頁面
async function getActiveTab(): Promise<chrome.tabs.Tab | null> {
return new Promise((resolve) => {
chrome.tabs.query(
{
active: true,
currentWindow: true,
},
(tabs) => {
if (tabs.length > 0) {
resolve(tabs[0]);
} else {
resolve(null);
}
}
);
});
}
// 將指定的 tab 變成活躍的
async function activate(
tabId?: number,
url?: string
): Promise<number | undefined> {
if (typeof tabId === "undefined") {
return tabId;
}
// firefox 不支持 selected 參數(shù)
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update#parameters
const options: chrome.tabs.UpdateProperties = IS_FIREFOX
? { active: true }
: { selected: true };
if (url) {
options.url = url;
}
return new Promise((resolve) => {
chrome.tabs.update(tabId, options, () => resolve(tabId));
});
}
// 打開新窗口,或者是激活窗口
async function openOrActivate(url: string): Promise<number> {
const pattern = getUrlPattern(url);
return new Promise<number>((resolve) => {
chrome.tabs.query(
{
url: pattern,
},
(tabs) => {
if (tabs.length > 0 && tabs[0].id) {
return Tabs.activate(tabs[0].id);
} else {
this.open(url).then((id) => resolve(id));
}
}
);
});
}
content scripts
下文簡稱 content,它只能使用有限的 chrome API。
由于 content 可以訪問 DOM,可以用它來選擇、修改、刪除、增加網(wǎng)頁元素。
但是 content 是運行在隔離的空間(類似沙盒),所以如果需要和頁面的其他腳本通信,需要采用 window.postMessage 的方式。
比如頁面內(nèi)容如下:
<!-- index.html -->
<html>
<body>
<div id="app"></div>
<button id="btn" type="button">submit</button>
</body>
<script>
window.globalData = {
userId: 12345,
};
</script>
</html>
content 內(nèi)容如下:
// 成功
document.getElementById("app").innerHTML = "hello chrome";
// window.globalData 是 undefined
console.log(window.globalData);
資源注入
content 可以向頁面中注入 <script>,由此給頁面提供 SDK 等功能,注入的腳本和頁面自己的腳本一樣,都無法和 content 直接通信。
注意:注入的資源要先在 menifest 的 web_accessible_resources 字段中聲明。
// content 內(nèi)容
const script = document.createElement("script");
script.src = chrome.runtime.getURL("sdk.js");
document.body.appendChild(script);
// sdk.js
window.jsbridge = {
version: "1.0.1",
// ...
};
content 執(zhí)行之后,可以看到頁面結(jié)構(gòu)多了個 <script src="chrome-extension://xxxxxxxxxxxxx/sdk.js"></script>,xxxxxxxx 表示插件的 id,由 chrome 生成。
注意,注入的 sdk.js 腳本是可以被頁面內(nèi)其他腳本訪問到的(可以看作是頁面自己的腳本,只是 origin 是 chrome-extensions://xxxxxxxxxxxxx),如下:
document.getElementById("btn").addEventListener(
  "click",
  () => {
    console.log(window.jsbridge.version);
  },
  false
);通信
content 可以和 background、popup、options 使用 chrome API 通信,參考官方文檔:https://developer.chrome.com/docs/extensions/mv2/background_pages/
常用的通信 API 是 chrome.runtime.sendMessage。
UI
content 可以向頁面中注入 UI,比如 evernote 的剪輯插件。

前面提到過,點擊 popup 之外的區(qū)域會導致 popup 收起,操作 DOM 會導致 popup 隱藏,而 popup 無法用代碼主動打開,所以 evernote 的剪輯插件的 UI 就無法用 popup 來實現(xiàn)了。
這時候可以把 UI 作為 iframe 插入頁面,比如:
// content
const app = document.createElement("iframe");
app.src = chrome.runtime.getURL("app.html");
document.body.appendChild(app);
神奇的是 iframe 里的 javascript 是可以像 content 一樣和 background 通信的。
background 給 iframe 發(fā)送消息時,不僅需要指定所在 tab 的 id,還需要指定 iframe 的 id。這里說的 iframe id 類似 tab id,是 chrome 分配的,而不是 iframe 標簽的 id 屬性。
功能頁面
popup/options 和 background 的關(guān)系很親密,它們甚至可以通過 chrome.extension.getBackgroundPage()? 獲取到 background 的全局變量。所以它們直接的通信花樣很多,不過一般也是用 chrome.runtime 通信。
popup/options 和 content 之間的通信方式,可以 background -> content 通信類似。
options 用來設置插件,所以一般需要調(diào)用 chrome.storage 存儲配置。
適配其他瀏覽器
目前 chrome 插件適配工作量是比較小的,因為 edge、opera 都已經(jīng)切換到 chromium 內(nèi)核,firefox 也支持 chrome API。
不過需要查看用到的 API 是否支持,以及 API 的入?yún)ⅰ⒊鰠⑹欠褚恢?。比如前文提?firefox chrome.tabs.update 方法第一個參數(shù)不支持 selected 屬性。
firefox 還支持 browser API,和 chrome API 不同的是 browser API 不使用回調(diào)函數(shù),而是返回 promise。比如:
browser.tabs.query({ currentWindow: true }).then((res) => console.log(res));
chrome.tabs.query({ currentWindow: true }, (res) => {
  console.log(res);
});可以參考各瀏覽器的開發(fā)文檔:
- firefox: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Build_a_cross_browser_extension
 - edge: https://docs.microsoft.com/zh-cn/microsoft-edge/extensions-chromium/developer-guide/port-chrome-extension
 - 360: http://open.se.#/open/extension_dev/overview.html
 - 搜狗: http://ie.sogou.com/open/doc/
 
發(fā)布
- chrome 發(fā)布插件需要花費 5 美元開通賬號:https://developer.chrome.com/docs/webstore/register/
 - firefox 發(fā)布文檔:https://addons.mozilla.org/en-US/developers/
 - edge:https://docs.microsoft.com/zh-cn/microsoft-edge/extensions-chromium/publish/create-dev-account
 
總結(jié)
總體來說,chrome 插件開發(fā)對前端工程師來說還是比較容易的。















 
 
 








 
 
 
 