[譯] 網速敏感的視訊延遲載入方案

SHERlocked93發表於2019-03-03

一個大視訊的背景,如果做的好,會是一個絕佳的體驗!但是,在首頁新增一個視訊並不僅僅是隨便找個人,然後加個 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章