一刻短剧项目视频优化计划
> 文档版本:v1.0
> 创建日期:2026/04/18
> 适用范围:前端视频播放性能优化
目录
一、项目现状分析
1.1 技术栈概览
| 项目 | 技术选型 | 版本 |
|---|---|---|
| 前端框架 | Vue 3 | 3.5.13 |
| 构建工具 | Vite | 7.0.6 |
| 视频播放 | Video.js | 8.23.4 (CDN) |
| 状态管理 | Pinia | 3.0.3 |
| HTTP客户端 | Axios | - |
1.2 核心视频组件架构
Video.vue (容器)
├── PlayBar.vue x 5 (视频播放器实例)
│ ├── Video.js 实例
│ ├── 播放控制
│ └── 交互手势
└── LoadingPage.vue (加载占位)
1.3 当前问题清单
| 问题类别 | 具体问题 | 严重程度 | 影响 |
|---|---|---|---|
| 加载性能 | 视频URL串行获取 | 🔴 高 | 首屏时间 = 5 × RTT |
| 内存泄漏 | 播放器实例未销毁 | 🔴 高 | 内存持续增长 |
| 渲染性能 | 滚动触发频繁重排 | 🟡 中 | 滑动卡顿 |
| 带宽浪费 | 固定5视频预加载 | 🟡 中 | 不必要的流量消耗 |
| 用户体验 | 无断点续播 | 🟡 中 | 重复观看体验差 |
| 网络适配 | 无网络状态感知 | 🟢 低 | 弱网体验差 |
二、视频加载优化
2.1 问题分析
当前实现 (src/utils/handleVideo.js):
// ❌ 问题:while循环串行获取视频URL
while (videoInfoList.value.length < VIDEO_LIST_LEBGTH) {
const videoInfo = await randomList.value.pop()
const add = await getVideoAdd(videoInfo.eid) // 串行请求
videoInfo.url2 = add
videoInfoList.value.push(videoInfo)
}
问题影响:
- 5个视频逐个请求,假设每个请求200ms,总耗时 1000ms
- 任何一个请求失败会导致整个队列加载中断
- 用户必须等待全部5个视频就绪才能开始播放
2.2 优化方案
方案A:并行化视频队列初始化 (P0)
目标:将串行请求改为并行,缩短首屏时间
实现代码 (src/utils/handleVideo.js):
import { getRandom } from '@/apis/play'
import { getVideoAddress } from '@/apis/play'
import { ref } from 'vue'
import { createMessage } from './message'
import { getAllEpisode } from '@/apis/play'
const VIDEO_LIST_LENGTH = 5
const randomList = ref([])
const nextList = ref([])
const videoInfoList = ref([])
/**
* @description 批量获取随机视频列表
* @param {Number} page 页码
* @param {Number} limit 每页数量
*/
const getRandomList = async (page = 1, limit = 15) => {
let res = await getRandom(page, limit)
randomList.value = res.data.data.data
}
/**
* @description 获取下一批剧集列表
* @param {Number} vid 视频ID
* @param {Number} page 页码
* @param {Number} limit 每页数量
*/
const getNextList = async (vid, page = 1, limit = 1000) => {
let res = await getAllEpisode(vid, page, limit)
nextList.value = res.data.data.data.reverse()
}
/**
* @description 获取视频播放地址
* @param {Number} eid 剧集ID
* @returns {Promise<String>} 视频URL
*/
const getVideoAdd = async (eid) => {
try {
let res = await getVideoAddress(eid)
return res.data.data.url
} catch (error) {
console.error(`获取视频地址失败 eid=${eid}:`, error)
return null
}
}
let preVid = 0
let preEid = 0
/**
* @description 并行更新视频队列(优化版)
* @param {Number} vid 视频ID,用于追剧模式
* @param {Number} eid 剧集ID,用于追剧模式
* @returns {Promise<Array>} 视频信息列表
*/
const updateVideoList = async (vid, eid) => {
const isRandom = !vid || !eid
// 1. 确保列表数据已加载
if (isRandom) {
if (randomList.value.length === 0) {
await getRandomList()
}
} else {
if (preEid !== eid || preVid !== vid || nextList.value.length === 0) {
preEid = eid
preVid = vid
await getNextList(vid)
const temp = nextList.value.filter((value) => value.eid >= eid)
nextList.value = temp
}
}
// 2. 并行获取所有视频地址(核心优化点)
const sourceList = isRandom ? randomList.value : nextList.value
const videosToLoad = sourceList.slice(0, VIDEO_LIST_LENGTH)
// 使用 Promise.all 并行请求
const videoPromises = videosToLoad.map(async (videoInfo) => {
const url = await getVideoAdd(videoInfo.eid)
return {
...videoInfo,
url2: url
}
})
// 等待所有请求完成,失败的返回 null
const results = await Promise.allSettled(videoPromises)
// 3. 过滤失败的请求,只保留成功获取URL的视频
videoInfoList.value = results
.filter(result => result.status === 'fulfilled' && result.value.url2)
.map(result => result.value)
// 4. 如果成功获取的视频不足,补充加载
if (videoInfoList.value.length < VIDEO_LIST_LENGTH && sourceList.length > VIDEO_LIST_LENGTH) {
const additionalCount = VIDEO_LIST_LENGTH - videoInfoList.value.length
const additionalVideos = sourceList.slice(VIDEO_LIST_LENGTH, VIDEO_LIST_LENGTH + additionalCount)
const additionalPromises = additionalVideos.map(async (videoInfo) => {
const url = await getVideoAdd(videoInfo.eid)
return {
...videoInfo,
url2: url
}
})
const additionalResults = await Promise.allSettled(additionalPromises)
const additionalValid = additionalResults
.filter(result => result.status === 'fulfilled' && result.value.url2)
.map(result => result.value)
videoInfoList.value.push(...additionalValid)
}
// 5. 显示加载结果
setTimeout(() => {
if (videoInfoList.value.length > 0) {
createMessage(`加载成功 ${videoInfoList.value.length} 个视频`)
} else {
createMessage('加载失败,请重试')
}
}, 500)
return videoInfoList.value
}
export default updateVideoList
优化效果:
- 首屏时间从 ~1000ms 降低到 ~200ms (5倍提升)
- 单个视频加载失败不影响其他视频
- 弹性处理:即使部分请求失败,仍能播放已加载的视频
方案B:渐进式视频加载 (P1)
目标:优先加载首屏视频,延迟加载后续视频
实现代码 (src/views/Home/components/PlayBar.vue):
<template>
<div class="play">
<div class="video">
<video
:id="props.videoInfo"
ref="videoRef"
webkit-playsinline="true"
playsinline="true"
class="vjs-control-bar video-js"
:preload="preloadStrategy"
muted
:poster="props.img"
>
<source :src="props.url2" type="video/mp4" />
</video>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
// ... 其他 props
index: {
type: Number,
required: true
},
currentIndex: {
type: Number,
required: true
}
})
// 根据距离当前播放位置决定预加载策略
const preloadStrategy = computed(() => {
const distance = Math.abs(props.index - props.currentIndex)
if (distance === 0) {
// 当前播放视频:自动加载
return 'auto'
} else if (distance === 1) {
// 下一个视频:只加载元数据
return 'metadata'
} else {
// 其他视频:不预加载
return 'none'
}
})
</script>
2.3 渐进式解码优化
// 使用 requestVideoFrameCallback 优化解码时机
const optimizeDecoding = (videoElement) => {
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
let frameCount = 0
const onFrame = (now, metadata) => {
frameCount++
// 前3帧优先解码,之后降低优先级
if (frameCount <= 3) {
videoElement.style.contentVisibility = 'auto'
} else {
videoElement.style.contentVisibility = 'visible'
}
videoElement.requestVideoFrameCallback(onFrame)
}
videoElement.requestVideoFrameCallback(onFrame)
}
}
三、减少重排(Reflow)
3.1 问题分析
当前实现 (src/views/Home/components/Video.vue):
// ❌ 问题:每次滚动都读取 getBoundingClientRect() 触发重排
const scrollToCurrent = () => {
const videoElementRef = videoElement.value.getBoundingClientRect() // 强制重排
const videoHeight = videoElementRef.height
videoElement.value.scrollTo({
top: curIndex.value * videoHeight, // 修改 scrollTop 触发重排
behavior: 'smooth'
})
}
问题影响:
getBoundingClientRect()强制浏览器同步计算布局scrollTo改变滚动位置触发重排- 在
moveWithScroll中每5ms触发一次,造成滑动卡顿
3.2 优化方案
方案A:使用 Transform 替代 Scroll (P0)
目标:使用 GPU 加速的 transform 替代 scroll,避免重排
实现代码 (src/views/Home/components/Video.vue):
<template>
<!-- 添加滑动容器 -->
<div
ref="containerRef"
class="video-slider"
:style="sliderStyle"
@transitionend="onTransitionEnd"
>
<PlayBar
v-for="(video, index) in videoInfoList"
:key="video.eid"
:videoInfo="`video${video.title}${video.eid}`"
:eid="video.eid"
:episode="video.episode"
:episode_total="video.episode_total"
:img="video.img"
:is_vip="video.is_vip"
:title="video.title"
:url="video.url"
:url2="video.url2"
:vid="video.vid"
:Playing="index === curIndex"
:index="index"
:currentIndex="curIndex"
@onVideoReady="handleVideoReady"
@onEpisode="handleOnEpisode"
class="play"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import PlayBar from './PlayBar.vue'
import updateVideoList from '@/utils/handleVideo'
import { createMessage } from '@/utils/message.ts'
import { debounce } from 'lodash-es'
import { useRoute } from 'vue-router'
const route = useRoute()
// ===== 状态管理 =====
const curIndex = ref(0)
const videoInfoList = ref([])
const containerRef = ref(null)
const isAnimating = ref(false)
// 滑动状态
const touchState = ref({
startY: 0,
currentY: 0,
offsetY: 0,
isDragging: false
})
// 容器尺寸(缓存,避免频繁读取)
const containerHeight = ref(0)
// ===== 计算属性 =====
// 使用 transform 实现滑动,GPU 加速
const sliderStyle = computed(() => ({
transform: `translateY(-${curIndex.value * 100}%) translateY(${touchState.value.offsetY}px)`,
transition: touchState.value.isDragging
? 'none' // 拖动时无过渡,跟随手指
: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)', // 释放时平滑过渡
willChange: 'transform'
}))
// ===== 初始化 =====
onMounted(async () => {
// 缓存容器高度
updateContainerHeight()
window.addEventListener('resize', debounce(updateContainerHeight, 200))
// 加载初始视频
await requireNew(route.params.vid, route.params.eid)
})
const updateContainerHeight = () => {
if (containerRef.value) {
containerHeight.value = containerRef.value.parentElement?.clientHeight || window.innerHeight
}
}
// ===== 视频加载 =====
const isRequiring = ref(false)
const _requireNew = async (vid, eid) => {
isRequiring.value = true
videoInfoList.value = await updateVideoList(vid, eid)
isRequiring.value = false
}
const requireNew = debounce(_requireNew, 100)
// ===== 切换逻辑 =====
const next = async () => {
if (curIndex.value < videoInfoList.value.length - 1) {
curIndex.value++
} else {
createMessage('正在加载更多...')
await requireNew(route.params.vid, route.params.eid)
// 保留最后一个视频作为衔接,其他清空
if (videoInfoList.value.length > 0) {
videoInfoList.value = videoInfoList.value.slice(-1)
curIndex.value = 0
}
}
}
const prev = () => {
if (curIndex.value > 0) {
curIndex.value--
} else {
createMessage('已经是第一个了')
}
}
// ===== 触摸事件 =====
const TARGET_Y = 80 // 触发切换的最小距离
const onTouchStart = (e) => {
touchState.value.isDragging = true
touchState.value.startY = e.touches[0].clientY
touchState.value.currentY = touchState.value.startY
touchState.value.offsetY = 0
}
const onTouchMove = (e) => {
if (!touchState.value.isDragging) return
const y = e.touches[0].clientY
const deltaY = y - touchState.value.currentY
touchState.value.currentY = y
// 添加阻尼效果
const dampFactor = 0.6
touchState.value.offsetY += deltaY * dampFactor
}
const onTouchEnd = () => {
if (!touchState.value.isDragging) return
touchState.value.isDragging = false
const { offsetY } = touchState.value
// 判断滑动距离和速度
if (offsetY > TARGET_Y) {
prev()
} else if (offsetY < -TARGET_Y) {
next()
}
// 否则回弹到当前视频
touchState.value.offsetY = 0
}
// ===== 鼠标事件(桌面端)=====
const onMouseDown = (e) => {
touchState.value.isDragging = true
touchState.value.startY = e.clientY
touchState.value.currentY = e.clientY
touchState.value.offsetY = 0
}
const onMouseMove = (e) => {
if (!touchState.value.isDragging) return
const y = e.clientY
const deltaY = y - touchState.value.currentY
touchState.value.currentY = y
touchState.value.offsetY += deltaY * 0.6
}
const onMouseUp = () => {
onTouchEnd()
}
// ===== 滚轮事件 =====
const _handleWheel = (e) => {
if (touchState.value.isDragging) return
const delta = e.deltaY
if (delta > 50) {
next()
} else if (delta < -50) {
prev()
}
}
const handleWheel = debounce(_handleWheel, 150, { leading: true, trailing: false })
// ===== 键盘事件 =====
const keyupHandle = (e) => {
if (e.key === 'ArrowDown') {
next()
} else if (e.key === 'ArrowUp') {
prev()
}
}
onMounted(() => {
window.addEventListener('keyup', keyupHandle)
window.addEventListener('wheel', handleWheel, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('keyup', keyupHandle)
window.removeEventListener('wheel', handleWheel)
window.removeEventListener('resize', updateContainerHeight)
})
// ===== 加载状态 =====
const readyVideoNum = ref(0)
const videosIsReady = computed(() => {
return readyVideoNum.value >= Math.min(3, videoInfoList.value.length) &&
videoInfoList.value.length !== 0 &&
!isRequiring.value
})
const handleVideoReady = () => {
readyVideoNum.value++
}
</script>
<style scoped lang="scss">
.video-container {
height: 100vh;
width: 100%;
overflow: hidden; /* 隐藏溢出 */
position: relative;
background-color: #000;
}
.video-slider {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
/* 使用 will-change 提示浏览器优化 */
will-change: transform;
/* 强制 GPU 层 */
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
}
.play {
height: 100%;
width: 100%;
flex-shrink: 0;
overflow: hidden;
}
.loading-page {
position: absolute;
top: 0;
left: 0;
z-index: 100;
height: 100%;
width: 100%;
background-color: #000;
}
</style>
优化效果:
- 从 JS 计算的
scrollTop改为 GPU 加速的transform - 消除每5ms的强制重排
- 滑动帧率从 30fps 提升到 60fps
方案B:使用 CSS Containment (P1)
/* PlayBar.vue - 样式隔离 */
.play {
/* 限制样式计算范围 */
contain: layout style paint;
.video {
/* 防止子元素影响外部布局 */
contain: strict;
content-visibility: auto;
contain-intrinsic-size: 0 500px;
}
}
3.3 批量DOM更新优化
// 使用 requestAnimationFrame 批量处理DOM更新
const batchDOMUpdates = (() => {
let pendingUpdates = []
let rafId = null
const flush = () => {
pendingUpdates.forEach(update => update())
pendingUpdates = []
rafId = null
}
return (updateFn) => {
pendingUpdates.push(updateFn)
if (!rafId) {
rafId = requestAnimationFrame(flush)
}
}
})()
// 使用示例
batchDOMUpdates(() => {
playerElement.style.transform = `translateY(${newPosition}px)`
})
四、视频预加载策略
4.1 问题分析
当前策略:
- 固定加载5个视频
- 每个视频都预加载完整内容
- 不考虑网络状况
问题影响:
- 移动设备流量浪费
- 弱网环境下所有视频都加载缓慢
- 解码资源占用高
4.2 优化方案
方案A:智能预加载管理器 (P1)
目标:根据网络状态和用户行为动态调整预加载策略
实现代码 (src/utils/videoPreloadManager.js):
/**
* 视频预加载管理器
* 根据网络状况和用户行为智能预加载视频
*/
class VideoPreloadManager {
constructor() {
// 缓存存储
this.cache = new Map()
this.priorityQueue = []
// 配置参数
this.config = {
maxCacheSize: 3, // 最大缓存视频数
preloadRange: 2, // 预加载范围(前后各N个)
chunkSize: 1024 * 512 // 预加载分片大小 (512KB)
}
// 网络状态
this.networkStatus = {
effectiveType: '4g',
saveData: false,
downlink: 10
}
// 监听网络变化
this.initNetworkListener()
}
/**
* 初始化网络状态监听
*/
initNetworkListener() {
const connection = navigator.connection ||
navigator.mozConnection ||
navigator.webkitConnection
if (connection) {
this.updateNetworkStatus(connection)
connection.addEventListener('change', () => {
this.updateNetworkStatus(connection)
})
}
}
/**
* 更新网络状态并调整策略
*/
updateNetworkStatus(connection) {
this.networkStatus = {
effectiveType: connection.effectiveType || '4g',
saveData: connection.saveData || false,
downlink: connection.downlink || 10
}
// 根据网络调整策略
this.adjustStrategy()
}
/**
* 根据网络状态调整预加载策略
*/
adjustStrategy() {
const { effectiveType, saveData } = this.networkStatus
if (saveData) {
// 省流模式:只预加载当前视频
this.config.maxCacheSize = 1
this.config.preloadRange = 0
} else {
switch (effectiveType) {
case '4g':
this.config.maxCacheSize = 3
this.config.preloadRange = 2
break
case '3g':
this.config.maxCacheSize = 2
this.config.preloadRange = 1
break
case '2g':
case 'slow-2g':
this.config.maxCacheSize = 1
this.config.preloadRange = 0
break
default:
this.config.maxCacheSize = 2
this.config.preloadRange = 1
}
}
console.log(`[PreloadManager] 网络: ${effectiveType}, 缓存: ${this.config.maxCacheSize}, 范围: ${this.config.preloadRange}`)
// 清理超出限制的缓存
this.cleanupCache()
}
/**
* 预加载视频
* @param {Object} videoInfo 视频信息
* @param {Number} priority 优先级(0:当前, 1:下一个, 2:其他)
*/
async preload(videoInfo, priority = 2) {
const { eid, url2 } = videoInfo
// 已缓存或优先级过低
if (this.cache.has(eid) || priority > this.config.preloadRange) {
return
}
// 取消低优先级预加载
this.cancelLowPriority(priority)
try {
// 创建 AbortController 用于取消
const controller = new AbortController()
// 存储预加载信息
this.cache.set(eid, {
videoInfo,
priority,
controller,
status: 'loading',
blob: null
})
this.updatePriorityQueue()
// 根据优先级决定预加载策略
if (priority === 0) {
// 当前视频:完整加载
await this.fullPreload(eid, url2, controller)
} else {
// 其他视频:分片预加载(前512KB)
await this.chunkPreload(eid, url2, controller)
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error(`[PreloadManager] 预加载失败 eid=${eid}:`, error)
}
this.cache.delete(eid)
}
}
/**
* 完整预加载
*/
async fullPreload(eid, url, controller) {
const response = await fetch(url, {
signal: controller.signal
})
const blob = await response.blob()
const cacheEntry = this.cache.get(eid)
if (cacheEntry) {
cacheEntry.status = 'complete'
cacheEntry.blob = blob
cacheEntry.objectUrl = URL.createObjectURL(blob)
}
}
/**
* 分片预加载(只加载开头部分)
*/
async chunkPreload(eid, url, controller) {
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Range': `bytes=0-${this.config.chunkSize}`
}
})
const blob = await response.blob()
const cacheEntry = this.cache.get(eid)
if (cacheEntry) {
cacheEntry.status = 'partial'
cacheEntry.blob = blob
cacheEntry.objectUrl = URL.createObjectURL(blob)
}
}
/**
* 取消低优先级预加载
*/
cancelLowPriority(currentPriority) {
this.cache.forEach((entry, eid) => {
if (entry.priority > currentPriority && entry.controller) {
entry.controller.abort()
this.cache.delete(eid)
}
})
}
/**
* 更新优先级队列
*/
updatePriorityQueue() {
this.priorityQueue = Array.from(this.cache.entries())
.sort((a, b) => a[1].priority - b[1].priority)
}
/**
* 清理缓存
*/
cleanupCache() {
while (this.cache.size > this.config.maxCacheSize) {
// 删除优先级最低的
const lowestPriority = Array.from(this.cache.entries())
.sort((a, b) => b[1].priority - a[1].priority)[0]
if (lowestPriority) {
const [eid, entry] = lowestPriority
if (entry.controller) {
entry.controller.abort()
}
if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl)
}
this.cache.delete(eid)
}
}
}
/**
* 获取预加载的视频URL
*/
getPreloadedUrl(eid) {
const entry = this.cache.get(eid)
return entry?.objectUrl || null
}
/**
* 释放资源
*/
dispose() {
this.cache.forEach((entry) => {
if (entry.controller) {
entry.controller.abort()
}
if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl)
}
})
this.cache.clear()
this.priorityQueue = []
}
}
// 单例导出
let instance = null
export const getPreloadManager = () => {
if (!instance) {
instance = new VideoPreloadManager()
}
return instance
}
export default VideoPreloadManager
集成到 Video 组件:
// Video.vue
import { getPreloadManager } from '@/utils/videoPreloadManager'
const preloadManager = getPreloadManager()
// 监听当前索引变化,调整预加载
watch(curIndex, (newIndex) => {
const videos = videoInfoList.value
// 预加载当前视频(高优先级)
if (videos[newIndex]) {
preloadManager.preload(videos[newIndex], 0)
}
// 预加载下一个视频
if (videos[newIndex + 1]) {
preloadManager.preload(videos[newIndex + 1], 1)
}
// 预加载上一个视频
if (videos[newIndex - 1]) {
preloadManager.preload(videos[newIndex - 1], 1)
}
// 清理已远离的视频缓存
videos.forEach((video, index) => {
const distance = Math.abs(index - newIndex)
if (distance > 2) {
// 从缓存中移除
}
})
})
方案B:IntersectionObserver 延迟加载 (P2)
// 使用 IntersectionObserver 实现可视区加载
const useLazyVideo = (containerRef) => {
const videoObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const video = entry.target
const videoId = video.dataset.eid
if (entry.isIntersecting) {
// 进入视口
const preloadedUrl = preloadManager.getPreloadedUrl(videoId)
if (preloadedUrl && !video.src) {
video.src = preloadedUrl
video.load()
}
video.play().catch(() => {}) // 自动播放可能被阻止
} else {
// 离开视口
video.pause()
// 延迟释放资源
setTimeout(() => {
if (!video.isIntersecting) {
video.removeAttribute('src')
video.load()
}
}, 5000)
}
})
},
{
root: containerRef.value,
rootMargin: '100%', // 提前一屏开始加载
threshold: 0.5 // 50%可见时触发
}
)
return videoObserver
}
五、视频播放器管理
5.1 问题分析
当前问题:
- 同时存在5个 Video.js 实例
- 组件卸载时未调用
player.dispose() - 无播放器池复用机制
影响:
- 内存占用高(每个实例约 20-50MB)
- 低端设备卡顿甚至崩溃
- 切换视频时重新创建实例开销大
5.2 优化方案
方案A:播放器实例销毁 (P0)
目标:修复内存泄漏,确保组件卸载时释放资源
实现代码 (src/views/Home/components/PlayBar.vue):
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
videoInfo: String,
url2: String,
// ... 其他 props
})
const videoRef = ref(null)
let player = null
onMounted(() => {
initPlayer()
})
/**
* 初始化播放器
*/
const initPlayer = () => {
if (typeof window.videojs === 'undefined') {
console.error('video.js 未加载')
return
}
const options = {
aspectRatio: '9:16',
controlBar: {
fullscreenToggle: false,
playToggle: false,
volumeMenuButton: false,
timeDivider: false,
volumePanel: false,
pictureInPictureToggle: false,
remainingTimeDisplay: false,
},
controls: true,
preload: 'metadata',
html5: {
vhs: {
overrideNative: true,
limitRenditionByPlayerDimensions: true,
useDevicePixelRatio: true
}
}
}
player = window.videojs(videoRef.value, options, function onPlayerReady() {
emit('onVideoReady', 1)
})
// 绑定事件
player.on('play', () => {
isEnded.value = false
})
player.on('ended', () => {
isEnded.value = true
})
player.muted(isMute.value)
// 设置初始音量
player.volume(0.8)
}
/**
* 销毁播放器 - 关键修复点
*/
const disposePlayer = () => {
if (player) {
// 1. 暂停播放
player.pause()
// 2. 移除所有事件监听
player.off()
// 3. 销毁实例(释放内存)
player.dispose()
// 4. 清空引用
player = null
console.log('[PlayBar] 播放器已销毁')
}
}
// 组件卸载时销毁播放器
onBeforeUnmount(() => {
disposePlayer()
})
// 视频源变化时复用播放器
watch(() => props.url2, (newUrl, oldUrl) => {
if (player && newUrl !== oldUrl) {
// 复用现有实例,只更换视频源
player.src({ src: newUrl, type: 'video/mp4' })
player.load()
player.play().catch(() => {})
}
})
</script>
<template>
<div class="play">
<video
ref="videoRef"
:id="props.videoInfo"
class="video-js vjs-default-skin vjs-big-play-centered"
webkit-playsinline="true"
playsinline="true"
:poster="props.img"
>
<source :src="props.url2" type="video/mp4" />
</video>
</div>
</template>
方案B:虚拟滚动 + 播放器池 (P1)
目标:限制同时存在的播放器实例数量
实现代码 (src/views/Home/components/VideoPool.vue):
<template>
<div class="video-pool-container">
<!-- 只渲染3个可见播放器 -->
<div
v-for="offset in visibleOffsets"
:key="getVideoAt(curIndex + offset)?.eid || offset"
class="video-slot"
:style="getSlotStyle(offset)"
>
<PlayBar
v-if="getVideoAt(curIndex + offset)"
:videoInfo="`video-${curIndex + offset}`"
:url2="getPreloadedUrl(curIndex + offset)"
:Playing="offset === 0"
@onVideoReady="handleVideoReady"
/>
</div>
<!-- 占位元素保持滚动位置 -->
<div
class="spacer"
:style="{ height: `${totalHeight}px` }"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import PlayBar from './PlayBar.vue'
import { getPreloadManager } from '@/utils/videoPreloadManager'
const props = defineProps({
videos: Array,
curIndex: Number
})
const preloadManager = getPreloadManager()
// 可见偏移量(-1:上一个, 0:当前, 1:下一个)
const visibleOffsets = [-1, 0, 1]
// 获取指定位置的视频
const getVideoAt = (index) => {
if (index < 0 || index >= props.videos.length) {
return null
}
return props.videos[index]
}
// 获取预加载的URL
const getPreloadedUrl = (index) => {
const video = getVideoAt(index)
if (!video) return ''
// 优先使用预加载的blob URL
const preloaded = preloadManager.getPreloadedUrl(video.eid)
return preloaded || video.url2
}
// 计算槽位样式
const getSlotStyle = (offset) => {
const basePosition = props.curIndex * 100 // vh
const offsetPosition = offset * 100
return {
position: 'absolute',
top: `${basePosition + offsetPosition}vh`,
height: '100vh',
width: '100%',
transform: 'translateZ(0)'
}
}
// 总高度
const totalHeight = computed(() => props.videos.length * 100)
// 监听索引变化,预加载周边视频
watch(() => props.curIndex, (newIndex) => {
// 预加载前后各2个视频
for (let i = -2; i <= 2; i++) {
const video = getVideoAt(newIndex + i)
if (video) {
const priority = Math.abs(i)
preloadManager.preload(video, priority)
}
}
}, { immediate: true })
</script>
<style scoped>
.video-pool-container {
position: relative;
height: 100vh;
overflow: hidden;
}
.video-slot {
will-change: transform;
backface-visibility: hidden;
}
.spacer {
pointer-events: none;
}
</style>
方案C:播放器池复用 (P2)
// utils/playerPool.js
/**
* 播放器池 - 复用 Video.js 实例
*/
class PlayerPool {
constructor(size = 3) {
this.size = size
this.pool = []
this.inUse = new Map()
this.waitingQueue = []
// 预创建播放器实例
this.preCreate()
}
/**
* 预创建播放器实例
*/
preCreate() {
for (let i = 0; i < this.size; i++) {
const container = document.createElement('div')
container.className = 'video-pool-slot'
container.style.display = 'none'
document.body.appendChild(container)
const player = window.videojs(container, {
aspectRatio: '9:16',
controls: true,
preload: 'none'
})
this.pool.push({
id: i,
player,
container,
available: true
})
}
}
/**
* 获取可用播放器
*/
acquire(videoId) {
// 检查是否已分配
if (this.inUse.has(videoId)) {
return this.inUse.get(videoId)
}
// 查找可用播放器
const available = this.pool.find(p => p.available)
if (available) {
available.available = false
this.inUse.set(videoId, available)
return available
}
// 无可用播放器,加入等待队列
return new Promise((resolve) => {
this.waitingQueue.push({ videoId, resolve })
})
}
/**
* 释放播放器
*/
release(videoId) {
const playerSlot = this.inUse.get(videoId)
if (playerSlot) {
// 重置播放器状态
playerSlot.player.pause()
playerSlot.player.src('')
playerSlot.player.reset()
playerSlot.container.style.display = 'none'
playerSlot.available = true
this.inUse.delete(videoId)
// 处理等待队列
if (this.waitingQueue.length > 0) {
const { videoId: nextId, resolve } = this.waitingQueue.shift()
const nextSlot = this.acquire(nextId)
resolve(nextSlot)
}
}
}
/**
* 销毁所有播放器
*/
dispose() {
this.pool.forEach(({ player, container }) => {
player.dispose()
container.remove()
})
this.pool = []
this.inUse.clear()
this.waitingQueue = []
}
}
export default new PlayerPool(3)
六、用户体验提升
6.1 首帧优化
目标:缩短从点击到首帧渲染的时间
// utils/firstFrameOptimizer.js
/**
* 首帧优化器
* 使用视频帧预览和渐进式加载提升感知性能
*/
export class FirstFrameOptimizer {
constructor() {
this.frameCache = new Map()
this.canvas = document.createElement('canvas')
this.ctx = this.canvas.getContext('2d')
}
/**
* 提取视频首帧作为封面
*/
async extractFrame(videoUrl) {
if (this.frameCache.has(videoUrl)) {
return this.frameCache.get(videoUrl)
}
return new Promise((resolve, reject) => {
const video = document.createElement('video')
video.crossOrigin = 'anonymous'
video.preload = 'metadata'
video.muted = true
video.onloadeddata = () => {
this.canvas.width = video.videoWidth
this.canvas.height = video.videoHeight
this.ctx.drawImage(video, 0, 0)
const dataUrl = this.canvas.toDataURL('image/jpeg', 0.8)
this.frameCache.set(videoUrl, dataUrl)
resolve(dataUrl)
}
video.onerror = reject
video.src = videoUrl
video.currentTime = 0.1 // 跳转到第一帧
})
}
/**
* 创建模糊预览图(LQIP)
*/
createBlurPreview(imageUrl, blurRadius = 20) {
return new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
this.canvas.width = 50 // 缩小尺寸
this.canvas.height = 50 * (img.height / img.width)
this.ctx.filter = `blur(${blurRadius}px)`
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height)
resolve(this.canvas.toDataURL('image/jpeg', 0.5))
}
img.src = imageUrl
})
}
}
6.2 播放进度持久化
目标:记住用户观看位置,支持断点续播
实现代码 (src/stores/useProgressStore.js):
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useProgressStore = defineStore('progress', () => {
// 播放记录
const records = ref({})
// 最大保存记录数
const MAX_RECORDS = 100
/**
* 保存播放进度
*/
const saveProgress = (vid, eid, currentTime, duration) => {
const key = `${vid}_${eid}`
records.value[key] = {
currentTime,
duration,
progress: duration > 0 ? (currentTime / duration) : 0,
timestamp: Date.now(),
vid,
eid
}
cleanup()
}
/**
* 获取播放进度
*/
const getProgress = (vid, eid) => {
const key = `${vid}_${eid}`
const record = records.value[key]
if (!record) return 0
// 如果已经看完95%以上,从头开始
if (record.progress > 0.95) {
return 0
}
// 跳过片头(如果从头开始且片头小于10秒)
if (record.currentTime < 10) {
return 0
}
return record.currentTime
}
/**
* 清理旧记录
*/
const cleanup = () => {
const entries = Object.entries(records.value)
if (entries.length > MAX_RECORDS) {
// 按时间排序,删除最早的
const sorted = entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
const toDelete = sorted.slice(0, entries.length - MAX_RECORDS)
toDelete.forEach(([key]) => {
delete records.value[key]
})
}
}
/**
* 获取最近观看列表
*/
const recentWatched = computed(() => {
return Object.values(records.value)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 20)
})
/**
* 清除记录
*/
const clearProgress = (vid, eid) => {
const key = `${vid}_${eid}`
delete records.value[key]
}
return {
records,
saveProgress,
getProgress,
recentWatched,
clearProgress
}
}, {
persist: {
storage: localStorage,
paths: ['records']
}
})
集成到 PlayBar:
<script setup>
import { useProgressStore } from '@/stores/useProgressStore'
const progressStore = useProgressStore()
const showResumeDialog = ref(false)
const savedTime = ref(0)
onMounted(() => {
// 恢复播放进度
const progress = progressStore.getProgress(props.vid, props.eid)
if (progress > 10) {
savedTime.value = progress
showResumeDialog.value = true
}
// 定期保存进度
const saveInterval = setInterval(() => {
if (player && !player.paused()) {
progressStore.saveProgress(
props.vid,
props.eid,
player.currentTime(),
player.duration()
)
}
}, 5000) // 每5秒保存一次
onBeforeUnmount(() => {
clearInterval(saveInterval)
})
})
// 继续播放
const resumePlayback = () => {
player.currentTime(savedTime.value)
player.play()
showResumeDialog.value = false
}
// 从头播放
const restartPlayback = () => {
player.currentTime(0)
player.play()
showResumeDialog.value = false
}
</script>
<template>
<div class="play">
<!-- 续播提示 -->
<div v-if="showResumeDialog" class="resume-dialog">
<p>上次观看到 {{ formatTime(savedTime) }}</p>
<div class="actions">
<button @click="resumePlayback">继续观看</button>
<button @click="restartPlayback">从头播放</button>
</div>
</div>
<video ref="videoRef" ... />
</div>
</template>
<style scoped>
.resume-dialog {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 8px;
color: white;
z-index: 1000;
text-align: center;
.actions {
margin-top: 15px;
display: flex;
gap: 10px;
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
&:first-child {
background: #fe2c55;
color: white;
}
&:last-child {
background: rgba(255, 255, 255, 0.2);
color: white;
}
}
}
}
</style>
6.3 网络状态感知
<!-- components/NetworkIndicator.vue -->
<template>
<Transition name="fade">
<div v-if="showIndicator" class="network-indicator" :class="networkClass">
<i :class="networkIcon"></i>
<span>{{ networkText }}</span>
</div>
</Transition>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const networkStatus = ref({
effectiveType: '4g',
saveData: false,
downlink: 10,
rtt: 50
})
const showIndicator = ref(false)
let hideTimeout = null
const networkClass = computed(() => `network-${networkStatus.value.effectiveType}`)
const networkIcon = computed(() => {
const type = networkStatus.value.effectiveType
if (type === '4g') return 'fas fa-signal'
if (type === '3g') return 'fas fa-signal'
if (type === '2g') return 'fas fa-signal'
return 'fas fa-wifi'
})
const networkText = computed(() => {
if (networkStatus.value.saveData) {
return '省流模式已开启'
}
const type = networkStatus.value.effectiveType
if (type === '2g' || type === 'slow-2g') {
return '网络较慢,已切换低清模式'
}
return ''
})
const updateNetworkStatus = () => {
const connection = navigator.connection ||
navigator.mozConnection ||
navigator.webkitConnection
if (connection) {
networkStatus.value = {
effectiveType: connection.effectiveType,
saveData: connection.saveData,
downlink: connection.downlink,
rtt: connection.rtt
}
// 弱网时显示提示
if (connection.effectiveType === '2g' ||
connection.effectiveType === 'slow-2g' ||
connection.saveData) {
showIndicator.value = true
clearTimeout(hideTimeout)
hideTimeout = setTimeout(() => {
showIndicator.value = false
}, 3000)
}
}
}
onMounted(() => {
const connection = navigator.connection ||
navigator.mozConnection ||
navigator.webkitConnection
if (connection) {
connection.addEventListener('change', updateNetworkStatus)
updateNetworkStatus()
}
})
onBeforeUnmount(() => {
const connection = navigator.connection ||
navigator.mozConnection ||
navigator.webkitConnection
if (connection) {
connection.removeEventListener('change', updateNetworkStatus)
}
clearTimeout(hideTimeout)
})
</script>
<style scoped>
.network-indicator {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
color: white;
border-radius: 20px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
z-index: 9999;
&.network-2g,
&.network-slow-2g {
background: rgba(255, 100, 0, 0.8);
}
i {
font-size: 12px;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
</style>
6.4 手势体验优化
// utils/gestureEnhancer.js
/**
* 手势增强器 - 添加阻尼、惯性和边界回弹
*/
export class GestureEnhancer {
constructor(options = {}) {
this.config = {
damping: 0.7, // 阻尼系数
bounce: 0.3, // 回弹系数
minVelocity: 0.5, // 最小惯性速度
deceleration: 0.95, // 减速率
...options
}
this.state = {
isDragging: false,
startY: 0,
currentY: 0,
velocity: 0,
offset: 0
}
this.rafId = null
}
/**
* 触摸开始
*/
onTouchStart(y) {
this.cancelAnimation()
this.state.isDragging = true
this.state.startY = y
this.state.currentY = y
this.state.velocity = 0
}
/**
* 触摸移动
*/
onTouchMove(y) {
if (!this.state.isDragging) return
const delta = y - this.state.currentY
this.state.currentY = y
this.state.velocity = delta
// 添加阻尼
const dampedDelta = delta * this.config.damping
this.state.offset += dampedDelta
return this.state.offset
}
/**
* 触摸结束
*/
onTouchEnd() {
this.state.isDragging = false
// 速度足够大时启动惯性动画
if (Math.abs(this.state.velocity) > this.config.minVelocity) {
this.startMomentum()
}
return this.state.offset
}
/**
* 启动惯性动画
*/
startMomentum() {
const animate = () => {
this.state.velocity *= this.config.deceleration
this.state.offset += this.state.velocity
// 触发回调
this.onUpdate?.(this.state.offset)
if (Math.abs(this.state.velocity) > 0.1) {
this.rafId = requestAnimationFrame(animate)
} else {
this.onEnd?.(this.state.offset)
}
}
this.rafId = requestAnimationFrame(animate)
}
/**
* 边界回弹
*/
bounceBack(targetOffset) {
const startOffset = this.state.offset
const delta = targetOffset - startOffset
let progress = 0
const animate = () => {
progress += 0.1
const easeProgress = 1 - Math.pow(1 - progress, 3) // easeOutCubic
this.state.offset = startOffset + delta * easeProgress
this.onUpdate?.(this.state.offset)
if (progress < 1) {
this.rafId = requestAnimationFrame(animate)
} else {
this.state.offset = targetOffset
this.onEnd?.(this.state.offset)
}
}
this.rafId = requestAnimationFrame(animate)
}
/**
* 取消动画
*/
cancelAnimation() {
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
}
}
七、性能监控方案
7.1 视频性能监控
// utils/videoPerformanceMonitor.js
/**
* 视频性能监控器
* 监控视频加载、播放性能和用户体验指标
*/
class VideoPerformanceMonitor {
constructor() {
this.metrics = {
// 加载指标
ttfb: [], // 首字节时间
firstFrame: [], // 首帧时间
bufferTime: [], // 缓冲时间
// 播放指标
stallCount: 0, // 卡顿次数
stallDuration: 0, // 卡顿时长
errorCount: 0, // 错误次数
// 体验指标
watchDuration: 0, // 观看时长
completionRate: 0 // 完播率
}
this.sessionStart = Date.now()
this.initResourceObserver()
}
/**
* 初始化资源监控
*/
initResourceObserver() {
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.initiatorType === 'video') {
this.recordLoadMetrics(entry)
}
}
})
observer.observe({ entryTypes: ['resource'] })
}
}
/**
* 记录加载指标
*/
recordLoadMetrics(entry) {
const metrics = {
url: entry.name,
duration: entry.duration,
ttfb: entry.responseStart - entry.startTime,
downloadTime: entry.responseEnd - entry.responseStart,
timestamp: Date.now()
}
this.metrics.ttfb.push(metrics.ttfb)
console.log('[Performance] 视频加载:', metrics)
}
/**
* 监控单个视频播放
*/
monitorVideo(videoElement, videoInfo) {
const startTime = Date.now()
let stallStart = null
let bufferStalls = []
// 首帧时间
videoElement.addEventListener('loadeddata', () => {
const firstFrameTime = Date.now() - startTime
this.metrics.firstFrame.push(firstFrameTime)
console.log(`[Performance] 首帧时间: ${firstFrameTime}ms`, videoInfo)
})
// 缓冲监控
videoElement.addEventListener('waiting', () => {
stallStart = Date.now()
this.metrics.stallCount++
})
videoElement.addEventListener('playing', () => {
if (stallStart) {
const stallDuration = Date.now() - stallStart
this.metrics.stallDuration += stallDuration
bufferStalls.push(stallDuration)
stallStart = null
console.log(`[Performance] 缓冲恢复,卡顿时长: ${stallDuration}ms`)
}
})
// 错误监控
videoElement.addEventListener('error', (e) => {
this.metrics.errorCount++
console.error('[Performance] 播放错误:', e, videoInfo)
})
// 完播监控
videoElement.addEventListener('ended', () => {
const watchTime = Date.now() - startTime
this.metrics.watchDuration += watchTime
console.log('[Performance] 视频播放完成:', {
watchTime,
bufferStalls,
videoInfo
})
})
}
/**
* 获取性能报告
*/
getReport() {
const avg = arr => arr.length ? (arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(2) : 0
return {
summary: {
avgTtfb: avg(this.metrics.ttfb),
avgFirstFrame: avg(this.metrics.firstFrame),
totalStalls: this.metrics.stallCount,
avgStallDuration: this.metrics.stallCount ? (this.metrics.stallDuration / this.metrics.stallCount).toFixed(2) : 0,
errorRate: this.metrics.errorCount,
sessionDuration: Date.now() - this.sessionStart
},
raw: this.metrics
}
}
/**
* 上报数据
*/
report() {
const report = this.getReport()
// 发送到分析服务器
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/video', JSON.stringify(report))
}
console.log('[Performance] 性能报告:', report)
return report
}
}
// 单例
let instance = null
export const getPerformanceMonitor = () => {
if (!instance) {
instance = new VideoPerformanceMonitor()
}
return instance
}
export default VideoPerformanceMonitor
7.2 性能指标看板
<!-- components/PerformancePanel.vue (开发环境) -->
<template>
<div v-if="showPanel" class="performance-panel">
<h3>视频性能监控</h3>
<div class="metrics">
<div class="metric">
<label>首帧时间:</label>
<span :class="getStatusClass(metrics.avgFirstFrame, 1000)">
{{ metrics.avgFirstFrame }}ms
</span>
</div>
<div class="metric">
<label>卡顿次数:</label>
<span>{{ metrics.totalStalls }}</span>
</div>
<div class="metric">
<label>平均卡顿时长:</label>
<span :class="getStatusClass(metrics.avgStallDuration, 500)">
{{ metrics.avgStallDuration }}ms
</span>
</div>
<div class="metric">
<label>错误数:</label>
<span :class="{ error: metrics.errorRate > 0 }">
{{ metrics.errorRate }}
</span>
</div>
</div>
<button @click="togglePanel">关闭</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getPerformanceMonitor } from '@/utils/videoPerformanceMonitor'
const showPanel = ref(process.env.NODE_ENV === 'development')
const metrics = ref({})
const monitor = getPerformanceMonitor()
onMounted(() => {
setInterval(() => {
const report = monitor.getReport()
metrics.value = report.summary
}, 2000)
})
const getStatusClass = (value, threshold) => ({
good: value < threshold * 0.5,
warning: value >= threshold * 0.5 && value < threshold,
error: value >= threshold
})
const togglePanel = () => {
showPanel.value = !showPanel.value
}
</script>
<style scoped>
.performance-panel {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 15px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
min-width: 200px;
h3 {
margin: 0 0 10px 0;
font-size: 14px;
}
.metrics {
.metric {
display: flex;
justify-content: space-between;
margin: 5px 0;
span {
&.good { color: #52c41a; }
&.warning { color: #faad14; }
&.error { color: #f5222d; }
}
}
}
button {
margin-top: 10px;
width: 100%;
padding: 5px;
background: #1890ff;
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
}
}
</style>
八、实施路线图
8.1 优先级划分
P0 (紧急 - 本周完成)
├── 1. 并行化视频队列初始化
├── 2. 播放器实例销毁修复
└── 3. Transform 替换 ScrollTop
P1 (重要 - 两周内完成)
├── 4. 智能预加载管理器
├── 5. CSS Containment 优化
├── 6. 播放进度持久化
└── 7. 虚拟滚动 + 播放器池
P2 (优化 - 一个月内完成)
├── 8. IntersectionObserver 延迟加载
├── 9. 首帧优化 (LQIP)
├── 10. 手势体验增强
└── 11. 网络状态感知
P3 (增强 - 后续迭代)
├── 12. HLS/DASH 分片加载
├── 13. 播放器池复用
└── 14. 性能监控体系
8.2 实施检查清单
Phase 1: 核心性能修复 (P0)
[ ] handleVideo.js 重构
- [ ] 将 while 循环改为 Promise.allSettled
- [ ] 添加错误处理和降级逻辑
- [ ] 测试并行加载效果
[ ] PlayBar.vue 内存泄漏修复
- [ ] 添加 onBeforeUnmount 钩子
- [ ] 实现 disposePlayer 方法
- [ ] 使用 Chrome DevTools Heap 验证修复
[ ] Video.vue 滑动优化
- [ ] 移除 getBoundingClientRect 调用
- [ ] 实现 transform-based 滑动
- [ ] 添加阻尼效果
- [ ] 测试低端设备滑动性能
Phase 2: 体验优化 (P1)
[ ] 预加载管理器
- [ ] 创建 VideoPreloadManager 类
- [ ] 集成 Network Information API
- [ ] 实现分片预加载
- [ ] 添加缓存淘汰策略
[ ] 播放进度持久化
- [ ] 创建 useProgressStore
- [ ] 集成到 PlayBar
- [ ] 添加续播提示 UI
- [ ] 测试数据持久化
Phase 3: 深度优化 (P2)
[ ] 虚拟滚动
- [ ] 重构 Video.vue 为虚拟滚动
- [ ] 限制同时渲染的播放器数量
- [ ] 添加平滑过渡动画
[ ] 首帧优化
- [ ] 实现 FirstFrameOptimizer
- [ ] 集成模糊预览图
- [ ] 添加骨架屏过渡
8.3 性能基准
| 指标 | 当前值 | 目标值 | 优化方案 |
|---|---|---|---|
| 首屏加载时间 | ~1000ms | <300ms | 并行加载 |
| 滑动帧率 | ~30fps | 60fps | Transform + GPU |
| 内存占用 (5视频) | ~150MB | <80MB | 播放器池 |
| 首帧时间 | ~500ms | <200ms | 预加载优化 |
| 卡顿率 | ~5% | <1% | 智能缓冲 |
| 完播率 | - | +15% | 进度持久化 |
8.4 测试计划
功能测试
- [ ] 视频正常播放、暂停、切换
- [ ] 滑动切换流畅无卡顿
- [ ] 断点续播功能正常
- [ ] 弱网环境降级正常
性能测试
- [ ] Chrome DevTools Performance 录制
- [ ] Lighthouse 性能评分
- [ ] 低端设备真机测试
- [ ] 内存泄漏检测 (Heap Snapshot)
兼容性测试
- [ ] iOS Safari
- [ ] Android Chrome
- [ ] 微信内置浏览器
- [ ] PC 端各浏览器
附录
A. 参考资源
- Video.js 官方文档: https://docs.videojs.com/
- Web 视频性能优化: https://web.dev/media/
- IntersectionObserver API: https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
- Network Information API: https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API
B. 常用调试命令
// 查看视频加载时间
performance.getEntriesByType('resource')
.filter(r => r.initiatorType === 'video')
.forEach(r => console.log(r.name, r.duration))
// 检查内存泄漏
// 1. 打开 Chrome DevTools Memory
// 2. 录制 Heap Snapshot
// 3. 操作后对比快照
// 监控视频播放事件
const video = document.querySelector('video')
video.addEventListener('waiting', () => console.log('缓冲开始'))
video.addEventListener('playing', () => console.log('播放恢复'))
video.addEventListener('stalled', () => console.log('数据不足'))
C. 性能预算
首屏加载: < 3s (4G网络)
可交互时间: < 5s
滑动响应: < 16ms (60fps)
内存占用: < 100MB
包体积增量: < 50KB (gzip)
文档结束