基于video.js的移动端h5视频播放实现方法示例
作者:尼欧neo
一、背景
<video>标签是 html5 带来的特性,提供页面中视频播放的功能。然而基础的<video>标签功能单一,样式控件在不同端的表现也不一致,所以日常开发中一般我们会借助第三方封装好的插件来实现。
在众多 h5 视频播放器插件中,video.js 凭借开源、灵活和强大的扩展性,使用较为广泛。
- 官网:https://videojs.com/
- 官方 API 文档:https://docs.videojs.com/
不过 video.js 的基础功能比较简陋,需要依赖各种 api 进行配置,由于官方文档的 api 非常繁杂(左侧目录 500+ 个菜单)且是全英文文档,所以上手比较费劲。
且 video.js 在 pc 端上相对简单问题不大,但在移动端上会有很多问题踩坑点(移动端特性,非插件问题)。
基于此,本文分享下使用 video.js 插件(v8.23.3)在移动端 h5 项目中的实践记录,帮助相关同学快速上手。
二、基本使用
1. 引入依赖
npm i video.js -S npm i @types/video.js -D
2. react中使用
import React, { useEffect, useRef } from 'react';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import Player from 'video.js/dist/types/player';
export default function VideoPlayer() {
const playerRef = useRef<null | Player>(null);
useEffect(() => {
const player = videojs('myVideoJs', {
sources: [{ src: 'xxx.mp4', type: 'video/mp4' }]
});
playerRef.current = player;
return () => {
playerRef.current?.dispose();
playerRef.current = null;
}
}, []);
return (
<div className="video-player">
<video-js id="myVideoJs" class="video-js" />
</div>
);
}3. 常见配置
const options = {
controls: true, // 显示控件栏
autoplay: true, // 自动播放
preload: 'auto', // 视频预加载
fluid: true, // 启用高度自适应模式
loop: true, // 循环播放
muted: true, // 静音
poster: null, // 初始播放前展示海报图
playsinline: true, // 配置ios不自动全屏
}- 需要特别注意的是 playsinline 字段,需要配置为 true 防止 ios 下自动全屏。
- 背景:playsinline 是 HTML5 <video> 标签的一个属性,它的作用是 在支持的设备上,允许视频“内联(inline)”播放,即视频能够在 网页中直接播放,而不会进入全屏模式。在很多移动设备(特别是 iOS 设备)上,默认情况下,HTML5 视频会自动切换到全屏模式播放。
三、扩展问题
1. 标签类型定义
<video-js> 标签是 video.js 插件最新推荐的写法,web component 风格的自定义元素,本质上也是 <video>标签的封装。
<video-js>在项目下 ts 检验会报类型错误,需要显示定义它的类型。

- 解决方式:在项目全局类型声明文件 declare.d.ts 里定义类型
declare namespace JSX {
interface IntrinsicElements {
// 描述 <video-js /> 元素的属性
'video-js': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
// 如果 Video.js 有额外的自定义属性,也可以在这里定义
id?: string;
class?: string;
'data-setup'?: string;
};
}
}2. px单位适配
一般项目会配置将 px 单位自动转换为 rem 单位,比如使用px2rem插件,包括 node_modules 里的依赖包也会自动转换,如果 rootValue 是 100,平时开发都要按两倍的 px 值来写,和一般的像素单位不兼容。
video.js 虽然基本都是用 em 单位,但不排除个别情况使用的 px 的情况,所以建议做排除处理。
- 解决方式:px2rem插件配置黑名单规则,按类名排除 video.js 插件:
px2rem: {
rootValue: 100,
minPixelValue: 2,
selectorBlackList: ['vjs'],
}同时,video-js 标签元素的字体大小默认是 0.1em,可能项目下实际表现是按 html 根字体大小来计算的,不符合预期,会导致视频控件很小。
- 解决方式:覆盖 video-js 标签的样式,修改字体大小,建议值范围 10px - 12px,具体代码写的值按你项目配置的px2rem转换比例来,我这里2倍就写20px。
.video-js {
font-size: 20px;
}3. 多语言配置
在一些场景,例如视频资源加载失败等,video.js 会显示一些文字提示,默认是英文。
其实 video.js 自带有多国语言包,已经翻译好了,但是可能是出于打包大小的考量,默认未做引入,只通过 language 字段配置语言也不会生效,还需要手动配置语言包。

- 解决方式:手动引入语言包,并通过 addLanguage 配置。
import zhCN from 'video.js/dist/lang/zh-CN.json';
import videojs from 'video.js';
videojs.addLanguage('zh-CN', zhCN);
const options = {
language: isZh ? 'zh-CN' : 'en-US',
}
const player = videojs('myVideoJs', options);4. 自定义控件
video.js 的控件栏(controlBar)是自定义实现的,这样能保证不同终端的控件展示一致性,控件区域的支持配置各个小部件的展示。
- 配置示例:
const options = {
controlBar: {
children: [
{ name: 'playToggle' }, // 播放按钮
{ name: 'currentTimeDisplay' }, // 已播时间
{ name: 'progressControl' }, // 播放进度条
{ name: 'durationDisplay' }, // 总时间
{ name: 'volumePanel' }, // 音量控制
{ name: 'playbackRateMenuButton' }, // 倍速播放
{ name: 'fullscreenToggle' }, // 全屏按钮
],
},
playbackRates: [0.5, 1, 1.25, 1.5, 2], // 倍速可选项
}
const player = videojs('myVideoJs', options);异常 case:
有些小部件即使配置了可能也没显示出来,不知道是不是 video.js 的 bug,貌似问题存在很久了。
经排查,发现未显示的小部件 css 样式是 display:none。
- 解决方式:通过样式覆盖手动修改小部件样式。
/* 已播时间 */
.vjs-current-time {
display: block;
}
/* 总时间 */
.vjs-duration {
display: block;
}5. 自定义主题
video.js 官网也提供了几套主题风格,主要是初始播放按钮、控制栏等区块的样式,不过官方主题不适合移动端,我们可以通过自定义主题实现。
主题的使用方式是在组件上添加类名 vjs-theme-xxx 来实现。
自定义主题实际上就是定义 .vjs-theme-xxx 类名下的 css 样式,本质也是样式覆盖,封装为主题主要是方便复用。
结合 styled-component 样式方案的主题实现方式:

- 样式定义:vjs-theme-han.ts
import { css } from 'styled-components';
const vjsThemeHan = css`
.vjs-theme-han {
background: linear-gradient(to right, #fff, #daf3ff);
&.vjs-fullscreen {
background: rgba(0, 0, 0, 1);
z-index: 999999;
}
/* 初始时中间的播放按钮 */
.vjs-big-play-button {
font-size: 4em;
width: 1em;
height: 1em;
line-height: 1em;
border-radius: 1em;
background-color: rgba(0, 0, 0, 30%);
border-width: 0;
margin-top: -0.5em;
margin-left: -0.5em;
}
.vjs-control-bar {
background-color: rgba(43, 51, 63, 0.3);
}
/* 已播时间 */
.vjs-current-time {
display: block;
}
/* 总时间 */
.vjs-duration {
display: block;
}
}
`;
export default vjsThemeHan;- 样式引入:styles.ts
import styled from 'styled-components';
import vjsThemeHan from './themes/vjs-theme-han';
export const StyledComponent = styled.div`
.playerWrap {
${vjsThemeHan};
max-height: 100vh;
.video-js {
overflow: hidden;
width: 100%;
height: auto;
font-size: 20px;
}
}
`;- 添加类名:index.tsx
<div className="playerWrap"> <video-js id="myVideoJs" class="video-js vjs-theme-han" /> </div>
6. 全屏播放
默认情况下,video.js 的全屏也是 js 常规的 全屏 API 实现,即 Element.requestFullscreen(),而全屏模式是浏览器自己实现的行为,在这种全屏模式就无法显示自定义的控件栏,而是显示为浏览器自己的默认控件,在安卓和 ios 下的展示也不同。
要想使用我们的自定义控件栏,就不能使用浏览器全屏 API,不过我们可以通过 css fixed 布局来模拟全屏效果来实现,video.js 的扩展插件 videojs-landscape-fullscreen 就是这个原理。
- 解决方式:引入扩展插件实现。
tnpm i videojs-landscape-fullscreen -S
const options = {
plugins: {
landscapeFullscreen: {
fullscreen: {
enterOnRotate: true,
exitOnRotate: true,
alwaysInLandscapeMode: true,
iOS: true,
},
},
}
}
const player = videojs('myVideoJs', options);异常 case:
在移动端,特别是 ios 下,fixed 定位的表现和其他端不太一致,有些场景 fixed 会表现异常,影响全屏效果,需要特别注意。
例如:在使用 antd-mobile 轮播插件 Swiper 来轮播展示视频时,ios 下全屏效果异常。
- 场景 1:父元素(.ant-swiper)有 postion: relative,且 z-index 层级为 0,子元素的 fixed 层级失效。
- 解决方式:提高此父元素的层级。
.adm-swiper {
z-index: 99;
}- 场景 2:父元素(.adm-swiper-track)有 transform 属性,也会导致子元素的 fixed 层级失效。
- 解决方式:将此父元素的 transform 属性置为 none。
.adm-swiper-horizontal .adm-swiper-track {
transform: none;
}- antd-mobile,Swiper 组件横向轮播时不需要这个 transform 属性。
7. 自动横屏
需求:在点击视频全屏播放时自动横屏展示。
js 本身有提供相关 API 来实现设备横屏:
- 进入横屏:window.screen.orientation?.lock('landscape-primary')
- 退出横屏:window.screen.orientation?.unlock()
这套 API 在安卓下能生效,但是在 ios 下不支持。
- 上述全屏插件 videojs-landscape-fullscreen 的配置字段 alwaysInLandscapeMode 本身也是这个实现原理。
解决方式:使用客户端提供的 jsapi 来实现横屏。
- 钉钉端:
// 进入横屏
dd.rotateScreenView({
showStatusBar: false,
clockwise: true,
});
// 退出横屏
dd.resetScreenView({});监听全屏事件,判断当前状态是全屏时进入横屏,非全屏时退出横屏:
const player = videojs('myVideoJs', options)
player.on('fullscreenchange', () => {
handleFullscreenChange();
});
const handleFullscreenChange = () => {
if (player.isFullscreen()) {
// 全屏状态,调用进入横屏
} else {
// 非全屏状态,调用退出横屏
}
};8. 悬浮窗展示
需求:视频播放器默认在页面顶部,当页面往上滚动,视频容器消失时,让视频模块自动以悬浮窗展示。
画中画模式:
- 视频播放 video 标签默认的画中画模式,和全屏模式类似是浏览器自己控制的行为,在不同端的表现也不一致,无法自定义,不满足需求。
实现思路:
- 悬浮模式,通过将视频组件置为 fixed 定位来实现。
- 需要先给视频组件添加一个静态的父容器。
- 利用 IntersectionObserver 的 API,监听父容器在视窗中是否可见,不可见时视频组件设为 fixed 定位悬浮展示,可见时重置为 static 正常展示。
// 监听视频组件的容器是否进入视窗
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry.isIntersecting) {
setIsFloat(true);
} else if (entry.isIntersecting) {
setIsFloat(false);
}
},
{ threshold: 0.1 },
);
observer.observe(compRef?.current);
return () => {
observer.disconnect();
};
}, []);- 如果悬浮窗需要添加关闭按钮,建议添加两层父元素,里层父元素作为视频组件和关闭按钮元素的公共容器,作为一个整体来改变其 fixed 定位,外层父元素作为被监听的静态元素。
// 外层父容器,position: static;
<div ref={compRef}>
// 里层容器,position: 动态改变 relative / fixed;
<div className="playerWrap">
// 视频组件
<video-js id="myVideoJs" class="video-js vjs-theme-han" />
// 关闭按钮,position: absolute;
<div className="closeWrap"></div>
</div>
</div>父容器高度计算:
- 要实现丝滑的悬浮切换效果,需要保证视频父容器的高度固定,这样在视频组件 fixed 定位时,父容器也能撑起页面高度,保持整体高度不变。
- 一般我们会将视频组件配置 fluid 高度自适应,那么父容器的高度就需要动态计算。
- 动态计算方式:监听视频的初始化加载事件,动态获取视频组件的高度,给父容器设置。
- 实际中发现这个高度不是很精准,可能有微小差距。
- 建议高度设置使用 minHeight 代替 height,避免父容器过小时导致视频显示不全。
const player = videojs('myVideoJs', options);
player.on('loadedmetadata', () => {
setContainerHeight();
});
const setContainerHeight = () => {
const playerWrapEle: any = document.querySelector('.playerWrap');
if (compRef.current && playerWrapEle) {
const height = playerWrapEle.offsetHeight;
compRef.current.style.minHeight = `${height}px`;
}
};控件栏小部件动态隐藏:
- 悬浮窗模式下,宽度较小,控制栏小部件可能显示不下,一些非必要的小部件可以动态隐藏掉。
- 通过 player.controlBar.xxx.hide() 实现动态隐藏。
- 例如,动态隐藏 已播时间、总时间、播放速度。
useEffect(() => {
if (!playerRef.current) return;
const showOrHide = isFloat ? 'hide' : 'show';
['currentTimeDisplay', 'durationDisplay', 'playbackRateMenuButton'].forEach((key) => {
playerRef.current?.controlBar?.[key]?.[showOrHide]?.();
});
}, [isFloat]);9. 自动暂停
需求:页面不可见时自动暂停视频,页面恢复可见时保持暂停状态。
一般情况下即使不做额外处理,在移动端页面隐藏时视频也会自动暂停,但页面重新显示时也会自动恢复播放,重点是不想让他恢复播放。
在移动端多开 webview 的场景下,做回退操作,在回退到视频页 webview 时突然播放视频,会影响用户体验。
核心主要是判断页面的显隐状态。
解决方式:
- 监听 document 的 visibilitychange 事件。
- document.addEventListener('visibilitychange', handleVisibilityChange, false)
- 根据 document.visibilityState 值来判断当前页面所处状态:
- visible:显示
- hidden:隐藏
- 页面隐藏时调用 player.pause() 暂停视频。
异常 case:
- 上述方式在 ios 下有效,但是安卓下不生效。
- 通过调试发现,页面隐藏时正常的 js 都能执行,而 player.pause() 像是没有执行一样。
- 原因猜测:
- 安卓页面隐藏时,WebView 可能冻结或暂停 UI 渲染线程,以减少后台消耗,而视频的渲染控制会可能被丢弃,导致 player.pause() 无法生效。
- 页面恢复时,WebView 恢复前台状态,可能重新调度视频播放。
- 解决方式:
- 监听到页面恢复显示时,异步调用视频暂停方法。
- 直接调用也不生效,可能页面还未完全恢复,通过setTimeout 包裹为异步执行,才能达到预期效果。
// 页面恢复显示时
setTimeout(() => {
player.pause();
}, 1);10. 失败重试机制
需求:视频加载失败时自动重新加载。
利用 video.js 自带的插件扩展方式,手写个失败重试插件来引入。
实现要点:
- 支持配置最大重试次数,避免一直失败死循环。
- 监听加载失败事件:player.on('error', function () {})
- 获取当前视频播放源:player.currentSrc()。
- 重新加载方法:player.load()
对于切片视频,会存在中途加载失败的情况,重试时应该从中断位置继续播放。
支持断点续播:
- 失败时需要记录中断时的时间点。
- 获取当前播放进度时间:player.currentTime()
- 恢复时指定时间点播放。
- 需要在视频数据加载完后才能指定时间播放,监听 loadeddata 事件。
- 设置在指定时间点播放视频:player.currentTime(time)
插件代码:
- 实现插件:
/**
* video.js 视频加载失败重试插件
*/
import videojs from 'video.js';
export const PLUGIN_NAME = 'retryOnError';
export type RetryOnErrorOptions = {
maxRetryCount?: number; // 最大重试次数
};
const defaultOptions: RetryOnErrorOptions = {
maxRetryCount: 3,
};
const registerPlugin = videojs.registerPlugin || videojs.plugin;
const mergeOptions = videojs.obj.merge || videojs.mergeOptions;
const retryOnError = function (this: any, options: any) {
const player = this as any;
const { maxRetryCount } = mergeOptions(defaultOptions, options);
let retryCount = 0;
player.on('error', function () {
if (retryCount >= maxRetryCount) {
console.warn('视频源加载失败,已达最大重试次数');
return;
}
const curSrc = player.currentSrc?.();
if (!curSrc) {
console.warn('视频源为空', curSrc);
return;
}
const curTime = player.currentTime?.() || 0;
retryCount++;
// console.log(`视频源加载失败,开始第${retryCount}次重试`);
player.load();
player.on('loadeddata', function () {
if (retryCount >= maxRetryCount) {
return;
}
retryCount = 0;
if (curTime) {
player.currentTime(curTime);
player.play();
}
});
});
};
registerPlugin(PLUGIN_NAME, retryOnError);
export default retryOnError;- 使用插件:
import './plugins/videojs-retry-on-error';
const options = {
plugins: {
retryOnError: {
maxRetryCount: 3,
},
},
}
const player = videojs('myVideoJs', options);11. 非wifi网络提醒
需求:判断用户所处网络状态,wifi 下自动播放,非 wifi 网络下不自动播放,点击播放时弹窗提醒。
视频对网络流量的损耗比较大,在流量敏感的场景,有必要添加网络判断,以免引发客户投诉。
核心要点:
- 判断用户所处的网络环境,区分 wifi 和 非wifi 场景。
- video.js 初始化后会在中间展示一个大播放键,不自动播放时可手动点击,需要拦截其点击事件,然后弹窗提醒。
判断网络环境:
- 浏览器有提供 API 来判断网络状态:window.navigator.connection.effectiveType,不过在 ios 下不生效,只能放弃。
- 要稳定判断,还是得依靠客户端提供的 API。
- 钉钉端:
dd.getWifiStatus({}).then((res: any) => {
const isWifi = res.status === 1;
})拦截大播放键的点击事件:
- video.js 的这个大播放键并没有暴漏相关 api 给我们,所以我们没法拦截它的点击事件。
- 当然我们可以通过隐藏 video.js 自带的大播放键,然后手动写一个播放按钮并添加事件来实现。不过比较麻烦,需要自己实现播放按钮样式,并监听 ready 事件然后动态插入,不建议。
- 更简单的处理方式:偷梁换柱。
- 通过 js 原生的 oldElement.cloneNode(true) 方法来克隆一个大播放键 newElement,入参传 true 表示只克隆 dom 不克隆绑定事件;
- 然后通过 oldElement.parentNode.replaceChild(newElement, oldElement) 来替换掉原播放键。
- 再给新的大播放键 newElement 添加自定义的点击事件即可。
封装为 video.js 插件:
- 插件代码:
/**
* video.js 非wifi网络播放提醒插件
*/
import videojs from 'video.js';
import { Dialog, Checkbox } from 'antd-mobile';
import React from 'react';
import { localCache } from '@alipay/fish-utils';
import native from '@/utils/native';
import $i18n from '@/i18n';
export const PLUGIN_NAME = 'wifiWarning';
export type WifiWarningOptions = {
open?: boolean; // 是否开启
appName: string; // 项目应用名(用于 localStorage 存储时 key 的前缀)
};
const defaultOptions: WifiWarningOptions = {
open: true,
appName: window.yuyanMonitor?.userConfig?.yuyanId || 'appName',
};
const registerPlugin = videojs.registerPlugin || videojs.plugin;
const mergeOptions = videojs.obj.merge || videojs.mergeOptions;
let isDontShow = false;
const confirmWifi = async (): Promise<boolean> => {
const contentTips = $i18n.get({
id: 'wifiWarningTips',
dm: '您当前未连接 WiFi,将会消耗数据流量,是否继续?',
});
const dontShowTips = $i18n.get({
id: 'wifiDontShowTips',
dm: '30日内不再提醒',
});
const confirmText = $i18n.get({
id: 'continueText',
dm: '继续',
});
const cancelText = $i18n.get({
id: 'cancelText',
dm: '取消',
});
const renderContent = () => {
const onCheckbocChange = (value: boolean) => {
isDontShow = value;
};
return (
<div className="confirmContent">
<div className="tipsWrap">{contentTips}</div>
<div className="formWrap" style={{ paddingTop: '6px' }}>
<Checkbox
style={{
'--icon-size': '16px',
'--font-size': '14px',
'--gap': '6px',
}}
onChange={onCheckbocChange}
>
<span style={{ color: '#999' }}>{dontShowTips}</span>
</Checkbox>
</div>
</div>
);
};
return Dialog.confirm({
content: renderContent(),
confirmText,
cancelText,
});
};
const storage = {
setParams(this: any, mergedOpt: any) {
const { appName } = mergedOpt;
const key = `${appName}-wifi-warning`;
const expires = 30 * 24 * 60 * 60 * 1000;
this.key = key;
this.value = 'warned';
this.expires = expires;
},
getVal(this: any) {
return localCache.get(this.key);
},
setVal(this: any) {
localCache.set(this.key, this.value, { expires: this.expires });
},
};
const handleBigPlayButton = (player: any, mergedOpt: any) => {
const { open } = mergedOpt;
storage.setParams(mergedOpt);
const oldEle: HTMLElement = document.querySelector('.vjs-big-play-button');
if (oldEle) {
const myBigPlayButton = oldEle.cloneNode(true);
oldEle.parentNode.replaceChild(myBigPlayButton, oldEle);
myBigPlayButton.addEventListener('click', () => {
if (open && !storage.getVal()) {
native.getIsWifi().then((isWifi) => {
if (!isWifi) {
confirmWifi().then((confirmed) => {
if (confirmed) {
player.play();
if (isDontShow) {
storage.setVal();
}
}
});
}
});
} else {
player.play();
}
});
}
};
const wifiWarning = function (this: any, options: any) {
const player = this as any;
const mergedOpt = mergeOptions(defaultOptions, options);
const { open } = mergedOpt;
if (!open) {
return;
}
native.getIsWifi().then((isWifi) => {
if (!isWifi) {
player.autoplay(false);
}
});
player.on('ready', () => {
handleBigPlayButton(player, mergedOpt);
});
};
registerPlugin(PLUGIN_NAME, wifiWarning);
export default wifiWarning;- 使用方式:
import './plugins/videojs-wifi-warning.ts';
const options = {
plugins: {
wifiWarning: {
open: true,
appName: 'yourAppName',
},
},
}
const player = videojs('myVideoJs', options);12. 视频总大小
判断视频总的大小有多少 m,可以用在 4g 网络时告诉用户预计会消耗多少流量,也可以用于清晰度切换时展示预计消耗的流量等等。
判断思路:
如果视频是一次性加载完的单个视频
可以判断资源请求时 response 响应头里的 Content-Length 字段,直接获取。
如果是切片视频。
切片视频,无法直接通过 Content-Length 获取。
不过切片视频资源一般在 response 响应头里添加 Content-Range 字段,里面有总大小的数据,需要手动截取下。
Content-Range 字段值示例:bytes 425984-52949424/52949425
我们可以手动请求一个字节的切片数据,解析 Content-Range 获取视频总大小。
async function getVideoFileSizeWithRange(url) {
try {
// 使用 Range 请求加载第一个字节
const response = await fetch(url, {
method: 'HEAD',
headers: { Range: 'bytes=0-1' }, // 请求文件的第一部分
});
const contentRange = response.headers.get('Content-Range');
if (contentRange) {
// 从 Content-Range 中提取总大小
const totalSizeMatch = contentRange.match(//(\d+)$/);
if (totalSizeMatch) {
const totalSize = parseInt(totalSizeMatch[1], 10);
console.log('视频总大小(字节):', totalSize);
return totalSize;
}
}
console.log('无法获取总大小');
return null;
} catch (error) {
console.error('获取视频大小失败:', error);
return null;
}
}四、结语
总的来说,video.js 是一个非常不错的社区插件,功能丰富,通过一定的配置足以满足我们的日常需求。
利用大模型:
虽然 video.js 的 API 非常繁杂,但是我们有大模型啊,很多功能和实现可以直接去问大模型,它会给你推荐各种实现方案,并提供相关的 API 代码,不用我们去翻官方文档。
这篇文章里的实现,至少有 50% 是大模型提供的思路或灵感,代码很多也是基于大模型生成后修改优化的,省下大量脑细胞~
到此这篇关于基于video.js的移动端h5视频播放实现方法的文章就介绍到这了,更多相关video.js移动端h5视频播放内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

