Pressidian
花园入口
笔记
项目
关于
实验室
GitHub
花园入口
笔记
项目
关于
实验室
GitHub

KNOWLEDGE PATHS

笔记库
当前位置
笔记库/前端/三件套/JS/优化

一刻短剧项目视频优化计划

6 分钟阅读 · Note

目录树 578 篇

            • 节流和防抖
            • 一刻短剧项目视频优化计划
          • 前端模块化
      • 前端技术栈
    • 笔记目录
    • CLAUDE.md
    • Vue 组件与 Render 函数

关联笔记 6

↗节流和防抖同一路径↗00 JavaScript学习地图共同主题↗01 JavaScript基础共同主题↗02 类型系统与类型转换共同主题↗03 Number与String共同主题↗04 Array数组共同主题
  • 一刻短剧项目视频优化计划

一刻短剧项目视频优化计划

> 文档版本:v1.0
> 创建日期:2026/04/18
> 适用范围:前端视频播放性能优化


目录

  1. 项目现状分析
  2. 视频加载优化
  3. 减少重排Reflow
  4. 视频预加载策略
  5. 视频播放器管理
  6. 用户体验提升
  7. 性能监控方案
  8. 实施路线图

一、项目现状分析

1.1 技术栈概览

项目技术选型版本
前端框架Vue 33.5.13
构建工具Vite7.0.6
视频播放Video.js8.23.4 (CDN)
状态管理Pinia3.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 问题分析

当前问题:

  1. 同时存在5个 Video.js 实例
  2. 组件卸载时未调用 player.dispose()
  3. 无播放器池复用机制

影响:

  • 内存占用高(每个实例约 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并行加载
滑动帧率~30fps60fpsTransform + GPU
内存占用 (5视频)~150MB<80MB播放器池
首帧时间~500ms<200ms预加载优化
卡顿率~5%<1%智能缓冲
完播率-+15%进度持久化

8.4 测试计划

功能测试

  • [ ] 视频正常播放、暂停、切换
  • [ ] 滑动切换流畅无卡顿
  • [ ] 断点续播功能正常
  • [ ] 弱网环境降级正常

性能测试

  • [ ] Chrome DevTools Performance 录制
  • [ ] Lighthouse 性能评分
  • [ ] 低端设备真机测试
  • [ ] 内存泄漏检测 (Heap Snapshot)

兼容性测试

  • [ ] iOS Safari
  • [ ] Android Chrome
  • [ ] 微信内置浏览器
  • [ ] PC 端各浏览器

附录

A. 参考资源

  1. Video.js 官方文档: https://docs.videojs.com/
  2. Web 视频性能优化: https://web.dev/media/
  3. IntersectionObserver API: https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
  4. 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)

文档结束