先把结论放到最前面:这种缩略图卡片一点都不神秘,拆开来看就是“封面图 + 几个角标 + 标题区 + 作者与数据区”的组合。我这篇文章要做的事情,就是把弹幕视频站首页那种经典的视频缩略图样式拆成一个个可复用的前端部件,用原生 HTML/CSS/JavaScript 完整实现一版。全程不依赖框架,也不用脚手架,适合正在做前端练手项目、个人作品页、活动落地页的开发者参考。就算你是刚接触前端不久的新手,只要按下面的代码一步步来,也能在一个晚上把它跑起来。最终效果是:卡片按网格排列,封面右下角显示时长、左上角显示分辨率和弹幕数,鼠标悬停时封面轻微上浮并出现播放按钮,底部标题和 UP 主信息分层展示,全部数据由 mock 数组驱动,后面接真实接口也方便。
1. 项目拆解与整体方案设计
1.1 我们到底在仿什么
很多人看到这类卡片的第一反应是“圆角大一点、阴影重一点不就像了”,真做起来才发现完全不是那么回事。以我多次复刻这类界面的经验来看,它看起来“对味”的核心原因是信息层级和视觉权重完全一致。
我们观察到的这个弹幕视频站缩略图,从上到下其实非常固定:
- 封面图占据最大的视觉面积,比例大约是 16:10;
- 封面右上角或左上角有分辨率标识,例如“高清”或“4K”;
- 封面右下角固定有视频时长;
- 封面右上角会有弹幕数量或热度值;
- 封面下方是标题,通常限制两行,超出部分省略;
- 标题下面有一行次要信息,包括 UP 主名、播放量、发布时间或点赞数。
这才是这套缩略图真正的“骨架”。所以我在动手前先画了一个非常粗糙的信息权重排序:封面 > 标题 > 角标 > 作者信息。设计稿上哪个元素多大、放在哪里,本质上都是在表达这个权重关系。如果你不按照这个次序去排,就算像素级还原封面,整体观感还是会差很多。
1.2 为什么不用框架,而是选原生三件套
我知道现在很多团队一上来就用组件库或者框架,但这类“仿站练习”我反而建议先用原生实现。原因有三个。
第一,缩略图卡片是高度固定的 UI 模式,本身不涉及复杂状态管理,用框架反而要多写一层数据绑定代码。第二,原生 HTML/CSS/JS 写完之后可以直接嵌入任何项目,不管后面用的是 Vue、React 还是纯静态页面,这段代码都能迁移。第三,对我们理解布局模型有好处。用组件库十分钟能画出一张看起来差不多的卡片,但组件库帮你隐藏了大量布局细节,一旦遇到特殊需求,比如自定义角标位置、异常图片兜底,你就得回头补基础。
所以我最终采用的结构非常简单:
project-root/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── data.js │ └── main.js └── img/ └── cover-x.jpgdata.js 放模拟数据,main.js 负责渲染和交互,style.css 负责全部样式。没有构建步骤,双击 index.html 就能跑。
1.3 数据驱动渲染的思路
我一开始是把卡片写死在 HTML 里的,写完五张就后悔了。缩略图每隔一段时间就得更新,如果继续写死,每次新增一个视频都要复制一长串 HTML 标签,效率和后期维护都很痛苦。更合理的做法是把视图和数据分离:数据全部存在一个数组里,页面只负责读取数组、生成相应 DOM。
这也符合真实项目里前后端联调的节奏。后端接口返回什么字段,前端就维护一个对应的字段映射。接口字段还没定的时候,先用 mock 数据把页面效果做出来,等接口联调时再替换数据源。我把这一步放在最开始,是因为整篇后续所有代码都围绕这份数据结构展开。
2. 数据模型与格式化工具
2.1 字段设计
先看我这版使用的数据字段。模拟一条视频记录大概长这样:
const videoList = [ { videoId: 10001, title: "用一顿晚饭的时间搞懂页面布局原理", author: "某UP主", cover: "./img/cover-10001.jpg", duration: 372, playCount: 260000, danmakuCount: 1200, publishTime: "2025-06-08 19:30", category: "前端教程", resolution: "4K" }, { videoId: 10002, title: "新手也能看懂的CSS Grid入门", author: "前端练习生", cover: "./img/cover-10002.jpg", duration: 648, playCount: 89000, danmakuCount: 356, publishTime: "2025-06-07 12:00", category: "教程", resolution: "高清" } ];字段说明用表格整理一下,后面写渲染逻辑时会更清楚。
| 字段 | 类型 | 作用 |
|---|---|---|
| videoId | number | 视频唯一标识,用于点击跳转 |
| title | string | 卡片标题,两行截断 |
| author | string | UP 主名称 |
| cover | string | 封面图地址 |
| duration | number | 视频时长,单位秒 |
| playCount | number | 播放量,渲染时格式化 |
| danmakuCount | number | 弹幕数,渲染时格式化 |
| publishTime | string | 发布时间,渲染相对时间 |
| category | string | 分区分类,可做角标 |
| resolution | string | 分辨率标识,如 4K、高清 |
duration 用秒而不是直接给“06:12”,是一个我特别想强调的细节。很多时候我们图方便,接口直接给格式化好的时间字符串,结果后面要做“稍后再看”“播放记录”这类功能时,就得再写一个反解析函数。以秒为单位的原始数值,既能展示成时分秒,也能参与计算,灵活性高很多。
2.2 播放量、时长与相对时间格式化
页面不是直接把 260000 这种数字往卡片里塞,那样既不美观也不符合这类平台的展示习惯。播放量超过一万以后要转成“26万”,超过一亿转成“1.2亿”,时长则要变成“06:12”或者“1:02:33”。
我写了一个公共工具文件,方便后面多处调用:
// format.js function formatCount(num) { num = Number(num); if (num < 10000) { return String(num); } if (num < 100000000) { const w = num / 10000; return w >= 100 ? Math.round(w) + "万" : w.toFixed(1) + "万"; } const y = num / 100000000; return y >= 100 ? Math.round(y) + "亿" : y.toFixed(1) + "亿"; } function formatDuration(seconds) { seconds = Math.floor(Number(seconds)); const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; const pad = (n) => String(n).padStart(2, "0"); if (h > 0) { return `${pad(h)}:${pad(m)}:${pad(s)}`; } return `${pad(m)}:${pad(s)}`; } function formatRelativeTime(dateStr) { const time = new Date(dateStr).getTime(); const diff = Date.now() - time; const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; if (diff < minute) return "刚刚"; if (diff < hour) return Math.floor(diff / minute) + "分钟前"; if (diff < day) return Math.floor(diff / hour) + "小时前"; if (diff < 7 * day) return Math.floor(diff / day) + "天前"; return new Date(dateStr).toLocaleDateString("zh-CN"); }播放量格式化这里有一个容易踩的坑:如果直接用w.toFixed(1),遇到 260000 会得到“26.0万”,看起来既啰嗦又不太像平台上常见的展示。所以我加了一个判断,超过 100 万就取整,保留整数位;不到 100 万保留一位小数。这个规则没有绝对标准,但实测观感比统一保留一位小数好。
相对时间的判断也同样,超过 7 天就不要一直算“12天前”,直接返回具体日期更符合直觉。真实接口如果返回的是时间戳,也可以直接用这套函数处理,只需要把new Date(dateStr)换成new Date(timestamp)就行。
3. 卡片结构与 CSS 样式拆解
3.1 HTML 结构设计
信息拆解已经明确,结构就很好定了。我采用语义化布局,封面部分用div包裹,标题使用a标签,保证后续能跳转;元信息使用一个独立的 meta 行,方便做 flex 布局。
<div class="video-grid" id="videoGrid"></div> <!-- 渲染后单张卡片的形状如下 --> <div class="vc-card"> <div class="vc-cover"> <img class="vc-cover-img">.vc-cover { position: relative; width: 100%; aspect-ratio: 16 / 10; background: #f1f2f3; overflow: hidden; border-radius: 8px 8px 0 0; }有人会担心aspect-ratio的兼容性。实际我测下来,目前主流浏览器都支持良好。如果你要兼容比较老的环境,可以退一步使用经典的padding-top: 62.5%方案,原理是让高度由 padding 撑起来,再把图片绝对定位在容器内。两种方式实现效果一样,新项目我建议直接用aspect-ratio,代码更干净。
封面里几个角标都是绝对定位,这也是这类 UI 的固定套路。左上角放分辨率和分类,右下角放时长,右上角放弹幕数,每个位置都有它固定的含义。
.vc-resolution { position: absolute; left: 8px; top: 8px; background: rgba(0, 0, 0, 0.6); color: #ffdbc9; font-size: 12px; padding: 1px 6px; border-radius: 4px; } .vc-duration { position: absolute; right: 8px; bottom: 8px; background: rgba(0, 0, 0, 0.72); color: #fff; font-size: 12px; padding: 1px 6px; border-radius: 4px; font-variant-numeric: tabular-nums; } .vc-danmaku { position: absolute; right: 8px; top: 8px; background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; padding: 1px 6px; border-radius: 4px; }时长标签我加了font-variant-numeric: tabular-nums,这是非常容易被忽略但很实用的一个属性。它会让数字按等宽方式显示,变成“06:12”时两个数字不会因为宽度不同而左右抖动。如果列表里有多个时长同时出现,这个细节会让整体整齐很多。
3.3 标题区与元信息布局
封面下面是信息区。标题区我采用两行省略,超出部分用省略号。这个效果需要三行 CSS 搭配使用:
.vc-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; font-size: 14px; font-weight: 500; line-height: 20px; color: #212121; margin: 8px 0 4px; }如果你只需要单行省略,就用white-space: nowrap; overflow: hidden; text-overflow: ellipsis;这套组合。但视频标题往往比较长,两行是更常见的产品选择,所以这里用line-clamp更合适。
信息区下面一行是作者和播放量。我使用 flex 布局,并让它们左对齐:
.vc-meta { display: flex; align-items: center; gap: 8px; color: #999; font-size: 12px; } .vc-author { flex-shrink: 0; color: #666; }注意flex-shrink: 0的用途。如果作者名和播放量放在同一行,容器宽度不够时,flex 默认会压缩子元素。不限制收缩的作者名可能被挤成一团。加这一行后,作者名会保持原有长度,超出部分由后面的播放量承担换行或省略,观感会好很多。
4. 渲染逻辑与交互实现
4.1 用模板字符串批量渲染列表
数据结构确定后,渲染代码就非常直观。我直接在 main.js 里读取数组,把每一条视频数据映射成卡片 HTML:
import { videoList } from "./data.js"; import { formatCount, formatDuration, formatRelativeTime } from "./format.js"; function escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function renderGrid() { const grid = document.querySelector("#videoGrid"); grid.innerHTML = videoList.map((item) => { const durationText = formatDuration(item.duration); const playText = formatCount(item.playCount) + "播放"; const danmakuText = "弹幕 " + formatCount(item.danmakuCount); const timeText = formatRelativeTime(item.publishTime); return ` <div class="vc-card">.vc-cover { transition: transform 0.25s ease; } .vc-card:hover .vc-cover { transform: translateY(-4px); } .vc-card::after { content: ""; position: absolute; inset: 0; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); opacity: 0; transition: opacity 0.25s ease; pointer-events: none; } .vc-card:hover::after { opacity: 1; }播放按钮用伪元素画一个三角形,避免引入图片或图标库:
.vc-play-icon { position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%) scale(0.8); background: rgba(0, 0, 0, 0.5); border: 2px solid #fff; border-radius: 50%; opacity: 0; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; } .vc-play-icon::after { content: ""; width: 0; height: 0; margin-left: 3px; border-left: 12px solid #fff; border-top: 8px solid transparent; border-bottom: 8px solid transparent; } .vc-card:hover .vc-play-icon { opacity: 1; transform: translate(-50%, -50%) scale(1); }这里我想提醒一个交互细节:播放按钮的初始状态用scale(0.8),悬停后变成scale(1)。只做淡入淡出会显得单调,加一个轻微放大,视觉反馈更明显,又不会像弹跳动画那样显得夸张。
4.3 图片懒加载
封面图是列表中最占流量和内存的部分。一次渲染几十张甚至上百张卡片时,如果全部加载,首屏体验会非常差。我采用IntersectionObserver实现按需加载:
function initLazyLoad() { const images = document.querySelectorAll(".vc-cover-img"); const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; img.classList.add("loaded"); observer.unobserve(img); } }); }, { rootMargin: "200px" } ); images.forEach((img) => observer.observe(img)); } initLazyLoad();rootMargin: "200px"表示图片进入视口前 200px 就开始加载,相当于预加载,用户快速向下滚动时不会看到空白区域闪一下。图片真正加载完成后再补loaded类名,可以做一个淡入效果:
.vc-cover-img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.3s ease; } .vc-cover-img.loaded { opacity: 1; }这里有一个细节经验:原生 HTML 的loading="lazy"属性虽然好用,但它的加载时机和IntersectionObserver不完全一致,触发距离不可控。如果已经使用了>document.querySelector("#videoGrid").addEventListener("click", (event) => { const card = event.target.closest(".vc-card"); if (!card) return; const videoId = card.dataset.videoId; const target = videoList.find((item) => item.videoId === Number(videoId)); if (target) { // 实际项目在这里跳转或打开弹窗 window.open(`https://example.com/video/${videoId}`, "_blank"); } });
事件委托还有一个好处:卡片里的标题链接是真正的a标签,鼠标点击标题时浏览器会执行默认跳转;点击封面的其他区域时,则由我们的事件处理函数接管。两种点击方式互不冲突,用户体感也更自然。
5. 响应式适配与性能优化
5.1 网格自适应方案
这类缩略图列表最常遇到的问题是布局在不同宽度下要灵活变化。我这里是双列到五列之间切换,直接使用 CSS Grid 配合媒体查询:
.video-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; max-width: 1440px; margin: 0 auto; padding: 16px; } @media (min-width: 600px) { .video-grid { grid-template-columns: repeat(3, 1fr); } } @media (min-width: 960px) { .video-grid { grid-template-columns: repeat(4, 1fr); } } @media (min-width: 1280px) { .video-grid { grid-template-columns: repeat(5, 1fr); } }你可能会想,能不能用repeat(auto-fill, minmax(200px, 1fr))一行搞定?可以,但问题是你无法精确控制某个断点下的列数。比如设计稿要求 1280px 宽时必须是 5 列,而不是能塞几列是几列,这时用显式媒体查询更可控。
另外,我在移动端保留了 2 列而不是 1 列。单列卡片在手机上会显得很长,需要滑很久才能看到几条内容;两列则更符合这类平台的移动端习惯。如果担心 2 列下标题太长,可以把标题的line-clamp调整为 2 不变,只需要适当减小字体和角度。
5.2 减少重排与触屏适配
渲染列表时有一个性能原则:不要逐条插入 DOM。如果循环里每次都用appendChild,浏览器可能在每一条插入后都触发一次重排。我上面的写法是先map生成完整 HTML 字符串,再一次赋值给innerHTML,总共只触发一次解析和渲染。列表量在几十条以内时体感差异不大,上百条时差距就很明显了。
触屏设备上还有一类难缠问题::hover效果在手机上不会触发,反而可能导致点按后出现“粘住”的状态。解决办法是在支持 hover 的设备上才启用浮起效果:
@media (hover: hover) { .vc-card:hover .vc-cover { transform: translateY(-4px); } .vc-card:hover .vc-play-icon { opacity: 1; transform: translate(-50%, -50%) scale(1); } }加上这个限制后,桌面鼠标悬停的效果保留,手机和平板上不会有悬停残留。同时,手机上点击卡片依然会走事件委托里的跳转逻辑,交互不受影响。
6. 常见问题排查与避坑清单
写这类列表页时,我踩过不少坑,也帮别人排查过不少,挑几个频率最高的整理成表格。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 封面图高度撑不起来,卡片参差不齐 | 没有给封面容器固定宽高比 | 使用aspect-ratio: 16/10或 padding-top 方案 |
| 图片加载失败后是一片默认灰底 | 没有兜底处理 | 在img.error时替换默认占位图 |
| 标题截断失效,显示成三行 | 漏写-webkit-box相关属性 | 检查display、orient、line-clamp、overflow |
| 播放量显示成“NaN万” | 后端返回了字符串或空值 | 格式化前先Number(num)并做空值判断 |
| 时长出现“0:6:2”这种格式 | 补零逻辑没处理好 | 使用padStart(2, "0")对分秒不足两位时补零 |
| 移动端点击后出现灰底 | 点击高亮或 hover 伪状态残留 | 增加全局-webkit-tap-highlight-color: transparent |
| 图片懒加载失效 | 页面初始化时图片已进入视口但没触发 | 调整rootMargin或初始化时手动判断一次 |
图片加载失败这里多说一句。网络图库的图片很容易因为防盗链、资源过期而打不开,如果什么都不做,卡片就变成一个灰色块,非常难看。可以在初始化时给图片绑定 error 事件:
document.querySelectorAll(".vc-cover-img").forEach((img) => { img.addEventListener("error", () => { img.src = "./img/placeholder.jpg"; }); });占位图可以是一张纯色背景加简单文字的图片。这个兜底方案一定要放在懒加载之后执行,否则图片src还没有赋值,浏览器还没有开始请求,自然也就不会触发 error。
还有一个容易被忽略的问题,就是弹幕数和播放量在数值比较大时,会出现“26万播放”和“1200弹幕”混排,长度不一样,视觉上会显得乱。我处理的办法是给 stats 区域固定最小宽度,或者干脆让它们右对齐显示。如果你的产品设计里元信息统一靠右,那就在vc-meta上加一个margin-left: auto让播放量等数据靠右。
在真实项目中,后端返回的数据字段名不一定和我 mock 里完全一致,比如有的叫viewCount,有的叫play_num。我在接入真实接口时通常会在渲染前做一层数据映射:
function normalizeVideo(raw) { return { videoId: raw.id, title: raw.title, author: raw.upName, cover: raw.coverUrl, duration: raw.duration, playCount: raw.viewNum, danmakuCount: raw.dmNum, publishTime: raw.publishAt }; }这样做的目的是让模板渲染层保持稳定,不管后端字段怎么变,只改这一层映射就够了。很多新手喜欢直接在模板里写raw.coverUrl,后端改一个字段名就要在模板里翻半天,这种经历我相信不少人都遇到过。
最后分享一个小技巧:如果你想让这套缩略图看起来更有“视频社区”的味道,可以再加一个“分区角标”。比如视频属于“科技”“音乐”“游戏”,就在封面左上角的分辨率标签旁边再放一个分类标签。这个只要在渲染时多加一个 span 就行,但前提是数据里要有category字段。别小看这一个角标,多一个分类信息之后,整个页面的信息密度和真实感会明显提升。
这个项目我做到后面最大的体会是:缩略图卡片看似简单,真正决定它好不好看的不是某一个炫技特效,而是信息层级、字体大小、间距和状态反馈这些细节。每个部分单独拿出来都不难,难的是把它们组合成一个稳定可靠又耐看的组件。如果你照着做一遍之后发现某个细节和常见网站不太一样,不用太纠结,因为不同版本里这类 UI 本身就有细微差异,抓住“封面优先、标题次之、元信息最后”这个核心逻辑就足够了。后面我打算再写一篇多形态缩略图的扩展,比如加上直播角标、排行榜编号或者多图封面,到时候再一起交流。