最终的demo页:

最终实现的调用方式只有几行:
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 生成、组件树分析混在一起。实现前,我把它限制为五项职责:
- 开启和停止选择模式;
- 找到鼠标当前指向的元素;
- 绘制不影响页面交互的高亮层;
- 点击后返回
Element; - 负责事件与 Overlay 的完整清理。
对应的 API 也不需要复杂:
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 上注册捕获阶段监听:
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 会先于目标节点上的普通冒泡监听器收到事件。
确认选择时,再阻止页面原有动作:
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:
const element = event.target as Element;
但事件可能经过 Shadow DOM。event.target 会受到事件重定向影响,而 composedPath() 提供了事件实际经过的节点序列。插件从路径中找到第一个 Element:
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 宿主使用固定属性标记:
const HOST_ATTRIBUTE = 'data-dom-picker-overlay';
命中后统一经过 canSelect:
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;
}
字符串形式适合稳定规则:
<aside data-picker-ignore>选择结果</aside>
new DomPicker({ exclude: '[data-picker-ignore]' });
函数形式适合业务判断,例如跳过不可见元素、编辑器浮层或某类自定义组件。
Overlay 不要修改目标元素
高亮一个元素有两种做法。一种是给目标节点添加 outline;另一种是创建独立 Overlay。
直接改目标样式实现简单,但有几个问题:
- 可能覆盖页面原有内联样式;
- transform、transition 和伪类会让恢复变得复杂;
- 频繁修改目标节点可能触发业务 MutationObserver;
- SVG 和普通 HTML 元素的样式行为不完全一致。
因此插件创建一个独立的固定定位矩形,根据目标的视口位置绘制:
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 越界:
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:
div {
box-sizing: content-box;
transition: all 1s;
}
如果 Overlay 直接插入 body,这些规则可能改变它的尺寸、动画甚至可见性。实现中为宿主创建开放的 Shadow Root,把样式和节点都放进去:
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,而不是下方真正要选的元素。
:host {
position: fixed;
inset: 0;
z-index: 2147483647;
pointer-events: none;
}
一个真实踩坑:all 会重置层级
为了彻底隔离样式,我最初写了:
:host {
all: initial;
}
Overlay 节点存在,内部矩形也在更新,但页面上看不到任何高亮。原因是 all: initial 不只清理字体和颜色,它也把宿主的 position、inset 和 z-index 重置了。原本通过内联样式设置的最高层级,被 Shadow DOM 内的 :host 规则覆盖。
修复方式是在重置后重新声明关键布局属性:
:host {
all: initial;
position: fixed;
inset: 0;
z-index: 2147483647;
pointer-events: none;
}
Shadow DOM 可以隔离选择器,但不会让 CSS 级联规则消失。:host 仍然能够修改宿主本身,这一点在调试“节点存在但不可见”时尤其值得检查。
另一个取舍:先保证指针更新可靠
pointermove 触发频率很高,通常会考虑用 requestAnimationFrame 节流。但节流状态如果没有在所有分支正确释放,可能出现首个动画帧之后,后续移动全部被忽略的问题。
当前实现选择直接更新:
private readonly handlePointerMove = (event: PointerEvent) => {
if (!this.active) return;
this.setTarget(this.findTarget(event));
};
setTarget 会判断目标是否变化;目标相同则只刷新位置。对于当前 Demo 和一般业务页面,这个实现更容易验证,也足够流畅。
如果后续在复杂页面中发现布局读取成本过高,可以再引入“只保存最新 PointerEvent,每帧统一消费一次”的节流模型。但性能优化应该建立在可测量的问题上,而不是先增加状态机复杂度。
生命周期必须成对
插件对外提供三个方法:
start()注册监听并显示选择状态;stop()移除监听、隐藏高亮并恢复光标;destroy()在停止后彻底移除 Overlay。
start 和 stop 都是幂等的,重复调用不会重复注册或错误清理。
在 React 页面中,只需要创建一次实例,并在卸载时销毁:
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 上下文提取,都可以把它当作统一入口继续向后连接。