首页 / 浏览器扩展开发入门教程 / 内容脚本基础

浏览器扩展开发入门教程

内容脚本基础

本教程共 56 篇 · 第 16 篇 · 更新于 2026-08-13 · 约 9 分钟阅读

内容脚本content script注入机制隔离世界isolated world主世界scriptingMV3

本节目标:学完你能说清内容脚本能干什么、不能干什么,知道三种注入方式分别用在什么场景,并理解隔离世界与主世界的区别——这是后面所有页面改造功能的地基。

第 9 章我们在清单里写过 content_scripts 字段,那是”怎么声明”。这一章往里走一层,讲”它到底是个什么东西、浏览器怎么把它塞进页面、塞进去之后它活在一个什么样的环境里”。

16-1 内容脚本是扩展里唯一能碰网页的角色

内容脚本(content script)是一段跟着网页一起运行的 JavaScript。它能读这个网页的 DOM,也能改。扩展的其他部分——服务工作者、popup、选项页——统统拿不到网页 DOM,只有内容脚本能。

这句话反过来说同样重要:内容脚本的能力比很多人以为的要窄。它不是”扩展的全功能分身”。它能直接调用的扩展 API 只有一小撮:

  • chrome.dom
  • 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,比如 chrome.tabschrome.cookieschrome.downloadschrome.alarms,内容脚本一律碰不到。想用怎么办?发消息给 service worker,让它代办,再把结果回传。这是平台的既定设计,不是配置没写对,加权限也解不开。

Note

这个分工要明确:内容脚本管”页面里的事”,service worker 管”浏览器层面的事”,两者靠消息通信衔接。消息机制在模块五展开,这里只要知道有这条路就行。

顺带一提,内容脚本里也能用 fetch() 去读扩展自己打包的文件,但前提是那些文件声明成了可从网页访问的资源。这一点 16-7 再说。

16-2 三种注入方式,各管一段场景

MV3 给内容脚本准备了三条注入路径。你不必全部精通,但要知道什么时候该选哪条。

第一条:静态声明。 写在 manifest.json 的 content_scripts 里,浏览器在匹配页面加载时自动注入。适合”目标页面固定、每次都要跑”的脚本。

{
  "name": "My extension",
  "manifest_version": 3,
  "content_scripts": [
    {
      "matches": ["https://*.nytimes.com/*"],
      "css": ["my-styles.css"],
      "js": ["content-script.js"]
    }
  ]
}

第二条:动态注册。 在 service worker 里用 chrome.scripting.registerContentScripts() 把规则注册进去。适合匹配范围要等运行时才知道的场景,比如让用户在选项页里自己填要处理的网址。

chrome.scripting
  .registerContentScripts([{
    id: "session-script",
    js: ["content.js"],
    persistAcrossSessions: false,
    matches: ["*://example.com/*"],
    runAt: "document_start",
  }])
  .then(() => console.log("registration complete"))
  .catch((err) => console.warn("unexpected error", err));

注册过的脚本可以查、可以改、可以撤:getRegisteredContentScripts() 列出当前注册的,updateContentScripts() 改条件,unregisterContentScripts({ ids: [...] }) 按 id 删掉。上面那个 persistAcrossSessions: false 表示浏览器重启后不保留这条注册。

第三条:编程式注入。 由某个事件触发,用 chrome.scripting.executeScript() 打一枪。适合”用户点了按钮才动手”这种一次性动作。

chrome.action.onClicked.addListener((tab) => {
  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: ["content-script.js"]
  });
});

除了注入文件,也可以直接注入一个函数体,还能给它传参:

function injectedFunction(color) {
  document.body.style.backgroundColor = color;
}

chrome.action.onClicked.addListener((tab) => {
  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: injectedFunction,
    args: ["orange"],
  });
});

这里有个坑值得单独说:注入的函数是那个函数的副本,不是原函数本身。所以函数体必须自给自足,引用了外部变量就会在页面里抛 ReferenceError

三条路径的权限门槛也不一样:

  1. 静态声明里的 matches 本身就是注入依据,不需要在 host_permissions 里再重复写一遍。
  2. 编程式注入需要目标页面的宿主权限,或者用 activeTab 拿一次临时授权。
  3. 动态注册同样要有对应页面的权限支撑。
Tip

本章只把三条路径摆出来建立全局感。chrome.scripting 的完整参数在模块八有专章,现在不用背。

16-3 注入顺序:谁先跑

同一个文档阶段里,注入是有先后的,搞混了会出”我的脚本读不到另一个脚本定义的东西”这类怪问题。规则不复杂:

  • 清单里静态声明的内容脚本最先注入,早于其他任何方式注册的脚本。
  • 清单里声明了多组时,按它们在 content_scripts 数组里的书写顺序注入。
  • 同一组内,css 先于 js;数组里的文件按元素顺序依次注入。

所以如果你的主脚本依赖某个工具文件里的函数,把工具文件排在数组前面就行,不需要额外做模块加载。

16-4 隔离世界:变量互相看不见

内容脚本活在一个隔离世界(isolated world)里。官方给的定义是:隔离世界是一个私有的执行环境,页面和其他扩展都访问不到它。落到实际,最直接的后果就是——内容脚本里的 JavaScript 变量,宿主页面看不见,其他扩展的内容脚本也看不见。

拿官方那个按钮例子体会一下。页面自己长这样:

<html>
  <button id="mybutton">click me</button>
  <script>
    var greeting = "hello, ";
    var button = document.getElementById("mybutton");
    button.person_name = "Bob";
    button.addEventListener(
        "click", () => alert(greeting + button.person_name + "."), false);
  </script>
</html>

扩展往这个页面注入一段内容脚本,变量名故意撞车:

var greeting = "hola, ";
var button = document.getElementById("mybutton");
button.person_name = "Roberto";
button.addEventListener(
    "click", () => alert(greeting + button.person_name + "."), false);

点一下按钮,结果是两个弹窗依次出现:一个说 “hello, Bob.”,一个说 “hola, Roberto.”。

这个结果信息量很大,值得拆开看:

  1. 两个 greeting 是两个变量,各在自己的世界里,谁也没被覆盖。
  2. 挂在按钮节点上的 person_name 也是各世界一份,内容脚本写的没有盖掉页面写的。
  3. 但两个 addEventListener 都生效了,因为监听器加在同一个真实节点上。

隔离带来的好处很实在:你不必担心变量名和页面撞车,也不必担心和别的扩展打架,页面脚本更没法读走你的内部状态。

Note

页面、你的内容脚本、别人扩展的内容脚本,是三个互不相通的世界,谁都拿不到别人的上下文和变量。这个隔离机制从 Chrome 最早期就有了,最初是为浏览器标签页做隔离引入的。

16-5 唯一的共享地带是 DOM

上面第 3 条点出的正是关键:隔离的是 JavaScript 环境,不是 DOM。DOM 只有一份,页面和内容脚本共享同一棵节点树。

这就是内容脚本能改页面的根本原因,也是它和页面唯一的公共接口。你插一个按钮,页面能看到;页面删一个节点,你的脚本也会跟着受影响。

推论有两条,先记下,第 19 章会展开:

  • 想跟页面脚本交换数据,只能借道 DOM(包括 DOM 上的事件、window.postMessage 这类走 DOM 层的机制)。
  • “把对象挂到 window 上共享” 在默认配置下走不通,因为两个 window 不是同一个视角。

16-6 主世界:另一种运行方式

如果确实需要放弃隔离,content_scripts 里有个 world 字段。官方对两个取值的定义是这样的:

  • ISOLATED:隔离世界,本扩展独有的执行环境。默认值
  • MAIN:DOM 的主世界,也就是和宿主页面 JavaScript 共享的那个执行环境。
{
  "manifest_version": 3,
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["page-hook.js"],
      "world": "MAIN"
    }
  ]
}

写成 MAIN 之后,你的脚本基本等同于页面自己的一段 <script>:能直接读页面的全局变量,页面也能直接干扰你。这个开关什么时候值得动、代价是什么,第 18 章专门讲。默认情况下别碰它。

16-7 内容脚本怎么用扩展自己的文件

内容脚本跑在网页里,所以直接写 images/logo.png 这种相对路径会被当成网页的相对路径,肯定取不到。正确做法是用 chrome.runtime.getURL() 换成绝对地址:

let image = chrome.runtime.getURL("images/my_image.png");

注入的 CSS 里要引用扩展内的图片或字体,用预定义的扩展 ID 占位符拼地址:

body {
  background-image: url('chrome-extension://__MSG_@@extension_id__/background.png');
}

不管哪种写法,这些资源都必须在清单里声明成可从网页访问的资源,否则页面加载时会被拦:

{
  "manifest_version": 3,
  "web_accessible_resources": [
    {
      "resources": ["images/*.png"],
      "matches": ["https://example.com/*"]
    }
  ]
}
Tip

这里有个安全代价要知道:声明成可访问资源,等于把这些文件也暴露给了同一站点上运行的任何第一方或第三方脚本。所以 matches 要写窄,别顺手写成全站可访问。

16-8 有些页面注入不进去

新手常遇到”我明明写了 matches,脚本就是不跑”,其中一类原因是页面本身不允许注入:

  • 浏览器内部页面,比如 chrome://extensionschrome://settings
  • Chrome Web Store 的页面。
  • 其他扩展的页面。

另外 file:// 开头的本地文件页面,需要用户在扩展详情页里手动打开”允许访问文件网址”这个开关,装上就能跑是不成立的。

排查顺序我建议这样:先确认页面不属于上面这几类,再回头怀疑 matches 写错了,最后才怀疑脚本本身报错。

16-9 小结

这一章的骨架就三句话。内容脚本是扩展里唯一能碰网页 DOM 的角色,但能直接调的扩展 API 只有一小撮,其余靠消息中转。注入有静态声明、动态注册、编程式三条路,分别对应固定页面、运行时才确定、事件触发三种场景。内容脚本活在隔离世界,JavaScript 环境和页面完全分开,只有 DOM 是共享的。

下一章我们把 matches 里那串符号彻底讲透——匹配模式的语法规则不算多,但踩错一个字符整个扩展就加载不了。