☰
仿B站视频卡片动态预览全实现:WebP动图、性能优化与兼容性处理
2026/10/17 23:11:32 网站建设 项目流程

做前端的人应该都有这种经历:逛B站首页时,鼠标从一排排视频卡片上滑过去,封面图会突然“活”过来,变成几秒的动态预览,人物动作、镜头切换、文字滚动都缩在那一小块封面里;鼠标一挪走,它又安静地变回一张静态图。这个交互看起来只是简单的 hover 换图,但真到自己动手实现时,你会发现它牵扯到封面信息组织、动图素材准备、懒加载策略、浏览器兼容性、内存释放,还有移动端降级,远没有想象中那么轻松。

这篇文章就把我最近一次“仿 B 站视频缩略图”的完整实现过程记录下来。从需求拆解开始,到技术选型、HTML/CSS/JS 具体写法,再到性能优化和兼容性排查,全部展开聊。不管是刚入门前端的新手,还是想在个人项目里做出这种卡片效果的开发者,都能直接拿去参考。文中代码我都按可运行的标准写了,数据部分用 Mock 接口模拟,换成你自己的接口就能跑起来。

1. 先拆需求:所谓“缩略图”到底在动什么

1.1 一张视频卡片里藏着多少信息

很多人以为 B 站缩略图就是“一张封面 + 播放量 + 时长”,真去照着做才发现,一张卡片的信息密度远比想象中高。我按自己复刻的版本梳理了一下,至少包含下面这些:

  • 封面图:16:9 比例,大部分情况下是视频里比较有代表性的某个画面帧。
  • 时长角标:封面右下角,比如“12:34”,字体不大但对比度要够。
  • 分区/精选角标:封面左上角或右上角,比如“热门”“影视”之类的小标签。
  • 标题区:两行文本,超出省略,字体加粗程度要区分主标题和副标题。
  • UP 主信息:头像、昵称,一般放在标题下方。
  • 数据行:播放量、弹幕数,可能还有点赞数。
  • 收藏/稍后再看按钮:平时藏在角落里,hover 到卡片时才会更明显地浮出来。

这些信息在布局上是有节奏的:封面在最上方,标题居中偏下,数据行在最底部。所以做 HTML 结构时不能把标签随便叠,而是要分块:封面容器一个块,信息区一个块。封面容器里再细分静态盖层、动态预览层、角标层、浮层操作区。

1.2 交互核心不是换图,而是“渐进式反馈”

B 站缩略图的精髓,不在于 hover 之后立刻换掉封面,而是有层次地展示内容。鼠标刚移上去时,封面图像是被“激活”了,轻微放大或加了一层遮罩,然后动态防御才开始播放;如果你继续停留,封面周围会浮出收藏、加稍后再看、实时弹幕等按钮。整个过程是分阶段触发的,不是一蹶子一脚。

我复刻时把交互分了 4 个阶段:

  1. 默认态:静态封面 + 时长 + 标题等基础信息。
  2. 动态预告态:鼠标移入,封面图淡出/淡入,切换到动态预览。
  3. 浮层态:停留时间超过一定阈值(比如 300ms)后,显示操作按钮与更丰富的信息。
  4. 恢复态:鼠标移出后回到第 1 阶段,但如果动态图已经加载过,最好能直接使用浏览器缓存,不要重新拉一次网络请求。

这个“渐进式反馈”的好处是,用户快速划过的场景下,只会触发动图这一层,不会频繁触发浮层,减少不必要的 DOM 操作和请求。

1.3 移动端要单独考虑,“hover 不存在”是前提

移动端没有鼠标,也没有 hover 这个概念。手指点到卡片上,如果只是跳转播放页,那缩略图动效就没有存在价值。所以移动端的方案一般是两个方向:要么点击卡片后,在上方弹出一个快速预览浮层;要么干脆不展示动态图,只保留静态封面。

我一开始在移动端直接套用了 PC 的 hover 方案,结果手指一点卡片就跳转,预览根本来不及看。后来改成“点击卡片时,如果动态图已加载,则先播 2 秒再跳转”的逻辑,才稍微有点感觉。不过坦率说,移动端最适合的还是简化:静态封面 + 干净的信息展示就够了。

2. 技术方案选型:动态 WebP、APNG、GIF 还是视频帧

2.1 四种动图路线的优缺点对比

封面动态预览本质上是“循环播放一段极短视频的画面”。最直接的实现方式是用动图,但动图格式之间的差异很大。我把常见方案排了个队,逐个分析:

方案优点缺点适用场景
GIF兼容性最好,几乎全平台支持颜色最多 256 色,体积大,容易有噪点低复杂度素材、老平台兼容
动态 WebP体积小,支持 Alpha 透明,解码性能较好Safari 旧版本不支持动画 WebP,工具链稍麻烦Chrome/Edge/Android 生态,体积敏感场景
APNG支持真彩色与透明,Safari 支持不错文件体积比 WebP 大不少,制作工具少苹果生态为主的项目
短视频帧 / Video 元素画质最好,可自由控制播放进度自动播放策略限制多,实现复杂度高,内存开销大高清预览、需要交互控制的场景

额外还有 Canvas 逐帧绘制、CSS 动画拼接多张静态图等方式。Canvas 灵活但需要把每一帧作为独立资源加载,请求数量会很多;CSS 搞多帧拼接图虽然可以,但做缩略图预览有点小题大做。

2.2 我的选择:动态 WebP 为主,静态封面做兜底

综合考虑体积、兼容性和实现成本,我最终采用动态 WebP 作为主力预览图格式。原因很直接:B 站这类卡片动效通常出现在 Chrome、Edge、Android 浏览器上,这些内核都是 Chromium 系,对 WebP 动画的支持非常成熟。单看体积,同样 10 帧 320x180 的画面,WebP 动图大概是 GIF 的 1/3 到 1/2,压缩质量还好很多。

但 Safari 的兼容性不能装没看见。我的做法是:页面环境里先检测浏览器是否支持<picture>和 WebP 动画,支持就走动态 WebP;不支持就让预览层永远保持 opacity 0,只展示静态封面,保证功能不挂。个别需求如果要求 Safari 也必须动,那就加一层降级:用 mp4 静音视频源,配合muted playsinline autoplay挂在卡片里,鼠标 hover 时播放。这个方案稍重,但效果好。

2.3 预览素材怎么准备:FFmpeg 抽帧再合成

素材这一块,很多人会忽略。如果是做演示项目,直接下载一段视频剪辑成 2 到 4 秒的小片段;但站点里的视频千千万,不可能手工做动图。合理的处理流程是:服务器端定时任务从视频里抽出一个包含关键动作的片段,再压缩成动态 WebP。

我项目里给了个简单实现,核心就是 FFmpeg。假设源视频是clip.mp4,要生成 320 宽、每秒 12 帧的 GIF,可以这么写:

# 先把片段修剪到 3 秒,12fps ffmpeg -i clip.mp4 -t 3 -vf "fps=12,scale=320:-1:flags=lanczos" -y preview_raw.gif # 再将原始 GIF 转换成 WebP 动图,quality 控制体积 cwebp -quiet -q 70 -m 6 -loop 0 -t 3 preview_raw.gif -o preview.webp

有个小细节我踩过坑:不要直接用短视频转 WebP 动图,因为 WebP 默认按时间线压缩,源视频里如果有大量近似帧,编码器会干脆跳过,导致“预览看起来卡成一帧”。所以先转 GIF 再转 WebP 更稳妥,等于中间滤波了一遍。

另外,动态预览文件不建议做太大。我在测试中把 320x180、12fps、3 秒的 WebP 动图压到 150KB 左右,观感基本能接受。再往上提画质,体积会急速增长,而且用户划过卡片时不一定会盯着看,收益很低。

3. 动手实现:卡片结构、样式与交互逻辑

3.1 HTML 分层:把静态层和动态层分开

写 HTML 结构的第一步,是把“静态封面”和“动态预览”设计成两个层,而不是用 JS 动态替换src。原因有两个:一是不替换src就不会在切换瞬间出现白边或闪图;二是在 CSS 里做 opacity 渐变,比 JS 控制加载状态后再替换要自然得多。

下面是我项目里用的卡片结构,去掉无关业务字段后的核心版本:

<div class="video-card">.video-card { width: 100%; border-radius: 8px; overflow: hidden; transition: box-shadow 0.2s ease; } .cover { position: relative; display: block; width: 100%; padding-top: 56.25%; /* 16:9 比例 */ overflow: hidden; background: #1b1f24; } .cover-img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; } .cover-dynamic { opacity: 0; transition: opacity 0.25s ease; z-index: 2; } .video-card:hover .cover-dynamic { opacity: 1; }

我发现只做透明度切换还不够,因为动态预览一旦开始播放,静态封面被盖住了,但卡片本身缺少“被激活”的视觉反馈。所以我加了一点点缩放效果:

.video-card:hover .cover-static { transform: scale(1.06); transition: transform 0.4s ease; }

注意这个缩放只作用于 static 层,dynamic 层要跟着一起缩放或者保持原样。我在第一版里让两层都缩放,结果出现边缘模糊和锯齿,后来改成 dynamic 层直接覆盖、玻璃面板只做透明度变化,视觉上干净很多。

浮层信息我用了一个绝对定位于封面底部的.hover-float:

.hover-float { position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; padding: 24px 12px 10px; background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent); opacity: 0; transform: translateY(6px); transition: opacity 0.2s ease, transform 0.2s ease; } .video-card:hover .hover-float { opacity: 1; transform: translateY(0); }

浮层里可以放“正在播出”“稍后再看”这类按钮,样式随意,但作用是把交互层级拉出来,让用户知道卡片可操作。

3.3 JS 交互:预加载、延迟填充 src 与页面内存控制

页面缩略图数量一大,每个卡片都各自监听事件会浪费内存。我用事件委托的方式,在卡片容器上统一监听mouseenter、mouseleave。核心逻辑是:

const grid = document.querySelector('.video-grid'); grid.addEventListener('mouseenter', (e) => { const card = e.target.closest('.video-card'); if (!card) return; const dynamicImg = card.querySelector('.cover-dynamic'); if (!dynamicImg || dynamicImg.dataset.loaded) return; const previewSrc = dynamicImg.dataset.previewSrc; if (!previewSrc) return; // 用 Image 对象预加载,避免设置 src 后出现明显的等待灰块 const loader = new Image(); loader.onload = () => { dynamicImg.src = previewSrc; dynamicImg.dataset.loaded = '1'; dynamicImg.classList.add('is-loaded'); }; loader.src = previewSrc; }, true); grid.addEventListener('mouseleave', (e) => { const card = e.target.closest('.video-card'); if (!card) return; const dynamicImg = card.querySelector('.cover-dynamic'); // 从 DOM 上移除预览图 src,释放解码内存,下次再 hover 时走浏览器缓存 // 又不会真的发起网络请求 if (dynamicImg && dynamicImg.dataset.loaded) { dynamicImg.removeAttribute('src'); dynamicImg.dataset.loaded = '1'; // 标记已缓存,下次不再重复预加载 } }, true);

这里有个我调试了很久的经验:mouseleave时如果把src移除,下次 hover 重新设置src,浏览器会直接从 HTTP 缓存读出图片,几乎没有网络开销,但解码后的内存会被释放。这个做法非常适合大量卡片滚动的页面。不过要注意,dataset.loaded这个标记和“已缓存”是两回事,它只代表这个预览图曾经成功加载过一次,不代表浏览器缓存一定可用。更严谨的做法是用Promise或标记对象记录加载状态,演示项目里直接给 DOM 挂标记最省事,也不会被官方文档里的缓存机制带偏。

3.4 数据模拟:一份 JSON 还原接口响应

真实项目的视频数据肯定是接口返回的,我这里写一个 Mock 版本,方便调试。接口结构长这样:

{ "code": 0, "data": [ { "vid": "BV1024", "title": "用动态预览做卡片缩略图的实验上", "cover": "https://cdn.example.com/covers/BV1024.jpg", "preview": "https://cdn.example.com/previews/BV1024.webp", "duration": "12:34", "playCount": 123000, "danmakuCount": 2345, "uperName": "某UP主", "uperAvatar": "https://cdn.example.com/avatar/xx.jpg" } ] }

前端渲染我用的模板字符串,先把列表数据拼成 map,再 append 到容器里。

function renderCard(item) { const div = document.createElement('div'); div.className = 'video-card'; div.dataset.vid = item.vid; div.innerHTML = ` <a class="cover" href="/video/${item.vid}"> <img class="cover-img cover-static" src="${item.cover}" alt="封面"> <img class="cover-img cover-dynamic">const lazyImages = document.querySelectorAll('img[loading="lazy"]'); const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const img = entry.target; if (img.dataset.src) { img.src = img.dataset.src; } observer.unobserve(img); }); }, { rootMargin: '0px 0px 500px 0px' }); lazyImages.forEach((img) => observer.observe(img));

注意一点:静态封面图的src可以先不填,放到>const preloadTimers = new WeakMap(); grid.addEventListener('mouseenter', (e) => { const card = e.target.closest('.video-card'); if (!card) return; const dynamicImg = card.querySelector('.cover-dynamic'); if (!dynamicImg || dynamicImg.dataset.loaded) return; clearTimeout(preloadTimers.get(card)); const timer = setTimeout(() => { loadPreview(card); }, 200); preloadTimers.set(card, timer); }, true); grid.addEventListener('mouseleave', (e) => { const card = e.target.closest('.video-card'); if (!card) return; clearTimeout(preloadTimers.get(card)); }, true);

这里用WeakMap保存定时器很合适,因为卡片 DOM 被移除时不会造成内存泄漏。

4.3 图片体积控制:压缩策略和大小参考

一个视频预览 WebP 动图,我建议控制在 80KB 到 200KB 之间。超过 300KB 就要怀疑是帧率过高或者尺寸过大。B 站本身使用的封面预览图大多也控制在几百 KB 以内,因为首页瞬时拉量很大,一个文件多 100KB,整页就要多出几十 MB 流量。

压图维度上,我通常先降尺寸,再降帧率,最后才动质量参数。原因是用户在小卡片上几乎分辨不出 320px 和 480px 的差别,但帧率从 15fps 降到 10fps 肉眼感知也不明显。可以用 FFmpeg 批量处理测试集,主观选一版。下面这组参数是我做“仿 B 站缩略图”时的参考值:

ffmpeg -i clip.mp4 -t 3 -vf "fps=10,scale=320:-1" -y preview_10fps.gif cwebp -quiet -q 75 -m 6 -loop 0 -t 3 preview_10fps.gif -o preview.webp

压完再看体积,如果还是很大,检查源视频是否有大量复杂噪点;如果源视频本身就是高码率,建议先做一次锐化或降噪,不然 WebP 编码器会把噪点当成有效内容来保留,体积立刻膨胀。

4.4 移动端降级:hover 不存在,就不要硬做

移动端如果没有 hover 环境,继续在 CSS 里写:hover反而会造成奇怪的“点击后高亮”效果。我在移动端是通过 UA 判断来降级:

const isMobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent); if (isMobile) { document.documentElement.classList.add('is-mobile'); }

CSS 里配合:

.is-mobile .video-card:hover .cover-dynamic { opacity: 0; }

这样即使移动端浏览器触发了 hover 模拟,也不会出现動態图闪烁。移动端我采用的是“点击展开预览浮层”模式:点击卡片后,从底部弹出一个小播放按钮,播放预览视频,再点一次继续跳详情页。这个交互不需要动图,直接用<video muted playsinline>更自然。

4.5 渲染提效:content-visibility给长列表减负

滚动列表里大量卡片同时存在于 DOM 中,让浏览器一直做栅格化和绘制,会很卡。CSS 的content-visibility: auto是很好用的性能手段,它能让远离视口的卡片跳过渲染,只占位:

.video-card { content-visibility: auto; contain-intrinsic-size: 0 420px; }

contain-intrinsic-size是为了避免没渲染时容器高度变成 0,导致滚动条抽搐。420px 是我卡片实际的大致高度,你可以按自己布局调整。这个属性在 Chromium 系浏览器表现很好,Safari 近几个版本也支持了,加上它不会改变布局行为,适合直接应用在卡片列表上。

5. 排查实录:把踩过的坑都集中在这一章节

5.1 Safari 一直显示静态图,动态 WebP 不加载

这个问题我排查了挺久。Safari 新版本其实已经支持 WebP 静态图的解码,但对“动画 WebP”的支持依然不完全。我的做法是在 JS 里检测:

function canPlayAnimatedWebP() { const canvas = document.createElement('canvas'); canvas.width = 2; canvas.height = 2; const context = canvas.getContext('2d'); const base64 = 'UklGRi4AAABXRUJQVlA4ICIAAABQAQCdASoBAAEALmk0mk0iIiIiIgBoSygABc6zbAAA/v56QAAAAA=='; // 上述字符串是一张 1ms 的透明 WebP,浏览器能否解码就代表是否支持 const data = 'data:image/webp;base64,' + base64; const supported = typeof context !== 'undefined' && context.fillStyle === 'string'; return supported && /Chrome|Edg|Opera|Android/i.test(navigator.userAgent); }

不过这个检测比较粗糙,生产建议直接用现成的支持库做能力嗅探。没有更好的办法时,我直接把 Safari 判定为“不支持动态预览”,让页面只保留静态封面。功能不降级但不完整,总比白屏或者加载错误图强。

5.2 动态图第一次 hover 时会“刷”地闪一下

现象是:鼠标移上去,预览图不是平滑淡入,而是先亮一下再变成半透明。原因在于我一开始把预览图的src直接写在 HTML 上,但 CSS 里默认 opacity 是 0。浏览器在图片加载完成后,其实已经把它渲染出来了,opacity 动画只是让它在同一层渐变。问题出现在我给loader预加载后又把同一张图赋给dynamicImg.src,这时浏览器发现相同的 URL 会复用解码结果,处理时长很短,但仍然会产生一次“从无到有”的布局刷新。

解决方法是把加载状态和动画拆开:先用loader.onload标记完成,然后再通过requestAnimationFrame给卡片加上is-loaded类,让透明度过渡发生在下一帧:

loader.onload = () => { requestAnimationFrame(() => { dynamicImg.src = previewSrc; dynamicImg.classList.add('is-loaded'); }); };

这样图片先填充进 DOM,浏览器完成一帧之后再触发 CSS transition,就不会闪了。

5.3 页面内存持续上涨,滚动一多就卡

动态 WebP 解压后的位图数据会占据一定内存,几十张卡片同时渲染完,内存曲线直接起飞。我一开始为了“体验流畅”,把所有预览图都提前加载并缓存着,结果页面滚动起来后台一直在解码,卡顿明显。

后来调整成了“只保留当前悬停卡片与相邻几张的预览图缓存,其余一律释放”。这是我在代码里做的主要改动:

function cleanPreview() { document.querySelectorAll('.cover-dynamic.is-loaded').forEach((img) => { const rect = img.closest('.video-card').getBoundingClientRect(); if (rect.bottom < 0 || rect.top > window.innerHeight) { img.removeAttribute('src'); img.classList.remove('is-loaded'); } }); } window.addEventListener('scroll', throttle(cleanPreview, 200), { passive: true });

滚动时定期清掉视口外已加载的预览图,内存会平稳很多。代价是快速滚回来时预览图需要重新解码,但因为有 HTTP 缓存,网络开销很小,体感基本能接受。

5.4mouseenter与IntersectionObserver同时处理同一张图,状态互相覆盖

有段时间预览图偶尔不加载,看了控制台才发现:mouseenter的事件委托和我自己的懒加载观察器都在处理cover-dynamic,一个把src填进去,另一个又把src清掉,互相打架。

我的解决办法是统一事件入口:预览图只由 hover 逻辑负责,静态封面只由 IntersectionObserver 负责。两者职责分开,不要在一个函数里同时处理“是否滚动加载”和“是否悬停加载”。这也是给后来者的忠告:一张图片的资源加载策略只能有一个决策者,否则必然出现并发状态问题。

5.5 卡片标题里有特殊字符,渲染出来一片空白

Mock 数据里如果标题带了<script>或者&,模板拼字符串不注意就会把 HTML 搞坏。演示项目可以不处理,但真实接口数据一定要对文本字段做转义。我写了个极简版本:

function escapeHtml(str) { return String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); }

渲染时所有用户生成内容,包括 UP 主昵称、标题、简介,全都要走一遍escapeHtml。这是最不起眼但最不能省的一步。

最后再分享两个实操里的心得

第一点是,做这套缩略图功能,真正难的不是 CSS 动画,也不是 WebP 资源准备,而是“在几十上百张卡片的页面上维持稳定内存”。我一度执着于把所有预览动效都做得很炫,结果首屏加载时间和滚动帧率双双告急。后来我把动态预览只保留在用户真正悬停的卡片上,大部分时间页面其实跑的是静态封面,照样流畅。功能和性能的平衡点,值得你在自己的项目里多试几次。

第二点,推荐你在本地调试时把预览图 URL 改成可控的小体积素材,而不是直接从视频网站拿远程资源。远程封面经常有防盗链限制,或者体积超大,直接影响你调试的体验。先用本地静态文件跑通交互,再替换成真实的动态预览地址,会顺手很多。

如果你也实现了类似效果,特别欢迎来交流具体的做法。不管是 WebP 体积控制、Safari 兼容方案,还是移动端预览交互,都有很多细节可以讨论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询