视频播放器交互设计与性能优化 - 面试准备文档
好,作为这个项目的开发者,我来以面试回答的方式,把 video.js 的集成设计讲清楚。
一、为什么选 video.js?
这是一个抖音式的竖屏短剧播放器,核心需求:
- 竖屏 9:16 视频播放
- 上下滑动切换视频(类似抖音)
- 需要进度条、静音切换、播放/暂停
- 移动端优先,必须支持
playsinline(iOS 内联播放)
video.js 是开箱即用的选择:它有完整的控制栏、事件系统、插件生态,而且对移动端 H.264/MP4 兼容性很好。我不想从零写一个播放器。
二、CDN 加载策略 —— 为什么不 npm 安装?
[这是最核心的设计决策]
你看 [index.html](file:///g:/Save/Grogramming/Vue3/yike/index.html#L6-L8):
<link href="https://vjs.zencdn.net/7.20.3/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/7.20.3/video.min.js"></script>
三个原因:
video.js 本体很大(压缩后约 500KB+)。如果是 SPA 首屏加载,会严重拖慢 FCP。走 CDN 可以利用浏览器缓存——用户访问过任何用 video.js CDN 的网站,这块就是 0 成本。
Vite 的构建速度。 video.js 是一个复杂的库,走 npm 构建时 Rollup 要处理大量 CommonJS 兼容和 polyfill,会拖慢开发时的 HMR。CDN 加载完全绕过了构建系统。
与服务端视频流 CDN 同源策略无关。 视频流来自后端 API 返回的 CDN 地址(见 [handleVideo.js](file:///g:/Save/Grogramming/Vue3/yike/src/utils/handleVideo.js#L27-L33) 的
getVideoAdd),播放器和视频源是完全解耦的。
代价是失去了 TypeScript 类型支持,所以我做了防御式判断:
// PlayBar.vue onMounted 里
if (typeof window.videojs === 'undefined') {
console.error('video.js 未加载,请检查 CDN 引入')
return
}
三、整体架构:三层设计
┌──────────────────────────────────────────────┐
│ Video.vue │ ← 滚动容器层
│ 管理 5 个视频队列 │
│ 处理上下滑动 / 滚轮 / 键盘事件 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ PlayBar │ │ PlayBar │ │ PlayBar │ ... │ ← 播放器实例层
│ │ video.js │ │ video.js │ │ video.js │ │ 每个都是独立播放器
│ └──────────┘ └──────────┘ └──────────┘ │
│ ↑ ↑ ↑ │
│ ┌──────────────────────────────────────┐ │
│ │ handleVideo.js │ │ ← 队列管理层
│ │ · 随机模式:预加载 15 个剧集元信息 │ │
│ │ · 追剧模式:获取全部剧集(1000集) │ │
│ │ · 维护 5 个视频的播放队列 │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
四、核心组件:PlayBar.vue —— 单个播放器的完整封装
[PlayBar.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/PlayBar.vue#L254-L310) 是播放器的最小单元。
4.1 初始化配置
player = window.videojs(props.videoInfo, {
height: '1415',
width: '795.94',
aspectRatio: '9:16', // 竖屏比例
controlBar: {
fullscreenToggle: false, // 禁用全屏按钮
playToggle: false, // 禁用播放按钮(我们用点击画面控制)
volumeMenuButton: false, // 禁用音量按钮(侧边栏自己控制)
timeDivider: false,
volumePanel: false,
pictureInPictureToggle: false,
remainingTimeDisplay: false,
},
userAction: { click: true, doubleClick: false },
controls: true,
})
设计思路: 这是一个极简的控制栏。video.js 默认的控制栏是为 16:9 横屏设计的,按钮太多。我把播放/暂停、音量、全屏全部禁用,只保留进度条——因为抖音式的交互是"点击画面播放/暂停","侧边按钮控制静音"。
4.2 点击与拖拽的冲突处理
这是一个很重要的细节。视频画面需要同时支持:
- 点击 → 播放/暂停
- 上下拖拽 → 切换视频
如果直接监听 click 事件,用户在滑动切换视频时手指抬起的瞬间也会触发 click。所以我用了一套"位移阈值判断":
// 伪代码逻辑
onTouchStart → 记录起点 (startX, startY)
onTouchMove → 计算移动距离,距离 > 30px → 标记 isDragging = true
onTouchEnd → 如果 !isDragging → 这是点击,触发 播放/暂停
如果 isDragging → 这是滑动,交给 Video.vue 处理切换
阈值设为 30px,小于这个距离的视为点击,大于的视为滑动。
4.3 事件监听与状态同步
player.on('play', () => {
dramaStore.updateWatchRecord(props.vid, props.eid) // 播放时记录观看历史
isEnded.value = false
})
player.on('ended', () => {
isEnded.value = true // 播完显示"已结束"遮罩
})
onVideoReady 回调通过 emit 通知父组件 Video.vue:这个播放器准备好了。Video.vue 统计就绪数量,全部就绪后才隐藏 loading 页。
五、队列管理:handleVideo.js —— 5 视频滑动窗口
[handleVideo.js](file:///g:/Save/Grogramming/Vue3/yike/src/utils/handleVideo.js) 是整个播放系统的引擎。
5.1 为什么是 5 个?
用户正在看当前视频
↑
上一个(预留在内存中,往回滑不卡)
↑
当前 ← 正在播放
↓
下一个(预加载,往下滑瞬间切换)
↓
下下一个(预加载,再往下滑也不卡)
5 个是一个平衡点:
- 太少(如 3 个)→ 连续滑两次就没缓存了
- 太多(如 10 个)→ 5 个
<video>标签同时存在内存压力大,而且 video.js 每个实例都有开销
5.2 两种播放模式
随机模式(首页 /):
getRandomList(page=1, limit=15) → 一次拿 15 集的元信息
↓
从 15 集中取出 5 个 → 逐个调 getVideoAdd 拿到真实播放地址
↓
维护 videoInfoList (长度=5)
↓
用户滑到底 → 重新取 15 集 → 再取 5 个
追剧模式(/play/:vid/:eid):
getAllEpisode(vid, limit=1000) → 一次拿该剧全部 1000 集
↓
按 eid 过滤 → 只保留 >= 当前 eid 的剧集
↓
从过滤后的列表取 5 集 → 逐个拿播放地址
5.3 已知性能问题
handleVideo.js 里有一个明显的优化点——视频地址是串行获取的:
while (videoInfoList.value.length < VIDEO_LIST_LENGTH) {
const videoInfo = await randomList.value.pop()
const add = await getVideoAdd(videoInfo.eid) // ← 一个一个等
videoInfo.url2 = add
videoInfoList.value.push(videoInfo)
}
这意味着 5 个视频地址要等 5 次网络往返。如果改成 Promise.all,可以并行请求,理论上快 5 倍。当时写的时候优先保证功能正确性,这是后续优化的 TODO。
六、Video.vue —— 竖屏滑动容器
[Video.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/Video.vue) 作为外层容器,负责:
- 维护
curIndex:当前正在播放的视频在队列中的索引 - 触摸/鼠标/滚轮/键盘事件:
touchstart/move/end、mousedown/move/up、wheel、ArrowUp/ArrowDown - 滑动判断:累计位移超过 80px 触发切换,否则回弹
scrollTo({ behavior: 'smooth' })实现丝滑切换- Loading 状态管理:通过
videosIsReady计算属性,等所有 PlayBar 都 emitonVideoReady后才隐藏 loading
6.1 切换逻辑
// 触摸结束
if (offsetY > 80) → prev() // 上滑 → 上一个视频
if (offsetY < -80) → next() // 下滑 → 下一个视频
else → scrollToCurrent() // 回弹
6.2 滚动容器的 CSS
.video-container {
height: 100vh;
overflow: scroll;
scrollbar-width: none; // Firefox 隐藏滚动条
&::-webkit-scrollbar { display: none; } // Chrome 隐藏滚动条
}
这是一个原生滚动容器,不是一个虚拟滚动(那是 TreeDemo 那边的东西)。5 个 PlayBar 各占 100vh,通过 scrollTop 切换。
七、样式定制 —— 覆盖 video.js 默认皮肤
[play.scss](file:///g:/Save/Grogramming/Vue3/yike/src/styles/play.scss) 覆盖了 video.js 的默认样式:
.vjs-control-bar {
background-color: rgba(255, 0, 0, 0) !important; // 透明背景
.vjs-progress-holder {
background-color: rgba(255, 255, 255, 0.25); // 半透明白底
border-radius: 50px; // 圆角进度条
}
.vjs-play-progress.vjs-slider-bar {
border-radius: 50px; // 播放进度圆角
}
.vjs-poster {
filter: blur(3px); // 海报模糊效果
transform: scale(1.05);
}
}
设计意图:抖音风的控制栏是透明底 + 圆角进度条,完全不同于 video.js 默认的深色半透明条。!important 是不得已——video.js 的默认样式优先级很高。
八、整体数据流
用户操作(滑动/点击)
│
▼
Video.vue (curIndex 变化)
│
├─→ 通过 props.Playing 告诉 PlayBar 是否播放
│
└─→ scrollToCurrent() 滚动到对应视频
│
▼
PlayBar 收到 Playing prop
│
├─ true → player.play()
└─ false → player.pause()
│
▼
video.js 实例控制 <video> 标签
总结:三个关键设计决策
| 决策 | 选择 | 原因 |
|---|---|---|
| 加载方式 | CDN 而非 npm | 减小打包体积、利用浏览器缓存、避开构建兼容问题 |
| 队列大小 | 5 个视频 | 内存与流畅度的平衡点 |
| 控制栏 | 只保留进度条 | 抖音式交互——点击画面控制播放,侧边按钮控制静音 |
每个决策都是为"抖音式竖屏短剧体验"这个目标服务的。不是 video.js 的默认用法,而是根据产品需求做了大量裁剪和定制。
好的,这个问题问得非常到位,直接涉及到视频类产品的核心体验策略。让我结合这个项目的实际做法和业界通用原则来讲。
这个项目的做法:先加载"壳",再加载"肉"
看 [Video.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/Video.vue#L242-L260) 的模板部分:
<Transition>
<KeepAlive>
<div class="loading-page" v-if="!videosIsReady">
<LoadingPage />
</div>
</KeepAlive>
</Transition>
<PlayBar
v-for="(video, index) in videoInfoList"
:key="video.eid"
:Playing="index == curIndex"
...
/>
以及就绪判断逻辑([Video.vue#L297-L307](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/Video.vue#L297-L307)):
const videosIsReady = computed(() => {
if (
readyVideoNum.value >= videoInfoList.value.length &&
videoInfoList.value.length != 0 &&
!isRequiring.value
) {
return true
}
return false
})
const handleVideoReady = (e) => {
readyVideoNum.value += e
}
这个流程是:
1. Video.vue created → 立即渲染 LoadingPage(壳)
↓
2. onBeforeMount → requireNew() 发起 API 请求拿数据
↓
3. 数据返回 → videoInfoList 填充 → 渲染 5 个 PlayBar 组件
↓
4. 每个 PlayBar mounted → 初始化 video.js → player ready
↓
5. emit('onVideoReady') → readyVideoNum++ → 全部就绪?
↓ 是
6. videosIsReady = true → LoadingPage 隐藏 → 视频画面出现
结论:UI 壳(Loading + 播放器容器)先出来,视频流最后加载。
业界通用的加载策略:分层渲染
几乎所有成熟的视频产品都遵循这个层次:
┌──────────────────────────────────────────┐
│ 第 1 层:UI 壳(立即渲染) │
│ · 导航栏、侧边按钮、底部信息栏 │
│ · 播放器容器(黑色背景占位) │
│ · Loading 动画 / 骨架屏 │
│ · 海报图(poster) │
├──────────────────────────────────────────┤
│ 第 2 层:封面图(最快到达) │
│ · video.js 的 poster 属性 │
│ · 或者一个独立的 <img> 标签 │
│ · 通常用 CDN,秒加载 │
├──────────────────────────────────────────┤
│ 第 3 层:播放器 SDK 初始化 │
│ · video.js / hls.js / shaka-player 等 │
│ · 创建 MediaSource / MSE 实例 │
├──────────────────────────────────────────┤
│ 第 4 层:视频首帧(最关键) │
│ · 发起网络请求获取视频流 │
│ · 解码第一帧 → 用户看到画面动起来了 │
└──────────────────────────────────────────┘
核心原则一句话:让用户"看到东西"的延迟尽可能短,视频数据是最后一步。
为什么要这样分层?
1. 心理感知时间
| 策略 | 用户感受 |
|---|---|
| 白屏 → 等 3 秒 → 视频出现 | "卡死了,关掉" |
| 骨架屏 → 封面图(0.5s) → 封面变视频(2s) | "挺快的,已经在播了" |
白屏是最差的体验,因为它让用户觉得"什么都没发生"。Loading 动画或骨架屏告诉用户"正在加载中,请稍等"。
2. 这个项目的 poster 策略
看 [PlayBar.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/PlayBar.vue#L477-L487):
<video
:poster="props.img"
preload="auto"
muted
>
<source :src="props.url2" type="video/mp4" />
</video>
poster 是封面图,preload="auto" 告诉浏览器尽可能提前下载视频。当用户看到封面图的时候,视频数据已经在后台加载了。封面图是一张小图片(通常几十 KB),而视频是几十 MB——小的先到,大的后到。
再看 [play.scss](file:///g:/Save/Grogramming/Vue3/yike/src/styles/play.scss):
.vjs-poster {
filter: blur(3px); // 海报模糊
transform: scale(1.05); // 略微放大
}
封面图做了模糊处理,这样当视频首帧出现时,从模糊到清晰的过渡有一种"对焦完成"的感觉,比直接从黑屏跳到清晰画面自然得多。这是短视频产品常用的手法。
3. 数据获取的时序
回到 [Video.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/Video.vue) 的 onBeforeMount:
onBeforeMount(async () => {
await requireNew(route.params.vid, route.params.eid)
})
requireNew 里做了两件事:
- 先拿到剧集元信息(vid、eid、title、img 封面图 → 这些很小)
- 再逐一拿到视频播放地址(
getVideoAdd→ CDN URL)
元信息先到,封面图先渲染 → 用户先看到图 → 视频地址后到 → 视频流最后加载。
但这里有一个反模式:视频地址串行请求
[handleVideo.js](file:///g:/Save/Grogramming/Vue3/yike/src/utils/handleVideo.js#L52-L66):
while (videoInfoList.value.length < VIDEO_LIST_LENGTH) {
const videoInfo = await randomList.value.pop()
const add = await getVideoAdd(videoInfo.eid) // await 了 5 次
videoInfo.url2 = add
videoInfoList.value.push(videoInfo)
}
已有的剧集元信息是 15 个(看 getRandomList(limit=15)),只需要 5 个。但获取这 5 个播放地址是 串行的——第 1 个地址返回了才开始请求第 2 个。如果每个请求 300ms,5 个就是 1.5 秒。
应该改成 Promise.all 并行请求:
// 应该的做法(伪代码)
const videoInfos = randomList.value.splice(0, VIDEO_LIST_LENGTH)
const urls = await Promise.all(videoInfos.map(v => getVideoAdd(v.eid)))
// 一次等 5 个,总耗时 ≈ max(单个请求) 而不是 sum
这就是为什么 CLAUDE.md 里标记了这个问题为"已知优化点"。
总结
先加载什么:
UI 壳 → 封面图 → 播放器 SDK → 元信息 → 视频流
↑ ↑
0ms 200-800ms
用户立即看到"有东西" 用户看到画面动起来
永远不要让用户看到白屏。 哪怕你的视频流要等 3 秒,也要先用一个 loading 动画、一张封面图、一个骨架屏把空间填满。视频产品里,"先加载壳"是一个铁律——因为视频数据是最后一个到的,你不应该让它阻塞任何 UI 层的渲染。
一、项目现状分析
1.1 项目架构
- 框架: Vue 3 + Vite
- 视频播放: Video.js (CDN加载)
- 状态管理: Pinia
- 组件结构:
Video.vue (容器 - 管理滑动切换) └── PlayBar.vue (播放器 - Video.js实例)
1.2 核心问题清单
| 优先级 | 问题 | 位置 | 影响 |
|---|---|---|---|
| P0 | 内存泄漏 - 不销毁video.js实例 | PlayBar.vue | 长时间使用页面崩溃 |
| P1 | 视频串行加载慢 | handleVideo.js | 首屏加载时间长 |
| P1 | 滚动性能差 - 触发重排 | Video.vue | 滑动卡顿 |
| P2 | 事件冲突风险 | Video.vue + PlayBar.vue | 偶发交互异常 |
| P2 | 硬编码mock数据 | PlayBar.vue:72 | 可维护性差 |
二、问题详解与解决方案
2.1 滑动与点击事件冲突
问题描述
上下滑动切换视频时,可能误触发视频播放/暂停。
当前实现
// PlayBar.vue
let isDragging = false
const currentMoveThreshold = 30
function handleTouchStart(e) {
startX = touch.clientX
startY = touch.clientY
isDragging = false // 重置标志
}
function handleTouchMove(e) {
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (distance > currentMoveThreshold) {
isDragging = true // 超过阈值认为是滑动
}
}
function handleTouchEnd(e) {
if (!isDragging) {
// 点击才触发播放/暂停
player.paused() ? player.play() : player.pause()
}
}
优化方案
// 统一使用 Pointer Events (推荐)
const useGesture = () => {
const state = {
startX: 0,
startY: 0,
startTime: 0,
isDragging: false
}
const TAP_THRESHOLD = 30 // 像素
const TAP_TIME_THRESHOLD = 300 // 毫秒
const onPointerDown = (e) => {
state.startX = e.clientX
state.startY = e.clientY
state.startTime = Date.now()
state.isDragging = false
}
const onPointerMove = (e) => {
const dx = e.clientX - state.startX
const dy = e.clientY - state.startY
if (Math.sqrt(dx * dx + dy * dy) > TAP_THRESHOLD) {
state.isDragging = true
}
}
const onPointerUp = (e) => {
const duration = Date.now() - state.startTime
if (!state.isDragging && duration < TAP_TIME_THRESHOLD) {
// 确认是点击
handleTap()
}
}
return { onPointerDown, onPointerMove, onPointerUp }
}
Pointer Events(指针事件)是前端开发中一套统一的 DOM 事件模型,专门用来处理各种指针输入设备,比如鼠标、触控笔、单点或多点的手指触摸等。
简单来说,它的出现就是为了解决过去前端开发中“鼠标事件(Mouse Events)”和“触摸事件(Touch Events)”两套逻辑并存、需要分别维护的痛点。有了 Pointer Events,开发者可以**“一次编写,多端运行”**,无论是 PC 端还是移动端,都能用同一套代码完美处理拖拽、点击等交互。
结合你提供的代码,我来为你详细拆解一下它的核心概念以及你这段代码的精妙之处:
🎯 为什么推荐使用 Pointer Events?
在 Pointer Events 出现之前,如果你想写一个拖拽功能,通常需要同时监听 mousedown/mousemove/mouseup(给电脑用)和 touchstart/touchmove/touchend(给手机用)。而 Pointer Events 将它们统一成了语义完全相同的事件:
pointerdown:代替了mousedown和touchstartpointermove:代替了mousemove和touchmovepointerup:代替了mouseup和touchend
💡 你的代码在做什么?(精准的手势识别)
你提供的 useGesture 代码是一个非常经典的**“区分点击与拖拽”**的实战案例。它的核心逻辑在于:
- 记录起点:在
onPointerDown时,记录下手指或鼠标按下的初始坐标 (startX,startY) 和时间 (startTime)。 - 判断拖拽:在
onPointerMove时,计算当前移动的距离。如果移动距离超过了TAP_THRESHOLD(30像素),就认定用户是在“拖拽”,而不是“点击”。 - 确认点击:在
onPointerUp时,只有当isDragging为false(没怎么移动)且 按下时间小于TAP_TIME_THRESHOLD(300毫秒)时,才触发handleTap()。这完美避免了“拖拽结束时误触点击”的常见 Bug。
🚀 代码优化建议:加上“指针捕获”
虽然你的代码逻辑已经很清晰,但在实际拖拽场景中,如果用户的手指/鼠标移动速度过快,移出了元素本身,浏览器可能会丢失 pointermove 和 pointerup 的监听。
Pointer Events 提供了一个非常强大的 API:setPointerCapture。建议在 onPointerDown 中加上它,确保即使指针移出元素边界,事件依然能被捕获:
const onPointerDown = (e) => {
// 记录初始状态...
state.startX = e.clientX
state.startY = e.clientY
state.startTime = Date.now()
state.isDragging = false
// 【新增优化】锁定当前指针,确保快速拖拽时不会丢失 pointerup 事件
e.target.setPointerCapture(e.pointerId)
}
const onPointerUp = (e) => {
// 处理点击逻辑...
if (!state.isDragging && duration < TAP_TIME_THRESHOLD) {
handleTap()
}
// 【新增优化】释放指针锁定
e.target.releasePointerCapture(e.pointerId)
}
📊 Pointer Events 的独有优势
除了统一事件,它还提供了很多传统鼠标事件没有的专属属性,方便你做更精细的交互(比如画画应用):
e.pointerType:可以直接判断用户是用mouse(鼠标)、touch(手指)还是pen(触控笔)在操作,从而给出不同的 UI 反馈。e.pressure:能获取按压的力度(0到1之间),常用于手写笔压感。e.pointerId:每个触点都有唯一的 ID,是实现多点触控(比如双指缩放、旋转)的基础。
目前主流浏览器(Chrome, Firefox, Safari, Edge)都已经广泛支持 Pointer Events,在 2026 年的今天,它绝对是处理前端交互的首选方案。
要不要我把优化后的完整代码整理出来,方便你直接复制到项目里用?
2.2 内存泄漏(核心问题)
问题代码
// PlayBar.vue - 只创建不销毁
onMounted(() => {
player = window.videojs(props.videoInfo, options)
player.on('play', () => { ... })
player.on('ended', () => { ... })
})
// ❌ 缺少 onBeforeUnmount
修复方案
import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => {
if (player) {
// 1. 移除所有事件监听
player.off('play')
player.off('pause')
player.off('ended')
// 2. 暂停播放
player.pause()
// 3. 销毁实例 (会清理DOM和事件)
player.dispose()
player = null
}
})
内存泄漏检测方法
// Chrome DevTools 检测步骤:
// 1. Performance → Record → 执行滑动操作 → Stop
// 2. Memory → Take Heap Snapshot
// 3. 对比多次操作后的内存占用
// 4. 搜索 detached 查看游离DOM
// 代码中临时检测
if (window.performance && performance.memory) {
console.log('Used JS Heap:', performance.memory.usedJSHeapSize / 1048576, 'MB')
}
2.3 自动播放机制
当前实现
// Video.vue - 同时渲染5个视频,通过Playing控制
<PlayBar
v-for="(video, index) in videoInfoList"
:key="video.eid"
:Playing="index == curIndex"
/>
面试点:为什么需要用户交互后才能播放?
// 浏览器自动播放策略 (Autoplay Policy)
// Chrome 66+ 要求:
// 1. 静音视频可以自动播放
// 2. 用户与域名有过交互后可以播放有声视频
// 最佳实践
const autoPlay = async (player) => {
try {
await player.play()
} catch (err) {
// 自动播放被阻止,显示播放按钮
showPlayButton = true
}
}
// 检测自动播放权限
const canAutoPlay = async () => {
const video = document.createElement('video')
video.muted = true
try {
await video.play()
return true
} catch {
return false
}
}
2.4 视频队列与预加载
当前问题:串行加载慢
// handleVideo.js - 串行加载(慢)
while (videoInfoList.value.length < VIDEO_LIST_LENGTH) {
const videoInfo = await randomList.value.pop()
const add = await getVideoAdd(videoInfo.eid) // 等待每个请求
videoInfo.url2 = add
videoInfoList.value.push(videoInfo)
}
优化:并行加载
// 优化后 - 并行加载(快5倍)
const loadVideosParallel = async () => {
const videosToLoad = Array(VIDEO_LIST_LENGTH)
.fill()
.map(() => randomList.value.pop())
const loadedVideos = await Promise.all(
videosToLoad.map(async (videoInfo) => {
const url = await getVideoAdd(videoInfo.eid)
return { ...videoInfo, url2: url }
})
)
videoInfoList.value = loadedVideos
}
进阶:预加载策略
// 预加载下一个视频
const preloadNextVideo = (currentIndex) => {
const nextIndex = currentIndex + 1
if (nextIndex < videoInfoList.value.length) {
const nextVideo = videoInfoList.value[nextIndex]
const link = document.createElement('link')
link.rel = 'preload'
link.href = nextVideo.url2
link.as = 'video'
document.head.appendChild(link)
}
}
2.5 滚动性能优化
当前问题
// Video.vue - scrollTo 触发重排 (Reflow)
videoElement.value.scrollTo({
top: curIndex.value * videoHeight, // 读取再写入
behavior: 'smooth'
})
优化方案:使用 Transform(GPU加速)
// CSS
.video-wrapper {
transform: translateY(var(--offset-y, 0));
transition: transform 0.3s ease;
will-change: transform;
}
// JS
const scrollToCurrent = (index) => {
const offset = -index * 100 // vh单位
videoWrapper.value.style.setProperty('--offset-y', `${offset}vh`)
}
为什么 transform 比 scrollTop 快?
scrollTop:触发布局计算(Layout)→ 绘制(Paint)→ 合成(Composite)transform:直接合成层(Composite),跳过布局和绘制
三、面试常见问题
Q1: 移动端视频播放有哪些兼容性问题?
1. **全屏问题**
- iOS:强制全屏播放,无法禁止
- 解决:使用 inline 属性 + playsinline
```html
<video playsinline webkit-playsinline x5-playsinline>
自动播放
- 微信内置浏览器:首次必须用户点击
- 解决:监听 WeixinJSBridgeReady
层级问题
- 安卓:视频默认置顶,会覆盖其他元素
- 解决:使用 cover-view 或同层播放器
黑屏/白屏
- 原因:视频编码不支持(如H.265)
- 解决:提供多格式源或转码
### Q2: 如何设计一个流畅的短视频滑动组件?
```markdown
1. **渲染优化**
- 虚拟列表:只渲染当前+前后各1个视频(3个)
- 回收复用:滑动后销毁移出屏幕的视频
2. **内存优化**
- 限制同时存在的video实例数(建议3-5个)
- 及时调用 dispose() 销毁播放器
- 使用对象池复用播放器实例
3. **加载优化**
- 首屏只加载当前视频
- 预加载下一个视频(静音+暂停)
- 使用 HTTP Range 请求分段加载
4. **交互优化**
- 滑动距离阈值(如80px)才切换
- 滑动速度检测(快速滑动直接切换)
- 吸附效果(snap)
Q3: video.js 和原生 video 各有什么优缺点?
| 特性 | Video.js | 原生 video |
|---|---|---|
| UI定制 | 丰富,可自定义皮肤 | 有限,各浏览器不同 |
| 插件生态 | 丰富(广告、统计等) | 无 |
| 体积 | 较大(需额外加载) | 无额外体积 |
| 学习成本 | 需要学习API | 直接使用 |
| 移动端 | 可能需要适配 | 原生支持好 |
| 直播支持 | HLS/DASH 支持好 | 有限 |
Q4: 视频首屏加载慢怎么优化?
1. **视频本身优化**
- 压缩视频(FFmpeg转码)
- 使用 CDN 分发
- 自适应码率(根据网络选择清晰度)
2. **加载策略**
- 封面图占位(poster属性)
- 懒加载:进入视口才加载视频
- 分段加载(HTTP 206 Partial Content)
3. **用户体验**
- 显示加载进度
- 先播放低清晰度版本,再切换高清
- 预加载策略:preload="metadata" 只加载元数据
Q5: 如何检测视频卡顿?
// 方法1:检测播放帧率
let lastTime = 0
let frameCount = 0
const checkStutter = () => {
const now = performance.now()
frameCount++
if (now - lastTime >= 1000) {
const fps = frameCount
if (fps < 20) {
console.warn('视频卡顿,FPS:', fps)
}
frameCount = 0
lastTime = now
}
requestAnimationFrame(checkStutter)
}
// 方法2:检测 buffered 进度
const checkBuffering = (video) => {
const buffered = video.buffered
const currentTime = video.currentTime
// 查找当前时间是否在已缓冲范围内
let isBuffered = false
for (let i = 0; i < buffered.length; i++) {
if (currentTime >= buffered.start(i) && currentTime <= buffered.end(i)) {
isBuffered = true
break
}
}
if (!isBuffered) {
console.log('正在缓冲...')
}
}
// 方法3:监听 waiting 事件
video.addEventListener('waiting', () => {
console.log('视频因缓冲暂停')
})
video.addEventListener('playing', () => {
console.log('视频恢复播放')
})
四、修复计划
Phase 1: 紧急修复(内存泄漏)
- [ ] 在 PlayBar.vue 添加 onBeforeUnmount 钩子
- [ ] 调用 player.dispose() 销毁实例
- [ ] 测试内存占用变化
Phase 2: 性能优化
- [ ] handleVideo.js 串行改并行加载
- [ ] Video.vue scrollTo 改为 transform
- [ ] 添加视频预加载逻辑
Phase 3: 体验优化
- [ ] 统一手势判断逻辑
- [ ] 添加加载状态提示
- [ ] 优化首屏加载速度