EN
Shopify 知识库 · 概念

Impact 的媒体懒加载:用 Proxy 包装异步加载的播放器

Impact 7.2.0 的 BaseMedia 用 Proxy 把尚未加载的 YouTube、Vimeo、原生视频与 3D 模型播放器包装成可直接调用的对象,配合 template 延迟创建 iframe、同组互斥播放和低电量模式下的自动播放降级。
历史资料
请结合文中的适用版本和来源阅读。

本篇基于 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:

  1. 把 <template> 换成 iframe;
  2. 如果全局还没有 YT.Player 或 Vimeo.Player,插入 SDK 的 <script> 并等待加载完成;YouTube 还要额外等待全局回调 onYouTubeIframeAPIReady;
  3. 自动播放的视频,以及在小屏幕上播放的视频,一律静音,以满足浏览器的自动播放策略;
  4. 把播放器的状态事件同步到元素的 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,避免出现黑屏。

值得借鉴的点

  1. 用 Proxy 把“尚未就绪的对象”包装成可以立即调用的对象,把等待逻辑集中在一处。
  2. 用 <template> 推迟 iframe 的创建,比 loading="lazy" 更彻底:在需要之前,iframe 根本不存在。
  3. 惰性创建播放器:SDK 的加载时机由第一次调用决定,而不是由页面加载决定。
  4. 处理自动播放被拒绝的情况,而不是假设 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(),请求会重复发出。目前只是多一次无害的调用。