在别人的页面上挂自己的界面,难的不是把它画出来,是让它在一个你控制不了的页面里活下去。
宿主是个 SPA,它会随时重建 DOM;它自己也在监听各种事件;而你注入的东西会被它的代码「看见」。
三类问题,每一类的表现都不像是 UI 引起的。
纪律一:一切注入必须幂等
SPA 路由切换、列表刷新、弹窗重开——这些都会让你的挂载代码再跑一次。没有幂等保护的话,同一个按钮会出现三个。
用 data-* 记状态,不要用模块级变量:
const MARK = 'data-ext-mounted';
function mountBadge(row: HTMLElement): void { if (row.hasAttribute(MARK)) return; // ★ 已经挂过了 row.setAttribute(MARK, '1'); row.appendChild(createBadge());}为什么不用模块级变量:内容脚本注入每一个 frame,模块级变量每 frame 各一份,锁不住任何东西(这件事单独写过一篇:all_frames 的代价)。而 data-* 挂在 DOM 上,跨 frame 天然共享,宿主重建节点时也跟着一起没了——这正是你想要的语义。
同样的道理适用于注入到主世界的脚本:
if ((window as any).__myBridgeInstalled) return;(window as any).__myBridgeInstalled = true;少了这一行,监听器会叠加,一个请求收到多份响应。
纪律二:别污染宿主的文本
这条是三条里最贵的,因为归因方向完全错误。
某个版本我往一个表单字段的 label 里挂了几枚状态徽标。功能没问题,看起来也不错。
从那以后:
labelEl.textContent// 想要的:'客户'// 实际的:'客户 查档案 历史记录 详情'所有 === '客户' 的精确匹配当场全线失配。 找字段、写模板、同步标题、判断有没有草稿——全挂。
最难受的是:你改的是 UI,坏的是查找逻辑。 没有人会把「加了个徽标」和「填单功能失灵」联系起来,包括写这段代码的我自己。
而且这个坑我在两个不同的位置各踩了一遍——第二次是另一个模块里的另一份 label 读取函数,它没有被第一次的修复覆盖到。
解法有两半,缺一不可:
① 给自己注入的每个节点加统一标记。
badge.className = 'ext-badge'; // 或者 badge.dataset.extInjected = '1'② 所有读宿主文本的地方,先把自己的节点剔掉。
export function cleanTextOf(el: Element | null): string { if (!el) return ''; const clone = el.cloneNode(true) as HTMLElement; clone.querySelectorAll('[class*="ext-"]').forEach((n) => n.remove()); return clone.textContent ?? '';}cloneNode 是为了不动原节点——你只是要读个文本,不该有副作用。
凡是往宿主 DOM 里注入东西,就要假设有人在读那块 DOM 的文本。 加标记是一次性的小成本;不加的话,这个坑会在你想不到的第二个、第三个位置复发。
顺带一提:这条对你自己的查找代码有几份也提出了要求。如果项目里有三个地方各自实现了「读 label 文本」,你就得改三处——而你只会想起其中一处。
纪律三:别让 MutationObserver 自激
要在 SPA 上维持注入的 UI,几乎一定会用 MutationObserver。而这里有两个坑叠在一起。
坑 A:无防抖 + 全页观察 = 主线程被吃掉
// ✗ 这样写会出事new MutationObserver(() => applyOnce()) .observe(document.body, { childList: true, subtree: true });宿主是 React SPA,每来一条消息、每次列表刷新都是一批 DOM 变更。回调被高频喂。
我们有两个模块各自这样写了一个,叠在一起。实测某个计数器跑到了 589 次。
而每次回调要做的事情不轻:跨 frame 找表单、遍历所有行读 label 文本、写六个 inline style(写 style 会强制 layout)、更新徽标、装饰按钮……全是同步 DOM 读写。
用户的感受是「打字卡顿」——因为这些回调在和他的输入抢主线程。
修法是 rAF 合帧:
let scheduled = false;
function schedule(): void { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; applyOnce(); });}
new MutationObserver(schedule) .observe(document.body, { childList: true, subtree: true });同一帧内的多次变更只跑一次。改动很小,效果立竿见影。
能缩小观察范围就缩小:observe(document.body, {subtree: true}) 是最粗暴的写法。如果你知道目标容器,就观察那个容器。
坑 B:观察的同时又在改,等于死循环
MutationObserver 观察 body 的 childList,而你的回调往 body 里加节点——你的修改会再次触发你自己。
有三种处理方式,按优先级:
- 让写入幂等(纪律一)。已经挂过就直接 return,不产生新的 mutation。这是最干净的。
- 收窄观察类型。只关心节点增删就别观察
attributes——否则你给自己的节点setAttribute也会触发一轮。我们有个按钮装饰功能是改title属性的,它就必须确保 observer 没订阅attributes。 - 实在不行才
disconnect()/ 改完再observe()——但这期间宿主的变更你会漏掉。
坑 C:写成功不代表写住
这条严格说不属于 observer,但它和前两条是同一个家族的问题。
有个重试轮询(100ms 一次、最多 20 次,条件是「字段为空就写模板」)在后台跑着。同时另一条链路往同一个字段写了真实内容。
那个还没退出的轮询下一拍醒来,把真实内容覆盖成了空模板。
而写入函数的返回值是 { ok: true }——因为那一刻确实写进去了。
有后台轮询或定时器在改同一块 DOM 时,「写成功」的返回值不代表最终状态。 要么置一道闸让它们让路,要么写完回读确认。
我们两条都做了:一道跨 frame 的可重入闸(细节在 all_frames 那篇),加上写完 waitFor 回读。
附带一条:CSS filter 会制造 containing block
做夜间模式时踩的,很冷门但很值得记。
给一个元素加 filter,会让它内部所有 position: fixed 的后代相对它定位,而不是相对视口。
我们的夜间模式是整页反色,最初加在 :root 上——结果挂在 documentElement 下的浮动面板全都错位了。
改成加在 body 上就好了:浮动件挂在 documentElement 下,不在 body 的子树里,于是不受影响。
同类的还有 transform、perspective、backdrop-filter、will-change 里带这些属性的值——它们都会创建 containing block。
浮动定位突然错位,先查祖先链上有没有人加了
filter/transform。
(顺带:对一个 position: fixed 的元素做 transform: scale() 悬停动画,在重页面上会触发合成层闪现。改成只换底色和阴影就没事了。)
三条纪律其实可以合成一句:
你注入的东西,对宿主来说是「页面的一部分」。
宿主的代码会读到它、会被它触发、会因为它而重排。反过来,宿主的每一次重建也会把它清掉。
所以注入式 UI 的正确心态不是「我在页面上加了个东西」,而是**「我和宿主在共同维护同一棵 DOM 树」**——而对方完全不知道你的存在。