8
头图

扩展程序是基于事件的程序,用于修改或增强 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 有严格隐私保护,只会在指定的区块链浏览器网站上运行(可以在配置中关闭),不会在其他网站上运行,不会上传自定义信息。请大家放心使用 :)


MangoGoing
780 声望1.2k 粉丝

开源项目:详见个人详情