我是AI时代的无业游民,我游荡在现实与意念之间
当过滤器选择放弃:uBlock Origin 与 Facebook 广告战的工程学复盘
过去几年里,内容拦截器和大型平台的对抗已经从"规则数量竞赛"演变为"规则维护成本的消耗战"。uBlock Origin 近期在 Facebook 广告拦截上的策略调整,本质上是这场消耗战里一次公开的止损决策。对中级开发者而言,这件事的价值不在于"某插件认输了",而在于它清晰暴露了一个工程问题:当对手的 DOM 结构以小时为单位变化时,静态规则列表的边际收益会迅速跌到零以下。本文不复述事件,而是拆解这个决策背后的设计取舍,以及它对你写爬虫、写前端监控、写任何"对抗性选择器"代码的启示。

背景与痛点
内容拦截器的工作模型极其朴素:拦截网络请求(网络层),或隐藏/移除已渲染节点(外观层)。前者依赖 URL 模式匹配,后者依赖 CSS 选择器。问题在于,Facebook 这类平台把"广告"和"内容"在 DOM 上做了深度同构——同一个 div 容器,今天承载赞助帖子,明天承载好友动态,class 名由构建工具生成且高频轮换。于是外观层过滤退化成一场"你改结构、我改选择器"的拉锯。
不解决这个问题的代价是可量化的:规则列表体积膨胀带来内存与 CPU 开销(uBO 官方一直强调其低资源占用是核心卖点),更重要的是误杀风险——一条过于激进的选择器可能隐藏掉正常动态,用户感知为"插件坏了"。维护者要为此承担持续的 issue triage 成本,而收益仅是"部分时段有效"。当维护成本曲线越过收益曲线,放弃就成了理性选择,而非失败。
方案设计
摆在维护者面前其实有三条路,取舍逻辑值得逐条看清:
| 方案 | 思路 | 优势 | 致命短板 |
|---|---|---|---|
| 继续堆静态规则 | 高频更新选择器列表 | 实现简单,无需改架构 | 维护成本随对抗强度线性上升,误杀率难控 |
| 脚本注入 + 运行时启发式 | 用内容脚本在页面内动态识别广告容器 | 适应性更强 | 需更高权限,易被平台反制,性能与稳定性风险大 |
| 战略性放弃特定站点 | 停止为该域维护外观规则 | 释放维护带宽,保证核心质量 | 该站点广告回归 |
选第三条的核心理由是边界清晰:uBO 的定位是"广谱、低开销的拦截器",而不是"与每个平台打定制战争"的军备竞赛工具。把有限维护资源投向规则生态(社区列表、通用追踪器拦截)比死磕单一站点 ROI 更高。这里明确放弃的替代方案是"用更激进的脚本注入强行维持效果"——它会把一个可预测的规则系统变成一个不可预测的运行时系统,违背了拦截器"低资源、可审计"的根本契约。
核心实现
子节一:网络层与外观层的职责切分
关键设计是把可稳定的部分留在网络层,把易变的部分交给外观层并允许其失效。网络层拦截基于请求 URL 与资源类型,平台难以在不破坏自身功能的前提下频繁改动;外观层则天然脆弱。因此正确的工程姿态是:网络层规则追求覆盖与精度,外观层规则接受"尽力而为"。
// 概念示意:网络层规则优先级高于外观层
const ruleset = {
network: [
{ pattern: "||facebook.com/tr/", type: "script" }, // 追踪像素,稳定
],
cosmetic: [
// 外观规则:允许因 DOM 变化而失效,不阻塞主流程
{ selector: "[data-ad-preview]", action: "hide", bestEffort: true },
],
};
子节二:外观规则的"失效安全"写法
即便保留少量外观规则,也必须写成失效即无副作用的形式。反例是把选择器写成依赖多层嵌套的强断言,一旦中间层变化就抛错或误伤。更稳的写法是缩小作用域、加防御性判断,并让异常被吞掉而非冒泡。
function applyCosmetic(rule) {
try {
const nodes = document.querySelectorAll(rule.selector);
nodes.forEach(n => n.style.setProperty("display", "none", "important"));
} catch (e) {
// 选择器失效不应影响页面其他逻辑
console.debug("[cosmetic] rule skipped:", rule.selector);
}
}
子节三:用 MutationObserver 替代轮询的边界
若确实需要运行时识别,优先用 MutationObserver 监听增量节点,而非 setInterval 全量扫描——后者在长列表页面上会造成可观测的卡顿。但要注意:观察器的回调必须做节流与去重,否则高频 DOM 变更会把主线程拖垮。
const seen = new WeakSet();
const observer = new MutationObserver(muts => {
for (const m of muts) {
m.addedNodes.forEach(n => {
if (n.nodeType !== 1 || seen.has(n)) return;
seen.add(n);
// 仅做轻量判定,重活交给 requestIdleCallback
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
效果验证
验证这类改动是否"有效",不能只看"广告是否消失",而要看三个可复现指标:规则命中率(外观规则实际匹配到的节点数 / 预期数)、误杀率(被隐藏节点中正常内容的占比)、单页 CPU 时间(DevTools Performance 面板采样)。可复现步骤:在同一 Facebook 页面分别加载旧规则集与新规则集,跑 3 次 Performance 录制取中位数,对比脚本执行时间与样式重算次数。预期结论是:放弃高频外观规则后,命中率下降但误杀率与 CPU 开销同步下降——这正是"止损"的量化证据。若你的场景里误杀率不降反升,说明选择器写得过于宽泛,需要回退到更窄的作用域。
边界与演进
这套"战略性放弃"的边界很明确:它适用于广告与内容同构、DOM 高频变动的平台;对结构稳定的站点,静态规则仍是性价比最高的方案。不适用场景包括:合规要求必须完全去广告的企业环境(此时应改用网络层 DNS/代理方案而非外观层)。下一步优化方向是把外观层规则从"硬编码选择器"演进为"声明式特征描述 + 运行时打分",让规则表达"什么样的节点像广告"而非"第几层第几个 div",从而在 DOM 变动时保持部分有效性——但这会引入新的误判维度,需要配套的可观测性与灰度机制,不能一步到位。
回到最初的问题:这次调整不是拦截技术的退步,而是一次诚实的工程边界声明。对开发者而言,真正该带走的是那条判断准则——当维护成本的增长速度超过收益,主动缩小战场比硬撑更专业。
本文复盘uBlock Origin在Facebook广告拦截中主动放弃外观层规则的决策过程,聚焦其背后工程学逻辑:当DOM高频变动导致CSS选择器维护成本远超收益时,通过网络层/外观层职责切分、失效安全的选择器写法、MutationObserver替代轮询等手段实现理性止损。分析涵盖误杀率、CPU开销与命中率的量化验证方法,并指出该策略适用于DOM同构且动态性强的平台,不适用于结构稳定或强合规场景。

336

被折叠的 条评论
为什么被折叠?



