ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

上下文模式实战:按站点动态切换行为的浏览器扩展开发指南

上下文模式实战:按站点动态切换行为的浏览器扩展开发指南 几个月前我在做一个浏览器扩展功能很简单针对不同站点注入不同的样式和行为。最初版本一顿写就扔上去实测时就发现一个问题——扩展在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 到 APIChrome 扩展上下文机制的关键入口动手写代码之前先把和上下文相关的几个入口理清楚。这些是 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和windowsconst 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 而不是仅仅 CSSCSS 能覆盖视觉上下文但业务级行为必须靠 JS。比如某站点默认展开折叠内容或者自动把某个按钮移动到更顺手的位置。context-mode 的规则结构里可以加一个jsActions数组{ hostname: news.example.com, jsActions: [expand-article, sticky-header] }然后在后台脚本里使用chrome.scripting.executeScriptasync 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 页面根本不触发 completechrome.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 行为、无痕窗口开关、标签页分组。别在第一版就追求“全场景全功能”上下文模式最大的价值不是功能多而是该出现的时候自然出现不该出现的时候完全不打扰。这种克制的产品体验正是它和那些全局开关堆砌出来的扩展最本质的区分。
返回列表