技术考古,react-html5video 包解析

前言

最近在业务代码中看到了 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 被重写了,具体的组件如下面代码中所示:

jsx 复制代码
    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 组件上。像下面这样的用法。

jsx 复制代码
    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 函数。

jsx 复制代码
    /** @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 对象,里面挂了各种各样的状态;一个函数,返回的对象里面挂了一堆方法。

jsx 复制代码
    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。

jsx 复制代码
    /**
     * 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 之类的,可以参考文中所提到的高阶组件模式。

参考文献

相关推荐
丁总学Java18 分钟前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
懒羊羊大王呀29 分钟前
CSS——属性值计算
前端·css
无咎.lsy1 小时前
vue之vuex的使用及举例
前端·javascript·vue.js
fishmemory7sec1 小时前
Electron 主进程与渲染进程、预加载preload.js
前端·javascript·electron
fishmemory7sec1 小时前
Electron 使⽤ electron-builder 打包应用
前端·javascript·electron
豆豆2 小时前
为什么用PageAdmin CMS建设网站?
服务器·开发语言·前端·php·软件构建
twins35203 小时前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky3 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~3 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
安冬的码畜日常3 小时前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺