videojs v10 源代码系列解读:06 · DOM 工具箱:事件、聚焦、Shadow DOM、定位

上一篇讲了 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-5resolveEventTarget 解决这个问题:

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-9getDeepActiveElement

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-6isRTL(判断从右到左布局):

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;
}

isInteractiveTargetclosest——所以命中交互元素的子节点也算交互。这符合直觉:你点了一个按钮里的 <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
}

isInteractiveActivationmatches——只看目标元素本身。而且 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-74tryShowPopover 用了三重防御:

export function tryShowPopover(el: HTMLElement | null): void {
  try { el?.showPopover?.(); } catch { /* ... */ }   // L61-63
}

三层防护:

  1. el?. 防 null。
  2. showPopover?.() 防 API 不存在(可选链)。
  3. 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 补类型:WebKitPresentationModeWebKitVideoElement(带 webkitCurrentPlaybackTargetIsWirelesswebkitPresentationMode)、WebKitFullscreenElementWebKitDocumentWebkitAvailabilityEvent

运行时检测有两个函数,我读的时候注意到一个双重检测模式:

// 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-30rafThrottle 用 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-75onEvent 就是干这个:

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 的自定义元素)写得很干净,不用每个组件重新处理这些边界情况。

几个值得带走的点:

  1. composedPath() 穿透 Shadow DOM——自定义元素里拿真实事件目标的标配。
  2. closest vs matches 分场景——交互区判定用 closest,激活键判定用 matches。
  3. 三重防御的兼容写法——?. + try/catch 让调用方零负担。
  4. 双重能力检测——先环境后元素,避免误判。
  5. Promise + AbortSignal 的防泄漏——两个监听互相清理。

下一篇我们看 utils 里另一个关键模块 ./events——AbortSignal 的组合与可取消异步,这是 v10 整个清理机制的基石。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值