
赛道真题里卡片化标签这个题型出现的频率不低原因很实在它把 CSS 布局、状态管理、DOM 渲染、交互反馈四件事压在一个不到一屏的界面上考察密度极高。它看着像做几个小方块加文字实际动手就会发现卡片不像纯文字标签那样能随便挤在一起尺寸、间距、换行、选中态、超限提示、窗口缩放全都是坎。我带过几个刚开始刷题的朋友做这题第一版几乎都栽在同一个地方——把卡片当成div硬写样样都对但一改需求就整段推倒重来。这篇文章把我自己反复打磨过的实现思路、参数计算过程、踩坑记录完整写出来从零搭一个能通过绝大多数变体的卡片化标签组件。刚入门的前端能照着抄作业有过一两年经验的同学可以重点看选型对比和排查表那些是评分表上真正拉开差距的地方。1. 先把题目读薄卡片化标签到底在考什么1.1 从标签到卡片变的不只是形状纯文字标签业内常叫 pill 或者 chip本质是一个词 一个底色信息层级只有一层用户扫一眼就知道是什么。卡片化标签不一样它把每个标签升级成一张有边界、有内边距、可能有图标和副标题的小卡片点击区域从文字扩展到整块矩形视觉权重大幅提升。这个变化直接带来三个连锁反应一是布局不能再靠文字自然流必须给每张卡片一个最小宽度否则长短不一的内容会把整行撑得歪歪扭扭二是卡片之间必须留出呼吸缝间距不够就会糊成一片色块三是选中态必须比纯文字标签更明显因为卡片面积大浅浅改个边框色用户根本察觉不到。我在真实项目里用这套东西最多的场景是技能标签墙、兴趣多选、商品筛选、快捷入口导航。它们的共同点是选项数量不固定、文案长度不固定、需要多选或单选。赛题往往就是从这些场景里抽一个出来让你在限定时间内做出视觉和交互都过关的版本。所以审题时不要只盯着画得像不像要把选项会变多、文案会变长、窗口会变窄这三个变量提前想进去后面写代码才不会白干。1.2 我在动手前会先拆出的四个考察点拿到这类题我不会立刻打开编辑器写 HTML先在纸上列四条按重要性排序。第一条是视觉一致性所有卡片的圆角、内边距、字号、阴影必须来自同一套变量不能这个 8px 那个 10px评审一眼就能看出来是随手写的。第二条是交互完整性选中、取消、达到上限后拒绝、清空全部这四件事缺一件就算功能不完整很多人的实现里取消是靠再点一次碰运气逻辑上根本没闭环。第三条是数据驱动能力。真题常常给一段 JSON 或者一个数组要求渲染出来如果你把十张卡片硬编码在 HTML 里改数据就得改结构扩展性直接零分。第四条是边界处理超长文案、只有一个选项、选项为空、快速连点、窗口从 1440 拖到 375这些边界不写防护现场演示一拖窗口就露馅。这四条排完序我心里对哪些必须做对、哪些可以锦上添花就有数了时间分配也不会失控。1.3 技术选型的取舍纯 CSS 能走多远有个经典问题是能不能不用 JS。答案是能但只能做最简单的单选。做法是用隐藏的input[typecheckbox]配合label包裹卡片内容靠:checked label选择器改样式。这套方案的好处是零 JS、天然支持键盘、状态跟着表单走坏处也很硬多选上限、剩余可选数量提示、清空按钮、动态数据渲染这几样纯 CSS 全都做不了最多用:has()做一点点近邻判断兼容性和可读性都不划算。方案实现成本能做多选上限支持动态数据键盘可达适用场景纯 CSSinputlabel低否否原生支持单选用、选项固定JS 数据驱动 渲染中是是需手写赛题、真实业务框架组件化中高是是需手写长期维护的项目我给的建议很直接赛题既然给了数据或者明确要求多选逻辑就走 JS 数据驱动别为了炫技硬套纯 CSS最后为补一个上限判断又塞进脚本两套状态打架比一开始就统一更乱。选型的判断标准永远是需求边界在哪里不是哪种写法看起来更高级。2. 结构与样式的底层设计2.1 HTML 语义骨架先写对再写好结构这一层我坚持用ul li button而不是一堆div加onclick。理由有三个button天生可以被 Tab 聚焦按 Enter 和 Space 就能触发无障碍和键盘操作白送ul/li让列表语义清晰屏幕阅读器会读出列表共 N 项按钮自带的:focus-visible也能省掉一部分焦点样式的手写工作。很多人图省事写div结果评审用 Tab 键点两下发现焦点进不去直接扣分。section classtag-wall aria-labelledbytag-wall-title header classtag-wall__head h2 idtag-wall-title选择你关注的领域/h2 p classtag-wall__hint 已选 span idpicked-count0/span / 3 /p /header ul classtag-list idtag-list rolelist !-- 由脚本渲染 -- /ul footer classtag-wall__foot button typebutton classbtn btn--ghost idclear-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 ≤ 1164n 取 10。此时每列宽(1152 - 9 * 12) / 10 104.4px比最小值 96px 大说明 1fr 起效了卡片会被拉伸填满不会留一条难看的右边缘空隙。如果把minmax的最小值改成 140pxn 就掉到 7列宽 154px卡片变大、行数变多这是调参时最直接的抓手。在 375px 的手机宽度下容器内边距缩到 16px可用宽度 343px间距 12px343 88n 12(n - 1)得100n 355n 取 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) ({ : amp;, : lt;, : gt;, : quot;, : #39; }[ch])); function render() { listEl.innerHTML TAGS.map((tag) { const active picked.has(tag.id); return li button typebutton classtag-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元素匹配如果坚持做rolelistboxroleoption那就该配aria-selected和方向键导航两套语义不要混着用混用会被读屏工具读成四不像。另外数量提示我加在头部文案里视觉上够了但屏幕阅读器不会自动播报变化稳妥的做法是给提示容器加aria-livepolite。p classtag-wall__hint aria-livepolite 已选 span idpicked-count0/span / 3 /p还有一个细节卡片内部有图标和副标题读屏时如果全都读一遍会很吵。我给图标加aria-hiddentrue把主文案和副标题包成一个整体读出来就是前端工程构建与性能按钮未按下信息完整又不啰嗦。这类打磨在评分表里通常占不高分但它是看得出有没有做过真实项目的分水岭。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 剩 142px14px 的主文案能放 10 个汉字够用如果再加一行 12px 的副标题卡片高度会从 56px 涨到 76px一屏只能露两行滑动成本变高。隐藏副标题不是偷懒是权衡之后的取舍写的时候顺手加一句注释说明意图评审看了会觉得你想过。4. 常见问题与排查实录4.1 样式类问题速查表这类题的 bug 有很强的规律性我把遇到过的整理成表出问题时按表逐条排除比盲目改代码快得多。问题现象触发条件根本原因处理方案卡片高度参差不齐副标题长短不一grid 子项默认stretch但内容撑高不一致给卡片设min-height并让内容区flex: 1hover 阴影被裁掉卡片紧贴容器边缘父级overflow: hidden裁掉了溢出阴影父级改用overflow: visible或加内边距点击卡片时文字被选中快速拖动或连点浏览器默认允许选中文本卡片上加user-select: none选中态几乎看不出来浅色背景屏只改了边框颜色对比度不足边框 背景 文字三重变化长文案撑破卡片文案超过 12 个汉字未设最小宽度与换行规则min-width: 0overflow-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找节点该用>