你加了一条请求头规则,点了应用,界面上它安安静静躺在列表里。切到网络面板,那个头没有出现。你把值改了一遍、把 URL 匹配放宽、又刷新了几次——什么都没变。
更奇怪的是,同一个配置里别的规则也一起不动了。而它们上一次明明是好的。
这不是匹配没命中,也不是缓存。这是一次写入被浏览器整体拒绝了,而被拒的代价,比"规则没生效"更隐蔽。
先把三件事分开看
一、规则是"先写进去"的
MV3 里改 HTTP 头走的是 declarativeNetRequest。它的模型是声明式的:你把规则编译成一份声明交给浏览器,请求发生时由浏览器网络栈直接执行,扩展的 JavaScript 根本不参与。
好处很实在——请求不经过扩展代码,没有唤醒 service worker 的开销,也不存在扩展拖慢浏览的问题。代价是另一面:任何"要到那一刻才知道"的东西都进不来。规则里没有请求上下文,因为规则被写下的时候,请求还不存在。
二、写入是原子的
规则通过一次 API 调用装进浏览器:
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1, 2, 3],
addRules: [ /* 新编译出来的规则 */ ]
})
文档对这次调用的描述只有一句,但这句是本文的关键:
This update happens as a single atomic operation: either all specified rules are added and removed, or an error is returned.
要么全部生效,要么一条都不生效。没有"部分成功"这个中间态。
三、append 在请求头上有一份白名单
modifyHeaders 支持三种操作:set、append、remove。但 append 用在请求头上时,Chrome 只接受 21 个头:
accept · accept-encoding · accept-language · access-control-request-headers · cache-control · connection · content-language · cookie · forwarded · if-match · if-none-match · keep-alive · range · te · trailer · transfer-encoding · upgrade · user-agent · via · want-digest · x-forwarded-for
名单之外的请求头一律不行——包括几乎所有 X- 开头的自定义头,也就是最容易想到要"追加一个链路 ID"的那类。响应头不受这个限制,append 随便用。
这条规矩本身不难理解,麻烦的是它与第二条撞在一起之后的效果。
合起来会发生什么
假设你的应用策略是全量重建——每次应用先清掉自己的旧规则、再写入新编译的规则,两个动作放在同一次调用里(这是最干净的做法,规则 id 天然不会撞):
const current = await chrome.declarativeNetRequest.getDynamicRules()
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: current.map(r => r.id), // 清掉旧的
addRules: compiled // 写入新的
})
现在这批 compiled 里混进了一条 X-Trace-Id: append。会发生什么?
addRules 违规 → 整次调用失败 → removeRuleIds 也一并回滚。
于是结果不是"规则全没了"。是浏览器里仍然装着上一次成功写入的那批规则,并且继续执行它们。
图 1 · 没有守卫:整批被拒,旧规则继续生效
AuthorizationsetX-EnvsetX-Trace-IdappendCookieappend**Refererremove
✕ 整批拒绝updateDynamicRules() remove + add单次原子调用
浏览器规则引擎
v1
仍是上一次成功写入的规则集
编辑器显示 v2 · 实际执行 v1
这就是那个反直觉的地方:你不会得到一个空的规则引擎,你会得到昨天的规则引擎。编辑器里显示着你刚改好的 v2,网络里跑的是 v1,两边没有任何东西提示你它们已经脱节。
为什么这比普通的"规则没生效"难查得多:
- 现象具有欺骗性。有些规则"还在生效"(旧的那批),你会自然地推断"扩展是工作的,是我这条写错了",于是一头扎进匹配条件里。
- 失败点和症状离得很远。出问题的是
X-Trace-Id那条,表现出来的却是Authorization那条没更新。你盯着后者查,永远查不到。 - API 的报错帮不上忙。你 catch 到的是一句笼统的失败,不会告诉你是哪一条规则、哪个头触发的。
十行代码自证
不用信我,在任意 MV3 扩展的 service worker 控制台里跑一遍:
const before = await chrome.declarativeNetRequest.getDynamicRules()
console.log("before:", before.length)
try {
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: before.map(r => r.id),
addRules: [{
id: 1001,
priority: 1,
condition: { urlFilter: "||example.com^", resourceTypes: ["xmlhttprequest"] },
action: {
type: "modifyHeaders",
requestHeaders: [
{ header: "X-Trace-Id", operation: "append", value: "abc" } // 不在白名单
]
}
}]
})
} catch (e) {
console.error("rejected:", e.message)
}
const after = await chrome.declarativeNetRequest.getDynamicRules()
console.log("after:", after.length) // 与 before 相同 —— remove 没有发生
after.length === before.length:这一行就是证据。你以为自己清空并重写了规则集,实际上什么都没变。
把 operation 从 "append" 改成 "set",同一段代码立刻通过。
校验该放在哪一层
直觉上会想到在调用外面包一层 try/catch,失败就提示用户。这解决不了问题:你捕获到的错误没有指向性,用户看到"应用失败",仍然不知道该改哪一条。
正确的位置在写入之前——把不合法的规则在编译期挑出来,跳过它并说明原因,其余照常写入:
图 2 · 守卫在编译期:坏规则被分流,其余照常写入
AuthorizationsetX-EnvsetX-Trace-IdappendCookieappend**Refererremove
编译期校验白名单 · 头名 · 变量不合法者不进入写入
已跳过 · 附原因
X-Trace-Id:Chrome 不支持对该请求头 append,改用 set
浏览器规则引擎
v2
写入 4 条 · 跳过 1 条
其余规则照常生效
一条规则进入写入调用之前,值得先查三件事:
- 操作与头名的组合是否被支持:
append+ 请求头 → 查那 21 个的白名单; - 头名本身是否合法:空名、含非法字符的都会让整批失败;
- 取值是否已经可解析:引用了未定义的变量、或者用了运行期才知道的值(如当前 URL、查询参数),在头规则里都无法成立——因为如前所述,写入的时刻请求还不存在。
这三条任何一条不过,就把那一条规则跳过并记下原因,让剩下的规则正常写入。一条坏规则的代价应该是"它自己不生效",而不是"这次应用什么都没生效"。
HeaderFlex 里就是这么做的:白名单是一个 Set,编译期查一次表;不合法的规则不进入 updateDynamicRules,而是变成一条带原因的诊断。你在编辑器里改的当下就会看到提示:
图 3 · 编辑器里的即时提示(应用之前)
请求头规则
AuthorizationsetBearer ••••••••
X-Trace-Idappendreq-8f21应用时将跳过
Chrome 不支持对请求头「X-Trace-Id」追加,请改用「设置」
Cookieappenddebug=1
应用之后再给一份完整的报告——写入几条、跳过几条、每条为什么:
图 4 · 应用之后的诊断(可点开每一条)
本次应用
4写入条
1跳过条
1警告条
0错误条
跳过原因
X-Trace-IdX-Trace-Id:Chrome 不支持对该请求头 append,改用 set
这套东西不复杂,自己实现也就是一个白名单加几个判断。真正的要点是位置:校验必须在原子写入之前完成,因为写入之后就没有"部分成功"可谈了。
什么时候该用 set 代替
大多数想 append 的场景,其实 set 就够了:
| 你想做的事 | 可行做法 |
|---|---|
| 给请求打一个自定义链路 ID | set(X- 开头的头本来也不会有原值需要保留) |
往 Cookie 里塞一个调试项 | append 可用,它在白名单里 |
追加 Accept 的一个 MIME | append 可用 |
| 在响应头上追加任意头 | append 可用,响应头不受限 |
| 追加自定义鉴权头 | 改用 set,把完整值一次写好 |
判断方法很简单:你追加的这个头,原本存在吗? 如果本来就不存在,append 和 set 的效果没有区别,直接用 set。
边界
- 响应头不受此限。同一个
append,用在响应头上完全合法。 - session 规则同理。
updateSessionRules的原子性与updateDynamicRules一致,按标签页作用域下发的规则也走同一套校验。 - 还有条数上限。动态规则有数量天花板,超出同样是整批失败——所以编译期除了查白名单,也该顺手数一下条数。
- 这套思路不只对 DNR 有效。任何"批量提交、原子生效"的 API 都有同一个形状的坑:一条脏数据可以让整批静默回退。凡是遇到这种 API,把校验前移到提交之前,并且让每一条失败都带上自己的原因——比事后 catch 一个笼统错误有用得多。
如果你想看这套校验在真实产品里长什么样,HeaderFlex 的规则与应用文档写了完整的诊断口径;对匹配条件的部分,URL 匹配里有更细的说明。