Hybrid 架构 H5 视频播放:自动播放、全屏适配与埋点实践

5 阅读8分钟

一、技术选型:何时放弃原生 <video>

在 C 端产品的 H5 页面中集成视频能力时,我们并非总是直接使用 <video> 标签。某些场景下,基于 Canvas 的自研播放器反而是更优解。这一决策通常源于原生视频在以下三个维度的不可控性

UI 层面:播放按钮的位置、进度条样式、全屏转场动画的缓动曲线,乃至加载指示器的设计,均受制于浏览器内核的实现。若产品对视觉一致性有严苛要求,原生控件往往难以满足。

行为层面:点击视频区域触发的是播放/暂停还是全屏?用户滑动调节音量时是否会与页面手势冲突?这些交互逻辑没有跨平台标准,只有各平台特有的"惯例",调试成本极高。

状态层面:播放状态的程序化控制常遭遇权限卡点,尤其在自动播放策略收紧的背景下,业务逻辑的预测性大打折扣。

因此,对于教程引导、流程演示等以视觉传达为核心的短内容,Canvas 方案值得考虑——这类场景通常无需音频,也避开了音频焦点冲突问题。反之,长视频或必须带声音的内容,仍建议采用原生 <video>,但需配套完善的权限降级策略。

Canvas 渲染陷阱:数据加载完成前 Canvas 呈透明状态,若外层未设置背景色,在部分 WebView 中会透出黑色底色。建议预置与视频首帧一致的占位图或背景色,避免视觉闪烁。

二、原生 <video> 的五大难题

2.1 自动播放策略与优雅降级

浏览器的自动播放策略(Autoplay Policy)是 H5 视频的首要技术障碍:

平台策略要点
iOS Safari允许静音自动播放;有声播放需用户手势触发
Chrome依赖媒体参与度(MEI)评分,新用户受限明显
其他平台可能存在额外限制,需实测验证

推荐降级路径:优先尝试有声播放 → 失败则静音重试 → 仍失败则等待用户点击触发。

具体实现可参考 MDN 文档:HTMLMediaElement.play()

/**
 * 尝试播放视频,内置自动播放策略的降级逻辑
 * @param elem - video 元素
 * @param isRetry - 是否为重试调用(内部使用)
 */
function playVideo(elem: HTMLVideoElement, isRetry = false): Promise<void> {
  return new Promise((resolve, reject) => {
    const playPromise = elem.play();

    // 旧版浏览器可能返回 undefined
    if (!playPromise) {
      resolve();
      return;
    }

    playPromise.then(resolve).catch((err) => {
      if (isRetry) {
        console.error("播放失败,需用户交互触发:", err);
        reject(err);
        return;
      }

      // 首次失败,尝试静音降级
      console.log("自动播放受限,降级为静音播放");
      elem.muted = true;
      playVideo(elem, true).then(resolve).catch(reject);
    });
  });
}

2.2 埋点体系:从浏览器事件到业务语义

浏览器原生事件与业务状态之间存在语义鸿沟,且各平台实现机制迥异,同一事件的监听逻辑往往需要平台适配:

事件名触发时机识别逻辑平台差异说明
video_impression组件挂载并进入视口Intersection Observer 或页面加载后触发无显著差异
video_play首次点击封面开始播放用户点击后调用 play()currentTime < 0.3无显著差异
video_resume暂停后继续播放play 事件触发且 currentTime > 0.3无显著差异
video_pause全屏状态下用户主动暂停pause 事件触发,且处于全屏状态安卓:检测 document.fullscreenElement
iOS:检测 video.webkitDisplayingFullscreen
video_stop退出全屏导致的播放停止pause 事件触发,且已退出全屏同上,全屏状态取反
video_complete视频自然播放结束pause 事件触发且 video.ended === true无显著差异
video_fullscreen_enter进入全屏播放调用全屏 API 成功后触发安卓:监听 fullscreenchange 事件
iOSwebkitEnterFullscreen 调用成功即触发(无原生事件,需业务层标记)
video_fullscreen_exit退出全屏从全屏状态退出安卓fullscreenchange 事件 + fullscreenElement 为空
iOS:监听 webkitendfullscreen(私有事件)或轮询 webkitDisplayingFullscreen

建议:事件监听回调使用传统 function 函数而非箭头函数,以便通过 this 直接获取 video DOM 引用。

状态推断实现

// 播放事件:区分首次播放与恢复播放
video.addEventListener("play", function () {
  // currentTime > 0.5 视为恢复播放(规避初始缓冲误差)
  const eventType = this.currentTime > 0.5 ? "video_resume" : "video_play";
  track(eventType, getPayload(this));
});

// 暂停事件:多维度推断业务语义
video.addEventListener("pause", function () {
  if (this.ended) {
    track("video_complete", getPayload(this));
    return;
  }

  // 检测全屏状态:webkitDisplayingFullscreen 为 iOS 私有属性
  const isExitingFullscreen =
    !document.fullscreenElement && !this.webkitDisplayingFullscreen;

  track(isExitingFullscreen ? "video_stop" : "video_pause", getPayload(this));
});

2.3 全屏 API 的兼容性治理

各平台全屏 API 差异显著,需封装适配层:

平台进入全屏退出全屏状态检测特殊注意
标准 APIrequestFullscreen()exitFullscreen()fullscreenElementiOS Safari 不支持
iOS SafariwebkitEnterFullscreen()系统控制webkitDisplayingFullscreen需预检 webkitSupportsFullscreen

跨平台全屏封装

/**
 * 进入全屏的跨平台封装
 */
function enterFullscreen(video: HTMLVideoElement): void {
  if (video.webkitSupportsFullscreen) {
    // iOS Safari 标准视频全屏(非页面全屏)
    video.webkitEnterFullscreen?.();
  } else if (video.requestFullscreen) {
    // 标准 Fullscreen API
    video.requestFullscreen({ navigationUI: "auto" });
  } else {
    // 降级:CSS 模拟全屏(固定定位覆盖全屏)
    video.classList.add("fullscreen-fallback");
  }
}

/**
 * 检测视频是否处于全屏状态
 */
function isVideoFullscreen(video: HTMLVideoElement): boolean {
  return (
    document.fullscreenElement === video ||
    document.webkitFullscreenElement === video ||
    video.webkitDisplayingFullscreen ||
    video.classList.contains("fullscreen-fallback")
  );
}

2.4 封面图的工程权衡

poster 属性作为原生方案,具备零维护成本、性能最优、切换流畅等优势。但以下三类场景需考虑自定义封面实现:

场景一:流程劫持需求 当业务要求点击封面后不直接播放,而是插入前置逻辑(如横屏观看提示、登录态校验、权限申请)时,poster 的默认点击行为无法拦截。自定义封面(独立 <img><div>)可实现完全可控的事件派发。

场景二:状态层叠需求 视频进入特定状态后需覆盖原生控制栏的场景,例如自动全屏播放后用户退出全屏,此时视频暂停且原生控制栏显现,业务层希望展示自定义的"继续播放"按钮或引导操作。poster 仅在未播放状态存在,无法承担中途遮罩职责。

场景三:样式约束突破 poster 作为视频元素的内部渲染层,不受完整 CSS 控制

  • border-radius 无法作用于 poster,容器圆角时封面会溢出
  • box-shadowfilter 等视觉效果不生效
  • mix-blend-mode 等进阶样式不支持

当设计系统要求封面与容器视觉完全一致(如统一 16px 圆角卡片)时,自定义封面是唯一选择。

自定义封面方案

// 结构:封面层 + 视频层(初始隐藏)
const VideoPlayer = () => {
  const [showVideo, setShowVideo] = useState(false);
  const videoRef = useRef<HTMLVideoElement>(null);

  const handlePosterClick = async () => {
    const video = videoRef.current;
    if (!video) return;

    setShowVideo(true);

    try {
      await playVideo(video);
      enterFullscreen(video);
    } catch (err) {
      // 播放失败,回退到封面状态
      setShowVideo(false);
    }
  };

  return (
    <div className="video-container">
      {!showVideo && (
        <img
          className="video-poster"
          src="/assets/poster.jpg"
          onClick={handlePosterClick}
          alt="点击播放"
        />
      )}
      <video
        ref={videoRef}
        src="/assets/video.mp4"
        style={{ display: showVideo ? "block" : "none" }}
        muted
        preload="auto"
      />
    </div>
  );
};

2.5 安卓端的退出交互兜底

安卓 WebView 的系统全屏退出按钮存在体验缺陷:尺寸小、位置固定、图标语义模糊,用户易产生困惑。建议在安卓端为全屏视频补充滑动手势退出的兜底交互:

  • 左滑退出:契合安卓系统"返回"的横向手势惯性
  • 上滑退出:模拟 iOS 底部上滑返回桌面的肌肉记忆

实现时需设置合理阈值(建议滑动距离超过 100px 触发),避免误操作。

三、完整实现案例

以下是一个基于 Taro + React 的工程化实现,整合了前述各项策略:

import { View } from '@tarojs/components';
import './index.scss';
import { memo, useCallback, useEffect, useRef } from 'react';
import { getDeviceType } from '@/utils/device';
import { LogAction } from '@/firebase/logEvent';
import { CustomEventParamMap } from '@/firebase/type';
import { getTouchPosition } from '@/utils/gesture';

const deviceType = getDeviceType();
export const videoHref = 'x.mp4';

function isVideoInFullscreen() {
  return document.fullscreenElement?.nodeName === 'VIDEO';
}

function playVideo(elem: HTMLVideoElement, isFailed = false) {
  return new Promise((res, rej) => {
    const playPromise = elem.play();
    if (playPromise !== undefined) {
      playPromise.then(res, err => {
        if (isFailed) {
          rej(err);
          return;
        }
        console.log('尝试静音播放');
        elem.muted = true;
        playVideo(elem, true).then(res).catch(rej);
      });
    }
  });
}

const toFix2 = (num: number): number => Math.round(num * 100) / 100;

function getReportPayload(
  dom: HTMLVideoElement
): CustomEventParamMap['education_video_play'] {
  // 未请求完成时 duration 可能为 Infinity
  const video_total_duration = toFix2(
      dom.duration !== Infinity ? dom.duration : NaN || 60
    ),
    video_played_duration = toFix2(dom.currentTime);

  return {
    video_id: 'education_intro_v1',
    video_played_duration,
    video_total_duration,
    video_played_ratio: toFix2(video_played_duration / video_total_duration),
  };
}

export const EducationVideo = memo(function EducationVideo() {
  const videoRef = useRef<HTMLVideoElement>(null);
  const isClickedRef = useRef(false);
  const isPlaying = useRef(false);

  useEffect(() => {
    LogAction('education_video_impression');
  }, []);

  useEffect(() => {
    const videoDom = videoRef.current;
    if (!videoDom) return;

    const abortController = new AbortController();
    const signal = { signal: abortController.signal };
    let startX = 0, startY = 0;

    if (deviceType !== 'ios') {
      // iOS 无法监听该事件
      document.addEventListener('fullscreenchange', () => {
        if (!document.fullscreenElement) {
          videoDom.pause();
        }
      }, signal);

      videoDom.addEventListener('touchstart', (ev) => {
        const { x, y } = getTouchPosition(ev);
        startX = x;
        startY = y;
      }, signal);

      videoDom.addEventListener('touchend', (ev) => {
        const { x, y } = getTouchPosition(ev);
        if (Math.abs(startX - x) > 100 || Math.abs(startY - y) > 100) {
          document.exitFullscreen();
        }
      }, signal);
    }

    videoDom.addEventListener('play', function () {
      if (this.currentTime > 0.3) {
        LogAction('education_video_resume', getReportPayload(this));
      }
    }, signal);

    videoDom.addEventListener('pause', function () {
      isPlaying.current = false;
      if (!this.ended) {
        if (videoDom?.webkitDisplayingFullscreen || document.fullscreenElement) {
          LogAction('education_video_pause', getReportPayload(this));
        } else {
          LogAction('education_video_stop', getReportPayload(this));
        }
      } else {
        // 播放完成(未设置 loop 属性)
        LogAction('education_video_complete', getReportPayload(this));
      }
    }, signal);

    return () => abortController.abort();
  }, []);

  const onPosterClick = useCallback(() => {
    const elem = videoRef.current!;
    if (isPlaying.current || elem.webkitDisplayingFullscreen || isVideoInFullscreen()) {
      return;
    }

    isPlaying.current = true;
    elem.style.display = 'block';

    playVideo(elem)
      .then(() => {
        if (deviceType === 'ios') {
          if (elem.webkitSupportsFullscreen) {
            elem.webkitEnterFullscreen?.();
          } else {
            elem.webkitRequestFullscreen?.();
          }
        } else {
          elem.requestFullscreen({ navigationUI: 'auto' });
        }

        if (!isClickedRef.current) {
          LogAction('education_video_play', getReportPayload(elem));
          isClickedRef.current = true;
        }
      })
      .catch((rej) => {
        console.error('播放异常:', rej);
        // 保留 catch 块避免 unhandled rejection
      });
  }, []);

  return (
    <View className="video-ctn">
      <img
        className="video-poster"
        src={require('x.jpg')}
        onClick={onPosterClick}
        alt="视频封面"
      />
      <View className="video-view">
        <video
          muted
          ref={videoRef}
          src={videoHref}
          controls={true}
          controlsList="nodownload"
          disablePictureInPicture
          disableRemotePlayback
          preload="auto"
        />
      </View>
    </View>
  );
});

附录:WebKit 扩展 API 的 TypeScript 类型补全

Safari / WebKit 提供了一系列视频相关的扩展 API,但这些属性不属于标准 DOM 规范,因此不会出现在 TypeScript 自带的 lib.dom.d.ts 中。通过 声明合并(Declaration Merging),可在项目中补充类型定义,获得完整的类型提示与检查支持:

// types/webkit-video.d.ts
declare global {
  interface HTMLVideoElement {
    /** 允许内联播放(iOS Safari 常用) */
    webkitPlaysInline?: boolean;

    /** 是否处于全屏显示状态(部分版本存在) */
    webkitDisplayingFullscreen?: boolean;

    /** 进入/退出全屏(不同版本存在差异) */
    webkitEnterFullscreen?: () => void;
    webkitExitFullscreen?: () => void;

    /** 媒体组设置(用于 AirPlay/媒体选择等) */
    webkitMediaGroup?: string;

    /** AirPlay 相关 */
    webkitShowPlaybackTargetPicker?: () => void;
    webkitCurrentPlaybackTargetIsWireless?: boolean;

    /** 展示模式(inline / fullscreen / picture-in-picture) */
    webkitPresentationMode?: "inline" | "fullscreen" | "picture-in-picture";
    webkitSetPresentationMode?: (
      mode: "inline" | "fullscreen" | "picture-in-picture",
    ) => void;
  }
}

export {};