เล่นอย่างรวดเร็วด้วยเสียงและวิดีโอที่โหลดไว้ล่วงหน้า

วิธีเร่งความเร็วในการเล่นสื่อด้วยการโหลดทรัพยากรล่วงหน้า

ฟร็องซัว โบฟอร์
François Beaufort

การเริ่มเล่นที่เร็วขึ้นหมายความว่ามีผู้คนดูวิดีโอหรือฟังเสียงของคุณมากขึ้น นี่เป็นข้อเท็จจริงที่ทราบแล้ว ในบทความนี้ เราจะมาสำรวจเทคนิคที่คุณสามารถใช้เพื่อเร่งความเร็วการเล่นเสียงและวิดีโอด้วยการโหลดทรัพยากรล่วงหน้า ทั้งนี้ขึ้นอยู่กับกรณีการใช้งานของคุณ

เครดิต: copyright Blender Foundation | www.blender.org

ฉันจะอธิบายวิธีการโหลดไฟล์สื่อล่วงหน้า 3 วิธี โดยเริ่มจากข้อดีและข้อเสีย

เยี่ยมเลย... แต่ว่า...
แอตทริบิวต์การโหลดวิดีโอล่วงหน้า ใช้งานง่ายสำหรับไฟล์ที่ไม่ซ้ำกันซึ่งโฮสต์อยู่ในเว็บเซิร์ฟเวอร์ เบราว์เซอร์อาจไม่สนใจแอตทริบิวต์นี้เลย
การดึงข้อมูลทรัพยากรจะเริ่มต้นเมื่อเอกสาร HTML โหลดและแยกวิเคราะห์เสร็จสมบูรณ์
ส่วนขยายแหล่งที่มาของสื่อ (MSE) ไม่สนใจแอตทริบิวต์ preload ในองค์ประกอบสื่อเนื่องจากแอปมีหน้าที่จัดหาสื่อให้กับ MSE
การโหลดลิงก์ล่วงหน้า บังคับให้เบราว์เซอร์ขอทรัพยากรวิดีโอโดยไม่บล็อกเหตุการณ์ onload ของเอกสาร คำขอช่วง HTTP ใช้ร่วมกันไม่ได้
ใช้ได้กับ MSE และกลุ่มไฟล์ ควรใช้สำหรับไฟล์สื่อขนาดเล็ก (<5 MB) เมื่อดึงข้อมูลทรัพยากรทั้งหมดเท่านั้น
การบัฟเฟอร์ด้วยตนเอง ควบคุมได้ดั่งใจ การจัดการข้อผิดพลาดที่ซับซ้อนเป็นความรับผิดชอบของเว็บไซต์

แอตทริบิวต์การโหลดวิดีโอล่วงหน้า

หากแหล่งที่มาของวิดีโอเป็นไฟล์ที่ไม่ซ้ำกันซึ่งโฮสต์อยู่บนเว็บเซิร์ฟเวอร์ คุณอาจต้องใช้แอตทริบิวต์ preload ของวิดีโอเพื่อให้คำแนะนำแก่เบราว์เซอร์ว่าข้อมูลหรือเนื้อหาที่ต้องโหลดล่วงหน้ามีปริมาณเท่าใด ซึ่งหมายความว่า Media Source Extensions (MSE) จะใช้ร่วมกับ preload ไม่ได้

การดึงข้อมูลทรัพยากรจะเริ่มต้นเฉพาะเมื่อเอกสาร HTML เริ่มต้นโหลดและแยกวิเคราะห์เสร็จสมบูรณ์ (เช่น เหตุการณ์ DOMContentLoaded เริ่มทํางานแล้ว) ขณะที่เหตุการณ์ load ที่แตกต่างกันมากจะเริ่มทํางานเมื่อมีการดึงทรัพยากรจริงๆ

การตั้งค่าแอตทริบิวต์ preload เป็น metadata บ่งบอกว่าผู้ใช้ไม่น่าจะต้องการวิดีโอ แต่เราขอแนะนำให้ดึงข้อมูลเมตา (มิติข้อมูล รายการแทร็ก ระยะเวลา และอื่นๆ) โปรดทราบว่าใน Chrome 64 ค่าเริ่มต้นสำหรับ preload คือ metadata (ก่อนหน้านี้เป็น 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>

การตั้งค่าแอตทริบิวต์ preload เป็น auto บ่งบอกว่าเบราว์เซอร์อาจแคชข้อมูลที่เพียงพอเพื่อให้การเล่นเสร็จสมบูรณ์ได้โดยไม่ต้องหยุดบัฟเฟอร์เพิ่มเติม

<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>

แต่ก็มีข้อควรระวังบางประการ ข้อมูลนี้เป็นเพียงคำแนะนำ เบราว์เซอร์อาจไม่สนใจแอตทริบิวต์ preload โดยสิ้นเชิง ในขณะที่เขียน กฎบางข้อ ที่นำมาใช้ใน Chrome มีดังนี้

  • เมื่อเปิดใช้โปรแกรมประหยัดอินเทอร์เน็ต Chrome จะบังคับให้ค่า preload เป็น none
  • ใน Android 4.3 Chrome จะบังคับให้ค่า preload เป็น none เนื่องจาก Android Bug
  • ในการเชื่อมต่อเครือข่ายมือถือ (2G, 3G และ 4G) Chrome จะบังคับให้ค่า preload เป็น metadata

เคล็ดลับ

หากเว็บไซต์มีทรัพยากรวิดีโอจำนวนมากในโดเมนเดียวกัน เราขอแนะนำให้ตั้งค่า preload เป็น metadata หรือระบุแอตทริบิวต์ poster และตั้งค่า preload เป็น none ด้วยวิธีนี้ คุณจะหลีกเลี่ยงปัญหาการเชื่อมต่อ HTTP กับโดเมนเดียวกันถึงจำนวนสูงสุด (6 ตามข้อกำหนด HTTP 1.1) ซึ่งอาจทำให้โหลดทรัพยากรได้ไม่เพียงพอ โปรดทราบว่าวิธีนี้อาจช่วยปรับปรุงความเร็วหน้าเว็บด้วยหากวิดีโอไม่ได้เป็นส่วนหนึ่งของประสบการณ์หลักของผู้ใช้

ตามที่กล่าวถึงในบทความอื่นๆ การโหลดลิงก์ล่วงหน้าคือการดึงข้อมูลแบบประกาศที่ให้คุณบังคับให้เบราว์เซอร์ขอทรัพยากรโดยไม่ต้องบล็อกเหตุการณ์ load และขณะที่หน้ากำลังดาวน์โหลด ทรัพยากรที่โหลดผ่าน <link rel="preload"> จะจัดเก็บอยู่ในเบราว์เซอร์ภายในเครื่อง และจะมีประสิทธิภาพลดลงจนกว่าจะมีการอ้างอิงอย่างชัดเจนใน DOM, JavaScript หรือ CSS

การโหลดล่วงหน้าแตกต่างจากการดึงข้อมูลล่วงหน้าตรงที่จะมุ่งเน้นที่การนำทางปัจจุบัน และจะดึงทรัพยากรที่มีลำดับความสำคัญตามประเภท (สคริปต์ รูปแบบ แบบอักษร วิดีโอ เสียง เป็นต้น) ควรใช้เพื่อเตรียมแคชของเบราว์เซอร์สำหรับเซสชันปัจจุบัน

โหลดวิดีโอแบบเต็มล่วงหน้า

ต่อไปนี้เป็นวิธีการโหลดวิดีโอแบบเต็มบนเว็บไซต์ล่วงหน้า เพื่อที่ว่าเมื่อ JavaScript ขอให้ดึงเนื้อหาวิดีโอ ระบบจะอ่านเนื้อหานั้นจากแคช เนื่องจากเบราว์เซอร์อาจแคชแหล่งข้อมูลไว้แล้ว หากคำขอโหลดล่วงหน้ายังไม่เสร็จสมบูรณ์ ระบบจะดึงข้อมูลเครือข่ายตามปกติ

<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>

เนื่องจากองค์ประกอบวิดีโอในตัวอย่างจะใช้ทรัพยากรที่โหลดไว้ล่วงหน้า ค่าลิงก์โหลดล่วงหน้าของ as จึงเป็น video หากเป็นองค์ประกอบเสียง ก็จะเป็น as="audio"

โหลดส่วนแรกไว้ล่วงหน้า

ตัวอย่างด้านล่างแสดงวิธีโหลดส่วนแรกของวิดีโอล่วงหน้าด้วย <link rel="preload"> และใช้กับส่วนขยายแหล่งที่มาของสื่อ หากคุณไม่คุ้นเคยกับ MSE JavaScript API โปรดดูข้อมูลเบื้องต้นเกี่ยวกับ MSE

เพื่อให้เข้าใจง่าย สมมติว่าวิดีโอทั้งหมดถูกแบ่งออกเป็นไฟล์ขนาดเล็กลง เช่น file_1.webm, file_2.webm, file_3.webm เป็นต้น

<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>

การสนับสนุน

คุณตรวจสอบการรองรับ as ประเภทต่างๆ สำหรับ <link rel=preload> ได้ด้วยข้อมูลโค้ดด้านล่างนี้

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');
}

การบัฟเฟอร์ด้วยตนเอง

ก่อนจะเจาะลึกเรื่อง Cache API และ Service Worker มาดูวิธีบัฟเฟอร์วิดีโอด้วย MSE ด้วยตนเองกัน ตัวอย่างด้านล่างถือว่าเว็บเซิร์ฟเวอร์ของคุณรองรับคําขอ HTTP Range แต่จะค่อนข้างคล้ายกันกับกลุ่มไฟล์ โปรดทราบว่าไลบรารีมิดเดิลแวร์บางรายการ เช่น Shaka Player ของ Google, JW Player และ Video.js สร้างขึ้นเพื่อจัดการเรื่องนี้ให้คุณ

<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>

ข้อควรพิจารณา

เนื่องจากตอนนี้คุณเป็นผู้ควบคุมการบัฟเฟอร์สื่อทั้งหมดแล้ว เราจึงขอแนะนำให้คุณพิจารณาระดับแบตเตอรี่ของอุปกรณ์ ค่ากำหนดของผู้ใช้ "โหมดประหยัดอินเทอร์เน็ต" และข้อมูลเครือข่ายเมื่อต้องการโหลดล่วงหน้า

การรับรู้แบตเตอรี่

ให้คำนึงระดับแบตเตอรี่ของอุปกรณ์ของผู้ใช้ก่อนพิจารณา เกี่ยวกับการโหลดวิดีโอล่วงหน้า โหมดนี้จะช่วยยืดอายุการใช้งานแบตเตอรี่ เมื่อระดับพลังงานเหลือน้อย

ปิดการโหลดล่วงหน้าหรืออย่างน้อยโหลดวิดีโอความละเอียดต่ำไว้ล่วงหน้าเมื่อแบตเตอรี่อุปกรณ์หมด

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.
    }
  });
}

ตรวจหา "โปรแกรมประหยัดข้อมูล"

ใช้ส่วนหัวของคำขอคำแนะนำไคลเอ็นต์ Save-Data เพื่อนำส่งแอปพลิเคชันที่ใช้งานง่ายและรวดเร็วให้แก่ผู้ใช้ที่เลือกใช้โหมด "ประหยัดอินเทอร์เน็ต" ในเบราว์เซอร์ เมื่อระบุส่วนหัวของคำขอนี้ แอปพลิเคชันของคุณจะปรับแต่งและมอบประสบการณ์การใช้งานที่มีการเพิ่มประสิทธิภาพให้แก่ผู้ใช้ที่มีข้อจำกัดด้านต้นทุนและประสิทธิภาพได้

ดูข้อมูลเพิ่มเติมได้ที่การให้บริการแอปพลิเคชันที่เร็วและใช้ทรัพยากรน้อยด้วยการบันทึกข้อมูล

การโหลดอัจฉริยะตามข้อมูลเครือข่าย

คุณอาจต้องการตรวจสอบ navigator.connection.type ก่อนการโหลดล่วงหน้า เมื่อตั้งค่าเป็น cellular คุณจะป้องกันการโหลดล่วงหน้าและแนะนำผู้ใช้ว่าผู้ให้บริการเครือข่ายมือถืออาจกำลังชาร์จแบนด์วิดท์ และเริ่มเล่นเฉพาะเนื้อหาที่แคชไว้ก่อนหน้านี้โดยอัตโนมัติเท่านั้น

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

ดูตัวอย่างข้อมูลเครือข่ายเพื่อดูวิธีตอบสนองต่อการเปลี่ยนแปลงของเครือข่ายด้วย

แคชกลุ่มแรกหลายกลุ่มล่วงหน้า

ทีนี้จะเป็นอย่างไรหากฉันต้องการโหลดเนื้อหาสื่อบางอย่างไว้ล่วงหน้า โดยไม่รู้ว่าท้ายที่สุดแล้วผู้ใช้จะเลือกใช้สื่อใด หากผู้ใช้อยู่ในหน้าเว็บที่มีวิดีโอ 10 รายการ เราอาจมีหน่วยความจำเพียงพอที่จะดึงไฟล์กลุ่ม 1 ไฟล์จากแต่ละรายการ แต่เราไม่ควรสร้างองค์ประกอบ <video> ที่ซ่อนอยู่ 10 รายการและออบเจ็กต์ MediaSource อีก 10 รายการและเริ่มป้อนข้อมูลดังกล่าว

ตัวอย่าง 2 ส่วนด้านล่างแสดงวิธีแคชส่วนแรกของวิดีโอล่วงหน้าหลายๆ ส่วนโดยใช้ Cache API ที่มีประสิทธิภาพและใช้งานง่าย โปรดทราบว่าสิ่งที่คล้ายกันนี้ สามารถทำได้โดยใช้ IndexedDB เช่นกัน เรายังไม่ได้ใช้ Service Worker เนื่องจากออบเจ็กต์ window ก็เข้าถึง Cache API ได้เช่นกัน

ดึงข้อมูลและแคช

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;
    });
  });
}

โปรดทราบว่าหากใช้คําขอ HTTP Range ฉันจะต้องสร้างออบเจ็กต์ Response อีกครั้งด้วยตนเอง เนื่องจาก Cache API จะไม่รองรับการตอบกลับ Range ในขณะนี้ โปรดทราบว่าการเรียกใช้ networkResponse.arrayBuffer() จะดึงข้อมูลเนื้อหาทั้งหมดของการตอบกลับพร้อมกันไปยังหน่วยความจำโหมดแสดงภาพ คุณจึงควรใช้ช่วงขนาดเล็ก

เพื่อเป็นข้อมูลอ้างอิง เราได้แก้ไขบางส่วนของตัวอย่างด้านบนให้บันทึกคำขอช่วง HTTP ไปยังแคชล่วงหน้าของวิดีโอ

    ...
    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;
    });

เล่นวิดีโอ

เมื่อผู้ใช้คลิกปุ่มเล่น เราจะดึงข้อมูลส่วนแรกของวิดีโอที่มีอยู่ใน Cache API เพื่อให้การเล่นเริ่มเล่นทันทีหากมี ไม่เช่นนั้น เราจะดึงข้อมูลจากเครือข่าย โปรดทราบว่าเบราว์เซอร์และผู้ใช้อาจเลือกที่จะล้างแคช

อย่างที่เห็นก่อนหน้านี้ เราใช้ MSE เพื่อฟีดส่วนแรกของวิดีโอไปยังองค์ประกอบวิดีโอ

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.
      });
    }
  });
}

สร้างการตอบกลับช่วงด้วย Service Worker

จะเกิดอะไรขึ้นหากคุณดึงข้อมูลไฟล์วิดีโอทั้งหมดและบันทึกไว้ใน Cache API เมื่อเบราว์เซอร์ส่งคำขอ HTTP Range คุณคงไม่อยากนำวิดีโอทั้งหมดไปไว้ในหน่วยความจำของโหมดแสดงภาพ เนื่องจาก Cache API ยังไม่รองรับการตอบกลับ Range ในขณะนี้

ดังนั้นเราจะแสดงวิธีสกัดกั้นคำขอเหล่านี้และแสดงผลการตอบกลับ Range ที่กำหนดเองจาก Service Worker

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;
  }
}

สิ่งสำคัญที่ควรทราบคือฉันใช้ response.blob() เพื่อสร้างการตอบสนองแบบแบ่งส่วนนี้ใหม่ เนื่องจากจะใช้แฮนเดิลกับไฟล์ได้ในขณะที่ response.arrayBuffer() นำทั้งไฟล์มาไว้ในหน่วยความจำของโหมดแสดงภาพ

ส่วนหัว HTTP ของ X-From-Cache ที่กำหนดเองสามารถใช้เพื่อให้ทราบว่าคำขอนี้มาจากแคชหรือจากเครือข่าย โปรแกรมเล่น เช่น ShakaPlayer จะใช้ค่านี้เพื่อละเว้นเวลาในการตอบกลับเป็นตัวบ่งชี้ความเร็วของเครือข่ายได้

ดูแอปสื่อตัวอย่างอย่างเป็นทางการ และโดยเฉพาะอย่างยิ่งไฟล์ ranged-response.js เพื่อดูโซลูชันที่สมบูรณ์ในการจัดการคำขอ Range