用原生JS写一个视频播放条组件

1,549 阅读6分钟

视频播放器都有一个可以点击,可以拖动的播放进度条,本文将使用原生JavaScript来实现一个播放条组件,先看看最终的效果:

progressbar.gif

1. HTML结构

首先对播放条进行抽象,分析其基本结构。

一般情况下,播放条由整体进度条当前进度条缓存进度条滑块弹幕高能进度条等等组成,根据不同的功能需要可以进行相应模块的增加。

本文以原理性解释为主,为了简洁性,只实现 整体进度条,当前进度条滑块3个部分。

<div class="progress-bar">
    <!-- 整体进度条 -->
    <div class="progress-bg"></div>
    <!-- 当前进度条 -->
    <div class="progress-indicator"></div>
    <!-- 滑块 -->
    <div class="progress-pointer"></div>
</div>

2. CSS装饰

对HTML进行简单的CSS装饰:

/* progress bar */
.progress-bar {
    display: flex;
    align-items: center;
    position: relative;
    height: 50px;
    width: 100%;
    cursor: pointer;
    background: #aaa;
}

/* progress bar background */
.progress-bar .progress-bg {
    position: absolute;
    left: 0;
    height: 5px;
    width: 100%;
    background-color: hsla(0, 0%, 100%, .2);
}

/* progress bar indicator */
.progress-bar .progress-indicator {
    position: absolute;
    left: 0;
    height: 5px;
    width: 100%;
    transform-origin: 0 0;
    transform: scaleX(0);
    background-color: #00a1d6;
}

/* progress bar pointer */
.progress-bar .progress-pointer {
    position: absolute;
    left: 0;
    height: 15px;
    width: 15px;
    border-radius: 50%;
    background-color: #00a1d6;
}

3. 操作定义和状态转换图

在完成了基本 UI 结构的定义和装饰后,来考虑交互性。

首先思考播放条可以接受哪些操作:

鼠标按下鼠标拖动鼠标松开手指接触屏幕手指滑动手指离开屏幕

这些操作将会触发哪些操作?

  • 鼠标按下 / 触屏开始:

    • 当前进度条移动到鼠标或手指的位置
    • 滑块移动到鼠标或手指的位置
  • 鼠标按下不松开移动 / 手指触摸后不松手进行滑动:

    • 当前进度条随着鼠标或手指的移动而移动
    • 滑块随着鼠标或手指的移动而移动
  • 鼠标松开 / 手指离开屏幕:

    • 进入结束状态
    • 进入结束状态

为了便于理解,画出上述操作的状态转换图:

state.svg

4. 更新操作

播放条组件自身具有的一个状态属性是当前的进度,这里我们用百分比 percentage 来表示,percentage 为 0 表示当前进度为 0,percentage 为 1 表示当前进度为 100%。

每次进行更新操作时,首先获取鼠标或手指的位置,根据这个位置计算出当前的百分比,再由百分比更新DOM:

update.svg

这样设计可以将更新函数与用户操作进行解耦,更新函数只依赖 播放条组件自身的 percentage 状态。

另外,我们可以创建一个代理对象用来监听 percentage 的变化,这个代理对象可以用来注册一些函数,每当 percentage 变化时,代理对象就通知这些函数执行。

4. 具体代码实现

在了解播放条的实现原理后,开始动手写代码,首先搞一个 Progress 类。

当新建一个对象时,传入播放条的入口dom配置项,在初始化函数中,获取 整体进度条滑块 的 dom 接口,定义播放条的状态。

class Progress {
	constructor(progressElement /* 入口dom */, options /* 配置项 */) {
            this.$progress = progressElement
            this.$indicator = this.$progress.querySelector(".progress-indicator")
            this.$pointer = this.$progress.querySelector(".progress-pointer")
            this.options = options
            this.states = { percentage: 0 }

            // 用来存储插件函数
            this.plugins = {}

            // 代理对象,用来代理组件的状态属性
            const that = this
            this.stateProxy = new Proxy(this.states, {
                set(states, state, value, stateProxy) {
                    if (value < 0) {
                        value = 0
                    } else if (value > 1) {
                        value = 1
                    }
                    // 当状态改变时,通知注册函数执行
                    if (that.plugins[state]) {
                        that.plugins[state].forEach((handler) => handler(value))
                    }
                    return Reflect.set(states, state, value, stateProxy)
                },
                get(states, state) {
                    return Reflect.get(states, state)
                },
            })
    }
}

4.1 获取鼠标和手指的位置

通过计算事件触发时相对 viewport 的距离减去播放条相对 viewport 的距离得到鼠标在播放条里相对坐标,再用这个相对坐标播放条的宽度得到百分比。

这里在获取事件相对 viewport 的距离时同时考虑了鼠标事件和 Touch 事件。

getPosInElement(event) {
      const rect = this.$progress.getBoundingClientRect()
      // 同时考虑鼠标事件和 Touch 事件
      const eventX = event.clientX ? event.clientX : event.touches[0].clientX
      const relativeX = eventX - rect.x
      this.stateProxy.percentageX = relativeX / rect.width
}

4.2 更新函数

更新函数做的事情很简单,根据组件的 percentage 状态进行播放条组件 UI 的更新,需要更新的DOM就两个:当前进度条滑块

updateUI() {
    // update indicator
    const percentage = this.stateProxy.percentage
    this.$indicator.style = `transform: scaleX(${percentage});`

    // update pointer
    const progressBarWidth = this.$progress.getBoundingClientRect().width
    const progressPointerWidth = this.$pointer.getBoundingClientRect().width
    let pointerPos = percentage * progressBarWidth
    // 防止滑块越界
    if (pointerPos + progressPointerWidth > progressBarWidth) {
        pointerPos = progressBarWidth - progressPointerWidth
    }
    this.$pointer.style = `transform: translateX(${pointerPos}px);`
}

4.3 事件处理函数与注册

根据状态转换图写出不同事件触发时需要进行的操作,这里一个值得注意的点就是在鼠标按下和手指按下时,注册鼠标滑动,手指滑动,鼠标松开和手指松开的事件处理函数,在鼠标松开和手指松开时注销这些处理函数。

如果读者写过 drag 和 drop 相关功能,应该会对这些操作很熟悉,原理是差不多的。

// 鼠标按下或手指按下
startHandler(event) {
      this.getPosInElement(event)
      this.updateUI()

      document.addEventListener("mousemove", this.moveHandler)
      document.addEventListener("touchmove", this.moveHandler)
      document.addEventListener("mouseup", this.endHandler)
      document.addEventListener("touchend", this.endHandler)
  }
// 鼠标按下移动,手指滑动
moveHandler(event) {
    this.getPosInElement(event)
    this.updateUI()
}
// 鼠标松开后
endHandler() {
    document.removeEventListener("mousemove", this.moveHandler)
    document.removeEventListener("touchmove", this.moveHandler)
    document.removeEventListener("mouseup", this.endHandler)
    document.removeEventListener("touchend", this.endHandler)
}

在定义完上面的事件处理函数后,只需要将 startHandler 注册到播放条的点击和触摸事件上即可:

this.$progress.addEventListener("mousedown", this.startHandler)
this.$progress.addEventListener("touchstart", this.startHandler)

4.4 实现功能函数插拔

做完以上的工作后,一个看起来可以正常点击滑动的播放条就 “完成” 了。

但是事情还没有结束,此时的播放条只是一个自娱自乐的玩意儿,我们需要它能跟外界交互。

比如,当点击或滑动播放条时,视频应该跳转到相应的位置。

怎么实现这个功能呢?前面我们建立了一个代理对象注册函数的机制,是时候让它们派上用处了。

实现一个注册函数,用来注册需要实现的功能。

注册函数接收两个参数,第一个参数是要监听的状态属性,第二个参数是一个功能函数,该功能函数的参数是它监听的状态属性。

// 注册函数接收两个参数,第一个参数
on(state, handler) {
    if (this.plugins[state]) {
        const index = this.plugins[state].indexOf(handler)
        if (index === -1) {
            this.plugins[state].push(handler)
        } else {
            return false
        }
    } else {
        this.plugins[state] = []
        this.plugins[state].push(handler)
    }
    return true
}

再来一个注销函数:

off(state, handler) {
    if (this.plugins[state]) {
        const index = this.plugins[state].indexOf(handler)
        if (index !== -1) {
            this.plugins[state].splice(index, 1)
            return true
        }
    }
    return false
}

注册一个函数用来更新视频的当前播放时间:

// 这只是一段演示代码
function updateVideo(percentage) {
		video.currentTime = video.duration * percentage
}
progress.on("percentage", updateVideo)

percentage 变化时,stateProxy 里的 set 函数就会触发,在这里我们执行所有跟 percentage 建立了监听关系的函数。

set(states, state, value, stateProxy) {
    if (value < 0) {
        value = 0
    } else if (value > 1) {
        value = 1
    }
    // 当状态改变时,通知注册函数执行
    if (that.plugins[state]) {
        that.plugins[state].forEach((handler) => handler(value))
    }
    return Reflect.set(states, state, value, stateProxy)
}

除此之外还能做很多事情,每次要增加新的功能时只需要通过注册函数将功能进行注册,不需要的时候就将其移除,实现类似插件插拔的功能。

5. 还能做什么

由于篇幅限制,本文到这里就结束了。

但是要写一个功能完善,健壮性好的播放条组件,还有很多工作需要做,比如:

  • 组件如何应对响应式变化?当浏览器窗口大小变动或者手机横屏时,怎么保证UI的正确?
  • 这个组件只支持横向的播放条,能不能支持竖向的?
  • 用户不停地滑动滑块,导致插件函数频繁触发,特别是插件函数的计算量较大时会有性能问题,怎么解决?
  • 怎么解决无障碍访问问题?
  • 怎样对组件进行进一步抽象,使其可以用在不同的地方比如音量调节,音乐播放条?
  • 怎么控制 UI 的大小,比如宽高?
  • 怎么设计组件对外的API?

这些问题留给有兴趣的读者研究。

6. 参考文献

How to Style a Video Player: the basics | Blue Billywig

Adding more advanced HTML5 video player custom controls | Blue Billywig