最近有个需求要加载长视频,用户反馈视频加载慢、播放卡顿。为了解决这个问题,我研究了两种技术方案:Range + fmp4 + MediaSource 和 M3u8切片方案。这两种方案各有优缺点,下面详细聊聊我的实现思路和踩过的坑。
方案一:Range + fmp4 + MediaSource
这个方案的核心思路是分段加载视频内容,而不是一次性加载整个视频文件。这样用户在观看前几秒内容时,后面的部分已经在后台默默加载了。
关键技术点拆解
1. Range请求头
通过 Range
请求头告诉服务端:“我只需要文件的某一部分”。比如:
Range: bytes=200-1000
:要第200到1000字节Range: bytes=200-
:从200字节一直要到最后Range: bytes=-1000
:只要最后1000字节
服务端需要支持范围请求:
- 响应
206 Partial Content
表示成功返回部分内容 - 如果请求范围不合法(比如超过文件大小),返回
416 Range Not Satisfiable
- 如果服务端不处理
Range
请求,直接返回200
和整个文件
2. fmp4格式
普通MP4文件的元数据(比如视频时长、分辨率)集中在文件头部,如果只加载中间片段,播放器可能无法解析。而 fmp4(Fragmented MP4) 将元数据分散到各个片段,每个片段都能独立播放。
3. MediaSource API
浏览器提供的 MediaSource
对象允许我们动态拼接视频片段。大致流程:
- 创建
MediaSource
实例,生成一个虚拟的媒体URL - 监听
sourceopen
事件,创建SourceBuffer
用于接收数据 - 分片请求视频内容,通过
appendBuffer
添加到SourceBuffer
- 所有片段加载完成后,调用
endOfStream
后端实现(Express)
关键点在于处理 Range
请求头,直接用 Express 的 res.download
方法即可自动处理范围请求:
app.get('/getMp4/demo', (req, res) => {
res.download(
path.join(__dirname, '/static/demo.mp4'),
{
acceptRanges: true // 开启范围请求支持
}
)
})
前端实现(Vue3)
前端需要注意动态获取视频总大小,而不是写死:
const videoSrc = ref('')
const getRangeVideo = () => {
// 应该从第一次请求的响应头中获取总大小
// 比如 Content-Range: bytes 0-999/5524488
const totalSize = 5524488
const chunkSize = 1000000 // 每次加载1MB
const mediaSource = new MediaSource()
videoSrc.value = URL.createObjectURL(mediaSource)
mediaSource.addEventListener('sourceopen', () => {
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E"')
const sendRequest = () => {
if (startByte >= totalSize) return
axios({
url: '/getMp4/demo',
headers: { Range: `bytes=${startByte}-${startByte + chunkSize}` }
}).then(res => {
sourceBuffer.appendBuffer(res.data)
startByte += chunkSize + 1
setTimeout(sendRequest, 500) // 控制加载频率
})
}
sendRequest()
sourceBuffer.addEventListener('updateend', function () {
if (startByte >= totalSize) mediaSource.endOfStream()
})
})
}
方案二:M3u8切片方案
这个方案更“省心”,利用成熟的流媒体协议 HLS(HTTP Live Streaming),将视频切分为多个 .ts
文件,通过 .m3u8
索引文件控制播放顺序。
实现步骤
1. 使用FFmpeg切片
本地安装FFmpeg后,执行命令将视频转为HLS格式:
ffmpeg -i input.mp4 \
-c:v libx264 -an \
-hls_time 5 \ # 每段5秒
-hls_list_size 0 \ # m3u8保留所有片段信息
output.m3u8
生成的文件结构:
output.m3u8
:索引文件,记录每个.ts片段的信息output0.ts
,output1.ts
...:实际视频片段
然后将切片之后的文件放到前端能够访问的地方就行。放在前端项目代码内都行,比如public
等文件夹内。
需要注意m3u8
文件和所有切片文件都要放在同一个目录下。
2. 前端使用video.js播放
安装 video.js
后在页面使用
<template>
<div id="playerWrapper">
<video class="video-js" controls></video>
</div>
</template>
<script setup>
import videoJs from 'video.js'
import lang_zhCn from 'video.js/dist/lang/zh-CN.json'
import 'video.js/dist/video-js.min.css'
videoJs.addLanguage('zh-CN', lang_zhCn)
const myVideo = ref(null)
onMounted(() => {
myVideo.value = videoJs('videoId', {
sources: [{ src: '/videos/output.m3u8', type: 'application/x-mpegURL' }],
autoplay: true,
controls: true
})
})
</script>
两种方案对比
对比项 | Range + fmp4方案 | M3u8切片方案 |
---|---|---|
实现复杂度 | 需要手动处理分片和拼接 | 依赖FFmpeg和video.js,配置简单 |
播放体验 | 拖动进度条可能需重新加载 | 拖动流畅,自动切换清晰度 |
适用场景 | 需要精细控制加载逻辑的项目 | 快速上线、对体验要求高的场景 |
最后的选择
如果项目周期紧张,推荐直接用 M3u8方案,毕竟FFmpeg和video.js的生态更成熟。但如果需要深度定制加载策略(比如根据网络速度动态调整分片大小),Range + fmp4方案 会更灵活。