
1. 先把“卡片化标签”这道题拆开看前端Web赛道里卡片化标签属于那种“一眼看上去很简单、真动手写就开始掉分”的题型。它不像算法题有明确的输入输出也没有标准答案可比对考的是你对 DOM 结构、CSS 布局、状态管理和交互细节的综合掌控。我见过不少同学拿到题就开始写div一顿span加背景色最后功能全对分数出来却不高——问题往往不在“能不能用”而在“用得规不规范、细节扛不扛得住看”。先说清楚它是什么。卡片化标签本质是把一组标签Tag从传统的“小药丸”形态升级成“卡片”形态每个标签有自己的标题、可能有副标题或描述、有图标位、有选中态、有删除按钮多个卡片按容器宽度自动换行排列用户可以点选、取消、限选、搜索过滤。它比普通标签多了信息层级也比普通卡片多了“可切换状态”这一层语义。适合谁看如果你正在准备前端Web赛道、想补一套可复用的组件写法、或者单纯想把“列表渲染 状态机 响应式布局”这套组合拳打扎实这篇内容都能直接拿去用。我个人的判断是这道题的分水岭在三个地方——结构是否语义化、状态是否单一数据源、细节是否有“被追问”的余地。下面按我实际的做题顺序展开从思路到代码到踩坑尽量把每一步的“为什么”都说透。1.1 从评分维度倒推这题到底在考什么赛题给的需求描述通常比较简略比如“实现一个卡片化标签区域支持单选/多选、支持删除、支持响应式”。但评委手里的评分维度往往比题面细得多。我总结下来大致是这几块功能完整性选中、取消、限选提示、删除、结构与语义合理使用列表、可访问性属性、样式还原度间距、圆角、层级、状态区分、响应式表现窄屏是否塌陷、换行是否自然、代码质量是否数据驱动、有无重复代码、命名是否清晰。理解这一点非常关键。因为它直接决定了你的时间分配功能实现大概占六成精力剩下四成要留给结构和细节。很多人把 90% 的时间花在“点上去变个颜色”最后交互有了但结构是一堆平铺的div窄屏下卡片被挤成两行文字这部分分就白丢了。我的建议是做题前先在草稿纸上列一张自己的验收清单把“能点、能取消、能限选、能删、能搜、能换行、能键盘操作”七条写下来写完一条划掉一条。这个动作花不了三分钟但能避免最后十分钟手忙脚乱地补漏。1.2 卡片化标签和传统标签差别到底在哪很多人栽跟头是因为把它当成“加了个边框的标签”来做结果信息层级撑不起来。我用一张表把这俩的差异摆清楚你对着看就知道该往哪儿使劲。对比项传统标签卡片化标签信息层级单行文本标题 描述 可选图标尺寸自适应文字宽度有最小宽度通常等宽或按内容分档排列方式行内流式排列网格或弹性换行讲究对齐状态表达背景色变化边框、投影、角标、勾选图标多重反馈交互成本点击切换点击切换 删除 限选提示 键盘布局难点基本没有换行对齐、末行不满、高度不齐看完这张表你会发现真正的难点不在“做个圆角矩形”而在排列的一致性和状态的可读性。传统标签参差不齐反而无所谓卡片化标签一旦高度不齐、间距不匀视觉上就散了。所以后面的样式章节我会把重点放在“如何保证卡片高度一致”和“如何用最少的视觉手段区分三种状态”上。1.3 开工前的文件与目录规划别小看这一步。单文件堆到底当然也能跑但调试成本会高很多。我的习惯是拆成三个文件index.html放结构style.css放样式app.js放逻辑。如果赛题允许再抽一个data.js放模拟数据这样改数据不用翻逻辑代码。目录大致长这样project/ ├── index.html ├── style.css ├── app.js └── data.js为什么不把所有东西塞进 HTML因为卡片化标签的核心是“数据驱动渲染”逻辑一旦和数据混在一起改一个字段要翻三处出错概率直线上升。分开之后调试路径非常清晰显示不对先看 CSS数据不对先看 data点了没反应先看 app。注意如果赛题明确要求单文件提交也要在单文件内部用注释把三块区域分隔开逻辑上保持同样的分区别让评委在一坨代码里找你的状态管理。2. 结构设计语义化骨架怎么搭结构是这题的地基地基歪了后面 CSS 怎么调都别扭。我见过最典型的错误是用一串平铺的div当卡片容器每个卡片内部再套五层div结果既不好选中也不好维护。正确的做法是先想清楚“这块内容在语义上是什么”再决定用哪个标签。卡片化标签本质上是“一组可选项的集合”所以最外层应该是一个区域section里面是一个列表ul每个卡片是一个列表项li。列表项内部再分标题区、描述区、操作区。这个结构天然带层级读屏软件也能识别出“这是一个包含 N 项的列表”无障碍这块就先拿了一分。2.1 三层结构拆解法区域、列表、卡片我习惯用三层来拆最外层是“标签面板”负责布局和标题中间层是“标签列表”负责排列和换行最内层是“标签卡片”负责单个项的内容和状态。三层各司其职CSS 也好写——面板管 padding列表管 gap卡片管自身的视觉。section classtag-panel aria-labelledbypanel-title header classtag-panel__head h2 idpanel-title classtag-panel__title选择你关注的方向/h2 p classtag-panel__hint最多可选 3 项/p /header ul classtag-list idtagList rolelist !-- 卡片由 JS 渲染 -- /ul p classtag-panel__empty idemptyTip hidden没有匹配的标签/p /section单个卡片的结构我建议固定成“图标位 文本区 操作位”三段式哪怕某张卡片没有图标也保留占位节点用aria-hiddentrue隐藏这样所有卡片的高度和内部对齐方式完全一致不会出现“有的卡片两行、有的卡片三行”的错位感。li classtag-card>// data.js export const TAG_DATA [ { id: fe, title: 前端工程, desc: 构建、打包与性能优化, icon: FE, removable: true }, { id: ui, title: 交互设计, desc: 动效与可用性, icon: UI, removable: true }, { id: data, title: 数据处理, desc: 清洗、聚合与可视化, icon: DA, removable: false }, { id: test, title: 质量保障, desc: 用例设计与自动化, icon: QA, removable: true } ];渲染函数用模板字符串拼 HTML 再一次性赋值innerHTML比循环createElement快得多代码也更短。这里唯一要小心的是 XSS —— 如果数据来自用户输入就必须转义但赛题里的数据是我们自己写的常量风险可控我会加一个极简的转义函数作为保险顺便向评委展示安全意识。// app.js const escapeHTML (str) String(str).replace(/[]/g, (ch) ({ : amp;, : lt;, : gt;, : quot;, : #39; }[ch])); function renderCards(list) { const listEl document.getElementById(tagList); listEl.innerHTML list.map((item) li classtag-card>listEl.addEventListener(keydown, (e) { const card e.target.closest(.tag-card); if (!card) return; if (e.key Enter || e.key ) { e.preventDefault(); toggleCard(card); } });这里preventDefault()不能省否则空格会滚动页面体验很差。另外建议给可聚焦元素加上:focus-visible样式用一圈清晰的外描边告诉用户“现在焦点在这张卡片上”不要直接outline: none了事。注意如果卡片内部有删除按钮键盘用户在卡片上按 Tab 会先进入卡片、再进入删除按钮这是正确顺序。不要给卡片设置tabindex-1去“简化”焦点流那会让键盘用户彻底无法选中卡片。3. 样式实现布局、卡片、状态三件套样式这块的评分权重很高因为它是最直观的部分。但我不建议一上来就调颜色先把骨架布局跑通再上视觉最后补状态。这个顺序能避免“样式一改布局全塌”的返工。3.1 Flex 与 Grid 的取舍我为什么选 Grid卡片化标签的排列需求是“自动换行 每个卡片宽度接近 末行左对齐”。Flex 和 Grid 都能做但表现不一样。Flex 的flex-wrap: wrap加固定宽度会让末行左对齐很自然但卡片宽度是被内容撑开的容易参差Grid 用repeat(auto-fill, minmax(200px, 1fr))能保证所有卡片等宽末行不满时也会左对齐整体性更好。我实测下来更推荐 Grid写法如下.tag-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; list-style: none; margin: 0; padding: 0; }minmax(200px, 1fr)的意思是“最小 200px有空间就平分”。这样宽屏下一行放四五个窄屏自动降到两个、一个中间不需要任何媒体查询这就是响应式的第一层保险。为什么定 200px因为卡片里有一行标题加一行描述中文 12 个字左右200px 减去左右 padding 刚好放得下再窄描述就要折行了。如果你必须用 Flex那就给卡片设flex: 1 1 200px配合max-width但末行卡片会被拉宽视觉上不整齐我不太推荐。3.2 卡片视觉的统一间距、圆角、阴影的“一套数”视觉不统一是失分重灾区。我见过一个页面里同时出现 6px、8px、10px 三种圆角三种不同深浅的灰色边框一眼就是“东拼西凑”的感觉。解决办法很简单开头就定一套数值全篇只用这套数。属性取值理由圆角12px足够柔和又不至于像气泡内边距14px 16px上下略小于左右视觉更稳卡片间距12px与内边距接近整体不散边框1px solid #e3e6ea浅灰打底不抢内容默认阴影0 1px 2px rgba(0,0,0,.04)极轻用来托起卡片选中阴影0 4px 12px rgba(47,108,246,.18)明显但不刺眼卡片内部用 Flex 竖向排列重点是把高度撑齐.tag-card { display: flex; align-items: flex-start; gap: 10px; min-height: 76px; padding: 14px 16px; border: 1px solid #e3e6ea; border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .04); cursor: pointer; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; } .tag-card__body { display: flex; flex-direction: column; gap: 4px; min-width: 0; /* 关键允许文本区收缩避免撑破卡片 */ } .tag-card__title { font-size: 14px; font-weight: 600; color: #1f2329; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tag-card__desc { font-size: 12px; color: #8a9099; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }min-width: 0这行是血泪教训。Flex 子项默认min-width: auto当标题特别长时它宁可撑破容器也不收缩结果卡片溢出一大片。加上这行之后配合text-overflow: ellipsis长标题就乖乖变成省略号了。描述行用-webkit-line-clamp限两行保证所有卡片高度一致。3.3 三种状态的表达默认、悬停、选中状态区分不需要花哨三档就够默认是白底浅边框悬停时边框加深、微微上浮选中时边框换成主色、加浅色底、右上角出现勾选角标。关键在于用同一套视觉语言不要悬停用阴影、选中用背景色用户会以为这是两种不同的东西。.tag-card:hover { border-color: #c7ccd4; box-shadow: 0 4px 10px rgba(0, 0, 0, .06); transform: translateY(-1px); } .tag-card:focus-visible { outline: 2px solid #2f6cf6; outline-offset: 2px; } .tag-card[data-stateselected] { border-color: #2f6cf6; background: #f2f6ff; box-shadow: 0 4px 12px rgba(47, 108, 246, .18); } .tag-card[data-stateselected] .tag-card__title { color: #2f6cf6; } .tag-card[data-stateselected]::after { content: ✓; position: absolute; top: 8px; right: 10px; font-size: 12px; color: #2f6cf6; }这里用>.tag-panel { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; } media (max-width: 480px) { .tag-list { grid-template-columns: 1fr; gap: 10px; } .tag-card { padding: 12px; min-height: 68px; } .tag-card__desc { -webkit-line-clamp: 1; } }窄屏下直接切成单列是我在小屏设备上反复验证过最稳的方案。同时把描述限制成一行避免卡片被撑得太高、一屏只能看到两张。如果条件允许用clamp()控制字号和间距会更省事比如padding: clamp(12px, 2vw, 16px)一行搞定三档尺寸不用写一堆媒体查询。这也是能体现“你懂现代 CSS”的小细节。3.5 动画的克制用法过渡动画只写需要的属性不要图省事写transition: all .2s。all会把布局属性也纳入过渡一旦某个元素宽度变化你就会看到莫名其妙的抖动。我一般只写三个border-color、box-shadow、transform这三个都是合成层或绘制层属性性能好。删除卡片时的消失在动画也可以做但必须用animationend监听来做兜底不能只靠setTimeout否则用户切到后台标签页时定时器被节流卡片会卡在那里不动。这一点在赛场上不一定有人测但评委如果真的切窗口试一下你能稳住就是加分。4. 交互逻辑状态管理才是真正的得分点布局和样式都是“看得到”的东西而交互逻辑是“点得到”的东西。这一块最容易写成意大利面条点一下改 DOM再点一下改回来限选判断写三遍删除逻辑复制粘贴。要避免这种局面核心思路是把状态的唯一来源放在 JS 数据里DOM 只做展示。4.1 状态模型一个 Set 加两个阈值就够了我的状态对象通常只有三个字段当前选中项集合、最大可选数量、是否允许多选。const state { selected: new Set(), // 存 id maxSelect: 3, multiple: true };为什么用Set而不是数组因为选中判断是高频操作每次点击都要查一下“这张卡片选没选”Set.has()是 O(1)数组includes()是 O(n)。卡片数量不多的时候差别可以忽略但用Set还自带去重省掉一个手动去重的分支。另外Set的增删都很直白add、delete、has、clear代码读起来就像在描述业务。切换逻辑的完整写法function toggleCard(card) { const id card.dataset.id; const isSelected state.selected.has(id); if (!state.multiple) { state.selected.clear(); state.selected.add(id); syncAllCards(); return; } if (isSelected) { state.selected.delete(id); card.dataset.state default; card.setAttribute(aria-checked, false); return; } if (state.selected.size state.maxSelect) { showToast(最多只能选择 ${state.maxSelect} 项); return; } state.selected.add(id); card.dataset.state selected; card.setAttribute(aria-checked, true); }单选分支里我用了“先清空再加”的写法然后调syncAllCards()全量同步。看起来有点浪费但单选场景下卡片数量通常不多全量同步比逐个判断谁该取消选中要安全得多不会出现“点了 BA 还亮着”的经典 bug。showToast是个非常轻的提示函数用固定定位的浮层显示两秒就消失let toastTimer null; function showToast(msg) { let el document.getElementById(toast); if (!el) { el document.createElement(div); el.id toast; el.className toast; document.body.appendChild(el); } el.textContent msg; el.classList.add(is-show); clearTimeout(toastTimer); toastTimer setTimeout(() el.classList.remove(is-show), 1800); }这里用clearTimeout重置计时器是为了处理“用户连点两次”的情况。不重置的话第二次提示会提前消失用户还没看清就没了。4.2 事件委托一个监听器管住所有卡片卡片是动态渲染的如果你在渲染函数里给每张卡绑定点击事件那么每次重新渲染都要重新绑删除卡片后还要解绑非常容易漏。正确做法是在列表容器上挂一个监听器靠事件冒泡统一处理这就是事件委托。const listEl document.getElementById(tagList); listEl.addEventListener(click, (e) { const card e.target.closest(.tag-card); if (!card) return; // 删除按钮优先级高于卡片切换 const removeBtn e.target.closest(.tag-card__remove); if (removeBtn) { e.stopPropagation(); removeCard(card); return; } toggleCard(card); });closest()是关键它从点击目标往上找最近的匹配祖先这样用户点在标题、描述、图标上都能正确识别到卡片。删除按钮的判断必须放在前面因为删除按钮在卡片内部如果先执行toggleCard用户点删除会先把卡片选中体验就很奇怪。删除逻辑我一般做成“先从数据里移除再整体重渲染”而不是直接移除 DOM 节点function removeCard(card) { const id card.dataset.id; state.selected.delete(id); const idx TAG_DATA.findIndex((t) t.id id); if (idx -1) TAG_DATA.splice(idx, 1); renderCards(TAG_DATA); }重渲染的好处是数据永远和视图一致坏处是丢失了动画和焦点。如果赛题对动画有要求可以改成只移除当前节点再补一个退场动画但一定要同步更新TAG_DATA否则下次搜索过滤的时候被删掉的卡片又回来了——这个 bug 我踩过排查了快二十分钟。注意TAG_DATA如果是import进来的常量直接splice会有模块副作用问题。本地调试时建议先structuredClone一份再用const data structuredClone(TAG_DATA)干干净净。4.3 限选提示的体验细节限选这件事功能上“超过就 return”是对的但体验上不能毫无反馈。用户点第四张卡片没反应会以为页面卡了。我的做法是三步阻止选中、给被点的那张卡片一个短暂的抖动、弹出提示文字。keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } } .tag-card.is-shake { animation: shake .3s ease; border-color: #f2544b; }function shakeCard(card) { card.classList.add(is-shake); card.addEventListener(animationend, () { card.classList.remove(is-shake); }, { once: true }); }{ once: true }保证监听器只执行一次自动移除不会越积越多。另外还有一个常被忽略的点限选数量最好显示在面板标题旁边比如“最多可选 3 项已选 2”让用户随时知道剩余额度而不是等到被拦截才反应过来。4.4 搜索过滤与空状态搜索过滤是常见附加要求实现要点是“过滤只影响渲染不影响选中状态”。也就是说用户搜“前端”时只显示匹配的卡片但之前选中的“交互设计”仍然是选中状态搜索框清空后它应该还是亮的。function filterCards(keyword) { const kw keyword.trim().toLowerCase(); const filtered TAG_DATA.filter((item) item.title.toLowerCase().includes(kw) || item.desc.toLowerCase().includes(kw) ); renderCards(filtered); syncAllCards(); // 关键重新渲染后要把选中态补回去 document.getElementById(emptyTip).hidden filtered.length 0; }syncAllCards()的作用是遍历当前所有卡片按state.selected重新设置>function syncAllCards() { document.querySelectorAll(.tag-card).forEach((card) { const on state.selected.has(card.dataset.id); card.dataset.state on ? selected : default; card.setAttribute(aria-checked, String(on)); }); }空状态那行hidden的切换也别漏搜索结果为空时页面一片空白用户会以为出错了。给一句“没有匹配的标签”加个浅灰底色观感立刻不一样。5. 完整实操过程从零到能提交的四版迭代前面讲的是拆解这一节按我实际的做题节奏走一遍。我的习惯是分四版推进每版都能跑都能截图这样即使时间不够被叫停手里也有一个完整的东西而不是半成品。5.1 第一版静态骨架先跑通布局第一版不做任何交互纯粹把 HTML 结构和 CSS 布局搭起来手动写四到六个卡片硬编码在 HTML 里。这一版的目标只有一个确认宽屏、中等屏、窄屏三种情况下卡片排列是否自然末行是否左对齐卡片高度是否一致。ul classtag-list li classtag-card>.tag-card__remove { margin-left: auto; flex-shrink: 0; width: 20px; height: 20px; border: none; border-radius: 50%; background: transparent; color: #b0b6bf; font-size: 14px; line-height: 1; cursor: pointer; transition: background-color .15s ease, color .15s ease; } .tag-card__remove:hover { background: #f2f3f5; color: #f2544b; }margin-left: auto把删除按钮推到最右边flex-shrink: 0防止它被压缩。这两个属性配合无论内容多长按钮位置都稳。5.3 第三版接上状态与交互第三版开始写state、toggleCard、syncAllCards、事件委托。这一版做完点击、取消、限选、删除、键盘都应该是通的。我会在这一版结束时跑一遍手测清单测试动作预期结果对应代码位置点击未选中卡片变选中边框变蓝toggleCard再点一次恢复默认态toggleCard选满 3 项后点第 4 项抖动 提示不选中showToastshakeCard点删除按钮卡片消失不触发选中事件委托中stopPropagation键盘 Tab 聚焦后按空格等同点击keydown监听搜索后清空之前的选中态还在syncAllCards这张表我建议你抄下来做完一版对着划。尤其是“搜索后清空选中态还在”这一条十个人里至少有六个第一次会漏。5.4 第四版细节打磨与提交前自检最后一版只做打磨不加新功能。打磨清单我列了六条把所有颜色统一成四个色值主色、正文、次要文字、边框把间距统一成 4 的倍数确认所有button都有typebutton防止在form里误触提交确认所有纯装饰性元素带aria-hiddentrue确认页面在 320px 宽度下不出现横向滚动条确认控制台没有任何报错和警告。横向滚动条这一条特别值得查。最常见的成因是某个元素设置了固定宽度超过视口或者padding加在width: 100%的元素上导致总宽超限。快速定位的办法是在控制台执行一段脚本把超宽的元素揪出来[...document.querySelectorAll(*)] .filter((el) el.getBoundingClientRect().right document.documentElement.clientWidth) .forEach((el) console.log(el, el.getBoundingClientRect().right));这行代码能直接告诉你哪个元素越界了比挨个改 CSS 猜要快得多。6. 常见问题与排查技巧实录这一节是我在做这类题和帮别人看代码时积累的问题库基本覆盖了 90% 的翻车场景。按“现象—原因—解法”整理成表方便对照。6.1 布局类问题速查表现象大概率原因解法卡片高度参差不齐描述行数不同、图标尺寸不固定描述用-webkit-line-clamp限行图标定宽定高长标题撑破卡片Flex 子项min-width默认 auto给.tag-card__body加min-width: 0卡片挤成两行文字栅格最小宽度设得太小minmax调到 200px 以上末行卡片被拉宽用了 Flex 且没限制max-width改用 Grid 的auto-fill页面出现横向滚动条某元素固定宽度超出视口用上面的越界检测脚本定位卡片间距忽大忽小同时用了gap和margin二选一统一用gap图标和文字底部不齐图标用了baseline对齐容器改align-items: flex-start或center我自己最常犯的是“gap和margin混用”前几版看着没问题一加新卡片就发现间距不对。后来定了个规矩布局间距一律用gap组件内部间距一律用paddingmargin只用于“单独把某个元素推开”这种少数场景。这条规矩坚持下来间距问题基本绝迹。6.2 交互类问题速查表现象大概率原因解法点删除按钮顺手把卡片选中了事件顺序错未拦截冒泡先判断删除按钮再stopPropagation搜索后选中态丢失重渲染后没同步状态渲染后调syncAllCards()删除后刷新搜索又出现了只删了 DOM 没删数据同步更新数据数组连点两次提示提前消失计时器没重置clearTimeout后再setTimeout空格键导致页面滚动没阻止默认行为e.preventDefault()单选时旧卡片没取消只给新卡片加状态清空集合后全量同步键盘焦点丢失重渲染了整个列表保留节点或渲染后手动聚焦“重渲染丢焦点”这个坑值得多说一句。如果你的列表每次操作都全量innerHTML那么键盘用户每选一次卡片焦点就回到页面顶部体验极差。折中方案是切换选中态不重渲染只改>function refocus(id) { const el document.querySelector(.tag-card[data-id${id}]); if (el) el.focus(); }6.3 提交前的十分钟自检清单最后十分钟别再加功能按这个顺序过一遍打开控制台看有没有报错把浏览器窗口从最宽拖到最窄看卡片排列有没有塌陷用 Tab 键从页面顶部走一遍看焦点顺序是否合理、能不能选中卡片随便删两个卡片再搜索一次看有没有幽灵数据把所有颜色值搜一遍看有没有残留的随手写的色值最后看一眼 HTML 里有没有div套div套div但没有任何语义的层级。我在实际做题时的体会是这类题的分数差距往往不在“能不能做出来”而在“做出来之后经不经得起挑”。同一份需求两个人功能都实现了评委随便点两下一个页面稳如老狗一个页面点快了就闪一下、删完搜索又冒出来差距就出来了。所以我给自己的规矩是功能跑通之后至少留四分之一的模拟时间专门用来“找自己的茬”而且要找的是那种不看代码、只点页面就能发现的问题。这个习惯用到工作里也一样管用——毕竟真实项目里没人会给你一份带评分表的验收清单能自己把清单列出来的那个人往往就是最后收尾最省心的那个。