在别人的页面上挂自己的界面,难的不是把它画出来,是让它在一个你控制不了的页面里活下去。

宿主是个 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 里加节点——你的修改会再次触发你自己。

有三种处理方式,按优先级:

  1. 让写入幂等(纪律一)。已经挂过就直接 return,不产生新的 mutation。这是最干净的。
  2. 收窄观察类型。只关心节点增删就别观察 attributes——否则你给自己的节点 setAttribute 也会触发一轮。我们有个按钮装饰功能是改 title 属性的,它就必须确保 observer 没订阅 attributes。
  3. 实在不行才 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 树」**——而对方完全不知道你的存在。