与页面共舞 —— Content Scripts 的魔法
与页面共舞 —— Content Scripts 的魔法
本章目标:理解并掌握 Content Scripts 的工作原理,并成功地向特定网页“注入”我们自己的脚本,实现对网页内容的读取和修改。
从“驾驶舱”到“外派无人机”
想象一下我们目前的扩展。它就像一艘停泊在空间站里的飞船。我们的 Popup
就是这艘飞船的驾驶舱——设计精良,灯光闪烁,按钮按下去也会有悦耳的提示音。但它终究被限制在空间站(浏览器工具栏)这个小小的港湾里。它能看到外面广袤的星辰大海(无数的网页),却无法真正地触及它们。
如果我们想分析一颗行星(读取某个网页的数据),或者想在一颗荒芜的星球上插上旗帜(修改某个网页的外观),光待在驾驶舱里是远远不够的。我们需要派遣一种工具——一种可以脱离飞船、直接降落在行星表面、并执行我们指令的**“外派无人机”**。
在浏览器扩展的世界里,这种“无人机”就是我们今天的主角——Content Scripts。
Content Scripts(内容脚本)是一段特殊的 JavaScript 代码。它的神奇之处在于,它不运行在 Popup 的小世界里,也不运行在扩展的后台,而是被直接注入到用户当前浏览的网页上下文中,与网页自身的 HTML、CSS 和 JavaScript “生活”在一起。
这意味着什么?
- 它可以直接访问和操作网页的 DOM:就像无人机降落后,可以用机械臂直接抓取地面上的石头一样。
document.querySelector
,document.getElementById
这些我们熟悉的 DOM 操作,在 Content Script 里操作的,就是那个网页本身的元素。 - 它可以与我们扩展的其他部分通信:这架无人机虽然身处远方,但它随身携带了“无线电”,可以把在行星上收集到的数据(比如网页标题、文章内容)传送回我们的飞船驾驶舱(Popup)或指挥中心(Background Script)。
掌握了 Content Scripts,你的扩展能力将发生质的飞跃。你可以制作出:
- 网页增强工具:给 Twitter 增加“一键截图”按钮,给 GitHub 仓库增加文件大小显示。
- 数据抓取工具:自动从电商网站的产品页面提取价格、评论和图片。
- 自动化工具:自动填充表单、自动点击“下一页”进行翻页。
- 阅读辅助工具:像我的
Chat2File
一样,读取页面上的对话内容;或者像翻译插件一样,划词进行翻译。
今天,我们将亲手打造并派遣我们的第一架“无人机”,让它在浩瀚的互联网海洋中,为我们插上第一面旗帜。
3.1 深入理解 Content Scripts:一个“熟悉的陌生人”
在我们开始编写代码之前,我们必须先搞清楚 Content Script 的三个核心特性。它就像一个我们既熟悉又陌生的朋友。
1. 运行环境:共享的 DOM,隔离的“世界” (Isolated World)
这是 Content Script 最核心,也最容易让人混淆的概念。
想象一个网页,比如 www.github.com
。它本身就像一个完整的生态系统,有自己的 HTML 结构(骨架)、CSS(外观),还有它自己的 JavaScript(生命活动,比如你点击按钮时触发的交互)。
当我们的 Content Script 被注入到这个页面时,会发生一件非常奇妙的事情:
-
共享的 DOM:我们的 Content Script 和页面原有的 JavaScript,共享同一个 DOM。这意味着,页面上的任何一个元素,双方都能看到,也都能修改。如果 Content Script 把一个
<h1>
标签的颜色改成了红色,页面原有的脚本也能立刻看到这个变化。这就是我们能修改网页内容的基础。 -
隔离的“世界” (Isolated World):然而,除了 DOM 之外,它们几乎是完全隔离的。我们的 Content Script 运行在一个独立的、被沙箱保护的 JavaScript 环境中。这意味着:
- 变量不互通:你在 Content Script 里定义的变量
let mySecret = '123';
,页面的原生脚本是绝对访问不到的。反之亦然,你无法直接读取页面 JS 定义的全局变量(比如一个网站用var a = 10;
,你在 Content Script 里访问a
会得到undefined
)。 - 函数不互通:你无法直接调用页面 JS 定义的函数,反之亦然。
- 库不冲突:如果一个网页自己用了 jQuery 2.0 版本,你的 Content Script 完全可以放心大胆地引入 jQuery 3.0 版本,它们之间不会产生任何冲突,因为它们生活在两个“平行宇宙”里,只是恰好能看到并操作同一个“物理世界”(DOM)。
- 变量不互通:你在 Content Script 里定义的变量
为什么要有“隔离世界”?
这是出于安全和稳定性的考虑。
- 安全性:防止恶意扩展窃取网页的敏感信息。如果变量互通,一个恶意扩展就可以轻松读取你在网页上输入的密码(如果它被存在 JS 变量里的话)。
- 稳定性:防止你的扩展代码与网页原有代码发生冲突。如果没有隔离,你定义的一个函数
function init() {}
可能会意外地覆盖掉页面本身也叫init
的重要函数,导致整个网页崩溃。
一句话总结:Content Script 和页面 JS 是“室友”,他们住在同一个房间(共享 DOM),但各自有自己上锁的日记本(隔离的 JS 环境),互不干涉。
2. 何时以及如何注入?—— “静态”与“动态”两种派遣方式
我们怎么告诉浏览器,应该在什么时候、往哪个网页上派遣我们的“无人机”(Content Script)呢?主要有两种方式:
-
静态注入 (Static Injection):也叫声明式注入。这是最常用、最简单的方式。我们直接在“营业执照”
manifest.json
文件里,像立合同一样,白纸黑字地写清楚我们的派遣规则。- 语法:通过
manifest.json
中的content_scripts
字段来声明。 - 规则:你可以指定
matches
规则,比如["*://*.github.com/*"]
,意思就是“只要用户打开任何 github.com 的页面,就自动把下面指定的这个脚本给我注入进去!”。 - 优点:简单、直接、无需编写额外代码。
- 缺点:不够灵活。规则是固定的,扩展一旦安装,规则就定死了。
- 语法:通过
-
动态注入 (Programmatic Injection):也叫命令式注入。这种方式更加灵活和强大。我们不在
manifest.json
里写死规则,而是在我们的其他脚本(比如 Background Script 或 Popup Script)里,通过调用chrome.scripting
API,在需要的时候,像指挥官下达命令一样,动态地决定向哪个标签页注入哪个脚本。- 语法:使用
chrome.scripting.executeScript()
函数。 - 场景:比如,当用户在我们的 Popup 里点击一个按钮时,我们才决定向当前激活的标签页注入一个脚本。这给了用户主动权。
- 优点:极度灵活,按需执行,节省资源。
- 缺点:需要编写更多的逻辑代码来控制。
- 语法:使用
在本章,我们将首先聚焦于更基础、更常用的静态注入。
3. 权限问题:访问 chrome.*
API 的限制
我们的“无人机”虽然强大,但它的“无线电”功率是受限的。
在 Content Script 中,你可以自由使用绝大部分标准的 Web API,比如 document
、window
、fetch
等。但是,你只能访问一小部分 chrome.*
扩展 API。
具体来说,你可以使用的主要是:
chrome.runtime
:用于和扩展的其他部分进行消息通信。这是最重要的一个!chrome.i18n
:用于国际化。- 还有一些其他的,但核心就是
runtime
。
你不能直接在 Content Script 里使用像 chrome.tabs
, chrome.bookmarks
, chrome.storage
这样的高级 API。
为什么?
同样是出于安全考虑。这是一种“权限分离”的设计思想。把强大的能力集中在扩展的“指挥中心”(Background Script)里,而“外派无人机”(Content Script)只负责执行具体任务和数据收发。如果无人机需要操作书签,它不能自己动手,而是必须通过“无线电”(chrome.runtime.sendMessage
)向指挥中心发出请求:“报告指挥中心,我需要创建一个书签,请指示!” 然后由指挥中心来执行实际操作。
这确保了所有敏感操作都有一个统一的、可控的入口。
好了,理论知识已经足够武装我们的大脑了。是时候进入实战,派遣我们的第一架无人机了!
3.2 项目实战(小插曲):为 GitHub 插上我们的旗帜
为了让你能最直观地感受到 Content Script 的威力,我们先来做一个有趣的小插曲,偏离一下我们“标签页管家”的主线任务。
我们的目标:当用户打开任何一个 GitHub 页面时,我们自动在这个页面的左下角,添加一个固定的小小的、带着我们扩展 Logo 的“徽章”,点击这个徽章还能弹出一个 alert
。
这个小功能虽然简单,但它完美地展示了 Content Script 的核心能力:匹配特定网站、修改页面 DOM 和 执行自定义 JS。
第一步:创建我们的 Content Script 文件
首先,在我们的项目根目录 my-first-extension
下,创建一个新的文件夹,专门用来存放我们的脚本文件。我们叫它 scripts
。
📂 my-first-extension/
├── 📂 images/
├── 📂 scripts/ <-- 新建这个文件夹
├── 📄 manifest.json
└── 📄 popup.html
然后,在 scripts
文件夹里,创建一个新的 JavaScript 文件,就叫 content.js
。
📂 my-first-extension/
└── 📂 scripts/└── 📄 content.js <-- 新建这个文件
打开 content.js
,写入以下代码。这段代码就是我们“无人机”要执行的指令集。
// scripts/content.jsconsole.log("你好,来自 Content Script 的问候!我已成功注入到这个页面。");// 创建一个“徽章”元素
const badge = document.createElement('div');// 使用内联样式,给徽章添加样式
badge.style.position = 'fixed';
badge.style.bottom = '20px';
badge.style.left = '20px';
badge.style.padding = '10px 15px';
badge.style.backgroundColor = '#4A90E2';
badge.style.color = 'white';
badge.style.borderRadius = '8px';
badge.style.zIndex = '9999'; // 确保它在最上层
badge.style.fontFamily = 'sans-serif';
badge.style.fontSize = '14px';
badge.style.cursor = 'pointer';
badge.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)';badge.textContent = '🚩 My First Extension Was Here!';// 为徽章添加点击事件
badge.addEventListener('click', () => {alert('你发现了我!这是由你的扩展添加的交互。');
});// 将徽章添加到页面的 body 中
document.body.appendChild(badge);console.log("徽章已成功添加到页面!");
让我们来分析一下这段指令:
console.log(...)
: 我们的老朋友,调试信息。当这个脚本成功注入后,我们应该能在目标网页的开发者工具控制台里看到这条信息。document.createElement('div')
: 我们凭空创建了一个div
元素。badge.style...
: 我们用 JavaScript 直接给这个div
添加了一大堆 CSS 样式,把它变成了一个漂亮的、固定在左下角的蓝色徽章。zIndex: '9999'
是个小技巧,用来确保我们的元素能“浮”在网页绝大多数元素之上,不被遮挡。badge.textContent
: 我们设置了徽章上显示的文字。badge.addEventListener('click', ...)
: 我们给徽章绑定了一个点击事件。当用户点击它时,会弹出一个alert
警告框。document.body.appendChild(badge)
: 这是最关键的一步。我们把这个精心制作的徽章,作为子元素,添加到了当前网页的<body>
标签的末尾。于是,它就成为了页面的一部分,并被浏览器渲染出来。
第二步:在 manifest.json
中声明派遣规则
我们的“无人机指令集” (content.js
) 已经写好了,但浏览器还不知道什么时候该派遣它。现在,我们要去“市政厅”更新我们的“营业执照”。
打开 manifest.json
文件。我们需要在里面添加一个新的顶级字段:"content_scripts"
。
请将你的 manifest.json
修改为如下内容(注意添加的位置和逗号):
{"manifest_version": 3,"name": "我的第一个扩展","version": "1.0.0","description": "这是一个从零到一教程中诞生的神奇扩展!","icons": {"16": "images/icon16.png","48": "images/icon48.png","128": "images/icon128.png"},"action": {"default_icon": "images/icon16.png","default_title": "点我,有惊喜!","default_popup": "popup.html"},"content_scripts": [{"matches": ["*://*.github.com/*"],"js": ["scripts/content.js"]}]
}
我们来剖析一下这个新添加的 content_scripts
字段:
"content_scripts": [ ... ]
: 它的值是一个数组。这意味着你可以定义多套不同的派遣规则。比如,一套规则用于 GitHub,另一套规则用于知乎。{ ... }
: 数组里的每一个对象,就是一套独立的派遣规则。"matches": ["*://*.github.com/*"]
: 这是规则的核心——匹配模式 (Match Patterns)。- 它的值也是一个数组,你可以指定多个匹配模式。
*://*.github.com/*
是一个非常强大的表达式,我们来拆解它:*
(在://
前): 匹配http
或者https
。*
(在.github.com
前): 匹配github.com
的任何子域名,比如gist.github.com
,www.github.com
或者直接就是github.com
。/*
(在最后): 匹配域名后面的任何路径。
- 总而言之,这个模式的意思是:“无论协议是 http 还是 https,无论是 GitHub 的哪个子域名,也无论后面的路径是什么,只要是 GitHub 网站下的任何页面,都给我匹配上!”
"js": ["scripts/content.js"]
: 这指定了当匹配成功时,需要注入的 JavaScript 文件列表。- 它的值也是一个数组,你可以按顺序注入多个 JS 文件。
- 这里的路径
"scripts/content.js"
是相对于我们扩展的根目录的。
小提示:你还可以添加
"css": ["styles/my-styles.css"]
字段,来同时注入 CSS 文件。这比在 JS 里写内联样式要优雅得多,我们以后会用到。
第三步:部署与验证
万事俱备,只欠东风!
- 保存你修改过的
manifest.json
和新建的content.js
。 - 回到
chrome://extensions
页面,刷新我们的扩展。这是至关重要的一步,因为我们修改了manifest.json
,必须重新加载才能让新规则生效。 - 现在,打开一个新的标签页,访问
https://github.com
(或者 GitHub 上的任何其他页面)。
仔细观察你的浏览器窗口!就在页面加载完成的一瞬间,你应该能看到:
- 在页面的左下角,出现了一个我们设计的蓝色徽章,上面写着 “🚩 My First Extension Was Here!”
- 用鼠标点击这个徽章,一个系统
alert
弹窗会跳出来,内容是 “你发现了我!这是由你的扩展添加的交互。”
现在,让我们进行更深入的验证:
- 在 GitHub 页面上,右键 -> 检查,打开这个网页的开发者工具。
- 切换到
Console
面板。 - 你应该能在日志的最顶端或某个地方,看到我们写的那条
console.log
:“你好,来自 Content Script 的问候!我已成功注入到这个页面。” - 切换到
Elements
面板,滚动到<body>
标签的最底部,你就能找到我们动态添加的那个<div>
元素。
再做一个对比实验:打开一个非 GitHub 的网站,比如 www.google.com
。你会发现,徽章没有出现,控制台里也没有我们的日志。这证明了我们的 matches
规则在精确地工作!
恭喜你!你已经成功地派遣了你的第一架“无人机”,并让它在目标星球上留下了清晰的印记。
你现在已经掌握了一种足以改变整个浏览体验的强大能力。这个小小的徽章,是你通往无限创意世界的一张门票。
3.3 回到主线:为我们的“标签页管家”做准备
好了,精彩的“小插曲”结束了。虽然在 GitHub 上插旗很酷,但它和我们的“智能标签页管家”这个最终目标关系不大。
那么,Content Script 在我们的主线项目中将扮演什么角色呢?
回想一下我们的目标:管理标签页。很多时候,我们需要的不仅仅是标签页的 URL,我们可能还需要一些页面内部的信息。比如:
- 我们想获取当前页面的精确标题(有些标题可能是由 JS 动态生成的)。
- 我们想提取当前页面的主要内容摘要。
- 我们想知道当前页面是否在播放视频。
这些信息,都存在于页面的 DOM 或 JS 环境中,是 chrome.tabs
API 无法直接提供的。这时候,就需要我们的 Content Script “无人机”降落到页面上,去“勘探”这些信息,然后通过“无线电”发回给我们的 Popup 或 Background。
虽然我们这一章还不会实现这个通信过程(那是后面章节的重头戏),但我们可以为之打下基础。
让我们把刚才的 content.js
和 manifest.json
修改一下,让它更符合我们主线项目的需求。
修改 content.js
我们不再需要那个徽章了。让我们把它改成一个更通用的、准备好进行信息勘探的脚本。
// scripts/content.jsconsole.log("标签页管家 Content Script 已加载!");// 这是一个占位函数,未来我们可以用它来获取页面信息
function getPageDetails() {const title = document.title;const url = window.location.href;// 假设我们未来还想获取页面的描述 meta 标签const descriptionMeta = document.querySelector('meta[name="description"]');const description = descriptionMeta ? descriptionMeta.content : '没有找到描述';return {title: title,url: url,description: description};
}// 现在我们只是在控制台打印一下,证明我们可以获取到信息
console.log("当前页面详情:", getPageDetails());
修改 manifest.json
我们的标签页管家,理论上应该能作用于所有网页,而不仅仅是 GitHub。所以,我们需要修改 matches
规则。
有一个特殊的匹配模式可以做到这一点:"<all_urls>"
。
// manifest.json
..."content_scripts": [{"matches": ["<all_urls>"],"js": ["scripts/content.js"],"run_at": "document_idle"}],
...
这里我们做了两个重要的改动:
"matches": ["<all_urls>"]
: 这告诉浏览器,向所有http
,https
,ftp
,file
协议的页面注入我们的脚本。这基本上涵盖了你日常浏览的所有网页。"run_at": "document_idle"
: 这是一个新的、非常有用的属性。它指定了脚本注入的时机。它有三个可选值:"document_start"
: 在document.head
刚创建,<body>
还没出现,页面其他脚本还没运行时就注入。速度最快,适合需要抢在页面加载前做一些事情的场景。"document_end"
: 在 DOM 加载完成之后,但在图片、iframe 等子资源加载完成之前注入。这是默认值。"document_idle"
: 在浏览器认为页面已经加载完成并空闲下来之后注入。这是最晚、最“礼貌”的注入时机,它能确保不会因为我们的脚本注入而拖慢页面的初始加载和渲染。对于我们这种非紧急的、获取信息的任务来说,这是最佳选择。
再次部署与验证
保存文件,刷新扩展。现在,随便打开任何几个网站,比如百度、B站、知乎。在每一个页面的开发者工具控制台里,你都应该能看到我们的 Content Script 打印出的日志和它从当前页面提取到的标题、URL 等信息。
我们已经成功地为我们的“标签页管家”部署了一支庞大的“无人机舰队”,它们现在潜伏在你的每一个标签页中,时刻准备着响应指挥中心的召唤。
本章总结与展望
在这一章,我们解锁了一项至关重要的超能力:
- 深入理解了 Content Scripts:我们搞清楚了它“共享DOM,隔离JS”的独特运行环境,以及它受限的 API 访问权限。
- 掌握了静态注入:我们学会了如何通过修改
manifest.json
,使用content_scripts
字段和matches
模式,将我们的脚本精确地注入到目标网站。 - 实践了 DOM 操作:我们通过一个有趣的“插旗”实例,亲手修改了 GitHub 的页面,直观感受到了 Content Script 的威力。
- 为未来铺路:我们将脚本的注入范围扩展到了所有页面,并学习了
run_at
属性,为我们后续项目的开发做好了准备。
现在,我们的扩展已经拥有了“驾驶舱”(Popup)和“外派无人机”(Content Script)。但它们还像是两个独立的部门,无法有效地协同工作。驾驶舱里的指挥官无法向无人机下达指令,无人机也无法将勘探到的宝贵数据传回。
这正是我们下一章要解决的核心问题。我们将要搭建起整个扩展的“神经中枢”和“通信网络”—— Background Scripts (Service Worker) 和 Messaging (消息通信)。
下一章将是理论和实践结合得最紧密,也是最能体现扩展开发精髓的一章。准备好迎接挑战,我们将把所有独立的部件,组装成一个真正协同工作的强大系统!