深入Chrome扩展开发

Chrome扩展(插件)开发全攻略

Posted by Li Yucang on July 24, 2023

深入Chrome扩展开发

前言

近几年,随着 IE 浏览器的落幕,Chrome(包括使用Chrome内核的浏览器)其实质上已经垄断整个浏览器行业了,就连微软的 Edge 都用上了 Chrome 内核。

可以说,Chrome 的标准,事实上就可以看做是行业标准了。可以预见,Chrome 的发展前景将会非常广阔。而提到 Chrome 就绕不开它的扩展,或者叫插件(Chrome Extension)。

本篇文章主要是从开发者角度来对其进行介绍,帮助开发者深入了解 Chrome 开发方式。

Chrome 扩展 v3 版本的文档已经发布。但是只有最新的 Chrome 才支持,考虑兼容性问题,扩展使用的仍然是 v2 版本,本篇文章中的相关介绍,也都是以 v2 版本为主。

扩展安装

扩展的来源及安装使用有很多种方式。

1、其中最常用的就是直接官网安装了。

此为主流方式,就是直接从应用商店进行安装,这种方式最为安全,因为能上应用商店的插件都经过官方审核了的。

2、也可以从其它渠道获取,然后打开开发者模式,直接加载解压代码包。

这种方式也是我们在开发调试过程中的使用方式。

自己开发的或者受信任的朋友给予的插件文件代码包,通常是一个包含js、html、css、json文件的文件夹。

非官方渠道或者未上架的插件需要自己能够判断其安全性,插件是否有违规行为。

插件地址,直接在chrome浏览器地址栏输入地址: chrome://extensions/

3、除了上述两种外,还有一种就是“野生插件”了。

野生插件风险较大,非专业人员或非可信来源的插件使用需谨慎。

野生插件包括从网上各大公司官网、网上论坛、或其它地方下载的插件(注意辨别,安全风险较大)。

下载下来的文件名有的是以 “.crx” 为结尾的压缩包。这是官方插件格式的安装包,可以直接拖拽至 Chrome 插件页安装使用。

如果拖拽安装失败,也可以将其后缀手动改为 “.zip”,然后使用unzip解压软件进行解压,再通过开发者模式进行加载。

最后,插件虽好,但是要注意使用安全。非专业人员,不要加载不明来源、未知功能的插件

扩展工作方式

Chrome 扩展开发所用到的技术栈就是html/js/css。可以说就是纯“前端开发”,其界面是用 html+css 画的。

事件、行为、逻辑交互是用 javascript,就连数据请求也是用的前端最新异步请求API fetch 发起的。

所以说,Chrome 扩展开发并不是什么新鲜玩意儿,只是一个挂载到 Chrome 浏览器上的一个“扩展”模块。

这不就是网页应用吗?

插件是基于Web技术构建的,例如HTML、JavaScript和CSS。它们在单独的沙盒执行环境中运行并与Chrome浏览器进行交互。

我们的确可以把他看做是网页应用,当然,相比于纯网页页面他也有自己的特点:

1、有独立的入口,可以在浏览器右边的“插件”区域点击打开。

2、相比也网页有更多的功能特性,支持调用 Chrome 浏览器原生API,可跨浏览器 Tab 运行,生命周期不会不随着页面关闭而结束。

3、独立应用,无需部署,可发布到 Chrome 应用商店,作为一个独立的应用供其它用户使用。

交互界面与组成模块

一个扩展的组成模块如下图所示,整个 Chrome 扩展主要包含六个模块:扩展图标、插件弹窗、选项页、单独页、注入页面脚本、后台脚本。

各个模块互通信、协作、配合,就构成了一个完整的 Chrome 扩展。

当然,除了上面的六大模块,还有一个非常非常重要的组成部分,那就是 manifest.json。

manifest.json

这是整个 Chrome 扩展的核心,包含了整个插件的配置,也可以看做是整个插件的入口。

一个插件有什么功能,需要用到哪些文件,需要什么权限等都可以在配置里面体现出来。

/** 配置举例 */
{
  "name": "myFirstPlugin",
  "version": "0.1",
  "manifest_version": 2,
  "description": "我的第一个插件",
  "browser_action": {
    "default_popup": "popup.html",
    "default_title": "apihelper",
    "default_icon": "images/icon.png"
  },
  "permissions": [
    "scripting",
    "storage"
  ],
  "content_scripts": [
    {
      "matches": ["https://www.epoos.com/*"],
      "js": ["content-script.js"],
      "css": ["content-css.css"]
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "options_page": "options.html"
}

扩展图标

扩展图标即扩展在浏览器扩展区域显示的 logo,可以自定义,也可以缺省,缺省时默认使用插件名的缩写。

在如上 manifest.json 文件中的 action.default_icon 字段中配置,支持配置多个规格(数组)。

扩展弹窗

扩展弹窗的显示时机为当用户点击扩展图标之后弹出。

在如上 manifest.json 文件中的 action.default_popup 字段中配置。

其值是一个 html 文件,html文件内部可引用js/css等资源,可看做是一个独立页面。

其中 js 资源可调用浏览器原生 API。

选项页

选项页的显示时机为当用户在扩展图标上右键-选项可打开,点击之后打开新 Tab 页。

在如上 manifest.json 文件中的 options_page 字段中配置。

其值也是一个 html 文件,可以看做是独立页面,html 文件内部同样可以引用 js/css 等资源,多用做扩展的用户自定义配置。

其中 js 资源也可调用浏览器原生 API。

单独页

所谓单独页其实就是可以单独打开的页面,如上选项页其实就是一个单独页,只不过 Chrome 在右键菜单加了一个“选项”入口。除了选项页也可以是其它html页面,其路径拼接方式为:chrome-extension://id/{pageName}.html,id 为插件 id,pageName 为插件代码中的 html 文件名。可以用作插件的首页、介绍页、配置页等等。

注入页面脚本

注入脚本在如上 manifest.json 文件中的 content_scripts 字段中配置,其内容会被直接注入到目标网页的页面内容中去。

matches 字段表示需要注入脚本的网站地址规则,js和css字段分别表示注入页面的 js 代码和 css 代码。

注入的 js 代码能够操作页面 DOM,可以调用浏览器原生API,可以发起页面请求,但是它具有独立的执行空间,也就是说注入的 js 和页面本身的js脚本不能够直接互相调用。

可以同时在一个页面注入多个脚本,也可以在不同的页面注入多个不同的脚本。

后台脚本

后台脚本在如上 manifest.json 文件中的 background.service_worker 字段中配置。

从名字就可以看出来,这是一个运行在浏览器后台的脚本文件,其运行生命周期页面无关,浏览器打开多个 Tab 都只会共用同一个 background 脚本。

API 文档

前面说了,扩展应用其实就是特殊的 h5 前端应用,相比于传统的 h5 的功能, 扩展最大的优势就在于他可以直接调用 chrome 原生Api。

Chrome 扩展提供了许多特殊用途的 Api,囊括了本地存储、定时任务、Tab切换、网络请求、书签、屏幕截图、历史记录、文件下载、devTools信息、页面性能等在内的 100+ 个Api。

这里就不做一一说明,可以参考API 官方文档

官方文档是英文版的,国内也有好心人做了中文版的翻译版API 非官方中文翻译

在了解了大致功能之后,以后在开发过程中需要用到什么功能动态去查即可。

开发一个简单的扩展

Chrome插件并没有很严格的项目结构要求,比如src、public、components等等,因此我们如果去看很多插件的源码,会发现每个插件的项目结构,甚至项目下的文件名称都大相径庭;

我们在项目中创建一个最简单的manifest.json配置文件:

{
    // 插件名称
    "name": "Hello Extensions",
    // 插件的描述
    "description" : "Base Level Extension",
    // 插件的版本
    "version": "1.0",
    // 配置插件程序的版本号,主流版本是2,最新是3
    "manifest_version": 2
}

我们经常会点击右上角插件图标时弹出一个小窗口的页面,焦点离开时就关闭了,一般做一些临时性的交互操作;在配置文件中新增browser_action字段,配置popup弹框:

{
    "name": "Hello Extensions",
    "description" : "Base Level Extension",
    "version": "1.0",
    "manifest_version": 2,
    // 新增popup弹框
    "browser_action": {
      "default_popup": "popup.html",
      "default_icon": "popup.png"
    }
}

然后创建我们的弹框页面popup.html:

<html>
  <body>
    <h1>Hello Extensions</h1>
  </body>
</html>

点击图标后,插件显示popup.html。

为了用户方便点击,我们还可以在manifest.json中设置一个键盘快捷键的命令,通过快捷键来弹出popup页面:

{
  "name": "Hello Extensions",
  "description" : "Base Level Extension",
  "version": "1.0",
  "manifest_version": 2,
  "browser_action": {
    "default_popup": "popup.html",
    "default_icon": "popup.png"
  },
  // 新增命令
  "commands": {
    "_execute_browser_action": {
      "suggested_key": {
        "default": "Ctrl+Shift+F",
        "mac": "MacCtrl+Shift+F"
      },
      "description": "Opens popup.html"
    }
  }
}

这样我们的插件就可以通过按键盘上的Ctrl+Shift+F来弹出。

加载及调试插件

我们开发的插件需要在浏览器里面运行,打开插件标签页,打开开发者模式,点击加载已解压的扩展程序,选择项目文件夹,就可将开发中的插件加载进来。

开发中更改了代码,点击插件右下角刷新按钮即可重新加载

如果我们的代码中有错误,加载插件后,会显示红色的错误按钮:

点击错误按钮以查看错误的日志:

后台background

我们的插件安装后,popup页面也运行了;但是我们也发现了,popup页面只能做临时性的交互操作,用完就关了,不能存储信息或者和其他标签页进行交互等等;

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

background也是需要在manifest.json中进行配置,可以通过page指定一张网页,或者通过scripts直接指定一个js数组,Chrome会自动为js生成默认网页:

{
  "background": {
    // "page": "background.html",
    "scripts": ["background.js"],
    "persistent": true
  }
}

需要注意的是,page属性和scripts属性只需要配置一个即可,如果两个同时配置,则会报以下错误信息:

Only one of 'background.page', 'background.scripts', and 'background.service_worker' can be specified.

我们给background设置一个监听事件,当插件安装时打印日志:

// background.js
chrome.runtime.onInstalled.addListener(function () {
  console.log("插件已被安装");
});

点击查看视图旁边的背景页,看到我们设置的background:

storage存储

我们在插件安装时在storage中设置一个值,这将允许多个插件组件访问该值并进行更新操作:

//background.js
chrome.runtime.onInstalled.addListener(function () {
  // storage中设置值
  chrome.storage.sync.set({ color: "#3aa757" }, function () {
    console.log("storage init color value");
  });
  // 为特定的网址显示图标
  chrome.declarativeContent.onPageChanged.removeRules(undefined, function () {
    chrome.declarativeContent.onPageChanged.addRules([
      {
        conditions: [
          new chrome.declarativeContent.PageStateMatcher({
            pageUrl: { hostEquals: "baidu.com" },
          }),
        ],
        actions: [new chrome.declarativeContent.ShowPageAction()],
      },
    ]);
  });
});

chrome.declarativeContent用于精确地控制什么时候显示我们的页面按钮,或者需要在用户单击它之前更改它的外观以匹配当前标签页。

这里调用的chrome.storage和我们常用的localStorage和sessionStorage不是一个东西;由于调用到了storage和declarativeContent的API,因此我们需要在manifest中给插件注册使用的权限:

{
  // 新增
  "permissions": ["storage", "declarativeContent"],
  "background": {
    "scripts": ["background.js"],
    "persistent": true
  }
}

再次查看背景页的视图,我们就能看到打印的日志了;既然可以存储,那也能取出来,我们在popup中添加事件进行获取,首先我们新增一个触发的button:

<!-- popup.html -->
<html>
  <head>
    <style>
      button {
        width: 60px;
        height: 30px;
        outline: none;
      }
    </style>
  </head>
  <body>
    <button id="changeColor">change</button>
    <script src="popup.js"></script>
  </body>
</html>

我们再创建一个popup.js的文件,用来从storage存储中拿到颜色值,并将此颜色作为按钮的背景色:

let changeColor = document.getElementById("changeColor");

changeColor.onclick = function (el) {
  chrome.storage.sync.get("color", function (data) {
    changeColor.style.backgroundColor = data.color;
  });
};

如果需要调试popup页面,可以在弹框中右击 => 检查,在DevTools中进行调试查看。

我们多次打开popup页面,发现页面每次点开按钮都会恢复最开始的默认状态。

获取浏览器tabs

现在,我们获取到了storage中的值,需要逻辑来进一步与用户交互;更新popup.js中的交互代码:

// popupjs
changeColor.onclick = function (el) {
  chrome.storage.sync.get("color", function (data) {
    let { color } = data;
    chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
      chrome.tabs.executeScript(tabs[0].id, {
        code: 'document.body.style.backgroundColor = "' + color + '";',
      });
    });
  });
};

chrome.tabs的API主要是和浏览器的标签页进行交互,通过query找到当前的激活中的tab,然后使用executeScript向标签页注入脚本内容。

manifest同样需要activeTab的权限,来允许我们的插件使用tabs的API。

{
  "name": "Hello Extensions",
  // ...
  "permissions": ["storage", "declarativeContent", "activeTab"],
}

重新加载插件,我们点击按钮,会发现当前页面的背景颜色已经变成storage中设置的色值了;但是某些用户可能希望使用不同的色值,我们给用户提供选择的机会。

颜色选项页面

现在我们的插件功能还比较单一,只能让用户选择唯一的颜色;我们可以在插件中加入选项页面,以便用户更好的自定义插件的功能。

在程序目录新增一个options.html文件:

<!DOCTYPE html>
<html>
  <head>
    <style>
      button {
        height: 30px;
        width: 30px;
        outline: none;
        margin: 10px;
      }
    </style>
  </head>
  <body>
    <div id="buttonDiv"></div>
    <div>
      <p>选择一个不同的颜色</p>
    </div>
  </body>
  <script src="options.js"></script>
</html>

然后添加选择页面的逻辑代码options.js:

let page = document.getElementById("buttonDiv");
const kButtonColors = ["#3aa757", "#e8453c", "#f9bb2d", "#4688f1"];
function constructOptions(kButtonColors) {
  for (let item of kButtonColors) {
    let button = document.createElement("button");
    button.style.backgroundColor = item;
    button.addEventListener("click", function () {
      chrome.storage.sync.set({ color: item }, function () {
        console.log("color is " + item);
      });
    });
    page.appendChild(button);
  }
}
constructOptions(kButtonColors);

上面代码中预设了四个颜色选项,通过onclick事件监听,生成页面上的按钮;当用户单击按钮时,将更新storage中存储的颜色值。

options页面完成后,我们可以将其在manifest的options_page进行注册:

{
  "name": "Hello Extensions",
  //...
  "options_page": "options.html",
  //...
  "manifest_version": 2
}

重新加载我们的插件,点击详情,滚动到底部,点击扩展程序选项来查看选项页面。

或者可以在浏览器右上角插件图标上右击 => 选项。

扩展功能进阶

通过上面一个简单的小扩展,相信大家对扩展的功能和组件都有了一个大致的了解,知道了每个组件在其中发挥的作用;但这还只是扩展的一小部分功能,下面我们对扩展每个部分的功能以及组件做一个更深入的了解。

background

background是插件的事件处理程序,它包含对插件很重要的浏览器事件的监听器。background处于休眠状态,直到触发事件,然后执行指示的逻辑;一个好的background仅在需要时加载,并在空闲时卸载。

background监听的一些浏览器事件包括:

  • 插件程序首次安装或更新为新版本。

  • 后台页面正在监听事件,并且已调度该事件

  • 内容脚本或其他插件发送消息

  • 插件中的另一个视图(例如弹出窗口)调用runtime.getBackgroundPage

加载完成后,只要触发某个事件,background就会保持运行状态;在上面manifest中,我们还指定了一个persistent属性:

{
  "background": {
    "scripts": ["background.js"],
    "persistent": true
  }
}

persistent属性定义了插件常驻后台的方式;当其值为true时,表示插件将一直在后台运行,无论其是否正在工作;当其值为false时,表示插件在后台按需运行,这就是Chrome后来提出的Event Page(非持久性后台)。Event Page是基于事件驱动运行的,只有在事件发生的时候才可以访问;这样做的目的是为了能够有效减小插件对内存的消耗,如非必要,请将persistent设置为false。

官方推荐将其设置为false,并且在 v3 中,persistent 属性被取消,取而代之的是使用service script指定后台脚本,其中的脚本将以service worker的方式运行。

alarms

一些基于DOM页面的计时器(例如window.setTimeout或window.setInterval),如果在非持久后台休眠时进行了触发,可能不会按照预定的时间运行:

let timeout = 1000 * 60 * 3;  // 3 minutes in milliseconds
window.setTimeout(function() {
  alert('Hello, world!');
}, timeout);

Chrome提供了另外的API,alarms:

chrome.alarms.create({delayInMinutes: 3.0})

chrome.alarms.onAlarm.addListener(function() {
  alert("Hello, world!")
});

browserAction、pageAction

在Chrome ext v3版本中,browserAction与pageAction这两个区别并不大的功能被统一合并成了action功能。

action和 background 一样,可以访问 Chrome 提供的除devtools外的所有 API。

我们这里以browserAction为例进行讲解,在browserAction的配置中,我们可以提供多种尺寸的图标,Chrome会选择最接近的图标并将其缩放到适当的大小来填充;如果没有提供确切的大小,这种缩放会导致图标丢失细节或看起来模糊。

{
  // ...
  "browser_action": {
    "default_icon": {                // optional
      "16": "images/icon16.png",     // optional
      "24": "images/icon24.png",     // optional
      "32": "images/icon32.png"      // optional
    },
    "default_title": "hello popup",  // optional
    "default_popup": "popup.html"    // optional
  },
}

也可以通过调用browserAction.setPopup动态设置弹出窗口。

chrome.browserAction.setPopup({popup: 'popup_new.html'});

Tooltip

要设置提示文案,使用default_title字段,或者调用browserAction.setTitle函数。

chrome.browserAction.setTitle({ title: "New Tooltip" });

Badge

Badge(徽章)就是在图标上显示的一些文本内容,用来详细显示插件的提示信息;由于Bage的空间有限,因此最多显示4个英文字符或者2个函数;badge无法通过配置文件来指定,必须通过代码实现,设置badge文字和颜色可以分别使用browserAction.setBadgeText()和browserAction.setBadgeBackgroundColor():

chrome.browserAction.setBadgeText({ text: "new" });
chrome.browserAction.setBadgeBackgroundColor({ color: [255, 0, 0, 255] });
// or 颜色字符串
// chrome.action.setBadgeBackgroundColor({color: '#4688F1'});

content-scripts

content-scripts(内容脚本)是在网页上下文中运行的文件。通过使用标准的文档对象模型(DOM),它能够读取浏览器访问的网页的详细信息,对其进行更改,并将信息传递给其父级插件。内容脚本相对于background还是有一些访问API上的限制,它可以直接访问以下chrome的API:

  • i18n

  • storage

  • runtime:

    • connect

    • getManifest

    • getURL

    • id

    • onConnect

    • onMessage

    • sendMessage

内容脚本运行于一个独立、隔离的环境,它不会和主页面的脚本或者其他插件的内容脚本发生冲突,当然也不能调用其上下文和变量。假设我们在主页面中定义了变量和函数:

<html lang="en">
  <head>
    <title>Document</title>
  </head>
  <body>
    <script>
      const a = { a: 1, b: "2" };
      const b = { a: 1, b: "2", c: [] };
      function add(a, b){ return a + b };
    </script>
  </body>
</html>

由于隔离的机制,在内容脚本中调用add函数会报错:Uncaught ReferenceError: add is not defined。

内容脚本分为以代码方式或声明方式注入。

代码方式注入

对于需要在特定情况下运行的代码,我们需要使用代码注入的方式;在上面的popup页面中,我们就是将内容脚本以代码的方式进行注入到页面中:

chrome.tabs.executeScript(tabs[0].id, {
  code: 'document.body.style.backgroundColor = "red";',
});

或者可以注入整个文件。

chrome.tabs.executeScript(tabs[0].id, {
  file: "contentScript.js",
});

声明式注入

在指定页面上自动运行的内容脚本,我们可使用声明式注入的方式;以声明方式注入的脚本需注册在manifest文件的content_scripts属性下。它们可以包括JS文件或CSS文件。

{
  "content_scripts": [
    {
      // 必需。指定此内容脚本将被注入到哪些页面。
      "matches": ["https://*.lyc.com/*"],
      "css": ["myStyles.css"],
      "js": ["contentScript.js"]
    }
  ]
}

声明式注入除了matches必须外,还可以包含以下字段,来自定义指定页面匹配:

Name Type Description
exclude_matches 字符串数组 可选。排除此内容脚本将被注入的页面。
include_globs 字符串数组 可选。 在 matches 后应用,以匹配与此 glob 匹配的URL。旨在模拟 @exclude 油猴关键字。
exclude_globs 字符串数组 可选。 在 matches 后应用,以排除与此 glob 匹配的URL。旨在模拟 @exclude 油猴关键字。

声明匹配URL可以使用Glob属性,Glob属性遵循更灵活的语法。可接受的Glob字符串可能包含“通配符”星号和问号的URL。星号*匹配任意长度的字符串,包括空字符串,而问号?匹配任何单个字符。

{
  "content_scripts": [
    {
      "matches": ["https://*.lyc.com/*"],
      "exclude_matches": ["*://*/*business*"],
      "include_globs": ["*lyc.com/???s/*"],
      "exclude_globs": ["*science*"],
      "js": ["contentScript.js"]
    }
  ]
}

将JS文件注入网页时,还需要控制文件注入的时机,由run_at字段控制;首选的默认字段是document_idle,但如果需要,也可以指定为 “document_start” 或“document_end”。

{
  "content_scripts": [
    {
      "matches": ["https://*.lyc.com/*"],
      "run_at": "document_idle",
      "js": ["contentScript.js"]
    }
  ]
}

三个字段注入的时机区别如下:

Name Type Description
document_idle string 首选。 尽可能使用 “document_idle”。浏览器选择一个时间在 “document_end” 和window.onload 事件触发后立即注入脚本。 注入的确切时间取决于文档的复杂程度以及加载所需的时间,并且已针对页面加载速度进行了优化。在 “document_idle” 上运行的内容脚本不需要监听 window.onload 事件,因此可以确保它们在 DOM 完成之后运行。如果确实需要在window.onload 之后运行脚本,则扩展可以使用 document.readyState 属性检查 onload 是否已触发。
document_start string 在 css 文件之后,但在构造其他 DOM 或运行其他脚本前注入。
document_end string 在 DOM 创建完成后,但在加载子资源(例如 images 和 frames )之前,立即注入脚本。

消息通信

尽管内容脚本的执行环境和托管它们的页面是相互隔离的,但是它们共享对页面DOM的访问;如果内容脚本想要和插件通信,可以通过onMessage和sendMessage

// contentScript.js
chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) {
  console.log("content-script收到的消息", message);
  sendResponse("我收到了你的消息!");
});

chrome.runtime.sendMessage(
  { greeting: "我是content-script呀,我主动发消息给后台!" },
  function (response) {
    console.log("收到来自后台的回复:" + response);
  }
);

更多消息通信的在后面我们会进行详细的总结。

contextMenus

contextMenus可以自定义浏览器的右键菜单(也有叫上下文菜单的),主要是通过chrome.contextMenusAPI实现;在manifest中添加权限来开启菜单权限:

{
  // ...
  "permissions": ["contextMenus"],
  "icons": {
    "16": "contextMenus16.png",
    "48": "contextMenus48.png",
    "128": "contextMenus128.png"
   }
}

通过icons字段配置contextMenus菜单旁边的图标:

我们可以在background中调用contextMenus.create来创建菜单,这个操作应该在runtime.onInstalled监听回调执行:

chrome.contextMenus.create({
  id: "1",
  title: "Test Context Menu",
  contexts: ["all"],
});
//分割线
chrome.contextMenus.create({
  type: "separator",
});
// 父级菜单
chrome.contextMenus.create({
  id: "2",
  title: "Parent Context Menu",
  contexts: ["all"],
});
chrome.contextMenus.create({
  id: "21",
  parentId: "2",
  title: "Child Context Menu1",
  contexts: ["all"],
});
// ...

如果我们的插件创建多个右键菜单,则Chrome会自动将其折叠为一个父菜单。

contextMenus创建对象的属性可以在附录里面找到;我们看到在title属性中有一个%s的标识符,当contexts为selection,使用%s来表示选中的文字;我们通过这个功能可以实现一个选中文字调用百度搜索的小功能:

chrome.contextMenus.create({
  id: "1",
  title: "使用百度搜索:%s",
  contexts: ["selection"],
  onclick: function (params) {
    chrome.tabs.create({
      url:
        "https://www.baidu.com/s?ie=utf-8&wd=" +
        encodeURI(params.selectionText),
    });
  },
});

效果如下:

contextMenus还有一些API可以调用:

// 删除某一个菜单项
chrome.contextMenus.remove(menuItemId);
// 删除所有自定义右键菜单
chrome.contextMenus.removeAll();
// 更新某一个菜单项
chrome.contextMenus.update(menuItemId, updateProperties);
// 监听菜单项点击事件
chrome.contextMenus.onClicked.addListener(function(OnClickData info, tabs.Tab tab)

override

覆盖页面(override)是一种将Chrome默认的特定页面替换为插件程序中的HTML文件。除了HTML之外,覆盖页面通常还有CSS和JS代码;插件可以替换以下Chrome的页面。

  • 书签管理器

    • 当用户从 Chrome 菜单中选择书签管理器菜单项或在 Mac 上从书签菜单中选择书签管理器项时出现的页面。您也可以通过输入 URL chrome://bookmarks来访问此页面。
  • 历史记录

    • 当用户从 Chrome 菜单中选择历史记录菜单项或在 Mac 上从历史记录菜单中选择显示完整历史记录项时出现的页面。您也可以通过输入URL chrome://history来访问此页面。
  • 新标签

    • 当用户创建新标签或窗口时出现的页面。您也可以通过输入 URL chrome://newtab来访问此页面。

像我们熟知的Momentum插件,就是覆盖了新标签页面。

需要注意的是:单个插件只能覆盖某一个页面。例如,插件程序不能同时覆盖书签管理器和历史记录页面。

在manifest进行如下配置:

{
  "chrome_url_overrides": {
    "newtab": "newtab.html",
    // "history": "history.html",
    // "bookmarks": "bookmarks.html"
  }
}

覆盖newtab效果如下:

如果我们覆盖多个特定页面,Chrome加载插件时会直接报错:

storage

用户在操作时,会产生一些用户数据,插件需要在本地存储这些数据,在需要调用的时候再拿出来;Chrome推荐使用chrome.storage的API,该API经过优化,提供和localStorage相同的存储功能;不推荐直接存在localStorage中,两者主要有以下区别:

  • 用户数据使用chrome.storage存储可以和Chrome的同步功能自动同步。

  • 插件的内容脚本可以直接访问用户数据,而无需background。

  • chrome.storage可以直接存储对象,而localStorage是存储字符串,需要再次转换

如果要使用storage的自动同步,我们可以使用storage.sync:

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);
});

当Chrome离线时,Chrome会将数据存储在本地。下次浏览器在线时,Chrome会同步数据。即使用户禁用同步,storage.sync仍将工作。

不需要同步的数据可以用storage.local进行存储:

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);
});

如果我们想要监听storage中的数据变化,可以用onChanged添加监听事件;每当存储中的数据发生变化时,就会触发该事件:

// background.js

chrome.storage.onChanged.addListener(function (changes, namespace) {
  for (let [key, { oldValue, newValue }] of Object.entries(changes)) {
    console.log(
      `Storage key "${key}" in namespace "${namespace}" changed.`,
      `Old value was "${oldValue}", new value is "${newValue}".`
    );
  }
});

devtools

用过Vue或者React的devtools的童鞋应该见过这样新增的扩展面板:

DevTools可以为Chrome的DevTools添加功能,它可以添加新的UI面板和侧边栏,与检查的页面交互,获取有关网络请求的信息等等;它可以访问以下特定的API:

  • devtools.inspectedWindow

  • devtools.network

  • devtools.panels

DevTools扩展的结构与任何其他扩展一样:它可以有一个背景页面、内容脚本和其他项目。此外,每个DevTools扩展都有一个DevTools页面,可以访问DevTools的API。

配置devtools不需要权限,只要在manifest中配置一个devtools.html:

{
  "devtools_page": "devtools.html",
}

devtools.html中只引用了devtools.js,如果写了其他内容也不会展示:

<!DOCTYPE html>
<html lang="en">
  <head> </head>
  <body>
    <script type="text/javascript" src="./devtools.js"></script>
  </body>
</html>

在项目中新建devtools.js:

// devtools.js
// 创建扩展面板
chrome.devtools.panels.create(
  // 扩展面板显示名称
  "DevPanel",
  // 扩展面板icon,并不展示
  "panel.png",
  // 扩展面板页面
  "Panel.html",
  function (panel) {
    console.log("自定义面板创建成功!");
  }
);

// 创建自定义侧边栏
chrome.devtools.panels.elements.createSidebarPane(
  "Sidebar",
  function (sidebar) {
    sidebar.setPage("sidebar.html");
  }
);

这里调用create创建扩展面板,createSidebarPane创建侧边栏,每个扩展面板和侧边栏都是一个单独的HTML页面,其中可以包含其他资源(JavaScript、CSS、图像等)。

DevPanel

DevPanel面板是一个顶级标签,和Element、Source、Network等是同一级,在一个devtools.js可以创建多个;在Panel.html中我们先设置2个按钮:

<!DOCTYPE html>
<html lang="en">
  <head></head>
  <body>
    <div>dev panel</div>
    <button id="check_jquery">检查jquery</button>
    <button id="get_all_resources">获取所有资源</button>
    <script src="panel.js"></script>
  </body>
</html>

panel.js中我们使用devtools.inspectedWindow的API来和被检查窗口进行交互:

document.getElementById("check_jquery").addEventListener("click", function () {
  chrome.devtools.inspectedWindow.eval(
    "jQuery.fn.jquery",
    function (result, isException) {
      if (isException) {
        console.log("the page is not using jQuery");
      } else {
        console.log("The page is using jQuery v" + result);
      }
    }
  );
});

document
  .getElementById("get_all_resources")
  .addEventListener("click", function () {
    chrome.devtools.inspectedWindow.getResources(function (resources) {
      console.log(resources);
    });
  });

eval函数为插件提供了在被检查页面的上下文中执行JS代码的能力,而getResources获取页面上所有加载的资源;我们找到一个页面,然后右击检查打开调试工具,发现在最右侧多了一个DevPanel的tab页,点击我们的调试按钮,那么日志在哪里能看到呢?

我们在调试工具上右击检查,再开一个调试工具,这个就是调试工具的调试工具.

最终两个调试工具的效果如下:

Sidebar

回到devtools.js,我们使用createSidebarPane创建了侧边栏面板,并且设置为sidebar.html,最终呈现在Element面板的最右侧:

有几种方法可以在侧边栏中显示内容:

  • HTML内容。调用setPage以指定要在窗格中显示的 HTML 页面。

  • JSON数据。将JSON对象传递给setObject.

  • JavaScript表达式。将表达式传递给setExpression

通过JS表达式,我们可以很方便进行页面查询,比如,查询页面上所有的img元素:

chrome.devtools.panels.elements.createSidebarPane(
  "All Images",
  function (sidebar) {
    sidebar.setExpression('document.querySelectorAll("img")', "All Images");
  }
);

另外,我们可以通过elements.onSelectionChanged监听事件,在Element面板选中元素更改后,更新侧边栏面板的状态;例如,可以将我们关心的一些元素的样式进行实时展示在侧边面板,方面查看:

var page_getProperties = function () {
  if ($0 instanceof HTMLElement) {
    return {
      tag: $0.tagName.toLocaleLowerCase(),
      class: $0.classList,
      width: window.getComputedStyle($0)["width"],
      height: window.getComputedStyle($0)["height"],
      margin: window.getComputedStyle($0)["margin"],
      padding: window.getComputedStyle($0)["padding"],
      color: window.getComputedStyle($0)["color"],
      fontSize: window.getComputedStyle($0)["fontSize"],
    };
  } else {
    return {};
  }
};

chrome.devtools.panels.elements.createSidebarPane(
  "Select Element",
  function (sidebar) {
    function updateElementProperties() {
      sidebar.setExpression(
        "(" + page_getProperties.toString() + ")()",
        "select element"
      );
    }
    updateElementProperties();
    chrome.devtools.panels.elements.onSelectionChanged.addListener(
      updateElementProperties
    );
  }
);

notifications

Chrome提供chrome.notifications的API来推送桌面通知;同样也需要先在manifest中注册权限:

{
  "permissions": [
    "notifications"
  ],
}

在background调用创建即可

// background.js
chrome.notifications.create(null, {
  type: "basic",
  iconUrl: "drink.png",
  title: "喝水小助手",
  message: "看到此消息的人可以和我一起来喝一杯水",
});

效果如下:

webRequest

通过webRequest的API可以对浏览器发出的任何HTTP请求进行拦截、组织或者修改;可以拦截的请求还包括脚本、样式的GET请求以及图片的链接;我们也需要在manifest中配置权限才能使用API:

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

权限中还需要声明拦截请求的URL,如果你想拦截所有的URL,可以使用*://*/*(不过不推荐这么做,数据会非常多),如果我们想以阻塞方式使用Web请求API,则需要用到webRequestBlocking权限。

比如我们可以对拦截的请求进行取消:

chrome.webRequest.onBeforeRequest.addListener(
  function (detail) {
    return {cancel: details.url.indexOf("example.com") != -1};;
  },
  { urls: ["<all_urls>"] },
  ["blocking"]
);

omnibox

omnibox是向用户提供搜索建议的一种方式。先来看个gif图以便了解一下这东西到底是个什么鬼:

先在manifest.json中指定一个关键字以提供搜索建议(只能设置一个关键字)

{
	"omnibox": { "keyword" : "go" },
}

在background.js中监听相关事件

// 输入框内容变化时触发,suggest用以提示做输入建议
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
	if(!text) return;
	if(text == '美女') {
		suggest([
			{content: '中国' + text, description: '你要找“中国美女”吗?'}
		])
	}
})

// 当用户接收关键字建议时触发
chrome.omnibox.onInputEntered.addListener((text) => { });

消息通信

开发时主要会用到 content-script与 popup 和 background 之间的通信,通信分为短链接和长链接。

两者的通信其实就是进程间的通信,通信内容必须可以被序列化,可以理解消息体会被JSON.stringify后进行传递。所以消息体中不能发送function、symbol、Map等数据。

background中可以通过chrome.extension.getViews({type:’popup’})来获取已打开的popup,进而访问其中的属性、方法。不过前提是popup弹框已经展示,否则获取到的views是空数组。

popup可以通过chrome.extension.getBackgroundPage或者chrome.runtime.getBackgroundPage获取到background的window,进而访问其属性和方法。

popup 和 background 给 content-script 发送消息

短链接

接收方 content-script 需要先完成消息事件的监听

const handleMessage = (message, sender, sendResponse) => {  }
chrome.runtime.onMessage.addListener(handleMessage)
  • message: 消息内容

  • sender: 发送者信息

  • sendResponse: 回复消息的方法

发送方 popup | background 调用API 发送消息

// 封装获取当前选中的tab标签方法
const getCurrentTab = () => new Promise((resolve, reject) => {
  chrome.tabs.query({active: true, currentWindow: true}, ([tab]) => {
    tab?.id ? resolve(tab) : reject('not found active tab')
  })
})

const tab = await getCurrentTab()
// 发送消息
chrome.tabs.sendMessage(tab.id, {greeting: "hello"}, (response) => { })

tabs.sendMessage的三个参数分别是

  • 要与之通信的tab标签页的id

  • 消息体

  • 响应的回调函数:这个就是上面的 sendResponse 函数

短链接注意事项:

  • sendResponse只能使用一次,不能多次使用。

  • 默认情况下,handleMessage 函数执行结束,消息通道就会关闭,此时的sendResponse已经无效。也就是说sendResponse不能异步使用。

  • 如果需要异步使用sendResponse,需要在handleMessage中明确的写下return true,这样消息通道会一直保持,直到sendResponse被调用。

长链接

接收方 content-script 需要先完成消息事件的监听

// 监听长链接 链接事件
chrome.runtime.onConnect.addListener(port => {
  // 可以根据 name 来区分不同的长链接逻辑
  if (port.name === 'knockknock') {
    // 给另一端发送消息
    port.postMessage()

    // 监听另一端的消息
    port.onMessage.addListener(message => {})
  }
})

发送方 popup | background 调用API 发送消息

const tab = await getCurrentTab()

// 建立链接
const port = chrome.tabs.connect(tab.id, {name: "knockknock"})

// 给另一端发送消息
port.postMessage()

// 监听另一端的消息
port.onMessage.addListener(message => {})

port 端口对象:

  • name: 端口名称

  • disconnect: 关闭端口

  • postMessage: 发送消息

  • onDisconnect: 监听端口关闭事件

  • onMessage: 监听端口消息事件

  • sender: 发送者的信息

content-script —> popup | background

content-script 给 popup 和 background 发送消息

两者的逻辑其实是一样的,只不过popup | background给content-script发送消息时,使用的是chrome.tabs API,需要指定一个tab的id。

而content-script给popup | background发送消息时,使用的时chrome.runtime API,不需要id。

短链接

接收方 popup | background 需要先完成消息事件的监听

const handleMessage = (message, sender, sendResponse) => {  }
chrome.runtime.onMessage.addListener(handleMessage)

发送方 content-script 调用 API 发送消息

// tabs 改为了 runtime
chrome.runtime.sendMessage({greeting: "hello"}, (response) => { })

如果popup和background都使用了runtime.onMessage监听了事件,那么当content-script发送了消息,两者都会接到。 但是sendResponse只有一个,一个先用了后者就无法使用了。

这里还存在一个坑,我们下节再说。

长链接

接收方 popup | background 需要先完成消息事件的监听

// 和上面一模一样
chrome.runtime.onConnect.addListener(port => {
  if (port.name === 'knockknock') {
    port.postMessage()

    port.onMessage.addListener(message => {})
  }
})

发送方 content-script 调用API 发送消息

// tabs 改为了 runtime
const port = chrome.runtime.connect({name: "knockknock"})

port.postMessage()
port.onMessage.addListener(message => {})

可以看到两者出了API的调用之外几乎没有区别,具体由谁主动发送消息,由谁来监听,需要根据实际需求来决定。

回复content-script短链接消息踩坑

说一下在短链接中,popup | background 回复 content-script 时的坑。

问题的前置条件:

  • sendResponse 需要异步发送。

  • popup和background都使用runtime.onMessage监听了content-script发来的消息。

当遇到上述场景时,会发现调用sendResponse后无法回复消息。

原因也很简单,我们在上面已经说过了,当sendResponse需要异步发送时,需要明确的在runtime.onMessage监听事件中返回true,但是由于有两者都监听了,那么其中一个可能就会事先返回undefined,这就导致了消息通道的提前关闭。

解决办法呢也很简单,我们需要将发送给popup和background的消息区分并封装,并将是否是异步消息发送给接收方。

代码如下

// 消息格式
interface RuntimeMessage<T = string> {
  type: T
  payload: any
  receiver?: 'bgs' | 'popup'
  isAsync?: boolean
}
// 封装发送消息
const sendMessageToRuntime = (
  msg: RuntimeMessage,
  cb?: LooseFunction,
) => {
  // 当传入callback时,默认这是一个异步消息
  if (msg.isAsync === void 0 && isFunction(cb)) {
    msg.isAsync = true
  }
  chrome.runtime.sendMessage(msg, cb)
}
// 使用
sendMessageToRuntime({
  type: 'crossFetch',
  payload: {...},
  receiver: 'bgs',
}, (response) => {})

// background.js
chrome.runtime.onMessage.addListener(
  (
    {
      type,
      payload,
      receiver,
      isAsync,
    },
    sender,
    sendResponse,
  ) => {
    if (receiver === 'bgs') {...}
    return isAsync
  },
)

// popup.js
chrome.runtime.onMessage.addListener(
  (
    {
      type,
      payload,
      receiver,
      isAsync,
    },
    sender,
    sendResponse,
  ) => {
    if (receiver === 'popup') {...}
    return isAsync
  },
)

两个tab签之间的通信

某些场景下可能需要实现两个tab签之间的通信,而常规的JS手段无法与另一个tab页面建立通信(新tab页面地址是多次重定向的结果)。

这里主要演示一下由background作为消息通道,为两个tab签建立通信。

// tab页面1 content-script
const port = chrome.runtime.connect({
  name: 'createTabAndConnect',
})
// 创建目标tab
port.postMessage({
  type: 'createTab',
  // tab 信息
  payload: {...},
})
// 监听消息
port.onMessage.addListener(handleMessage)
// 发送消息
port.postMessage({
  type: 'message',
  payload: {...}
})
// tab页面2 content-script
chrome.runtime.onConnect.addListener(port => {
  if (port.name === 'createTabAndConnect') {
    // 监听消息
    port.onMessage.addListener(handleMessage)
    // 发送消息
    port.postMessage({
      type: 'message',
      payload: {...}
    })
  }
})
// background.js
port.onMessage.addListener(async ({ type, payload }) => {
  switch (type) {
    case 'createTab':
      {
        // 创建tab
        const tab = await new Promise(resolve => {
          chrome.tabs.create(payload, resolve)
        })
        // 监听tab 页面的状态
        chrome.tabs.onUpdated.addListener((id, info) => {
          if (id === tab.id) {
            // 加载完成
            if (info.status === 'complete') {
              // 建立链接
              tabPort = chrome.tabs.connect(id, {
                name: port.name,
              })
              // 监听消息
              tabPort.onMessage.addListener(msg => {
                // 中转tabPort的消息给port
                port.postMessage({
                  type: 'message',
                  payload: msg,
                })
              })
            }
          }
        })
      }
      return
    case 'message':
      // 中转port的消息给tabPort
      tabPort?.postMessage(payload)
      return
  }
})

实用扩展推荐

介绍了这么多扩展的开发,我们来介绍一下应用市场上的优秀扩展,这些插件能够帮助我们在平时的开发中提高生产效率。

Adblock Plus

Adblock Plus是一款可以屏蔽广告以及任何你想屏蔽元素的软件;它不仅内置了一些过滤规则,可以自动屏蔽广告,还可以自行添加屏蔽内容。

选择拦截元素,淡黄色框住的内容就是拦截的内容

Axure RP Extension for Chrome

Axure RP Extension for Chrome是原型设计工具Axure RP的Chrome浏览器插件,chrome浏览器打开axure生成的HTML静态文件页面预览打开如下报错,这是因为chrome浏览器没有安装Axure插件导致的。

FeHelper前端助手

FE助手是由国人开发的一款前端工具集合的小插件,插件功能比较全面:包括字符串编解码、代码压缩、美化、JSON格式化、正则表达式、时间转换工具、二维码生成与解码、编码规范检测、页面性能检测、页面取色、Ajax接口调试。

Momentum

Momentum插件是一款自动更换壁纸,自带时钟,任务日历和工作清单的chrome浏览器插件。官方的解释就是:替换你 Chrome 浏览器默认的“标签页”。里面的图片全部来自500PX里面的高清图,无广告,无弹窗,非常适合笔记本使用,让装逼再上新台阶。让我来感受下出自细节,触及心灵的美。

Octotree

在Github上查看源代码的体验十分糟糕,尤其是从一个目录跳转到另一个目录的时候,非常麻烦。Octotree是一款chrome插件,用于将Github项目代码以树形格式展示,而且在展示的列表中,我们可以下载指定的文件,而不需要下载整个项目。

OneTab

Chrome浏览器很好用,这是我们不得不承认的;但是人无完人,何况一个浏览器呢?一直以来,Chrome占用内存这样的“吃相”就很让人头疼。

OneTab是一款可以在用户打开过多Chrome标签页而“不知所措”的时候点击OneTab插件一键释放Chrome标签页内存的谷歌浏览器插件,OneTab插件并不是像关闭浏览器那样直接把所有的标签页都关闭掉,它会先把现有的标签页都缓存起来,然后使用一键关闭所有标签页的功能弹出只有一个恢复窗口的新标签页,在这个OneTab插件的标签页中用户可以选择恢复其中有用的Chrome标签页而放弃其他应该关闭的标签页。

在恢复标签页的时候,OneTab插件会以新标签页的方式去恢复,所以用户可以简单地点击几次鼠标都可以把有用的标签都找出来一起恢复,当用户打开的Chrome标签页过多的时候使用OneTab插件大约能够节省用户95%的系统内存,还可以让用户在标签页变小的情况下更加清晰地关注自己应该关注的Chrome标签页。

Tampermonkey

Tampermonkey(俗称油猴)是一款免费的浏览器扩展和最为流行的用户脚本管理器。虽然有些受支持的浏览器拥有原生的用户脚本支持,但 Tampermonkey将在您的用户脚本管理方面提供更多的便利。 它提供了诸如便捷脚本安装、自动更新检查、标签中的脚本运行状况速览、内置的编辑器等众多功能, 同时Tampermonkey还有可能正常运行原本并不兼容的脚本。

通过给管理器安装各类脚本,可以让大部分 HTML 为主的网页更方便易用,比如:全速下载网盘文件、去广告、悬停显示大图、Flash/HTML5 播放器转换、阅读模式等。有点像给Chrome的插件装上插件(这里又是一个套娃)。

Web Vitals

多年来,Google 提供了很多工具:Lighthouse, Chrome DevTools, PageSpeed Insights, Search Console’s Speed Report 等来衡量和报告性能。而其中的衡量标准都很难学习和使用,Web Vitals计划的目的就是简化场景,降低学习成本,并帮助站点关注最重要的指标

Web Vitals是Google发起的,旨在提供各种质量信号的统一指南,其可获取三个关键指标(CLS、FID、LCP):

通过在浏览器安装Web Vitals插件,我们就可以在页面加载完成后很方便的查看这三个指标的情况。

Allow CORS

我们开发过程中经常会遇到接口跨域的问题,通过Allow CORS: Access-Control-Allow-Origin这个插件,可以允许我们在接口的响应头轻松执行跨域请求,只需要激活插件并且执行。

安装插件后,默认是不会添加跨域响应头的,点击插件弹框的C字母按钮,按钮变成橙色插件激活。

Window Resizer

Window Resizer是一款可以设置浏览器窗口大小的Chrome扩展,用户安装了window resizer插件后可以快速调节chrome的窗口大小,用户可以将窗口调节为320x480、480x800、1024x768等大小,也可以选择自定义浏览器窗口的尺寸。