创见博客
从零实现一个 DOM 元素选择器:事件命中、Overlay 与 Shadow DOM
七崽爱吃小饼干2026/08/21阅读 1

最终的demo页:

最终实现的调用方式只有几行:

ts
const picker = new DomPicker({
  exclude: '[data-picker-ignore]',
  onHover: (element) => console.log('hover', element),
  onSelect: (element) => console.log('selected', element),
  onCancel: () => console.log('cancelled'),
});

picker.start();

启动后,鼠标经过页面元素时会出现高亮框;点击确认选择,回调拿到原始 Element;按 Escape 取消。这个插件不处理 AST、Source Map 或源码行号,只负责把“用户在页面上指出了哪个元素”转换成一个 DOM 对象。

边界保持简单,后面的源码映射就可以独立演进。

先确定插件职责

DOM Picker 容易和源码定位、CSS Selector 生成、组件树分析混在一起。实现前,我把它限制为五项职责:

  1. 开启和停止选择模式;
  2. 找到鼠标当前指向的元素;
  3. 绘制不影响页面交互的高亮层;
  4. 点击后返回 Element;
  5. 负责事件与 Overlay 的完整清理。

对应的 API 也不需要复杂:

ts
export type DomPickerOptions = {
  root?: Document | Element;
  exclude?: string | ((element: Element) => boolean);
  preventDefault?: boolean;
  onHover?: (element: Element | null) => void;
  onSelect?: (element: Element, event: MouseEvent) => void;
  onCancel?: () => void;
};

root 限制可选区域,exclude 排除工具栏等插件自身 UI,preventDefault 决定是否阻止目标元素原来的点击行为。三个回调则把 UI 选择结果交给上层。

这里没有 React 类型。插件主体只依赖浏览器 DOM API,因此可以被 React、Vue 或原生页面复用。

用捕获阶段接管页面事件

选择模式下,点击链接不能跳转,点击提交按钮也不能真的提交表单。事件必须在业务代码执行前被选择器截获。

启动时,插件在 document 上注册捕获阶段监听:

ts
start() {
  if (this.active) return;

  this.active = true;
  this.ensureOverlay();
  this.previousCursor = this.document.documentElement.style.cursor;
  this.document.documentElement.style.cursor = 'crosshair';

  this.document.addEventListener('pointermove', this.handlePointerMove, true);
  this.document.addEventListener('click', this.handleClick, true);
  this.document.addEventListener('keydown', this.handleKeyDown, true);
  this.document.addEventListener('scroll', this.handleViewportChange, true);
  this.document.defaultView?.addEventListener('resize', this.handleViewportChange);
}

最后一个参数 true 表示捕获阶段。事件从 document 向目标传播时,Picker 会先于目标节点上的普通冒泡监听器收到事件。

确认选择时,再阻止页面原有动作:

ts
private readonly handleClick = (event: MouseEvent) => {
  if (!this.active) return;

  const target = this.findTarget(event);
  if (!target) return;

  if (this.options.preventDefault !== false) {
    event.preventDefault();
    event.stopImmediatePropagation();
  }

  this.stop();
  this.options.onSelect?.(target, event);
};

preventDefault() 阻止链接跳转和表单提交,stopImmediatePropagation() 阻止同一事件继续触发其他监听器。调用方如果希望保留业务点击,可以显式传入 preventDefault: false。

为什么使用 composedPath

最容易想到的是读取 event.target:

ts
const element = event.target as Element;

但事件可能经过 Shadow DOM。event.target 会受到事件重定向影响,而 composedPath() 提供了事件实际经过的节点序列。插件从路径中找到第一个 Element:

ts
private findTarget(event: Event): Element | null {
  const pathTarget = event.composedPath().find(
    (node): node is Element => node instanceof Element,
  );

  return pathTarget && this.canSelect(pathTarget) ? pathTarget : null;
}

这也让 SVG 的 path、circle 等元素可以直接成为选择结果,因为它们同样继承自 Element。

需要注意,Closed Shadow DOM 不会把内部结构完整暴露给外部代码。对于这类组件,选择器最多只能拿到宿主元素,除非组件本身提供协作接口。

排除工具自身和指定区域

Picker 的控制栏、结果面板和 Overlay 都不应该成为可选目标,否则用户会不断选中工具自己。

Overlay 宿主使用固定属性标记:

ts
const HOST_ATTRIBUTE = 'data-dom-picker-overlay';

命中后统一经过 canSelect:

ts
private canSelect(element: Element) {
  if (element === this.host || element.closest(`[${HOST_ATTRIBUTE}]`)) {
    return false;
  }

  if (
    this.root instanceof Element &&
    element !== this.root &&
    !this.root.contains(element)
  ) {
    return false;
  }

  if (
    typeof this.options.exclude === 'string' &&
    element.closest(this.options.exclude)
  ) {
    return false;
  }

  if (
    typeof this.options.exclude === 'function' &&
    this.options.exclude(element)
  ) {
    return false;
  }

  return true;
}

字符串形式适合稳定规则:

html
<aside data-picker-ignore>选择结果</aside>
ts
new DomPicker({ exclude: '[data-picker-ignore]' });

函数形式适合业务判断,例如跳过不可见元素、编辑器浮层或某类自定义组件。

Overlay 不要修改目标元素

高亮一个元素有两种做法。一种是给目标节点添加 outline;另一种是创建独立 Overlay。

直接改目标样式实现简单,但有几个问题:

  • 可能覆盖页面原有内联样式;
  • transform、transition 和伪类会让恢复变得复杂;
  • 频繁修改目标节点可能触发业务 MutationObserver;
  • SVG 和普通 HTML 元素的样式行为不完全一致。

因此插件创建一个独立的固定定位矩形,根据目标的视口位置绘制:

ts
const rect = element.getBoundingClientRect();

Object.assign(this.box.style, {
  display: 'block',
  left: `${left}px`,
  top: `${top}px`,
  width: `${width}px`,
  height: `${height}px`,
});

getBoundingClientRect() 返回的正是视口坐标,与 position: fixed 的 Overlay 使用同一坐标系,不需要额外叠加页面滚动距离。

实现中还把矩形裁剪到当前视口,避免一个超大元素让 Overlay 越界:

ts
const viewportWidth = this.document.documentElement.clientWidth;
const left = Math.max(0, Math.min(rect.left, viewportWidth));
const top = Math.max(0, rect.top);
const width = Math.max(0, Math.min(rect.right, viewportWidth) - left);
const height = Math.max(
  0,
  Math.min(rect.bottom, this.document.documentElement.clientHeight) - top,
);

当内部滚动容器滚动,或者窗口尺寸变化时,目标的视口坐标会改变。插件监听捕获阶段的 scroll 和窗口 resize,重新执行 renderOverlay。

用 Shadow DOM 隔离 Overlay 样式

页面可能存在这样的全局 CSS:

css
div {
  box-sizing: content-box;
  transition: all 1s;
}

如果 Overlay 直接插入 body,这些规则可能改变它的尺寸、动画甚至可见性。实现中为宿主创建开放的 Shadow Root,把样式和节点都放进去:

ts
const host = this.document.createElement('div');
host.setAttribute('data-dom-picker-overlay', '');

const shadowRoot = host.attachShadow({ mode: 'open' });
const style = this.document.createElement('style');
const box = this.document.createElement('div');

shadowRoot.append(style, box);
this.document.body.append(host);

Overlay 自身必须设置 pointer-events: none。否则它覆盖在页面上方后,鼠标命中的就会变成 Overlay,而不是下方真正要选的元素。

css
:host {
  position: fixed;
  inset: 0;
  z-index: 2147483647;
  pointer-events: none;
}

一个真实踩坑:all 会重置层级

为了彻底隔离样式,我最初写了:

css
:host {
  all: initial;
}

Overlay 节点存在,内部矩形也在更新,但页面上看不到任何高亮。原因是 all: initial 不只清理字体和颜色,它也把宿主的 position、inset 和 z-index 重置了。原本通过内联样式设置的最高层级,被 Shadow DOM 内的 :host 规则覆盖。

修复方式是在重置后重新声明关键布局属性:

css
:host {
  all: initial;
  position: fixed;
  inset: 0;
  z-index: 2147483647;
  pointer-events: none;
}

Shadow DOM 可以隔离选择器,但不会让 CSS 级联规则消失。:host 仍然能够修改宿主本身,这一点在调试“节点存在但不可见”时尤其值得检查。

另一个取舍:先保证指针更新可靠

pointermove 触发频率很高,通常会考虑用 requestAnimationFrame 节流。但节流状态如果没有在所有分支正确释放,可能出现首个动画帧之后,后续移动全部被忽略的问题。

当前实现选择直接更新:

ts
private readonly handlePointerMove = (event: PointerEvent) => {
  if (!this.active) return;
  this.setTarget(this.findTarget(event));
};

setTarget 会判断目标是否变化;目标相同则只刷新位置。对于当前 Demo 和一般业务页面,这个实现更容易验证,也足够流畅。

如果后续在复杂页面中发现布局读取成本过高,可以再引入“只保存最新 PointerEvent,每帧统一消费一次”的节流模型。但性能优化应该建立在可测量的问题上,而不是先增加状态机复杂度。

生命周期必须成对

插件对外提供三个方法:

  • start() 注册监听并显示选择状态;
  • stop() 移除监听、隐藏高亮并恢复光标;
  • destroy() 在停止后彻底移除 Overlay。

start 和 stop 都是幂等的,重复调用不会重复注册或错误清理。

在 React 页面中,只需要创建一次实例,并在卸载时销毁:

tsx
useEffect(() => {
  const picker = new DomPicker({
    exclude: '[data-picker-ignore]',
    onSelect: (element) => setSelected(getElementDetails(element)),
  });

  pickerRef.current = picker;
  return () => picker.destroy();
}, []);

动态新增的节点不需要注册。选择器监听的是 document,每次移动都从当前事件路径读取元素,因此运行时插入的 DOM 会自然进入选择范围。

Demo 应该验证什么

为了避免只在一个普通 div 上验证,测试页覆盖了这些情况:

  • 多层嵌套的卡片、标题和文本;
  • 链接、按钮与输入框,验证原始交互是否被阻止;
  • SVG 的 path 和 circle;
  • 带 CSS transform 的旋转元素;
  • 独立滚动容器,验证 Overlay 是否随滚动刷新;
  • 运行时新增节点;
  • 带 data-picker-ignore 的控制栏和结果面板。

选择完成后,Demo 展示标签名、ID、class、尺寸和截断后的文本。这些数据不是 Picker 的核心返回模型,只是用于确认回调拿到了预期节点。

当前实现的边界

这个版本解决的是同一文档中的元素选择,还没有覆盖所有浏览器环境:

  • 同源 iframe 需要在 iframe 的 document 中创建独立 Picker;
  • 跨域 iframe 不能直接读取内部 DOM,需要 iframe 页面配合并通过 postMessage 通信;
  • CSS 伪元素不是 DOM 节点,无法作为 Element 返回;
  • Closed Shadow DOM 只能定位到外部可见的宿主;
  • 当前 Overlay 展示的是元素包围盒,不是完整的 margin、border、padding 盒模型;
  • 键盘选择、父子层级切换和无障碍提示仍可继续补充。

这些能力可以逐项增加,但不应该进入最小内核。一个可复用的 DOM Picker,最重要的不是功能数量,而是稳定返回正确的 Element,不破坏页面,并能在结束后清理干净。

当这层足够可靠后,AST 映射、Source Map、组件树分析或 AI 上下文提取,都可以把它当作统一入口继续向后连接。

评论
0/100