一般來(lái)說(shuō),插件的原理是向頁(yè)面中注入 javascript 腳本,對(duì)頁(yè)面進(jìn)行處理,比如屏蔽頁(yè)面中可能的廣告元素,改變某些元素的樣式,增加一些 UI。
開(kāi)發(fā)插件需要使用前端技術(shù):html css javascript。
本文就從入門(mén)開(kāi)始講述如何開(kāi)發(fā)一款 chrome 插件。
注意:chrome 插件機(jī)制本身也在更新,本文講述的是目前普遍使用的 V2 插件的開(kāi)發(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:相當(dāng)于插件的 meta 信息,包含插件的名稱(chēng)、版本號(hào)、圖標(biāo)、腳本文件名稱(chēng)等,這個(gè)文件是每個(gè)插件都必須提供的,其他幾部分都是可選的。
background script:可以調(diào)用全部的 chrome 插件 API,實(shí)現(xiàn)跨域請(qǐng)求、網(wǎng)頁(yè)截屏、彈出 chrome 通知消息等功能。相當(dāng)于在一個(gè)隱藏的瀏覽器頁(yè)面內(nèi)默默運(yùn)行。
功能頁(yè)面:包括點(diǎn)擊插件圖標(biāo)彈出的頁(yè)面(簡(jiǎn)稱(chēng) popup)、插件的配置頁(yè)面(簡(jiǎn)稱(chēng) options)。
content script:早期也被稱(chēng)為 injected script,是插件注入到頁(yè)面的腳本,但是不會(huì)體現(xiàn)在頁(yè)面 DOM 結(jié)構(gòu)里。content script 可以操作 DOM,但是它和頁(yè)面其他的腳本是隔離的,訪問(wèn)不到其他腳本定義的變量、函數(shù)等,相當(dāng)于運(yùn)行在單獨(dú)的沙盒里。content script 可以調(diào)用有限的 chrome 插件 API,網(wǎng)絡(luò)請(qǐng)求收到同源策略限制。
插件的架構(gòu)可以參考:https://developer.chrome.com/docs/extensions/mv2/architecture-overview/
重點(diǎn)說(shuō)明以下幾點(diǎn):
- browser action 和 page action:這倆我們可以理解為插件的按鈕。browser action 會(huì)固定在 chrome 的工具欄。而 page action 可以設(shè)置特定的網(wǎng)頁(yè)才顯示圖標(biāo),在地址欄的右端,如下圖:
大部分插件點(diǎn)擊之后會(huì)顯示 UI,也就是上文描述的插件功能頁(yè)面部分,一般稱(chēng)為 popup 頁(yè)面,如下圖:
popup 無(wú)法通過(guò)程序打開(kāi),只能由用戶(hù)點(diǎn)擊打開(kāi)。點(diǎn)擊 popup 之外的區(qū)域會(huì)導(dǎo)致 popup 收起。
page action 和 browser action 分別由 manifest.json 的 page_action 和 browser_action 字段配置。
- 由于 content script 受到同源策略的限制,所以一般網(wǎng)絡(luò)請(qǐng)求都交給 background script 處理。
- content script、插件功能頁(yè)面、background script 之間的通信架構(gòu)如下圖:
chrome 可以打開(kāi)多個(gè)瀏覽器窗口,而一個(gè)窗口會(huì)有多個(gè) tab,所以插件的結(jié)構(gòu)大致如下:
如上圖,功能頁(yè)面是每個(gè) window 一份,但是每個(gè) tab 都會(huì)注入 content script。
manifest.json
下文簡(jiǎn)稱(chēng) manifest ,其中有這么幾個(gè)字段可以重點(diǎn)說(shuō)明:
content_scripts
content_scripts 可以使用以下兩種方式注入頁(yè)面,這兩種方式并不沖突,可以結(jié)合使用。
聲明式注入
舉例如下:
{
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_idle",
"js": ["content.js"]
}
]
}
在 manifest 中聲明要加載的腳本,各個(gè)字段都比較直觀。其中:
- matches 表示頁(yè)面 url 匹配時(shí)才加載
- run_at? 表示在什么時(shí)機(jī)加載,一般是 document_idle,避免 content_scripts 影響頁(yè)面加載性能。
需要注意的是,如果用戶(hù)已經(jīng)打開(kāi)了 N 個(gè)頁(yè)面,然后再安裝插件,這 N 個(gè)頁(yè)面除非重新刷新,否則是不會(huì)加載 manifest 聲明的 content_scripts。安裝插件之后新打開(kāi)的頁(yè)面是可以加載 content_scripts 的。
所以需要在用戶(hù)點(diǎn)擊插件圖標(biāo)時(shí),探測(cè)頁(yè)面中的 content_scripts 是否存在(發(fā)送消息是否有響應(yīng)/出錯(cuò)),再提示用戶(hù)刷新頁(yè)面。
程序注入
還可以使用程序動(dòng)態(tài)注入腳本,代碼如下:
chrome.tabs.executeScript({
file: "content.js",
});
比如用戶(hù)點(diǎn)擊插件圖標(biāo)時(shí)執(zhí)行注入腳本,則無(wú)需刷新頁(yè)面,代碼如下:
// 監(jiān)聽(tīng)插件圖標(biāo)點(diǎn)擊事件
chrome.browserAction.onClicked.addListener(() => {
chrome.tabs.executeScript({
file: 'content.js',
});
});
值得注意的是,采用以上方式,用戶(hù)每次點(diǎn)擊插件圖標(biāo)時(shí),content.js 都會(huì)被執(zhí)行,可能會(huì)引起錯(cuò)誤。
// content.js
let loaded = false;
if (!loaded) {
// do something
loaded = true;
}
console.log(loaded);
第一次執(zhí)行 content.js 會(huì)打印 false,而第二次執(zhí)行 content.js 則會(huì)報(bào)錯(cuò),提示 loaded 變量已經(jīng)聲明了。
由此可見(jiàn) content.js 的執(zhí)行會(huì)影響其所在的沙盒。
我們可以這么做:
// content.js
if (!window.contentLoaded) {
// do something
window.contentLoaded = true;
}
console.log(window.contentLoaded);
使用沙盒內(nèi)的全局變量則可以避免 content.js 重復(fù)執(zhí)行帶來(lái)的問(wèn)題。
綜上所述:聲明式只會(huì)注入一次,缺點(diǎn)是可能需要刷新頁(yè)面。程序式不需要刷新頁(yè)面,缺點(diǎn)是可能會(huì)注入多次。
permissions
該字段是一個(gè)字符串?dāng)?shù)組,用來(lái)聲明插件需要的權(quán)限,這樣才能調(diào)用某些 chrome API,常見(jiàn)的有:
- tabs
- activeTab
- contextMenus:網(wǎng)頁(yè)右鍵菜單,browser_action 右鍵菜單
- cookies:操作 cookie,和用戶(hù)登錄態(tài)相關(guān)的功能可能會(huì)用到該權(quán)限
- storage:插件存儲(chǔ),不是 localStorage
- web_accessible_resources:網(wǎng)頁(yè)能訪問(wèn)的插件內(nèi)部資源,比如插件提供 SDK 給頁(yè)面使用,如 ethereum 的 metamask 錢(qián)包插件。或者是修改 DOM 結(jié)構(gòu)用到了插件的樣式、圖片、字體等資源。
permissions 中還可以聲明多個(gè) url patterns,表示插件需要訪問(wèn)這些 url,比如和 API 通信。
background script
下文簡(jiǎn)稱(chēng) background,可以理解它是在一個(gè)隱藏的 tab 中執(zhí)行,所在的頁(yè)面域名為空,這會(huì)影響對(duì) document.cookie 的使用。
比如 background 需要和 a.com 通信。首先應(yīng)該把 *://*.a.com/* 加入到 manifest 的 permissions 數(shù)組中。
當(dāng)發(fā)送網(wǎng)絡(luò)請(qǐng)求時(shí),瀏覽器會(huì)自動(dòng)帶上 a.com 的 cookie,服務(wù)器的 set-cookie 也會(huì)對(duì)瀏覽器生效。這是符合預(yù)期的。
但是讀取 document.cookie 時(shí),由于 background 所在的域名為空,a.com 被認(rèn)為是第三方 cookie,會(huì)讀取不到。所以需要使用 chrome.cookies API 來(lái)讀取 cookie。
background 設(shè)置 document.cookie 時(shí),不能指定域名,否則會(huì)設(shè)置失敗。比如:
// 會(huì)失敗,因?yàn)橹付ǖ挠蛎?background 所在的域名不符
document.cookie = `session=xxxxxxx; domain=a.com; max-age=9999999999; path=/`;
// 正確的做法,不要指定域名
document.cookie = `session=xxxxxxx; max-age=9999999999`;
一般不需要這么操作 cookie,但是可能依賴(lài)的 npm 包會(huì)操作 document.cookie,所以這里說(shuō)明一下。
background 使用 tabs 接口操作瀏覽器的 tab 窗口,比如:
// 打開(kāi)新 tab
async function open(url: string): Promise<number> {
return new Promise((resolve) => {
chrome.tabs.create(
{
url,
},
(tab) => resolve(tab.id!)
);
});
}
// 獲取活躍的 tab,通常是用戶(hù)正在瀏覽的頁(yè)面
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));
});
}
// 打開(kāi)新窗口,或者是激活窗口
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
下文簡(jiǎn)稱(chēng) content,它只能使用有限的 chrome API。
由于 content 可以訪問(wèn) DOM,可以用它來(lái)選擇、修改、刪除、增加網(wǎng)頁(yè)元素。
但是 content 是運(yùn)行在隔離的空間(類(lèi)似沙盒),所以如果需要和頁(yè)面的其他腳本通信,需要采用 window.postMessage 的方式。
比如頁(yè)面內(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 可以向頁(yè)面中注入 <script>,由此給頁(yè)面提供 SDK 等功能,注入的腳本和頁(yè)面自己的腳本一樣,都無(wú)法和 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í)行之后,可以看到頁(yè)面結(jié)構(gòu)多了個(gè) <script src="chrome-extension://xxxxxxxxxxxxx/sdk.js"></script>,xxxxxxxx 表示插件的 id,由 chrome 生成。
注意,注入的 sdk.js 腳本是可以被頁(yè)面內(nèi)其他腳本訪問(wèn)到的(可以看作是頁(yè)面自己的腳本,只是 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 可以向頁(yè)面中注入 UI,比如 evernote 的剪輯插件。
前面提到過(guò),點(diǎn)擊 popup 之外的區(qū)域會(huì)導(dǎo)致 popup 收起,操作 DOM 會(huì)導(dǎo)致 popup 隱藏,而 popup 無(wú)法用代碼主動(dòng)打開(kāi),所以 evernote 的剪輯插件的 UI 就無(wú)法用 popup 來(lái)實(shí)現(xiàn)了。
這時(shí)候可以把 UI 作為 iframe 插入頁(yè)面,比如:
// content
const app = document.createElement("iframe");
app.src = chrome.runtime.getURL("app.html");
document.body.appendChild(app);
神奇的是 iframe 里的 javascript 是可以像 content 一樣和 background 通信的。
background 給 iframe 發(fā)送消息時(shí),不僅需要指定所在 tab 的 id,還需要指定 iframe 的 id。這里說(shuō)的 iframe id 類(lèi)似 tab id,是 chrome 分配的,而不是 iframe 標(biāo)簽的 id 屬性。
功能頁(yè)面
popup/options 和 background 的關(guān)系很親密,它們甚至可以通過(guò) chrome.extension.getBackgroundPage()? 獲取到 background 的全局變量。所以它們直接的通信花樣很多,不過(guò)一般也是用 chrome.runtime 通信。
popup/options 和 content 之間的通信方式,可以 background -> content 通信類(lèi)似。
options 用來(lái)設(shè)置插件,所以一般需要調(diào)用 chrome.storage 存儲(chǔ)配置。
適配其他瀏覽器
目前 chrome 插件適配工作量是比較小的,因?yàn)?edge、opera 都已經(jīng)切換到 chromium 內(nèi)核,firefox 也支持 chrome API。
不過(guò)需要查看用到的 API 是否支持,以及 API 的入?yún)ⅰ⒊鰠⑹欠褚恢隆1热缜拔奶岬?firefox chrome.tabs.update 方法第一個(gè)參數(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);
});
可以參考各瀏覽器的開(kāi)發(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.360.cn/open/extension_dev/overview.html
- 搜狗: http://ie.sogou.com/open/doc/
發(fā)布
- chrome 發(fā)布插件需要花費(fèi) 5 美元開(kāi)通賬號(hào):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é)
總體來(lái)說(shuō),chrome 插件開(kāi)發(fā)對(duì)前端工程師來(lái)說(shuō)還是比較容易的。