Plasmo Framework:次世代的浏览器插件开发框架

3328次阅读  |  发布于2年以前

分享目标

缘起

最近团队在做的业务需要重度使用浏览器插件,所以有必要对浏览器插件进行全面的调研与实践,以了解其上限,并考虑将浏览器插件的开发与现有的 Web 工程化开发流程进行结合,提高开发的效率与幸福感,于是遇到了 Plasmo Framework -- 一个开发浏览器插件的工程化框架,本文将尝试介绍关于插件、插件开发、基于 Plasmo 的插件开发以及业务实践等相关内容。

阅读本文,你将学习到:

1 . 浏览器插件的 Why/What/How 等原理性的内容

2 . 了解传统浏览器插件的开发流程

3 . 了解 Plasmo Framework 的原理

4 . 了解 Plasmo Framework 框架引入之后的浏览器插件的开发流程

5 . 了解插件开发过程中的业务实践

6 . 更近一步,教你开发(可能是)人生中第一个插件:)

关于浏览器插件

注意:正文以 Chrome 插件为例进行讲解。

为什么需要浏览器插件?

早期的浏览器厂商有一个愿景,希望基于浏览器打造一个 Browser OS,浏览网页是 OS 的一类应用,使用 Browser 的扩展 API,构建更多的应用,或管理浏览网页的体验,或提供多个网页、应用之间进行交流的桥梁,也成为了浏览器厂商巩固自己地位,在激烈的浏览器大战中取得胜出的关键筹码。

想象一下今天的建筑在微信 OS 上的小程序、支付宝小程序,任何一个应用当集聚一定流量之后都希望用各种各样的 “手段” 留住用户,让用户高频次的打开自家应用,这就是为什么浏览器除了提供网页浏览体验之外,还希望提供个性化的 “浏览器插件” 这样的应用,就是希望浏览器插件可以成为一种新的 “Desktop App”(桌面端应用):

1 . 通过浏览器的流量积累

2 . 提供 “浏览器插件” 的 “应用” 方式

3 . 吸引开发者构建应用与提供原始 Web 开发技术栈的支持

4 . 为开发者提供应用分发的渠道 “Chrome Web Store”

5 . 提供浏览器插件可以触达用户的入口,用户可以方便的消费浏览器插件

6 . 各行各业浏览器插件涌现,与日益增长的用户形成良性的消费反馈循环

7 . 建立浏览器这一巨头应用的竞争壁垒

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

浏览器插件能干什么?

比较通俗一点:

image.png

Tab 管理

image.png

右键菜单栏

image.png

Devtools

image.png

搜索栏

定制新 Tab

参考 Chrome 插件官方提供的例子,可以对插件可以做的事情进行一个大致的归类,主要展示一些高频使用场景。

参考:https://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]

传统插件开发流程

上面我们提到插件分为两块:

image.png

所以一个传统的浏览器的插件开发流程如下:

image.png

上述的开发流程就和我们还没有引入前端工程化时期的开发流程很像,主要就是如下几个流程:

1 . 编写原生的 HTML/CSS/JavaScript,然后调用浏览器提供的插件 API 完成业务逻辑,通过 Git 管理开发代码

2 . 把在不同的环境使用不同的环境变量标志、调用不同的接口、获取不同的数据

3 . 因为没法使用 Node.js、没有包管理的概念,所以基本上只能手工测试,或者引入一些 UMD 的包测试框架进行测试

4 . 将插件目录文件夹打包发给用户进行验收测试,为了保持隐私,这里可能需要对代码进行一轮混淆

5 . 测试没问题,发布 PPE 进行测试,可以通过 CI/CD 来进行持续集成、交付

6 . PPE 没问题,发布线上进行测试,可以通过 CI/CD 来进行持续集成、交付

7 . 有问题进行回滚、Hotfix 等

传统插件开发 Quick Start

一份极简的插件开发代码如下:

目录结构如下:

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

该插件的地址参见:https://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 等命令

2 . 能够在使用主流前端框架、语言和 UI 库等,如 React、Redux、TypeScript、Tailwind、Ant Deisgn/Semi Design/Arco Design 等

3 . 内置最佳开发实践,一个插件开发的生命周期与各个模块能够以灵活、可扩展的方式提供出来

4 . 提供各种样例、开源代码库、有友好的开发者社区可以答疑解惑等等

5 . 能与现代 Web 工程化开发对齐,方便的整合进现有的 CI/CD 流程中

当然熟练掌握 Webpack/Parcel/Vite 的同学可能可以方便的搭建出上述的框架出来,而我们也是在权衡调研之后,发现了一个几乎解决了上述所有述求的开发框架:Plasmo Framework[36],甚至提供了比我们预期还要多得多的好用特性。

框架原理

Plasmo 是基于 Parcel 封装的一套脚手架,脚手架吸收了插件开发的最佳实践,并结合了现代 Web 前端工程化开发的最佳实践。

参考 Parcel:https://parceljs.org/recipes/web-extension/

Plasmo 的项目地址为:https://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:

【项目初始化】

在 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": "pnpm@6.23.6",
  "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": [
      "https://*/*"
    ]
  }
}

以下为写入到 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": ["https://*/*"],
  "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="https://www.plasmo.com" target="_blank">
          Plasmo
        </a>{" "}
        Extension!
      </h2>
      <input onChange={(e) => setData(e.target.value)} value={data} />
      <a href="https://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 的包

然后将插件发布到各家应用商店即可:

当然 Plasmo 还提供了 BPP、通过 Github Action 的 CI 工作流持续部署插件到各个商店,详情可以参考作者的文档:https://blog.plasmo.com/p/ext101-tut-0。

业务实践

使用 React + TypeScript 开发 UI[38]

参考:https://blog.plasmo.com/p/content-scripts-ui

在插件开发世界里有两种类型的 UI:

1 . Extension Page UI:存在于插件作用域下的 Web 页面,如 Popup Page UI、Option Page UI、Override Page UI 等

2 . 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 开发流程如下:

上诉流程繁冗且复杂,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

参考代码:

  • https://github.com/PlasmoHQ/examples/tree/main/with-content-scripts-ui
  • https://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 挂载的容器元素

2 . 在容器中创建一个 shadow DOM 的根节点

3 . 将 shadowDOM 根节点注入到主页面 Body 下

4 . 在 shadowDOM 根节点下创建一个 container 元素用于挂载 Virtual DOM(vDOM)

5 . 将 vDOM 挂载到 container 元素下

6 . 编写待挂载组件的逻辑,使用 TS(X)/Vue/Svelte 模板语法编写,将根组件渲染到此 container 元素下

7 . 设置打包工具,如 Webpack、Vite、Parcel 等将写的组件逻辑编译为单一 JS 文件

8 . 设置 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 />)
})

上述模板代码核心内容拆解如下:

认识到这一点之后,我们如果想要在运行时注入 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 提供各种维度的定制:

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]:

【~】[41]

当在源代码模块之外使用时,或在 data-base64data-texturlscheme 使用场景下时,~ 总是表示项目的根目录,也就是 package.json 所存在的那个目录,通常被使用在如下场景:

// package.json
{
  "manifest": {
    "action": {
        "default_icon": {
          "16": "~rulesets/icon16.png",
        },
        "default_popup": "popup.f4f22924.html"
      },
  }
}

~ 用于在一份源代码,如 tstsx 文件中,导入另外一份源代码,(tstsx 文件),它代表两层含义:

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>"
        ]
      }
    ]
  }
}

上述的资源会被构建到插件里:

除此之外,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

参考:https://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

2 . 如果此时处于录制中,点击 Action Button 就处理暂停逻辑

// background.ts

chrome.action.onClicked.addListener(tab => {
  if (isRecording) { // open 录制页面 }
  else { // 处理暂停逻辑 }
});

除此之外,还可以设置 BadgeBackground、BadgeText、Icon、Title、Popup。

开发实践心得

经过阶段性业务实践,目前有一定的积极性可以评估 Plasmo 适合作为插件开发长期演进方案。

但同时需要了解到,当你遇到问题时,考虑如下几种解决方案。

遇事不决:

当然,必要时可以啃一下 Plasmo Framework 的源码[50] :

参考资料

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

[2]动态改 Favicon: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/browserAction/set_icon_path

[3]页面背景颜色: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/browserAction/make_page_red

[4]添加右键菜单栏: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/contextMenus/global_context_search

[5]注入脚本: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/browserAction/print

[6]Tab 折叠: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/default_command_override

[7]新 Tab 展示页面重载: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/override/blank_ntp

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

[9]浏览器历史页面重载: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/history/historyOverride

[10]Github 地址: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/commands

[11]处理 Cookie: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/cookies

[12]处理 HTTP Headers: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/debugger/live-headers

[13]处理 JS 执行、暂停: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/debugger/pause-resume

[14]操作 Element 面板信息: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/devtools/panels/chrome-query

[15]Github 地址: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/notifications

[16]处理 OmniBox: https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/mv2-archive/api/omnibox/newtab_search

[17]clipboard: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_clipboard

[18]fileSystem: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_fileSystem

[19]storage: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_storage

[20]Github 地址: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/diff

[21]fileSystem: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_fileSystem

[22]storage: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_storage

[23]Github 地址: https://github.com/GoogleChrome/chrome-extensions-samples/tree/master/apps/samples/filesystem-access

[24]identity: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_identity

[25]Github 地址: https://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 地址: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/mini-code-edit

[30]代码编辑器: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/text-editor

[31]fileSystem: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_fileSystem

[32]storage: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps#_feature_storage

[33]Github 地址: https://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 地址: https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/apps/samples/rich-notifications

[36]Plasmo Framework: https://www.plasmo.com/

[37]Turborepo: https://turborepo.org/

[38]使用 React + TypeScript 开发 UI: https://blog.plasmo.com/p/content-scripts-ui

[39]Shadow DOM: https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM

[40]资源使用形式: https://docs.plasmo.com/workflows/faq#tilde-import-resolution

[41]【~】: https://docs.plasmo.com/workflows/faq#tilde-import-resolution

[42]【data-base64: 】: https://docs.plasmo.com/workflows/assets#importing-image-assets-inline

[43]【data-text:】: https://docs.plasmo.com/workflows/content-scripts-ui#getstyle

[44]【chrome.runtime.getURL】: https://github.com/PlasmoHQ/examples/tree/main/with-web-accessible-resources

[45]API 文档: https://developer.chrome.com/docs/extensions/reference/

[46]官方文档: https://docs.plasmo.com/

[47]例子: https://github.com/GoogleChrome/chrome-extensions-samples

[48]例子: https://github.com/PlasmoHQ/examples/tree/0cdf4d3608b574fffe6e662dfe1e2325ef109d0d

[49]Discord 社区: https://discord.com/invite/8rrxVYYtfd

[50]Plasmo Framework 的源码: https://github.com/PlasmoHQ/plasmo

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8