本文中的代码经过脱敏和简化,用于说明技术方案,不包含内部接口地址、私有组件名称和业务配置。

背景

在开发这个页面之前,官网视频分散在不同产品页、Demo 合集和帮助内容中。随着内容团队持续生产更细分的视频,原有形态逐渐暴露出几个问题:

  • 单个视频缺少稳定、可以被搜索引擎和售前 Agent 引用的详情页地址。
  • 每增加一个视频都依赖研发单独开发页面,发布和维护成本会持续增加。
  • 视频章节、逐字稿、推荐内容和 CTA 缺少统一的数据结构。
  • 直接在首屏挂载播放器和加载大视频,可能与页面关键资源竞争。

因此,这次需求没有只实现一个固定页面,而是建设了一套 CMS 可配置的视频详情页模板。运营人员只需要配置视频、封面、章节、逐字稿和推荐信息,就可以持续发布同类页面。

本文重点讨论其中三个工程问题:

  1. 如何在不增加服务端转码任务的情况下生成进度条缩略图。
  2. 如何利用 CMS 分类信息自动生成相关视频列表。
  3. 如何在首屏成本和首次播放等待之间设计分级加载策略。

整体链路

flowchart LR
  subgraph Editor[运营编辑器]
    File[选择本地视频] --> Metadata[读取视频时长]
    Metadata --> Sample[计算采样间隔]
    Sample --> Canvas[Canvas 合成 Sprite]
    Canvas --> Upload[上传 Sprite 和视频]
    Upload --> Config[回填 URL、时长和 interval]
  end

  subgraph Page[视频详情页]
    Config --> Cover[首屏封面]
    Config --> Preview[进度条缩略图]
    Config --> Player[按需挂载播放器]
    Category[Category ID] --> API[分类内容接口]
    API --> Recommend[相关视频]
  end

生成端和消费端通过一组简单的数据建立联系:Sprite URL、视频时长和抽帧间隔。生成端负责控制帧数和资源体积,播放器只负责时间与图片坐标之间的映射。

一、浏览器端生成视频 Sprite

为什么选择 Sprite

进度条预览常见的实现方式包括:

方案 优点 代价
Hover 时实时 Seek 视频 不需要额外图片 依赖视频解码,响应不稳定,还可能影响播放器状态
每帧生成一张图片 实现直观 图片数量和请求数量会快速增长
服务端 FFmpeg 抽帧 格式和性能更稳定 需要转码任务、状态轮询、存储和失败恢复链路
浏览器 Canvas 合成 Sprite 不增加服务端依赖,只需加载一张图片 处理速度和格式兼容性依赖编辑者浏览器

运营编辑器在上传前已经持有本地 File。因此第一版直接复用浏览器的视频解码和 Canvas 能力,在正式上传视频之前生成 Sprite,避免增加服务端任务。

自适应采样

如果始终每秒抽一帧,长视频会生成非常高的 Canvas。这里采用固定资源上限:默认每秒一帧,但最多生成 180 帧。

const MAX_FRAME_COUNT = 180;
const FRAME_WIDTH = 240;
const FRAME_HEIGHT = 135;
const COLUMN_COUNT = 5;

function ceilTo(value: number, digits = 2) {
  const multiplier = 10 ** digits;
  return Math.ceil(value * multiplier) / multiplier;
}

function resolveSampling(duration: number, preferredInterval = 1) {
  const interval = Math.max(
    preferredInterval,
    ceilTo(duration / MAX_FRAME_COUNT),
  );

  return {
    interval,
    frameCount: Math.min(
      MAX_FRAME_COUNT,
      Math.ceil(duration / interval),
    ),
  };
}

计算公式可以概括为:

interval = max(preferredInterval, ceil(duration / 180, 2))
frameCount = min(180, ceil(duration / interval))

这里对间隔向上取两位小数,而不是普通四舍五入,是为了避免间隔被舍小后帧数重新超过 180。

等待视频完成 Seek

修改 video.currentTime 是异步操作。如果设置时间后立即调用 drawImage,Canvas 可能拿到上一帧,甚至得到黑屏。因此每一次抽帧都需要先监听 seeked,并设置超时和错误处理。

function seekVideo(video: HTMLVideoElement, time: number) {
  return new Promise<void>((resolve, reject) => {
    const timeout = window.setTimeout(() => {
      cleanup();
      reject(new Error('Video seek timeout'));
    }, 10_000);

    const cleanup = () => {
      window.clearTimeout(timeout);
      video.removeEventListener('seeked', handleSeeked);
      video.removeEventListener('error', handleError);
    };

    const handleSeeked = () => {
      cleanup();
      resolve();
    };

    const handleError = () => {
      cleanup();
      reject(new Error('Video seek failed'));
    };

    video.addEventListener('seeked', handleSeeked);
    video.addEventListener('error', handleError);
    video.currentTime = time;
  });
}

监听器必须在设置 currentTime 之前注册,避免极短 Seek 已经完成后才开始监听。首帧使用接近 0 的时间,末帧不取视频时长的精确终点,也可以减少部分浏览器和编码格式的边界问题。

合成 Sprite

每帧统一缩放为 240x135,每行放置 5 帧。播放器只要知道抽帧间隔和列数,就可以反向计算任意时间对应的图片坐标。

async function createSprite(file: File, preferredInterval = 1) {
  const video = document.createElement('video');
  const objectUrl = URL.createObjectURL(file);

  try {
    const duration = await readVideoDuration(video, objectUrl);
    const { interval, frameCount } = resolveSampling(
      duration,
      preferredInterval,
    );
    const rowCount = Math.ceil(frameCount / COLUMN_COUNT);

    const canvas = document.createElement('canvas');
    canvas.width = FRAME_WIDTH * COLUMN_COUNT;
    canvas.height = FRAME_HEIGHT * rowCount;

    const context = canvas.getContext('2d');
    if (!context) throw new Error('Canvas is unavailable');

    for (let index = 0; index < frameCount; index += 1) {
      const column = index % COLUMN_COUNT;
      const row = Math.floor(index / COLUMN_COUNT);
      const time = Math.min(
        index * interval || 0.001,
        Math.max(duration - 0.001, 0),
      );

      await seekVideo(video, time);
      context.drawImage(
        video,
        column * FRAME_WIDTH,
        row * FRAME_HEIGHT,
        FRAME_WIDTH,
        FRAME_HEIGHT,
      );
    }

    const sprite = await canvasToFile(canvas, {
      type: 'image/jpeg',
      quality: 0.8,
    });

    return { duration, interval, sprite };
  } finally {
    URL.revokeObjectURL(objectUrl);
    video.removeAttribute('src');
    video.load();
  }
}

这里使用串行 Seek,而不是并行创建多个 Video。虽然串行处理耗时更长,但只有一个解码器和一个 Canvas,资源上限更容易控制,也不会出现多个异步 Seek 互相覆盖的问题。

播放器如何消费 Sprite

运行时不需要重新解析视频。鼠标在进度条上的横向位置可以先换算成时间:

hoverTime = offsetX / progressWidth * duration
frameIndex = floor(hoverTime / interval)
column = frameIndex % columns
row = floor(frameIndex / columns)

对应的样式计算可以简化为:

function getSpriteStyle(
  spriteUrl: string,
  time: number,
  duration: number,
  interval: number,
) {
  const columns = 5;
  const previewWidth = 186;
  const previewHeight = 103;
  const frameCount = Math.max(1, Math.ceil(duration / interval));
  const rows = Math.ceil(frameCount / columns);
  const frameIndex = Math.min(
    Math.floor(time / interval),
    frameCount - 1,
  );
  const column = frameIndex % columns;
  const row = Math.floor(frameIndex / columns);

  return {
    backgroundImage: `url(${spriteUrl})`,
    backgroundPosition:
      `-${column * previewWidth}px -${row * previewHeight}px`,
    backgroundSize:
      `${columns * previewWidth}px ${rows * previewHeight}px`,
    width: previewWidth,
    height: previewHeight,
  };
}
.video-thumbnail {
  overflow: hidden;
  background-repeat: no-repeat;
  background-image: var(--thumbnail-sprite);
  background-position: var(--thumbnail-position);
  background-size: var(--thumbnail-size);
}

缩略图覆盖层通过 Portal 挂到第三方播放器的进度条节点上。播放器内部 DOM 可能延迟创建或改变尺寸,因此还需要观察节点和布局变化。桌面端监听鼠标移动,移动端复用同一套时间换算逻辑处理 Touch 事件。

资源预算

最多 180 帧、每行 5 帧意味着 Canvas 最多 36 行,最大尺寸为 1200x4860。仅 RGBA 像素缓冲约占 22.3 MiB,不包含视频解码器自身的内存。

对两段实际视频进行离线检查,可以看到自适应间隔避免了 Sprite 随视频时长线性增长:

样本 时长 interval 帧数 Sprite 尺寸 文件大小
A 164.46 秒 1 秒 165 1200x4455 3.01 MiB
B 257.37 秒 1.43 秒 180 1200x4860 3.46 MiB

样本 B 的时长比样本 A 长约 56.5%,但帧数被限制在 180,Sprite 文件只增加约 14.9%。这只是两个离线样本,不代表线上整体分布,也不能证明当前参数已经最优。

二、根据 CMS 分类生成相关视频

问题是什么

视频属于不同合集,因此 Category ID 不能写死在页面组件中。同时,新增视频应该默认获得合理的推荐结果,不能要求运营每发布一个页面都重新维护整个右侧列表。

CMS 原本已经使用 Category 管理同类内容,但该字段属于发布元数据,没有进入页面运行时。最终方案是在发布链路中向页面注入最小的分类信息:Category ID 用于请求,Category Name 用于展示。

整个流程为:

  1. 从页面运行时读取当前 Category ID 和名称。
  2. 请求该分类下的内容列表。
  3. 根据当前 pathname 找到当前视频并将它排除。
  4. 过滤非视频页面并解析标题、封面、时长和标签。
  5. 如果存在人工推荐 ID,则严格按配置顺序展示。
  6. 没有人工配置时,按 Category 权重排序并取前 10 条。

核心筛选逻辑可以写成一个与请求层无关的纯函数:

interface VideoContent {
  id: string;
  path: string;
  weight: number;
  title: string;
  coverUrl: string;
}

function resolveRecommendations(
  items: VideoContent[],
  currentPath: string,
  configuredIds?: string[],
) {
  const candidates = items.filter(
    item =>
      item.path !== currentPath &&
      item.path.startsWith('/video'),
  );

  if (configuredIds?.length) {
    const candidatesById = new Map(
      candidates.map(item => [item.id, item]),
    );

    return configuredIds
      .flatMap(id => candidatesById.get(id) ?? [])
      .slice(0, 10);
  }

  return candidates
    .sort((left, right) => left.weight - right.weight)
    .slice(0, 10);
}

这种设计把“运营控制”和“自动兜底”分开:重点内容可以显式指定,大多数页面则不需要额外配置。

请求失败如何降级

推荐列表不是主播放链路,不能因为接口异常让页面永久处于 Loading。请求层使用 AbortController 设置超时,并在失败后退化为空列表。

useEffect(() => {
  if (!categoryId) return;

  const controller = new AbortController();
  const timeout = window.setTimeout(
    () => controller.abort(),
    8_000,
  );

  async function loadRecommendations() {
    try {
      const response = await fetch(
        buildCategoryUrl(categoryId),
        { signal: controller.signal },
      );
      const payload: unknown = await response.json();
      const items = parseCategoryContents(payload);

      setVideos(
        resolveRecommendations(
          items,
          window.location.pathname,
          configuredIds,
        ),
      );
    } catch {
      setVideos([]);
    } finally {
      window.clearTimeout(timeout);
      setLoading(false);
    }
  }

  void loadRecommendations();

  return () => {
    window.clearTimeout(timeout);
    controller.abort();
  };
}, [categoryId]);

接口响应按 unknown 处理,再逐层校验字段,而不是直接断言为业务类型。这样可以避免某一条内容的扩展字段格式异常导致整个推荐区域崩溃。

当前实现仍有一个需要改进的地方:推荐列表 Loading 会暂时阻塞主内容渲染,最坏可能等待到 8 秒超时。更合理的结构应该是主视频立即渲染,右侧推荐独立显示 Skeleton,并在失败后单独隐藏侧栏。

三、按需挂载与分级预加载

先明确“卡顿”是什么

这里要优化的不是播放过程中因网络抖动产生的 Buffering,而是两个更靠前的问题:

  • 首屏直接挂载播放器会增加脚本执行、DOM、事件监听和媒体请求。
  • 完全等到用户点击后再加载,又会让播放器初始化和视频请求同时发生,增加首次出画面的等待。

因此,方案不是简单选择“预加载”或“不预加载”,而是把播放器挂载和媒体资源预热拆开。

首屏只显示封面

播放器由显式的激活状态控制。未激活时只渲染封面和播放按钮,用户点击或从章节入口跳转后才创建真实播放器。

function VideoPreview({ video }: { video: VideoData }) {
  const [activated, setActivated] = useState(false);

  return activated ? (
    <VideoPlayer
      src={video.url}
      poster={video.coverUrl}
      autoplay
      controls
    />
  ) : (
    <button
      type="button"
      aria-label={`Play ${video.title}`}
      onClick={() => setActivated(true)}
    >
      <img src={video.coverUrl} alt={video.title} />
      <span aria-hidden="true">Play</span>
    </button>
  );
}

这样首屏不会创建播放器实例,也不会因为用户只是浏览页面就立即请求大体积媒体资源。

根据设备和网络选择预热等级

完全懒加载会增加首次起播等待,因此在用户点击前,页面会尝试在空闲阶段使用一个临时 Video 对同 URL 进行预热。

type PreloadMode = 'none' | 'metadata' | 'auto';

function getPreloadMode(): PreloadMode {
  const connection = (
    navigator as Navigator & {
      connection?: {
        saveData?: boolean;
        effectiveType?: string;
      };
    }
  ).connection;

  if (
    connection?.saveData ||
    connection?.effectiveType === 'slow-2g' ||
    connection?.effectiveType === '2g'
  ) {
    return 'none';
  }

  const isMobile = window.matchMedia(
    '(max-width: 599px)',
  ).matches;

  if (isMobile || connection?.effectiveType === '3g') {
    return 'metadata';
  }

  return 'auto';
}

对应策略如下:

环境 策略 目的
Save-Data、slow-2g、2g 不创建预热 Video 尊重省流设置,避免争抢弱网带宽
移动端或 3g preload="metadata" 只获取时长和必要媒体信息
其他桌面网络 preload="auto" 尝试提前进入浏览器媒体缓存

预热被安排在浏览器空闲阶段,不进入首屏关键任务:

type IdleWindow = Window & {
  requestIdleCallback?: (callback: () => void) => number;
  cancelIdleCallback?: (handle: number) => void;
};

useEffect(() => {
  if (!videoUrl || activated) return;

  const mode = getPreloadMode();
  if (mode === 'none') return;

  const idleWindow = window as IdleWindow;
  let preloadVideo: HTMLVideoElement | undefined;

  const warmup = () => {
    preloadVideo = document.createElement('video');
    preloadVideo.preload = mode;
    preloadVideo.src = videoUrl;
    preloadVideo.load();
  };

  const idleId = idleWindow.requestIdleCallback?.(warmup);
  const timer = idleId === undefined
    ? window.setTimeout(warmup, 1_000)
    : undefined;

  return () => {
    if (idleId !== undefined) {
      idleWindow.cancelIdleCallback?.(idleId);
    }
    if (timer !== undefined) {
      window.clearTimeout(timer);
    }

    preloadVideo?.pause();
    preloadVideo?.removeAttribute('src');
    preloadVideo?.load();
  };
}, [videoUrl, activated]);

播放器激活、视频切换或组件卸载时,需要取消空闲任务并清理临时 Video,避免旧视频继续请求或占用解码资源。Sprite 图片也使用类似方式在空闲阶段通过 new Image() 预取,避免第一次 Hover 才开始请求。

这个方案不能保证什么

preload 只是提供给浏览器的提示,浏览器可以根据自身策略忽略它。临时 Video 和正式播放器能否完全复用媒体缓存,也取决于缓存响应头、Range 请求、浏览器实现和底层播放器行为。

Network Information API 并非所有浏览器都支持。不支持时,当前策略会根据视口宽度在桌面 auto 和移动端 metadata 之间选择兜底行为。

因此,更准确的结论是:这套策略降低了首屏播放器成本,并尝试缩短首次起播等待;它没有实现码率自适应、播放中 Stall 恢复或 CDN 调度,不能表述为彻底解决视频播放卡顿。

四、如何验证方案

仅凭代码不能证明优化有效。开发时至少需要通过 Network 和 Performance 面板验证不同场景下的行为。

场景 应观察到的行为
页面刚完成首屏渲染 只有封面,不存在正式播放器实例
开启 Save-Data 或模拟 2G 点击前不主动创建预热 Video
移动端或模拟 3G 只预加载媒体元信息
桌面较好网络 空闲后允许浏览器预热同 URL 视频
第一次 Hover 进度条 复用同一张 Sprite,不逐帧请求图片
视频切换或组件卸载 空闲任务取消,旧 Video 的资源被释放
推荐接口超时或返回异常 页面最终降级为无推荐列表

如果需要量化首次起播,可以记录点击播放到 playing 事件之间的耗时:

let playRequestTime = 0;

playButton.addEventListener('click', () => {
  playRequestTime = performance.now();
});

video.addEventListener('playing', () => {
  const startupLatency = performance.now() - playRequestTime;
  reportMetric('video_startup_latency', startupLatency);
});

对比时需要固定视频、浏览器、缓存状态和网络节流条件,分别记录无预热、metadata 和 auto 三种策略。没有稳定实验数据之前,不应该给出“起播速度提升多少”之类的结论。

五、结语

这个需求其实当时挺紧急的,其实很多细节都还没考虑清楚,数据统计也没来得及去做。但是页面还是正式上线啦哈哈哈

大家也可以去看一看啦:

一个lark官网的视频页~

视频详情页中的进度条缩略图与相关视频列表