[译] 网速敏感的视频延迟加载方案

4,378 阅读6分钟

一个大视频的背景,如果做的好,会是一个绝佳的体验!但是,在首页添加一个视频并不仅仅是随便找个人,然后加个 25mb 的视频,那会让你的所有的性能优化都付之一炬。

Lazy pandas love lazy loading. (Photo by Elena Loshina)

我参加过一些团队,他们希望给首页加上类似的全屏视频背景。我通常不愿意那么做,因为这种做法通常会导致性能上的噩梦。老实说,我曾给一个页面加上一个 40mb 大的视频。 😬

上次有人让我这么做的时候,我很好奇应如何将背景视频的加载作为渐进增强(Progressive Enhancement),来提升网络连接状况比较好的用户的体验。除了和我的同事们强调视频体积小和压缩视频的重要性以外,也希望在代码上有一些奇迹发生。

下面是最终的解决方案:

  1. 尝试使用 JavaScript 加载 <source>
  2. 监听 canplaythrough 事件
  3. 如果 canplaythrough 事件没有在 2 秒内触发,那么使用 Promise.race() 将视频加载超时
  4. 如果没有监听到 canplaythrough 事件,那么移除 <source>,并且取消视频加载
  5. 如果监测到 canplaythrough 事件,那么使用淡入效果显示这个视频

标记

这里要注意的问题是,即使我正在 <video> 标签中使用 <source>,但我还没为这些 <source> 设置 src 属性。如果设置了 src 属性,那么浏览器会自动地找到它可以播放的第一个 <source>,并立即开始下载它。

因为在这个例子中,视频是作为渐进增强的对象,默认情况下我们不用真的加载视频。事实上唯一需要加载的,是我们为这个页面设置的预览图片。

  <video class="js-video-loader" poster="<?= $poster; ?>" muted="true" loop="true">
    <source data-src="path/to/video.webm" type="video/webm">
    <source data-src="path/to/video.mp4" type="video/mp4">
  </video>

JavaScript

我编写了一个简单的 JavaScript 类,用于查找带有 .js-video-loader 这个 class 的 video 元素,让我们以后可以在其他视频中复用这个逻辑。完整的源码可以从 Github 上看到

构造函数是这样的:

  constructor () {
    this.videos = Array.from(document.querySelectorAll('video.js-video-loader'));
    // 将在下面情况下返回
    // - 浏览器不支持 Promise
    // - 没有 video 元素
    // - 如果用户设置了减少动态偏好(prefers reduced motion) 
    // - 在移动设备上
    if (typeof Promise === 'undefined'
      || !this.videos
      || window.matchMedia('(prefers-reduced-motion)').matches
      || window.innerWidth < 992
    ) {
      return;
    }
    this.videos.forEach(this.loadVideo.bind(this));
  }

这里我们所做的就是找到这个页面上所有我们希望延迟加载的视频。如果没有,我们可以返回。当用户开启了减少动态偏好(preference for reduced motion)设置时,我们同样不会加载这样的视频。为了不让某些低网速或低图形处理能力的手机用户担心,在小屏幕手机上也会直接返回。(我在考虑是否可以通过 <source> 元素的媒体查询来做这些,但也不确定。)

然后给每个视频运行这个视频加载逻辑。

loadVideo

loadVideo() 是一个调用其他函数的简单的函数:

  loadVideo(video) {
    this.setSource(video);
    // 加上了视频链接后重新加载视频
    video.load();
    this.checkLoadTime(video);
  }

setSource

setSource() 中,我们找到那些作为数据属性(Data Attributes)插入的视频链接,并且将它们设置为真正的 src 属性。

  /**
    * 找 video 子元素中是 <source> 的,
    * 基于 data-src 属性,
    * 给每个 <source> 设置 src 属性
    *
    * @param {DOM Object} video
    */
    setSource (video) {
      let children = Array.from(video.children);
      children.forEach(child => {
        if (child.tagName === 'SOURCE' && typeof child.dataset.src !== 'undefined') {
          child.setAttribute('src', child.dataset.src);
        }
      });
    }

基本上,我所做的就是遍历每一个 <video> 元素的子元素,找一个定义了 data-src 属性(child.dataset.src)的 <source> 子元素。如果找到了,那就用 setAttribute 将它的 src 属性设置为视频链接。

现在视频链接已经被设置给 <video> 元素了,下面需要让浏览器再次加载视频。我们通过在 loadVideo() 中的 video.load() 来完成这个工作。load() 方法是 HTMLMediaElement API 的一部分,它可以重置媒体元素并且重启加载过程。

checkLoadTime

接下来是见证奇迹的时刻。在 checkLoadTime() 方法中我们创建了两个 Promise。第一个 Promise 将在 <video> 元素的 canplaythrough 事件触发时被 resolve。这个 canplaythrough 事件是浏览器认为这个视频可以在不停下来缓冲的情况下持续播放的时候被触发。我们在这个 Promise 中添加一个这个事件的监听回调,当这个事件触发的时候执行 resolve()

  // 创建一个 Promise,将在
  // video.canplaythrough 事件发生时被 resolve
  let videoLoad = new Promise((resolve) => {
    video.addEventListener('canplaythrough', () => {
      resolve('can play');
    });
  });

我们同时创建另一个 Promise 作为计时器。在这个 Promise 中,当经过一个设定好的时间后,我们使用 setTimeout 来将这个 Promise 给 resolve 掉,我这设置了一个 2 秒的时延(2000毫秒)。

  // 创建一个 Promise 将在
  // 特定时间(2s)后被 resolve
  let videoTimeout = new Promise((resolve) => {
    setTimeout(() => {
      resolve('The video timed out.');
    }, 2000);
  });

现在我们有了两个 Promise,我们可以通过 Promise.race() 看他们谁先完成。

  // 将 promises 进行 Race 看看哪个先被 resolves
  Promise.race([videoLoad, videoTimeout]).  then(data => {
    if (data === 'can play') {
      video.play();
      setTimeout(() => {
        video.classList.add('video-loaded');
      }, 3000);
    } else {
      this.cancelLoad(video);
    }
  });

在这个 .then() 的回调中我们等着拿到最先被 resolve 的那个 Promise 传回来的信息。如果这个视频可以播放,那么我就会拿到之前传的 can play,然后试一下是否可以播放这个视频。video.play() 是使用 HTMLMediaElement 提供的 play() 方法来触发视频播放。

3 秒后,setTimeout() 将会给这个标签加上 .video-loaded 类,这将有助于视频文件更巧妙的淡入自动循环播放。

如果我们没接收到 can play 字符串,那么我们将取消这个视频的加载。

cancelLoad

cancelLoad() 方法做的基本上跟 loadVideo() 方法相反。它从每个 source 标签移除 src 属性,并且触发 video.load() 来重置视频元素。

如果我们不这么做,这个视频元素将会在后台保持加载状态,即使我们都没将它显示出来。

  /**
    * 通过移除所有的 <source> 来取消视频加载
    * 然后触发 video.load().
    *
    * @param {DOM object} video
    */
    cancelLoad (video) {
      let children = Array.from(video.children);
      children.forEach(child => {
        if (child.tagName === 'SOURCE' && typeof child.dataset.src !== 'undefined') {
          child.parentNode.removeChild(child);
        }
      });
      // 重新加载没有 <source> 标签的 video
      // 这样它会停止下载
      video.load();
    }

总结

这个方法的缺点是,我们仍然试图通过一个不一定靠谱的链接来下载一个可能比较大的文件,但是通过提供一个超时时间,我们希望能够给某些网速慢的用户节约一些流量并且获得更好的性能。根据我在 Chrome Dev Tools 里将网速节流到慢 3G 条件下的测试,这个方法将在超时之前加载了 512kb 的视频。即使是一个 3-5mb 的视频,对于一些网速慢的用户来说,这也带来了显著的流量节省。

你觉得怎么样?如果有改进的建议,欢迎在评论里分享!


Originally published at benrobertson.io.

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

PS:欢迎大家关注我的公众号【前端下午茶】,一起加油吧~

另外可以加入「前端下午茶交流群」微信群,长按识别下面二维码即可加我好友,备注加群,我拉你入群~