前言

最近在业务代码中看到了 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 点:

  1. 把 video 标签封装成了一个 React 组件,也就是 Default Player。
  2. 提供了国际化和可访问性的支持。
  3. 提供了定制播放器控制组件的能力,但是要通过高阶组件来实现,通过 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>
      );
    }
  };

画张图加深一下理解:

image

再看一下 api 文件,里面都是纯函数,直接导出调用就行。

看到这里已经能回答最初的问题了。这个包主要做的事情就是:重写了 video 标签的 controls 对应的组件,通过高阶组件的形式把 video 标签的属性同步到 react 状态中。当然,通过 videoConnect, mapStateToProps, mapVideoElToProps 这些高阶组件的方法我们能够定制播放器 controls。

动手

大致的脉络搞清楚之后,可以再深入看一下具体的组件,其实全都是 old school 的 react class 组件写法。没有啥特别的东西,组件的 props 都是从高阶组件传入的,包含 mapStateToProps, mapVideoElToProps 计算出来的值和函数。

为了加深理解,我们来改一下进度条吧,加入一个 kun kun,可以参考这里,其实非常简单,理解了原来的代码之后,加入这样的功能就很简单。

image

总结

本文解析了 react-html5video npm 包的代码,对 video 标签和通过高阶组件映射 video 标签的事件到状态的模式有了更多的了解。如果你想封装一个复杂一些的 h5 标签,video, audio 之类的,可以参考文中所提到的高阶组件模式。

参考文献

作者:ES2049 / 金克丝

文章可随意转载,但请保留此原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com


ES2049
3.7k 声望3.2k 粉丝