上一篇讲了 utils 的整体设计模式,这篇我们钻进它最大的子模块
./dom。播放器特别需要几类 DOM 能力:穿透 Shadow DOM 的事件目标、深挖 shadow tree 的焦点、判断用户点的是不是可交互元素、浮动层定位。这些 utils 都有专门处理,而且处理得相当考究。
事件目标:穿透 Shadow DOM 的 resolveEventTarget
播放器的 UI 大量用自定义元素(Web Components),很多带 Shadow DOM。这就带来一个麻烦:事件冒泡穿过 shadow 边界时,event.target 会被 retarget 到 shadow host,你拿不到真正触发事件的内部元素。
dom/event.ts:2-5 的 resolveEventTarget 解决这个问题:
export function resolveEventTarget(event: Event): EventTarget {
const path = event.composedPath(); // L3
return path.length > 0 ? path[0]! : event.target; // L4
}
composedPath() 返回从最深目标到 window 的完整路径,path[0] 就是真实的 dispatch 目标。相比 event.target,它能穿透 Shadow DOM 边界拿到 shadow tree 内部的真实元素。path[0]! 用非空断言,因为 composedPath 至少有一个元素。
这个函数在 utils 里被反复用,比如下面要讲的交互判定。
焦点:深挖 shadow tree 的 getDeepActiveElement
document.activeElement 有个众所周知的坑:当焦点元素在 open shadow tree 里时,它只返回 shadow host,不返回真正聚焦的叶子。
dom/focus.ts:1-9 的 getDeepActiveElement:
export function getDeepActiveElement(root: Document | ShadowRoot = document): Element | null {
let active = root.activeElement; // L2
while (active?.shadowRoot?.activeElement) { // L4
active = active.shadowRoot.activeElement; // L5
}
return active;
}
默认参数是 document(L1),然后循环下钻 shadowRoot.activeElement,直到没有更深的 active element。这是自定义元素里实现键盘/焦点逻辑的必备工具——播放器控件很多都包在 shadow root 里,不深挖就拿不到真焦点。
配套还有 dom/direction.ts:2-6 的 isRTL(判断从右到左布局):
export function isRTL(element: Element): boolean {
const dir = element.closest('[dir]')?.getAttribute('dir'); // L3
if (dir) return dir.toLowerCase() === 'rtl'; // L4
return getComputedStyle(element).direction === 'rtl'; // L5
}
这里有个性能优化的细节:两段式策略。先用 closest('[dir]') 向上找最近声明了 dir 属性的祖先(cheap,DOM 属性查询),命中就直接比对字符串;没命中才退回 getComputedStyle(expensive,强制布局计算)。优先用便宜的,fallback 到贵的。
交互判定:closest 还是 matches,分场景用
播放器经常要判断「用户点的是不是可交互元素」——比如判断键盘事件要不要被播放器拦截。dom/interactive.ts 这块写得很细。
先看选择器定义。INTERACTIVE_SELECTOR(L3-16)把 button、input、select、textarea、a[href]、一堆 ARIA role、[data-interactive] 全 .join(',') 拼成一个大选择器串。EDITABLE_SELECTOR(L20-26)类似,覆盖 textarea、select、各种 input、contenteditable。
然后是两个判定函数,我对比着读的时候发现了关键区别:
// L39-43 — 判断「是否在交互区内」用 closest
isInteractiveTarget(event) {
const target = resolveEventTarget(event); // 先穿透 shadow
return target instanceof Element && target.closest(INTERACTIVE_SELECTOR) !== null;
}
isInteractiveTarget 用 closest——所以命中交互元素的子节点也算交互。这符合直觉:你点了一个按钮里的 <span>,也算点在交互区里。
但激活键判定不一样:
// L55-60 — 判断「激活键是否应触发激活」用 matches
isInteractiveActivation(event) {
if (!ACTIVATION_KEYS.has(event.key)) return false; // L56 — 先查 Space/Enter
const target = resolveEventTarget(event);
return target instanceof Element && target.matches(ACTIVATABLE_SELECTOR); // matches,不是 closest
}
isInteractiveActivation 用 matches——只看目标元素本身。而且 ACTIVATABLE_SELECTOR(L52)比 INTERACTIVE_SELECTOR 更窄,故意排除了 input/textarea/select,因为在那些元素上 Space/Enter 是文本输入而不是激活。
这里的洞察是:两套语义对应两个不同的 DOM 查询原语。「是否在交互区内」用 closest(向上冒泡找),「激活键是否应触发」用 matches(严格匹配当前元素)。不读源码,很容易把两个都写成 closest,那就会在 input 里按回车误触发播放器激活。
Popover:三重防御的 tryShowPopover
播放器的菜单、tooltip 依赖 Popover API。但这个 API 的浏览器支持参差不齐,dom/popover.ts:60-74 的 tryShowPopover 用了三重防御:
export function tryShowPopover(el: HTMLElement | null): void {
try { el?.showPopover?.(); } catch { /* ... */ } // L61-63
}
三层防护:
el?.防 null。showPopover?.()防 API 不存在(可选链)。try/catch防「已显示」或「不支持」等运行时异常。
调用方完全无需关心 popover API 的兼容性——这就是好工具库的本分。tryHidePopover(L68-74)对称。
能力探测在 dom/supports.ts。我特别注意到 supportsAnchorPositioning(L9-11)的写法:
export function supportsAnchorPositioning(): boolean {
return typeof CSS !== 'undefined' && CSS.supports('anchor-name: --a');
}
用 CSS.supports('anchor-name: --a') 探测 CSS Anchor Positioning,比测属性存在更可靠。而且先 typeof CSS !== 'undefined' 守卫 SSR。
这个文件里还有 getPositionedSide(L42-58)和 OPPOSITE_SIDE 映射(L14-19),做浮动层的边界翻转计算——比如 tooltip 在右边快出屏了,翻到左边显示。
WebKit 兼容:AirPlay 与旧版全屏
Safari 的媒体 API 有大量 WebKit 前缀。dom/webkit.ts 专门处理这些。
类型定义部分(L2-28)给 lib.dom 缺失的 vendor API 补类型:WebKitPresentationMode、WebKitVideoElement(带 webkitCurrentPlaybackTargetIsWireless、webkitPresentationMode)、WebKitFullscreenElement、WebKitDocument、WebkitAvailabilityEvent。
运行时检测有两个函数,我读的时候注意到一个双重检测模式:
// webkit.ts:34-36
supportsWebKitAirPlay(): boolean {
return 'WebKitPlaybackTargetAvailabilityEvent' in globalThis;
}
// webkit.ts:39-41
isWebKitAirPlayCapable(media: EventTarget): media is WebKitVideoElement {
return supportsWebKitAirPlay() && 'webkitCurrentPlaybackTargetIsWireless' in media;
}
isWebKitAirPlayCapable 先确认环境支持(supportsWebKitAirPlay),再确认具体 media 元素暴露了该属性。返回类型把普通 EventTarget 收窄为 WebKitVideoElement——这样后续代码就能安全访问 webkit 属性了。双重检测避免在「环境支持但当前 media 不支持」或「环境不支持」两种情况下误判。
帧调度:trailing-coalesce 的 rafThrottle
播放器有大量高频事件(scroll、mousemove、resize)。dom/raf-throttle.ts:9-30 的 rafThrottle 用 trailing-coalesce 模式节流:
// raf-throttle.ts:13-20(简化)
const throttled = (...args) => {
latestArgs = args; // L14 — 捕获最新参数
if (rafId !== null) return; // L15 — 已有挂起帧就只更新 args,不再排帧
rafId = requestAnimationFrame(() => {
rafId = null; // L17 — 先清 rafId
fn(...latestArgs); // L18 — 用最新 args 触发
});
};
典型 trailing-coalesce:一帧内多次调用只排一次 rAF,最终用最后一次的 args 触发。
我注意到一个容易出 bug 的细节:L17 先清 rafId 再调 fn。为什么?保证 fn 内部如果再次调用 throttled 函数,不会因为 rafId 还非 null 被错误丢弃。顺序反了就会漏触发。
cancel(L22-27)调 cancelAnimationFrame 并置 null。idle-callback.ts 的 Safari fallback(用 setTimeout(..., 1) 伪造 IdleDeadline)05 篇提过,这里不重复。
onEvent:Promise 风格的一次性等待
有时候你想「等某个事件发生」,用 Promise 比回调舒服。dom/event.ts:50-75 的 onEvent 就是干这个:
export function onEvent(target, type, options?) {
return new Promise((resolve, reject) => { // L51
const handleAbort = () => reject(options?.signal?.reason ?? 'Aborted'); // L52-54
if (options?.signal?.aborted) { // L57-60 — 已 abort 快速失败
handleAbort(); return;
}
options?.signal?.addEventListener('abort', handleAbort, { once: true }); // L63
target.addEventListener(type, function handler(event) {
options?.signal?.removeEventListener('abort', handleAbort); // L69 — 防泄漏
resolve(event); // L71
}, { ...options, once: true }); // L72 — 强制 once
});
}
这里有个容易忽视的内存陷阱,源码处理得很到位。因为 Promise 没有 cancel 概念,必须显式解绑 abort 监听器——否则 signal 长寿而 Promise 短寿,会造成监听器堆积。L69 在事件触发时先 removeEventListener 掉 abort handler,再 resolve。两层监听互相清理,不泄漏。
小结
读完 ./dom 这个子模块,我最深的感受是:播放器的 DOM 需求比一般业务复杂得多,尤其是 Shadow DOM 穿透、焦点深挖、交互判定的语义区分。utils 把这些「播放器特有」的 DOM 工具都沉淀下来了,所以上层(core 的 UI 组件、html 的自定义元素)写得很干净,不用每个组件重新处理这些边界情况。
几个值得带走的点:
composedPath()穿透 Shadow DOM——自定义元素里拿真实事件目标的标配。closestvsmatches分场景——交互区判定用 closest,激活键判定用 matches。- 三重防御的兼容写法——
?.+try/catch让调用方零负担。 - 双重能力检测——先环境后元素,避免误判。
- Promise + AbortSignal 的防泄漏——两个监听互相清理。
下一篇我们看 utils 里另一个关键模块 ./events——AbortSignal 的组合与可取消异步,这是 v10 整个清理机制的基石。

164

被折叠的 条评论
为什么被折叠?



