内容脚本

内容脚本是扩展的一部分,它在特定网页的上下文中运行(与扩展的一部分的后台脚本或网站本身的一部分的脚本相反,例如使用 <script> 元素加载的脚本)。

后台脚本 可以访问所有 WebExtension JavaScript API,但它们无法直接访问网页内容。因此,如果您的扩展需要执行此操作,则需要内容脚本。

就像由普通网页加载的脚本一样,内容脚本可以使用标准 DOM API 读取和修改其页面的内容。但是,只有在 已授予对网页来源的主机权限 时,它们才能执行此操作。

内容脚本只能访问 WebExtension API 的一小部分,但它们可以使用消息传递系统 与后台脚本通信,从而间接访问 WebExtension API。

加载内容脚本

您可以将内容脚本加载到网页中

  1. 在安装时,加载到与 URL 模式匹配的页面中。
  2. 在运行时,加载到与 URL 模式匹配的页面中。
  3. 在运行时,加载到特定标签页中。

每个框架、每个扩展程序只有一个全局作用域。这意味着来自一个内容脚本的变量可以直接被另一个内容脚本访问,而不管内容脚本是如何加载的。

使用方法(1)和(2),您只能将脚本加载到可以使用 匹配模式 表示的 URL 的页面中。

使用方法(3),您还可以将脚本加载到打包在扩展程序中的页面中,但您无法将脚本加载到特权浏览器页面(如“about:debugging”或“about:addons”)。

注意:动态 JS 模块导入 现在可以在内容脚本中使用。有关更多详细信息,请参阅 Firefox 错误 1536094。仅允许使用moz-extension方案的 URL,这排除了数据 URL (Firefox 错误 1587336)。

权限、限制和局限性

权限

只有在扩展程序被授予该域的 主机权限 时,才会执行注册的内容脚本。

要以编程方式注入脚本,扩展程序需要 activeTab 权限主机权限scripting 权限是使用 scripting API 中的方法所必需的。

从清单 V3 开始,安装时不会自动授予主机权限。用户可以在安装扩展程序后选择加入或退出主机权限。

受限域

主机权限activeTab 权限 对某些域都有例外。内容脚本被阻止在这些域上执行,例如,为了保护用户免受扩展程序通过特殊页面升级权限的影响。

在 Firefox 中,这包括以下域

  • accounts-static.cdn.mozilla.net
  • accounts.firefox.com
  • addons.cdn.mozilla.net
  • addons.mozilla.org
  • api.accounts.firefox.com
  • content.cdn.mozilla.net
  • discovery.addons.mozilla.org
  • install.mozilla.org
  • oauth.accounts.firefox.com
  • profile.accounts.firefox.com
  • support.mozilla.org
  • sync.services.mozilla.com

其他浏览器对扩展程序可以安装的网站有类似的限制。例如,Chrome 中限制了对 chrome.google.com 的访问。

注意:由于这些限制包括 addons.mozilla.org,因此尝试在安装后立即使用扩展程序的用户可能会发现它无法正常工作。为避免这种情况,您应该添加适当的警告或 入门页面 以将用户从 addons.mozilla.org 移开。

可以通过企业策略进一步限制域集:Firefox 识别 mozilla/policy-templates 中的 ExtensionSettings 中记录的 restricted_domains 策略。Chrome 的 runtime_blocked_hosts 策略记录在 配置 ExtensionSettings 策略 中。

局限性

可以使用 data: URIBlob 对象和其他类似技术加载整个标签页或框架。对将内容脚本注入此类特殊文档的支持在不同浏览器中有所不同,有关详细信息,请参阅 Firefox 错误 #1411641 评论 41

内容脚本环境

DOM 访问

内容脚本可以访问和修改页面的 DOM,就像普通页面脚本一样。它们还可以看到页面脚本对 DOM 所做的任何更改。

但是,内容脚本会获得 DOM 的“干净”视图。这意味着

  • 内容脚本无法看到页面脚本定义的 JavaScript 变量。
  • 如果页面脚本重新定义了内置 DOM 属性,则内容脚本会看到该属性的原始版本,而不是重新定义的版本。

“Chrome 不兼容性”中的“内容脚本环境” 中所述,浏览器之间的行为有所不同

  • 在 Firefox 中,此行为称为X光视觉。内容脚本可能会遇到来自其自身全局作用域的 JavaScript 对象或来自网页的 Xray 包装版本。
  • 在 Chrome 中,此行为通过隔离的世界强制执行,该世界使用了一种根本不同的方法。

考虑一个像这样的网页

html
<!doctype html>
<html lang="en-US">
  <head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
  </head>

  <body>
    <script src="page-scripts/page-script.js"></script>
  </body>
</html>

脚本 page-script.js 执行以下操作

js
// page-script.js

// add a new element to the DOM
let p = document.createElement("p");
p.textContent = "This paragraph was added by a page script.";
p.setAttribute("id", "page-script-para");
document.body.appendChild(p);

// define a new property on the window
window.foo = "This global variable was added by a page script";

// redefine the built-in window.confirm() function
window.confirm = () => {
  alert("The page script has also redefined 'confirm'");
};

现在,扩展程序将内容脚本注入到页面中

js
// content-script.js

// can access and modify the DOM
let pageScriptPara = document.getElementById("page-script-para");
pageScriptPara.style.backgroundColor = "blue";

// can't see properties added by page-script.js
console.log(window.foo); // undefined

// sees the original form of redefined properties
window.confirm("Are you sure?"); // calls the original window.confirm()

反之亦然;页面脚本无法看到内容脚本添加的 JavaScript 属性。

这意味着内容脚本可以依靠 DOM 属性以可预测的方式运行,而无需担心其变量与页面脚本的变量发生冲突。

此行为的一个实际后果是,内容脚本无法访问页面加载的任何 JavaScript 库。因此,例如,如果页面包含 jQuery,则内容脚本将无法看到它。

如果内容脚本需要使用 JavaScript 库,则应将库本身作为内容脚本想要使用它的内容脚本一起注入

json
"content_scripts": [
  {
    "matches": ["*://*.mozilla.org/*"],
    "js": ["jquery.js", "content-script.js"]
  }
]

注意:Firefox确实提供了一些 API,使内容脚本能够访问页面脚本创建的 JavaScript 对象,并将其自己的 JavaScript 对象公开给页面脚本。

有关更多详细信息,请参阅与页面脚本共享对象

WebExtension API

XHR 和 Fetch

内容脚本可以使用正常的window.XMLHttpRequestwindow.fetch()API 发出请求。

注意:在清单 V2 的 Firefox 中,内容脚本请求(例如,使用fetch())在扩展程序的上下文中发生,因此您必须提供一个绝对 URL 来引用页面内容。

在 Chrome 和清单 V3 的 Firefox 中,这些请求在页面的上下文中发生,因此它们被发送到相对 URL。例如,/api 被发送到 https://«当前页面 URL»/api

内容脚本获得与扩展程序其余部分相同的跨域权限:因此,如果扩展程序已使用manifest.json中的permissions键为某个域请求了跨域访问,那么其内容脚本也将获得该域的访问权限。

注意:使用清单 V3 时,当目标服务器使用CORS选择加入时,内容脚本可以执行跨源请求;但是,主机权限在内容脚本中不起作用,但在常规扩展程序页面中仍然起作用。

这是通过在内容脚本中公开权限更高的 XHR 和 fetch 实例来实现的,这会产生副作用,即不会像页面本身发出的请求那样设置OriginReferer标头;这通常是可取的,以防止请求泄露其跨源性质。

注意:在清单 V2 的 Firefox 中,需要执行表现得像由内容本身发送的请求的扩展程序可以使用content.XMLHttpRequestcontent.fetch()代替。

对于跨浏览器扩展程序,必须通过功能检测来检测这些方法的存在。

在清单 V3 中这是不可能的,因为content.XMLHttpRequestcontent.fetch()不可用。

注意:在 Chrome 中,从版本 73 开始,以及在使用清单 V3 的 Firefox 中,从版本 101 开始,内容脚本受与其运行的页面相同的CORS策略约束。只有后端脚本具有提升的跨域权限。请参阅Chrome 扩展程序内容脚本中跨源请求的更改

与后台脚本通信

虽然内容脚本无法直接使用大多数 WebExtension API,但它们可以使用消息传递 API 与扩展程序的后台脚本通信,因此可以间接访问后台脚本可以访问的所有相同 API。

在后台脚本和内容脚本之间通信有两种基本模式

  • 您可以发送一次性消息(可选响应)。
  • 您可以设置两方之间更持久的连接,并使用该连接交换消息。

一次性消息

要发送一次性消息(可选响应),您可以使用以下 API

在内容脚本中 在后台脚本中
发送消息 browser.runtime.sendMessage() browser.tabs.sendMessage()
接收消息 browser.runtime.onMessage browser.runtime.onMessage

例如,以下是一个内容脚本,它侦听网页中的点击事件。

如果点击的是链接,它会向后台页面发送一条消息,其中包含目标 URL

js
// content-script.js

window.addEventListener("click", notifyExtension);

function notifyExtension(e) {
  if (e.target.tagName !== "A") {
    return;
  }
  browser.runtime.sendMessage({ url: e.target.href });
}

后台脚本侦听这些消息,并使用notificationsAPI 显示通知

js
// background-script.js

browser.runtime.onMessage.addListener(notify);

function notify(message) {
  browser.notifications.create({
    type: "basic",
    iconUrl: browser.extension.getURL("link.png"),
    title: "You clicked a link!",
    message: message.url,
  });
}

(此示例代码略微改编自 GitHub 上的notify-link-clicks-i18n示例。)

基于连接的消息传递

如果您在后台脚本和内容脚本之间交换大量消息,则发送一次性消息可能会变得很麻烦。因此,另一种模式是在两种上下文中建立更持久的连接,并使用此连接交换消息。

双方都有一个runtime.Port对象,它们可以使用该对象交换消息。

创建连接

这将返回一个runtime.Port对象。

一旦每一侧都有一个端口,这两侧就可以

  • 使用runtime.Port.postMessage()发送消息
  • 使用runtime.Port.onMessage()接收消息

例如,以下内容脚本在加载后立即

  • 连接到后台脚本
  • Port存储在变量myPort
  • 侦听myPort上的消息(并记录它们)
  • 当用户单击文档时,使用myPort向后台脚本发送消息
js
// content-script.js

let myPort = browser.runtime.connect({ name: "port-from-cs" });
myPort.postMessage({ greeting: "hello from content script" });

myPort.onMessage.addListener((m) => {
  console.log("In content script, received message from background script: ");
  console.log(m.greeting);
});

document.body.addEventListener("click", () => {
  myPort.postMessage({ greeting: "they clicked the page!" });
});

相应的后台脚本

  • 侦听来自内容脚本的连接尝试
  • 收到连接尝试时
    • 将端口存储在名为portFromCS的变量中
    • 使用端口向内容脚本发送消息
    • 开始侦听通过端口接收到的消息,并记录它们
  • 当用户单击扩展程序的浏览器操作时,使用portFromCS向内容脚本发送消息
js
// background-script.js

let portFromCS;

function connected(p) {
  portFromCS = p;
  portFromCS.postMessage({ greeting: "hi there content script!" });
  portFromCS.onMessage.addListener((m) => {
    portFromCS.postMessage({
      greeting: `In background script, received message from content script: ${m.greeting}`,
    });
  });
}

browser.runtime.onConnect.addListener(connected);

browser.browserAction.onClicked.addListener(() => {
  portFromCS.postMessage({ greeting: "they clicked the button!" });
});

多个内容脚本

如果您有多个内容脚本同时通信,您可能希望将其连接存储在数组中。

js
// background-script.js

let ports = [];

function connected(p) {
  ports[p.sender.tab.id] = p;
  // …
}

browser.runtime.onConnect.addListener(connected);

browser.browserAction.onClicked.addListener(() => {
  ports.forEach((p) => {
    p.postMessage({ greeting: "they clicked the button!" });
  });
});

在一次性消息和基于连接的消息传递之间进行选择

在一次性消息和基于连接的消息传递之间进行选择取决于扩展程序期望如何利用消息传递。

推荐的最佳实践是

  • 在以下情况下使用一次性消息…
    • 仅期望对消息有一个响应。
    • 少量脚本侦听接收消息(runtime.onMessage调用)。
  • 在以下情况下使用基于连接的消息传递…
    • 脚本参与交换多条消息的会话。
    • 扩展程序需要了解任务进度或任务是否被中断,或者想要中断使用消息传递发起的任务。

与网页通信

默认情况下,内容脚本无法访问页面脚本创建的对象。但是,它们可以使用 DOM window.postMessagewindow.addEventListenerAPI 与页面脚本通信。

例如

js
// page-script.js

let messenger = document.getElementById("from-page-script");

messenger.addEventListener("click", messageContentScript);

function messageContentScript() {
  window.postMessage(
    {
      direction: "from-page-script",
      message: "Message from the page",
    },
    "*",
  );
}
js
// content-script.js

window.addEventListener("message", (event) => {
  if (
    event.source === window &&
    event?.data?.direction === "from-page-script"
  ) {
    alert(`Content script received message: "${event.data.message}"`);
  }
});

有关此内容的完整工作示例,请访问 GitHub 上的演示页面并按照说明操作。

警告:以这种方式与不受信任的网页内容交互时要非常小心!扩展程序是特权代码,可以具有强大的功能,而恶意网页可以轻松地欺骗它们访问这些功能。

举一个简单的例子,假设接收消息的内容脚本代码执行以下操作

js
// content-script.js

window.addEventListener("message", (event) => {
  if (
    event.source === window &&
    event?.data?.direction === "from-page-script"
  ) {
    eval(event.data.message);
  }
});

现在,页面脚本可以使用内容脚本的所有权限运行任何代码。

在内容脚本中使用 eval()

注意:清单 V3 中不可用eval()

在 Chrome 中

eval始终在内容脚本的上下文中运行代码,而不是在页面的上下文中运行。

在 Firefox 中

如果调用eval(),它将在内容脚本的上下文中运行代码。

如果调用window.eval(),它将在页面的上下文中运行代码。

例如,考虑以下内容脚本

js
// content-script.js

window.eval("window.x = 1;");
eval("window.y = 2");

console.log(`In content script, window.x: ${window.x}`);
console.log(`In content script, window.y: ${window.y}`);

window.postMessage(
  {
    message: "check",
  },
  "*",
);

此代码只是使用window.eval()eval()创建了一些变量xy,记录其值,然后向页面发送消息。

收到消息后,页面脚本将记录相同的变量

js
window.addEventListener("message", (event) => {
  if (event.source === window && event.data && event.data.message === "check") {
    console.log(`In page script, window.x: ${window.x}`);
    console.log(`In page script, window.y: ${window.y}`);
  }
});

在 Chrome 中,这将产生如下输出

In content script, window.x: 1
In content script, window.y: 2
In page script, window.x: undefined
In page script, window.y: undefined

在 Firefox 中,这将产生如下输出

In content script, window.x: undefined
In content script, window.y: 2
In page script, window.x: 1
In page script, window.y: undefined

同样的情况也适用于setTimeout()setInterval()Function()

警告:在页面的上下文中运行代码时要非常小心!

页面的环境由可能具有恶意的网页控制,这些网页可以重新定义您与之交互的对象,使其以意外的方式运行

js
// page.js redefines console.log

let original = console.log;

console.log = () => {
  original(true);
};
js
// content-script.js calls the redefined version

window.eval("console.log(false)");