内容脚本

内容脚本是扩展程序的一部分,它在网页的上下文中运行。它可以使用标准Web API读取和修改页面内容。内容脚本的行为类似于作为网站一部分的脚本,例如使用<script>元素加载的脚本。但是,内容脚本只有在授予网页源的主机权限后才能访问页面内容。

内容脚本可以访问WebExtension API 的一个小子集,但它们可以通过消息系统与后台脚本通信,从而间接访问 WebExtension API。后台脚本可以访问所有WebExtension JavaScript API,但不能直接访问网页内容。

注意:某些 Web API 仅限于安全上下文,这也适用于在这些上下文中运行的内容脚本。除了PointerEvent.getCoalescedEvents(),它可以在 Firefox 中从不安全上下文中的内容脚本调用。

加载内容脚本

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

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

每个帧,每个扩展程序只有一个全局作用域。这意味着内容脚本中的变量可以被任何其他内容脚本访问,无论内容脚本是如何加载的。

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

使用方法 (3),您还可以将脚本加载到与扩展程序一起打包的页面中,但不能将脚本加载到特权浏览器页面(如 about:debuggingabout:addons)中。

注意:动态 JS 模块导入现在在内容脚本中有效。有关更多详细信息,请参阅 Firefox bug 1536094。只允许使用 moz-extension 方案的 URL,这不包括数据 URL(Firefox bug 1587336)。

持久性

使用 scripting.executeScript() 或(仅限 Manifest V2)tabs.executeScript() 加载的内容脚本按请求运行且不持久。

在清单文件的 content_scripts 键中定义或使用 scripting.registerContentScripts() 或(仅限 Firefox 中的 Manifest V2)contentScripts API 定义的内容脚本默认是持久的。它们在浏览器重启和更新以及扩展程序重启后仍然保持注册。

但是,scripting.registerContentScripts() API 提供了将脚本定义为非持久性的功能。例如,当您的扩展程序(代表用户)只想在当前浏览器会话中激活内容脚本时,这可能很有用。

权限、限制和局限性

Permissions

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

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

从 Manifest 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 识别 restricted_domains 策略,如 mozilla/policy-templates 中的 ExtensionSettings 所述。Chrome 的 runtime_blocked_hosts 策略在 配置 ExtensionSettings 策略 中有说明。

局限性

可以使用 data: URIBlob 对象和其他类似技术加载整个标签页或帧。内容脚本注入此类特殊文档的支持因浏览器而异,有关详细信息,请参阅 Firefox bug #1411641 comment 41

内容脚本环境

DOM 访问

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

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

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

Chrome 不兼容性中的“内容脚本环境”所述,行为因浏览器而异

  • 在 Firefox 中,此行为称为Xray 视觉。内容脚本可能会遇到来自其自身全局作用域的 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 提供 cloneInto()exportFunction(),以使内容脚本能够访问页面脚本创建的 JavaScript 对象,并将它们的 JavaScript 对象公开给页面脚本。

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

WebExtension API

除了标准 DOM API 之外,内容脚本还可以使用以下 WebExtension API

来自 extension

来自 runtime

来自 i18n

来自 menus

所有来自

XHR 和 Fetch

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

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

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

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

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

这是通过在内容脚本中公开更多特权的 XHR 和 fetch 实例来实现的,其副作用是不设置 OriginReferer 标头,就像来自页面本身的请求那样;这通常是优选的,以防止请求暴露其跨域性质。

注意:在 Firefox 中,对于 Manifest V2,需要执行行为如同内容本身发送的请求的扩展程序可以使用 content.XMLHttpRequestcontent.fetch()

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

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

注意:在 Chrome 中,从版本 73 开始,以及 Firefox 中,从版本 101 开始,当使用 Manifest V3 时,内容脚本受制于与它们运行的页面相同的 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 });
}

后台脚本监听这些消息,并使用 notifications API 显示通知

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.addEventListener API 与页面脚本通信。

例如

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()

注意:eval() 在 Manifest V3 中不可用。

在 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)");