DOM-Modifier:持久化修改第三方页面解决方案

140 阅读5分钟

掘金横图.png

一、背景

A_B 可视化实验流程 - visual selection.png

在 Web 开发中,我们难免会遇到需要修改第三方页面的场景,这就会让我们面临一个技术难题:在无法直接掌控源代码的条件下,如何对第三方页面的 DOM 进行修改?更棘手的是,传统的 DOM 操作常常深陷持久化的泥沼:当页面动态加载、SPA 路由切换或第三方脚本重新渲染时,手动修改的 DOM 内容容易被覆盖或失效。正是在这样的问题与需求的双重驱动下,我们提出了 🏆 DOM-Modifier —— 一种轻量级、可配置的持久化 DOM 修改解决方案,旨在开发者提供一种高效、稳定且兼容性强的解决方案,打破第三方页面定制化的难题。本文将深入解析 DOM-Modifier 的核心功能、使用方法和技术方案等内容。

二、DOM-Modifier

2.1 应用场景

动态变更现有网页是指对页面布局、样式、交互等进行动态调整,主要包含以下变更场景:

截屏2025-05-26 23.04.32.png

2.2 使用方法

基础使用:

npm i dom-modifier
import modifier from 'dom-modifier';

// 执行变更
const controllers = modifier([
  { type: 'html', selector: '#id', action: 'set', value: 'hello world' },
  { type: 'class', selector: '#id', action: 'remove', value: 'text-14px' },
]);

// 撤销变更
controllers.forEach((controller) => controllers?.revert?.());

数据协议:

截屏2025-05-26 23.08.13.png

/**
 * 内容变更
 */
const htmlSchema = [
  // 增加内容
  { type: 'html', selector: '#id', action: 'append', value: 'hello world' },
  // 替换内容
  { type: 'html', selector: '#id', action: 'set', value: 'hello world' },
  // 移除内容
  { type: 'html', selector: '#id', action: 'remove' },
];

/**
 * 类名变更
 */
const classSchema = [
  // 增加类名
  { type: 'class', selector: '#id', action: 'append', value: 'text-14px text-red' },
  // 设置类名(完全覆盖)
  { type: 'class', selector: '#id', action: 'set', value: 'text-14px lh-22px' },
  // 移除类名(空格区分),若为空则清除所有类名
  { type: 'class', selector: '#id', action: 'remove', value: 'text-14px bg-green' },
];

/**
 * 样式变更
 */
const styleSchema = [
  // 增加样式
  { type: 'style', selector: '#id', action: 'append', value: 'color: red; font-size: 14px;' },
  // 设置样式(完全覆盖)
  { type: 'style', selector: '#id', action: 'set', value: 'color: red; font-size: 14px;' },
  // 移除样式(驼峰式且空格区分),若为空则清除所有行内样式
  { type: 'style', selector: '#id', action: 'remove', value: 'color fontSize' },
];

/**
 * 属性变更
 */
const attributeSchema = [
  // 属性值增加(不建议用)
  { type: 'attribute', selector: '#id', attribute: 'data-id', action: 'append', value: '123' },
  // 属性值设置
  { type: 'attribute', selector: '#id', attribute: 'data-id', action: 'set', value: '123' },
  // 移除属性
  { type: 'attribute', selector: '#id', attribute: 'data-id', action: 'remove' },
];

/**
 * 位置移动
 */
const positionSchema = [
  // 将 #id 元素移动到 #parent 之内 #child 之前,如果没有 insertBeforeSelector,则放到 parentSelector 内部最后
  { type: 'position', selector: '#id', insertBeforeSelector: '#child', parentSelector: '#parent' },
];

/**
 * 插入内容
 */
const widgetSchema = [
  { type: 'widget', selector: '#id', widgetInsertPosition: 'beforebegin', value: '<div>hello world</div>' },
];

单一使用:

import { attribute, widget, position, styles, classes, html } from 'dom-modifier';

const { revert } = attribute(selector, attribute, (oldAttributeValue: string | null) => string | null);
const { revert } = widget(selector, () => { position: InsertPosition; content?: string | null});
const { revert } = position(selector, () => { parentSelector: string; insertBeforeSelector?: string | null});
const { revert } = styles(selector, (oldStyleObj: Record<string, string>) => void);
const { revert } = classes(selector, (oldClassSet: Set<string>) => void);
const { revert } = html(selector, (oldInnerHTML: string) => string);

2.3 技术方案

2.3.1 如何选中元素?

Dom-modifier 通过 class 选择器来定位需要变更的元素,即数据协议中的 selector 字段,因此需要使用者传入对应的选择器。根据 CSS Selector Generator Benchmark 中内容可以使用以下库来生成选择器:

2.3.2 如何更改元素?

变更内容变更方法
内容设置el.innerHTML = value
类名设置el.className = val
样式设置el.setAttribute('style', val)
属性设置el.setAttribute(attrName, val)
元素移动parentNode.insertBefore(el, insertBeforeNode)
新增组件insertAdjacentHTML(position, val)

💡 【元素移动】元素移动位置通过 parentNode 和 insertBeforeNode 来进行新位置定位

<parentNode>
  //...
  <target></target>
  <insertBeforeNode></insertBeforeNode>
  //...
</parentNode>

💡 【新增组件】新增组件基于目标元素主要有 4 个位置可以进行插入:beforebegin、afterbegin、beforeend、afterend

<!-- beforebegin -->
<p>
  <!-- afterbegin -->
  foo
  <!-- beforeend -->
</p>
<!-- afterend -->

在实现中为了能够高效执行撤销操作,每一个位置通过一个样式为 display: contents 且携带[widget-beforebegin-id="XXX"] 属性标识的位置容器进行包裹,同时插入的每一个元素也通过一个样式为 display: contents 且携带 id 属性的变更容器进行包裹。容器标识的目的是为了方便管理新增组件以及变更撤销时能够定位元素快速删除(存在新增组件包含多个同层级html,如果不包裹就需要删除多次)。同一个目标元素的位置容器 id 相同,所有变更容器的 id 不同以保持唯一。整体 HTML 格式示例如下:

<div style="display: contents;" widget-beforebegin-id="el-id">
  <div style="display: contents;" id="mutation-id-1">XXX</div>
  <div style="display: contents;" id="mutation-id-2">XXX</div>
</div>
<p>
  <div style="display: contents;" widget-afterbegin-id="el-id">
    <div style="display: contents;" id="mutation-id-3">XXX</div>
    <div style="display: contents;" id="mutation-id-4">XXX</div>
  </div>
  foo
  <div style="display: contents;" widget-beforeend-id="el-id">
    <div style="display: contents;" id="mutation-id-5">XXX</div>
    <div style="display: contents;" id="mutation-id-6">XXX</div>
  </div>
</p>
<div style="display: contents;" widget-afterend-id="el-id">
  <div style="display: contents;" id="mutation-id-7">XXX</div>
  <div style="display: contents;" id="mutation-id-8">XXX</div>
</div>

2.3.3 如何持久更改?

image005.png

DOM 内容变更会根据元素选择器找到目标元素执行相应的变更,并且对目标元素通过 MutationObserver 添加监听,当目标元素发生变动时会重新执行变更;同时开启全局监听,当前未找到目标元素但后续出现时会完成变更执行。监听页面变动以执行变更主要涉及两类监听:

  1. 页面全局监听:主要针对变更的目标元素不在当前页面中后面才会出现的场景;
  2. 变更元素监听:主要针对变更的目标元素发生变化需要重新执行变更(vue/react渲染);

新增组件需要监听目标元素的子元素层级和目标元素父元素的子元素层级变动;元素移动需要监听目标元素父元素的子元素层级变动。

截屏2025-05-26 23.10.24.png

  • subtree:监听以 target 为根节点的整个子树,包括子树中所有节点的属性,而不仅仅是针对 target;
  • childList:监听 target 节点中发生的节点的新增与删除(同时,如果 subtree 为 true,会针对整个子树生效);
  • attributes:观察所有监听的节点属性值的变化,当声明了 attributeFilter 或 attributeOldValue,默认值则为 false;
  • attributeFilter:一个用于声明哪些属性名会被监听的数组。如果不声明该属性,所有属性的变化都将触发通知;

三、总结

DOM-Modifier 为我们提供了一种优雅且高效的解决方案,它能够让我们在不控制原始 HTML 的情况下,持久地应用 DOM 变化,展现出极好的可靠性与灵活性。凭借对 MutationObserver 的精妙运用,DOM-Modifier 确保在各类动态场景下,修改都能正确保持。它支持多种 DOM 操作模式,还具备撤销变更的功能。无论是对第三方组件进行定制,修改动态内容,开发浏览器插件,还是开展 A/B 测试,DOM-Modifier 都能助力开发者在不改动原始代码的前提下,达成可靠且精准的界面定制目标。