内容脚本 (Content Scripts)
内容脚本是浏览器扩展中最强大的工具之一。它们允许你将JavaScript和CSS注入到任何网页中,并几乎不受限制地对其进行修改。注入的内容可以简单到一些视觉上的调整,也可以复杂到整个单页应用框架。扩展还可以使用它们来实现非用户界面相关的功能:内容脚本可以读取和修改页面的DOM,还可以作为已认证用户发送网络请求。
内容脚本简介
内容脚本最常见的使用方式是通过清单(manifest)进行声明式注入。清单指定了应该注入的文件以及应该注入的域名,浏览器会将它们注入到页面中。注入的JavaScript类似于动态地将一个<script>
标签插入到页面中,而注入的CSS则类似于动态地将一个<link>
标签插入到页面中。下面的示例展示了一个扩展,它将JS和CSS注入到所有网页中:
示例 9-1a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"content_scripts": [
{
"matches": ["<all_urls>"],
"css": ["content-script.css"],
"js": ["content-script.js"]
}
],
"permissions": []
}
示例 9-1b. content-script.js
console.log(window.jQuery);
document.body.innerHTML = "Hello, world!";
示例 9-1c. content-script.css
body {
background-color: red !important;
}
注入 CSS
将 CSS 内容脚本注入到宿主页面中相对简单。如果 URL 匹配,该脚本将被注入,就好像它被列为一个额外的 元素一样。安装前一个示例扩展程序并访问任意网站,即可查看 CSS 注入结果(图 9-1)。
以下是需留意之处:
- 内容脚本仅投射至页面中,你不会看到实际添加的
<script>
或<link>
标签。 - 注入的 CSS 优先级低于页面自身的 CSS,因此通常需要使用
!important
。
内容脚本隔离
接下来,导航至 jquery.com(或任何其他加载了 jQuery 库的网站)(图 9-2)。
当 jQuery JavaScript 库在 jquery.com 上加载时,jQuery
属性会在 window
对象上定义,但内容脚本无法看到此属性。然而,在开发者工具中,window.jQuery
是已定义的。这是由于内容脚本的运行时隔离特性。
内容脚本的 JavaScript 在与宿主页面平行的独立运行时中执行。两个运行时都无法访问对方的变量,并且每个运行时都有自己的事件循环、全局作用域和任务队列。然而,两者都可以访问与相同对象交互的 API:例如,DOM、localStorage
、sessionStorage
、IndexedDB
、cookieStore
等。假设宿主页面在全局作用域中定义了一个变量。内容脚本无法访问其值。然而,如果宿主页面将该变量写入 localStorage
,内容脚本的 localStorage
API 就可以访问该值!
对 DOM 的共享访问特别有趣。一个立即有用的功能是内容脚本能够从宿主页面添加、修改或删除 DOM 节点。例如,如果你想清除 所有网页上的所有 CSS 样式,可以使用以下示例扩展:
示例 9-2a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"content_scripts": [
{
"matches": ["<all_urls>"],
"css": [],
"js": ["content-script.js"]
}
],
"permissions": []
}
示例 9-2b. content-script.js
for (const el of document.querySelectorAll("style")) {
el.parentElement.removeChild(el);
}
for (const el of document.querySelectorAll('link[rel="stylesheet"]')) {
el.parentElement.removeChild(el);
}
for (const el of document.querySelectorAll("[style]")) {
el.removeAttribute("style");
}
加载此扩展并导航到任意网页。你会注意到所有 CSS 样式都将被完全移除。
注意:这不会影响在内容脚本运行后由 JavaScript 添加的 CSS 样式。一个简单的解决方案可能是将此脚本包装在 setInterval()
中,但考虑到所有额外的 querySelectorAll
表达式都会被评估,可能会有性能方面的考虑。这种页面管理正是让内容脚本难以正确实现的原因。
页面自动化
虽然内容脚本(Content Script)无法直接访问事件处理程序,但它可以在共享的 DOM 节点上派发事件。宿主页面在宿主 JavaScript 上下文中分配的事件处理程序,会通过内容脚本 JavaScript 上下文中派发的事件被调用。以下示例通过在 wikipedia.org
上自动化搜索演示了这一点。
示例 9-3a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"content_scripts": [
{
"matches": ["https://*.wikipedia.org/*"],
"css": [],
"js": ["content-script.js"]
}
],
"permissions": []
}
示例 9-3b. content-script.js
// 等待几秒钟,以便用户可以看到查询输入过程
setTimeout(() => {
document.querySelector("#searchInput").value = "javascript";
}, 2000);
setTimeout(() => {
document.querySelector('button[type="submit"]').click();
}, 3000);
加载此扩展后,访问 https://wikipedia.org
。您将看到扩展在输入框中设置值并点击搜索按钮,随后跳转到关于 JavaScript 的页面。
如果希望使文本输入过程更真实,可以将内容脚本调整为以下代码:
content-script.js
const typedValue = "javascript";
const input = document.querySelector("#searchInput");
const form = document.querySelector("#search-form");
function typeOrSubmit(idx = 0) {
const char = typedValue[idx];
if (!char) {
setTimeout(() => form.submit(), 500);
} else {
input.value = input.value + char;
setTimeout(() => typeOrSubmit(++idx), 100);
}
}
if (input && form) {
setTimeout(() => {
input.focus();
typeOrSubmit();
}, 2000);
}
重新加载扩展和 Wikipedia 页面后,内容脚本将逐字输入搜索词。此外,这里内容脚本直接提交表单,而不是仅点击搜索按钮。
这涉及编写内容脚本时需要牢记的一个重要概念:您受制于宿主页面。在上述示例中,我们使用了宿主页面定义的选择器来定位想要交互的元素。如果宿主页面稍微更改了这些选择器,内容脚本就会失效。此外,内容脚本使用了内置的事件派发方法,如 click()
、focus()
和 submit()
。这些方法很方便,因为它们简洁,并且部分允许您避免手动派发事件。
有时,需要手动派发事件。内容脚本无法看到哪些元素绑定了事件处理程序,但开发者可以提前使用 getEventListeners()
方法获取这些信息。例如,在 https://wikipedia.org
上,您可以使用此方法查找“Read Wikipedia in your language”按钮上的所有事件监听器(图 9-3)。
我们在此处看到,宿主页面设置了一个事件监听器。然后,我们可以通过在开发者控制台中派发一个测试点击事件来检查该处理程序在做什么——你应该会看到它切换了语言菜单。为了自动化打开此菜单的操作,我们当然可以在内容脚本中调用 click()
方法,但为了本示例的目的,我们改为通过手动派发点击事件来打开菜单:
content-script.js
setTimeout(() => {
const el = document.querySelector("#js-lang-list-button");
// 向下滚动到按钮位置,以便我们可以看到点击操作的效果
el.scrollIntoView();
el.dispatchEvent(new Event("click"));
}, 2000);
重新加载扩展程序和 Wikipedia 页面。你应该会看到内容脚本将页面向下滚动,并自动打开了语言菜单。
日志记录与错误
内容脚本生成的控制台消息和错误会显示在宿主页面的开发者控制台中(图 9-4)。 由于在内容脚本中抛出的未捕获错误仍属于扩展程序的上下文,因此这些错误消息也会显示在扩展程序的错误视图中(图 9-5)。
扩展程序 API 访问
与弹出窗口和选项页面不同,内容脚本只能访问 WebExtensions API 的有限部分:
chrome.i18n.*
chrome.storage.*
chrome.runtime.connect
chrome.runtime.getManifest
chrome.runtime.getURL
chrome.runtime.id
chrome.runtime.onConnect
chrome.runtime.onMessage
chrome.runtime.sendMessage
如果需要使用 API 的其他部分,可以通过发送消息来触发远程过程调用,从而委托给后台服务工作线程。后台服务工作线程可以使用完整的 API,并在需要时返回结果。
内容脚本的一个不寻常之处在于,尽管它们可以使用 runtime.getURL()
,但无法打开扩展程序 URL 的标签页。标签页会打开,但页面不会渲染,而是显示浏览器错误。内容脚本在不受信任的环境中运行,因此使用 <a href>
或 window.open(chrome-extension://...)
来访问扩展程序页面,必然意味着宿主页面也可以打开这些 URL。如《后台脚本》章节所示,解决方案是向后台发送一条消息,指示其使用所需的 URL 打开一个新标签页。
模块与代码拆分
任何有经验的开发者都知道,现代 JavaScript 大量使用 ES6 模块和 import
关键字。由于顶级内容脚本不是模块,并且目前无法将其定义为模块,因此不能使用静态导入。幸运的是,有几个简单的解决方案。
打包
大多数扩展程序开发都使用诸如 Parcel、Webpack 或 Plasmo 等复杂的构建工具,这些工具通常配置为将整个内容脚本代码图压缩到一个文件中,从而消除了在顶级内容脚本中使用导入的需求。传统的 Web 应用程序对懒加载的需求更大,因为从性能角度来看,从远程服务器加载数据的代价很高。由于浏览器扩展程序仅从本地设备上的扩展程序文件服务器提供服务,因此将整个内容脚本打包成一个巨大的单体脚本几乎不会产生性能损失。
动态导入
内容脚本可能无法使用静态导入,但它们完全能够使用动态导入并加载一个可以使用静态导入的二级模块。这样做需要向扩展程序文件服务器发出额外的网络请求,但这种开销微不足道,因此是可以接受的。
当然,内容脚本中的任何静态或动态导入都会为该模块生成一个网络请求。因此,为了允许导入这些模块,必须将它们列为 web_accessible_resources
。以下示例扩展程序演示了如何执行动态导入。加载该扩展程序并检查控制台输出:
示例 9-4a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"content_scripts": [
{
"matches": ["<all_urls>"],
"css": [],
"js": ["content-script.js"]
}
],
"permissions": [],
"web_accessible_resources": [
{
"resources": ["*.js"],
"matches": ["<all_urls>"]
}
]
}
示例 9-4b. content-script.js
const url = chrome.runtime.getURL("foo.js");
import(url).then((fooModule) => {
fooModule.bar();
});
示例 9-4c. foo.js
import apiTest from "./api-test.js";
export function bar() {
console.log("Called bar!");
}
console.log("Loaded foo module!");
apiTest();
示例 9-4d. api-test.js
export default function () {
// 仅当脚本可以访问 WebExtensions API 时,此方法才会存在
console.log("Can access API:", !!chrome.runtime.getURL);
}
结果如图 9-6 所示。
以下是需要注意的几点:
- 正如预期,静态导入和动态导入都会为模块文件生成网络请求。
- 导入的模块保留对 WebExtensions API 的访问权限,并且可以导入其他模块。
- 由于是动态导入,您可以从返回的模块对象上调用导入模块的方法。
动态脚本标签
还可以通过在页面中动态创建 <script type="module">
来以类似方式加载模块。将之前的示例扩展程序修改为以下内容:
示例 9-5. content-script.js
const url = chrome.runtime.getURL("foo.js");
const script = document.createElement("script");
script.setAttribute("type", "module");
script.setAttribute("src", url);
document.head.appendChild(script);
重新加载扩展程序后,您将看到如图 9-7 所示:
以下是需要注意的几点:
- 动态创建的脚本标签会失去对 WebExtensions API 的访问权限。
- 由于这里没有使用
import
关键字,因此我们无法使用模块中的特定导出。
您可能会遇到一些特殊情况,在这些情况下动态创建脚本标签很有用,但总体而言,它的用处不如使用动态导入。
专用内容脚本属性
内容脚本可以进一步定制,以控制脚本的注入时间、应该或不应该在哪些 URL 路径上注入,以及是否应该在 about:blank
等特殊 URL 中注入。可以使用以下属性:
run_at
match_about_blank
match_origin_as_fallback
exclude_matches
include_globs
exclude_globs
all_frames
这些属性的行为在扩展程序清单章节中有详细说明。
编程式注入
清单文件并不是注入内容脚本的唯一方式。还可以使用 chrome.scripting
API 以编程方式将 JavaScript 和 CSS 注入到页面中。考虑以下示例,在工具栏图标被点击后注入 JS 和 CSS:
示例 9-6a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["scripting", "activeTab"],
"action": {}
}
示例 9-6b. background.js
chrome.action.onClicked.addListener((tab) => {
const target = {
tabId: tab.id,
};
chrome.scripting.executeScript({
target,
func: () => {
document.body.innerHTML = `Hello, world!`;
},
});
chrome.scripting.insertCSS({
target,
css: `body { background-color: red !important; }`,
});
});
如示例所示,chrome.scripting
API 可以用于以临时方式轻松地将 JavaScript 和 CSS 注入到页面中。将函数传入页面的机制有些不寻常:它实际上是在调用 func.toString()
,然后在页面的上下文中评估该字符串。
除了传入函数和字符串外,还可以提供文件引用。以下示例的行为与前一个示例相同:
示例 9-7a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["scripting", "activeTab"],
"action": {}
}
示例 9-7b. background.js
chrome.action.onClicked.addListener((tab) => {
const target = {
tabId: tab.id,
};
chrome.scripting.executeScript({
target,
files: ["content-script.js"],
});
chrome.scripting.insertCSS({
target,
files: ["content-script.css"],
});
});
示例 9-7c. content-script.js
document.body.innerHTML = `Hello, world!`;
示例 9-7d. content-script.css
body {
background-color: red !important;
}
重要的是,在序列化期间不会捕获任何函数闭包;所有外部变量引用都会丢失。相反,变量可以通过 args
属性序列化并传递给函数。以下示例演示了这一点:
示例 9-8. background.js
const outerVar = "foobar";
function wipeOutPage(bg) {
// 在内容脚本中记录 typeof
const cs = typeof outerVar;
document.body.innerHTML = `${bg} -> ${cs}`;
}
const css = `
body {
background-color: red !important;
}`;
chrome.action.onClicked.addListener((tab) => {
const target = {
tabId: tab.id,
};
// 在后台记录 typeof
const backgroundTypeof = typeof outerVar;
chrome.scripting.executeScript({
target,
func: wipeOutPage,
// 这个值数组将被 curry 到 `func` 中(类似于 Array.apply)
args: [backgroundTypeof],
});
chrome.scripting.insertCSS({
target,
css,
});
});
重新加载此扩展程序后,您将看到页面内容为 string -> undefined
。这表明在页面中评估函数时,变量引用已丢失。
需要注意的是,以前可以将函数字符串传递给 executeScript()
。在 manifest v3 中,这已不再可能,因为这会导致任意代码执行。
还可以动态注册和注销声明式内容脚本。这类似于更新清单文件的 content_scripts
属性。由于这是在修改声明式注入列表,因此脚本将在下次页面加载时才会被注入。以下示例是一个扩展程序,当工具栏图标被点击时切换声明式注入。
示例 9-9a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["scripting", "activeTab"],
"action": {}
}
示例 9-9b. background.js
const id = "1";
chrome.action.onClicked.addListener(async () => {
const activeScripts = await chrome.scripting.getRegisteredContentScripts();
// 切换内容脚本
if (activeScripts.find((x) => x.id === id)) {
chrome.scripting.unregisterContentScripts({
ids: [id],
});
console.log("Unregistered content script");
} else {
chrome.scripting.registerContentScripts([
{
id,
matches: ["<all_urls>"],
js: ["content-script.js"],
css: ["content-script.css"],
},
]);
console.log("Registered content script");
}
});
示例 9-9c. content-script.js
document.body.innerHTML = "Hello, world!";
示例 9-9d. content-script.css
body {
background-color: red !important;
}
总结
在本章中,您了解了内容脚本如何允许您几乎完全控制宿主网页。尽管它们对 WebExtensions API 的访问权限有限,但内容脚本可以与后台服务工作线程协调,为用户的浏览器体验增添强大的增强功能。最后,您了解了如何动态添加和移除页面中的内容脚本。
在下一章中,您将学习如何将自定义开发工具页面构建到浏览器的开发工具界面中。您还将学习如何使用仅可从扩展程序的开发工具页面访问的自定义开发工具 API。