前言
上期我们学习了前端视频分片加载的基本处理,完成了仅在web前端通过MSE与mp4box.js库实现视频分片的拼装。接下来,我们要完善请求视频分片的逻辑,完成这个小型的视频分片组件。那我们直接进入正题:
实现视频分片请求
HTTP-Range
分片请求的基础是HTTP-Range:http范围请求,通过这个技术能通过Range请求头指定服务端返回部分资源。然后在返回头Content-Range可以获取到资源的大小。
js
const rRange = /(\d+)-(\d+)\/(\d+)/
// 请求视频片段
export async function fetchRange({ videoSrc, from, chunkSize }, options = {}) {
const to = from + chunkSize - 1
const currentUrl = `${videoSrc}?from=${from}`
try {
const res = await fetch(currentUrl, {
headers: {
Range: `bytes=${from}-${to}`,
},
...options,
})
const contentRange = res.headers.get('Content-Range') || ''
const [, beginString, endString, totalString] = contentRange.match(rRange) || []
const begin = parseInt(beginString, 10)
const end = parseInt(endString, 10)
const total = parseInt(totalString, 10)
// mp4box解析分片需要bytes.fileStart
const bytes = await res.arrayBuffer()
bytes.fileStart = begin
return {
bytes,
begin,
end,
total,
}
} catch (e) {
return {
error: e,
}
}
}
请求队列
在基础的资源请求之外,我们还要考虑到需求功能,我的需求是随着滚动播放不同的视频。因此需要先对视频预加载少量视频,以便视频可以快速播放,在此基础下,为了防止短时间内加载资源过多占用网络,我们可以实现一个按优先级的请求队列,以播放中 > 按前后排序 的逻辑提高请求效率。
以下是请求队列的实现:
js
type QueueInfo = {
task: () => Promise<any>
resolve: (value: any) => void
reject: (reason?: any) => void
priority: number
}
/**
* 并发限制
* 支持修改优先级
*/
export default class TaskQueue {
constructor(limit) {
this.limit = limit
}
limit: number
queue: QueueInfo[] = []
executing: Promise<any>[] = []
stoped = false
addTask<T>({ task, priority = 0 }: { task: () => Promise<T>; priority: number }): Promise<T> {
const taskPromise = new Promise<T>((resolve, reject) => {
this.queue.push({ task, resolve, reject, priority })
this.queue.sort((a, b) => b.priority - a.priority) // 根据优先级排序
this.schedule()
})
return taskPromise
}
schedule() {
if (this.executing.length >= this.limit || this.queue.length === 0 || this.stoped) {
return
}
const queueInfo = this.queue.shift()
if (!queueInfo) {
return
}
const { task, resolve, reject } = queueInfo
const p = task()
this.executing.push(p)
p.then((v) => resolve(v))
.catch(reject)
.finally(() => {
this.executing.splice(this.executing.indexOf(p), 1)
// 在循环推入任务时,等待调整优先级
setTimeout(() => {
this.schedule()
})
})
}
adjustPriority(taskPromise, newPriority) {
const executingTaskIndex = this.executing.findIndex(item => item === taskPromise) // 执行中的任务
const queueTaskIndex = this.queue.findIndex(item => item.task === taskPromise) // 等待中的任务
if (queueTaskIndex !== -1) {
this.queue[queueTaskIndex].priority = newPriority
this.queue.sort((a, b) => b.priority - a.priority) // 根据优先级重新排序
}
return queueTaskIndex !== -1 || executingTaskIndex !== -1
}
stop() {
this.stoped = true
}
resume() {
if (this.stoped) {
this.stoped = false
this.schedule()
}
}
}
实现网络检测
在网络断开后重连的时候,需要检测网络的上线并重新开始下载。可以通过window.addEventListener('online')
实现。
js
// 网络检测
type Callback = () => void
export default class NetworkDetect {
callbackList: Callback[] = []
isOnce = false
constructor(options = { isOnce: false }) {
this.isOnce = options.isOnce
}
addHandler(cb: () => void) {
this.callbackList.push(cb)
}
removeHandler(cb) {
const index = this.callbackList.findIndex(cb)
this.callbackList.splice(index, 1)
}
handler() {
this.callbackList.forEach(cb => {
cb()
})
if (this.isOnce) {
this.unWatch()
}
}
watch() {
window.addEventListener('online', this.handler.bind(this))
}
unWatch() {
this.callbackList = []
window.removeEventListener('online', this.handler)
}
}
还有在部分情况下需要检测wifi可以使用navigator.connection.type
判断,但是需要注意这个兼容性很差,基本只有安卓的chrome才能正常运行。

js
/**
* 检查网络是否为wifi
*/
export function checkNetworkIsWifi() {
// 限制非wifi环境下不自动播放视频
const navigator = window.navigator
if (typeof navigator !== 'undefined') {
const connection = navigator?.connection || navigator?.mozConnection || navigator?.webkitConnection
if (connection?.type === 'wifi') {
return true
}
}
return false
}
实现调整播放进度
调整播放进度的难点在于加载中的视频需要修改目前资源加载顺序,比如刚开始加载播放的视频被调整播放到末尾时,需要优先加载末尾部分的视频资源,并在播放完从头开始时再次继续下载并解析前面的未结束的视频。
为了实现调整播放进度的功能,非常重要的一点就是获取到播放时间对应的buffer节点,以便在资源加载的时候获取到指定的位置。而这个能力可以通过mp4box.js的seek方法查询到指定时长的buffer节点。
但需要注意的是seek方法会影响到视频解析的位置,即使用mp4box.js加载的视频切片需要按seek后的位置来处理。且获取到的位置是上一sample片的起始位置。
mp4box.seek方法会设置mp4box加载下一sample分片位置
具体视频分片下载逻辑如下图:
具体整合代码如下:
js
import { fetchRange } from './api'
import TaskQueue from './task-queue'
import { chunkHandlerType } from '../../types'
import NetworkDetect from './utils/network-detect'
type VideoInfo = {
videoSrc: string // 视频链接
chunkList: number[] // 剩余未加载分片起始位置列表
currentChunk?: number // 当前加载中分片起始位置
nextChunk: number // 下一个分片起始位置
currentTask?: Function // 当前任务
adjustNextChunk?: number // 调整的下一个分片起始位置
hasInit: boolean // 是否已经初始化/加载完第一分片
total: number // 视频总大小
chunkHandler: chunkHandlerType // 分片处理函数
abortController?: AbortController // 取消请求控制器
}
type DownloadManagerParams = {
limit?: number // 同时下载的视频数量限制
}
// 分片起始位置列表
function getChunkList(total: number, chunkSize: number) {
const chunkList: number[] = []
let nextChunk = chunkSize
while (nextChunk < total) {
chunkList.push(nextChunk)
nextChunk += chunkSize
}
return chunkList
}
export default class DownloadManager {
constructor({ limit = 2 }: DownloadManagerParams = {}) {
this.videoMap = new Map()
this.limit = limit
this.taskQueue = new TaskQueue(limit)
this.chunkSize = 500 * 1024
this.networkDetect = new NetworkDetect({ isOnce: true })
}
videoMap: Map<string, VideoInfo> // 视频信息
limit: number // 同时下载的视频数量限制
currentPlayingVideo = '' // 当前播放的视频
taskQueue: TaskQueue // 下载队列
chunkSize: number // 分片大小
networkDetect: NetworkDetect // 网络检测
// 销毁前清除监听
beforeDestroy() {
this.networkDetect.unWatch()
}
// 添加视频,视频第一分片进入下载队列
addVideo(unitId: string, videoSrc: string, chunkHandler: chunkHandlerType) {
if (this.videoMap.has(unitId)) {
return this.videoMap.get(unitId)
} else {
// 第一分片任务
const abortController = new AbortController()
const task = () => {
return fetchRange({ videoSrc, from: 0, chunkSize: this.chunkSize }, { signal: abortController.signal })
}
this.videoMap.set(unitId, {
videoSrc,
chunkList: [],
nextChunk: 0,
currentTask: task,
hasInit: false,
chunkHandler,
total: 0,
abortController,
})
this.taskQueue
.addTask({
task,
priority: 1,
})
.then(params => {
const videoInfo = this.videoMap.get(unitId)
if (params.bytes && videoInfo) {
const { bytes, begin, end, total } = params
const chunkList = getChunkList(total, this.chunkSize)
// 不考虑moov后置的情况
const nextChunk = chunkList[0]
const isEof = chunkList.length === 0
// 更新初始视频信息
videoInfo.chunkList = chunkList
videoInfo.nextChunk = nextChunk
videoInfo.currentTask = undefined
videoInfo.hasInit = true
videoInfo.total = total
chunkHandler({ bytes, begin, end, total, isEof, needNextRequest: true })
} else if (params.error) {
chunkHandler({ error: params.error })
this.networkDetect.addHandler(() => {
this.addVideo(unitId, videoSrc, chunkHandler)
})
this.networkDetect.watch()
}
})
}
}
// 开始播放(提高当前分片优先级/开始加载下一分片)
playingVideo(unitId: string) {
this.currentPlayingVideo = unitId
const videoInfo = this.videoMap.get(unitId)
if (!videoInfo) {
return
}
// 视频已完全加载
if (videoInfo.hasInit && videoInfo.chunkList.length === 0) {
// console.log('video has loaded')
return
}
// 提高已进入队列的任务优先级
if (videoInfo.currentTask) {
this.taskQueue.adjustPriority(videoInfo.currentTask, 2)
} else if (videoInfo.nextChunk) {
if (videoInfo.adjustNextChunk) {
videoInfo.nextChunk = videoInfo.adjustNextChunk
videoInfo.adjustNextChunk = undefined
}
videoInfo.currentChunk = videoInfo.nextChunk
// console.log('nextChunk', videoInfo.nextChunk)
const abortController = new AbortController()
const task = () => {
return fetchRange(
{ videoSrc: videoInfo.videoSrc, from: videoInfo.nextChunk, chunkSize: this.chunkSize },
{ signal: abortController.signal },
)
}
videoInfo.currentTask = task
videoInfo.abortController = abortController
this.taskQueue
.addTask({
task,
priority: 2,
})
.then(params => {
if (params.bytes && videoInfo) {
const { bytes, begin, end, total } = params
const chunkList = videoInfo.chunkList
// console.log('chunkList', videoInfo.adjustNextChunk, begin, chunkList)
// 更新视频信息
videoInfo.currentTask = undefined
let needNextRequest = true
let skipChunkHanlder = false
if (videoInfo.adjustNextChunk) {
videoInfo.nextChunk = videoInfo.adjustNextChunk
videoInfo.adjustNextChunk = undefined
skipChunkHanlder = true
} else {
// 删除已加载的分片
const currentChunkIndex = videoInfo.chunkList.indexOf(begin)
if (currentChunkIndex !== -1) {
videoInfo.chunkList.splice(currentChunkIndex, 1)
}
const nextChunkIndex = chunkList.findIndex(item => item >= begin)
if (nextChunkIndex !== -1) {
const nextChunk = chunkList[nextChunkIndex]
videoInfo.nextChunk = nextChunk
} else {
videoInfo.nextChunk = chunkList[0]
needNextRequest = false
}
}
const isEof = videoInfo.chunkList.length === 0
videoInfo.chunkHandler({ bytes, begin, end, total, isEof, needNextRequest, skipChunkHanlder })
} else if (params.error) {
videoInfo.currentTask = undefined
videoInfo.chunkHandler({ error: params.error })
this.networkDetect.addHandler(() => {
this.playingVideo(this.currentPlayingVideo)
})
this.networkDetect.watch()
}
})
.catch(e => {
videoInfo.currentTask = undefined
videoInfo.chunkHandler({ error: e })
})
}
}
// 暂停播放
pauseVideo(unitId: string) {
if (this.currentPlayingVideo === unitId) {
this.currentPlayingVideo = ''
const videoInfo = this.videoMap.get(unitId)
// 特殊情况下存在未下载待播放的视频资源
if (videoInfo?.currentTask) {
this.taskQueue.adjustPriority(videoInfo.currentTask, 0)
}
}
}
// 调整播放进度
adjustProgress(unitId: string, progressOffset: number) {
// console.log('adjustProgress', videoSrc, progressOffset)
const videoInfo = this.videoMap.get(unitId)
if (!videoInfo) {
return
}
if (progressOffset) {
// videoInfo.abortController?.abort('adjustProgress')
// videoInfo.currentTask = undefined
videoInfo.adjustNextChunk = progressOffset
this.playingVideo(unitId)
} else {
this.playingVideo(unitId)
}
}
// 限制播放时长,减少加载资源
limitDownloadTotal(unitId: string, limitDurationEndByte: number) {
const videoInfo = this.videoMap.get(unitId)
if (!videoInfo) {
return
}
videoInfo.total = limitDurationEndByte
videoInfo.chunkList = videoInfo.chunkList.filter(item => item < limitDurationEndByte + this.chunkSize)
}
}
监控下载的资源大小
为了评估成本,我们还需要对加载资源进行统计,这里需要注意排除掉http缓存的资源。判断http缓存可以使用PerformanceObserver方法。

js
function observeNetworkRequestIsCache(url) {
return new Promise(resolve => {
if (typeof PerformanceObserver === 'function') {
const observer = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
if (entry?.name?.includes(url)) {
observer.disconnect()
resolve(!entry?.transferSize)
}
})
})
observer.observe({ entryTypes: ['resource'] })
} else {
resolve(false)
}
})
}
总结
除了视频加载的逻辑外,还有一个通过document.elementFromPoint
与IntersectionObserver
检测元素在视口内位置的功能由于与本文关系不大,不在此处占用篇幅,如果有人感兴趣我再另外写文。
视频分片后对资源加载的逻辑会比想象中更复杂,很多逻辑需要手动实现,且浏览器的支持度也不是很高,功能实现上也还有一些瑕疵,比如目前使用mse的视频是以blob文件的形式播放,无法复用视频资源等,后续如果有优化的方案也会再分享出来,如果有大佬有建议或优化方案也希望能再评论区分享下。
参考
Web 视频播放的那些事儿对于视频的在线播放,按视频内容的实时性可以分为点播(VOD)和直播(Live Streamin - 掘金