几个月前我在做一个浏览器扩展,功能很简单:针对不同站点注入不同的样式和行为。最初版本一顿写就扔上去,实测时就发现一个问题——扩展在A网站表现正常,换到B网站要么失灵要么过度干预。后来我把逻辑拆了一遍,发现问题不在功能实现,而在于“上下文”这个概念从来没有被明确设计过。
这个坑也让我彻底想通了一个词:context-mode,也就是上下文模式。今天这篇东西就围绕它展开,讲清浏览器扩展里上下文模式是什么、为什么默认配置容易踩坑、以及我怎么从零手写一个按站点上下文切换行为的完整扩展。想直接看代码的可以跳到第3节,想搞懂原理的建议从头读。
1. 上下文模式是什么:从"一个扩展"到"一套场景规则"
1.1 浏览器的上下文远比你想象的多
这里的上下文指的不是某个API参数,而是扩展运行时所处的环境集合:当前是普通窗口还是隐身窗口、标签页加载的是哪个域名、页面里有没有iframe、当前frame的URL指向哪里、服务工作者脚本唤醒时的入口事件是什么……这些都是上下文的一部分。
生活里有个很贴切的类比:你在办公室穿正装,在家穿拖鞋,去健身房穿速干衣——不是说“穿衣服”这一行为变了,而是行为要根据场合选择具体形态。浏览器扩展也一样:同一个扩展,在Google搜索页和某个视频网站里,在普通窗口和隐身窗口里,合理的表现本来就该不一样。context-mode 的核心思路,就是把这套“看场合办事”的逻辑做成显式规则,而不是让所有用户面对同一套全局配置。
我见过很多扩展,包括一些下载量不小的,配置页打开是一堆开关:主题色、字号、自动展开、懒加载……功能做了一堆,但全部是全局开关。用户一旦在不同场景下需要不同设置,就只能反复手动切换,非常劝退。而 context-mode 就是把“开关”变成“如果……那么……”的条件规则,按域名、按窗口类型、按点击来源等条件组合出不同行为。
1.2 为什么说默认的扩展配置天然和上下文冲突
Chrome扩展的经典架构里,一个扩展默认面向“全局”生效。content_scripts声明式注入可以指定matches匹配特定URL,场面上看好像支持区分上下文,但实际使用中很快会发现:规则写死了就改不了,用户想在安装后自己调整站点列表,就得依赖后台逻辑动态注入脚本;而动态注入又要考虑权限、API限制、运行时机等问题。最典型的矛盾就是隐身/无痕窗口。
Chrome 默认情况下,扩展的 content script 不会注入到无痕窗口,因为无痕模式本身要求更强的隔离性。用户如果想让你的扩展在无痕窗口也生效,必须手动去扩展详情页打开“允许在无痕模式下运行”。这个开关一打开,问题又来了——无痕窗口里的浏览行为是否应该和普通窗口共用一个配置?大多数人希望答案是“不应该”。可如果不在代码里显式处理上下文隔离,那么这个开关打开后,无痕和普通窗口用的是同一套数据,毫无区分度。这就是缺了 context-mode 设计导致的体验割裂。
所以上下文模式其实包含两个层面:一是运行环境的自动感知(窗口类型、域名、frame),二是基于环境差异的规则响应(分别存储、分别执行)。设计得当,用户甚至会感觉“这个扩展本来就是为我的工作流定制的”。
如果你打算写一个认真可用的扩展,我建议一开始就把这两层做进架构,而不是等用户量上来了再补。
2. 从 manifest 到 API:Chrome 扩展上下文机制的关键入口
动手写代码之前,先把和上下文相关的几个入口理清楚。这些是 context-mode 实现的地基,每个都值得建立一个准确的认知。
2.1 manifest.json 中的上下文声明
MV3 的content_scripts是声明式注入,也是最“原始”的上下文配置:
{ "content_scripts": [ { "matches": ["https://*/*"], "exclude_matches": ["https://sensitive-site.com/"], "js": ["content.js"], "css": ["theme.css"], "run_at": "document_idle", "all_frames": false } ] }matches和exclude_matches:匹配哪些 URL 上下文,支持通配符。all_frames:是否注入到 iframe 等非顶级 frame。run_at:选择document_start、document_end还是document_idle,不同时机对SPA和静态页面影响很大。
但这是编译期写死的规则,用户无法通过配置页动态调整。真正动态化时需要chrome.scripting系列 API,后面的 Demo 会用到insertCSS和executeScript。
另外一个常被忽略的点是incognito字段。manifest 里可以声明:
{ "incognito": "spanning" }spanning表示扩展在普通窗口和无痕窗口共享同一个后台实例;split则为无痕窗口创建独立的后台实例,数据互不相通。实际开发中split模式坑比较多,很多 API 在 split 下的行为并不直观,大多数扩展走的还是spanning,然后在业务代码里自己判断窗口类型来区分逻辑。
2.2 判断当前上下文的常用 API
拿到当前上下文信息,最常用的组合是tabs和windows:
const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const win = await chrome.windows.get(tabs[0].windowId); const url = new URL(tabs[0].url);tabs[0].incognito:布尔值,标记该标签是否在无痕窗口中。url.hostname:当前站点的域名。win.incognito:整个窗口是否为无痕模式。
还有一点容易被忽视:content script 内部判断自身上下文时,可以直接使用window.location和chrome.runtime.id,不需要向后台发消息。如果扩展允许 iframe 中被注入,记得用window.top.location与window.location对比,判断当前脚本运行在顶层还是子 frame。
2.3 contextMenus:右键菜单本身就是上下文感知
chrome.contextMenus是一个生来就带上下文属性的 API。它可以在不同上下文环境显示不同菜单项,比如只在用户选中文本时出现“翻译”,只在图片链接上出现“保存原图”。
chrome.contextMenus.create({ id: "toggle-context-mode", title: "在此站点启用上下文模式", contexts: ["page", "selection", "link"], documentUrlPatterns: ["https://*/*"] });contexts支持page、frame、selection、link、image、video等,documentUrlPatterns还能继续圈定菜单出现的URL范围。这就是一个天然契合 context-mode 的入口:用户不用打开配置页,在页面上右键就能为当前站点快速设置规则。
下面我做的示例扩展里就加了这项功能,操作路径极短,实测用户反馈比预想的好。
3. 手写 context-mode 扩展:按站点上下文切换行为的完整实现
为了避免空谈,我直接做了一个可运行的示例。项目名称就叫 context-mode,目标非常窄:用户为不同域名添加“阅读上下文”,比如在新闻站启用夜间主题和扩大字号,在文档站只启用更柔和的背景色,其他站点一律不干预。麻雀虽小,但域名匹配、动态注入、配置存储、无痕模式处理都覆盖了。
3.1 项目结构与最简 manifest
按 MV3 标准组织项目:
context-mode/ ├── manifest.json ├── background.js ├── options.html ├── options.js ├── content.css ├── content.js └── icons/manifest 如下:
{ "manifest_version": 3, "name": "context-mode", "version": "0.1.0", "description": "按站点上下文自动切换阅读模式", "permissions": ["storage", "scripting", "tabs", "contextMenus"], "host_permissions": ["https://*/*"], "background": { "service_worker": "background.js" }, "action": { "default_popup": "options.html", "default_title": "context-mode" }, "options_page": "options.html", "incognito": "spanning" }几个容易引发困惑的点我提前说明:
tabs权限仅仅为了读取tab.url,在 MV3 中只需要"tabs"即可访问 URL 和 title 字段,不需要额外声明<all_urls>的 host 权限。host_permissions声明了https://*/*,这是为了让chrome.scripting能在所有 HTTPS 页面注入脚本。如果只想支持特定站点,出于安全考虑建议收窄。- 我没写
content_scripts,因为规则完全动态,不打算启动时盲目注入任何东西。
3.2 配置结构的核心:规则即上下文
用户在配置页看到的是“域名 + 行为”二元组。底层数据结构尽量简单,我用了普通对象数组:
// options.js 中维护的规则状态 const rules = [ { id: "rule_1", hostname: "news.example.com", theme: "dark", fontSize: 1.12, grayScale: false }, { id: "rule_2", hostname: "docs.example.org", theme: "light", fontSize: 1.0, grayScale: true } ];保存到chrome.storage.local,键名ctxRules。为什么用local而不是session?因为规则是跨会话的,用户设置一次就应该长期生效。storage.session更适合存放临时运行状态,比如“当前正在执行的注入任务ID”,浏览器重启后丢弃。
如果希望无痕窗口使用独立规则或者完全关闭规则,可以在后台脚本里通过tab.incognito判断。我的处理是:无痕窗口默认不执行任何注入,除非用户在配置页勾选“无痕窗口同样启用”。这是一个尊重隐私默认值的做法,也避免了很多不必要的用户困惑。
3.3 后台脚本:监听导航事件并按规则执行
核心逻辑在background.js。采用“监听 + 动态注入”的路径:
const RULE_KEY = "ctxRules"; async function getRules() { const data = await chrome.storage.local.get(RULE_KEY); return data[RULE_KEY] || []; } function matchRule(rules, hostname) { return rules.find((r) => hostname === r.hostname || hostname.endsWith("." + r.hostname)); } async function applyContext(tabId, url) { if (!url || !/^https?:/.test(url)) return; const hostname = new URL(url).hostname; const rules = await getRules(); const rule = matchRule(rules, hostname); if (!rule) return; const css = buildThemeCSS(rule); const ops = { target: { tabId }, css, origin: "context-mode" }; // 重复调用 insertCSS 时会抛出错误,先移除旧样式再插入新样式 try { await chrome.scripting.removeCSS(ops); } catch (e) { // 样式尚未注入时移除会报错,这里忽略 } await chrome.scripting.insertCSS(ops); } chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { if (changeInfo.status === "complete") { applyContext(tabId, tab.url); } }); chrome.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId === "toggle-context-mode") { // 打开配置页并预填当前站点域名 chrome.runtime.openOptionsPage(); } });buildThemeCSS根据规则生成 CSS 字符串:
function buildThemeCSS(rule) { const parts = []; if (rule.theme === "dark") { parts.push(` html { background-color: #1e1e1e !important; filter: invert(0.9) hue-rotate(180deg); } img, video { filter: invert(1) hue-rotate(180deg); } `); } if (rule.fontSize && rule.fontSize !== 1.0) { parts.push(`html { font-size: ${rule.fontSize * 100}% !important; }`); } if (rule.grayScale) { parts.push(`html { filter: grayscale(1); }`); } return parts.join("\n"); }注意insertCSS的样式是全局注入的,所以尽量把选择器和!important控制好,避免污染原站太多。filter: invert这种调暗方式只适合快速上手,生产级环境建议逐个元素覆盖变量或 class,但示例代码能跑通完整链路,就够了。
3.4 从配置页到动态生效:被大多数教程忽略的一步
如果你只是在配置页保存了规则,后台脚本并不知道你已经改了配置。所以必须在options.js里手动通知后台:
async function saveRules(rules) { await chrome.storage.local.set({ [RULE_KEY]: rules }); chrome.runtime.sendMessage({ type: "RULES_UPDATED" }).catch(() => {}); }后台脚本监听该消息并立即对当前激活标签检查一次:
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === "RULES_UPDATED") { (async () => { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (tab?.id) await applyContext(tab.id, tab.url); })(); } });这一步很多人会漏,导致用户改完规则必须刷新页面才生效。加了消息通知后,只要激活标签属于新规则站点,背景样式就会立刻更新,体验会好很多。
3.5 content script 还能做什么:注入 JS 而不是仅仅 CSS
CSS 能覆盖视觉上下文,但业务级行为必须靠 JS。比如某站点默认展开折叠内容,或者自动把某个按钮移动到更顺手的位置。context-mode 的规则结构里可以加一个jsActions数组:
{ hostname: "news.example.com", jsActions: ["expand-article", "sticky-header"] }然后在后台脚本里使用chrome.scripting.executeScript:
async function executeActions(tabId, actions) { for (const action of actions) { await chrome.scripting.executeScript({ target: { tabId }, files: [`actions/${action}.js`] }); } }做到这一步,扩展就从“调样式”升级成“调行为”了。需要注意的是,executeScript每次注入相当于在页面里执行一段全新的 JS,代码里不要依赖上次注入留下的全局变量。对跨调用状态,可以写到chrome.storage.session或页面 DOM 的 dataset 里,但尽量别给页面环境留太多持久副作用。
4. 上线前必看的踩坑记录:我在这套逻辑上翻过的车
代码能跑通只是第一步,context-mode 这类项目的坑大多藏在真实浏览器环境里。下面几条是我在实际调试中遇到的,任何一条都足以让用户觉得扩展“时灵时不灵”。
4.1 导航事件的触发时机:SPA 页面根本不触发 complete
chrome.tabs.onUpdated的changeInfo.status === "complete"最适合传统多页应用。但现在的站点大量使用前端路由,点击跳转时 URL 变了,页面并没有重新加载,complete不会再次触发。这会导致用户在站内跳转到其他文章后,新页面没有应用上下文样式。
对策有两个:一是 content script 常驻页面,通过history.pushState监听 URL 变化后向后台发送消息重新匹配规则;二是在落地页注入一个轻量脚本,内部通过setInterval或MutationObserver监听location.href变化。我实测下来MutationObserver比轮询可靠,且开销不大。
4.2 insertCSS 重复调用的边界
如果规则没变,但我们在onUpdated里盲目重复调用insertCSS,控制台会看到报错。原因是对同一个origin重复插入相同 CSS 文本时,Chrome 会认为样式已经存在。所以我在applyContext里先调removeCSS再insertCSS,并且把removeCSS的错误吞掉——“样式不存在”的报错不需要让用户看见。
更好的做法是记住当前 tabId 应用了哪条规则,规则没变就不重复操作:
const appliedMap = new Map(); async function applyContext(tabId, url) { const rule = matchRule(await getRules(), new URL(url).hostname); const key = tabId + ":" + rule?.hostname; if (appliedMap.get(tabId) === key) return; // ... 注入逻辑 appliedMap.set(tabId, key); }4.3 无痕模式下的"幽灵"行为
无痕窗口的隔离性比预想的更彻底。chrome.storage.local在无痕模式下并非完全独立,具体表现取决于扩展的incognito设置和用户是否手动开启开关。实测中最常见的现象是:用户在普通窗口保存规则,无痕窗口偶尔能读到,偶尔读不到,毫无规律。
后来我统一在后台接口处做判断:
function isIncognitoTab(tab) { return tab.incognito === true; } async function applyContext(tabId, tab) { const settings = await chrome.storage.local.get("incognitoEnabled"); if (isIncognitoTab(tab) && !settings.incognitoEnabled) return; // ... }优先尊重用户的预期:默认无痕窗口不加样式,除非他主动打开配置项。这比让用户在不同窗口间看到不一致且不可控的行为更安全、更利于信任建立。
4.4 iframe 和 PDF 查看器的噪声
tabs的 URL 看起来是https://example.com/paper.pdf,但实际内容可能是 Chrome 内置 PDF 查看器。这时插入的 CSS 往往无效,还可能干扰阅读。规避方式是先看contentType,不过tab对象拿不到这个信息,只能在 content script 里判断document.contentType。大部分情况直接对application/pdf跳过即可。
iframe 同理:顶级页面的域名和某个 iframe 的域名不一样,如果你在配置里匹配的是 iframe 域名,可能整个页面都没生效。建议默认只在顶级 frame 执行:
const ops = { target: { tabId, allFrames: false }, ... };确实需要 iframe 场景时,再单独加allFrames: true,并且要在 content script 里判断window.self !== window.top来区分。
4.5 Service Worker 的休眠导致状态丢失
MV3 后台用 Service Worker,空闲几秒后会被浏览器杀掉。如果你依赖一个全局Map保存“当前页面已应用规则”的状态,那么 SW 被回收后这些 Map 就消失了。虽然applyContext本身的逻辑不依赖存活状态,但如果你想做“规则未变不要重复注入”的优化,就要把状态存到storage.session,而不是留在内存里。
这里有个小技巧:
await chrome.storage.session.set({ appliedKey: key });storage.session可以在 SW 休眠苏醒后继续读取,而且它天然不持久化,不会把临时运行状态写进本地磁盘。
5. 从站点级扩展到场景级:context-mode 的进阶想象
做完域名维度的上下文切换之后,我建议你想一件事:域名只是最小的上下文单元,真正的上下文应该更接近用户的意图。
比如同样是研究型网站,用户可能处于“快速查阅答案”和“深度阅读全文”两种状态,前者希望页面越精简越好,后者希望排版宽适。再比如用户同时开了购物网站和比价网站,如果规则只按域名匹配,他切换标签页时行为会割裂。我在这类需求上尝试的升级方向有两个,目前验证下来效果不错。
5.1 与标签页分组联动
Chrome 标签分组(Tab Group)是一种天然的上下文分隔装置。可以允许用户把某个分组命名为“工作”,再让 context-mode 对该分组应用一套规则。实现上需要监听chrome.tabs.onUpdated、chrome.tabGroups.onUpdated,并在规则结构里加入groupName字段。
这种设计比单纯域名匹配更贴合真实工作流:用户在“工作”分组里打开一个娱乐类网站,看到的是符合工作场景的克制排版;而在“摸鱼”分组里打开同一个网站,则回到默认的舒适阅读模式。上下文在这里不再由页面地址决定,而是由页面在用户生活场景中的位置决定。
5.2 与右键菜单的快捷教学
我强烈推荐在配置页之外加上 contextMenus 快速入口。扩展安装后,用户不一定马上理解“上下文模式”是什么,但右键点击“在此站点启用阅读模式”,立刻看到效果,远比跑到配置页编辑规则直观。甚至可以在点击后直接把当前域名加入规则并用默认主题生效,再将“编辑更多细节”作为二级选项。
这种交互方式,符合“先给结果,再给控制权”的产品逻辑。我自己的体验是:配置规则类扩展最怕用户打开配置页面对空白状态不知所措。如果第一个交互是右键点上即用,学习成本几乎降到零。
5.3 后续要面对的取舍
做得越深,越要小心“规则爆炸”的问题。用户配置了30条规则、5个分组、若干窗口类型后,context-mode 自身也可能变得难以理解。我的建议是给每条规则提供“最后修改时间”和“最近匹配次数”,并在配置页展示哪些规则本周实际触发过,帮助用户清理僵尸规则。这个思路来源于我真实使用中的抱怨——配置太多后,我根本不知道哪条规则在起作用。
另外考虑默认值设计:context-mode 的价值在于“该动手时才动手”,所以默认不做任何事。让用户主动添加规则,而不是默认全局生效再逐条豁免,能够最大化避免打扰。
我最后的实测体会
这套代码我在本地扩展加载页(chrome://extensions,开发者模式,Load unpacked)跑了一周,每天的常规路径是:打开新闻站自动变暗色,打开文档站变柔和灰度,其余站点完全无感。印象最深的一次是深夜在一个研报站点读长文,字体和背景的适配让眼睛舒服了很多——那一刻我才觉得,上下文模式的复杂配置没有白做。
如果你准备做类似的扩展,我的建议是从最小闭环开始:先支持域名匹配和 CSS 注入,跑通整个链路后再加 JS 行为、无痕窗口开关、标签页分组。别在第一版就追求“全场景全功能”,上下文模式最大的价值不是功能多,而是该出现的时候自然出现,不该出现的时候完全不打扰。这种克制的产品体验,正是它和那些全局开关堆砌出来的扩展最本质的区分。