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團隊 收貨大廠一手好文章~