上一篇讲了
HTMLMediaElementHost的主机-组件模型。但 host 是个普通 JS 对象,不是 Web Component。这篇讲CustomMediaElement------一个类工厂,把 host 包装成自定义元素,让它能像<video>一样用 HTML 属性声明式控制。核心是两层属性转发:HTML 属性 → host 属性 → 真实媒体元素。
它是什么:一个混入工厂
packages/media/src/dom/custom-media-element/custom-media-element.ts:105-108:
ts
export function CustomMediaElement(tag, MediaHost) {
// ...
}
它接收一个 HTML 标签名(如 'video')和一个 MediaHost 构造器(如 HTMLVideoElementHost),返回一个 CustomMedia 自定义元素类。这个类桥接自定义元素 API 和 media host。
比如 <mux-video> 元素就是 CustomMediaElement('video', HlsJsMedia) 的产物------用户写 <mux-video src="...">,它内部转发到 host 和真实 <video>。
内部元素在开放的 Shadow DOM
构造函数(L234-244)会 attachShadow({ mode: 'open' }),模板(getVideoTemplateHTML L26-56)渲染一个 <slot name="media"><video></video></slot>。
get target()(L270-277)解析内部元素,优先级:slotted 子元素(:scope > [slot=media])> 宿主中的 <tag> > shadow root 中的 <tag>。
为什么用开放 Shadow DOM?因为 slotted 模式让外部 CSS 和 light DOM 子元素(<track>/<source>)仍然能正常工作,同时把内部媒体元素隔离。
两层转发策略
属性转发是这篇的重点,有两套机制。
第一层:Host 原型遍历(L152-203)
static #define 内部遍历 MediaHost.prototype 的原型链,为每个属性在 CustomMedia.prototype 上装代理:
方法是(L172-175):
ts
config.value = function(...args) { return this.#mediaHost[prop](...args); };
getter(L176-179):
ts
config.get = function() { return this.#mediaHost[prop]; };
setter 分两种:
- 如果属性的 kebab-case 名在
observedAttributes里(L182-184),setter 通过toggleAttribute/setAttribute(L186-192)------设置 JS 属性会触及 HTML 属性,走attributeChangedCallback路由。 - 否则直接写
this.#mediaHost[prop] = val(L194-196)。
这个分支很关键:有些属性要走 HTML 属性(因为属性变化会触发浏览器行为),有些直接写 host 。比如 src 走属性(浏览器会触发 loadstart),volume 可以直接写。
第二层:显式属性循环(L205-223)
static properties(L119-135)声明了属性到属性的映射,包括 autoplay、controls、loop、muted(属性 muted)、preload、src、streamType(属性 stream-type)等。每个条目指定 { type: Boolean|String, attribute?, empty? }。
对那些没被第一层遍历连接的属性,第二层定义直接读/写元素属性的 getter/setter:布尔用 hasAttribute,字符串用 getAttribute。
两层为什么要分开
我读的时候琢磨了一下。第一层(原型遍历)是「自动发现」------host 有啥就代理啥,省得手写。但有些属性的 HTML 名和 JS 名不一致(defaultMuted 对应属性 muted、playsInline 对应 playsinline),自动遍历搞不定,需要第二层显式声明。
两层配合:自动的尽量自动,特殊的显式处理。这是务实的工程取舍。
attributeChangedCallback:属性 → 属性
L322-353 处理 HTML 属性变化:
- 如果
mediaHostAttrToProp映射到 host 属性(L323-337),按typeof this.#mediaHost[prop]做类型转换(布尔/数字/字符串),写入 host------最终流向附加元素。 - 如果属性没被 CustomMedia 观察到但被子类观察(L339-344),提前 return 让子类处理。
- 否则对同步目标(
syncTargetAttributes = tag !== 'iframe',L346-352),镜像到内部元素:null →removeAttribute,否则setAttribute。
注意 syncTargetAttributes------iframe/embed(Vimeo 那种)驱动外部播放器,属性不镜像到内部。
子元素同步:<track> 和 <source>
构造函数设了个 MutationObserver(L244 附近)监听 <track>/<source> 子元素,slotchange 时重新附加 + 重新同步。
#syncMediaChildren(L355-385)把 slotted 的 <track>/<source> 克隆到内部原生元素并保持同步。iframe 排除在外。
#enableDefaultTrack(L403-415)为 chapters/metadata 轨道启用 default------因为浏览器不会对 JS 添加的轨道自动启用。这是个浏览器行为的补丁。
事件桥
addEventListener(L296-306)把监听器类型转发到 #mediaHost。#bridgeEvent(L316-320)重建并重新分发事件,除非它已经组合(composed 事件会自动冒泡穿过 shadow 边界,不用桥接)。
这和 06 篇讲的 resolveEventTarget(composedPath 穿透 shadow)是互补的------这里是从内向外转发。
销毁保护:queueMicrotask 检查 isConnected
disconnectedCallback(L287-294)和 element 包的 DestroyMixin(10 篇)思路类似:遵 keep-alive 属性,排 queueMicrotask 检查 isConnected------同步重新挂载(移除后立即插回)不会销毁 host。
不过这里用的是单次 queueMicrotask(不是 DestroyMixin 的双重 rAF)。因为这里保护的是「同步重新挂载」,微任务足够;DestroyMixin 的双重 rAF 保护的是「跨帧 DOM 重组」,更激进。
小结
CustomMediaElement 把 host 包装成可声明式使用的 Web Component。核心是两层属性转发:
- 原型遍历自动代理------host 有啥就代理啥。
- 显式属性声明------处理名字不一致的特殊属性。
attributeChangedCallback类型转换------字符串属性转成正确类型写 host。- 子元素克隆同步 ------
<track>/<source>进 shadow。 - 事件桥------非 composed 事件手动转发。
这层让 <mux-video>、<hlsjs-video> 这些自定义元素能像原生 <video> 一样用。下一篇讲这些引擎适配器的全景。