一条 append 规则,为什么会让整批规则悄悄回退

5 阅读7分钟

你加了一条请求头规则,点了应用,界面上它安安静静躺在列表里。切到网络面板,那个头没有出现。你把值改了一遍、把 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 支持三种操作:setappendremove。但 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 条
其余规则照常生效

一条规则进入写入调用之前,值得先查三件事:

  1. 操作与头名的组合是否被支持append + 请求头 → 查那 21 个的白名单;
  2. 头名本身是否合法:空名、含非法字符的都会让整批失败;
  3. 取值是否已经可解析:引用了未定义的变量、或者用了运行期才知道的值(如当前 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 就够了:

你想做的事可行做法
给请求打一个自定义链路 IDsetX- 开头的头本来也不会有原值需要保留)
往 Cookie 里塞一个调试项append 可用,它在白名单里
追加 Accept 的一个 MIMEappend 可用
在响应头上追加任意头append 可用,响应头不受限
追加自定义鉴权头改用 set,把完整值一次写好

判断方法很简单:你追加的这个头,原本存在吗?  如果本来就不存在,append 和 set 的效果没有区别,直接用 set

边界

  • 响应头不受此限。同一个 append,用在响应头上完全合法。
  • session 规则同理updateSessionRules 的原子性与 updateDynamicRules 一致,按标签页作用域下发的规则也走同一套校验。
  • 还有条数上限。动态规则有数量天花板,超出同样是整批失败——所以编译期除了查白名单,也该顺手数一下条数。
  • 这套思路不只对 DNR 有效。任何"批量提交、原子生效"的 API 都有同一个形状的坑:一条脏数据可以让整批静默回退。凡是遇到这种 API,把校验前移到提交之前,并且让每一条失败都带上自己的原因——比事后 catch 一个笼统错误有用得多。

如果你想看这套校验在真实产品里长什么样,HeaderFlex 的规则与应用文档写了完整的诊断口径;对匹配条件的部分,URL 匹配里有更细的说明。