Chrome Extension v3 開發指南

MangoGoing發表於2022-11-23

擴充套件程式是基於事件的程式,用於修改或增強 Chrome 瀏覽體驗,如果此時你想構建一個 Chrome 擴充套件程式,並努力尋找一篇涵蓋 Chrome 擴充套件程式的整個構思、構建和啟動過程的文章,這裡有一個綜合指南,可幫助您完成整個過程。

本文分幾個部分講解 Chrome 擴充套件開發的全流程以及高階使用技巧,適用用最新的 v3 版本,希望這可以節省你學習瀏覽器外掛開發入門和提升的時間,詳細內容請參閱官方文件

為什麼是V3?

  1. 更高的安全性、隱私性和效能
  2. 支援 service workers 和 promises
  3. 獲得更快速的程式碼稽核
  4. Chrome 網上應用店不再接受 Manifest V2 擴充套件

為什麼使用 Chrome 擴充套件程式?

  1. 開發門檻低

    幾乎支援任意的技術棧,對於前端入門使用者如果想開發一個簡單的用於增強使用者體驗的外掛功能,例如高亮關鍵字、增加黑夜模式等會比開發傳統類似功能的網站和移動應用成本低的多,即使你不會使用 React 或者 Vue 你也可以透過 Jquery 又或者是原生 Javascript 去實現它。

  2. 覆蓋範圍廣

    Chrome 在市場份額上以很大的優勢擊敗了其他瀏覽器,因此,優先開發 Chrome 擴充套件是獲取下載量和流量最好的入口。外掛部署後,所有 Chrome 使用者都可以在Chrome 網上應用店下載你的擴充套件程式。

  3. u can do whtever u want

    在你的擴充套件程式中,你可以不用為瀏覽器的同源策略擔憂,你可以在你想要的任意網站中"侵入"你自己的程式碼,你可以像 React devTools 那樣增強你自己的偵錯程式,你甚至可以在你的擴充套件程式中管理別人的擴充套件程式(如防釣魚,防沉迷等)。

Chrome Extension 架構組成

manifest.json

{
   "name": "__MSG_extName__", // 國際化語法,或預設去根目錄下找_locales.en(對應的語言包).message.extName
   "version": "1.0.0",
   "description": "__MSG_extDescription__", // 同name
   "icons": {
    '16': 'src/assets/icons/icon16.png',
    '32': 'src/assets/icons/icon32.png',
    '48': 'src/assets/icons/icon48.png',
    '128': 'src/assets/icons/icon128.png'
    },
"background": {
      "persistent": false, // 保持後臺指令碼持續活動的唯一情況是擴充套件使用chrome.webRequest API 來阻止或修改網路請求。webRequest API 與非永續性後臺頁面不相容。預設情況下,"persistent"設定為 true。
      "scripts": [ "background.js" ]
    },
   
   "content_scripts": [ {
      "js": [ 'src/content/ethers/address.tsx' ],
      "matches": [ "*://etherscan.io/address/*", "*://*.bscscan.com/address/*" ]
   } ],
"web_accessible_resources": [
    {
      "matches": ['<all_urls>'],
      "resources": ['src/assets/images/*.png']
    }
  ],
  "action": {
    "default_popup": 'src/popup/popup.html',
    "default_icon": {
      '16': 'src/assets/icons/icon16.png',
      '32': 'src/assets/icons/icon32.png',
      '48': 'src/assets/icons/icon48.png',
      '128': 'src/assets/icons/icon128.png'
    }
  },
  "permissions": ['storage', 'webNavigation', 'webRequest'], // 沒有用到的許可權不要新增,否則稽核過不了
  "host_permissions": [
    '*://explorer.btc.com/*',
    '*://etherscan.io/*',
    '*://cn.etherscan.com/*',
    '*://polygonscan.com/*',
    '*://*.bscscan.com/*',
    '*://snowtrace.io/*',
    '*://optimistic.etherscan.io/*',
    '*://arbiscan.io/*',
    '*://ftmscan.com/*',
    '*://cronoscan.com/*',
    '*://*.moonscan.io/*',
    'https://*.blocksec.com/*', // 自己的業務請求api域名,這樣才不會跨域
    'https://explorer.api.btc.com/*' // 使用webRequest監聽請求資訊,被監聽的域名也要配置,否則不生效
  ] 
}

配置清單有幾個注意點:

  1. 以最小許可權約束外掛,permissions 欄位如果寫了你應用中沒用到(或者不必要)的許可權,在提交稽核時會被拒絕。注意 tabs 絕大部分API是不需要申請這個 tabs 許可權的。
  2. 如果涉及跨域請求,需要在 host_permissions 裡面配置域名,當然你也可以用 <all_urls> ,更好的注重使用者隱私的做法是實事求是,用到哪些列哪些,因為這個配置檔案會存在使用者的硬碟上,這會引起使用者擔憂。
  3. 如果涉及 webRequest 攔截請求,比如監聽使用者翻頁了(其實就是監聽 getMore 的介面請求完成了),需要把你要監聽的請求域名配置在 permissions
  4. background 中保持後臺指令碼持續活動的唯一情況是擴充套件使用 chrome.webRequest API 來阻止或修改網路請求。webRequest API 與非永續性後臺頁面不相容。預設情況下,"persistent"設定為 true
  5. 無論是 matchesresources 還是 host_permissions 都可以用萬用字元描述。
  6. 外掛中用到的靜態資源都需要在 web_accessible_resources 中配置。
  7. content_scripts 中的資源是按照你列表中的順序載入到頁面中的,請注意依賴關係的先後順序。

background.js

後臺指令碼大部分時間都處於休眠狀態,並且包含僅在某些瀏覽器事件發生時才觸發指令碼的偵聽器。 後臺指令碼會貫穿你外掛應用的全生命週期,所以在這裡一般用於監聽 Popup 或者 content script 的事件,例如網路請求等。

import { chromeEvent } from '@common/event'
import { reloadCurrentTab, isNil } from '@common/utils'
import commonApi from '@common/api'
import { REFRESH } from '@common/constants'

/** refresh current page (usually user change the settings) */
chromeEvent.on(REFRESH, () => {
  reloadCurrentTab()
})

chromeEvent.on('custom-event-name', async params => {
  try {
    const { success, data, msg } = await commonApi.getXxx(params)
    return {
      success: success,
      data: data,
      message: msg
    }
  } catch (error) {
    /** external exception */
    return { success: false, data: error, message: 'error' }
  }
})

chrome.webRequest.onCompleted.addListener(
  async details => {
    const { url, tabId } = details
        // do something
  },
  { urls: [] }
)

popup

使用者介面,點選瀏覽器擴充套件圖示後展示的UI元素。

<img src="https://picgo-cloudimg.oss-cn-hangzhou.aliyuncs.com/img/Xnip2022-11-19_09-14-32.jpg" style="zoom:30%;" />

開發popup與你開發一個正常的webapp時沒有任何區別,唯一需要注意的是打包的時候需要把popup.html配置到entry和output以正常訪問到這個頁面,開發時目錄結構類似以下所示:

image-20221119092623788

content script

內容指令碼讀取和修改網頁。它們是用 Javascript 編寫的,並在網頁載入時執行。例如,MetaDock 的內容指令碼部分功能用於替換*scan頁面中的address顯示標籤,如下所示:

<img src="https://picgo-cloudimg.oss-cn-hangzhou.aliyuncs.com/img/202211190931705.png" alt="image-20221119093113672" style="zoom:50%;" />

"run_at" 用於配置指令碼執行時機,預設是 document_idle,此時 DOM 已經掛載完成。另外兩個可配置選項值是 document_start (dom掛載前)和 document_end (dom掛載完成後立馬執行,此時其他影像和框架等子資源可能並沒有載入完成)。

"all_frames" 欄位允許擴充套件指定是否應將 JavaScript 和 CSS 檔案注入到符合指定 URL 要求的所有框架中,還是僅注入到選項卡中最頂層的框架中。

如果有內容指令碼與宿主頁面的通訊需求請使用 window.postMessage

const port = chrome.runtime.connect();

window.addEventListener("message", (event) => {
  // We only accept messages from ourselves
  if (event.source != window) {
    return;
  }

  if (event.data.type && (event.data.type == "FROM_PAGE")) {
    console.log("Content script received: " + event.data.text);
    port.postMessage(event.data.text);
  }
}, false);
document.getElementById("theButton").addEventListener("click", () => {
  window.postMessage({ type: "FROM_PAGE", text: "Hello from the webpage!" }, "*");
}, false);

options

配置頁面,在清單中配置此項會在外掛郵件選單中多一個選項的 item,options 頁面與 popup 開發模式沒什麼區別,不多做介紹。

{
  "name": "My extension",
  ...
  "options_ui": {
    "page": "options.html",
    "open_in_tab": false
  },
  ...
}

嵌入式選項

其他業務頁面

外掛可以有自己域下的頁面,開發這些頁面跟開發多頁應用的流程一致,比如你要在外掛中新增一個隱私策略頁面,目錄結構應該類似以下:

image-20221119095351484

配置 entryoutput,以下以 viterollupOptions 為例。

rollupOptions: {
  input: {
    policy: 'src/pages/PrivacyPolicy/index.html'
  }
}

透過 chrome-extension://fkhgpeojcbhixxxxxliepkpcgcoo/src/pages/PrivacyPolicy/index.html 開啟此頁面。

下圖摘自Google 的指南,說明了各種檔案之間的互動。傳送和接收訊息是檔案之間通訊的關鍵方法,用於協調整個擴充套件的功能。

Chrome API

Chrome.runtime

  1. chrome.runtime.sendMessage:它允許您向事件偵聽器傳送一條訊息,允許不同指令碼之間的互動(無法傳送到內容指令碼)
  2. chrome.runtime.onMessage.addListener:監聽並在從擴充套件程式/另一個指令碼接收到訊息時觸發
  3. chrome.runtime.getURL:獲取外掛的資源路徑,一般路徑往往是以 chrome-extension://fkhgpeojcbhixxxxxliepkpcgcoo 開頭,fkhgpeojcbhixxxxxliepkpcgcoo 是外掛ID,這個一般不會變,在應用中你不用去維護這個ID即可獲得資源的完整路徑。一般獲取圖片等資源時可以用chrome.runtime.getURL('/src/assets/images/logo.png')
  4. chrome.runtime.openOptionsPage:允許使用者透過提供選項頁面來自定義擴充套件的行為。使用者可以透過右鍵單擊工具欄中的擴充套件圖示然後選擇選項或導航到擴充套件管理頁面chrome://extensions,找到所需的擴充套件,單擊詳細資訊,然後選擇選項鍊接來檢視擴充套件的選項。

Chrome.tabs

如果您的擴充套件程式與瀏覽器選項卡有關,則您需要此 API。

  1. chrome.tabs.get:獲取有關任何指定選項卡的詳細資訊(例如 URL、標題、ID、是否處於活動狀態)。如果您只想在使用者訪問某些網站時觸發操作(例如,如果您的擴充套件程式是特定於網站的),這將很有用。
  2. chrome.tabs.getCurrent : 獲取當前標籤的詳細資訊
  3. chrome.tabs.sendMessage:將訊息傳送到指定選項卡的內容指令碼,並在傳送迴響應時執行可選的回撥
  4. chrome.tabs.create:建立一個新標籤頁(你可以指定一個 URL)
  5. chrome.tabs.reload:重新整理選項卡頁面
  6. chrome.tabs.query:獲取選項卡相關

chrome.webRequest

使用chrome.webRequestAPI 觀察和分析流量並攔截、阻止或修改執行中的請求。

從webrequest API看一個web請求的生命週期

生命週期中每個勾子都能被監聽到。更多細節請參考文件

開發除錯

外掛中的頁面如 popupoptions ,還有後臺指令碼等除錯工具是跟你當前瀏覽器的除錯工具(F12)獨立的,如果需要除錯元素和網路請求等控制檯資訊,請在 popup 皮膚上右鍵檢查,注意:每重新開一個 tab 都需要重新執行上述步驟。

image-20221119101926148

關於 content script 熱更新除錯問題整理了以下兩個方案,都有嘗試,推薦第二種方案。

  1. webpack 版本解決方案(複雜外掛開發過程不穩定,延遲高)

    • 配置 webpack server,將 bundle 寫到磁碟。
    • 透過 webpack plugin 暴露 compiler 物件。
    • 為 webpack server 增加中介軟體,攔截 reload 請求,轉化為 SSE,compiler 註冊編譯完成的鉤子,在回撥函式中透過 SSE 傳送訊息。
    • chrome extension 啟動後,background 與 webpack server 建立連線,監聽 reload 方法,收到 server 的通知後,執行 chrome 本身的 reload 方法,完成更新。
  2. CRXJS Vite 外掛(推薦)

CRXJS Vite 外掛使用技巧

額外的 HTML 頁面

擴充套件程式包含您無法在清單中宣告的網頁是很常見的。例如,您可能希望在使用者登入後更改彈出視窗或在使用者安裝擴充套件程式時開啟歡迎頁面。此外,像 React Developer Tools 這樣的開發工具擴充套件不會在清單中宣告它們的檢查器皮膚。

給定以下檔案結構和清單,index.html並將src/panel.html在開發期間可用,但在生產構建中不可用。我們可以在vite.config.ts

.
├── vite.config.ts
├── manifest.json
├── index.html
└── src/
    ├── devtools.html
    └── panel.html
// manifest.json
{
  "manifest_version": 3,
  "version": "1.0.0",
  "name": "example",
  "devtools_page": "src/devtools.html"
}
// vite.config.js
import { resolve } from 'path';
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';

export default defineConfig({
  build: {
    rollupOptions: {
      // add any html pages here
      input: {
        // output file at '/index.html'
        welcome: resolve(__dirname, 'index.html'),
        // output file at '/src/panel.html'
        panel: resolve(__dirname, 'src/panel.html'),
      },
    },
  },
  plugins: [crx({ manifest })],  
});

使用動態清單檔案

想象一下,如何將 manifest.json 中的版本號跟 package.json 中的版本統一??

Vite 外掛提供了一個defineManifest與 Vite 功能類似的defineConfig功能,並提供了 IntelliSense,可以輕鬆地在構建時擴充套件你的清單。

// manifest.config.ts

import { defineManifest } from '@crxjs/vite-plugin'
import { version } from './package.json'

const names = {
  build: 'My Extension',
  serve: '[INTERNAL] My Extension'
}

// import to `vite.config.ts`
export default defineManifest((config, env) => ({
  manifest_version: 3,
  name: names[env.command],
  version,
}))

圖示和公共資源

你可以參考清單中的公共檔案。如果 CRXJS 在其中沒有找到匹配的檔案,它將查詢相對於Vite 專案根目錄的檔案並將資產新增到輸出檔案中。

你可以將這些靜態資源統一放置在 public 目錄中管理。

Web Accessible Resources

你每次使用一個圖片都要手動去更新到清單,這個過程可能會讓我們覺得比較繁瑣。我們正在使用構建工具,那麼為什麼要做不必要的手動工作呢?當你將影像匯入內容指令碼時,CRXJS 會自動更新清單。✨

import logoPath from './logo.png'

const logo = document.createElement('img')
logo.src = chrome.runtime.getURL(logo)

動態內容指令碼

親測路徑解析沒問題,但是我去 executeScript 次路徑的指令碼時沒有反應,也許是版本bug,也許是我使用有誤,請自行判斷

可能會遇到一個場景:比如你在 background.js 監聽某個網路請求,命中後想執行一遍你的某個 content script,這時你需要在 executeScript 中寫指令碼的路徑才能正確執行,但是你並不知道打包後的指令碼路徑(打包後的路徑你並不關心或者檔名是經過雜湊處理的),這時候動態內容指令碼就派上用場了。

CRXJS 使用唯一的匯入查詢來指定匯入指向內容指令碼。當匯入名稱以查詢結尾時?script,預設匯出是內容指令碼的輸出檔名。

import scriptPath from './content-script?script'

chrome.action.onClicked.addListener((tab) => {  
  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: [scriptPath]
  });
});

使用技巧

  1. 推薦使用 fetch 進行網路請求,可以使用ky包減少負擔,另外 axios 我記得在外掛開發中有額外的需要配置或者坑。
  2. 所有時間監聽包括請求推薦在 background.js 中統一處理,使用訊息通訊傳遞結果到其他指令碼/頁面中。
  3. 訊息傳遞如果是其他 -> background.js 可以使用 chrome-extension-coreEvent 進行傳遞,友好的支援了 typescript,使用方式大致如下:

    // common/event.js
    // 在全域性的event檔案中管理所有的事件定義,包括引數約束
    import { Event } from 'chrome-extension-core'
    
    import { SCOPE } from '@common/constants'
    import type { PostXXXParams } from '@common/api/types'
    import type { REFRESH, GET_XXX } from '@common/constants/event'
    
    type EventInfo = {
      [REFRESH]: boolean
      [GET_XXX]: PostXXXParams
    }
    
    export const chromeEvent = new Event<EventInfo>(SCOPE)
    // content script
    const res = await chromeEvent.emit<typeof GET_XXXX, Response>(
      GET_XXX,
      {
        name: 'ghostwang'
      }
    )
    // background.js
    
    chromeEvent.on(GET_XXX, async params => {
      try {
        const { success, data, msg } = await commonApi.getXxx(params)
        return {
          success: success,
          data,
          message: msg
        }
      } catch (error) {
        return { success: false, data: error, message: 'error' }
      }
    })
  4. background.js 主動給其他發訊息,注意必須要處理非同步,不然會在外掛皮膚報錯(雖然這個錯誤可能不影響你的功能)

    // 後臺指令碼傳送邏輯
    chrome.tabs.sendMessage(tabId, EXECUTE_XXX_SCRIPT, function () {
      /** 注意:以下程式碼不要刪 */
      if (!chrome.runtime.lastError) {
        // 如果你有任何回應
      }
    })
    
    // 內容指令碼監聽邏輯
    chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
      if (message === EXECUTE_XXX_SCRIPT) {
        run()
        sendResponse()
      }
    })
  5. 持久化儲存可以封裝自定義的 hooks 並實現 監聽邏輯。

    import { useCallback, useEffect, useState } from 'react'
    
    import { store, defaultValue, type StorageInfo } from '@src/store'
    
    /**
     * 持久化儲存store
     */
    export default function useStore<Key extends keyof StorageInfo>(
      key: Key
    ): [StorageInfo[Key], (newValue: StorageInfo[Key]) => Promise<void>] {
      const [value, setValue] = useState<StorageInfo[Key]>(defaultValue[key])
      useEffect(() => {
        const getStore = async () => {
          const currentStoreValue = await store.get(key)
          setValue(currentStoreValue)
        }
        const storeWatcher = (
          data: Record<keyof StorageInfo, chrome.storage.StorageChange>
        ) => {
          if (data[key]) {
            const changedData = data[key]
            setValue(changedData.newValue)
          }
        }
        store.addWatcher(storeWatcher)
        getStore()
        return () => {
          store.removeWatcher(storeWatcher)
        }
      }, [key])
    
      const setStore = useCallback(
        (newValue: StorageInfo[Key]) => {
          return store.set(key, newValue)
        },
        [key]
      )
    
      return [value, setStore]
    }
    import type { WatcherCallback } from 'chrome-extension-core'
    import { useEffect } from 'react'
    
    import type { StorageInfo } from '@src/store'
    import { store } from '@src/store'
    
    export default function useStoreWatcher(
      callback: WatcherCallback<StorageInfo>,
      deps?: (keyof StorageInfo)[]
    ) {
      useEffect(() => {
        const storeWatcher = (
          data: Record<keyof StorageInfo, chrome.storage.StorageChange>
        ) => {
          const dataKeys = Object.keys(data)
          if (!deps || deps.some(val => dataKeys.includes(val))) {
            callback(data)
          }
        }
        store.addWatcher(storeWatcher)
        return () => {
          store.removeWatcher(storeWatcher)
        }
      }, [callback, deps])
    }
  6. 內容指令碼載入字型等資源比較耗時,因為本身執行時機可能就是在宿主網頁資源載入後,所以儘量避免用 iconfont ,使用小圖片替代會更快。
  7. 外掛的本地化無法提供動態切換的功能,所以如果要做國際化,還得寫兩套,外掛支援的國際化功能適用於外掛市場展示你的外掛應用的名稱和描述資訊,popup 等頁面需要透過 i18next 實現動態切換語言,所以,你的目錄結構應該類似:

    image-20221119113155558

Chrome 內建的國際化目錄必須在根目錄下,可以配合 vitevite-plugin-files-copy 外掛實現:

CopyPlugin({
  patterns: [
    {
      from: './src/_locales/chrome',
      to: 'dist/_locales'
    }
  ]
})

寫在最後

推薦一個專門為 Web3 使用者服務的 Chorme 瀏覽器外掛 —— MetaDock

產品功能:
安裝外掛後,在大家訪問 EtherscanBscScanBTC.com 等區塊鏈瀏覽器時,提供多項增強功能,直接展示在頁面中。目前已實現地址標籤、風險評分,資金流向圖,合約程式碼下載,快速在 Phalcon 中開啟交易,跳轉到多款區塊鏈瀏覽器等功能。

隱私保護:
MetaDock 有嚴格隱私保護,只會在指定的區塊鏈瀏覽器網站上執行(可以在配置中關閉),不會在其他網站上執行,不會上傳自定義資訊。請大家放心使用 :)

相關文章