首页 / 浏览器扩展开发入门教程 / run_at 与 world 配置

浏览器扩展开发入门教程

run_at 与 world 配置

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

run_atworlddocument_idledocument_startISOLATEDMAIN内容脚本MV3

本节目标:学完你能根据脚本要干的事,准确选出 run_at 的时机值,也能判断自己到底需不需要 world: "MAIN",并知道选它要承担什么。

上一章解决了”注入到哪些页面”,这一章解决另外两个维度:“什么时候进场”和”进场后站在哪个世界”。这两个字段都是可选的,默认值也都足够好用,但一旦遇到”脚本跑了却什么也没抓到”这类问题,答案往往就在它们身上。

18-1 页面加载有几个可插入点

先建立时间轴的概念。浏览器打开一个页面,大致会经过这么几步:拿到 HTML 开始解析、逐步构建 DOM、DOM 结构完成、图片和 iframe 这些子资源陆续加载完、window.onload 触发。

run_at 就是在这条时间轴上选一个点插入你的脚本。它有三个取值,对应三个不同的插入位置。

要强调的是:run_at 控制的是注入时机,不是”你的逻辑什么时候执行”。脚本被注入后立刻开跑,所以选早了,DOM 还没建好;选晚了,页面可能已经渲染完并被用户看到了。

18-2 document_start:抢在页面脚本之前

这是最早的插入点。官方的定义是:脚本在 css 里的文件之后注入,但在构建任何其他 DOM、运行任何其他脚本之前注入。

翻译成体感:这时候页面基本还是空的,你是这个页面上第一个跑起来的 JavaScript。

适合什么:

  • 需要在页面脚本之前动手的场景,比如提前插入一条样式规则、提前占住某个位置。
  • 想在页面自己的初始化之前埋点观察。

不适合什么:

// document_start 阶段,这行几乎肯定拿到 null
const title = document.querySelector('h1.article-title');
console.log(title.textContent); // TypeError

这时 DOM 还没构建,查元素基本都是空。要在这个时机等元素出现,你得自己等:

// 等 DOM 结构完成
document.addEventListener('DOMContentLoaded', () => {
  const title = document.querySelector('h1.article-title');
  console.log(title?.textContent);
});

或者用 MutationObserver 盯着节点插入。这两种写法都行,但要意识到:一旦你需要写这些等待逻辑,说明 document_start 可能不是你要的时机。

18-3 document_end:DOM 刚建好

官方定义:脚本在 DOM 完成后立即注入,但在图片、框架等子资源加载之前注入。

这个时机的特点是”结构齐了,皮肉还没长全”。你能查到所有元素、能改文字、能插节点,但图片的实际尺寸可能还是 0,iframe 里的内容也还没就位。

适合读取和改造完整的 DOM 结构,又希望尽早动手、别让用户看到改造过程。如果你的逻辑依赖图片尺寸或者 iframe 内容,这个时机就偏早了。

18-4 document_idle:默认值,也是首选

这是不写 run_at 时的默认值,官方也明确推荐尽可能用它。

它的行为是:浏览器会在 document_endwindow.onload 刚触发这个区间里,自己挑一个时机注入。具体挑哪一刻,取决于文档有多复杂、加载花了多久,浏览器的优化目标是页面加载速度。

这里有个很实用的保证:document_idle 运行的内容脚本不需要监听 window.onload,它保证在 DOM 完成之后才运行。所以下面这种写法是多余的:

// 没必要,document_idle 已经保证 DOM 完整了
window.addEventListener('load', () => {
  initMyFeature();
});

直接写就行:

initMyFeature();

那如果逻辑确实要等到 window.onload 之后呢?官方给的办法是查 document.readyState,判断 onload 是否已经触发过:

if (document.readyState === 'complete') {
  initMyFeature();
} else {
  window.addEventListener('load', initMyFeature, { once: true });
}

这段写法兼顾两种情况:注入时页面已经加载完就直接跑,还没完就等一下。

Tip

绝大多数改页面的需求,用默认的 document_idle 就是最省心的选择。别看到有三个值就觉得必须挑一个”更好的”。

18-5 三个时机怎么选

给你一张决策表,按”你的脚本要干什么”来查:

你的需求建议时机
常规的读 DOM、改 DOM、加按钮document_idle(默认)
要尽早改造、减少页面闪烁document_end
必须跑在页面脚本之前document_start
依赖图片尺寸、iframe 内容document_idle 或自行等 load

写法上就是加一行:

{
  "manifest_version": 3,
  "content_scripts": [
    {
      "matches": ["https://*.nytimes.com/*"],
      "run_at": "document_idle",
      "js": ["contentScript.js"]
    }
  ]
}

动态注册时对应的属性名是驼峰的 runAt,取值一样:

chrome.scripting.registerContentScripts([{
  id: "test",
  matches: ["https://*.nytimes.com/*"],
  runAt: "document_idle",
  js: ["contentScript.js"],
}]);
Note

同一个阶段内也有先后:清单里静态声明的内容脚本先注入,早于用其他方式注册的脚本;清单里多组之间按书写顺序注入。依赖关系靠这个顺序来保证。

18-6 world:两个世界的官方定义

world 字段决定脚本在哪个 JavaScript 世界里执行,默认值是 ISOLATED。官方对两个取值的定义是:

  • ISOLATED:隔离世界,本扩展独有的执行环境。
  • MAIN:DOM 的主世界,也就是与宿主页面 JavaScript 共享的那个执行环境。

第 16 章我们看过隔离的效果:变量不撞车、页面读不到你的内部状态、别的扩展也干扰不到你。这些好处都来自 ISOLATED

把值改成 MAIN,等于主动放弃这层保护,你的脚本变成页面的一段普通脚本:

{
  "manifest_version": 3,
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["page-hook.js"],
      "world": "MAIN",
      "run_at": "document_start"
    }
  ]
}

18-7 什么时候真的需要 MAIN

只有一类需求非它不可:你必须直接接触页面自己的 JavaScript 对象。举两个典型:

  • 页面把播放器实例、图表实例挂在了 window 上,你要拿到这个实例调它的方法。
  • 你要在页面初始化之前替换某个全局函数,让页面后面的代码走你的版本。

这两件事在隔离世界里做不到,因为隔离世界看不见页面的 window 属性。除此之外的需求——读 DOM、改 DOM、插节点、监听事件——隔离世界全都能做,不需要动 world

MAIN 的代价有四条,写之前先掂量:

  1. 变量不再隔离。你和页面撞了名字就会互相覆盖,出问题极难查。
  2. 页面能看到你。页面脚本可以读、可以改、可以删掉你的效果,也能反过来探测到扩展的存在。
  3. 别指望在这里用扩展 API。需要 chrome.* 的活儿要交回隔离世界去办。
  4. 环境共享。页面的报错、页面对原型链打的补丁,都可能连带影响你。
Note

官方给内容脚本开放的那一小撮扩展 API(storage、runtime 消息等),是围绕隔离世界设计的。你在 MAIN 世界里的脚本本质上就是页面脚本,请按”页面脚本没有扩展权限”来设计它。

18-8 推荐的做法:两组脚本分工

真要用 MAIN,我建议的模式是”最小暴露”:主世界只放必须贴着页面做的那一小段,其余全留在隔离世界,两边靠 DOM 事件或消息搭桥。

{
  "manifest_version": 3,
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["page-hook.js"],
      "world": "MAIN",
      "run_at": "document_start"
    },
    {
      "matches": ["https://example.com/*"],
      "js": ["main-logic.js"],
      "world": "ISOLATED",
      "run_at": "document_idle"
    }
  ]
}

这份声明的分工是:page-hook.js 在页面脚本之前进入主世界,只负责抓住页面对象、把需要的数据抛出来;main-logic.js 留在隔离世界,DOM 就绪后跑主要逻辑,并且它才是能用 chrome.storage、能给 service worker 发消息的那一方。

两组脚本怎么把数据递过去,是下一章的主题。

18-9 小结

run_at 三个值对应页面加载时间轴上三个插入点:document_start 最早、DOM 还空着;document_end 是 DOM 刚建好、子资源未完;document_idle 是默认与首选,保证 DOM 完整、不用监听 onload。需要在 onload 之后跑就查 document.readyState

world 默认 ISOLATED,这是你想要的默认值。只有”必须直接摸页面 JavaScript 对象”这一类需求才值得换成 MAIN,换了就要接受变量撞车、被页面干扰、用不上扩展 API 这些代价。真要用,就用两组脚本分工,把主世界的暴露面压到最小。

下一章我们把这条桥修起来——内容脚本和页面脚本到底怎么安全地交换数据。