前言
最近在业务代码中看到了 react-html5video这个组件,发现原来的代码拿到 ref
之后,还是调用了很多 h5 video 标签的原生方法,比较好奇这个组件到底干了啥,于是进去看了下代码,发现有点东西,这里总结出来和大家分享一下。
解析
了解一个 npm 包的第一步当然是去看在 npm 网站上提供的 readme 了。这里让人有点震惊的是,这个包的最后一次更新是在 6 年前了,而且这个包已经不再维护了(2024 年 4 月),但是每周的下载量仍然稳定在 7k 左右。
看看文档
用于 HTML5 video 标签的的可定制 HoC(高阶组件),支持 i18n 和 a11y,支持定制 video 标签的 controls。
基本用法 : 使用此组件的最简单方法是使用所提供的默认播放器。它的工作方式与普通的 HTML5 video 标签相同,除了 controls 之外,它支持了所有 HTML5 video 标签的属性。 controls 部分的 UI 被重写了,具体的组件如下面代码中所示:
import { DefaultPlayer as Video } from 'react-html5video';
import 'react-html5video/dist/styles.css';
render() {
return (
<Video autoPlay loop muted
controls={['PlayPause', 'Seek', 'Time', 'Volume', 'Fullscreen']}
poster="http://sourceposter.jpg"
onCanPlayThrough={() => {
// Do stuff
}}>
<source src="http://sourcefile.webm" type="video/webm" />
<track label="English" kind="subtitles" srcLang="en" src="http://source.vtt" default />
</Video>
);
}
啊,实际业务代码中的用法比这个还要简单,🤔 只是用了 Video 组件,用到的 props 也就是 className 额外加一些样式,src 视频地址,muted 是否静音播放,还有就是通过 ref 去拿了 video 标签的 dom。
a11y 和 i18n : 本组件提供的定制 controls 部分使用了
<button>
标签和<input type="range">
标签,也就是说当聚焦这些标签的时候,一些基本的键盘事件会被响应。比如说,你可以按空格键,静音,播放,全屏; 聚焦到进度条的时候,也可以通过方向键前进后退。组件使用了aria-label
属性,支持通过屏幕阅读器交互。可以使用如下方式改变控制组件的 aria-label 的值<Video copy={{ key: value }}>
copy 默认的英文可以在这里找到 。高级用法:如果你想要一个定制的播放器,你需要使用高阶组件。高阶组件把 html5 video 标签的所有属性和组件包裹的第一个子 video 标签并链接到 React 组件上。像下面这样的用法。
import videoConnect from "react-html5video";
const MyVideoPlayer = ({ video, videoEl, children, ...restProps }) => (
<div>
<video {...restProps}>{children}</video>
<p>
Here are the video properties for the above HTML5 video:
{JSON.stringify(video)}
</p>
<a
href="#"
onClick={e => {
e.preventDefault();
// You can do what you like with the HTMLMediaElement DOM element also.
videoEl.pause();
}}
>
Pause video
</a>
</div>
);
export default videoConnect(MyVideoPlayer);
上面的代码只是简单打印了 MyVideoPlayer 组件中的 video 标签的属性。现在属性和 video 标签的 dom 在组件内都是可用的,你可以使用它们定制自己的新的 controls 组件。一个详细的例子就是我们提供的 default player。
看看代码
读完 readme 发现,整体上的用法还是比较简单的。主要提供的能力应该有 3 点:
- 把 video 标签封装成了一个 React 组件,也就是 Default Player。
- 提供了国际化和可访问性的支持。
- 提供了定制播放器控制组件的能力,但是要通过高阶组件来实现,通过 videoConnect 方法把 video 标签上的属性注入到定制的 react 组件中。具体怎么做到的呢,除了高阶组件还有其他方法吗?看看代码。
直接 fork 源代码的仓库或者把代码下载到本地都行。直接 npm install
npm run start
一套搞下来,发现有报错 :< 。
看一下 package.json,webpack 停留在 1.13 版本,webpack 配置的写法还停留在非常早期的阶段,幸亏有 chatGpt 在,不然有的文档要查了。实际最后的问题还是 webpack 相关的配置问题,具体不再详细介绍了,可以参考这里的修改。
书归正传,我们来看一下这个组件究竟干了啥。
入口文件还是比较简单,比较规整,导出了一堆组件,还有一些函数,包括上面文档里面提到的有点奇怪的 videoConnect
函数。
/** @file 整个 npm 包的入口,导出了一堆东西 */
import videoConnect from "./video/video";
// 一堆工具函数,可以直接传入 h5元素让外部调用,本质上是在封装 h5 video 的 DOM 操作
import * as apiHelpers from "./video/api";
// 这里的 DefaultPlayer 已经是 connect 之后的了
import DefaultPlayer, {
Time,
Seek,
Volume,
Captions,
PlayPause,
Fullscreen,
Overlay,
} from "./DefaultPlayer/DefaultPlayer";
export {
videoConnect as default,
apiHelpers,
DefaultPlayer,
Time,
Seek,
Volume,
Captions,
PlayPause,
Fullscreen,
Overlay,
};
再接着看 DefaultPlayer 文件,这里其实就看到了实际的 video
标签,除此之外还有一些其他组件,比如 Overlay, Seek, PlayPause
等等。这里需要注意的是,DefaultPlayer
组件在导出之前通过 videoConnect 函数调用包裹了一下,类似于 redux 中的 connect 函数的写法。可以看到这里 videoConnect 的函数调用传入了三个参数:组件;一个函数,返回了 video 对象,里面挂了各种各样的状态;一个函数,返回的对象里面挂了一堆方法。
import React from "react";
import PropTypes from "prop-types";
// 高阶组件
import videoConnect from "./../video/video";
import copy from "./copy";
// 工具函数,封装 H5 video 的 dom 操作
import {
setVolume,
showTrack,
toggleTracks,
toggleMute,
togglePause,
setCurrentTime,
toggleFullscreen,
getPercentagePlayed,
getPercentageBuffered,
} from "./../video/api";
import styles from "./DefaultPlayer.css";
// 定制 controls 组件
import Time from "./Time/Time";
import Seek from "./Seek/Seek";
import Volume from "./Volume/Volume";
import Captions from "./Captions/Captions";
import PlayPause from "./PlayPause/PlayPause";
import Fullscreen from "./Fullscreen/Fullscreen";
import Overlay from "./Overlay/Overlay";
const DefaultPlayer = ({
// 各种各样的 props
className,
style,
video, // 对象,video 的各种属性值
children,
controls, // 字符串数组
copy,
onSeekChange,
onVolumeChange,
onVolumeClick,
onCaptionsClick,
onPlayPauseClick, // connect 注入的
onFullscreenClick,
onCaptionsItemClick,
...restProps
}) => {
const onSeekChangeWrapper = e => {
// eslint-disable-next-line
console.log(
"e and e.target.value at on onSeekChangeWrapper",
{ ...e },
e.target.value
);
onSeekChange && onSeekChange(e);
};
return (
<div className={[styles.component, className].join(" ")} style={style}>
{/* H5 video 标签 */}
<video className={styles.video} {...restProps}>
{children}
</video>
{/* 蒙层 */}
{/* video 对象传递给各个子组件 */}
<Overlay onClick={onPlayPauseClick} {...video} />
{controls && controls.length && !video.error ? (
<div className={styles.controls}>
{controls.map((control, i) => {
switch (control) {
case "Seek":
return (
<Seek
key={i}
// 类似于 img 的 alt 属性
// ref https://developer.mozilla.org/zh-CN/docs/Web/Accessibility/ARIA/Attributes/aria-label
ariaLabel={copy.seek}
className={styles.seek}
onChange={onSeekChangeWrapper}
{...video}
/>
);
case "PlayPause":
return (
<PlayPause
key={i}
ariaLabelPlay={copy.play}
ariaLabelPause={copy.pause}
onClick={onPlayPauseClick}
{...video}
/>
);
case "Fullscreen":
return (
<Fullscreen
key={i}
ariaLabel={copy.fullscreen}
onClick={onFullscreenClick}
{...video}
/>
);
case "Time":
return <Time key={i} {...video} />;
case "Volume":
return (
<Volume
key={i}
onClick={onVolumeClick}
onChange={onVolumeChange}
ariaLabelMute={copy.mute}
ariaLabelUnmute={copy.unmute}
ariaLabelVolume={copy.volume}
{...video}
/>
);
case "Captions":
// 字幕
return video.textTracks && video.textTracks.length ? (
<Captions
key={i}
onClick={onCaptionsClick}
ariaLabel={copy.captions}
onItemClick={onCaptionsItemClick}
{...video}
/>
) : null;
default:
return null;
}
})}
</div>
) : null}
</div>
);
};
const controls = [
"PlayPause",
"Seek",
"Time",
"Volume",
"Fullscreen",
"Captions",
];
DefaultPlayer.defaultProps = {
copy, // defaultProps 这里传入了默认的 copy, 给 a11y 搞的便利
controls,
video: {},
};
DefaultPlayer.propTypes = {
copy: PropTypes.object.isRequired,
// PropTypes.oneOf 高级用法
controls: PropTypes.arrayOf(PropTypes.oneOf(controls)),
video: PropTypes.object.isRequired,
};
// 向外部直接导出的是这个,是 connect 返回的组件
// videoConnect 是一个函数,三个参数,组件, 返回包含 video 属性的对象的函数,返回一堆方法对象的函数
const connectedPlayer = videoConnect(
DefaultPlayer,
// 参数在 video/video.js 中传入的,这些全是监听事件,从 video 标签上同步的状态
({ networkState, readyState, error, ...restState }) => {
return {
video: {
readyState,
networkState,
// networkState === 3 表示找不到对应的资源
// ref https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLMediaElement/networkState
error: error || networkState === 3,
// TODO: This is not pretty. Doing device detection to remove
// spinner on iOS devices for a quick and dirty win. We should see if
// we can use the same readyState check safely across all browsers.
loading:
readyState < (/iPad|iPhone|iPod/.test(navigator.userAgent) ? 1 : 4),
percentagePlayed: getPercentagePlayed(restState),
percentageBuffered: getPercentageBuffered(restState),
...restState,
},
};
},
// 默认的 defaultMapVideoElToProps 会直接把 videoEl 包装成 { videoEl: videoEl },就是包装成 props 的形式
// 这里也是把高阶组件 传入 videoEl 和 state 派生出来的函数包装成 props 形式
(videoEl, state) => ({
onFullscreenClick: () => toggleFullscreen(videoEl.parentElement),
// 点击音量 icon,开启或者关闭静音
onVolumeClick: () => toggleMute(videoEl, state),
// 是否展示字幕
onCaptionsClick: () => toggleTracks(state),
onPlayPauseClick: () => {
togglePause(videoEl, state);
},
// 切换字幕,track 是 videoConnect 时候,监听 track 加载事件,注入到 props 中的
onCaptionsItemClick: track => {
showTrack(state, track);
},
// 修改音量值
onVolumeChange: e => setVolume(videoEl, state, e.target.value),
onSeekChange: e =>
setCurrentTime(videoEl, state, (e.target.value * state.duration) / 100),
})
);
export {
connectedPlayer as default,
DefaultPlayer, // 这个虽然叫 DefaultPlayer 但是需要具名导出,导出之后使用的也不是它。
Time,
Seek,
Volume,
Captions,
PlayPause,
Fullscreen,
Overlay,
};
再接着看 videoConnect 方法所在的 video 文件,可以看到这里其实就是高阶组件,传入的 BaseComponent,mapStateToProps,mapVideoElToProps
就是上面的三个参数。高阶组件里面的主要就做了两件事,一是通过各种事件监听器,同步 video 组件的事件到 React 的 state 中,二是调用传入的 mapStateToProps,mapVideoElToProps
方法,得到传入给 BaseComponent
的 props。
/**
* This is a HoC that finds a single
* <video> in a component and makes
* all its PROPERTIES available as props.
*/
import React, { Component } from "react";
// ref: https://react.dev/reference/react-dom/findDOMNode
// 找到 react 组件的最外层 dom,这里就是用来找外层的 video 标签的
import { findDOMNode } from "react-dom";
// 字符串常量数组
import { EVENTS, PROPERTIES, TRACKEVENTS } from "./constants";
// 函数,将传入 state 挂到 返回的对象的 video 属性上
// ref https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
const defaultMapStateToProps = (state = {}) =>
Object.assign({
video: {
...state,
},
});
// 参数包装成 props 的形式
const defaultMapVideoElToProps = videoEl => ({
videoEl,
});
// 函数,合并传入的对象
const defaultMergeProps = (stateProps = {}, videoElProps = {}, ownProps = {}) =>
Object.assign({}, stateProps, videoElProps, ownProps);
// videoConnect 函数,返回一个 class 组件
export default (
BaseComponent,
mapStateToProps = defaultMapStateToProps,
mapVideoElToProps = defaultMapVideoElToProps,
mergeProps = defaultMergeProps
) =>
class Video extends Component {
constructor(props) {
super(props);
// 超级老的写法,还要 bind this, updateState 访问了类的变量
this.updateState = this.updateState.bind(this);
this.state = {};
}
updateState() {
this.setState(
// 依次更新属性值, reduce 初始值是空对象,从 this.videoEl 中获取各种属性更新到 state 中
PROPERTIES.reduce((p, c) => {
p[c] = this.videoEl && this.videoEl[c];
return p;
}, {})
);
}
// 监听各种事件,同步值的变化到 state 中
bindEventsToUpdateState() {
EVENTS.forEach(event => {
this.videoEl.addEventListener(event.toLowerCase(), this.updateState);
});
// 字幕文本相关的事件
TRACKEVENTS.forEach(event => {
// TODO: JSDom does not have this method on
// `textTracks`. Investigate so we can test this without this check.
this.videoEl.textTracks.addEventListener &&
this.videoEl.textTracks.addEventListener(
event.toLowerCase(),
this.updateState
);
});
// If <source> elements are used instead of a src attribute then
// errors for unsupported format do not bubble up to the <video>.
// Do this manually by listening to the last <source> error event
// to force an update.
// https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Using_HTML5_audio_and_video
// 尝试获取子元素 source 标签,找到最后一个 source 标签,监听 error 并更新到 state 中
const sources = this.videoEl.getElementsByTagName("source");
if (sources.length) {
const lastSource = sources[sources.length - 1];
lastSource.addEventListener("error", this.updateState);
}
}
// 解除上面的事件绑定,和上面 addEventListener 的操作对称
unbindEvents() {
EVENTS.forEach(event => {
this.videoEl.removeEventListener(event.toLowerCase(), this.updateState);
});
TRACKEVENTS.forEach(event => {
// TODO: JSDom does not have this method on
// `textTracks`. Investigate so we can test this without this check.
this.videoEl.textTracks.removeEventListener &&
this.videoEl.textTracks.removeEventListener(
event.toLowerCase(),
this.updateState
);
});
const sources = this.videoEl.getElementsByTagName("source");
if (sources.length) {
const lastSource = sources[sources.length - 1];
lastSource.removeEventListener("error", this.updateState);
}
}
// 卸载时解除监听
componentWillUnmount() {
this.unbindEvents();
}
// Stop `this.el` from being null briefly on every render,
// see: https://github.com/mderrick/react-html5video/pull/65
// https://react.dev/reference/react-dom/findDOMNode#reading-components-own-dom-node-from-a-ref
// 可以改进,这里其实就是获取 connect 之后的组件的 根 dom 节点
setRef(el) {
this.el = findDOMNode(el);
}
// 挂载时,1 2. 监听事件并更新到 state
componentDidMount() {
// 有点绕,找到此组件根 dom 节点下的首个 video 标签
this.videoEl = this.el.getElementsByTagName("video")[0];
this.bindEventsToUpdateState();
}
render() {
const stateProps = mapStateToProps(this.state, this.props);
// 把 video 标签实例,state props 传入 mapVideoElToProps 函数调用, 派生出 controls 部分需要的 函数 props。
const videoElProps = mapVideoElToProps(
this.videoEl,
this.state,
this.props
);
return (
<div ref={this.setRef.bind(this)}>
<BaseComponent
{...mergeProps(stateProps, videoElProps, this.props)}
/>
</div>
);
}
};
画张图加深一下理解:
再看一下 api 文件,里面都是纯函数,直接导出调用就行。
看到这里已经能回答最初的问题了。这个包主要做的事情就是:重写了 video 标签的 controls 对应的组件,通过高阶组件的形式把 video 标签的属性同步到 react 状态中。当然,通过 videoConnect, mapStateToProps, mapVideoElToProps 这些高阶组件的方法我们能够定制播放器 controls。
动手
大致的脉络搞清楚之后,可以再深入看一下具体的组件,其实全都是 old school 的 react class 组件写法。没有啥特别的东西,组件的 props 都是从高阶组件传入的,包含 mapStateToProps, mapVideoElToProps 计算出来的值和函数。
为了加深理解,我们来改一下进度条吧,加入一个 kun kun,可以参考这里,其实非常简单,理解了原来的代码之后,加入这样的功能就很简单。
总结
本文解析了 react-html5video npm 包的代码,对 video 标签和通过高阶组件映射 video 标签的事件到状态的模式有了更多的了解。如果你想封装一个复杂一些的 h5 标签,video, audio 之类的,可以参考文中所提到的高阶组件模式。
参考文献
作者:ES2049 / 金克丝
文章可随意转载,但请保留此原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com 。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。