Phát nhanh với tính năng tải trước âm thanh và video

Cách tăng tốc độ phát nội dung nghe nhìn bằng cách chủ động tải trước tài nguyên.

[Tên người]
François Beaufort

Bắt đầu phát nhanh hơn có nghĩa là sẽ có nhiều người xem video hoặc nghe âm thanh của bạn hơn. Đây là một sự thật chúng ta biết. Trong bài viết này, tôi sẽ khám phá các kỹ thuật bạn có thể dùng để tăng tốc độ phát âm thanh và video bằng cách chủ động tải trước các tài nguyên tuỳ thuộc vào trường hợp sử dụng của bạn.

Ghi nhận quyền tác giả: bản quyền Blender Foundation | www.blender.org .

Tôi sẽ mô tả 3 phương thức tải trước tệp nội dung nghe nhìn, bắt đầu với ưu và nhược điểm của chúng.

Thật tuyệt... Nhưng...
Thuộc tính tải trước video Dễ sử dụng đối với tệp duy nhất được lưu trữ trên máy chủ web. Trình duyệt có thể hoàn toàn bỏ qua thuộc tính này.
Quá trình tìm nạp tài nguyên bắt đầu khi tài liệu HTML đã được tải và phân tích cú pháp hoàn chỉnh.
Tiện ích nguồn nội dung đa phương tiện (MSE) bỏ qua thuộc tính preload trên các phần tử nội dung đa phương tiện vì ứng dụng này chịu trách nhiệm cung cấp nội dung đa phương tiện cho MSE.
Tải trước đường liên kết Buộc trình duyệt yêu cầu tài nguyên video mà không chặn sự kiện onload của tài liệu đó. Các yêu cầu phạm vi HTTP không tương thích.
Tương thích với MSE và các phân đoạn tệp. Chỉ nên sử dụng cho các tệp nội dung nghe nhìn nhỏ (<5 MB) khi tìm nạp toàn bộ tài nguyên.
Lưu vào bộ đệm thủ công Toàn quyền kiểm soát Trang web phải chịu trách nhiệm xử lý lỗi phức tạp.

Thuộc tính tải trước video

Nếu nguồn video là một tệp duy nhất được lưu trữ trên máy chủ web, bạn nên sử dụng thuộc tính video preload để gợi ý cho trình duyệt về lượng thông tin hoặc nội dung cần tải trước. Điều này có nghĩa là Tiện ích nguồn nội dung nghe nhìn (MSE) không tương thích với preload.

Quá trình tìm nạp tài nguyên sẽ chỉ bắt đầu khi tài liệu HTML ban đầu đã được tải và phân tích cú pháp hoàn chỉnh (ví dụ: sự kiện DOMContentLoaded đã kích hoạt), trong khi sự kiện load rất khác sẽ được kích hoạt khi tài nguyên đã thực sự được tìm nạp.

Việc đặt thuộc tính preload thành metadata cho biết rằng người dùng sẽ không cần video, nhưng họ nên tìm nạp siêu dữ liệu của video (kích thước, danh sách theo dõi, thời lượng, v.v.). Xin lưu ý rằng kể từ Chrome 64, giá trị mặc định của preloadmetadata. (Trước đây là auto).

<video id="video" preload="metadata" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

Việc đặt thuộc tính preload thành auto cho biết trình duyệt có thể lưu đủ dữ liệu vào bộ nhớ đệm để có thể phát hoàn chỉnh mà không cần dừng lưu thêm vào bộ đệm.

<video id="video" preload="auto" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

Tuy nhiên, có một số lưu ý. Vì đây chỉ là gợi ý nên trình duyệt có thể hoàn toàn bỏ qua thuộc tính preload. Tại thời điểm viết bài này, dưới đây là một số quy tắc được áp dụng trong Chrome:

  • Khi Trình tiết kiệm dữ liệu bật, Chrome sẽ buộc giá trị preload thành none.
  • Trong Android 4.3, Chrome buộc giá trị preload thành none do Lỗi Android.
  • Trên kết nối di động (2G, 3G và 4G), Chrome buộc giá trị preload thành metadata.

Mẹo

Nếu trang web của bạn chứa nhiều tài nguyên video trên cùng một miền, bạn nên đặt giá trị preload thành metadata hoặc xác định thuộc tính poster và đặt preload thành none. Bằng cách đó, bạn sẽ tránh được việc đạt số lượng kết nối HTTP tối đa đến cùng một miền (6 kết nối theo thông số kỹ thuật HTTP 1.1) có thể khiến việc tải tài nguyên bị treo. Xin lưu ý rằng cách này cũng có thể cải thiện tốc độ trang nếu video không nằm trong trải nghiệm người dùng cốt lõi.

Như đã đề cập trong các bài viết khác, tải trước đường liên kết là phương thức tìm nạp mang tính khai báo cho phép bạn buộc trình duyệt yêu cầu tài nguyên mà không chặn sự kiện load và trong khi trang đang tải xuống. Các tài nguyên tải qua <link rel="preload"> được lưu trữ cục bộ trong trình duyệt và được lưu trữ hiệu quả cho đến khi chúng được tham chiếu rõ ràng trong DOM, JavaScript hoặc CSS.

Hoạt động tải trước khác với phương thức tìm nạp trước vì phương thức này tập trung vào hoạt động điều hướng hiện tại và tìm nạp tài nguyên có mức độ ưu tiên dựa trên loại tài nguyên (tập lệnh, kiểu, phông chữ, video, âm thanh, v.v.). Bạn nên dùng dữ liệu này để khởi động bộ nhớ đệm của trình duyệt cho các phiên hiện tại.

Tải trước toàn bộ video

Dưới đây là cách tải trước một video đầy đủ trên trang web của bạn để khi JavaScript yêu cầu tìm nạp nội dung video, nó sẽ được đọc từ bộ nhớ đệm vì tài nguyên có thể đã được trình duyệt lưu vào bộ nhớ đệm. Nếu yêu cầu tải trước chưa hoàn tất, thì quá trình tìm nạp mạng thông thường sẽ diễn ra.

<link rel="preload" as="video" href="https://cdn.com/small-file.mp4">

<video id="video" controls></video>

<script>
  // Later on, after some condition has been met, set video source to the
  // preloaded video URL.
  video.src = 'https://cdn.com/small-file.mp4';
  video.play().then(() => {
    // If preloaded video URL was already cached, playback started immediately.
  });
</script>

Vì tài nguyên tải trước sẽ được phần tử video sử dụng trong ví dụ, nên giá trị liên kết tải trước asvideo. Nếu đó là một phần tử âm thanh, thì giá trị đó sẽ là as="audio".

Tải trước phân đoạn đầu tiên

Ví dụ bên dưới cho biết cách tải trước phân đoạn đầu tiên của video bằng <link rel="preload"> và sử dụng phân đoạn này với Tiện ích nguồn nội dung nghe nhìn. Nếu bạn chưa quen dùng MSE JavaScript API, hãy xem nội dung Kiến thức cơ bản về MSE.

Để cho đơn giản, giả sử toàn bộ video đã được chia thành các tệp nhỏ hơn như file_1.webm, file_2.webm, file_3.webm, v.v.

<link rel="preload" as="fetch" href="https://cdn.com/file_1.webm">

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // If video is preloaded already, fetch will return immediately a response
    // from the browser cache (memory cache). Otherwise, it will perform a
    // regular network fetch.
    fetch('https://cdn.com/file_1.webm')
    .then(response => response.arrayBuffer())
    .then(data => {
      // Append the data into the new sourceBuffer.
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch file_2.webm when user starts playing video.
    })
    .catch(error => {
      // TODO: Show "Video is not available" message to user.
    });
  }
</script>

Hỗ trợ

Bạn có thể phát hiện khả năng hỗ trợ nhiều loại as cho <link rel=preload> bằng các đoạn mã dưới đây:

function preloadFullVideoSupported() {
  const link = document.createElement('link');
  link.as = 'video';
  return (link.as === 'video');
}

function preloadFirstSegmentSupported() {
  const link = document.createElement('link');
  link.as = 'fetch';
  return (link.as === 'fetch');
}

Lưu vào bộ đệm thủ công

Trước khi chúng ta tìm hiểu sâu về Cache API và trình chạy dịch vụ, hãy xem cách lưu video vào bộ đệm theo cách thủ công bằng MSE. Ví dụ bên dưới giả định rằng máy chủ web của bạn hỗ trợ các yêu cầu HTTP Range, nhưng điều này sẽ khá giống với các phân đoạn tệp. Xin lưu ý rằng một số thư viện phần mềm trung gian như Trình phát Shaka của Google, Trình phát JWVideo.js được tạo để xử lý việc này cho bạn.

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // Fetch beginning of the video by setting the Range HTTP request header.
    fetch('file.webm', { headers: { range: 'bytes=0-567139' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      sourceBuffer.appendBuffer(data);
      sourceBuffer.addEventListener('updateend', updateEnd, { once: true });
    });
  }

  function updateEnd() {
    // Video is now ready to play!
    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);

    // Fetch the next segment of video when user starts playing the video.
    video.addEventListener('playing', fetchNextSegment, { once: true });
  }

  function fetchNextSegment() {
    fetch('file.webm', { headers: { range: 'bytes=567140-1196488' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      const sourceBuffer = mediaSource.sourceBuffers[0];
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch further segment and append it.
    });
  }
</script>

Những yếu tố nên cân nhắc

Do bạn đang có quyền kiểm soát toàn bộ trải nghiệm lưu vào bộ đệm nội dung nghe nhìn, nên bạn nên cân nhắc mức pin của thiết bị, lựa chọn ưu tiên của người dùng "Chế độ tiết kiệm dữ liệu" và thông tin mạng khi cân nhắc việc tải trước.

Nhận biết về pin

Hãy tính đến mức pin thiết bị của người dùng trước khi nghĩ đến việc tải trước video. Điều này sẽ giúp duy trì thời lượng pin khi pin ở mức thấp.

Tắt tính năng tải trước hoặc ít nhất là tải trước video có độ phân giải thấp hơn khi thiết bị sắp hết pin.

if ('getBattery' in navigator) {
  navigator.getBattery()
  .then(battery => {
    // If battery is charging or battery level is high enough
    if (battery.charging || battery.level > 0.15) {
      // TODO: Preload the first segment of a video.
    }
  });
}

Phát hiện "Trình tiết kiệm dữ liệu"

Sử dụng tiêu đề yêu cầu gợi ý của ứng dụng Save-Data để phân phối các ứng dụng nhanh và nhẹ cho những người dùng đã chọn chế độ "tiết kiệm dữ liệu" trong trình duyệt của họ. Bằng cách xác định tiêu đề của yêu cầu này, ứng dụng của bạn có thể tuỳ chỉnh và cung cấp trải nghiệm người dùng được tối ưu hoá cho những người dùng bị ràng buộc về chi phí và hiệu suất.

Xem bài viết Phân phối ứng dụng nhanh và nhẹ bằng giải pháp tiết kiệm dữ liệu để tìm hiểu thêm.

Tải thông minh dựa trên thông tin mạng

Bạn nên kiểm tra navigator.connection.type trước khi tải trước. Khi đặt thành cellular, bạn có thể ngăn tải trước và thông báo cho người dùng rằng nhà cung cấp dịch vụ mạng di động của họ có thể đang tính phí băng thông và chỉ bắt đầu tự động phát nội dung đã lưu vào bộ nhớ đệm trước đó.

if ('connection' in navigator) {
  if (navigator.connection.type == 'cellular') {
    // TODO: Prompt user before preloading video
  } else {
    // TODO: Preload the first segment of a video.
  }
}

Bạn cũng có thể xem Mẫu Thông tin mạng để tìm hiểu cách phản ứng với những thay đổi về mạng.

Lưu trước nhiều phân đoạn đầu tiên vào bộ nhớ đệm

Bây giờ, nếu tôi muốn tải trước một số nội dung nghe nhìn theo suy đoán mà không biết cuối cùng người dùng sẽ chọn loại nội dung nghe nhìn nào? Nếu người dùng đang ở trên một trang web có chứa 10 video, chúng ta có thể có đủ bộ nhớ để tìm nạp một tệp phân đoạn từ mỗi video. Tuy nhiên, chúng ta không nên tạo 10 phần tử <video> và 10 đối tượng MediaSource ẩn rồi bắt đầu cung cấp dữ liệu đó.

Ví dụ gồm hai phần bên dưới cho bạn biết cách lưu trước nhiều phân đoạn đầu tiên của video vào bộ nhớ đệm bằng API bộ nhớ đệm mạnh mẽ và dễ sử dụng. Xin lưu ý rằng bạn cũng có thể đạt được điều tương tự với IndexedDB. Chúng tôi chưa sử dụng trình chạy dịch vụ vì API Bộ nhớ đệm cũng có thể truy cập được từ đối tượng window.

Tìm nạp và lưu vào bộ nhớ đệm

const videoFileUrls = [
  'bat_video_file_1.webm',
  'cow_video_file_1.webm',
  'dog_video_file_1.webm',
  'fox_video_file_1.webm',
];

// Let's create a video pre-cache and store all first segments of videos inside.
window.caches.open('video-pre-cache')
.then(cache => Promise.all(videoFileUrls.map(videoFileUrl => fetchAndCache(videoFileUrl, cache))));

function fetchAndCache(videoFileUrl, cache) {
  // Check first if video is in the cache.
  return cache.match(videoFileUrl)
  .then(cacheResponse => {
    // Let's return cached response if video is already in the cache.
    if (cacheResponse) {
      return cacheResponse;
    }
    // Otherwise, fetch the video from the network.
    return fetch(videoFileUrl)
    .then(networkResponse => {
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, networkResponse.clone());
      return networkResponse;
    });
  });
}

Lưu ý rằng nếu sử dụng các yêu cầu HTTP Range, tôi sẽ phải tạo lại đối tượng Response theo cách thủ công vì Cache API chưa hỗ trợ Range phản hồi nhưng. Hãy lưu ý rằng việc gọi networkResponse.arrayBuffer() sẽ tìm nạp cùng lúc toàn bộ nội dung của phản hồi vào bộ nhớ kết xuất. Đó là lý do bạn nên sử dụng các phạm vi nhỏ.

Để tham khảo, tôi đã sửa đổi một phần ví dụ ở trên để lưu các yêu cầu Phạm vi HTTP vào bộ nhớ đệm trước của video.

    ...
    return fetch(videoFileUrl, { headers: { range: 'bytes=0-567139' } })
    .then(networkResponse => networkResponse.arrayBuffer())
    .then(data => {
      const response = new Response(data);
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, response.clone());
      return response;
    });

Phát video

Khi người dùng nhấp vào nút phát, chúng tôi sẽ tìm nạp phân đoạn đầu tiên của video có trong API bộ nhớ đệm để quá trình phát sẽ bắt đầu ngay lập tức nếu có. Nếu không, chúng ta sẽ chỉ cần tìm nạp dữ liệu từ mạng. Xin lưu ý rằng trình duyệt và người dùng có thể quyết định xoá Bộ nhớ đệm.

Như đã thấy trước đó, chúng ta sử dụng MSE để đưa đoạn video đầu tiên đó vào phần tử video.

function onPlayButtonClick(videoFileUrl) {
  video.load(); // Used to be able to play video later.

  window.caches.open('video-pre-cache')
  .then(cache => fetchAndCache(videoFileUrl, cache)) // Defined above.
  .then(response => response.arrayBuffer())
  .then(data => {
    const mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

    function sourceOpen() {
      URL.revokeObjectURL(video.src);

      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');
      sourceBuffer.appendBuffer(data);

      video.play().then(() => {
        // TODO: Fetch the rest of the video when user starts playing video.
      });
    }
  });
}

Tạo phản hồi Dải ô bằng trình chạy dịch vụ

Nếu bạn đã tìm nạp toàn bộ tệp video và lưu vào API Bộ nhớ đệm thì sao? Khi trình duyệt gửi yêu cầu HTTP Range, chắc chắn bạn không muốn đưa toàn bộ video vào bộ nhớ kết xuất đồ hoạ vì Cache API chưa hỗ trợ Range phản hồi chưa.

Hãy để tôi cho biết cách chặn các yêu cầu này và trả về phản hồi Range tuỳ chỉnh từ một trình chạy dịch vụ.

addEventListener('fetch', event => {
  event.respondWith(loadFromCacheOrFetch(event.request));
});

function loadFromCacheOrFetch(request) {
  // Search through all available caches for this request.
  return caches.match(request)
  .then(response => {

    // Fetch from network if it's not already in the cache.
    if (!response) {
      return fetch(request);
      // Note that we may want to add the response to the cache and return
      // network response in parallel as well.
    }

    // Browser sends a HTTP Range request. Let's provide one reconstructed
    // manually from the cache.
    if (request.headers.has('range')) {
      return response.blob()
      .then(data => {

        // Get start position from Range request header.
        const pos = Number(/^bytes\=(\d+)\-/g.exec(request.headers.get('range'))[1]);
        const options = {
          status: 206,
          statusText: 'Partial Content',
          headers: response.headers
        }
        const slicedResponse = new Response(data.slice(pos), options);
        slicedResponse.setHeaders('Content-Range': 'bytes ' + pos + '-' +
            (data.size - 1) + '/' + data.size);
        slicedResponse.setHeaders('X-From-Cache': 'true');

        return slicedResponse;
      });
    }

    return response;
  }
}

Điều quan trọng cần lưu ý là tôi đã sử dụng response.blob() để tạo lại phản hồi được cắt lát này vì điều này chỉ giúp tôi xử lý tệp trong khi response.arrayBuffer() đưa toàn bộ tệp vào bộ nhớ kết xuất đồ hoạ.

Tôi có thể dùng tiêu đề HTTP X-From-Cache tuỳ chỉnh để biết yêu cầu này đến từ bộ nhớ đệm hay từ mạng. Trình phát như ShakaPlayer có thể sử dụng phương thức này để bỏ qua thời gian phản hồi dưới dạng chỉ báo về tốc độ mạng.

Hãy xem Ứng dụng đa phương tiện mẫu chính thức và cụ thể là tệp ranged-response.js để biết giải pháp hoàn chỉnh về cách xử lý các yêu cầu Range.