赛道真题里,卡片化标签这个题型出现的频率不低,原因很实在:它把 CSS 布局、状态管理、DOM 渲染、交互反馈四件事压在一个不到一屏的界面上,考察密度极高。它看着像"做几个小方块加文字",实际动手就会发现,卡片不像纯文字标签那样能随便挤在一起,尺寸、间距、换行、选中态、超限提示、窗口缩放全都是坎。我带过几个刚开始刷题的朋友做这题,第一版几乎都栽在同一个地方——把卡片当成div硬写,样样都对,但一改需求就整段推倒重来。这篇文章把我自己反复打磨过的实现思路、参数计算过程、踩坑记录完整写出来,从零搭一个能通过绝大多数变体的卡片化标签组件。刚入门的前端能照着抄作业,有过一两年经验的同学可以重点看选型对比和排查表,那些是评分表上真正拉开差距的地方。
1. 先把题目读薄:卡片化标签到底在考什么
1.1 从"标签"到"卡片",变的不只是形状
纯文字标签(业内常叫 pill 或者 chip)本质是"一个词 + 一个底色",信息层级只有一层,用户扫一眼就知道是什么。卡片化标签不一样,它把每个标签升级成一张有边界、有内边距、可能有图标和副标题的小卡片,点击区域从文字扩展到整块矩形,视觉权重大幅提升。这个变化直接带来三个连锁反应:一是布局不能再靠文字自然流,必须给每张卡片一个最小宽度,否则长短不一的内容会把整行撑得歪歪扭扭;二是卡片之间必须留出呼吸缝,间距不够就会糊成一片色块;三是选中态必须比纯文字标签更明显,因为卡片面积大,浅浅改个边框色用户根本察觉不到。
我在真实项目里用这套东西最多的场景是技能标签墙、兴趣多选、商品筛选、快捷入口导航。它们的共同点是"选项数量不固定、文案长度不固定、需要多选或单选"。赛题往往就是从这些场景里抽一个出来,让你在限定时间内做出视觉和交互都过关的版本。所以审题时不要只盯着"画得像不像",要把"选项会变多、文案会变长、窗口会变窄"这三个变量提前想进去,后面写代码才不会白干。
1.2 我在动手前会先拆出的四个考察点
拿到这类题,我不会立刻打开编辑器写 HTML,先在纸上列四条,按重要性排序。第一条是视觉一致性,所有卡片的圆角、内边距、字号、阴影必须来自同一套变量,不能这个 8px 那个 10px,评审一眼就能看出来是随手写的。第二条是交互完整性,选中、取消、达到上限后拒绝、清空全部,这四件事缺一件就算功能不完整,很多人的实现里"取消"是靠再点一次碰运气,逻辑上根本没闭环。
第三条是数据驱动能力。真题常常给一段 JSON 或者一个数组,要求渲染出来,如果你把十张卡片硬编码在 HTML 里,改数据就得改结构,扩展性直接零分。第四条是边界处理,超长文案、只有一个选项、选项为空、快速连点、窗口从 1440 拖到 375,这些边界不写防护,现场演示一拖窗口就露馅。这四条排完序,我心里对"哪些必须做对、哪些可以锦上添花"就有数了,时间分配也不会失控。
1.3 技术选型的取舍:纯 CSS 能走多远
有个经典问题是"能不能不用 JS"。答案是能,但只能做最简单的单选。做法是用隐藏的input[type=checkbox]配合label包裹卡片内容,靠:checked + label选择器改样式。这套方案的好处是零 JS、天然支持键盘、状态跟着表单走;坏处也很硬:多选上限、剩余可选数量提示、清空按钮、动态数据渲染,这几样纯 CSS 全都做不了,最多用:has()做一点点近邻判断,兼容性和可读性都不划算。
| 方案 | 实现成本 | 能做多选上限 | 支持动态数据 | 键盘可达 | 适用场景 |
|---|---|---|---|---|---|
| 纯 CSS(input+label) | 低 | 否 | 否 | 原生支持 | 单选用、选项固定 |
| JS 数据驱动 + 渲染 | 中 | 是 | 是 | 需手写 | 赛题、真实业务 |
| 框架组件化 | 中高 | 是 | 是 | 需手写 | 长期维护的项目 |
我给的建议很直接:赛题既然给了数据或者明确要求多选逻辑,就走 JS 数据驱动,别为了"炫技"硬套纯 CSS,最后为补一个上限判断又塞进脚本,两套状态打架,比一开始就统一更乱。选型的判断标准永远是"需求边界在哪里",不是"哪种写法看起来更高级"。
2. 结构与样式的底层设计
2.1 HTML 语义骨架:先写对,再写好
结构这一层,我坚持用ul > li > button而不是一堆div加onclick。理由有三个:button天生可以被 Tab 聚焦,按 Enter 和 Space 就能触发,无障碍和键盘操作白送;ul/li让列表语义清晰,屏幕阅读器会读出"列表,共 N 项";按钮自带的:focus-visible也能省掉一部分焦点样式的手写工作。很多人图省事写div,结果评审用 Tab 键点两下发现焦点进不去,直接扣分。
<section class="tag-wall" aria-labelledby="tag-wall-title"> <header class="tag-wall__head"> <h2 id="tag-wall-title">选择你关注的领域</h2> <p class="tag-wall__hint"> 已选 <span id="picked-count">0</span> / 3 </p> </header> <ul class="tag-list" id="tag-list" role="list"> <!-- 由脚本渲染 --> </ul> <footer class="tag-wall__foot"> <button type="button" class="btn btn--ghost" id="clear-btn">清空</button> </footer> </section>这里有个细节值得说:我把"已选 N / 3"的提示放在头部而不是底部,是因为用户点击卡片时视线在列表区域,头部在视野上方,动态变化的数字更容易被余光扫到。这类"信息放在视线动线上"的考虑,在还原题里属于加分项,写起来只多两行代码。
2.2 尺寸体系:用一套变量把比例锁死
卡片好不好看,八成取决于比例是否统一。我的习惯是先定一个基准字号,其他尺寸按比例推。基准文字 14px,卡片内边距取 12px 16px,圆角 10px,图标 16px,行高 1.4,这几个数之间是有关系的:内边距的横向值略大于纵向值,视觉上更接近正方形的呼吸感;圆角 10px 相对卡片高度 56px 左右,约等于 18%,超过 25% 就会显得像胶囊,低于 10% 又太方正。
:root { --card-radius: 10px; --card-pad-y: 12px; --card-pad-x: 16px; --card-gap: 12px; --card-min-w: 96px; --font-tag: 14px; --line-tag: 1.4; --color-line: #e5e7eb; --color-ink: #1f2937; --color-sub: #6b7280; --color-brand: #2563eb; --color-brand-soft: #eff6ff; --shadow-rest: 0 1px 2px rgba(16, 24, 40, 0.06); --shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.12); --ease: cubic-bezier(0.2, 0.8, 0.2, 1); }把尺寸集中成变量不是为了好看,是为了改一处全站生效。真题经常在最后加一句"把整体风格改成深色"或者"间距放大一号",变量化之后改五六行就能交差,硬编码的话你得翻遍整个样式表。我踩过的坑是早期用px写死所有尺寸,后期加一个"紧凑模式"需求,改到手酸还漏了两处。
2.3 布局方案对比:flex-wrap 还是 grid
这是选型里最容易被忽略的一步。flex-wrap的特点是每张卡片按自身内容宽度伸展,短文案的卡片就窄,长文案的就宽,整体像河流一样自然;grid配合repeat(auto-fill, minmax(...))则是等宽网格,所有卡片严格对齐,像地砖。视觉上哪个更像"卡片墙"?我的判断是:如果卡片只有一行主文案、长度差异不大,用 grid 更整齐;如果卡片带图标、副标题、长度差异明显,flex-wrap 更从容,不会出现"短文案卡片里大片空白"的尴尬。
.tag-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min-w), 1fr)); gap: var(--card-gap); padding: 0; margin: 0; list-style: none; }auto-fill和minmax(96px, 1fr)这组的计算过程值得捋一遍。假设容器内容宽 1200px、左右内边距各 24px,可用宽度是 1152px;间距 12px。列数 n 要满足n * 96 + (n - 1) * 12 ≤ 1152,化简得108n ≤ 1164,n 取 10。此时每列宽(1152 - 9 * 12) / 10 = 104.4px,比最小值 96px 大,说明 1fr 起效了,卡片会被拉伸填满,不会留一条难看的右边缘空隙。如果把minmax的最小值改成 140px,n 就掉到 7,列宽 154px,卡片变大、行数变多,这是调参时最直接的抓手。
在 375px 的手机宽度下,容器内边距缩到 16px,可用宽度 343px,间距 12px,343 = 88n + 12(n - 1)得100n = 355,n 取 3,列宽(343 - 24) / 3 ≈ 106px。三列 106px 的卡片,主文案 14px,一行能放大约 6 个汉字,超过就要折行,所以移动端必须给卡片设min-height或者允许两行,否则高度参差。
2.4 动效与选中态:让点击有"回执"
动效这件事,赛题里是双刃剑。做得好显得精致,做过头显得浮夸,还容易因为transition: all触发不必要的重排。我的原则是只动transform、box-shadow、border-color、background-color这四个属性,时长 160ms 到 200ms,缓动统一用一条自定义曲线,不用默认的ease,因为ease起步太软,点击反馈会显得"黏"。
.tag-card { display: flex; align-items: center; gap: 8px; min-height: 56px; padding: var(--card-pad-y) var(--card-pad-x); border: 1px solid var(--color-line); border-radius: var(--card-radius); background: #fff; box-shadow: var(--shadow-rest); color: var(--color-ink); font-size: var(--font-tag); line-height: var(--line-tag); cursor: pointer; user-select: none; transition: transform 180ms var(--ease), box-shadow 180ms var(--ease), border-color 180ms var(--ease), background-color 180ms var(--ease); } .tag-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover); } .tag-card.is-active { border-color: var(--color-brand); background: var(--color-brand-soft); color: var(--color-brand); } .tag-card:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }translateY(-2px)这个位移量不是随手写的。卡片高度 56px,位移 2px 约等于 3.5%,再大一点(比如 6px)在密集网格里会让相邻卡片看起来在互相挤压,尤其间距只有 12px 的时候。阴影的扩散半径 20px 也受间距约束,扩散太大两行卡片之间就会重叠出灰边,12px 的间距配 20px 扩散刚好卡在临界点,实测下来非常稳。选中态我用"边框变色 + 浅色底 + 文字变色"三重叠加,而不是只改边框,原因是卡片面积大,单一维度的变化在浅色屏幕上几乎看不见。
3. 交互逻辑的完整实现
3.1 数据驱动的渲染方式
渲染这一层,我写的是"数组 → 模板字符串 → 一次性写入 innerHTML"。为什么不用createElement逐个追加?因为选项数量从 5 到 50 不等,innerHTML一次赋值只触发一次重排,而逐个appendChild在数量多的时候会触发多次,虽然浏览器有优化,但写法上更啰嗦。当然要防注入,赛题数据一般是我们自己写的,但养成习惯没坏处,所有文案都套一层转义函数。
const TAGS = [ { id: 'fe', name: '前端工程', desc: '构建与性能', icon: '◆' }, { id: 'vis', name: '数据可视化', desc: '图表与交互', icon: '◆' }, { id: 'a11y', name: '无障碍', desc: '键盘与读屏', icon: '◆' }, { id: 'anim', name: '动效设计', desc: '节奏与反馈', icon: '◆' }, { id: 'node', name: '服务端渲染', desc: '首屏与缓存', icon: '◆' }, { id: 'test', name: '自动化测试', desc: '单测与回归', icon: '◆' } ]; const MAX_PICK = 3; const picked = new Set(); const listEl = document.getElementById('tag-list'); const countEl = document.getElementById('picked-count'); const escapeHtml = (str) => String(str).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); function render() { listEl.innerHTML = TAGS.map((tag) => { const active = picked.has(tag.id); return ` <li> <button type="button" class="tag-card${active ? ' is-active' : ''}" >listEl.addEventListener('click', (event) => { const card = event.target.closest('.tag-card'); if (!card) return; const id = card.dataset.id; if (picked.has(id)) { picked.delete(id); render(); return; } if (picked.size >= MAX_PICK) { shake(card); toast(`最多选择 ${MAX_PICK} 项`); return; } picked.add(id); render(); }); document.getElementById('clear-btn').addEventListener('click', () => { if (picked.size === 0) return; picked.clear(); render(); }); function shake(el) { el.classList.remove('is-shake'); void el.offsetWidth; // 强制重排,让动画能重放 el.classList.add('is-shake'); el.addEventListener('animationend', () => { el.classList.remove('is-shake'); }, { once: true }); }void el.offsetWidth这一行是很多人不知道的小技巧。CSS 动画如果在同一个元素上连续触发,浏览器会因为类名没变化而跳过重放,读一次offsetWidth强制触发样式重算,动画就能重新开始。{ once: true }让监听器执行一次后自动解绑,避免类名清理的回调越堆越多。抖动动画本身的幅度我也调过:横向位移 4px、三个来回、总时长 300ms 左右最合适,再长就显得卡顿。
@keyframes card-shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 50% { transform: translateX(4px); } 75% { transform: translateX(-3px); } } .tag-card.is-shake { animation: card-shake 300ms var(--ease); border-color: #f87171; }3.3 状态同步与无障碍细节
无障碍这块容易丢分,但其实只多写几个属性。按钮我用aria-pressed而不是aria-selected,因为前者语义是"可切换按钮的按下状态",与button元素匹配;如果坚持做role="listbox"+role="option",那就该配aria-selected和方向键导航,两套语义不要混着用,混用会被读屏工具读成四不像。另外数量提示我加在头部文案里,视觉上够了,但屏幕阅读器不会自动播报变化,稳妥的做法是给提示容器加aria-live="polite"。
<p class="tag-wall__hint" aria-live="polite"> 已选 <span id="picked-count">0</span> / 3 </p>还有一个细节:卡片内部有图标和副标题,读屏时如果全都读一遍会很吵。我给图标加aria-hidden="true",把主文案和副标题包成一个整体,读出来就是"前端工程,构建与性能,按钮,未按下",信息完整又不啰嗦。这类打磨在评分表里通常占不高分,但它是"看得出有没有做过真实项目"的分水岭。
3.4 响应式与容器自适应
响应式的关键不是写几个媒体查询,而是让布局自己会缩。我的做法是三层保险:第一层用minmax让列数自动变化,第二层用clamp()让内边距和字号在小屏上自动收缩,第三层针对 375px 以下做一次手动兜底。clamp(12px, 2vw, 16px)这种写法比写三档媒体查询更省事,2vw 在 375px 屏幕上是 7.5px,被下限 12px 兜住,在 1440px 屏幕上是 28.8px,被上限 16px 兜住,中间区域平滑过渡。
.tag-wall { max-width: 1200px; margin: 0 auto; padding: clamp(16px, 2vw, 24px); } .tag-card { padding: clamp(10px, 1.2vw, 12px) clamp(12px, 1.5vw, 16px); } @media (max-width: 480px) { .tag-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .tag-card__desc { display: none; /* 小屏只留主文案,避免两行挤压 */ } }小屏隐藏副标题这个决定,我是算过的:375px 宽、两列、间距 10px,每列约 166px,减去左右内边距 24px 剩 142px,14px 的主文案能放 10 个汉字,够用;如果再加一行 12px 的副标题,卡片高度会从 56px 涨到 76px,一屏只能露两行,滑动成本变高。隐藏副标题不是偷懒,是权衡之后的取舍,写的时候顺手加一句注释说明意图,评审看了会觉得你想过。
4. 常见问题与排查实录
4.1 样式类问题速查表
这类题的 bug 有很强的规律性,我把遇到过的整理成表,出问题时按表逐条排除,比盲目改代码快得多。
| 问题现象 | 触发条件 | 根本原因 | 处理方案 |
|---|---|---|---|
| 卡片高度参差不齐 | 副标题长短不一 | grid 子项默认stretch但内容撑高不一致 | 给卡片设min-height并让内容区flex: 1 |
| hover 阴影被裁掉 | 卡片紧贴容器边缘 | 父级overflow: hidden裁掉了溢出阴影 | 父级改用overflow: visible或加内边距 |
| 点击卡片时文字被选中 | 快速拖动或连点 | 浏览器默认允许选中文本 | 卡片上加user-select: none |
| 选中态几乎看不出来 | 浅色背景屏 | 只改了边框颜色,对比度不足 | 边框 + 背景 + 文字三重变化 |
| 长文案撑破卡片 | 文案超过 12 个汉字 | 未设最小宽度与换行规则 | min-width: 0+overflow-wrap: anywhere |
| 缩放窗口时卡片抖动 | 拖动浏览器边缘 | 列数在临界点反复切换 | 用clamp平滑过渡,避免硬断点 |
| 阴影发灰发脏 | 多层阴影叠加 | 阴影颜色用了纯黑且 alpha 过高 | 底色偏冷灰,alpha 控制在 0.12 以内 |
我重点说"长文案撑破卡片"这条,因为它最容易在演示环节翻车。表现是某张卡片横着伸出去一截,把整行挤歪。原因是grid子项默认min-width: auto,内容不肯收缩。修法有两步:给卡片加min-width: 0打破默认值,再给文案加overflow-wrap: anywhere,让超长英文或连续字符也能断行。如果是中文还好,遇到长英文单词或者 URL 就必炸,务必测一遍。
4.2 交互类问题的排查思路
交互 bug 不像样式那样肉眼可见,得靠逻辑推。我总结了一个排查顺序:先看状态,再看事件,最后看渲染。状态问题最典型的是"点击没反应",第一反应要看事件是不是绑在了被innerHTML替换掉的旧节点上——如果是,改成事件委托到不会变的父容器上,问题立刻消失。第二典型是"能选中但不能取消",八成是判断条件写成了if (!picked.has(id))却没写 else 分支,或者Set.delete之后忘了重新渲染。
事件层面的坑是"快速连点导致状态错乱"。这类问题在本地慢慢点永远复现不了,得用自动化工具或者手动狂点。根因通常是异步操作里读了旧状态,或者动画期间允许重复点击。我的处理办法很简单:状态变更的操作做成同步的,别塞setTimeout;动画用纯 CSS,不参与逻辑判断;如果需要防重复,加一个isAnimating标记,动画结束再放开。
渲染层面的坑比较隐蔽:"明明选中了但样式没变"。可能是类名拼写不一致(is-active和active混用),可能是选择器权重不够被别的规则覆盖,也可能是aria-pressed改了但类名没改。我的做法是渲染函数里只维护一份active布尔值,类名和aria属性都由它派生,绝不各写各的,从源头上杜绝不一致。
4.3 竞赛现场真正拉开差距的细节
前面都是"做对",这一节说"做好"。我观察下来,同样功能完整的两份实现,差距往往在这几处。代码分层是否清晰——HTML 只放骨架、CSS 只放样式、JS 只放逻辑,三者不互相污染;注释是否写到点子上——不是每行都写,而是在"为什么用clamp而不是媒体查询"这种决策点写一句;动效是否克制——所有过渡时长在 160 到 200ms 之间,全站一个缓动函数,不会这里 300ms 那里 100ms。
还有一个容易被忽视的:空状态。如果题目允许"清空",那清空之后列表应该长什么样?我的处理是保留卡片,只是全部取消选中,而不是整个列表消失,同时"清空"按钮在无选中时置灰不可点。这个小小的置灰逻辑,体现出你考虑过"按钮无效状态",是产品思维的体现。再就是控制台必须干净,任何一条 404 或者警告在评审眼里都是减分项,提交前按 F12 从头翻到尾,逐个清掉。
5. 从赛题到真实项目的迁移
5.1 顺手改造成筛选器组件
这套东西稍加改造就是真实业务里的多选筛选器。改动的核心是把"选中即展示"换成"选中即过滤":picked这个集合从展示状态变成查询条件,每次变化后触发一次数据过滤,把结果渲染到下面的列表里。用Set存储的好处在这里体现得很明显,判断某个元素是否被选是 O(1),不需要遍历数组。如果选项数量到几百个,我会把整体重渲染改成"只更新差异部分",但坦白讲,量级没到那一步之前,过早优化反而增加出错面。
再往前一步是"标签管理后台",可以新增、删除、改名。这时候TAGS数组就不能是常量了,得放进一个可变的数据源,渲染函数依然只负责读数据画界面。这个思维转变很关键:渲染是数据的投影,任何界面变化都应该先改数据再重画,而不是直接去操作 DOM 节点。养成这个习惯之后,你会发现 bug 数量断崖式下降,因为所有状态只有一个入口。
5.2 性能与可维护性的几个实用建议
最后分享几条实操建议。第一,把MAX_PICK、卡片尺寸这些"魔法数字"全部提成常量或 CSS 变量,放在文件顶部,改需求时只改一处。第二,渲染函数里避免字符串拼接大量 HTML 之后又去querySelector找节点,该用>