Plasmo Framework:次世代的瀏覽器外掛開發框架

語言: CN / TW / HK

       

分享目標

  • 有可能你生活、工作中很多場景可以通過開發一個瀏覽器外掛來解決,但是你不知道可以通過它來解決,也不瞭解瀏覽器外掛的能力上限,所以你就不會想到去實現,或者使用外掛去實現,本文希望從 “廣度”、“深度” 兩個方面著手,幫助你全面的認識瀏覽器外掛,並在合適的時候能夠想起它、使用它,提高工作效率!

  • 分享業務的開發實踐,因為 Plasmo 等框架對外掛開發做了一層抽象,那麼不太合理的抽象可能會導致一些 “抽象洩露”,隨之帶來開發的 “坑”,本文希望將這些坑也發掘出來,同時希望能夠思考,沉澱,希望形成可複用的知識,幫助後來者能夠更快,更進一步完成外掛開發的目標。

最近團隊在做的業務需要重度使用瀏覽器外掛,所以有必要對瀏覽器外掛進行全面的調研與實踐,以瞭解其上限,並考慮將瀏覽器外掛的開發與現有的 Web 工程化開發流程進行結合,提高開發的效率與幸福感,於是遇到了 Plasmo Framework -- 一個開發瀏覽器外掛的工程化框架,本文將嘗試介紹關於外掛、外掛開發、基於 Plasmo 的外掛開發以及業務實踐等相關內容。

閱讀本文,你將學習到:

  1. 瀏覽器外掛的 Why/What/How 等原理性的內容

  1. 瞭解傳統瀏覽器外掛的開發流程

  1. 瞭解 Plasmo Framework 的原理

  1. 瞭解 Plasmo Framework 框架引入之後的瀏覽器外掛的開發流程

  1. 瞭解外掛開發過程中的業務實踐

  1. 更近一步,教你開發(可能是)人生中第一個外掛:):smile_cat:

關於瀏覽器外掛

注意:正文以 Chrome 外掛 為例進行講解。

為什麼需要瀏覽器外掛?

早期的瀏覽器廠商有一個願景,希望基於瀏覽器打造一個 Browser OS,瀏覽網頁是 OS 的一類應用,使用 Browser 的擴充套件 API,構建更多的應用,或管理瀏覽網頁的體驗,或提供多個網頁、應用之間進行交流的橋樑,也成為了瀏覽器廠商鞏固自己地位,在激烈的瀏覽器大戰中取得勝出的關鍵籌碼。

想象一下今天的建築在微信 OS 上的小程式、支付寶小程式,任何一個應用當集聚一定流量之後都希望用各種各樣的 “手段” 留住使用者,讓使用者高頻次的開啟自家應用,這就是為什麼瀏覽器除了提供網頁瀏覽體驗之外,還希望提供個性化的 “瀏覽器外掛” 這樣的應用,就是希望瀏覽器外掛可以成為一種新的 “Desktop App”(桌面端應用):

  1. 通過瀏覽器的流量積累

  1. 提供 “瀏覽器外掛” 的 “應用” 方式

  1. 吸引開發者構建應用與提供原始 Web 開發技術棧的支援

  1. 為開發者提供應用分發的渠道 “Chrome Web Store”

  1. 提供瀏覽器外掛可以觸達使用者的入口,使用者可以方便的消費瀏覽器外掛

  1. 各行各業瀏覽器外掛湧現,與日益增長的使用者形成良性的消費反饋迴圈

  1. 建立瀏覽器這一巨頭應用的競爭壁壘

Chrome Web Store

image.png

瀏覽器外掛消費入口

image.png

外掛實際消費的效果(為頁面注入指令碼、UI)

當然瀏覽器巨頭的競爭,方便的是我們消費者,我們現在可以享受到各行各業的外掛應用帶來的效率與生產力的提升,甚至藉助外掛還可以獲取到很多整合類的訊息與諮詢,擴充了我們的視野。

什麼是瀏覽器外掛?

一句話解釋:滿足使用者打造個性化的瀏覽器體驗的一系列 “應用”,這些應用基於 Web 開發技術棧開發,可呼叫一系列外掛獨有的擴充套件 API,執行在安全的沙箱環境,開發出來之後可以上架到 Chrome Web Store,在瀏覽器側邊欄的外掛欄進行消費。

image.png

瀏覽器外掛、網頁、以及兩者之間的關係架構圖

其中圖中提到的 Background Script、Popup/Option/Override Page、Content Script 與 Web Page 圖示如下。

image.png

Background Script

image.png

Content Script

image.png

Web Page

Popup Page

image.png

Option Page

Override Page

瀏覽器外掛能幹什麼?

比較通俗一點:

  • 瀏覽器頁面能做的,外掛都能做

    • 因為外掛的 Content Script 與瀏覽器頁面共享 DOM,所以比如渲染頁面(HTML/CSS)、執行指令碼(JavaScript)、操作瀏覽器的 DOM、或通過操作 DOM 注入 JS 指令碼操作 BOM 等 API

  • 瀏覽器頁面不能做的,外掛也能做

    • runtime
      tabs
      cookie
      devtools
      
image.png

Tab 管理

image.png

右鍵選單欄

image.png

Devtools

image.png

搜尋欄

定製新 Tab

參考 Chrome 外掛官方提供的例子,可以對外掛可以做的事情進行一個大致的歸類,主要展示一些高頻使用場景。

參考:http://github.com/GoogleChrome/chrome-extensions-samples

外掛用途 使用的 API 外掛地址
書籤管理 -   bookmarks.create-   bookmarks.getTree-   bookmarks.remove-   bookmarks.update-   tabs.create-   ... Github 地址 [1]
瀏覽器頁面資訊管理 -   browserAction.onClicked-   browserAction.setIcon-   runtime.onInstalled-   storage.StorageArea.get-   storage.StorageArea.set-   ... Github 地址:1. 動態改 Favicon [2] 2.   頁面背景顏色 [3] 3.   新增右鍵選單欄 [4] 4.   注入指令碼 [5]
瀏覽器 Tab 管理 -   extension.getURL-   tabs.create-   tabs.update-   ... Github 地址:1. Tab 摺疊 [6] 2.   新 Tab 展示頁面過載 [7]
瀏覽歷史管理 -   history.deleteAll-   history.deleteUrl-   history.search-   ... Github 地址 [8] :1.   瀏覽器歷史頁面過載 [9]
快捷鍵管理 -   commands.onCommand-   ... Github 地址 [10]
網路管理 -   browserAction.onClicked-   cookies.getAll-   cookies.onChanged-   cookies.remove-   ... Github 地址:1. 處理 Cookie [11] 2.   處理 HTTP Headers [12]
除錯管理 -   browserAction.onClicked-   debugger.attach-   debugger.detach-   debugger.onEvent-   ... Github 地址:1. 處理 JS 執行、暫停 [13]
開發者工具欄管理 -   devtools.panels.ElementsPanel.createSidebarPane-   devtools.panels.ElementsPanel.onSelectionChanged-   ... Github 地址:1. 操作 Element 面板資訊 [14]
通知管理 -   notifications.create-   notifications.onButtonClicked-   ... Github 地址 [15]
搜尋欄管理 -   omnibox.onInputEntered-   tabs.create-   omnibox.onInputChanged-   omnibox.onInputEntered-   ... Github 地址:1. 處理 OmniBox [16]

舉例幾個可能和我們研發相關的外掛:

外掛介紹 使用 API 圖示 外掛地址
展示程式碼 Diff - clipboard [17] -   fileSystem [18] -   storage [19] Github 地址 [20]
讀取本地檔案系統 - fileSystem [21] storage [22] Github 地址 [23]
Github OAuth - identity [24] Github 地址 [25]
-   展示編輯器,進行程式碼編輯-   程式碼編輯器 - chrome.fileSystem [26] -   Runtime [27] -   Window [28] - Github 地址 [29] -   程式碼編輯器 [30]
圖片裁剪 - fileSystem [31] -   storage [32] Github 地址 [33]
進行各種瀏覽器通知 - Notification API documentation [34] Github 地址 [35]

傳統外掛開發流程

上面我們提到外掛分為兩塊:

  • 外掛域:整個瀏覽器生命週期只會存在一份

    • Popup/Option/Override Page:基於 Web 技術棧 HTML/CSS/JavaScript,可以呼叫外掛的 部分 API

    • Background Script:基於 JavaScript,執行在 Service Worker 中,可以呼叫外掛的 全部 API

  • 屬於外掛,但存在 Web 頁面的獨立域:和頁面相關,一個頁面的生命週期可以注入一到多個 Content Script

    • Content Script:基於 JavaScript 語法,與主頁面共享 DOM,可以呼叫外掛的 部分 API

image.png

所以一個傳統的瀏覽器的外掛開發流程如下:

image.png

上述的開發流程就和我們還沒有引入前端工程化時期的開發流程很像,主要就是如下幾個流程:

  1. 編寫原生的 HTML/CSS/JavaScript,然後呼叫瀏覽器提供的外掛 API 完成業務邏輯,通過 Git 管理開發程式碼

  1. 把在不同的環境使用不同的環境變數標誌、呼叫不同的介面、獲取不同的資料

  1. 因為沒法使用 Node.js、沒有包管理的概念,所以基本上只能手工測試,或者引入一些 UMD 的包測試框架進行測試

  1. 將外掛目錄資料夾打包發給使用者進行驗收測試,為了保持隱私,這裡可能需要對程式碼進行一輪混淆

  1. 測試沒問題,釋出 PPE 進行測試,可以通過 CI/CD 來進行持續整合、交付

  1. PPE 沒問題,釋出線上進行測試,可以通過 CI/CD 來進行持續整合、交付

  1. 有問題進行回滾、Hotfix 等

傳統外掛開發 Quick Start

一份極簡的外掛開發程式碼如下:

  • manifest.json
    background.js
    popup.html
    package.json
    
  • 包含 background.js ,執行在 Service Worker 中,呼叫外掛的 API,監聽定時器做定時喝水的通知提醒,然後監聽通知按鈕的點選進行續時
  • popup.html
    popup.js
    ON
    
  • 呼叫 chrome.notifications API 會在系統通知欄展示通知結果。

目錄結構如下:

.
├── background.js
├── drink_water128.png
├── drink_water16.png
├── drink_water32.png
├── drink_water48.png
├── manifest.json
├── popup.html
├── popup.js
└── stay_hydrated.png

該外掛的地址參見:http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/examples/water_alarm_notification

該外掛的程式碼如下:使用純 HTML/CSS/JavaScript 開發

manifest.json

{
"name": "Drink Water Event Popup",
"description": "Demonstrates usage and features of the event page by reminding user to drink water",
"version": "1.0",
"manifest_version": 3,
"permissions": [
"alarms",
"notifications",
"storage"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "Drink Water Event",
"default_popup": "popup.html"
},
"icons": {
"16": "drink_water16.png",
"32": "drink_water32.png",
"48": "drink_water48.png",
"128": "drink_water128.png"
}
}

background.js

'use strict';

chrome.alarms.onAlarm.addListener(() => {
chrome.action.setBadgeText({ text: '' });
chrome.notifications.create({
type: 'basic',
iconUrl: 'stay_hydrated.png',
title: 'Time to Hydrate',
message: 'Everyday I'm Guzzlin'!',
buttons: [
{ title: 'Keep it Flowing.' }
],
priority: 0
});
});

chrome.notifications.onButtonClicked.addListener(async () => {
const item = await chrome.storage.sync.get(['minutes']);
chrome.action.setBadgeText({ text: 'ON' });
chrome.alarms.create({ delayInMinutes: item.minutes });
});

popup.html

<!DOCTYPE html>
<html>
<head>
<title>Water Popup</title>
<style>
body {
text-align: center;
}

#hydrateImage {
width: 100px;
margin: 5px;
}

button {
margin: 5px;
outline: none;
}

button:hover {
outline: #80DEEA dotted thick;
}
</style>
<!--
- JavaScript and HTML must be in separate files
-->
</head>
<body>
<img src='./stay_hydrated.png' id='hydrateImage'>
<!-- An Alarm delay of less than the minimum 1 minute will fire
in approximately 1 minute increments if released -->
<button id="sampleMinute" value="1">Sample minute</button>
<button id="min15" value="15">15 Minutes</button>
<button id="min30" value="30">30 Minutes</button>
<button id="cancelAlarm">Cancel Alarm</button>
<script src="popup.js"></script>
</body>
</html>

popup.js

'use strict';

function setAlarm(event) {
let minutes = parseFloat(event.target.value);
chrome.action.setBadgeText({text: 'ON'});
chrome.alarms.create({delayInMinutes: minutes});
chrome.storage.sync.set({minutes: minutes});
window.close();
}

function clearAlarm() {
chrome.action.setBadgeText({text: ''});
chrome.alarms.clearAll();
window.close();
}

//An Alarm delay of less than the minimum 1 minute will fire
// in approximately 1 minute increments if released
document.getElementById('sampleMinute').addEventListener('click', setAlarm);
document.getElementById('min15').addEventListener('click', setAlarm);
document.getElementById('min30').addEventListener('click', setAlarm);
document.getElementById('cancelAlarm').addEventListener('click', clearAlarm);

關於 Plasmo Framework

工程化外掛開發流程

可以看到上述傳統外掛的開發流程,基本上使用原生的 HTML/CSS/JavaScript 技術棧,然後手工分發原始碼等方式完成包的部署,當然可以引入 CI/CD 來進行部署釋出等。

上述流程對於在極佳 DX 的前端工程化工具鏈的薰陶中的我們來說肯定是不符合我們現代化 Web 工程化開發的訴求的,我們期望的流程可能是如下這樣的:

image.png

我們希望:

  1. 能夠有腳手架,一鍵初始化專案,開啟專案開發伺服器(熱更新),構建可部署產物,提供如 Init/Dev/Build 等命令

  1. 能夠在使用主流前端框架、語言和 UI 庫等,如 React、Redux、TypeScript、Tailwind、Ant Deisgn/Semi Design/Arco Design 等

  1. 內建最佳開發實踐,一個外掛開發的生命週期與各個模組能夠以靈活、可擴充套件的方式提供出來

  1. 提供各種樣例、開原始碼庫、有友好的開發者社群可以答疑解惑等等

  1. 能與現代 Web 工程化開發對齊,方便的整合進現有的 CI/CD 流程中

當然熟練掌握 Webpack/Parcel/Vite 的同學可能可以方便的搭建出上述的框架出來,而我們也是在權衡調研之後,發現了一個幾乎解決了上述所有述求的開發框架: Plasmo Framework [36] ,甚至提供了比我們預期還要多得多的好用特性。

框架原理

Plasmo 是基於 Parcel 封裝的一套腳手架,腳手架吸收了外掛開發的最佳實踐,並結合了現代 Web 前端工程化開發的最佳實踐。

參考 Parcel:http://parceljs.org/recipes/web-extension/

Plasmo 的專案地址為:http://github.com/PlasmoHQ/plasmo

專案目錄結構如下:

.
├── cli // CLI、腳手架
│ ├── create-plasmo
│ │ └── src
│ └── plasmo
│ ├── i18n
│ ├── src
│ │ ├── commands
│ │ └── features
│ │ ├── extension-devtools
│ │ ├── extra
│ │ ├── helpers
│ │ └── manifest-factory
│ └── templates // 支援各種模板的渲染,如 React、Svelte、Vue3
│ └── static
│ ├── react17
│ ├── react18
│ ├── svelte3
│ └── vue3
├── examples // 例子
│ ├── with-ant-design
│ │ └── assets
│ ├── with-background
│ │ └── assets
├── extensions
│ ├── mice
│ │ ├── assets
│ │ ├── contents
│ │ ├── core
│ │ └── docs
│ └── world-edit
│ ├── assets
│ └── core
├── packages // 公共子包
│ ├── config
│ │ └── ts
│ ├── constants
│ │ └── manifest
│ ├── gcp-refresh-token
│ │ └── src
│ │ └── __snapshots__
│ ├── init // init 命令對應的執行邏輯
│ │ └── templates
│ │ └── assets
│ ├── parcel-bundler
│ │ └── src
│ ├── parcel-config
│ ├── parcel-namer-manifest
│ │ └── src
│ ├── parcel-packager
│ │ └── src
│ ├── parcel-resolver
│ │ └── src
│ ├── parcel-runtime
│ │ └── src
│ ├── parcel-transformer-inject-env
│ │ └── src
│ ├── parcel-transformer-manifest
│ │ ├── runtime
│ │ └── src
│ ├── parcel-transformer-svelte3
│ │ └── src
│ ├── parcel-transformer-vue3
│ │ └── src
│ ├── permission-ui
│ │ └── src
│ ├── prettier-plugin-sort-imports
│ │ └── src
│ │ ├── natural-sort
│ │ └── utils
│ ├── puro
│ │ └── src
│ ├── rps
│ │ └── src
│ │ └── core
│ ├── storage // 包裝的 Chrome Storage 的包,提供 React Hooks 版本
│ │ └── src
│ ├── use-hashed-state
│ │ └── src
│ └── utils
└── templates
└── qtt
└── src
└── __snapshots__

Plasmo 使用 Turborepo 來管理 Monrepo 專案,包管理使用 Pnpm。

Turborepo [37] 是一個為基於 JS/TS 的 Monrepo 設計高效能的構建系統。

其原理設計架構圖如下:

image.png

對應在 Plasmo Framework 背景下的外掛開發流程如下:

image.png

工程化外掛開發 Quick Start

【前置條件】

確保安裝了 Node.js、Pnpm、Git:

  • Node 安裝參見 http://nodejs.org/

  • Pnpm 安裝參見 http://pnpm.io/

  • Git 安裝參見:http://git-scm.com/

【專案初始化】

在 CLI 中執行如下命令建立一個 Plasmo 外掛專案,預設為 React 模板:

pnpm create plasmo

可以看到生成的目錄如下:

.
├── README.md
├── assets
│ └── icon512.png
├── node_modules
├── package.json
├── pnpm-lock.yaml
├── popup.tsx
└── tsconfig.json

【專案執行與應用效果檢視】

進入專案,執行 pnpm dev 命令,開啟開發伺服器:

cd hello-world
pnpm dev

接著開啟瀏覽器外掛管理頁面:chrome://extensions,選擇 build 產物,即可完成外掛的安裝與使用:

確保開啟開發者模式、點選載入已解壓的擴充套件程式、選擇 build/chrome-mv3-dev 外掛包。

image.png

載入成功之後就可以在管理面板看到對應的外掛:

image.png

然後在瀏覽器右上角外掛消費欄進行外掛消費:

image.png

【專案與程式碼分析】

可以看到這個外掛打開了一個 Popup 頁面,展示了標題、輸入框和按鈕,按鈕點選可以跳轉 Plasmo 的文件頁。

而外掛相關的 名稱等元資訊、需要特殊指定的 manifest 內容等則是在 package.json 中管理,後續 dev/build 時會自動提取這些元資訊,寫入到 manifest.json 中:

{
"name": "hello-world",
"displayName": "Hello world",
"version": "0.0.0",
"description": "A basic Plasmo extension.",
"author": "",
"packageManager": "[email protected]",
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build"
},
"dependencies": {
"plasmo": "0.52.4",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@plasmohq/prettier-plugin-sort-imports": "1.2.0",
"@types/chrome": "0.0.193",
"@types/node": "18.6.4",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6",
"prettier": "2.7.1",
"typescript": "4.7.4"
},
"manifest": {
"host_permissions": [
"http://*/*"
]
}
}

以下為寫入到 build/chrome-mv3-dev/manifest.json 中的內容:

{
"icons": {
"16": "icon16.bee5274e.png",
"48": "icon48.71d7523e.png",
"128": "icon128.a87b0594.png"
},
"manifest_version": 3,
"action": {
"default_icon": {
"16": "icon16.bee5274e.png",
"48": "icon48.71d7523e.png"
},
"default_popup": "popup.f4f22924.html"
},
"version": "0.0.0",
"name": "Hello world",
"description": "A basic Plasmo extension.",
"author": "",
"permissions": [],
"host_permissions": ["http://*/*"],
"content_security_policy": {
"extension_pages": "script-src 'self' http://localhost;object-src 'self';"
},
"web_accessible_resources": [
{ "matches": ["<all_urls>"], "resources": ["__parcel_hmr_proxy__"] }
]
}

外掛的 icon 自動識別 assets/icon512.png 圖片,然後在 dev/build 時進行處理,生成 16/48/128 三類格式,適配不同的使用場景。

專案中各種模組,如 popup 等則使用 React TypeScript 開發 UI、使用 TypeScript 撰寫指令碼,可以按照正常的 Web 工程化開發的方式進行檔案、資源的匯入使用。

以下是 popup.tsx 的內容,和我們平時撰寫的元件使用一模一樣:

import { useState } from "react"

function IndexPopup() {
const [data, setData] = useState("")

return (
<div
style={{
display: "flex",
flexDirection: "column",
padding: 16
}}>
<h2>
Welcome to your{" "}
<a href="http://www.plasmo.com" target="_blank">
Plasmo
</a>{" "}
Extension!
</h2>
<input onChange={(e) => setData(e.target.value)} value={data} />
<a href="http://docs.plasmo.com" target="_blank">
View Docs
</a>
</div>
)
}

export default IndexPopup

我們只需要遵守 Plasmo 內建的檔案命名、位置放置的規範、檔案匯入的規範,在 dev/build 時即可識別對應的檔案,生成 .plasmo 資料夾,然後將這些內容丟給 Parcel 進行構建,就可以生成符合預期、且可以在瀏覽器中執行的外掛 build 產物。

同時 Plasmo 提供了開發伺服器與熱更新,享受到改程式碼就可以實時獲取效果的便利。

當我們的外掛開發完成,就可以打包進行上架,只需要執行如下命令:

pnpm build --zip // 預設打 mv3 的包
pnpm build --target=firefox-mv2 --zip // 打相容 Firefox mv2 的包

然後將外掛釋出到各家應用商店即可:

  • Chrome Webstore: http://developer.chrome.com/docs/webstore/publish/

  • Edge Add-on: http://docs.microsoft.com/en-us/microsoft-edge/extensions-chromium/publish/publish-extension

  • Mozilla Firefox Add-on: http://extensionworkshop.com/documentation/publish/

當然 Plasmo 還提供了 BPP、通過 Github Action 的 CI 工作流持續部署外掛到各個商店,詳情可以參考作者的文件:http://blog.plasmo.com/p/ext101-tut-0。

業務實踐

使用 React + TypeScript 開發 UI [38]

參考:http://blog.plasmo.com/p/content-scripts-ui

在外掛開發世界裡有兩種型別的 UI:

  1. Extension Page UI:存在於外掛作用域下的 Web 頁面,如 Popup Page UI、Option Page UI、Override Page UI 等

  1. Extension Injected UI:注入到某個 Web 頁面下的 UI,如我們常見的在 Web 頁面裡面進行劃詞翻譯的外掛,你選中一個單詞,彈出對應的 “釋義” 框,這個“釋義” 框就是常見的 Extension Injected UI,在外掛裡的概念也叫 Content Script UI

常見的 Extenion Page UI 舉例如下:

Extension Page UI 的在下面三類中一個外掛只會有一個

Popup Page UI

Option Page UI

Override Page UI

常見的 Extension Injected UI 舉例如下:

Content Scripts 和主頁面共享 DOM,Extension Injected UI 每個頁面都會有一個,如果設定了對此頁面注入的話

Tango Extension

Loom Extension

Omni Extension

常規的上述 Page 開發流程如下:

  • Page UI

    • 建立 HTML 檔案,設定一個根節點,如 id="root" 用於渲染 Virtual DOM
    • 建立一段 JS/TS 邏輯,用於處理掛載到 id="root" 節點的邏輯
    • 編寫待掛載元件的邏輯,使用 TS(X)/Vue/Svelte 模板語法編寫

    • 設定打包工具,如 Webpack、Vite、Parcel 等將寫的元件邏輯編譯為單一 JS 檔案

    • 在 HTML 檔案裡面建立一個 script tag,然後指向上訴打包的檔案

    • 建立 manifest.json 檔案,將對應的入口指向 HTML 檔案
    • 傳統:寫 HTML,引入 JavaScript 與 CSS,只能支援瀏覽器支援的的寫法

    • 使用編譯工具:

  • Injected UI

    • 建立 shadow DOM 掛載的容器元素

    • 在容器中建立一個 shadow DOM 的根節點

    • 將 shadowDOM 根節點注入到主頁面 Body 下

    • 在 shadowDOM 根節點下建立一個 container 元素用於掛載 Virtual DOM(vDOM)

    • 將 vDOM 掛載到 container 元素下

    • 編寫待掛載元件的邏輯,使用 TS(X)/Vue/Svelte 模板語法編寫,將根元件渲染到此 container 元素下

    • 設定打包工具,如 Webpack、Vite、Parcel 等將寫的元件邏輯編譯為單一 JS 檔案

    • 設定 manifest.json 檔案的 content_scripts 陣列欄位,將這個檔案新增進去
    • 建立 Content Script,注入到主頁面

    • 通過共享的 DOM,建立 UI 元素

    • 新增 CSS 樣式

    • 設定一些操作響應邏輯

    • 為了防止樣式洩露,可能需要將內容建立在 iframe 或者 Shadow DOM [39]

    • 傳統:

    • 使用編譯工具:

上訴流程繁冗且複雜,Plasmo 為你做了一層抽象,使得你無需做任何的建立元素、掛載、編譯等流程,只需要在 Plasmo 工程下建立對應的 .tsx 檔案,編寫 React + TypeScript 的邏輯即可。

針對 Extension Page UI,如 popup.tsxoptions.tsx ,然後在檔案中 export default 對應的元件,Plasmo 會幫你自動完成上述 “使用編譯工具” 所需的全流程步驟:

// popup.tsx

function Popup() {
return <div>hello, plasmo popup</div>;
}

export default Popup;

針對 Injected UI,只需要建立 content.tsx 檔案,或者注入多份時( contents/<name>.tsx ),然後在檔案中 export default 對應的元件,Plasmo 會幫你自動完成上述 “使用編譯工具” 所需的全流程步驟:

// contents.tsx
function Content() {
return <div>hello, plasmo content</div>;
}

export default Content;

如果你只是想注入指令碼,那麼命名不需要下 x ,只需要 content.ts 即可。

上述的 Extension Page UI 和 Extension Injected UI 都屬於靜態的方式,即經過 Plasmo 編譯之後,會在 manifest.json 對應的欄位宣告,然後引入編譯後的這些檔案。

經過 build 之後,會形成如下目錄結構:

.
├── background.f44a92a3.js
├── common.49dcdc31.css
├── content.96c90f8e.js
├── manifest.json
├── popup.a51b985f.css
├── popup.c0bbeb4e.js
├── popup.f4f22924.html

對應的 manifest.json 如下:

// manifest.json
{
"action": {
// popup.tsx => popup.f4f22924.html
"default_popup": "popup.f4f22924.html"
},
// ...
"background": {
"service_worker": "background.f44a92a3.js",
"type": "module"
},
"content_scripts": [
// content.tsx => content.96c90f8e.js/common.49dcdc31.css
{
"matches": ["<all_urls>"],
"js": ["content.96c90f8e.js"],
"css": ["common.49dcdc31.css"],
"run_at": "document_end"
}
],
}

Runtime Injected UI:動態注入 Content Script UI

參考程式碼:

  • http://github.com/PlasmoHQ/examples/tree/main/with-content-scripts-ui

  • http://github.com/PlasmoHQ/plasmo/blob/main/cli/plasmo/templates/static/react18/content-script-ui-mount.tsx

在上一小節我們也提到了,宣告的 contents.tsx 在編譯之後實際上是宣告在 manifest.json 中,以靜態注入的方式注入到主頁面中,可以選擇 document_startdocument_enddocument_idle 邏輯,但是這就有一個限制,如果當我們的頁面已經載入完成,度過了上述的三個階段之後,我們才安裝外掛,此時就無法完成 Content Script UI 的注入。

為了解決這個問題,我們有必要重拾一下上述提到的 Content Script UI 的注入過程:

  1. 建立 shadow DOM 掛載的容器元素

  1. 在容器中建立一個 shadow DOM 的根節點

  1. 將 shadowDOM 根節點注入到主頁面 Body 下

  1. 在 shadowDOM 根節點下建立一個 container 元素用於掛載 Virtual DOM(vDOM)

  1. 將 vDOM 掛載到 container 元素下

  1. 編寫待掛載元件的邏輯,使用 TS(X)/Vue/Svelte 模板語法編寫,將根元件渲染到此 container 元素下

  1. 設定打包工具,如 Webpack、Vite、Parcel 等將寫的元件邏輯編譯為單一 JS 檔案

  1. 設定 manifest.json 檔案的 content_scripts 陣列欄位,將這個檔案新增進去

因為目前 Plasmo 只針對靜態的 Content Script UI 給了 Out-of-box 的方案,針對動態注入時,就需要手動實現這套方案,剖析 Plasmo 原始碼,參照上述的過程,我們可以實現動態注入。

參照 plasmo 提供的渲染模板: content-script-ui-mount.tsx

// @ts-nocheck
// prettier-sort-ignore
import React from "react"

import * as RawMount from "__plasmo_mount_content_script__"
import { createRoot } from "react-dom/client"

// Escape parcel's static analyzer
const Mount = RawMount

const MountContainer = () => {
// ...
return (
<div
id="plasmo-mount-container"
style={{
display: "flex",
position: "relative",
top,
left
}}>
<RawMount.default />
</div>
)
}

async function createShadowContainer() {
const container = document.createElement("div")

container.id = "plasmo-shadow-container"

container.style.cssText = `
z-index: 1;
position: absolute;
`

const shadowHost = document.createElement("div")

if (typeof Mount.getShadowHostId === "function") {
shadowHost.id = await Mount.getShadowHostId()
}

const shadowRoot = shadowHost.attachShadow({ mode: "open" })
document.body.insertAdjacentElement("beforebegin", shadowHost)

if (typeof Mount.getStyle === "function") {
shadowRoot.appendChild(await Mount.getStyle())
}

shadowRoot.appendChild(container)
return container
}

window.addEventListener("load", async () => {
const rootContainer =
typeof Mount.getRootContainer === "function"
? await Mount.getRootContainer()
: await createShadowContainer()

const root = createRoot(rootContainer)

root.render(<MountContainer />)
})

上述模板程式碼核心內容拆解如下:

  • __plasmo_mount_content_script__
    contents.tsx
    contents/<name>.tsx
    
  • 在靜態注入的背景下,在頁面 load 事件觸發時,將 Content Script UI 渲染到 rootContainer

認識到這一點之後,我們如果想要在執行時注入 Content Script UI,那麼只需要改動上述的邏輯即可。

src/injected 資料夾下建立 renderContent.tsx 檔案,將上述內容複製進去,然後修改對應的邏輯:

// 待渲染的 Content Script UI 指令碼
import * as RawMount from "../contents/content.tsx"
import { createRoot } from "react-dom/client"

// ...

// 將只在 load 事件觸發時執行改為可以在注入時動態呼叫
async function renderContent() {
const rootContainer =
typeof Mount.getRootContainer === 'function'
? await Mount.getRootContainer()
: await createShadowContainer();

const root = createRoot(rootContainer);

root.render(<MountContainer />);
}

renderContent();

接著我們在 background.ts 裡面監聽頁面 Tab 的啟用,在 Tab 啟用且已經載入完成的情況下,動態注入 Content Script UI:

import injectedContent from 'url:./injected/renderContent.tsx';

chrome.tabs.onActivated.addListener(activeInfo => {
const { tabId } = activeInfo;

chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (
!tabs?.[0]?.url?.includes('chrome://') &&
tabs?.[0]?.status === 'complete'
) {
// 這裡因為 background 與外掛的檔案目錄在一個目錄,所以不需要加 chrome-extensions://url 協議。
const arr = injectedContent.split('/');
const res = arr[arr.length - 1];

chrome.scripting.executeScript({
target: { tabId: tabs[0]?.id as number },
files: [res.split('?')[0]],
});
}
});
});

上述在獲取到 injectedContent 的路徑時還需要經過處理,這裡是因為 background 與外掛的檔案目錄在一個目錄,所以不需要加 chrome-extensions://xxx 等字首,只需要類似下面這樣的高亮的這一塊。

chrome-extension://gjfldhahgbflogekgjigjncfelbdecik/rewrite-ws.8250290b.js?1661074291141

Plasmo 非常智慧的一點就是,當我們以非常規字尾名進行檔案匯入時,會自動將檔案及其依賴編譯成為單一的 JS 檔案,然後插入進來。

如我們在上面的:

import injectedContent from 'url:./injected/renderContent.tsx';

則會將 renderContent.tsx 對應的檔案入口,將其所有的依賴樹進行分析、打包,成為單一可在瀏覽器中執行的程式碼形態。

這裡潛在的問題就是,如果有多份類似的 contents.tsx ,如 contents/<name>.tsx ,且每份檔案裡面是通過 import xx 語句進行匯入資源或依賴,Plasmo 依賴的 Parcel 會對每份資源進行一次構建,即可能多份 contents/<name>.tsx 依賴的同一資源會產生多份產出物。

常見的如 contents/a.tsx 裡面引用了一個 assets/logo.svgcontents/b.tsx 裡面也引用了一個 assets/logo.svg ,那麼最終產物裡會有兩個 assets/logo.svg ,且會生成不同的 hash 名稱,如 assets/logo.sasassa.svg

我們將在下一節討論如何解決這個問題。

Plasmo 支援對 Injected UI 提供各種維度的定製:

  • getMountPoint
  • getStyle
  • 修改 shadow-container 的樣式
  • getShadowHostId
  • getRootContainer

Injected UI 掛載到主頁面的結構如下:

<div>  <!-- getShadowHostId 改這個元素的 id --> 
#shadow-root (open)
<style></style> <!-- getStyle 與修改 shadow-container 都在這裡處理 -->
<!-- -->
<div id="plasmo-shadow-container" style="z-index: 1; position: absolute;">
<!-- getMountPoint 都在這裡處理-->
<div id="plasmo-mount-container" style="display: flex; position: relative; top: 0px; left: 0px;"></div>
</div>
</div>

其中 getRootContainer 則是自己完全重寫掛載 Injected UI 的邏輯,如是否需要建立 Shadow DOM、是否使用 getStyle 、是否使用 getShadowHostId 等邏輯:

async function createShadowContainer() {
const container = document.createElement('div');

container.id = 'plasmo-shadow-container';

container.style.cssText = `
z-index: 1;
position: absolute;
`;

const shadowHost = document.createElement('div');

if (typeof Mount.getShadowHostId === 'function') {
shadowHost.id = await Mount.getShadowHostId();
}

const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
document.body.insertAdjacentElement('beforebegin', shadowHost);

if (typeof Mount.getStyle === 'function') {
shadowRoot.appendChild(await Mount.getStyle());
}

shadowRoot.appendChild(container);
return container;
}

async function renderContent() {
const rootContainer =
typeof Mount.getRootContainer === 'function'
? await Mount . getRootContainer ()
: await createShadowContainer();

const root = createRoot(rootContainer);

root.render(<MountContainer />);
}

Plasmo 的檔案路徑那些事

在 Plasmo 的執行時下,為我們提供了幾種 資源使用形式 [40]

  • ~
  • url:
  • data-base64:
  • data-text:
  • chrome.runtime.getURL

【~】 [41]

當在原始碼模組之外使用時,或在 data-base64data-texturlscheme 使用場景下時, ~ 總是表示專案的根目錄,也就是 package.json 所存在的那個目錄,通常被使用在如下場景:

  • package.json
    manifest
    ~rulesets/test.json
    /rulesets/test.json
    
// package.json
{
"manifest": {
"action": {
"default_icon": {
"16": "~rulesets/icon16.png",
},
"default_popup": "popup.f4f22924.html"
},
}
}
  • 使用在 data-base64:~assets/image.png 中時,代表 /assets/image.png
  • 使用在 url:~src/code.js 中時,代表 /src/code.js

~ 用於在一份原始碼,如 tstsx 檔案中,匯入另外一份原始碼,( tstsx 檔案),它代表兩層含義:

  • 如果是預設設定, ~ 代表專案根目錄
  • src
    ~
    src
    ~core/code-module.tsx
    /src/core/code-module.tsx
    

url:

url:scheme 用於從 web-accessible resources 載入資源,例子如下:

import myJavascriptFile from "url:./path/to/my/file/something.js"

上述 something.js 會被 編譯 ,然後自動加到 manifest.json 對應的 web_accessible_resources 欄位中。

這裡著重標出了會被編譯,是一把雙刃劍,在大部分場景下,我們也需要檔案不被編譯也能使用,這會在 chrome.runtime.getURL 提到。

【data-base64: 】 [42]

將資源通過 base64 的方式內聯在原始碼裡:

import someCoolImage from "data-base64:~assets/some-cool-image.png"

<img src={someCoolImage} alt="Some pretty cool image" />

【data-text:】 [43]

以普通文字的方式載入內容,如載入 CSS 樣式:

import cssText from "data-text:~/contents/plasmo-overlay.css"

export const getStyle = () => {
const style = document.createElement("style")
style.textContent = cssText
return style
}

如果匯入的是 .scss.less 等,Plasmo 會對內容進行編譯,編譯成普通的 CSS 使用。

【chrome.runtime.getURL】 [44]

package.jsonmanifest 欄位宣告的資源會自動被複制到 Build 目錄, 並且不會被編譯 ,在程式碼裡可以通過 chrome.runtime.getURL 對這些資源進行引用:

// package.json
{
"manifest": {
"web_accessible_resources": [
{
"resources": [
"~raw.js",
"assets/pic*.png",
"resources/test.json"
],
"matches": [
"<all_urls>"
]
}
]
}
}

上述的資源會被構建到外掛裡:

  • raw.js 是存在於 package.json 所在的專案目錄
  • 在專案根目錄下任何匹配 assets/pic*.png 的檔案
  • 相對專案根目錄下的 resources/test.json 檔案

除此之外,Plasmo 還支援從 node_modules 匯入的檔案:

// package.json
{
"manifest": {
"web_accessible_resources": [
{
"resources": [
"~raw.js",
// ...
"@inboxsdk/core/pageWorld.js",
"@inboxsdk/core/background.js"
],
"matches": [
"<all_urls>"
]
}
]
}
}

上述 node_moudles 下的檔案也會被打包到外掛 Build 目錄下,可以通過 chrome.runtime.getURL 引用,且檔案不會被編譯。

執行時將 Content Script 注入到 Main World

參考:http://docs.plasmo.com/workflows/content-scripts#injecting-into-the-main-world

Content Script 實際是執行和主頁面指令碼隔離的環境裡,與主頁面指令碼共享 DOM,但是不共享作用域,比如在 Content Script 修改 window 物件是不生效的,也無法獲取到主頁面指令碼的 window 物件,然而 Chrome 提供了 chrome.scripting.executeScript 來給主頁面指令碼注入 Content Scripts,使得注入的指令碼可以操作 window

 chrome.scripting.executeScript(
{
target: {
tabId // the tab you want to inject into
},
world: "MAIN", // MAIN to access the window object
func: windowChanger // function to inject
// or
files: ['contents/app.js']
},
() => {
console.log("Background script got callback after injection")
}
)
}

上述 Runtime Injected UI 中我們在注入 Content Script UI 時沒有指明 world: MAIN 代表注入在 Content Script 仍然與主頁面指令碼隔離:

import injectedContent from 'url:./injected/renderContent.tsx';

chrome.tabs.onActivated.addListener(activeInfo => {
const { tabId } = activeInfo;

chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (
!tabs?.[0]?.url?.includes('chrome://') &&
tabs?.[0]?.status === 'complete'
) {
// 這裡因為 background 與外掛的檔案目錄在一個目錄,所以不需要加 chrome-extensions://url 協議。
const arr = injectedContent.split('/');
const res = arr[arr.length - 1];

chrome.scripting.executeScript({
target: { tabId: tabs[0]?.id as number },
files: [res.split('?')[0]],
});
}
});
});

執行時處理 Action Button

需要處理這段邏輯是因為我們的外掛期望是在 Popup Page UI 裡面點選開始錄製之後,如果沒有結束錄製,那麼此時再次點選外掛的 Action 按鈕期望是變成處理暫停與繼續錄製的功能,而並非繼續開啟 Popup Page UI。

參考 chrome.action.setPopup/openPopup ,發現外掛不支援動態設定點選 Action Button 是開啟 Popup Page UI 或不開啟 Popup Page UI,所以無法執行時設定 Popup Page UI 的顯影。

一個可行的思路,參考 Tango:

image.png

其實 Action Button 並沒有設定 Popup Page UI,而是作為一個控制按鈕:

  1. 如果此時不處於錄製中,點選 Action Button 就觸發錄製介面的 Content Script UI

  1. 如果此時處於錄製中,點選 Action Button 就處理暫停邏輯

// background.ts

chrome.action.onClicked.addListener(tab => {
if (isRecording) { // open 錄製頁面 }
else { // 處理暫停邏輯 }
});

除此之外,還可以設定 BadgeBackground、BadgeText、Icon、Title、Popup。

開發實踐心得

經過階段性業務實踐,目前有一定的積極性可以評估 Plasmo 適合作為外掛開發長期演進方案。

但同時需要了解到,當你遇到問題時,考慮如下幾種解決方案。

遇事不決:

  • 多看 Chrome 外掛的 API 文件 [45]

  • 多看 Plasmo 的 官方文件 [46]

  • 多看 Chrome 外掛的 例子 [47]

  • 多看 Plasmo 的 例子 [48]

  • 去 Plasmo 的 Discord 社群 [49] 提問

當然,必要時可以啃一下 Plasmo Framework 的原始碼 [50]

參考資料

[1]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/mv2-archive/api/bookmarks/basic

[2]

動態改 Favicon: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/browserAction/set_icon_path

[3]

頁面背景顏色: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/browserAction/make_page_red

[4]

新增右鍵選單欄: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/contextMenus/global_context_search

[5]

注入指令碼: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/browserAction/print

[6]

Tab 摺疊: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/default_command_override

[7]

新 Tab 展示頁面過載: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/override/blank_ntp

[8]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/browsingData/basic

[9]

瀏覽器歷史頁面過載: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/history/historyOverride

[10]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/commands

[11]

處理 Cookie: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/cookies

[12]

處理 HTTP Headers: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/debugger/live-headers

[13]

處理 JS 執行、暫停: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/debugger/pause-resume

[14]

操作 Element 面板資訊: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/devtools/panels/chrome-query

[15]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/notifications

[16]

處理 OmniBox: http://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/omnibox/newtab_search

[17]

clipboard: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_clipboard

[18]

fileSystem: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_fileSystem

[19]

storage: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_storage

[20]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/diff

[21]

fileSystem: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_fileSystem

[22]

storage: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_storage

[23]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/tree/master/apps/samples/filesystem-access

[24]

identity: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_identity

[25]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/github-auth

[26]

chrome.fileSystem: http://developer.chrome.com/apps/fileSystem.html

[27]

Runtime: http://developer.chrome.com/apps/app.runtime.html

[28]

Window: http://developer.chrome.com/apps/app.window.html

[29]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/mini-code-edit

[30]

程式碼編輯器: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/text-editor

[31]

fileSystem: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_fileSystem

[32]

storage: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_storage

[33]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/image-edit

[34]

Notification API documentation: http://developer.chrome.com/apps/notifications.html

[35]

Github 地址: http://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/rich-notifications

[36]

Plasmo Framework: http://www.plasmo.com/

[37]

Turborepo: http://turborepo.org/

[38]

使用 React + TypeScript 開發 UI: http://blog.plasmo.com/p/content-scripts-ui

[39]

Shadow DOM: http://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM

[40]

資源使用形式: http://docs.plasmo.com/workflows/faq#tilde-import-resolution

[41]

【~】: http://docs.plasmo.com/workflows/faq#tilde-import-resolution

[42]

【data-base64: 】: http://docs.plasmo.com/workflows/assets#importing-image-assets-inline

[43]

【data-text:】: http://docs.plasmo.com/workflows/content-scripts-ui#getstyle

[44]

【chrome.runtime.getURL】: http://github.com/PlasmoHQ/examples/tree/main/with-web-accessible-resources

[45]

API 文件: http://developer.chrome.com/docs/extensions/reference/

[46]

官方文件: http://docs.plasmo.com/

[47]

例子: http://github.com/GoogleChrome/chrome-extensions-samples

[48]

例子: http://github.com/PlasmoHQ/examples/tree/0cdf4d3608b574fffe6e662dfe1e2325ef109d0d

[49]

Discord 社群: http://discord.com/invite/8rrxVYYtfd

[50]

Plasmo Framework 的原始碼: http://github.com/PlasmoHQ/plasmo

- END -

:heart: 謝謝支援

以上便是本次分享的全部內容,希望對你有所幫助^_^

喜歡的話別忘了 分享、點贊、收藏 三連哦~。

歡迎關注公眾號 ELab團隊 收貨大廠一手好文章~