资讯动态

原生HTML/CSS/JS实现视频网站缩略图卡片组件开发

发布时间:2026/10/17 21:28:37 来源:尧图企业网站定制
先把结论放到最前面这种缩略图卡片一点都不神秘拆开来看就是“封面图 几个角标 标题区 作者与数据区”的组合。我这篇文章要做的事情就是把弹幕视频站首页那种经典的视频缩略图样式拆成一个个可复用的前端部件用原生 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: 高清 } ];字段说明用表格整理一下后面写渲染逻辑时会更清楚。字段类型作用videoIdnumber视频唯一标识用于点击跳转titlestring卡片标题两行截断authorstringUP 主名称coverstring封面图地址durationnumber视频时长单位秒playCountnumber播放量渲染时格式化danmakuCountnumber弹幕数渲染时格式化publishTimestring发布时间渲染相对时间categorystring分区分类可做角标resolutionstring分辨率标识如 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 classvideo-grid idvideoGrid/div !-- 渲染后单张卡片的形状如下 -- div classvc-card div classvc-cover img classvc-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 里读取数组把每一条视频数据映射成卡片 HTMLimport { videoList } from ./data.js; import { formatCount, formatDuration, formatRelativeTime } from ./format.js; function escapeHtml(str) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); } 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 classvc-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 的loadinglazy属性虽然好用但它的加载时机和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 本身就有细微差异抓住“封面优先、标题次之、元信息最后”这个核心逻辑就足够了。后面我打算再写一篇多形态缩略图的扩展比如加上直播角标、排行榜编号或者多图封面到时候再一起交流。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑