跳到主要内容

卡片鼠标悬浮自动播放预览实现

  • 鼠标悬浮自动播放预览”,这个功能在抖音、YouTube、B站的 Web 端都很常见
  • 这个是极易引发性能事故的功能,需要很细节优化实现

具体实现​

常见问题:

  • 初级工程师的直觉是:直接在 mouseenter 时插入 <video> 标签。 但在列表页(Feed 流)场景下,如果用户快速划过屏幕,会瞬间触发几十个视频请求,导致带宽爆炸、浏览器主线程卡顿

实现细节

  • 1、防抖(Debounce)机制:鼠标悬浮超过 500ms(或 300ms)才开始加载视频,防止误触
  • 2、静音播放(Muted Autoplay):浏览器策略限制,自动播放必须静音,我们通过 UI 提示用户
  • 3、资源销毁:鼠标移出后立即销毁 DOM 节点,释放内存。
  • 4、UI交互:进度条、渐变遮罩

关键点

  • 1、性能优化,鼠标经过时,增加定时器,避免频繁创建

    • 使用v-if来切换视频,避免使用v-show
    • <video> 标签不仅仅是一个 DOM 节点。浏览器会为每一个 <video> 分配解码缓冲区, 如果在一个长列表中,使用v-show,避免创建了多个视频video,会导致多个视频实例会一直存储在浏览器内存中,导致页面崩溃卡顿情况
    • 因此使用为了控制视频video的DOM节点数,使用v-if是正确的策略
      • 浏览器创建 DOM 的速度是非常快的(毫秒级)
    • 因此必须销毁视频video的DOM实例
  • 2、使用 playsinline,防止 iOS Safari 自动弹全屏

  • 3、静音强制 muted,这是 Chrome/Safari 自动播放策略的硬性要求

  • 4、记忆播放,存储每个卡片视频的播放进度,将卡片视频的播放时间点存储起来即可

  • 5、视频封面处理,当 v-if 创建 video 标签时,视频加载第一帧需要几十毫秒。如果立刻把图片隐藏,屏幕会闪一下黑屏

    • 优化技巧:
      • 1、让 video 盖在图片上,等 video 真的出来了,图片在底下自然就看不到了(或者稍微延迟一点隐藏图片),这样视觉上是无缝过度的
      • 2、图片显示隐藏,使用透明度来控制opacity

VideoCard组件 【支持预览视频】​

vue
<template>
<div
class="group flex w-full cursor-pointer flex-col gap-2"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-gray-900 ring-1 ring-white/10">
<img
v-show="!isPlaying"
:src="data.coverUrl"
:alt="data.title"
class="h-full w-full object-cover transition-transform duration-300 ease-in-out group-hover:scale-105"
loading="lazy"
>

<div v-if="isPlaying" class="absolute inset-0 z-10 animate-fade-in">
<video
ref="videoRef"
:src="data.videoUrl"
class="h-full w-full object-cover"
autoplay
muted
loop
playsinline
/>

<div class="absolute inset-0 flex flex-col justify-between bg-black/10 p-3">
<div />

<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-xs font-medium text-white drop-shadow-md">
<div class="flex items-center gap-1">
<span class="inline-block h-3 w-1 bg-white" /> <span>{{ currentTime }} / {{ data.duration }}</span>
</div>

<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-5 hover:text-gray-200">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-5 hover:text-gray-200">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 9.75L19.5 12m0 0l2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
</svg>
</div>
</div>

<div class="relative h-1 w-full overflow-hidden rounded-full bg-white/30">
<div class="absolute left-0 top-0 h-full w-[20%] bg-white" />
</div>
</div>
</div>
</div>

<div v-else class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />

<template v-if="!isPlaying">
<div class="absolute bottom-2 left-2 flex items-center text-xs font-medium text-white drop-shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="mr-1 size-3.5">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
<span>{{ data.likes }}</span>
</div>
<div class="absolute bottom-2 right-2 rounded bg-black/60 px-1 py-0.5 text-xs font-medium text-white backdrop-blur-sm">
{{ data.duration }}
</div>
</template>
</div>

<div class="flex flex-col gap-1 pr-2">
<h3 class="line-clamp-2 text-[15px] font-medium leading-snug text-gray-100 transition-colors group-hover:text-white">
{{ data.title }}
</h3>
<div class="flex items-center justify-between text-xs text-gray-400">
<div class="flex items-center gap-1">
<span class="hover:text-gray-300 transition-colors">@ {{ data.author }}</span>
</div>
<span>{{ data.publishDate }}</span>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import type { VideoMeta } from '~/types/video'
import { onUnmounted, ref } from 'vue'

// 严格 Props 定义
interface Props {
data: VideoMeta
}
defineProps<Props>()

// --- 状态管理 ---
const isHovering = ref(false)
const isPlaying = ref(false)
const videoRef = ref<HTMLVideoElement | null>(null)
let hoverTimer: ReturnType<typeof setTimeout> | null = null

// 模拟当前播放时间 (静态展示用,实际开发需监听 video 的 timeupdate 事件)
const currentTime = '00:22'

// --- 核心逻辑:防抖加载 ---
function handleMouseEnter() {
isHovering.value = true
// 只有悬浮超过 600ms 才开始请求视频流,节省带宽
hoverTimer = setTimeout(() => {
isPlaying.value = true
// nextTick 确保 video 标签渲染后再 play,这里 vue 会自动处理,但要注意时序
}, 600)
}

function handleMouseLeave() {
isHovering.value = false
isPlaying.value = false // 立即销毁视频组件
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
}

onUnmounted(() => {
if (hoverTimer)
clearTimeout(hoverTimer)
})
</script>

<style scoped>
/* 简单的淡入动画 */
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}

@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>