一、技术选型:何时放弃原生 <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.fullscreenElementiOS:检测 video.webkitDisplayingFullscreen |
video_stop | 退出全屏导致的播放停止 | pause 事件触发,且已退出全屏 | 同上,全屏状态取反 |
video_complete | 视频自然播放结束 | pause 事件触发且 video.ended === true | 无显著差异 |
video_fullscreen_enter | 进入全屏播放 | 调用全屏 API 成功后触发 | 安卓:监听 fullscreenchange 事件iOS: webkitEnterFullscreen 调用成功即触发(无原生事件,需业务层标记) |
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 差异显著,需封装适配层:
| 平台 | 进入全屏 | 退出全屏 | 状态检测 | 特殊注意 |
|---|---|---|---|---|
| 标准 API | requestFullscreen() | exitFullscreen() | fullscreenElement | iOS Safari 不支持 |
| iOS Safari | webkitEnterFullscreen() | 系统控制 | 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-shadow、filter等视觉效果不生效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 {};