一般來說,插件的原理是向頁面中注入 javascript 腳本,對頁面進行處理,比如屏蔽頁面中可能的廣告元素,改變某些元素的樣式,增加一些 UI。
開發(fā)插件需要使用前端技術: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.
插件構成
chrome 插件通常由以下幾部分組成:
manifest.json:相當于插件的 meta 信息,包含插件的名稱、版本號、圖標、腳本文件名稱等,這個文件是每個插件都必須提供的,其他幾部分都是可選的。
background script:可以調用全部的 chrome 插件 API,實現跨域請求、網頁截屏、彈出 chrome 通知消息等功能。相當于在一個隱藏的瀏覽器頁面內默默運行。
功能頁面:包括點擊插件圖標彈出的頁面(簡稱 popup)、插件的配置頁面(簡稱 options)。
content script:早期也被稱為 injected script,是插件注入到頁面的腳本,但是不會體現在頁面 DOM 結構里。content script 可以操作 DOM,但是它和頁面其他的腳本是隔離的,訪問不到其他腳本定義的變量、函數等,相當于運行在單獨的沙盒里。content script 可以調用有限的 chrome 插件 API,網絡請求收到同源策略限制。
插件的架構可以參考:https://developer.chrome.com/docs/extensions/mv2/architecture-overview/
重點說明以下幾點:
- browser action 和 page action:這倆我們可以理解為插件的按鈕。browser action 會固定在 chrome 的工具欄。而 page action 可以設置特定的網頁才顯示圖標,在地址欄的右端,如下圖:
大部分插件點擊之后會顯示 UI,也就是上文描述的插件功能頁面部分,一般稱為 popup 頁面,如下圖:
popup 無法通過程序打開,只能由用戶點擊打開。點擊 popup 之外的區(qū)域會導致 popup 收起。
page action 和 browser action 分別由 manifest.json 的 page_action 和 browser_action 字段配置。
- 由于 content script 受到同源策略的限制,所以一般網絡請求都交給 background script 處理。
- content script、插件功能頁面、background script 之間的通信架構如下圖:
chrome 可以打開多個瀏覽器窗口,而一個窗口會有多個 tab,所以插件的結構大致如下:
如上圖,功能頁面是每個 window 一份,但是每個 tab 都會注入 content script。
manifest.json
下文簡稱 manifest ,其中有這么幾個字段可以重點說明:
content_scripts
content_scripts 可以使用以下兩種方式注入頁面,這兩種方式并不沖突,可以結合使用。
聲明式注入
舉例如下:
{
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_idle",
"js": ["content.js"]
}
]
}
在 manifest 中聲明要加載的腳本,各個字段都比較直觀。其中:
- matches 表示頁面 url 匹配時才加載
- run_at? 表示在什么時機加載,一般是 document_idle,避免 content_scripts 影響頁面加載性能。
需要注意的是,如果用戶已經打開了 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 變量已經聲明了。
由此可見 content.js 的執(zhí)行會影響其所在的沙盒。
我們可以這么做:
// content.js
if (!window.contentLoaded) {
// do something
window.contentLoaded = true;
}
console.log(window.contentLoaded);
使用沙盒內的全局變量則可以避免 content.js 重復執(zhí)行帶來的問題。
綜上所述:聲明式只會注入一次,缺點是可能需要刷新頁面。程序式不需要刷新頁面,缺點是可能會注入多次。
permissions
該字段是一個字符串數組,用來聲明插件需要的權限,這樣才能調用某些 chrome API,常見的有:
- tabs
- activeTab
- contextMenus:網頁右鍵菜單,browser_action 右鍵菜單
- cookies:操作 cookie,和用戶登錄態(tài)相關的功能可能會用到該權限
- storage:插件存儲,不是 localStorage
- web_accessible_resources:網頁能訪問的插件內部資源,比如插件提供 SDK 給頁面使用,如 ethereum 的 metamask 錢包插件?;蛘呤切薷?DOM 結構用到了插件的樣式、圖片、字體等資源。
permissions 中還可以聲明多個 url patterns,表示插件需要訪問這些 url,比如和 API 通信。
background script
下文簡稱 background,可以理解它是在一個隱藏的 tab 中執(zhí)行,所在的頁面域名為空,這會影響對 document.cookie 的使用。
比如 background 需要和 a.com 通信。首先應該把 *://*.a.com/* 加入到 manifest 的 permissions 數組中。
當發(fā)送網絡請求時,瀏覽器會自動帶上 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 參數
// 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,可以用它來選擇、修改、刪除、增加網頁元素。
但是 content 是運行在隔離的空間(類似沙盒),所以如果需要和頁面的其他腳本通信,需要采用 window.postMessage 的方式。
比如頁面內容如下:
<!-- index.html -->
<html>
<body>
<div id="app"></div>
<button id="btn" type="button">submit</button>
</body>
<script>
window.globalData = {
userId: 12345,
};
</script>
</html>
content 內容如下:
// 成功
document.getElementById("app").innerHTML = "hello chrome";
// window.globalData 是 undefined
console.log(window.globalData);
資源注入
content 可以向頁面中注入 <script>,由此給頁面提供 SDK 等功能,注入的腳本和頁面自己的腳本一樣,都無法和 content 直接通信。
注意:注入的資源要先在 menifest 的 web_accessible_resources 字段中聲明。
// content 內容
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í)行之后,可以看到頁面結構多了個 <script src="chrome-extension://xxxxxxxxxxxxx/sdk.js"></script>,xxxxxxxx 表示插件的 id,由 chrome 生成。
注意,注入的 sdk.js 腳本是可以被頁面內其他腳本訪問到的(可以看作是頁面自己的腳本,只是 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 來實現了。
這時候可以把 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 的關系很親密,它們甚至可以通過 chrome.extension.getBackgroundPage()? 獲取到 background 的全局變量。所以它們直接的通信花樣很多,不過一般也是用 chrome.runtime 通信。
popup/options 和 content 之間的通信方式,可以 background -> content 通信類似。
options 用來設置插件,所以一般需要調用 chrome.storage 存儲配置。
適配其他瀏覽器
目前 chrome 插件適配工作量是比較小的,因為 edge、opera 都已經切換到 chromium 內核,firefox 也支持 chrome API。
不過需要查看用到的 API 是否支持,以及 API 的入參、出參是否一致。比如前文提到 firefox chrome.tabs.update 方法第一個參數不支持 selected 屬性。
firefox 還支持 browser API,和 chrome API 不同的是 browser API 不使用回調函數,而是返回 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
總結
總體來說,chrome 插件開發(fā)對前端工程師來說還是比較容易的。