Impact 的媒体懒加载:用 Proxy 包装异步加载的播放器
本篇基于 Impact 7.2.0,涉及 assets/theme.js 中的 BaseMedia(第 3592 行)、ModelMedia(第 3645 行)、VideoMedia(第 3694 行),以及 snippets/media.liquid。
问题
商品图片画廊和视频 section 里可能有原生视频、YouTube、Vimeo 和 3D 模型。它们的播放器 API 各不相同,而且外部播放器需要先加载 SDK、创建 iframe 才能调用。如果页面一打开就把这些都加载好,首屏性能会很差;如果按需加载,每个调用方都得先写一遍“等播放器准备好”的逻辑。
第一层:用 <template> 推迟创建 iframe
snippets/media.liquid 把外部视频的 iframe 放在 <template> 里:
<video-media host="youtube" …>
{{ 预览图 }}
<template>
{{- media | external_video_url: enablejsapi: true, … | external_video_tag -}}
</template>
</video-media>
<template> 中的内容不会被解析成真实的 DOM,iframe 也就不会发起任何请求。页面上只显示一张预览图,直到需要播放时,才把模板内容克隆出来替换 <template>。
原生视频直接输出 <video preload="metadata">,只加载元数据,不预先下载视频内容。
第二层:Proxy 让调用方不用等待
BaseMedia 的核心是 player getter(简化):
get player() {
return this._playerProxy ??= new Proxy(this._playerTarget(), {
get: (target, prop) => async () => {
target = await target;
this._playerHandler(target, prop);
},
});
}
play() { if (!this.playing) this.player.play(); }
pause() { if (this.playing) this.player.pause(); }
_playerTarget()由子类实现,返回真实的播放器,或者一个最终解析为播放器的 Promise。- Proxy 拦截所有属性访问。
this.player.play返回的是一个异步函数,调用时先await播放器准备好,再交给_playerHandler执行真正的操作。 _playerHandler由子类实现,把统一的play和pause翻译成各家 API,例如 YouTube 是playVideo()和pauseVideo(),原生视频是play()和pause()。
player 是惰性创建的:第一次访问 this.player 时才调用 _playerTarget(),也就是开始加载 SDK。 对视频来说,就是第一次需要播放的时候:自动播放的视频在进入视口时(inView),非自动播放的视频在用户点击时。
调用方完全不需要知道播放器加载到了哪一步。比如画廊切换到下一张时,会暂停其他所有视频,这时可以直接调用 pause(),不用判断播放器是否已经创建。
各子类的 _playerTarget()
YouTube 和 Vimeo:
- 把
<template>换成 iframe; - 如果全局还没有
YT.Player或Vimeo.Player,插入 SDK 的<script>并等待加载完成;YouTube 还要额外等待全局回调onYouTubeIframeAPIReady; - 自动播放的视频,以及在小屏幕上播放的视频,一律静音,以满足浏览器的自动播放策略;
- 把播放器的状态事件同步到元素的
playing属性上。
原生视频: 直接返回 <video> 元素,并把它的 play 和 pause 事件同步到 playing 属性。
3D 模型(ModelMedia): 通过 Shopify.loadFeatures 加载 model-viewer-ui 和 shopify-xr。它与视频不同:在 connectedCallback 里就主动访问了一次 this.player,所以元素一挂载就开始加载,没有等到需要播放的时候。
playing 属性是状态,事件由它派生
和对话框一样,播放状态以一个属性为准。attributeChangedCallback 监听 playing 的变化:
- 派发冒泡的
media:play或media:pause事件,作为对外接口。7.2.0 的theme.js内部没有监听这两个事件,它们是留给自定义代码和应用使用的。 - 如果元素带有
group属性,开始播放时会暂停所有同组的其他媒体。同一个商品画廊里的视频和模型不会同时播放。
CSS 可以直接用 [playing]、[loaded]、[can-play] 写样式。例如,在内容叠加在媒体上的布局中,非自动播放的视频一旦带上 [loaded],叠加的文字就会淡出(assets/theme.css 中的 .content-over-media > video-media:not([autoplay])[loaded] ~ *)。
低电量模式下的降级
iOS 低电量模式等环境会拒绝自动播放,video.play() 返回的 Promise 以 NotAllowedError 失败。VideoMedia 捕获这个错误后:
target.play().catch((error) => {
if (error.name === "NotAllowedError") {
this.setAttribute("suspended", "");
target.controls = true;
const replacementImageSrc = target.previousElementSibling?.currentSrc;
if (replacementImageSrc) target.poster = replacementImageSrc;
}
});
它会显示原生控件,让用户可以手动播放,并把视频前面那张预览图实际加载的地址(currentSrc,即响应式图片选中的那个尺寸)设为 poster,避免出现黑屏。
值得借鉴的点
- 用 Proxy 把“尚未就绪的对象”包装成可以立即调用的对象,把等待逻辑集中在一处。
- 用
<template>推迟 iframe 的创建,比loading="lazy"更彻底:在需要之前,iframe 根本不存在。 - 惰性创建播放器:SDK 的加载时机由第一次调用决定,而不是由页面加载决定。
- 处理自动播放被拒绝的情况,而不是假设
play()一定成功。
局限
- Proxy 拦截一切属性访问。 访问任何属性,包括
then,返回的都是函数。如果有人写await media.player,await会把它当成 thenable 调用then,原生视频分支随后会执行video.then()并报错,而这个await永远不会完成。当前代码只调用play和pause,没有触发这个问题,但扩展时要注意。 - YouTube 依赖全局回调。
onYouTubeIframeAPIReady在theme.js加载时就被赋值。如果其他脚本也赋值了这个全局函数,或者 YouTube SDK 在theme.js之前就已经加载完毕并调用过回调,等待它的 Promise 就永远不会完成,视频也就无法播放。 play()依据的playing属性是异步更新的。 从发出播放请求到属性被设置之间连续调用play(),请求会重复发出。目前只是多一次无害的调用。