我们日常使用时所说的浏览器插件其实指的时浏览器拓展程序,并不是真正的插件,真正意义上的浏览器插件应该是对浏览器底层能力的拓展,例如很久之前的pdf阅读、java程序运行等。这一部分插件现在有的被官方“收编”,有的已经退隐江湖。

浏览器的拓展能力可以帮助用户提高使用体验,例如:

  1. 增强浏览器功能:浏览器拓展程序能够为浏览器增加各种各样的功能,例如广告拦截、屏蔽推广、快速访问网页等功能。
  2. 提高用户体验:通过浏览器拓展程序,用户可以定制自己的浏览器,使其更符合自己的使用习惯和需求,从而提高了用户的体验。
  3. 提高工作效率:一些针对特定行业或应用场景的浏览器拓展程序,能够帮助用户提高工作效率,例如一些专业的开发工具、在线文档编辑器等。

当然,事物都有两面性,浏览器拓展程序也有它的缺陷:

  1. 安全性问题:一些浏览器拓展程序存在安全性问题,可能会窃取用户的个人信息或者造成其他不良影响。
  2. 影响性能:一些复杂的浏览器拓展程序可能会占用大量的系统资源,导致浏览器运行缓慢或崩溃。
  3. 不兼容性问题:一些浏览器拓展程序可能不兼容最新的浏览器版本,导致无法正常运行或者出现其他问题。

所以,这些功能通过插件的形式进行安装是非常合理的,想要好的体验,就需要牺牲一些东西,要么是性能,要么是安全。

鉴于Chrome是目前市场占有率最高的浏览器(Edge内核也是chromium,所以二者的拓展程序是通用的,二者在拓展程序方面可以视为一个浏览器),所以我们学习插件开发就以Chrome为例。

浏览器扩展架构

chrome 开发工具之扩展程序

我们学习一项新的技术之前都要看一下架构,下面是一个crx拓展程序可以包含的内容,其中manifest.json是拓展程序必须有的文件,

my-extension/                 # 扩展程序的根目录
├── manifest.json            # 扩展程序清单文件
├── background.html          # 后台页面
├── background.js            # 后台脚本
├── popup.html               # 弹出窗口页面
├── popup.js                 # 弹出窗口脚本
├── content_scripts/         # 内容脚本目录
│   ├── script1.js           # 自定义脚本1
│   ├── script2.js           # 自定义脚本2
│   └── style.css            # 自定义样式表
├── images/                  # 图标和资源目录
│   ├── icon16.png           # 扩展程序小图标(16x16)
│   ├── icon48.png           # 扩展程序中等图标(48x48)
│   └── icon128.png          # 扩展程序大图标(128x128)
└── vendor/                  # 第三方库目录(可选)
    └── jquery.min.js        # jQuery 库文件

一个 Chrome 插件应该包含以下文件和目录:

  1. manifest.json:这是扩展程序的清单文件,其中包含了扩展程序的名称、版本号、描述、图标、权限等信息。
  2. background.js 或 background.html:这是扩展程序的后台脚本或页面,用于处理事件和执行一些常驻的操作。你可以使用 JavaScript、HTML、CSS 等编写这个文件。
  3. popup.html:这是扩展程序的弹出窗口页面,通常用于展示一些 UI 控件和用户交互。你可以使用 HTML、CSS、JavaScript 等编写这个文件。
  4. popup.js:这是扩展程序弹出窗口的脚本文件,用于定义弹出窗口的行为和事件处理。你可以使用 JavaScript 编写这个文件。
  5. content_scripts:这是扩展程序的内容脚本,用于向浏览器注入自定义的脚本代码。

除了以上必须包含的文件和目录外,你还可以添加其他的文件和目录,例如图标文件、资源文件、第三方库等。

manifest.json

manifest.json 是扩展程序最重要的一个文件,它描述了扩展程序的配置和元信息,一个常见的manifest.json内容如下

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "description": "This is a sample extension",
  "icons": {
    "16": "icon16.png",
    "32": "icon32.png",
    "48": "icon48.png"
  },
  "action": {
    "default_title": "My Extension",
    "default_icon": "icon32.png",
    "default_popup": "popup.html"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      match: ["<all_urls>"],
      js: ["src/pages/content/index.js"]
    }
  ],
  "context_menus": [
    {
      id: 'popup_right_click',
          title: '右键菜单',
    }
  ],
  "permissions": [
    "activeTab",
    "storage",
    "contextMenus"
  ]
}

其中,各个字段含义如下:

  • "manifest_version":Manifest 文件的版本号,必须设置为 3。
  • "name":扩展程序的名称。
  • "version":扩展程序的版本号。
  • "description":扩展程序的描述信息。
  • "icons":扩展程序的图标列表。其中,"16""32""48" 分别表示不同尺寸的图标。
  • "action":指定浏览器工具栏按钮或弹出菜单图标的配置信息,用于扩展程序与用户进行交互。该字段允许通过点击浏览器工具栏按钮或者弹出菜单图标来启动应用程序,执行特定操作。
  • "content_scripts":指定应用在哪些网页中注入 JavaScript 脚本。该字段用于指定需要在哪些网站上运行应用程序的 JavaScript 脚本。可精确定位到特定域名、路径或 URL 参数等。
  • "background":指定后台页面或 Service Worker 的文件名。该字段用于指定在应用运行期间需要执行的后台脚本,用于处理事件和任务。
  • "permissions":扩展程序需要的权限列表。该字段用于指定扩展程序所需的各种权限,如访问网站、读取书签、管理浏览历史等等。需要用户明确授权后才能使用。

注意,Manifest V3 与 Manifest V2 有很大的差异,需要特别注意配置文件的格式和内容,并结合自身需求进行适当的调整和修改。V2好像快要放弃支持了,要学的话还是直接学V3吧。

content-scripts

Chrome 扩展中的 content scripts(内容脚本)是指可以在扩展程序的某些页面或者所有页面上注入 JavaScript 代码的功能。它可以用于改变网页的布局、监听网页事件、修改网页内容等,从而实现一系列与网页交互相关的功能。

具体的配置方式在上面的manifest中已经有示例了。

content-scripts和原始页面共享DOM,但是不共享JS,如要访问页面JS(例如某个JS变量),只能通过injected js来实现。

所谓的injected js 即通过content-scripts动态加载脚本的方法,在manifest.json中做如下声明(content script中用到的资源需要在web_accessible_resources声明)

{
  "name": "My Extension",
  "version": "1.0",
  "manifest_version": 3,
  "content_scripts": [
    {
      "matches": ["https://www.google.com/*"],
      "js": ["content-script.js"],
      "css": ["content-style.css"]
    }
  ],
  "web_accessible_resources": ["injected-script.js"]
}

然后在content-script.js中进行动态引入

const script = document.createElement('script');
script.src = chrome.runtime.getURL('injected-script.js');
document.body.appendChild(script);

background

background(后台脚本)是一个常驻的页面,它的生命周期是插件中所有类型页面中最长的,它随着浏览器的打开而打开,随着浏览器的关闭而关闭,所以通常把需要一直运行的、启动就运行的、全局的代码放在background里面。

在 background.js 文件中,我们可以使用 chrome.runtime API 来监听各种事件,并进行相应的处理。例如,下面的代码片段可以监听浏览器启动或扩展程序安装后的事件:

javascriptCopy Codechrome.runtime.onStartup.addListener(() => {
  console.log('Extension started up');
});

chrome.runtime.onInstalled.addListener((details) => {
  console.log(`Extension installed, version ${details.version}`);
});

需要注意的是,background 脚本与 content scripts ,它可以获得更高的访问权限,并且可以直接调用扩展程序的 API,因此在开发过程中需要特别注意安全性问题,避免将隐私数据暴露出去。而且它可以无限制跨域,也就是可以跨域访问任何网站而无需要求对方设置CORS

popup(弹出窗口)是指由用户主动点击扩展程序图标触发的一个浮动窗口,可以用于显示扩展程序的交互界面或者进行内部操作。

我们通过 "default_popup": "popup.html" 来指定弹出窗口的 HTML 文件。在该文件中,我们可以像编写普通的网页一样编写 JavaScript 代码,并且可以与扩展程序的其他部分直接进行交互。

例如

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .container {
      width: 200px;
      height: 600px;
      color: #409eff;
    }
  </style>
</head>
<body>
  <div class="container">
    popup UI
  </div>
</body>
</html>

通过扩展程序的开发者模式导入即可得到我们的简易插件如下

image-20230613223505429

devtools

devtools(开发者工具)是指一组 API 和 UI 面板,可以帮助扩展程序开发者调试和测试他们的扩展程序。例如Redux、Vue devtool等工具

image-20230613220242992.png

要编写一个devtool 扩展,首先在 manifest.json 文件中,添加 "devtools_page" 字段来指定 devtools 的 HTML 文件。例如:

{
  "name": "My Extension",
  "version": "1.0",
  "manifest_version": 3,
  "devtools_page": "devtools.html"
}

在 devtools.html 文件中,添加一个 <script> 标签来引入 devtools.js 文件。例如:

<!DOCTYPE html>
<html>
<head>
  <title>My Extension DevTools</title>
</head>
<body>
  <script src="devtools.js"></script>
</body>
</html>

在 devtools.js 文件中,使用 chrome.devtools.panels.create 方法创建一个新的面板,并指定该面板的标题、图标和页面。例如:

chrome.devtools.panels.create('My Panel', 'icon.png', 'panel.html');

使用 chrome.devtools.panels.create 方法创建了一个名为 “My Panel” 的面板,图标为 icon.png,页面为 panel.html。在 panel.html 文件中,我们可以像编写普通的网页一样编写 HTML、CSS 和 JavaScript 代码,并与 devtools 进行交互。

image-20230614204048036

在chrome 开发者文档中有一张关于devtools的图,它展示了devtools与页面及插件的通信。

image-20230614204355826

最后对比一下上面几种脚本的权限

JS种类 可访问的API DOM访问情况 JS访问情况 直接跨域
injected script 和普通JS无任何差别,不能访问任何扩展API 可以访问 可以访问 不可以
content script 只能访问 extension、runtime等部分API 可以访问 不可以 不可以
popup js 可访问绝大部分API,除了devtools系列 不可直接访问 不可以 可以
background js 可访问绝大部分API,除了devtools系列 不可直接访问 不可以 可以
devtools js 只能访问 devtools、extension、runtime等部分API 可以 可以 不可以

扩展 API

上面表格对比了几种脚本的权限,下面我们就来看下浏览器到底有哪些API(由于API是在太多,我们就不一一介绍,可以移步文档进行查看,我们这里仅介绍部分常用的API)。

action

除了通过manifest.json设置扩展程序托盘之外,还可以通过API来动态设置,action(只能用在manifest V3,manifest V2 中是browserAction)就是用来进行相关操作的。(下面的参数都是对象属性的方式传入,V3后的版本get方法都反悔Promise,V2及以前使用callback)

  • setIcon:设置扩展程序托盘图标,支持设置图标path或者imageData可以通过传入tabId来指定不同页面的不同action图标(tabId可选,不传则默认全局,下同);
  • setTitle/getTitle:设置/获取action的标题,通过tabId查询标题,通过tabIdtitle设置action的标题;
  • setBadgeText/getBadgeText:设置/获取action徽标的文本,通过tabId获取指定的徽标文本,通过tabIdtext设置对应tab的徽标(建议徽标不要超过4个字符);
  • setBadgeTextColor/getBadgeTextColor:设置/获取徽标的文本颜色,通过tabId获取指定的徽标文本颜色,通过tabIdcolor设置对应tab的徽标文本颜色;
  • setBadgeBackgroundColor/getBadgeBackgroundColor:设置/获取徽标的文本背景色,通过tabId获取指定的徽标背景颜色,通过tabIdcolor设置对应tab的徽标背景颜色;
  • setPopup/getPopup:设置/获取popup文档,根据tabId获取popup文档的路径(形如:chrome-extension://jcfbojffecidonfhggkbohjaeealgegb/popup.html),通过tabIdpopup设置对应tab的popup文档相对路径;
  • disable/enable:禁用/启用action;
  • openPopup:激活popup;
  • isEnabled:查询插件是否可用,可以通过传入tabId查询指定页面是否可用;
  • getUserSettings:返回与actions相关的用户设置。

contextMenus

右键菜单也是浏览器插件常用的功能(在使用右键菜单时需要在manifest中声明contextMenus权限),可以在manifest中配置右键菜单,也可以通过API动态创建

image-20230614223001315

通过contextMenus可以添加右键菜单,具体有以下API

  • create(createProperties: object, callback?: function):新建右键菜单项,设置项见附表
  • remove(id: string|number, callback?: function):通过 menuItemId 移除菜单项;
  • removeAll(callback?: function):移除通过当前插件添加的所有右键菜单;
  • update( id: string | number, updateProperties: object, callback?: function):通过ID 更新菜单项设置,更新属性和设置时一致。

附表

属性 作用
checked 复选框或单选框的初始状态
context 菜单项生效的范围,具体值见下方枚举
documentUrlPatterns 文档Url匹配,只在符合条件的页面下显示
enabled 是否可用
id 菜单项唯一标识(经测试为必填项)
parentId 父级标识
targetUrlPatterns 元素属性匹配,只在符合条件的元素上显示,例如img标签中的src、a标签中的href
title 菜单项标题
type 类型,可选值有normal, checkbox, radio, separator
visible 是否可见
onclick 点击事件,接收两个参数第一个参数是上下文信息,第二个参数是tab的详细信息

通过create创建右键菜单的时候在不同的元素上可以设置不同的内容,可以通过设置contexts设置为以下内容进行设置

enum ContextType {
  all, // 所有上下文环境
  page, // 当前活动页面
  frame, // 当前活动标签内任意一个子框架可见区域
  selection, // 用户选择的文本
  link, // 链接
  editable, // 可编辑元素,例如输入框
  image, // 图片
  video, // 视频
  audio, // 音频
  launcher, // 通过Chrome 浏览器启动的应用程序
  browser_action, // 右上角扩展程序托盘中的图标
  page_action, // url 输入框中的图标
  action,
}

storage

使用storage之前需要在manifest中声明storage权限。

storage有三种模式localsyncsession(我们称为area,其实还有一种managed,不常用,所以没加上),存储的数据格式是键值对。local是本地存储模式;sync是同步模式,数据会通过google账号进行多设备之间的同步;local和sync不会对数据加密,因此存储并不安全,session会将数据存放到内存中,不向脚本公开,浏览器关闭时会释放(chrome 112之前session可用空间为1MB,最新版为10MB)。

这几种模式下的方法都是一样的,例如

// 存储数据到本地存储
chrome.storage.local.set({'key': 'value'}, function() {
  console.log('Value is set to ' + value);
});

// 从本地存储中获取数据
chrome.storage.local.get(['key'], function(result) {
  console.log('Value currently is ' + result.key);
});

// 存储数据到同步存储
chrome.storage.sync.set({'key': 'value'}, function() {
  console.log('Value is set to ' + value);
});

// 从同步存储中获取数据
chrome.storage.sync.get(['key'], function(result) {
  console.log('Value currently is ' + result.key);
});

具体storage相关的API如下:

  • get:获取本地存储中指定的一个或多个key对应的值;
  • set:将数据存储在本地存储中,数据是由一个键值对组成的对象;
  • remove:移除一个或多个key对应的存储;
  • clear:清除已存储的数据;
  • getBytesInUse:获取已经使用的字节数;
  • setAccessLevel:为存储区域设置所需的访问级别,默认值将仅为受信任的上下文;
  • onChanged:通过onChanged.addListener存储内容发生变更之后触发事件,接受的参数是此次改变的内容(onChanged也可以不在area下绑定,则为全局绑定,每个area下的设置都会触发,此时回调回有第二个参数用于区分area)。

runtime

runtime 可用于消息通信(与浏览器、扩展程序和其他脚本进行通信,是扩展程序脚本之间通信的重要工具)、访问扩展和平台元数据、管理扩展生命周期和选项以及各种实用工具。

大部分runtime的API不需要权限,只有在使用sendNativeMessage和connetNative时需要声明nativeMessage权限。

常用的API如下:

  • connect(extensionId, connectInfo):与另一个扩展程序建立长连接并返回一个port;
  • connectNative(application):与桌面应用建立链接
  • getBackgroundPage:获取当前扩展程序的background,如果当前扩展没有background则报错;
  • getPlatformInfo:获取平台(操作系统等)信息;
  • getURL:将脚本资源转为绝对路径,一般用于content-script注入资源到页面;
  • openOptionsPage:打开设置页面(如果有的话);
  • requestUpdateCheck:立刻对扩展程序进行检查更新;
  • sendMessage(extensionId,message,options,callback):向其他脚本发送消息,并在接收到回复时执行指定的回调函数(callback的参数是响应的数据),chrome99+并且manifestV3下可以使用Promise,返回结果是响应数据;
  • sendNativeMessage(application, message, callback):向桌面应用发送消息,返回结果和sendMessage一样处理。

另外,runtime的事件用的比较多,这里列举一下常用的:

  • onConnect:当从扩展进程或content-script(通过runtime. connect)建立连接时触发;
  • onInstalled:首次安装扩展、更新到新版本以及更新到新版本时触发。
  • onMessage:当从扩展进程(通过runtime. sendMessage)或内容脚本(通过tabs.sendMessage)发送消息时触发。

最常用的就是消息通信了,这里简单代码示例下具体使用方法

// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message === 'get-data') {
    sendResponse(data);
  }
});

// content.js
chrome.runtime.sendMessage('get-data', (response) => {
  console.log('received data', response);
});

sendMessage和connect二者的区别在于一个是短连接一个是长连接,connect返回的port可以重复发送消息。

⚠️:如果sendMessage使用promise的形式接受返回值,需要在onMessage的处理程序中返回一个true,这意味着onMessage处理程序中不能使用async/await。

tabs

这里只说几个最常用的,和扩展脚本之间通信相关的:

query(queryInfo, callback):查询标签页,以数组形式返回;

sendMessage(tabId, message):向特定标签页脚本发送消息。

使用方式如下

const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
const response = await chrome.tabs.sendMessage(tab.id, {message: 'hello'})
console.log(response)

webRequest

webRequest 用于在网络请求发生时拦截、修改和阻止网络请求。需要在manifest中声明webRequest权限以及需要进行拦截的匹配域名,例如

{
  "permissions": [
    "webRequest",
    "*://*.google.com/"
  ]
}

关于webRequest的生命周期,google 开发者文档中有这么一张图

image-20230617203530717

这几个事件含义分别是:

  • onBeforeRequest (可选同步)

    当请求即将发生时触发。此事件在建立任何 TCP 连接之前发送,可用于取消或重定向请求。

  • onBeforeSendHeaders (可选同步)

    当请求即将发生并且初始标头已经准备好时触发。

  • onSendHeaders

    在所有扩展都有机会修改请求标头后触发,并显示最终 (*) 版本。该事件在标头发送到网络之前触发。此事件提供信息并异步处理。它不允许修改或取消请求。

  • onHeadersReceived (可选同步)

    每次收到 HTTP(S) 响应标头时触发。由于重定向和身份验证请求,每个请求可能会发生多次。此事件旨在允许扩展添加、修改和删除响应标头,例如传入的 Content-Type 标头。缓存指令在触发此事件之前处理,因此修改 Cache-Control 等标头不会影响浏览器的缓存。它还允许您取消或重定向请求。

  • onAuthRequired (可选同步)

    当请求需要用户身份验证时触发。可以同步处理此事件以提供身份验证凭据。请注意,扩展程序可能会提供无效凭据。注意不要通过反复提供无效凭据进入无限循环。这也可用于取消请求。

  • onBeforeRedirect

    在即将执行重定向时触发。重定向可以由 HTTP 响应代码或扩展程序触发。此事件提供信息并异步处理。它不允许您修改或取消请求。

  • onResponseStarted

    当接收到响应正文的第一个字节时触发。对于 HTTP 请求,这意味着状态行和响应标头可用。此事件提供信息并异步处理。它不允许修改或取消请求。

  • onCompleted

    当请求被成功处理时触发。

  • onErrorOccurred

    当请求无法成功处理时触发。

浏览器扩展通信

关于浏览器扩展内部的通信可以看下面这张图

image-20230618000914774

下表可以较为明显的列出几种脚本之间的通信方式

content-script popup-js background-js
content-script - chrome.runtime.sendMessage chrome.runtime.connect chrome.runtime.sendMessage chrome.runtime.connect
popup-js chrome.tabs.sendMessage chrome.tabs.connect - chrome.runtime.sendMessage chrome.runtime.connect
background-js chrome.tabs.sendMessage chrome.tabs.connect chrome.extension.getViews -

background 和 popup 通信

popup 可以通过runtime.sendMessage向backgroud发送消息,也可以通过chrome.extension.getBackgroundPage()获取background,直接调用background的方法,可以访问background的dom,通过这种方式需要有一个background.html(manifestV3不再支持background.page,所以V3只能用sendMessage了)。

background可以通过chrome.extension.getViews获取popup示例,但是不常用

cosnt views = chrome.extension.getViews({ type: 'popup' });
views[0].postMessage({ message: 'Hello from background.js!' }, '*');

注意,通过 chrome.extension.getViews() 方法获取到的窗口列表中可能会包含多个浮动窗口和其他类型的窗口,因此需要根据具体情况找到其中的 popup.html 窗口,并向其发送消息。

background 和 content sctipt通信

background 可以通过chrome.tabs.sendMessage向content-script发送消息

// background
const [tab] = await chrome.tabs.query({active: true, currentWindow: true}) 
const response = await chrome.tabs.sendMessage(tabs[0].id, message) 
console.log(response);

// content-script
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse){
    if(request.type == 'test') alert(request.value);
    sendResponse('received!');
});

反过来,content-script可以通过chrome.runtime.sendMessage向background发送消息

// content-script
const response = await chrome.runtime.sendMessage(message) 
console.log(response);

// background
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse){
    if(request.type == 'test') alert(request.value);
    sendResponse('received!');
});

插件本地调试

在开发过程中进行本地调试可以通过开发者模式添加文件目录为已解压插件,具体操作如下

image-20230614204905784

在弹出的文件选择器选择我们的代码目录即可将插件进行安装。

实战:OTP 虚拟令牌

首先我们可以列举一下这个扩展程序需要实现哪些功能:

  • 解析并保存otpauth
  • 在扩展程序托盘显示已绑定的令牌数量
  • 通过 popup展示已保存的authpath对应的实时计算的code
  • 通过 popup UI 进行令牌的绑定
  • 通过右键选择文本添加authpath
  • 在输入框选择对应的令牌自动输入
  • 通过粘贴二维码图片解析otpauth
  • 通过右键菜单解析二维码图片

代码已上传github


前端小白