浏览器扩展程序,顾名思义,就给浏览器扩展更多功能的程序。我们可以根据自己需求,安装不同的扩展来定制化属于自己的浏览器。
如果你去搜索"扩展程序"的相关资料,会发现网上基本上都是关于Chrome Extension的文章,有些甚至直接把"扩展程序"叫作Chrome Extension。然而"扩展程序"这类功能并不是谷歌Chrome浏览器首先发明的。早在Chrome浏览器出现之前,Firefox、Internet Explorer等就已经支持相似的扩展功能。但是Chrome在全球的市场份额较大,并且为开发者提供的扩展开发API相对规范且文档齐全,所以其他主流浏览器(例如Edge、Opera、国内的360浏览器等)也实现了与Chrome相兼容的扩展API,因此Chrome Extension可以在这些浏览器中直接使用或是经过少量修改就可以使用。因此,我们在开发扩展程序时可以直接参考Chrome Extension的API文档。
开发一个扩展程序
Chrome Extension在2023 年 11 月发布了最新版本Manifest V3,V2版本会在2024 年 6 月开始逐渐停用。所以下文中使用的是Manifest V3的API。
2.1 开发环境
API: Manifest V3 浏览器:Chrome: v120.0.6099.199 VS Code Version: v1.85.1
2.2 思路
抓取页面UI渲染完成的时机 获取网页的布局信息 获取所有的Profile信息进行分类 插入筛选模块
当前加载的是Profile tab
最终要实现的效果原型图如下:
2.3 创建项目
扩展程序项目没有严格的项目结构要求,只需要保证项目目录下有一个manifest.json即可。实际开发中,通常包括三部分:
manifest.json
image、css等资源文件
js脚本文件,比如popup.js、background.js、content-script.js
这是Chrome官方的一个项目结构示例:
2.4 项目文件介绍
2.4.1 manifest.json
项目的配置文件,必须放在根目录,非常重要。下面是一个配置示例:
{
"manifest_version": 3, //chrome插件的版本
"name": "AppleDeveloperExtension", //插件名称
"description": "Apple developer", //插件描述,支持多语言
"version": "0.1", //插件版本号
// 扩展程序要向浏览器或者网页注入的脚本
"content_scripts": [
{
// 在哪个页面注入
"matches": [
"https://developer.apple.com/account/resources/*"
],
// 要注入的css
"css": [
"css/content-script.css"
],
// 要注入的js脚本
"js": [
"jquery/jquery-1.8.3.js",
"content-script.js",
"js-utils.js",
"project-constants.js",
"content-script-profile.js",
"content-script-common.js"
],
//什么时候注入的js脚本,document_start=页面加载开始时,document_end=页面加载结束时
"run_at": "document_start"
}
],
// 后台脚本和页面的配置
"background": {
"service_worker": "background.js",
"type": "module"
},
// 插件需要用到的权限,必须在这里声明,否则相关代码不能生效
"permissions": [
"clipboardWrite", // 剪切板
"tabs", //tab选项卡API
"webRequest", //监听浏览器请求API
"webNavigation"
],
// 扩展程序在哪些网站生效
"host_permissions": [
"http://*/*",
"https://*/*"
]
}
2.4.2 background
在 Manifest V2 版本时,可以在配置文件的"background"中设置一个html页面和js脚本,他们会一直常驻的后台,其生命周期与扩展本身的生命周期相关,我们可以利用他来执行执行需要长时间运行的任务或维护扩展的状态,比如说一直监听网页的网络请求。
在Manifest V3版本中,Google 对扩展的后台机制进行了一些变更,取消了html页面,使用Service Worker 作为扩展的后台脚本,它是一个近乎于常驻的脚本,但是会在完成任务后自动进入休眠状态,从而节省系统资源。在上面的配置文件中,其实把 "service_worker": "background.js"改成 "service_worker": "service-worker.js"更符合V3的版本特性。(文件命名是可以修改的,不局限于"background.js"和"service-worker.js",但是我们一般按照和官方保持统一就好。本文中我依然称这个拥有常驻的脚本为background.js,便于大家理解。)
background.js的权限非常高,几乎可以调用所有的Chrome扩展API(除了devtools),出于安全和隔离的设计,我们大多数想要浏览器处理的事情都只能在这里完成,比如说缩放网页、新打开一个url。
关于service-worker的介绍可参考第四部分的参考文档。
2.4.3 content-script
向页面注入脚本的一种形式,我们可以实现通过配置的方式轻松向指定页面注入JS和CSS,最常见的比如:移除页面元素、定制页面CSS。
我们在实际开发中,拥有content-script功能的文件可以按逻辑拆分到多个js文件中,让项目结构更加清晰。
2.4.4 popup
我们安装扩展程序之后,扩展程序会在浏览器工具栏上显示出图标,但是因为每个扩展程序的生效范围不同,分为browser action和page action:
browser action:在浏览器的工具栏上显示一个图标,在所有页面都会显示
page action:只在某些特定的页面中显示图标
当用户在点击browser action或者page action图标时,Chrome Extension允许我们展示一个小窗口网页(popup.html)来进行临时性的交互。当popup.html展示时,对应的popup.js也会执行。
popup.js的生命周期只在弹出窗口打开时开始,且在弹出窗口关闭时结束,所以需要长时间运行的代码千万不要写在popup里面。
在API权限上,popup和background类似。
2.5 脚本间通信
从上面的介绍中,我们知道content-script如果想要调用浏览器的能力需要让background来完成,怎么实现呢?在扩展程序中,需要通过消息传递API(例如chrome.runtime.sendMessage或chrome.runtime.onMessage)。这种方式在background.js、content-script.js和popup.js之间都有效。
2.6 安装和调试
在正式开发之前,我们先介绍下如何安装和调试扩展,提高我们的开发效率。
2.6.1 安装
在Chrome打开"管理扩展程序"(也可以直接输入chrome://extensions/)
打开"开发者模式",点击"加载已解压的扩展程序"
选择我们的项目,打开即可
2.6.2 调试
可以使用debugger断言调试
调试扩展程序可以使用DevTools查看网络请求、log等信息
这里需要注意的一点是,写在background.js中的console.log需要在扩展程序对应的Service Worker中查看,其他脚本中的console.log可以正常在"开发者工具DevTools"中查看。
如果插件在运行中有错误,会直接显示到"管理扩展程序"中的插件上。另外,我们每次修改代码时,需要先在扩展中点击"重新加载"按钮,然后再刷新网页才能看到效果。
好了,我们开始正式开发。
2.7 如何判断当前加载的是Profile tab
在 chrome.tabs API中,我们可以利用onUpdated方法来监听当前tab的变化。使用tabs API时,需要在manifest.json中增加权限,然后在background.js中进行监听,主要代码如下:
// 权限
{
...
"permissions": [
"tabs"
],
...
}
// 监听tab变化
chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
if (changeInfo.status === 'complete') {
// load finished
}
});
但是在测试中发现"onUpdated"方法返回'complete'状态时,网页仍然显示loading,所以这种方式不能满足我们的要求。我们从上面的流程图中可以看出Apple Developer在分析完接口数据后展示Profile List列表,那我们是否可以知道Profile List列表已经被渲染出来呢?
想要监听网页DOM结构的任何变动,我们可以通过JavaScript中MutationObserver来实现。那这样就好办了,首先通过DevTools可以看到Profile List对应的根元素是<div id="list-profiles" class='main-container ">。
然后在content-script.js中添加代码监听"list-profiles"
document.addEventListener('DOMContentLoaded', function () {
if (location.href.match('https://developer.apple.com/account/resources')) {
console.log('-------- DOMContentLoaded --------');
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (document.getElementById('list-profiles')) {
// TODO: 添加筛选模块
console.log("list-profiles load finished");
}
});
});
observer.observe(document, {
attributes: true,
childList: true,
characterData: true,
subtree: true
});
}
});
经过测试,确实是在loading消失且Profile list显示的时候打印了log,符合我们的预期。
2.8 添加筛选模块
2.8.1 分析Profile数据
在页面加载完成之后,通过DevTools可以看到单个Profile是如下布局的:
一个<div>中包含四个<span>,这个四个<span>的显示内容分别对应"NAME"、"PLATFORM"、"TYPE"、"EXPIRATION"。要获取Profile的类型信息,第一种方法就是抓取四个<span>的显示内容,另外我们还可以监听接口的返回结果。如果是第一种的话,代码如下:
const filterPlatforms = new Set();
const filterTypes = new Set();
const filterExpirations = new Set();
// init data, 保存所有的item信息
itemInfos = [];
tabType = currentTabType;
// parse tables
let tableRoot = document.getElementsByClassName('infinite-list-container')[0];
let tableContent = document.getElementById('infiniteListContainer').firstElementChild;
let tableRows = tableContent.firstElementChild.children;
if (tableRows.length > 0) {
console.log('tableRow:' + tableRows.length);
for (var i = 0; i < tableRows.length; i++) {
let tableRow = tableRows[i];
let tableRowChildren = tableRow.children;
let itemInfo = {}; // use let
let dataId = tableRow.getAttribute('data-id');
// record data
if (i > 0) {
var platform = "";
var type = "";
var expiration = "";
for (var j = 0; j < tableRowChildren.length; j++) {
let children = tableRowChildren[j];
if (j == 0) {
// name
} else if (j == 1) {
// platform
let childDiv = children.children[0];
platform = childDiv.innerHTML;
filterPlatforms.add(platform);
} else if (j == 2) {
// type
let childDiv = children.children[0];
type = childDiv.innerHTML;
filterTypes.add(type);
} else if (j == 3) {
// expiration
let childDiv = children.firstElementChild.children[0];
expiration = childDiv.innerHTML;
filterExpirations.add(expiration);
}
}
itemInfo = {
dataId: dataId,
platform: platform,
type: type,
expiration: expiration,
}
itemInfos.push(itemInfo);
}
}
}
2.8.2 添加筛选标签
分析页面元素,我们可以看到<div class="list-header"/>和<div class="infinite-list-container"/>分别对应的是下图中的两部分。把筛选相关的标签插入到二者之间,对二者影响最小。
如何用代码实现呢?这部分其实和Web开发一样,通过JavaScript代码来查找、创建和插入标签,直接上代码:
var selectedFilterPlatform = '';
var selectedFilterType = '';
var selectedFilterExpiration = '';
function createFilterMenu() {
// header list-header
var parentDiv = document.querySelector('#list-profiles');
if (parentDiv) {
// 创建筛选的div
var customMenu = document.createElement('div');
customMenu.id = kFilterDivID;
customMenu.textContent = 'Filter: ';
customMenu.style.color = '#111111';
customMenu.style.fontSize = '24px';
customMenu.style.fontWeight = 'bold';
customMenu.style.display = 'flex';
customMenu.style.textAlign = 'right';
customMenu.style.marginBottom = '16px';
customMenu.style.marginTop = '16px';
// 添加「All」选项,允许打开全部Profiles
let filterPlatformValues = Array.from(filterPlatforms);
filterPlatformValues.unshift("All");
let filterTypesValues = Array.from(filterTypes);
filterTypesValues.unshift("All");
let filterExpirationsValues = Array.from(filterExpirations);
filterExpirationsValues.unshift("All");
// 添加筛选下拉框
createOptions(customMenu, kFilterPlatformOption, filterPlatformValues)
createOptions(customMenu, kFilterTypeOption, filterTypesValues)
createOptions(customMenu, kFilterExpirationOption, filterExpirationsValues)
// 添加批量打开按钮
customMenu.appendChild(createOpenFilterItemsButton());
// 插入标签
var targetDiv = parentDiv.querySelector('.list-header');
parentDiv.insertBefore(customMenu, targetDiv.nextSibling);
}
}
// 创建筛选项
function createOptions(parentDom, filterType, optionValues) {
var options = document.createElement('select');
options.id = filterType;
options.style.display = '';
options.style.width = '200px';
options.style.height = '40px';
options.style.marginLeft = "10px";
for (var i = 0; i < optionValues.length; i++) {
if (optionValues[i] != null) {
var option = document.createElement('option');
option.textContent = optionValues[i];
options.appendChild(option);
}
}
// 记录用户选择的结果
options.addEventListener('change', function () {
if (filterType == kFilterPlatformOption) {
selectedFilterPlatform = options.value
} else if (filterType == kFilterTypeOption) {
selectedFilterType = options.value
} else if (filterType == kFilterExpirationOption) {
selectedFilterExpiration = options.value
}
});
parentDom.appendChild(options);
}
// 创建批量打开按钮
function createOpenFilterItemsButton() {
var button = createMenuButton();
button.innerHTML = "Open Filter Items";
// TODO: 点击事件
return button;
}
2.9 添加批量打开Button的响应事件
首先经过分析,我们可以知道打开一个Profile详情页的URL的结构是
https://developer.apple.com/account/resources/profiles/review/ + ID
在上面的截图中,我们可以看到每个Profile的<div>中都有一个"data-id"属性,经过比对,这个"data-id"就是上面链接中需要替换的ID。接下来我们要做的就是根据用户的筛选结果来过滤出要打开的data-id,然后依次在浏览器新标签中打开。但是在"浏览器新标签中打开URL"这个操作需要在 background.js中完成,这里我们可以把筛选结果和全部的Profile信息传到background.js中,统一处理。代码如下:
// 在content-script.js中发送消息
button.addEventListener('click', (event) => {
event.stopPropagation();
chrome.runtime.sendMessage({
itemInfos: itemInfos,
selectedFilterPlatform: selectedFilterPlatform,
selectedFilterType: selectedFilterType,
selectedFilterExpiration: selectedFilterExpiration
}, function (response) {
});
});
// 在background中注册监听者
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
let itemInfos = request.itemInfos
if (itemInfos == null) {
console.log('receive return');
return
}
var willOpenDataIds = []
let selectedFilterPlatform = request.selectedFilterPlatform
let selectedFilterType = request.selectedFilterType
let selectedFilterExpiration = request.selectedFilterExpiration
if (request.actionType == 'openFilter') {
let baseUrl = "https://developer.apple.com/account/resources/profiles/review/"
itemInfos.forEach(element => {
if (!isEmpty(selectedFilterPlatform) && selectedFilterPlatform != 'All') {
if (element.platform != selectedFilterPlatform) {
return
}
}
if (!isEmpty(selectedFilterType) && selectedFilterType != 'All') {
if (element.type != selectedFilterType) {
return
}
}
if (!isEmpty(selectedFilterExpiration) && selectedFilterExpiration != 'All') {
if (element.expiration != selectedFilterExpiration) {
return
}
}
willOpenDataIds.push(element.dataId)
});
openDataIdsInNewTab(baseUrl, willOpenDataIds)
}
});
function openDataIdsInNewTab(baseUrl, dataIds) {
for (var i = 0; i < dataIds.length; i++) {
var dataId = dataIds[i];
let url = baseUrl + dataId
openUrlNewTab(url);
}
}
function openUrlNewTab(url, active = false) {
chrome.tabs.create({
url: url,
active: active
});
}
到此,所有的功能就完成了。
结尾
本文中旨在带大家了解Chrome Extension的开发,消除陌生感。在日常工作中,我们可以开发利用扩展程序来解决实际遇到的问题,提高工作效率。
参考文档
Service Worker :https://developer.chrome.com/docs/extensions/get-started/tutorial/service-worker-events?hl=zh-cn
API 参考 | Chrome for Developers:https://developer.chrome.com/docs/extensions/reference/api?hl=zh-cn
Chrome插件(扩展)开发全攻略:https://www.cnblogs.com/liuxianan/p/chrome-plugin-develop.html
关于领创集团