了解和开发一个浏览器插件

发布于

如果您还未使用过浏览器插件,可以打开 Chrome 浏览器插件商店 看看

Chrome 浏览器插件商店

和以往所见到的插件系统的定义相同,浏览器插件也是用来增强浏览器的功能,正如上图中的副标题:“让浏览器如虎添翼”

而根据具体需要可以选择不同的插件增强不同功能,同样见上图下半部分的“热门分类”

不妨选择下载试用下几个插件(仅举例,可自由选择试用)

  1. DeepL:人工智能翻译器和写作助手 划词翻译,无需再复制到翻译软件中粘贴查看译文
  2. Popup my Bookmarks 书签管理,个人习惯不展示浏览器书签栏,点击插件图标即展开书签
  3. Text Blaze: Templates and Snippets 输入框补全,可以预设常用片段,比如邮件、账号、密码等,浏览器中的输入场景就可以一键补全

还有各种场景可以解锁,熟练使用各种插件绝对可以提升你的工作学习效率

插件都可以做什么

  1. 增强 Tab 页,新增或删除内容,比如 DeepL 可以划词就地翻译展示,弹出窗口并不是当前访问页面所构建的,而是由插件创建并插入进当前页面的
  2. 增强设置操作按钮,每个插件都会展示在右上部分,您可以设置其收起或展开展示插件图标,点击图标时会展示插件提供的 popup 弹出窗口,比如 天气 - Weather 直接图标展示今日天气,点击展开弹出窗口查看更多信息
  3. 增强右键工具栏,访问任何页面都可以右键操作浏览器,可以在此新增快捷操作
  4. 增强地址栏输入,新增快捷访问等
  5. 增强打开新标签页,可以替换浏览器打开新标签页,自定义任何内容,比如 Clear New Tab 可以替换默认新标签页,替换为一个简洁的空白页
  6. 增强开发者工具,比如 Redux DevTools 可以查看 Redux 状态树

如何开发1

首先一个插件目录包括 manifest.json 配置文件,以下是一个最小清单内容:

manifest.json
{
  "manifest_version": 3,
  "name": "Extension Name",
  "description": "Extension Description",
  "version": "1.0.0"
}

在 manifest.json 新增 action 配置,并在插件目录中提供 popup.html,就是一个简单常规的 HTML 页面

manifest.json
{
  "action": {
    "default_popup": "popup.html"
  }
}

在点击图标时即可弹出窗口展示 popup.html 内容

popup.html

Service Worker 后台脚本2

本文不做详细 API 介绍,详情请参见文档

试想一个场景,我进入了一个阅读网站,但我只想在当前页面阅读 15 分钟,不想在此耗费太多时间,我点开右上角的定时器插件,希望在此页面阅读停留 15 分钟后闹铃提示我。 注意不是定时 15 分钟,而是在当前页面阅读满 15 分钟,不然为什么不直接使用闹钟呢?🤣

这种场景下我们考虑下如何实现?首先是整个应用最核心的部分(其他部分下文再继续补充):定时器,如果我们在 popup 中实现并维护定时器,一旦关闭 popup,保存在内存中的定时器也会随着程序的关闭而销毁,重新打开时又是一个初始化流程。

这时就需要借助一个可以持续运行的后台脚本,和 Web 应用一样,插件也支持注册 Service Worker 后台脚本

manifest.json
{
  "background": {
    "service_worker": "sw.js"
  }
}

在目录中新建 sw.js 并写入内容

sw.js
console.log("Service Worker Script")
setInterval(function () {
  console.log("log from service worker every 3s")
}, 3000)

然后打开插件管理页面,点击刷新重载插件,就可以看到 “检查视图 Service Worker”

加载 sw.js

点击会新窗口打开开发者工具,就可以看到 Service Worker 脚本的执行日志,setInterval 可以正常持续执行

查看 sw.js console 信息

如何通信

然后下一个需要解决的问题就是如何通信?需要将定时器信息从 popup 窗口传递到 Service Worker 脚本中

chrome 提供了两个 API 来实现各个组件之间的通信:chrome.runtime.sendMessagechrome.runtime.onMessage,推荐安装 @types/chrome 获得完整的类型定义

popup.js
const input = document.querySelector('#input-timer')
document.querySelector('#btn-set-timer').addEventListener('click', function () {
  chrome.runtime.sendMessage({
    type: 'set-timer',
    payload: {
      id: 'first-timer',
      timeout: +input.value,
    },
  })
})
sw.js
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  if (request.type === 'set-timer') {
    const { id, timeout } = request.payload
    setTimeout(() => {
      console.log(`Timer ${id} has expired after ${timeout} minutes`)
    }, timeout * 60000)
  }
})

这里仅展示创建定时器的逻辑,取消等逻辑可以自行添加 state 维护,但这里 chrome 出于性能和资源消耗考虑,会在闲时关停 Service Worker 脚本:

  1. 无操作 30 秒后。收到事件或调用扩展程序 API 会重置此计时器。
  2. 单个请求(例如事件或 API 调用)的处理用时超过 5 分钟。
  3. 当 fetch() 响应时间超过 30 秒时。

因此更推荐使用 chrome.alarms API 来代替 setTimeout,首先在 manifest.json 中申请 alarms 权限

manifest.json
{
  "permissions": ["alarms"]
}

然后修改 Service Worker 脚本

sw.js
chrome.runtime.onMessage.addListener(async function (request, sender, sendResponse) {
  if (request.type === 'set-timer') {
    const { id, timeout } = request.payload
    await chrome.alarms.create(id, { delayInMinutes: timeout })
  }
})

chrome.alarms.onAlarm.addListener((alarm) => {
  console.log(`Alarm ${alarm.name} has been triggered`)
})

Content Script 内容脚本3

扩展程序可以插入内容脚本来读取、修改当前所访问网页内容。

首先在 manifest.json 中添加注册,matches 字段可指定特定网址,详情见 匹配模式

manifest.json
{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "css": []
    }
  ]
}

然后在 content.js 中添加内容 console.log('Content Script'),内容脚本在当前网页成功执行

内容脚本成功执行

动态注册

像上面这样注册内容脚本成为静态脚本,部分场景比如匹配模式未知,比如部分插件提供 “在当前页面不启用” 功能,可以在 Service Worker 中使用 chrome.scripting 来动态注册更新内容脚本

sw.js
chrome.scripting.registerContentScripts([
  {
    id: 'session-script',
    js: ['content.js'],
    persistAcrossSessions: false,
    matches: ['*://example.com/*'],
    runAt: 'document_start',
  },
])

// sometime
chrome.scripting.updateContentScripts([
  {
    id: 'session-script',
    excludeMatches: ['*://sub.example.com/*'], // 额外排除 sub.example.com 页面下的启用
  },
])

补充计时器例子

了解到内容脚本对所访问页面可以进行的控制,让我们回到上面计时器的例子,现在希望将提示信息直接插入当前浏览的页面中,进行更明显的提示。

现在整个应用的执行流程是

  1. 在 popup 弹出窗口中开启闹钟,同步到 Service Worker 后台脚本中
popup.js
const input = document.querySelector('#input-timer')
const text = document.querySelector('#timer-rest')

document.querySelector('#btn-set-timer').addEventListener('click', function () {
  chrome.runtime.sendMessage({
    type: 'set-timer',
    payload: +input.value * 60,
  })
})

document.addEventListener('DOMContentLoaded', function () {
  update()
})

async function update() {
  const response = await chrome.runtime.sendMessage({ type: 'get-timer' })
  text.textContent = +response
  setTimeout(update, 1000)
}
  1. 在 Service Worker 中保存维护闹钟的状态,将定时器状态同时往 popup 和当前页面同步
sw.js
const timers = new Map()

chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  if (request.type === 'set-timer') {
    getActiveTab().then((tab) => {
      const host = new URL(tab.url).hostname
      timers.set(host, request.payload)
      chrome.tabs.sendMessage(tab.id, { type: 'setup' })
    })
  }
  if (request.type === 'get-timer') {
    getActiveTab().then((tab) => {
      const host = new URL(tab.url).hostname
      sendResponse(timers.get(host))
    })
  }
  if (request.type === 'update-timer') {
    getActiveTab().then((tab) => {
      const host = new URL(tab.url).hostname
      if (timers.has(host)) {
        timers.set(host, request.payload)
      }
    })
  }
  return true
})

function getActiveTab() {
  return new Promise((resolve) => {
    chrome.tabs.query({ active: true }, (tabs) => {
      resolve(tabs[0])
    })
  })
}
  1. 在内容脚本中取到闹钟的状态,同时收集有效时间做对比,当有效时间到达时,内容脚本直接在当前页面插入提示信息
content.js
let counting = document.visibilityState === 'visible'
let target = 0
let reduce = 0
let setupStamp
let lastStamp

function setup() {
  reset()
  setupStamp = Date.now()
  lastStamp = setupStamp
  chrome.runtime.sendMessage({ type: 'get-timer' }, (response) => {
    target = +response * 1000 // seconds => milliseconds
  })
}

chrome.runtime.onMessage.addListener((message) => {
  if (message.type === 'setup') {
    setup()
  }
})

document.addEventListener('visibilitychange', () => {
  counting = document.visibilityState === 'visible'
  if (document.visibilityState === 'visible') {
    lastStamp = Date.now()
  }
})

function flush() {
  const now = Date.now()
  reduce += now - lastStamp
  lastStamp = now
  chrome.runtime.sendMessage({
    type: 'update-timer',
    payload: Math.round((target - reduce) / 1000),
  })
}
function check() {
  if (reduce > target) {
    const message = `你已在当前页面停留了 ${target / 1000} 秒,到达设定时间`
    alert(message)
    reset()
  }
}
function reset() {
  reduce = 0
  target = 0
  setupStamp = null
  lastStamp = null
}
function start() {
  const id = setInterval(() => {
    if (setupStamp && counting) {
      flush()
      check()
    }
  }, 1000)
  return () => clearInterval(id)
}

start()

(这里是有更好的流程设计,比如使用 chrome.storage 或者 chrome.notifications API,仅做教程演示使用此流程)

工程化

但是上文的例子还只是简单的 alert 提示信息,非中断交互的提示是更好的,以及为了追求更好的交互体验,可能需要引入 UI 框架和组件库来帮助构建插件应用,因此工程化十分必要。

我们仅需要提供一个插件的目录给到浏览器加载,因此我们工程化的目标就是将代码和各种预设资源构建到插件目录即可。

比如有 popup 窗口的样板代码,引入了 React 帮助构建交互

src/popup/index.tsx
import * as React from 'react'
import * as ReactDom from 'react-dom/client'
import App from './App'
import './index.css'

ReactDom.createRoot(document.querySelector('#extension-popup')).render(<App />)

webpack

先加上基础的 webpack 配置

webpack.config.js
const path = require('path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')

const mode = process.env.NODE_ENV
const isDev = mode === 'development'
const devDirectory = 'dist-dev'

module.exports = {
  mode,
  entry: {
    popup: './src/popup/index.tsx',
    // 暂时仅有单入口,这样写预留多入口
  },
  output: {
    path: path.resolve(__dirname, isDev ? devDirectory : 'build'), // dev 和 build 区分目录
    clean: true,
    filename: '[name].js',
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx'],
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [{ loader: 'ts-loader' }],
      },
      { test: /\.css$/i, use: ['style-loader', 'css-loader'] },
    ],
  },
  plugins: [
    // 组装成 popup.html
    new HtmlWebpackPlugin({
      filename: 'popup.html',
      template: './src/popup/template.html',
      chunks: ['popup'],
    }),
  ],
}

最重要的 manifest.json 文件还没有包括到最终目录中,新增 webpack plugin npm i copy-webpack-plugin,将 manifest.json 和其他静态资源放在 assets 目录下,然后复制进最终插件目录中即可

webpack.config.js
const CopyPlugin = require('copy-webpack-plugin')

module.exports = {
  // ...
  plugins: [
    new CopyWebpackPlugin({
      patterns: [{ from: './assets', to: '.' }],
    }),
  ],
}

然后补充下 webpack 开发服务器,支持热更新代码,安装 npm i webpack-dev-server,然后新增配置

webpack.config.js
module.exports = {
  // ...
  devServer: {
    hot: true,
    static: [{ directory: path.resolve(__dirname, devDirectory) }],
    devMiddleware: {
      writeToDisk: (filePath) => {
        // 热更新文件同样写入插件目录
        return !/\.hot-update\.(js|json)/.test(filePath)
      },
    },
  },
}

至此,单独的 popup 应用开发流程就完成了,想新增插件的其他页面,流程也很简单,比如我想接管浏览器新增 Tab

  1. manifest.json 中新增页面配置
manifest.json
{
  // ...
  "chrome_url_overrides": {
    "newtab": "newtab.html"
  },
}
  1. 新增代码入口 src/newtab/index.tsx src/newtab/template.html
  2. 新增 webpack 配置
webpack.config.js
module.exports = {
  // ...
  entry: {
    // ...
    newtab: './src/newtab/index.tsx',
  },
  plugins: [
    // ...
    new HtmlWebpackPlugin({
      template: './src/newtab/template.html',
      filename: 'newtab.html',
      chunks: ['newtab'],
    }),
  ],
}

其他 options 等页面同理,以及其他构建工具比如 vite 也是相同思路

再次补充计时器例子

首先 webpack 中新增 entry

webpack.config.js
module.exports = {
  entry: {
    content: './src/content/index.tsx',
  },
}

还是用上面的计时器应用作为例子,这里想将定时结束后的 alert 提示改成更友好的在页面中弹出非中断的提示信息

在 Content Script 使用纯 javascript 编程的方式插入 react 构建的交互

src/content/index.tsx
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import Message from './Message'

function getRoot() {
  const id = 'extension-timer-message-root'
  if (document.getElementById(id)) {
    return document.getElementById(id)
  }
  const root = document.createElement('div')
  root.id = id
  document.body.appendChild(root)
  return root
}

createRoot(getRoot()).render(<Message />)

但是还漏了 CSS,所以还需要用相同方法插入样式,但需要将 css 转成纯文本作为 <style> 标签的内容插入,因此 npm i raw-loader -D 然后使用 webpack 的 inline loader 加载 css 文件

src/content/index.tsx
// ...
import cssText from '!!raw-loader!./style.css'

const style = document.createElement('style')
style.textContent = cssText
document.head.appendChild(style)

另外 !!raw-loader!./style.css 可能会报错,因为 ts 并不认识 !!raw-loader,所以需要补充下这种模块声明

新增 typings.d.ts (随意目录下,只要 tsconfig.json 配置的 include 字段将其包含进去即可)

typings.d.ts
declare module '!!raw-loader!*' {
  const content: string
  export default content
}

最后补充下 Message 组件和提供 api 用以替换 alert

src/content/store.ts
import { create } from 'zustand'

interface Message {
  text: string
  duration: number
  id: number
}
interface Store {
  messages: Message[]
  push: (message: Omit<Message, 'id'>) => void
}

let seed = 0
export const useStore = create<Store>((set) => ({
  messages: [],
  push: (message) => {
    seed++
    const newMessage = { ...message, id: seed }
    set((state) => ({
      messages: [...state.messages, newMessage],
    }))
    setTimeout(() => {
      set((state) => ({
        messages: state.messages.filter((msg) => msg.id !== newMessage.id),
      }))
    }, message.duration)
  },
}))

export function showMessage(text: string, duration: number) {
  const { push } = useStore.getState()
  push({ text, duration })
}
src/content/Message.tsx
import * as React from 'react'
import { useStore } from './store'
import start from './start'

export default function Message() {
  const { messages } = useStore()
  React.useEffect(() => {
    const un = start()
    return un
  }, [])
  return (
    <div className="timer-container">
      {messages.map((message) => (
        <div key={message.id} className="message-item">{message.text}</div>
      ))}
    </div>
  )
}

这里的 start 即上文例子中 content.js 中的 start 函数,最后找个页面测试下效果

计时器应用的测试效果图

以上 demo 代码见 github

另一个例子

现在来看另一个例子,我是一个 Web 开发者,需要经常打开 MDN 查询一些 API,我的操作流程是:

  1. 打开新窗口
  2. 在地址栏输入 mdn 关键字,等待其自动补全历史记录下拉列表
  3. 在历史记录下拉列表选择一个 mdn 的网址,点击进入
  4. 点击页面中的搜索输入框输入我想查询的关键词,回车进入到 mdn 的查询结果页面

当然,您也可以直接收藏 mdn 网址,需要直接打开即可,但相信也有部分人和我一样不喜欢使用书签栏,如果借助扩展程序,绝对可以将这个流程简化很多

Omnibox 多功能框

扩展程序中能做事情还有很多,包括扩展 Omnibox 多功能框,但这么说你可能有点不知所云,这个 Omnibox 其实就是地址栏,官方文档中称其为多功能框。

Omnibox 例图

回到上面的例子,希望怎么简化这个查询流程呢?我设想:

  1. 打开新窗口
  2. 在地址栏输入 mdn <search content> 回车
  3. 直接访问 mdn 的查询结果页面

然后我打开插件商店,搜索 “search in mdn”,果然已经有人开发过了类似的 插件

Search in MDN

体验效果还是很好的,这里主要介绍如何实现?

首先还是在 manifest.json 中添加 omnibox 配置信息,主要是 omnibox.keyword 配置激活插件的关键字

manifest.json
{
  // ...
  "omnibox": {
    "keyword": "mdn"
  }
}

加载插件后,在地址栏输入 mdn 加空格,就可以看到插件已经激活

Search in MDN

然后在 Service Worker 中添加 chrome.omnibox.onInputChangedchrome.omnibox.onInputEntered 事件监听器,监听用户输入并给出提示内容(记得在 manifest 中补充 background.service_worker 字段)

service_worker.js
chrome.omnibox.onInputChanged.addListener(function (text, suggest) {
  suggest([
    { content: text, description: 'Open MDN' },
    {
      content: 'https://developer.mozilla.org/zh-CN/search?q=' + text,
      description: 'Search in MDN',
    },
  ])
})

chrome.omnibox.onInputEntered.addListener(function (text) {
  const url = /https?:\/\//g.test(text)
    ? text
    : `https://developer.mozilla.org/zh-CN/search?q=${text}`
  chrome.tabs.create({ url })
})

Context Menu 右键菜单

但还有一个场景,比如在阅读一篇技术博客时,提到了一个 Web API,我想要选中然后右键菜单直接在 MDN 中搜索

还好,扩展程序同样支持扩展 Context Menu 右键菜单,首先在 manifest 配置清单中新增字段

manifest.json
{
  "permissions": ["contextMenus"],
}

然后在 Service Worker 中应用安装后(更多生命周期见文档)调用 chrome.contextMenus.create 插入菜单,指定有文本选中时才启用,然后监听菜单点击 chrome.contextMenus.onClicked,取到选中文本,并在当前网页右侧插入 tab 打开 MDN 搜索结果页面

service_worker.js
chrome.runtime.onInstalled.addListener(async () => {
  chrome.contextMenus.create({
    id: 'search-in-mdn',
    title: 'Search in MDN"',
    type: 'normal',
    contexts: ['selection'],
  })
})

chrome.contextMenus.onClicked.addListener(function (info, tab) {
  const selection = info.selectionText
  const newTabIndex = tab.index + 1
  const url = `https://developer.mozilla.org/zh-CN/search?q=${selection}`
  chrome.tabs.create({ url, index: newTabIndex })
})

找个页面测试一下,选中文本后右键点击 “Seach in MDN” 点击即跳转到新页面,这比最先的流程要方便的太多了

Search in MDN

以上例子已打包成扩展程序发布到 github,欢迎试用体验

安全问题

  1. 权限最小化原则:应只请求完成其功能所需的最小权限,避免请求不必要的权限。

  2. CSP 内容安全策略:默认情况下,插件只能从自己目录加载资源,相当于有默认配置

    manifest.json
    {
      "content_security_policy": {
        "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
      }
    }
    
  3. 安装前仔细查看其请求的权限列表,评估是否合理

  4. 谨慎授权,插件请求授权时评估其合理性

使用框架简化开发流程

案例分享

天气预报

后台脚本中获取天气信息,然后通过 chrome.action.setIcon 设置图标

sw.js
chrome.action.setIcon({
  path: getIconPath(alarm.name),
});

音乐播放器

详情可见《如何在浏览器扩展程序中创建音乐播放器》

  1. 在 service_worker 中创建 offsrceen document
  2. 在 offsrceen document 中创建 audio
  3. 使用 chrome.runtime.onMessage 通信

Input Completer

  1. 创建 Content Script 插入交互
  2. 由于同源策略,所有请求由插件的 Service Worker 代理

Footnotes

  1. 欢迎使用扩展程序!

  2. 扩展程序 Service Worker 简介

  3. 内容脚本