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.

François Beaufort
François Beaufort

Việc bắt đầu phát nhanh hơn sẽ giúp nhiều người xem video hoặc nghe âm thanh của bạn hơn. Đó là một sự thật đã biết. Trong bài viết này, tôi sẽ khám phá các kỹ thuật mà 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ỳ theo trường hợp sử dụng của bạn.

Tín dụng: Bản quyền Binder Foundation | www.blender.org .

Tôi sẽ mô tả 3 phương pháp tải trước tệp đa phương tiện, bắt đầu từ ưu và nhược điểm của những phương pháp đó.

Thật tuyệt... Nhưng...
Thuộc tính tải trước video Dễ sử dụng cho một tệp duy nhất được lưu trữ trên máy chủ web. Trình duyệt có thể bỏ qua hoàn toàn 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 toàn.
Tiện ích nguồn nội dung nghe nhì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 chịu trách nhiệm cung cấp nội dung nghe nhìn cho MSE.
Tải trước đường liên kết Buộc trình duyệt đưa ra yêu cầu về 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 đa phương tiện nhỏ (<5 MB) khi tìm nạp đầy đủ tài nguyên.
Lưu theo cách thủ công vào bộ nhớ đệm Toàn quyền kiểm soát Trang web 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, thì bạn nên sử dụng thuộc tính video preload để cung cấp gợi ý cho trình duyệt về lượng thông tin hoặc nội dung cần tải trước. Tức là Tiện ích nguồn phương tiệ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 toàn (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 dự kiến sẽ không cần video, nhưng việc 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.) là mong muốn. Xin lưu ý rằng bắt đầu 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 tất mà không cần dừng để lưu vào bộ đệm thê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ố điểm cần 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, sau đây là một số quy tắc được áp dụng trong Chrome:

  • Khi bạn bật Trình tiết kiệm dữ liệu, Chrome sẽ buộc giá trị preload thành none.
  • Trong Android 4.3, Chrome buộc giá trị preload thành none do một 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 rồi đặt preload thành none. Bằng cách đó, bạn sẽ tránh đạt đến số lượng kết nối HTTP tối đa đến cùng một miền (6 theo thông số kỹ thuật HTTP 1.1) có thể khiến tài nguyên bị treo. Xin lưu ý rằng việc này cũng có thể cải thiện tốc độ trang nếu bạn không cung cấp video không phải 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à một phương thức tìm nạp khai báo cho phép bạn buộc trình duyệt đưa ra yêu cầu về một 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 được tải qua <link rel="preload"> được lưu trữ cục bộ trong trình duyệt và hoạt động một cách 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 hoạt động tìm nạp trước ở chỗ hoạt động 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 sử dụng tính năng 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

Sau đây là cách tải trước toàn bộ video trên trang web của bạn để khi JavaScript yêu cầu tìm nạp nội dung video, nội dung đó sẽ được đọc từ bộ nhớ đệm vì trình duyệt có thể đã lưu tài nguyên vào bộ nhớ đệm. Nếu yêu cầu tải trước chưa hoàn tất, thì một lượt 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ì phần tử video trong ví dụ sẽ sử dụng tài nguyên được tải trước, nên giá trị đường liên kết tải trước asvideo. Nếu là phần tử âm thanh, thì giá trị này sẽ là as="audio".

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

Ví dụ dưới đây cho thấy 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 đó với Tiện ích nguồn nội dung nghe nhìn. Nếu bạn chưa quen với API JavaScript MSE, hãy xem phần Kiến thức cơ bản về MSE.

Để đơn giản, hãy 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 thủ công vào bộ đệm

Trước khi tìm hiểu sâu về API Bộ nhớ đệm và trình chạy dịch vụ, hãy xem cách lưu video vào bộ đệm 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 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ư shaka Player của Google, JW PlayerVideo.js được xây dựng để 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

Giờ đây, vì bạn là người kiểm soát toàn bộ trải nghiệm lưu nội dung nghe nhìn vào bộ đệm, 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 là "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 pin

Hãy tính đến mức pin của 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 mức năng lượng 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 ý ứ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 tham gia chế độ "tiết kiệm dữ liệu" trong trình duyệt. 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 người dùng bị hạn chế về chi phí và hiệu suất.

Xem Phân phối các ứng dụng nhanh và nhẹ bằng dữ liệu tiết kiệm để 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 thuộc tính này thành cellular, thì bạn có thể ngăn việc 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.
  }
}

Hãy xem mẫu Thông tin mạng để tìm hiểu cách phản ứng với các 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 đa phương tiện theo suy đoán mà không biết cuối cùng người dùng sẽ chọn nội dung nào? Nếu người dùng đang truy cập một trang web chứa 10 video, chúng ta có thể có đủ bộ nhớ để tìm nạp 1 tệp phân đoạn từ mỗi video. Tuy nhiên, chúng ta chắc chắn không nên tạo 10 phần tử <video> ẩn và 10 đối tượng MediaSource và bắt đầu cung cấp dữ liệu đó.

Ví dụ 2 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 cách sử dụng API Bộ nhớ đệm mạnh mẽ và dễ sử dụng. Xin lưu ý rằng bạn cũng có thể thực hiện một số thao tác tương tự bằng IndexedDB. Chúng ta chưa sử dụng trình chạy dịch vụ vì bạn cũng có thể truy cập vào API bộ nhớ đệm 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;
    });
  });
}

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

Để tham khảo, tôi đã sửa đổi một phần của 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 ta sẽ tìm nạp phân đoạn video đầu tiên có trong API bộ nhớ đệm để bắt đầu phát ngay nếu có. Nếu không, chúng tôi sẽ chỉ tìm nạp mã từ mạng. Xin lưu ý rằng các 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 để truyền đoạn video đầu tiên đó đến 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 Phạm vi bằng worker dịch vụ

Nếu bạn đã tìm nạp toàn bộ một 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 Range HTTP, chắc chắn bạn không muốn đưa toàn bộ video vào bộ nhớ của trình kết xuất vì API bộ nhớ đệm chưa hỗ trợ phản hồi Range.

Vì vậy, hãy để tôi chỉ cho bạn 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ì việc này chỉ đơn giản là cho tôi xử lý tệp trong khi response.arrayBuffer() đưa toàn bộ tệp vào bộ nhớ kết xuất.

Tiêu đề HTTP X-From-Cache tuỳ chỉnh của tôi có thể dùng để biết yêu cầu này đến từ bộ nhớ đệm hay từ mạng. Trình phát này có thể sử dụng trình phát này như ShakaPlayer để bỏ qua thời gian phản hồi. Đây là 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.