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

KNOWLEDGE PATHS

笔记库
当前位置
笔记库/前端/面试/项目/一刻

视频播放器交互设计与性能优化 - 面试准备文档

12 分钟阅读 · Note

目录树 578 篇

            • 视频播放器交互设计与性能优化 - 面试准备文档
        • 可投递企业
      • 前端技术栈
    • 笔记目录
    • CLAUDE.md
    • Vue 组件与 Render 函数

关联笔记 6

↗0旧简历实习共同主题↗1组件重构&权限面板共同主题↗2线上稳定&分页导出共同主题↗3监控埋点&错误共同主题↗4工程化&代码精简共同主题↗5职业素养&压力共同主题
  • 视频播放器交互设计与性能优化 - 面试准备文档

视频播放器交互设计与性能优化 - 面试准备文档

好,作为这个项目的开发者,我来以面试回答的方式,把 video.js 的集成设计讲清楚。


一、为什么选 video.js?

这是一个抖音式的竖屏短剧播放器,核心需求:

  • 竖屏 9:16 视频播放
  • 上下滑动切换视频(类似抖音)
  • 需要进度条、静音切换、播放/暂停
  • 移动端优先,必须支持 playsinline(iOS 内联播放)

video.js 是开箱即用的选择:它有完整的控制栏、事件系统、插件生态,而且对移动端 H.264/MP4 兼容性很好。我不想从零写一个播放器。


二、CDN 加载策略 —— 为什么不 npm 安装?

[这是最核心的设计决策]

你看 [index.html](file:///g:/Save/Grogramming/Vue3/yike/index.html#L6-L8):

<link href="https://vjs.zencdn.net/7.20.3/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/7.20.3/video.min.js"></script>

三个原因:

  1. video.js 本体很大(压缩后约 500KB+)。如果是 SPA 首屏加载,会严重拖慢 FCP。走 CDN 可以利用浏览器缓存——用户访问过任何用 video.js CDN 的网站,这块就是 0 成本。

  2. Vite 的构建速度。 video.js 是一个复杂的库,走 npm 构建时 Rollup 要处理大量 CommonJS 兼容和 polyfill,会拖慢开发时的 HMR。CDN 加载完全绕过了构建系统。

  3. 与服务端视频流 CDN 同源策略无关。 视频流来自后端 API 返回的 CDN 地址(见 [handleVideo.js](file:///g:/Save/Grogramming/Vue3/yike/src/utils/handleVideo.js#L27-L33) 的 getVideoAdd),播放器和视频源是完全解耦的。

代价是失去了 TypeScript 类型支持,所以我做了防御式判断:

// PlayBar.vue onMounted 里
if (typeof window.videojs === 'undefined') {
    console.error('video.js 未加载,请检查 CDN 引入')
    return
}

三、整体架构:三层设计

┌──────────────────────────────────────────────┐
│                  Video.vue                    │  ← 滚动容器层
│  管理 5 个视频队列                            │
│  处理上下滑动 / 滚轮 / 键盘事件                │
│                                              │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐     │
│  │ PlayBar  │ │ PlayBar  │ │ PlayBar  │ ... │  ← 播放器实例层
│  │ video.js │ │ video.js │ │ video.js │     │    每个都是独立播放器
│  └──────────┘ └──────────┘ └──────────┘     │
│        ↑           ↑           ↑            │
│  ┌──────────────────────────────────────┐    │
│  │        handleVideo.js                │    │  ← 队列管理层
│  │  · 随机模式:预加载 15 个剧集元信息   │    │
│  │  · 追剧模式:获取全部剧集(1000集)    │    │
│  │  · 维护 5 个视频的播放队列           │    │
│  └──────────────────────────────────────┘    │
└──────────────────────────────────────────────┘

四、核心组件:PlayBar.vue —— 单个播放器的完整封装

[PlayBar.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/PlayBar.vue#L254-L310) 是播放器的最小单元。

4.1 初始化配置

player = window.videojs(props.videoInfo, {
    height: '1415',
    width: '795.94',
    aspectRatio: '9:16',        // 竖屏比例
    controlBar: {
        fullscreenToggle: false,   // 禁用全屏按钮
        playToggle: false,         // 禁用播放按钮(我们用点击画面控制)
        volumeMenuButton: false,   // 禁用音量按钮(侧边栏自己控制)
        timeDivider: false,
        volumePanel: false,
        pictureInPictureToggle: false,
        remainingTimeDisplay: false,
    },
    userAction: { click: true, doubleClick: false },
    controls: true,
})

设计思路: 这是一个极简的控制栏。video.js 默认的控制栏是为 16:9 横屏设计的,按钮太多。我把播放/暂停、音量、全屏全部禁用,只保留进度条——因为抖音式的交互是"点击画面播放/暂停","侧边按钮控制静音"。

4.2 点击与拖拽的冲突处理

这是一个很重要的细节。视频画面需要同时支持:

  • 点击 → 播放/暂停
  • 上下拖拽 → 切换视频

如果直接监听 click 事件,用户在滑动切换视频时手指抬起的瞬间也会触发 click。所以我用了一套"位移阈值判断":

// 伪代码逻辑
onTouchStart → 记录起点 (startX, startY)
onTouchMove  → 计算移动距离,距离 > 30px → 标记 isDragging = true
onTouchEnd   → 如果 !isDragging → 这是点击,触发 播放/暂停
              如果 isDragging  → 这是滑动,交给 Video.vue 处理切换

阈值设为 30px,小于这个距离的视为点击,大于的视为滑动。

4.3 事件监听与状态同步

player.on('play', () => {
    dramaStore.updateWatchRecord(props.vid, props.eid)  // 播放时记录观看历史
    isEnded.value = false
})

player.on('ended', () => {
    isEnded.value = true    // 播完显示"已结束"遮罩
})

onVideoReady 回调通过 emit 通知父组件 Video.vue:这个播放器准备好了。Video.vue 统计就绪数量,全部就绪后才隐藏 loading 页。


五、队列管理:handleVideo.js —— 5 视频滑动窗口

[handleVideo.js](file:///g:/Save/Grogramming/Vue3/yike/src/utils/handleVideo.js) 是整个播放系统的引擎。

5.1 为什么是 5 个?

用户正在看当前视频
    ↑
  上一个(预留在内存中,往回滑不卡)
    ↑
  当前 ← 正在播放
    ↓
  下一个(预加载,往下滑瞬间切换)
    ↓
  下下一个(预加载,再往下滑也不卡)

5 个是一个平衡点:

  • 太少(如 3 个)→ 连续滑两次就没缓存了
  • 太多(如 10 个)→ 5 个 &lt;video&gt; 标签同时存在内存压力大,而且 video.js 每个实例都有开销

5.2 两种播放模式

随机模式(首页 /):

getRandomList(page=1, limit=15)  → 一次拿 15 集的元信息
         ↓
从 15 集中取出 5 个 → 逐个调 getVideoAdd 拿到真实播放地址
         ↓
维护 videoInfoList (长度=5)
         ↓
用户滑到底 → 重新取 15 集 → 再取 5 个

追剧模式(/play/:vid/:eid):

getAllEpisode(vid, limit=1000) → 一次拿该剧全部 1000 集
         ↓
按 eid 过滤 → 只保留 >= 当前 eid 的剧集
         ↓
从过滤后的列表取 5 集 → 逐个拿播放地址

5.3 已知性能问题

handleVideo.js 里有一个明显的优化点——视频地址是串行获取的:

while (videoInfoList.value.length < VIDEO_LIST_LENGTH) {
    const videoInfo = await randomList.value.pop()
    const add = await getVideoAdd(videoInfo.eid)   // ← 一个一个等
    videoInfo.url2 = add
    videoInfoList.value.push(videoInfo)
}

这意味着 5 个视频地址要等 5 次网络往返。如果改成 Promise.all,可以并行请求,理论上快 5 倍。当时写的时候优先保证功能正确性,这是后续优化的 TODO。


六、Video.vue —— 竖屏滑动容器

[Video.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/Video.vue) 作为外层容器,负责:

  1. 维护 curIndex:当前正在播放的视频在队列中的索引
  2. 触摸/鼠标/滚轮/键盘事件:touchstart/move/end、mousedown/move/up、wheel、ArrowUp/ArrowDown
  3. 滑动判断:累计位移超过 80px 触发切换,否则回弹
  4. scrollTo({ behavior: 'smooth' }) 实现丝滑切换
  5. Loading 状态管理:通过 videosIsReady 计算属性,等所有 PlayBar 都 emit onVideoReady 后才隐藏 loading

6.1 切换逻辑

// 触摸结束
if (offsetY > 80)   → prev()  // 上滑 → 上一个视频
if (offsetY < -80)  → next()  // 下滑 → 下一个视频
else                → scrollToCurrent()  // 回弹

6.2 滚动容器的 CSS

.video-container {
    height: 100vh;
    overflow: scroll;
    scrollbar-width: none;           // Firefox 隐藏滚动条
    &::-webkit-scrollbar { display: none; }  // Chrome 隐藏滚动条
}

这是一个原生滚动容器,不是一个虚拟滚动(那是 TreeDemo 那边的东西)。5 个 PlayBar 各占 100vh,通过 scrollTop 切换。


七、样式定制 —— 覆盖 video.js 默认皮肤

[play.scss](file:///g:/Save/Grogramming/Vue3/yike/src/styles/play.scss) 覆盖了 video.js 的默认样式:

.vjs-control-bar {
    background-color: rgba(255, 0, 0, 0) !important;  // 透明背景
    .vjs-progress-holder {
        background-color: rgba(255, 255, 255, 0.25);  // 半透明白底
        border-radius: 50px;                           // 圆角进度条
    }
    .vjs-play-progress.vjs-slider-bar {
        border-radius: 50px;                           // 播放进度圆角
    }
    .vjs-poster {
        filter: blur(3px);                             // 海报模糊效果
        transform: scale(1.05);
    }
}

设计意图:抖音风的控制栏是透明底 + 圆角进度条,完全不同于 video.js 默认的深色半透明条。!important 是不得已——video.js 的默认样式优先级很高。


八、整体数据流

用户操作(滑动/点击)
       │
       ▼
  Video.vue (curIndex 变化)
       │
       ├─→ 通过 props.Playing 告诉 PlayBar 是否播放
       │
       └─→ scrollToCurrent() 滚动到对应视频
              │
              ▼
         PlayBar 收到 Playing prop
              │
              ├─ true  → player.play()
              └─ false → player.pause()
                    │
                    ▼
              video.js 实例控制 <video> 标签

总结:三个关键设计决策

决策选择原因
加载方式CDN 而非 npm减小打包体积、利用浏览器缓存、避开构建兼容问题
队列大小5 个视频内存与流畅度的平衡点
控制栏只保留进度条抖音式交互——点击画面控制播放,侧边按钮控制静音

每个决策都是为"抖音式竖屏短剧体验"这个目标服务的。不是 video.js 的默认用法,而是根据产品需求做了大量裁剪和定制。

好的,这个问题问得非常到位,直接涉及到视频类产品的核心体验策略。让我结合这个项目的实际做法和业界通用原则来讲。


这个项目的做法:先加载"壳",再加载"肉"

看 [Video.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/Video.vue#L242-L260) 的模板部分:

<Transition>
    <KeepAlive>
        <div class="loading-page" v-if="!videosIsReady">
            <LoadingPage />
        </div>
    </KeepAlive>
</Transition>

<PlayBar
    v-for="(video, index) in videoInfoList"
    :key="video.eid"
    :Playing="index == curIndex"
    ...
/>

以及就绪判断逻辑([Video.vue#L297-L307](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/Video.vue#L297-L307)):

const videosIsReady = computed(() => {
    if (
        readyVideoNum.value >= videoInfoList.value.length &&
        videoInfoList.value.length != 0 &&
        !isRequiring.value
    ) {
        return true
    }
    return false
})

const handleVideoReady = (e) => {
    readyVideoNum.value += e
}

这个流程是:

1. Video.vue created → 立即渲染 LoadingPage(壳)
                        ↓
2. onBeforeMount → requireNew() 发起 API 请求拿数据
                        ↓
3. 数据返回 → videoInfoList 填充 → 渲染 5 个 PlayBar 组件
                        ↓
4. 每个 PlayBar mounted → 初始化 video.js → player ready
                        ↓
5. emit('onVideoReady') → readyVideoNum++ → 全部就绪?
                        ↓ 是
6. videosIsReady = true → LoadingPage 隐藏 → 视频画面出现

结论:UI 壳(Loading + 播放器容器)先出来,视频流最后加载。


业界通用的加载策略:分层渲染

几乎所有成熟的视频产品都遵循这个层次:

┌──────────────────────────────────────────┐
│  第 1 层:UI 壳(立即渲染)               │
│  · 导航栏、侧边按钮、底部信息栏           │
│  · 播放器容器(黑色背景占位)             │
│  · Loading 动画 / 骨架屏                  │
│  · 海报图(poster)                       │
├──────────────────────────────────────────┤
│  第 2 层:封面图(最快到达)              │
│  · video.js 的 poster 属性                │
│  · 或者一个独立的 <img> 标签              │
│  · 通常用 CDN,秒加载                     │
├──────────────────────────────────────────┤
│  第 3 层:播放器 SDK 初始化               │
│  · video.js / hls.js / shaka-player 等   │
│  · 创建 MediaSource / MSE 实例            │
├──────────────────────────────────────────┤
│  第 4 层:视频首帧(最关键)              │
│  · 发起网络请求获取视频流                 │
│  · 解码第一帧 → 用户看到画面动起来了      │
└──────────────────────────────────────────┘

核心原则一句话:让用户"看到东西"的延迟尽可能短,视频数据是最后一步。


为什么要这样分层?

1. 心理感知时间

策略用户感受
白屏 → 等 3 秒 → 视频出现"卡死了,关掉"
骨架屏 → 封面图(0.5s) → 封面变视频(2s)"挺快的,已经在播了"

白屏是最差的体验,因为它让用户觉得"什么都没发生"。Loading 动画或骨架屏告诉用户"正在加载中,请稍等"。

2. 这个项目的 poster 策略

看 [PlayBar.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/PlayBar.vue#L477-L487):

<video
    :poster="props.img"
    preload="auto"
    muted
>
    <source :src="props.url2" type="video/mp4" />
</video>

poster 是封面图,preload="auto" 告诉浏览器尽可能提前下载视频。当用户看到封面图的时候,视频数据已经在后台加载了。封面图是一张小图片(通常几十 KB),而视频是几十 MB——小的先到,大的后到。

再看 [play.scss](file:///g:/Save/Grogramming/Vue3/yike/src/styles/play.scss):

.vjs-poster {
    filter: blur(3px);        // 海报模糊
    transform: scale(1.05);   // 略微放大
}

封面图做了模糊处理,这样当视频首帧出现时,从模糊到清晰的过渡有一种"对焦完成"的感觉,比直接从黑屏跳到清晰画面自然得多。这是短视频产品常用的手法。

3. 数据获取的时序

回到 [Video.vue](file:///g:/Save/Grogramming/Vue3/yike/src/views/Home/components/Video.vue) 的 onBeforeMount:

onBeforeMount(async () => {
    await requireNew(route.params.vid, route.params.eid)
})

requireNew 里做了两件事:

  1. 先拿到剧集元信息(vid、eid、title、img 封面图 → 这些很小)
  2. 再逐一拿到视频播放地址(getVideoAdd → CDN URL)

元信息先到,封面图先渲染 → 用户先看到图 → 视频地址后到 → 视频流最后加载。


但这里有一个反模式:视频地址串行请求

[handleVideo.js](file:///g:/Save/Grogramming/Vue3/yike/src/utils/handleVideo.js#L52-L66):

while (videoInfoList.value.length < VIDEO_LIST_LENGTH) {
    const videoInfo = await randomList.value.pop()
    const add = await getVideoAdd(videoInfo.eid)   // await 了 5 次
    videoInfo.url2 = add
    videoInfoList.value.push(videoInfo)
}

已有的剧集元信息是 15 个(看 getRandomList(limit=15)),只需要 5 个。但获取这 5 个播放地址是 串行的——第 1 个地址返回了才开始请求第 2 个。如果每个请求 300ms,5 个就是 1.5 秒。

应该改成 Promise.all 并行请求:

// 应该的做法(伪代码)
const videoInfos = randomList.value.splice(0, VIDEO_LIST_LENGTH)
const urls = await Promise.all(videoInfos.map(v => getVideoAdd(v.eid)))
// 一次等 5 个,总耗时 ≈ max(单个请求) 而不是 sum

这就是为什么 CLAUDE.md 里标记了这个问题为"已知优化点"。


总结

先加载什么:

UI 壳 → 封面图 → 播放器 SDK → 元信息 → 视频流
  ↑                                    ↑
 0ms                              200-800ms
 用户立即看到"有东西"              用户看到画面动起来

永远不要让用户看到白屏。 哪怕你的视频流要等 3 秒,也要先用一个 loading 动画、一张封面图、一个骨架屏把空间填满。视频产品里,"先加载壳"是一个铁律——因为视频数据是最后一个到的,你不应该让它阻塞任何 UI 层的渲染。

一、项目现状分析

1.1 项目架构

  • 框架: Vue 3 + Vite
  • 视频播放: Video.js (CDN加载)
  • 状态管理: Pinia
  • 组件结构:
    Video.vue (容器 - 管理滑动切换)
    └── PlayBar.vue (播放器 - Video.js实例)
    

1.2 核心问题清单

优先级问题位置影响
P0内存泄漏 - 不销毁video.js实例PlayBar.vue长时间使用页面崩溃
P1视频串行加载慢handleVideo.js首屏加载时间长
P1滚动性能差 - 触发重排Video.vue滑动卡顿
P2事件冲突风险Video.vue + PlayBar.vue偶发交互异常
P2硬编码mock数据PlayBar.vue:72可维护性差

二、问题详解与解决方案

2.1 滑动与点击事件冲突

问题描述

上下滑动切换视频时,可能误触发视频播放/暂停。

当前实现

// PlayBar.vue
let isDragging = false
const currentMoveThreshold = 30

function handleTouchStart(e) {
    startX = touch.clientX
    startY = touch.clientY
    isDragging = false  // 重置标志
}

function handleTouchMove(e) {
    const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
    if (distance > currentMoveThreshold) {
        isDragging = true  // 超过阈值认为是滑动
    }
}

function handleTouchEnd(e) {
    if (!isDragging) {
        // 点击才触发播放/暂停
        player.paused() ? player.play() : player.pause()
    }
}

优化方案

// 统一使用 Pointer Events (推荐)
const useGesture = () => {
    const state = {
        startX: 0,
        startY: 0,
        startTime: 0,
        isDragging: false
    }
    
    const TAP_THRESHOLD = 30        // 像素
    const TAP_TIME_THRESHOLD = 300  // 毫秒
    
    const onPointerDown = (e) => {
        state.startX = e.clientX
        state.startY = e.clientY
        state.startTime = Date.now()
        state.isDragging = false
    }
    
    const onPointerMove = (e) => {
        const dx = e.clientX - state.startX
        const dy = e.clientY - state.startY
        if (Math.sqrt(dx * dx + dy * dy) > TAP_THRESHOLD) {
            state.isDragging = true
        }
    }
    
    const onPointerUp = (e) => {
        const duration = Date.now() - state.startTime
        if (!state.isDragging && duration < TAP_TIME_THRESHOLD) {
            // 确认是点击
            handleTap()
        }
    }
    
    return { onPointerDown, onPointerMove, onPointerUp }
}

Pointer Events(指针事件)是前端开发中一套统一的 DOM 事件模型,专门用来处理各种指针输入设备,比如鼠标、触控笔、单点或多点的手指触摸等。

简单来说,它的出现就是为了解决过去前端开发中“鼠标事件(Mouse Events)”和“触摸事件(Touch Events)”两套逻辑并存、需要分别维护的痛点。有了 Pointer Events,开发者可以**“一次编写,多端运行”**,无论是 PC 端还是移动端,都能用同一套代码完美处理拖拽、点击等交互。

结合你提供的代码,我来为你详细拆解一下它的核心概念以及你这段代码的精妙之处:

🎯 为什么推荐使用 Pointer Events?

在 Pointer Events 出现之前,如果你想写一个拖拽功能,通常需要同时监听 mousedown/mousemove/mouseup(给电脑用)和 touchstart/touchmove/touchend(给手机用)。而 Pointer Events 将它们统一成了语义完全相同的事件:

  • pointerdown:代替了 mousedown 和 touchstart
  • pointermove:代替了 mousemove 和 touchmove
  • pointerup:代替了 mouseup 和 touchend

💡 你的代码在做什么?(精准的手势识别)

你提供的 useGesture 代码是一个非常经典的**“区分点击与拖拽”**的实战案例。它的核心逻辑在于:

  1. 记录起点:在 onPointerDown 时,记录下手指或鼠标按下的初始坐标 (startX, startY) 和时间 (startTime)。
  2. 判断拖拽:在 onPointerMove 时,计算当前移动的距离。如果移动距离超过了 TAP_THRESHOLD(30像素),就认定用户是在“拖拽”,而不是“点击”。
  3. 确认点击:在 onPointerUp 时,只有当 isDragging 为 false(没怎么移动)且 按下时间小于 TAP_TIME_THRESHOLD(300毫秒)时,才触发 handleTap()。这完美避免了“拖拽结束时误触点击”的常见 Bug。

🚀 代码优化建议:加上“指针捕获”

虽然你的代码逻辑已经很清晰,但在实际拖拽场景中,如果用户的手指/鼠标移动速度过快,移出了元素本身,浏览器可能会丢失 pointermove 和 pointerup 的监听。

Pointer Events 提供了一个非常强大的 API:setPointerCapture。建议在 onPointerDown 中加上它,确保即使指针移出元素边界,事件依然能被捕获:

const onPointerDown = (e) => {
    // 记录初始状态...
    state.startX = e.clientX
    state.startY = e.clientY
    state.startTime = Date.now()
    state.isDragging = false

    // 【新增优化】锁定当前指针,确保快速拖拽时不会丢失 pointerup 事件
    e.target.setPointerCapture(e.pointerId)
}

const onPointerUp = (e) => {
    // 处理点击逻辑...
    if (!state.isDragging && duration < TAP_TIME_THRESHOLD) {
        handleTap()
    }
    
    // 【新增优化】释放指针锁定
    e.target.releasePointerCapture(e.pointerId)
}

📊 Pointer Events 的独有优势

除了统一事件,它还提供了很多传统鼠标事件没有的专属属性,方便你做更精细的交互(比如画画应用):

  • e.pointerType:可以直接判断用户是用 mouse(鼠标)、touch(手指)还是 pen(触控笔)在操作,从而给出不同的 UI 反馈。
  • e.pressure:能获取按压的力度(0到1之间),常用于手写笔压感。
  • e.pointerId:每个触点都有唯一的 ID,是实现多点触控(比如双指缩放、旋转)的基础。

目前主流浏览器(Chrome, Firefox, Safari, Edge)都已经广泛支持 Pointer Events,在 2026 年的今天,它绝对是处理前端交互的首选方案。


要不要我把优化后的完整代码整理出来,方便你直接复制到项目里用?


2.2 内存泄漏(核心问题)

问题代码

// PlayBar.vue - 只创建不销毁
onMounted(() => {
    player = window.videojs(props.videoInfo, options)
    player.on('play', () => { ... })
    player.on('ended', () => { ... })
})
// ❌ 缺少 onBeforeUnmount

修复方案

import { onBeforeUnmount } from 'vue'

onBeforeUnmount(() => {
    if (player) {
        // 1. 移除所有事件监听
        player.off('play')
        player.off('pause')
        player.off('ended')
        
        // 2. 暂停播放
        player.pause()
        
        // 3. 销毁实例 (会清理DOM和事件)
        player.dispose()
        player = null
    }
})

内存泄漏检测方法

// Chrome DevTools 检测步骤:
// 1. Performance → Record → 执行滑动操作 → Stop
// 2. Memory → Take Heap Snapshot
// 3. 对比多次操作后的内存占用
// 4. 搜索 detached 查看游离DOM

// 代码中临时检测
if (window.performance && performance.memory) {
    console.log('Used JS Heap:', performance.memory.usedJSHeapSize / 1048576, 'MB')
}

2.3 自动播放机制

当前实现

// Video.vue - 同时渲染5个视频,通过Playing控制
<PlayBar
    v-for="(video, index) in videoInfoList"
    :key="video.eid"
    :Playing="index == curIndex"
/>

面试点:为什么需要用户交互后才能播放?

// 浏览器自动播放策略 (Autoplay Policy)
// Chrome 66+ 要求:
// 1. 静音视频可以自动播放
// 2. 用户与域名有过交互后可以播放有声视频

// 最佳实践
const autoPlay = async (player) => {
    try {
        await player.play()
    } catch (err) {
        // 自动播放被阻止,显示播放按钮
        showPlayButton = true
    }
}

// 检测自动播放权限
const canAutoPlay = async () => {
    const video = document.createElement('video')
    video.muted = true
    try {
        await video.play()
        return true
    } catch {
        return false
    }
}

2.4 视频队列与预加载

当前问题:串行加载慢

// handleVideo.js - 串行加载(慢)
while (videoInfoList.value.length < VIDEO_LIST_LENGTH) {
    const videoInfo = await randomList.value.pop()
    const add = await getVideoAdd(videoInfo.eid)  // 等待每个请求
    videoInfo.url2 = add
    videoInfoList.value.push(videoInfo)
}

优化:并行加载

// 优化后 - 并行加载(快5倍)
const loadVideosParallel = async () => {
    const videosToLoad = Array(VIDEO_LIST_LENGTH)
        .fill()
        .map(() => randomList.value.pop())
    
    const loadedVideos = await Promise.all(
        videosToLoad.map(async (videoInfo) => {
            const url = await getVideoAdd(videoInfo.eid)
            return { ...videoInfo, url2: url }
        })
    )
    
    videoInfoList.value = loadedVideos
}

进阶:预加载策略

// 预加载下一个视频
const preloadNextVideo = (currentIndex) => {
    const nextIndex = currentIndex + 1
    if (nextIndex < videoInfoList.value.length) {
        const nextVideo = videoInfoList.value[nextIndex]
        const link = document.createElement('link')
        link.rel = 'preload'
        link.href = nextVideo.url2
        link.as = 'video'
        document.head.appendChild(link)
    }
}

2.5 滚动性能优化

当前问题

// Video.vue - scrollTo 触发重排 (Reflow)
videoElement.value.scrollTo({
    top: curIndex.value * videoHeight,  // 读取再写入
    behavior: 'smooth'
})

优化方案:使用 Transform(GPU加速)

// CSS
.video-wrapper {
    transform: translateY(var(--offset-y, 0));
    transition: transform 0.3s ease;
    will-change: transform;
}

// JS
const scrollToCurrent = (index) => {
    const offset = -index * 100  // vh单位
    videoWrapper.value.style.setProperty('--offset-y', `${offset}vh`)
}

为什么 transform 比 scrollTop 快?

  • scrollTop:触发布局计算(Layout)→ 绘制(Paint)→ 合成(Composite)
  • transform:直接合成层(Composite),跳过布局和绘制

三、面试常见问题

Q1: 移动端视频播放有哪些兼容性问题?

1. **全屏问题**
   - iOS:强制全屏播放,无法禁止
   - 解决:使用 inline 属性 + playsinline
   ```html
   &lt;video playsinline webkit-playsinline x5-playsinline&gt;
  1. 自动播放

    • 微信内置浏览器:首次必须用户点击
    • 解决:监听 WeixinJSBridgeReady
  2. 层级问题

    • 安卓:视频默认置顶,会覆盖其他元素
    • 解决:使用 cover-view 或同层播放器
  3. 黑屏/白屏

    • 原因:视频编码不支持(如H.265)
    • 解决:提供多格式源或转码

### Q2: 如何设计一个流畅的短视频滑动组件?

```markdown
1. **渲染优化**
   - 虚拟列表:只渲染当前+前后各1个视频(3个)
   - 回收复用:滑动后销毁移出屏幕的视频

2. **内存优化**
   - 限制同时存在的video实例数(建议3-5个)
   - 及时调用 dispose() 销毁播放器
   - 使用对象池复用播放器实例

3. **加载优化**
   - 首屏只加载当前视频
   - 预加载下一个视频(静音+暂停)
   - 使用 HTTP Range 请求分段加载

4. **交互优化**
   - 滑动距离阈值(如80px)才切换
   - 滑动速度检测(快速滑动直接切换)
   - 吸附效果(snap)

Q3: video.js 和原生 video 各有什么优缺点?

特性Video.js原生 video
UI定制丰富,可自定义皮肤有限,各浏览器不同
插件生态丰富(广告、统计等)无
体积较大(需额外加载)无额外体积
学习成本需要学习API直接使用
移动端可能需要适配原生支持好
直播支持HLS/DASH 支持好有限

Q4: 视频首屏加载慢怎么优化?

1. **视频本身优化**
   - 压缩视频(FFmpeg转码)
   - 使用 CDN 分发
   - 自适应码率(根据网络选择清晰度)

2. **加载策略**
   - 封面图占位(poster属性)
   - 懒加载:进入视口才加载视频
   - 分段加载(HTTP 206 Partial Content)

3. **用户体验**
   - 显示加载进度
   - 先播放低清晰度版本,再切换高清
   - 预加载策略:preload="metadata" 只加载元数据

Q5: 如何检测视频卡顿?

// 方法1:检测播放帧率
let lastTime = 0
let frameCount = 0

const checkStutter = () => {
    const now = performance.now()
    frameCount++
    
    if (now - lastTime >= 1000) {
        const fps = frameCount
        if (fps < 20) {
            console.warn('视频卡顿,FPS:', fps)
        }
        frameCount = 0
        lastTime = now
    }
    requestAnimationFrame(checkStutter)
}

// 方法2:检测 buffered 进度
const checkBuffering = (video) => {
    const buffered = video.buffered
    const currentTime = video.currentTime
    
    // 查找当前时间是否在已缓冲范围内
    let isBuffered = false
    for (let i = 0; i < buffered.length; i++) {
        if (currentTime >= buffered.start(i) && currentTime <= buffered.end(i)) {
            isBuffered = true
            break
        }
    }
    
    if (!isBuffered) {
        console.log('正在缓冲...')
    }
}

// 方法3:监听 waiting 事件
video.addEventListener('waiting', () => {
    console.log('视频因缓冲暂停')
})
video.addEventListener('playing', () => {
    console.log('视频恢复播放')
})

四、修复计划

Phase 1: 紧急修复(内存泄漏)

  • [ ] 在 PlayBar.vue 添加 onBeforeUnmount 钩子
  • [ ] 调用 player.dispose() 销毁实例
  • [ ] 测试内存占用变化

Phase 2: 性能优化

  • [ ] handleVideo.js 串行改并行加载
  • [ ] Video.vue scrollTo 改为 transform
  • [ ] 添加视频预加载逻辑

Phase 3: 体验优化

  • [ ] 统一手势判断逻辑
  • [ ] 添加加载状态提示
  • [ ] 优化首屏加载速度

五、相关链接

  • Video.js 文档
  • Chrome 自动播放策略
  • Web 视频性能优化