第八章:性能优化

视频性能优化的本质是一场资源争夺战——带宽、内存、CPU/GPU、主线程时间,每一项都是稀缺的。优化不是单一的银弹,而是一套系统性的工程:在正确的时机加载正确的数据量,在正确的线程做正确的事,并建立指标体系来持续衡量效果。本章从加载、渲染、内存、网络自适应、多路视频五个维度展开,给出可落地的工程方案。


第一部分:加载策略优化

preload 属性的精确控制

preload 是最直接影响首屏加载性能的属性,但它的默认行为在不同浏览器和设备上差异极大,不能依赖浏览器自行决策。三个取值的实际语义需要精确理解:

preload="none" 表示告诉浏览器完全不预加载任何数据,连元数据(时长、分辨率)也不加载。适用于页面上有大量视频但用户不一定会播放的场景,比如文章列表中的视频缩略图区域。

preload="metadata" 是最平衡的选择——浏览器会加载足够的数据以确定视频的时长、尺寸和轨道信息,通常只需要几 KB 到几十 KB 的数据。这是大多数场景的推荐默认值,能够让 video.durationvideo.videoWidthvideo.videoHeight 等属性立即可用,同时不会浪费流量。

preload="auto" 允许浏览器根据自身判断预加载尽可能多的数据。在桌面端通常意味着预加载整个视频,在移动端则往往被浏览器忽略。不要在移动端依赖这个值。

<!-- 列表页:完全不预加载,节省带宽 -->
<video preload="none" poster="cover.jpg" src="video.mp4"></video>
 
<!-- 详情页:预加载元数据,快速显示时长和尺寸 -->
<video preload="metadata" src="video.mp4"></video>
 
<!-- 即将播放的视频:允许完整预加载 -->
<video preload="auto" src="next-video.mp4"></video>

一个重要的细节:autoplay 属性存在时,preload 属性会被忽略——浏览器会直接开始加载并播放,preload 的设置没有意义。


fetchpriority 属性:资源优先级控制

fetchpriority 是 Fetch Priority API 提供的 HTML 属性,可以向浏览器的资源调度器发送优先级信号。它接受 highlowauto 三个值,影响浏览器的资源请求队列排序。web.dev

对于视频资源,fetchpriority 的典型用法是降低非关键视频的优先级,避免其与首屏关键资源(CSS、字体、LCP 图片)竞争带宽:

<!-- 首屏主视频:高优先级 -->
<video
  preload="auto"
  fetchpriority="high"
  src="hero-video.mp4"
  autoplay muted playsinline
></video>
 
<!-- 页面下方的视频:低优先级,避免竞争带宽 -->
<video
  preload="none"
  fetchpriority="low"
  src="secondary-video.mp4"
></video>

fetchpriority 是相对优先级,它调整的是浏览器默认优先级的基础上的偏移量,而不是绝对指定。目前 Chrome 102+、Safari 17.2+、Firefox 132+ 支持此属性。


基于 IntersectionObserver 的懒加载

对于页面上有多个视频的场景(信息流、视频列表),懒加载是节省带宽的关键手段。传统的 scroll 事件监听方案会在主线程上持续触发计算,性能差;IntersectionObserver 由浏览器在合成线程批量处理,对主线程零压力。cloud.tencent.com

class VideoLazyLoader {
  constructor(options = {}) {
    this.options = {
      rootMargin: '200px 0px',  // 提前 200px 开始加载
      threshold: 0.1,           // 10% 进入视口即触发
      preloadOnNear: true,      // 接近视口时预加载 metadata
      ...options
    };
 
    this.observer = new IntersectionObserver(
      this._onIntersect.bind(this),
      {
        rootMargin: this.options.rootMargin,
        threshold: [0, 0.1, 0.5, 1.0]  // 多个阈值,精细控制
      }
    );
  }
 
  observe(videoEl) {
    // 初始状态:不设置 src,只保留 data-src
    if (videoEl.dataset.src) {
      videoEl.preload = 'none';
      this.observer.observe(videoEl);
    }
  }
 
  _onIntersect(entries) {
    entries.forEach(entry => {
      const video = entry.target;
 
      if (entry.isIntersecting) {
        const ratio = entry.intersectionRatio;
 
        if (ratio >= 0.1 && !video.src) {
          // 进入视口:设置 src,开始加载 metadata
          video.src = video.dataset.src;
          video.preload = 'metadata';
        }
 
        if (ratio >= 0.5) {
          // 超过 50% 可见:升级为 auto 预加载
          video.preload = 'auto';
        }
 
      } else {
        // 离开视口:降级预加载,节省内存
        if (video.dataset.src && video.paused) {
          video.preload = 'none';
          // 注意:不要清空 src,否则会触发重新加载
        }
      }
    });
  }
 
  unobserve(videoEl) {
    this.observer.unobserve(videoEl);
  }
 
  disconnect() {
    this.observer.disconnect();
  }
}
 
// 使用
const lazyLoader = new VideoLazyLoader({ rootMargin: '300px 0px' });
 
document.querySelectorAll('video[data-src]').forEach(video => {
  lazyLoader.observe(video);
});

对应的 HTML 结构:

<!-- data-src 存储真实地址,src 留空 -->
<video
  data-src="https://example.com/video.mp4"
  poster="cover.jpg"
  preload="none"
  playsinline
  muted
></video>

对于确定用户即将播放的视频(如视频详情页的主视频),可以用 &lt;link rel="preload"&gt; 在 HTML 解析阶段就触发资源请求,比等到 &lt;video&gt; 元素渲染后再加载要早得多:

<head>
  <!-- 预加载主视频(仅预加载,不解析) -->
  <link
    rel="preload"
    as="video"
    href="https://example.com/main-video.mp4"
    type="video/mp4"
    fetchpriority="high"
  />
</head>

但要注意:&lt;link rel="preload" as="video"&gt; 的浏览器支持并不完整(Safari 对 as="video" 的支持历史上有问题),且预加载的资源必须在合理时间内被使用,否则浏览器会发出"preload resource not used"警告,甚至取消预加载。一般只对当前页面最核心的一个视频使用这个技术。


第二部分:首帧时间(TTFF)优化

TTFF 的构成与优化目标

首帧时间(Time To First Frame,TTFF)是用户点击播放到看到第一帧画面的时间,是视频播放体验最核心的指标之一。行业标准通常是:< 500ms 为优秀,< 1s 为良好,> 3s 用户流失率显著上升。

TTFF 的构成链路为:

用户点击
  ↓
DNS 解析(可用 DNS 预解析消除)
  ↓
TCP/TLS 握手(可用 HTTP/2、CDN 消除)
  ↓
首个视频数据包到达
  ↓
解码器初始化
  ↓
第一帧解码完成
  ↓
渲染到屏幕 ← TTFF 终点

前端能影响的环节主要是 DNS 解析、连接预热和解码器初始化。


DNS 预解析与连接预热

<head>
  <!-- DNS 预解析:提前解析视频 CDN 域名 -->
  <link rel="dns-prefetch" href="//video-cdn.example.com" />
 
  <!-- 预连接:建立 TCP + TLS 连接(更激进,消耗更多资源) -->
  <!-- 仅对确定会用到的域名使用 preconnect -->
  <link rel="preconnect" href="https://video-cdn.example.com" crossorigin />
</head>

dns-prefetch 只做 DNS 解析,资源消耗小,可以对所有可能用到的视频 CDN 域名使用。preconnect 会建立完整的 TCP+TLS 连接,消耗更多资源,只对确定会立即使用的域名使用,且数量不应超过 2-3 个。


Poster 图的首帧视觉欺骗

在视频真正加载完第一帧之前,poster 属性显示的封面图可以作为视觉占位,给用户"立即响应"的感知。关键是 poster 图要和视频第一帧保持一致,避免切换时的视觉跳变:

// 自动生成 poster:从视频第一帧截图
async function generatePoster(videoSrc) {
  return new Promise((resolve) => {
    const video = document.createElement('video');
    const canvas = document.createElement('canvas');
 
    video.src = videoSrc;
    video.preload = 'metadata';
    video.muted = true;
 
    video.addEventListener('loadeddata', () => {
      canvas.width  = video.videoWidth;
      canvas.height = video.videoHeight;
      canvas.getContext('2d').drawImage(video, 0, 0);
 
      // 转为 DataURL 作为 poster
      resolve(canvas.toDataURL('image/jpeg', 0.85));
 
      // 清理
      video.src = '';
    }, { once: true });
 
    // 跳到第 0.1 秒截图(避免纯黑帧)
    video.addEventListener('loadedmetadata', () => {
      video.currentTime = 0.1;
    }, { once: true });
  });
}
 
// 使用
const posterUrl = await generatePoster('video.mp4');
videoElement.poster = posterUrl;

双播放器预加载策略(秒开优化)

短视频信息流场景(类似抖音/TikTok 的滑动播放)中,"秒开"体验的核心是在用户滑到当前视频之前,下一个视频已经预加载好了首帧。实现这个效果的经典方案是双播放器实例交替复用:zhuanlan.zhihu.com

class DualVideoPreloader {
  constructor() {
    // 创建两个 video 实例,交替使用
    this.players = [
      this._createPlayer(),
      this._createPlayer()
    ];
    this.activeIndex = 0;
    this.preloadIndex = 1;
  }
 
  _createPlayer() {
    const video = document.createElement('video');
    video.muted    = true;
    video.preload  = 'auto';
    video.playsinline = true;
    video.setAttribute('webkit-playsinline', '');
    return video;
  }
 
  get activePlayer() {
    return this.players[this.activeIndex];
  }
 
  get preloadPlayer() {
    return this.players[this.preloadIndex];
  }
 
  // 预加载下一个视频
  preloadNext(src) {
    const preloader = this.preloadPlayer;
    preloader.src = src;
    preloader.preload = 'auto';
 
    // 加载到 HAVE_ENOUGH_DATA 后暂停,等待切换
    return new Promise((resolve) => {
      const onCanPlay = () => {
        preloader.pause();
        preloader.removeEventListener('canplay', onCanPlay);
        resolve(preloader);
      };
      preloader.addEventListener('canplay', onCanPlay);
      preloader.load();
    });
  }
 
  // 切换到预加载好的视频(几乎零延迟)
  switchToNext(containerEl) {
    // 停止当前播放
    this.activePlayer.pause();
    this.activePlayer.src = '';
 
    // 交换索引
    [this.activeIndex, this.preloadIndex] = [this.preloadIndex, this.activeIndex];
 
    // 将预加载好的 video 挂载到容器
    containerEl.innerHTML = '';
    containerEl.appendChild(this.activePlayer);
 
    // 立即播放(首帧已经就绪)
    return this.activePlayer.play();
  }
}

第三部分:渲染性能优化

GPU 加速与合成层提升

CSS 的 will-changetransform 属性可以将 &lt;video&gt; 元素提升到独立的 GPU 合成层,避免视频渲染触发页面的重排(reflow)和重绘(repaint):

video {
  /* 提升到独立合成层,视频渲染不影响其他元素 */
  will-change: transform;
  transform: translateZ(0);
 
  /* 防止子像素渲染导致的模糊 */
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

需要注意的是,will-change 是一个成本较高的提示——每个使用它的元素都会占用额外的 GPU 内存。不要对页面上所有视频都使用,只对正在播放的视频动态添加,播放结束后移除:

video.addEventListener('play',  () => { video.style.willChange = 'transform'; });
video.addEventListener('pause', () => { video.style.willChange = 'auto'; });
video.addEventListener('ended', () => { video.style.willChange = 'auto'; });

requestVideoFrameCallback 精准帧处理

requestVideoFrameCallback(rVFC)是专门为视频帧处理设计的 API,相比 requestAnimationFrame(rAF)有两个关键优势:它在新视频帧真正到达合成器时才触发(而 rAF 是固定间隔),并且提供帧的精确元数据。mdn.org.cn web.dev

class VideoFrameProcessor {
  constructor(video, canvas) {
    this.video  = video;
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.callbackId = null;
 
    // 帧率统计
    this.frameCount = 0;
    this.startTime  = 0;
    this.currentFPS = 0;
  }
 
  start() {
    if (!('requestVideoFrameCallback' in HTMLVideoElement.prototype)) {
      // 降级到 rAF
      this._startWithRAF();
      return;
    }
 
    this.startTime = performance.now();
    this._scheduleFrame();
  }
 
  _scheduleFrame() {
    this.callbackId = this.video.requestVideoFrameCallback(
      (now, metadata) => this._onFrame(now, metadata)
    );
  }
 
  _onFrame(now, metadata) {
    // metadata 包含精确的帧信息
    const {
      presentationTime,    // 帧渲染到屏幕的时间
      expectedDisplayTime, // 帧预期显示时间
      width, height,       // 帧的实际尺寸(可能与 videoWidth 不同)
      mediaTime,           // 帧在视频时间轴上的位置(对应 currentTime)
      presentedFrames,     // 已渲染的总帧数
      processingDuration,  // 帧处理耗时
    } = metadata;
 
    // 更新 canvas 尺寸(只在变化时更新)
    if (this.canvas.width !== width || this.canvas.height !== height) {
      this.canvas.width  = width;
      this.canvas.height = height;
    }
 
    // 将视频帧绘制到 canvas
    this.ctx.drawImage(this.video, 0, 0, width, height);
 
    // 在 canvas 上叠加自定义内容(字幕、水印、特效等)
    this._renderOverlay(metadata);
 
    // 统计实际帧率
    this.frameCount++;
    const elapsed = (now - this.startTime) / 1000;
    if (elapsed > 0) {
      this.currentFPS = (this.frameCount / elapsed).toFixed(1);
    }
 
    // 检测丢帧(presentedFrames 不连续)
    if (this._lastPresentedFrames !== undefined) {
      const dropped = metadata.presentedFrames - this._lastPresentedFrames - 1;
      if (dropped > 0) {
        console.warn(`丢帧检测: ${dropped} 帧`);
        this._onDroppedFrames(dropped);
      }
    }
    this._lastPresentedFrames = metadata.presentedFrames;
 
    // 继续下一帧
    if (!this.video.paused && !this.video.ended) {
      this._scheduleFrame();
    }
  }
 
  _renderOverlay(metadata) {
    // 示例:绘制时间戳水印
    this.ctx.fillStyle = 'rgba(255,255,255,0.8)';
    this.ctx.font = '14px monospace';
    this.ctx.fillText(
      `${metadata.mediaTime.toFixed(3)}s | ${this.currentFPS}fps`,
      10, 24
    );
  }
 
  _onDroppedFrames(count) {
    // 可以在这里上报丢帧数据到监控系统
    reportMetric('dropped_frames', count);
  }
 
  _startWithRAF() {
    // rAF 降级方案:无法获取帧元数据,但功能基本可用
    const loop = () => {
      if (!this.video.paused && !this.video.ended) {
        this.ctx.drawImage(this.video, 0, 0);
        this.callbackId = requestAnimationFrame(loop);
      }
    };
    this.callbackId = requestAnimationFrame(loop);
  }
 
  stop() {
    if (this.callbackId) {
      if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
        this.video.cancelVideoFrameCallback(this.callbackId);
      } else {
        cancelAnimationFrame(this.callbackId);
      }
      this.callbackId = null;
    }
  }
}

第四部分:内存管理

视频元素的内存释放

&lt;video&gt; 元素即使从 DOM 中移除,如果没有正确清理,解码器、网络连接和缓冲区仍然可能持续占用内存。正确的释放流程是:

function destroyVideo(video) {
  // 1. 停止播放
  video.pause();
 
  // 2. 清空 src(触发网络请求中断)
  video.removeAttribute('src');
 
  // 3. 清空所有 <source> 子元素
  while (video.firstChild) {
    video.removeChild(video.firstChild);
  }
 
  // 4. 调用 load() 使浏览器释放解码器资源
  // 这一步是关键:load() 会重置媒体元素状态,触发资源释放
  video.load();
 
  // 5. 移除所有事件监听器
  // 推荐使用 AbortController 统一管理
  video._abortController?.abort();
 
  // 6. 从 DOM 中移除
  video.parentNode?.removeChild(video);
}
 
// 推荐:使用 AbortController 管理事件监听器
function setupVideoEvents(video) {
  const controller = new AbortController();
  const { signal } = controller;
 
  video.addEventListener('play',  onPlay,  { signal });
  video.addEventListener('pause', onPause, { signal });
  video.addEventListener('ended', onEnded, { signal });
  video.addEventListener('error', onError, { signal });
 
  // 将 controller 挂载到 video 实例,方便统一清理
  video._abortController = controller;
 
  return controller;
}

MSE 场景下的缓冲区内存控制

在使用 MSE 的流式播放场景中,SourceBuffer 会随着分片不断 appendBuffer 而持续增长。Chrome 对单个 MediaSource 的内存限制约为 150MB,超出后会抛出 QuotaExceededError。必须主动清理已播放过的缓冲区:

class BufferManager {
  constructor(sourceBuffer, options = {}) {
    this.sb = sourceBuffer;
    this.opts = {
      maxBufferAhead:  30,   // 当前播放位置前方保留 30s
      maxBufferBehind: 10,   // 当前播放位置后方保留 10s
      ...options
    };
  }
 
  // 在每次 appendBuffer 前调用,确保有足够空间
  async ensureSpace(video) {
    if (this.sb.updating) {
      await this._waitForUpdateEnd();
    }
 
    const buffered = this.sb.buffered;
    const currentTime = video.currentTime;
    let totalBuffered = 0;
 
    for (let i = 0; i < buffered.length; i++) {
      totalBuffered += buffered.end(i) - buffered.start(i);
    }
 
    // 超过阈值时,清理播放位置后方的缓冲
    if (totalBuffered > this.opts.maxBufferAhead + this.opts.maxBufferBehind) {
      await this._evictOldBuffer(currentTime);
    }
  }
 
  async _evictOldBuffer(currentTime) {
    const removeEnd = currentTime - this.opts.maxBufferBehind;
    const buffered  = this.sb.buffered;
 
    for (let i = 0; i < buffered.length; i++) {
      const start = buffered.start(i);
      const end   = buffered.end(i);
 
      if (end < removeEnd) {
        // 整段都在清理范围内
        await this._removeRange(start, end);
      } else if (start < removeEnd) {
        // 部分在清理范围内
        await this._removeRange(start, removeEnd);
        break;
      }
    }
  }
 
  _removeRange(start, end) {
    return new Promise((resolve, reject) => {
      const onUpdateEnd = () => {
        this.sb.removeEventListener('updateend', onUpdateEnd);
        this.sb.removeEventListener('error', onError);
        resolve();
      };
      const onError = (e) => {
        this.sb.removeEventListener('updateend', onUpdateEnd);
        this.sb.removeEventListener('error', onError);
        reject(e);
      };
 
      this.sb.addEventListener('updateend', onUpdateEnd, { once: true });
      this.sb.addEventListener('error', onError, { once: true });
 
      try {
        this.sb.remove(start, end);
      } catch (e) {
        reject(e);
      }
    });
  }
 
  _waitForUpdateEnd() {
    return new Promise(resolve => {
      this.sb.addEventListener('updateend', resolve, { once: true });
    });
  }
}

第五部分:网络自适应

Network Information API

navigator.connection 提供了设备的网络连接信息,可以据此在播放前选择合适的视频质量,避免在弱网下加载高码率视频导致卡顿。juejin.cn web.dev

class NetworkAdaptivePlayer {
  constructor(videoSources) {
    // videoSources: { '4k': url, '1080p': url, '720p': url, '360p': url }
    this.sources = videoSources;
  }
 
  // 根据网络质量选择视频质量
  selectQuality() {
    const conn = navigator.connection ||
                 navigator.mozConnection ||
                 navigator.webkitConnection;
 
    if (!conn) {
      // API 不支持,返回中等质量
      return '720p';
    }
 
    const { effectiveType, downlink, saveData } = conn;
 
    // 尊重用户的"省流"设置
    if (saveData) return '360p';
 
    // 根据有效网络类型判断
    switch (effectiveType) {
      case '4g':
        return downlink > 10 ? '1080p' : '720p';
      case '3g':
        return '480p';
      case '2g':
      case 'slow-2g':
        return '360p';
      default:
        return '720p';
    }
  }
 
  // 监听网络变化,动态切换质量
  watchNetworkChange(video) {
    const conn = navigator.connection;
    if (!conn) return;
 
    conn.addEventListener('change', () => {
      const newQuality = this.selectQuality();
      const currentQuality = video.dataset.currentQuality;
 
      if (newQuality !== currentQuality) {
        console.log(`网络变化,切换质量: ${currentQuality} → ${newQuality}`);
        this._switchQuality(video, newQuality);
      }
    });
  }
 
  _switchQuality(video, quality) {
    const newSrc = this.sources[quality];
    if (!newSrc) return;
 
    const currentTime = video.currentTime;
    const wasPaused   = video.paused;
 
    video.src = newSrc;
    video.dataset.currentQuality = quality;
    video.load();
 
    video.addEventListener('loadedmetadata', () => {
      video.currentTime = currentTime;
      if (!wasPaused) video.play().catch(() => {});
    }, { once: true });
  }
 
  // 初始化播放
  init(video) {
    const quality = this.selectQuality();
    video.src = this.sources[quality];
    video.dataset.currentQuality = quality;
    this.watchNetworkChange(video);
  }
}
 
// 使用
const player = new NetworkAdaptivePlayer({
  '1080p': 'https://cdn.example.com/video-1080p.mp4',
  '720p':  'https://cdn.example.com/video-720p.mp4',
  '480p':  'https://cdn.example.com/video-480p.mp4',
  '360p':  'https://cdn.example.com/video-360p.mp4'
});
 
player.init(videoElement);

带宽探测:主动测速(续)

// 根据测速结果选择质量
async function selectQualityByBandwidth() {
  const mbps = await measureBandwidth();
 
  if (mbps === null) return '720p'; // 测速失败,保守选择
 
  if (mbps >= 20)  return '4k';
  if (mbps >= 8)   return '1080p';
  if (mbps >= 3)   return '720p';
  if (mbps >= 1)   return '480p';
  return '360p';
}
 
// 完整的初始化流程
async function initVideoWithBandwidthProbe(video, sources) {
  // 显示 loading 状态
  showLoading();
 
  // 并行执行:测速 + 加载 metadata
  const [quality] = await Promise.all([
    selectQualityByBandwidth(),
    // 同时预热 DNS/TCP 连接
    fetch(sources['720p'], { method: 'HEAD', cache: 'no-store' }).catch(() => {})
  ]);
 
  video.src = sources[quality];
  video.dataset.quality = quality;
 
  hideLoading();
}

第六部分:多路视频与列表页优化

播放器实例的生命周期管理

在信息流或视频列表页面,一个常见的错误是为每个视频条目都创建一个独立的 &lt;video&gt; 元素并保持其存活。每个 &lt;video&gt; 实例都持有解码器资源、网络连接和内存缓冲区,大量实例并存会导致严重的内存压力和性能问题。

正确的策略是播放器实例复用:全局只维护少量(1~3个)&lt;video&gt; 实例,根据用户的交互行为动态地将实例分配给当前可见的视频条目。离开视口的条目只保留 poster 图,不持有任何媒体资源。cloud.tencent.com

class VideoPoolManager {
  constructor(poolSize = 2) {
    // 创建固定数量的 video 实例池
    this.pool = Array.from({ length: poolSize }, () => this._createInstance());
    this.assignments = new Map(); // itemId -> video 实例
  }
 
  _createInstance() {
    const video = document.createElement('video');
    video.muted      = true;
    video.playsinline = true;
    video.setAttribute('webkit-playsinline', '');
    video.preload    = 'none';
    video._inUse     = false;
    return video;
  }
 
  // 获取一个空闲实例(如果没有空闲则抢占最老的)
  _acquire(itemId) {
    // 先找空闲实例
    let instance = this.pool.find(v => !v._inUse);
 
    if (!instance) {
      // 没有空闲实例,找最早分配的那个(LRU 策略)
      const oldestId = this.assignments.keys().next().value;
      instance = this.assignments.get(oldestId);
      this._release(oldestId);
    }
 
    instance._inUse   = true;
    instance._itemId  = itemId;
    this.assignments.set(itemId, instance);
    return instance;
  }
 
  // 释放实例
  _release(itemId) {
    const instance = this.assignments.get(itemId);
    if (!instance) return;
 
    instance.pause();
    instance.removeAttribute('src');
    instance.load(); // 触发资源释放
    instance._inUse  = false;
    instance._itemId = null;
    this.assignments.delete(itemId);
  }
 
  // 为某个条目分配播放器并开始播放
  play(itemId, src, containerEl) {
    // 如果已经分配了,直接播放
    if (this.assignments.has(itemId)) {
      const video = this.assignments.get(itemId);
      video.play().catch(() => {});
      return video;
    }
 
    const video = this._acquire(itemId);
    video.src = src;
    video.load();
 
    // 挂载到容器
    containerEl.appendChild(video);
 
    video.addEventListener('canplay', () => {
      video.play().catch(() => {});
    }, { once: true });
 
    return video;
  }
 
  // 停止某个条目的播放,但保留实例(用户可能马上滑回来)
  pause(itemId) {
    const video = this.assignments.get(itemId);
    if (video) video.pause();
  }
 
  // 彻底销毁某个条目的播放器(离开视口较远时)
  destroy(itemId) {
    this._release(itemId);
  }
}

结合 IntersectionObserver 的列表自动播放

将播放器池与 IntersectionObserver 结合,实现滚动时自动播放/暂停的完整方案:

class FeedVideoManager {
  constructor() {
    this.pool     = new VideoPoolManager(2);
    this.observer = new IntersectionObserver(
      this._onIntersect.bind(this),
      { threshold: [0, 0.5, 1.0] }
    );
    this.currentPlayingId = null;
  }
 
  register(itemEl) {
    this.observer.observe(itemEl);
  }
 
  _onIntersect(entries) {
    // 找出可见度最高的条目
    let maxRatio = 0;
    let mostVisible = null;
 
    entries.forEach(entry => {
      if (entry.intersectionRatio > maxRatio) {
        maxRatio     = entry.intersectionRatio;
        mostVisible  = entry.target;
      }
    });
 
    // 超过 50% 可见才自动播放
    if (mostVisible && maxRatio >= 0.5) {
      const itemId = mostVisible.dataset.videoId;
      const src    = mostVisible.dataset.videoSrc;
 
      if (itemId !== this.currentPlayingId) {
        // 暂停上一个
        if (this.currentPlayingId) {
          this.pool.pause(this.currentPlayingId);
        }
 
        // 播放新的
        const container = mostVisible.querySelector('.video-container');
        this.pool.play(itemId, src, container);
        this.currentPlayingId = itemId;
      }
    }
 
    // 完全离开视口的条目,销毁播放器实例
    entries.forEach(entry => {
      if (entry.intersectionRatio === 0) {
        const itemId = entry.target.dataset.videoId;
        // 延迟销毁,避免快速滑动时频繁创建销毁
        setTimeout(() => {
          if (itemId !== this.currentPlayingId) {
            this.pool.destroy(itemId);
          }
        }, 500);
      }
    });
  }
}
 
// 初始化
const feedManager = new FeedVideoManager();
document.querySelectorAll('.feed-item').forEach(item => {
  feedManager.register(item);
});

第七部分:性能指标监控

关键指标体系

视频性能优化的效果需要通过指标来量化。核心指标分为三类:

启播指标关注用户从触发播放到看到画面的体验。首帧时间(TTFF)是最核心的,可以通过记录 play 事件触发时间和 playing 事件触发时间的差值来计算——注意是 playing 而不是 play,因为 playing 才代表画面真正开始输出。秒开率(TTFF < 1s 的播放次数占比)是一个更直观的业务指标。

流畅度指标关注播放过程中的体验。卡顿率是卡顿总时长除以播放总时长,可以通过监听 waiting 事件(进入缓冲等待)和 playing 事件(缓冲结束恢复播放)来计算。丢帧率通过 requestVideoFrameCallback 中的 presentedFrames 不连续性来检测。

完播率是业务层面最重要的指标,反映内容质量和播放体验的综合效果。


指标采集完整实现

class VideoMetricsCollector {
  constructor(video, metadata = {}) {
    this.video    = video;
    this.metadata = metadata; // 视频 ID、页面来源等业务信息
 
    // 时间戳记录
    this.timestamps = {
      playIntent:    0,  // 用户点击播放的时间
      firstFrame:    0,  // 第一帧渲染时间(playing 事件)
      bufferStart:   0,  // 最近一次缓冲开始时间
    };
 
    // 累计指标
    this.metrics = {
      ttff:          0,  // 首帧时间 ms
      bufferCount:   0,  // 卡顿次数
      bufferDuration:0,  // 卡顿总时长 ms
      playDuration:  0,  // 播放总时长 s
      droppedFrames: 0,  // 丢帧数
      errorCode:     0,  // 错误码
    };
 
    this._playStartTime   = 0;
    this._lastFrameCount  = 0;
    this._rvfcId          = null;
 
    this._bind();
  }
 
  _bind() {
    const v = this.video;
    const signal = (this._controller = new AbortController()).signal;
 
    // 首帧时间:记录 play 意图时间
    v.addEventListener('play', () => {
      if (!this.timestamps.playIntent) {
        this.timestamps.playIntent = performance.now();
      }
    }, { signal });
 
    // 首帧时间:playing 事件代表画面真正输出
    v.addEventListener('playing', () => {
      if (!this.timestamps.firstFrame) {
        this.timestamps.firstFrame = performance.now();
        this.metrics.ttff = Math.round(
          this.timestamps.firstFrame - this.timestamps.playIntent
        );
      }
 
      // 如果是从缓冲恢复,记录卡顿时长
      if (this.timestamps.bufferStart > 0) {
        this.metrics.bufferDuration +=
          performance.now() - this.timestamps.bufferStart;
        this.timestamps.bufferStart = 0;
      }
 
      this._playStartTime = performance.now();
      this._startFrameMonitor();
    }, { signal });
 
    // 卡顿检测
    v.addEventListener('waiting', () => {
      this.metrics.bufferCount++;
      this.timestamps.bufferStart = performance.now();
 
      // 记录当前播放时长
      if (this._playStartTime > 0) {
        this.metrics.playDuration +=
          (performance.now() - this._playStartTime) / 1000;
        this._playStartTime = 0;
      }
    }, { signal });
 
    // 暂停/结束
    v.addEventListener('pause', () => this._onStop(), { signal });
    v.addEventListener('ended', () => {
      this._onStop();
      this._report('ended');
    }, { signal });
 
    // 错误
    v.addEventListener('error', () => {
      this.metrics.errorCode = v.error?.code || -1;
      this._report('error');
    }, { signal });
  }
 
  _onStop() {
    if (this._playStartTime > 0) {
      this.metrics.playDuration +=
        (performance.now() - this._playStartTime) / 1000;
      this._playStartTime = 0;
    }
    this._stopFrameMonitor();
  }
 
  _startFrameMonitor() {
    if (!('requestVideoFrameCallback' in HTMLVideoElement.prototype)) return;
 
    const monitor = (now, metadata) => {
      const dropped = metadata.presentedFrames - this._lastFrameCount - 1;
      if (this._lastFrameCount > 0 && dropped > 0) {
        this.metrics.droppedFrames += dropped;
      }
      this._lastFrameCount = metadata.presentedFrames;
 
      if (!this.video.paused && !this.video.ended) {
        this._rvfcId = this.video.requestVideoFrameCallback(monitor);
      }
    };
 
    this._rvfcId = this.video.requestVideoFrameCallback(monitor);
  }
 
  _stopFrameMonitor() {
    if (this._rvfcId) {
      this.video.cancelVideoFrameCallback(this._rvfcId);
      this._rvfcId = null;
    }
  }
 
  // 上报指标
  _report(event) {
    const report = {
      event,
      ...this.metadata,
      ...this.metrics,
      // 完播率计算
      completionRate: this.video.duration > 0
        ? Math.min(1, this.metrics.playDuration / this.video.duration)
        : 0,
      // 卡顿率
      bufferRate: this.metrics.playDuration > 0
        ? this.metrics.bufferDuration / (this.metrics.playDuration * 1000)
        : 0,
      timestamp: Date.now(),
    };
 
    console.log('[VideoMetrics]', report);
 
    // 发送到监控后端(使用 sendBeacon 避免页面卸载时数据丢失)
    if (navigator.sendBeacon) {
      navigator.sendBeacon(
        '/api/video-metrics',
        JSON.stringify(report)
      );
    }
  }
 
  destroy() {
    this._onStop();
    this._controller.abort();
    this._report('destroy');
  }
}
 
// 使用
const collector = new VideoMetricsCollector(videoElement, {
  videoId:  'abc123',
  pageType: 'detail',
  quality:  '1080p',
});

第八部分:性能优化清单

将本章所有优化手段按优先级整理,形成一份可在项目中直接使用的检查清单:

加载层

  • 列表页所有视频设置 preload="none",仅在进入视口后通过 IntersectionObserver 按需加载
  • 详情页主视频使用 preload="metadata"preload="auto",配合 &lt;link rel="preconnect"&gt; 预热连接
  • 对非关键视频添加 fetchpriority="low",避免与首屏资源竞争带宽
  • 短视频信息流采用双播放器实例预加载策略,实现秒开体验

渲染层

  • 正在播放的视频添加 will-change: transform 提升到 GPU 合成层,播放结束后移除
  • 需要逐帧处理(Canvas 叠加、特效、水印)时使用 requestVideoFrameCallback 替代 requestAnimationFrame
  • 避免在视频播放过程中触发大面积重排(修改 layout 相关 CSS 属性)

内存层

  • 销毁视频时必须执行:pause() → 清空 src → 清空 &lt;source&gt; 子元素 → load() → 移除 DOM
  • 使用 AbortController 统一管理事件监听器,防止内存泄漏
  • MSE 场景下主动清理已播放的 SourceBuffer 内容,维持缓冲区在合理范围内
  • 列表页使用播放器实例池,全局不超过 2~3 个活跃 &lt;video&gt; 实例

网络层

  • 读取 navigator.connection.effectiveTypesaveData,根据网络质量选择初始码率
  • 监听 navigator.connectionchange 事件,动态切换视频质量
  • 高要求场景下通过下载探针文件主动测速,精度优于 navigator.connection.downlink

监控层

  • 采集 TTFF(playing 时间 - play 时间)、卡顿率(waiting 累计时长 / 播放时长)、完播率
  • 通过 requestVideoFrameCallbackpresentedFrames 不连续性检测丢帧
  • 使用 navigator.sendBeacon 上报指标,确保页面卸载时数据不丢失

第八章《性能优化》至此完结。本章从加载策略(懒加载、预加载、fetchpriority)、首帧优化(TTFF 链路、双播放器预加载)、渲染性能(GPU 合成层、rVFC 帧处理)、内存管理(正确释放流程、MSE 缓冲区控制)、网络自适应(Network Information API、主动测速)、多路视频(实例池复用、信息流自动播放)到指标监控(TTFF/卡顿率/丢帧率采集),构成了一个完整的前端视频性能工程体系。

第九章将进入自定义播放器专题,覆盖控制栏 UI 架构、进度条拖拽与预览、画质/倍速/字幕切换面板,以及播放器插件化设计模式。需要继续请告诉我。