Chrome 插件開發指南和實踐

本文代碼:https://github.com/nyqykk/hello-extensions-react

閱讀本文你將瞭解到

  1. Chrome 插件整體架構;

  2. 如何開發一個 Chrome 插件(Popup 和 Devtools);

  3. 如何使用前端框架(React/Vue)進行開發;

  4. 如何調試插件;

  5. 如何使用 Puppeteer 對插件進行 E2E 測試(本地和 CI 環境)。

整體架構介紹

明確概念

Chrome 插件本質上就是一個特殊的 Web 頁面,在這個基礎上我們明確下文的稱謂:

manifest.json

Extensions start with their manifest and every extension requires a manifest.

每個擴展都始於一份 manifest 描述文件並且每個擴展都需要它。

https://developer.chrome.com/docs/extensions/mv3/manifest/

它就類似前端項目中的 package.json 文件,用於描述整個插件的架構和權限,類似下面這種結構,全部字段可以看官網。

{
  "name""Hello Extensions", // 名稱
  "description""An introductory tutorial", // 描述
  "version""1.0", // 插件的版本
  "manifest_version": 3, // 清單的版本,目前都是使用 V3

  // action 字段主要描述點擊右上角圖標彈出的頁面
  "action"{
    "default_popup""index.html", // 對應的入口 html 文件(Popup 在後面介紹)
    "default_title""Garfish Module",
    "default_icon"{
      "16""favicon.ico",
      "48""favicon.ico",
      "128""favicon.ico"
    }
  },

  // 當需要使用一些特殊 API 時需要在 permissions 聲明權限,會提示給用戶
  "permissions"["storage""scripting"],

  // 哪些域名允許使用插件
  "host_permissions"["<all_urls>"],

  // 聲明 background service worker 的路徑,在後面介紹
  "background"{
    "service_worker""background.js"
  },

  // 聲明 content script 的入口文件路徑、允許使用的域名以及執行時機
  "content_scripts"[{
    "js"["content.js"],
    "matches"["<all_urls>"],
    // 有 "document_start" "document_idle" "document_end" 三個值
    "run_at""document_idle"
  }]
}

content_script(內容腳本)

Content scripts are files that run in the context of web pages. By using the standard Document Object Model (DOM), they are able to read details of the web pages the browser visits, make changes to them, and pass information to their parent extension.

內容腳本是在目標頁面上下文中運行的文件。通過使用標準的文檔對象模型(Document Object Model,DOM),它們能夠讀取瀏覽器訪問的目標頁面的詳細信息,對它們進行更改,並將信息傳遞給它們的父擴展。

https://developer.chrome.com/docs/extensions/mv3/content_scripts/

content_script 本質上就是一個 js 文件,它可以使用插件特有的 API,同時可以操作目標頁面上下文中的 DOM,當你需要操作目標頁面的 DOM 時可以使用它,他的生命週期隨着插件的打開和關閉而開始和結束

但是請注意 content_script 有自己獨立的上下文,這就意味着它運行在類似沙盒的環境中,此時在 content_script 中修改全局變量,例如 window.a = 1 不會反映到目標頁面中,而是修改的沙盒中的 window 變量。

service_worker(background)

Events are browser triggers, such as navigating to a new page, removing a bookmark, or closing a tab. Extensions monitor these events using scripts in their background service worker, which then react with specified instructions.

瀏覽器觸發事件,例如導航到一個新頁面,刪除一個書籤,或關閉一個標籤。擴展使用後臺 service worker 監聽這些事件,並在觸發時執行回調。

https://developer.chrome.com/docs/extensions/mv3/service_workers/

service_worker 跑在一個單獨的線程,可以使用插件特有的 API,它本質上也是一個 js 文件,和 content_script 的區別在於:

瀏覽器插件右上角的小圖標點開時彈出的頁面稱爲 popup,其視圖本質上是一個 Web 頁面。

Devtools(調試工具)

A DevTools extension is structured like any other extension: it can have a background page, content scripts, and other items. In addition, each DevTools extension has a DevTools page, which has access to the DevTools APIs.

DevTools 擴展的結構與其他任何擴展一樣:它可以有一個背景頁面(service worker)、內容腳本和其他項目。此外,每個 DevTools 擴展都有一個 DevTools 頁面,該頁面可以訪問 DevTools API。

https://developer.chrome.com/docs/extensions/mv3/devtools/

Devtools 也是插件的一種形式,不同於 popup,它有一組 chrome.devtools 獨有的 API,當我們調用 chrome.devtools.panels.create 即可創建一個自定義的面板。

chrome.devtools.panels.create(
  // 擴展面板顯示名稱
  "DevPanel",
  // 擴展面板icon,並不展示
  "panel.png",
  // 擴展面板頁面
  "index.html",
  function (panel) {
    console.log("自定義面板創建成功!");
  }
);

像 Vue Devtools 和 React Devtools 都是這種形式,其視圖本質上就是一個 Web 頁面。

通信

完整的通信推薦查看:https://juejin.cn/post/7021072232461893639#heading-9

由於 content_script 和 service worker 獨立於插件頁面,所以時常有消息傳遞的需求,基本方式:

// 發送方 service worker || content_script
chrome.runtime.sendMessage(data)

// 接收方 content_script || service worker
chrome.runtime.onMessage.addListener(() ={})

但時常 content_script 會有多個,service_worker 只有一個,上述方式會通知所有的 content_script,導致出現問題,推薦指定發送到某一個 Tab 下的 content_script。

// 獲取當前活躍 Tab(活躍 Tab 概念可以看「明確概念」部分)
chrome.tabs.query({active: true}(tabs) ={
    chrome.tabs.sendMessage(tabs[0].id, response =>{
    console.log("background -> content script infos have been sended");        }
}

如何開發一個自己的插件

目錄結構

hello-extensions
├── background
│   └── index.js
├── index.html
├── index.js
├── manifest.json
├── package-lock.json
├── package.json
└── scripts
    └── index.js

配置 manifest.json

{
  "name""Hello Extensions",
  "description" : "Base Level Extension",
  "version""1.0",
  "manifest_version": 3,
  "action"{
    "default_title""Hello Extensions",
    "default_popup""index.html" // 指向入口 html 文件
  },
  "background"{
    "service_worker""background/index.js" // 指向一個 js 文件
  },
  "content_scripts"[{
    "matches"["<all_urls>"],
    "run_at""document_idle",
    "js"["scripts/index.js"] // 指向一個 js 文件
  }],
}
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta >
  <title>Document</title>
</head>
<body>
<div id="root">
  <input />
  <button>confirm</button>
</div>
<script src="./index.js"></script>
</body>
</html>

js 文件

// hello-extensions/index.js
console.log('i am index.js in html');

// hello-extensions/background/index.js
console.log('i am service worker');

// hello-extensions/scripts/index.js
console.log('i am content script');

至此一個最簡單的 popup 插件就已經完成,點擊右上角圖標即可打開 popup 面板。

開發 Devtools

Devtools 比較特殊,它需要在 manifest.json 中添加 devtools_page 字段指向一個入口 html 文件,在該文件中需要引入一段 js 來創建 devtools 面板,新的目錄結構如下:

├── background
│   └── index.js
├── devtools
│   └── index.js
├── devtools.html
├── index.html
├── index.js
├── manifest.json
├── package.json
└── scripts
    └── index.js
// manifest.json
"devtools_page""devtools.html"

Devtools 入口 html 文件 devtools.html 中需要引入一段 js 腳本來創建 devtools 面板。其實這個 devtools.html 個人覺得有些多餘,直接指向這個 js 腳本來創建面板就可以了,而不需要這個 html 文件。

<!-- devtools.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta >
  <title>Document</title>
</head>
<body>
<script src="./devtools/index.js"></script>
</body>
</html>
// devtools/index.js
// 創建擴展面板
chrome.devtools.panels.create(
  // 擴展面板顯示名稱
  "DevPanel",
  // 擴展面板icon,並不展示
  "panel.png",
  // 擴展面板頁面
  "../index.html",
  function (panel) {
    console.log("自定義面板創建成功!");
  }
);

然後安裝插件打開 F12 即可看到 Devtools 面板:

安裝

  1. 點擊 Chrome 右上角「管理擴展程序」;

  2. 打開右上角開發者模式;

  3. 左上角加載 hello-extensions 文件夾。

如何使用前端框架進行開發

在上述模式下開發會有什麼問題

所以我們需要使用熟悉的前端框架進行開發,這裏以 create-react-app 舉例(Vue 也差不多),創建一個 React 項目:

npx create-react-app hello-extensions-react
cd hello-extensions-react
npm install
npm run eject // 彈出 create-react-app 創建的模版項目的 webpack config 等配置

明確構建產物

  1. manifest.json 來描述插件整體架構和權限等;

  2. 固定名稱的 service worker、 content_script 和 devtools.js 腳本(不帶 hash),因爲配置在 manifest.json 中的名字是固定的(當然你可以寫插件在構建過程中動態修改,這不在本文討論範圍之內);

  3. 可以看到上面的例子中 service worker 和 content script 其實都沒有在插件頁面的入口 js 中被 import 引用(都只是描述在 manifest.json 中,由插件自行注入),所以在 webpack 等構建工具打包時生成的模塊依賴圖中不會包含這部分,所以我們需要單獨以它們爲入口文件進行打包

刪除冗餘部分後目錄結構如下:

├── README.md
├── config
│   ├── env.js
│   ├── getHttpsConfig.js
│   ├── jest
│   │   ├── babelTransform.js
│   │   ├── cssTransform.js
│   │   └── fileTransform.js
│   ├── modules.js
│   ├── paths.js // 一些路徑配置
│   ├── webpack
│   │   └── persistentCache
│   │       └── createEnvironmentHash.js
│   ├── webpack.config.js // webpack 配置
│   └── webpackDevServer.config.js
├── package.json
├── public
│   ├── devtools.html
│   ├── favicon.ico
│   ├── index.html
│   └── manifest.json
├── scripts
│   ├── build.js
│   ├── start.js
│   └── test.js
└── src
    ├── App.js
    ├── background
    │   └── index.js
    ├── content_scripts
    │   └── index.js
    ├── devtools
    │   └── index.js
    └── index.js

修改配置

  1. 在 public 文件夾下新增 manifest.json 文件,格式和上面「如何開發一個自己的插件」中一樣;

  2. 去掉 webpack.config.js 中生成 js 文件的 contenthash;

  3. 以 service worker、 content_scripts 和 devtools 爲入口進行多入口打包,同時由於 Devtools 需要一個默認的 html 頁面來引入打包後的 devtools.js(參考「如何開發一個自己的插件」),所以此時還需要配置 HtmlWebpackPlugin 多生成一份 html 文件,同時在其中引入 devtools.js 生成的 chunk

// paths.js
+ devtoolsHtml: resolveApp('public/devtools.html'),
+ devtools: resolveModule(resolveApp, 'src/devtools/index'),
+ background: resolveModule(resolveApp, 'src/background/index'),
+ content_script: resolveModule(resolveApp, 'src/content_scripts/index'),

// webpack.config.js
// dev 環境打包 service worker 等也沒用,因爲正常 web 頁面中不會使用到
entry: isEnvProduction ?
  {
    main: paths.appIndexJs,
    devtools: paths.devtools,
    background: paths.background,
    content_script: paths.content_script
  } :
  {
    main: paths.appIndexJs
  },

// 配置 HtmlWebpackPlugin
new HtmlWebpackPlugin(
  Object.assign(
    {},
    {
      inject: true,
      filename: 'index.html',
      template: paths.appHtml,
      chunks: ['main']
    }
  )
),
new HtmlWebpackPlugin(
  Object.assign(
    {},
    {
      inject: true,
      filename: 'devtools.html',
      template: paths.devtoolsHtml,
      chunks: ['devtools']
    }
  )
),

此時打包會報 eslint 的錯誤:

我們需要在根目錄添加 .eslintrc 文件來描述當前應用的目標產物是 extensions 也就是插件產物:

// .eslintrc
{
  "env"{
    "webextensions"true
  }
}

執行 npm run build 後將產物文件夾按上面「如何開發一個自己的插件」安裝即可(Devtools 沒出來關掉瀏覽器重試,比較玄學),後續就是正常的 Web 開發流程了,當然如果你想使用一些插件的 API 還是會報錯的,這樣只適合開發正常 Web 頁面邏輯,需要調試插件獨有的 API 還是需要 build 後安裝再進行調試。

如何調試插件

插件的調試稍微有點麻煩,分四個方面:

我們右鍵點擊右上角插件圖標 -> 審查彈出內容即可進入插件的 html 頁面中進行調試:

Devtools 的調試

F12 打開控制檯,選擇 Devtools 面板單獨打開瀏覽器進行調試:

然後在此頁面中 Mac 系統按住 Comand + Option + i(windows 筆者目前沒有,可以自行嘗試一下),即可打開 Devtools 的 Devtools(是的就是套娃),此時不僅可以調試自己的插件,還可以調試默認的 element、network 等面板,由此我們也可以得知類似 network 這種面板其實也是上述同樣的開發模式並且默認集成在 Chrome 中

content_script 調試

由於 content_script 是注入到目標頁面的,所以 F12 打開目標頁面的控制檯即可進行調試。

service worker 調試

進入到插件面板點擊 service worker 即可進行調試:

如何使用 Puppeteer 進行 E2E 測試

https://github.com/puppeteer/puppeteer

簡單介紹

Puppeteer 是一款瀏覽器自動化工具,可以幫助我們自動控制瀏覽器的行爲,例如打開指定 URL,點擊某個 button 等行爲。

如何測試

正常 E2E 測試需要跳轉到指定 URL 然後進行測試,Chrome 插件也不例外,所以我們無非是要獲取到插件頁面的 URL 跳轉進去再進行正常 E2E 測試即可。

首先觀察插件頁面的 URL 組成如下:

`chrome-extension://${插件id}`
// 例如
'chrome-extension://dmlpmahdbmhcfonakcknmkeobmopidgl'

所以我們的目標變成了獲取插件的 id,而 Puppeteer 是支持自動安裝上插件的,問題在於安裝上之後如何獲取 id,此時代碼如下:

const puppeteer = require('puppeteer');

async function bootstrap(options) {
  const { appUrl } = options;
  const extensionPath = 'xxx'; // 插件路徑
  const browser = await puppeteer.launch({
    headless: false, // 需要配置有頭模式,無頭模式找不到 service worker
    args: [
      // 除了 extensionPath 的插件都禁用掉,避免測試被影響
      `--disable-extensions-except=${extensionPath}`,
      // 安裝插件
      `--load-extension=${extensionPath}`
    ]
  });

  const appPage = await browser.newPage();
  await appPage.goto(appUrl, { waitUntil: 'load' });

  const targets = await browser.targets();
  // 找到 sercice worker 即可獲取到目標插件
  const extensionTarget = targets.find((target) ={
    return target.type() === 'service_worker'
  });
  // 解析目標插件 url 獲得插件 id
  const partialExtensionUrl = extensionTarget.url() || '';
  const [, , extensionId] = partialExtensionUrl.split('/');

  const extPage = await browser.newPage();
  const extensionUrl = `chrome-extension://${extensionId}/index.html`;
  await extPage.goto(extensionUrl, { waitUntil: 'load' });

  return {
    appPage,
    browser,
    extensionUrl,
    extPage
  };
}

bootstrap({
  appUrl: 'https://www.baidu.com'
})

module.exports = { bootstrap };

其中的問題

上述模式在有頭模式下本地跑 E2E 測試是沒問題的,因爲本地有圖形化界面,但是如果在 CI 環境比如 Linux 環境中並沒有圖形化界面,此時 headless:false 也就是有頭模式會直接報錯導致測試不通過,所以我們需要在沒有圖形化界面的環境中模擬一套圖形化界面,這時我們就可以用到 xvfb

Xvfb 在一個沒有圖像設備的機器上實現了 X11 顯示服務的協議。它實現了其他圖形界面都有的各種接口,但並沒有真正的圖形界面。所以當一個程序在 Xvfb 中調用圖形界面相關的操作時,這些操作都會在虛擬內存裏面運行,只不過你什麼都看不到而已。

https://zhuanlan.zhihu.com/p/350944759

簡單來說就是讓瀏覽器以爲自己跑在有圖形界面的系統中,從而讓有頭模式正常運行。

首先就需要在 CI 環境中安裝 xvfb,一般我們都是配置一份 pipeline 腳本來做,例如:

// xxx-pipeline.yaml
steps:
  - name: Configuration xvfb
    commands:
      - sudo apt-get update
      - sudo apt-get install xvfb

// 手動也可以
sudo apt-get update
sudo apt-get install xvfb

然後調整啓動測試腳本的邏輯

// before
node test/index.js

// after
xvfb-run node test/index.js

然後我們就可以愉快的發現在比如 Linux 環境下也可以跑通用例了~

✅ 至此開發一個 Chrome 插件的整個生命週期基本都已覆蓋。

關於我們

我們是字節跳動 Web Infra - Web Solutions 團隊,服務於整個公司的 Web 生態,我們的願景是 打造世界一流的 Web 生態技術體系,爲字節產品提供極致的用戶體驗和開發體驗。

我們堅信 Web 技術是最偉大的事情之一,Web Infra 團隊竭力提供更好用的工具,讓 Web 開發更加容易、讓開發者能收穫更好的開發體驗、用戶收穫更好的用戶體驗。與此同時,開源也一直是我們在長期探索的事情。

我們打造了一系列 Web 工具來提升開發效率和體驗,包括但不限於:

當前,這些工具在 ByteDance 內部被廣泛使用和好評。同時,其中多個項目已經開源到 GitHub,與社區開發者共同建設和發展。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qPeZZPwUGX2eRi4Q1AGfcw