卡片化标签列表:flex-wrap与grid布局及交互兜底
赛题发下来只有一句话——“实现一个卡片化标签列表”配一张静态效果图剩下全靠自己脑补。每年到这个环节考场里总是一片键盘声很多人十分钟就把 HTML 骨架和 flex 铺完了然后卡在“标签一多就换行错位”“删除之后留个坑”“卡片阴影发灰”这类细节上磨掉一个多小时。这道题看着简单本质上考的不是你会不会写 div而是你能不能在有限时间里把布局稳定性、视觉还原度、交互完整度和自适应兜底这四件事同时兜住。下面这套东西是我把这类组件反复写了很多遍之后沉淀下来的思路从赛题拆解一直讲到提交前逐项自检适合刚接触前端组件开发的读者照着复现也适合已经能写但总在细节上丢分的人对照查漏。1. 先把题面拆开卡片化标签到底在考什么1.1 “卡片”是容器语言“标签”是内容语言很多人一上手就把这两个词混着用结果做出来的东西既不像卡片也不像标签。我的理解是这两个词在视觉上是两套完全不同的语言必须分层处理。卡片的核心特征是“有边界的独立单元”它需要一个明确的轮廓边框或阴影、统一的内边距、统一的圆角以及和邻居之间稳定的间距。卡片回答的问题是“这一块内容从哪到哪”。标签的核心特征是“可枚举的短文本单元”字号小、行高紧、背景色块化、通常带圆角且圆角半径比卡片大因为尺寸小视觉上需要更圆才不显生硬并且往往伴随可选中、可关闭这类状态。把两者组合起来常见的题面有两种形态形态 A外层是若干卡片每张卡片内部横向排列一组标签标签超出换行。形态 B标签本身被“卡片化”即每个标签都是一个带阴影、带内边距的小卡片整体排成一个网格。这两种形态的布局策略、代码结构差异很大。我在读题时会先花两分钟确认属于哪一种因为形态 A 的重点在“卡片内部的行内换行”形态 B 的重点在“外层网格的自适应列宽”。选错了方向后面所有代码基本要推倒重来。提示如果效果图给的是多列排布先量一下同一行里卡片之间的间距和卡片自身的宽度算出大概的列数再去反推是定宽列还是流式列。这一步花两分钟能省掉后面二十分钟的返工。1.2 评分表里通常藏着哪几栏这类组件题的打分很少是“做出来就给分”基本都是分档制。我把历年见过的评分维度归了个类实际考场上大概率会落在下面这张表里评分维度具体观察点容易丢分的地方静态还原间距、圆角、字号、颜色、阴影是否与图一致用眼睛估数值导致间距差 2-4px布局稳定性窗口拉伸、内容变多时是否错位、溢出用固定 px 宽度窄屏直接横向滚动数据渲染是否由数组驱动而非手写死 DOM手写 8 个 div加一条数据就崩交互完整选中态、删除、新增是否闭环删完最后一条没有空状态代码质量命名、复用、是否有冗余嵌套三层 div 干一件事class 名全是 box1、box2自适应断点处理、超长文本处理长单词撑破卡片值得说的是代码质量这一栏。它不占很多分但它是“印象分”的载体——评委看你十行代码就能判断你的水平层次。同一个效果用div classcarddiv classcard-bodydiv classcard-title三层嵌套和用article classcardh3 classcard-title语义化标签观感完全不同。我的习惯是能不用 div 就不用标题用 h3列表用 ul/li可点击的按钮一律用 button。这不只是规范问题用 button 能免费拿到键盘焦点、回车触发、禁用态这些能力等于白捡分。另外提醒一句赛题如果给了数据格式比如一段 JSON一定要用给定的字段名。我见过有人自己造了一套字段本地跑得好好的结果评测脚本按原字段名读直接全空。拿到数据第一件事是打印出来看清结构别急着写渲染。2. 布局选型flex-wrap、grid 到底怎么选2.1 flex-wrap 方案最顺手也最容易塌绝大多数人的第一反应是display: flex; flex-wrap: wrap;。它确实简单一行就搞定了换行。但它的坑集中在一个地方最后一行怎么对齐。如果你写了justify-content: space-between数据刚好排满整行时看着完美可只要最后一行不满最后那个卡片就会被推到最右边中间裂开一道大缝。这是 flex 换行最经典的视觉事故。解决办法有这么几种各有代价改成justify-content: flex-start配合固定的gap最后一行自然左对齐。这是最稳的做法代价是失去了“两端对齐”的视觉张力但对卡片列表来说完全够用。用gapmargin-right的伪元素占位技巧给末尾补几个不可见的占位块。能用但 DOM 会变脏不推荐。用flex: 0 0 calc((100% - 2 * gap) / 3)这种定比例宽度保证每行严格三列。缺点是窄屏下卡片会被压得很扁。我自己的默认选择是flex-wrap: wrapgap 固定基准宽度的一套组合代码大概长这样.tag-list { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; } .tag-list li { flex: 0 1 auto; min-width: 0; /* 关键防止长文本把 flex 项撑破 */ }min-width: 0这一行很多人不写它是 flex 子项的一个隐藏陷阱。flex 项的默认min-width是auto意味着它不会小于内容的固有宽度于是超长单词或者white-space: nowrap的文本会把整个容器顶出横向滚动条。加这一行再配合overflow: hidden问题就消了。2.2 grid auto-fill一行代码吃掉自适应如果题面是形态 B标签自身卡片化、整体成网格我更倾向用 grid。核心就一行.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }这行的含义是列宽最小 220px尽量塞满一行剩下的空间平均分给每一列。窗口变宽列数自动增加窗口变窄列数自动减少。整个过程不需要写任何媒体查询。这里有个必须分清的细节auto-fill和auto-fit的区别。当容器只放得下两列但实际只有一条数据时auto-fill会保留空列卡片就占一格宽度auto-fit会把空列折叠掉卡片被拉伸占满整行。做卡片列表一般用auto-fill因为我们要的是“列宽稳定”auto-fit更适合那种“数据少的时候希望放大展示”的场景。选错了数据少的时候卡片会被拉成一条横向的长条非常难看。还有一个常见失误只写了minmax()却忘了给父容器限制宽度。如果.card-grid的父级是width: max-content或者位于一个横向滚动的 flex 里auto-fill会一直算出“够放一列”永远不换行。排查这类问题我第一件事是打开开发者工具看容器的实际计算宽度而不是盯着 grid 属性反复改。2.3 三种方案的取舍对照我把这类组件常见的三种布局方式整理成了一张表实际选型时对照着看一眼就行方案适用形态优势主要风险flex-wrap标签横向流式排列内容驱动标签长短不一也好看最后一行对齐问题、flex 项被撑破grid auto-fill卡片成网格自适应列数零成本auto-fill/auto-fit 混用导致拉伸定宽 媒体查询设计要求严格的多断点每个断点完全可控断点写漏就崩维护成本高我个人在时间有限的赛场上优先用 grid。理由是它的“自适应”是属性自带的不需要我一条条断点去试出错概率低。只有题目明确要求“10 个标签排一行、超出横向滚动”这种特殊交互我才会退回 flex。3. 视觉还原阴影、悬停、截断的手感调校3.1 单层 box-shadow 为什么总是发灰这是我最常见到的“明明参数和图一样但就是不好看”的问题。根源在于大多数人只写一层阴影比如box-shadow: 0 4px 12px rgba(0,0,0,0.1)。这一层相当于既当“贴地的接触阴影”又当“扩散的环境阴影”结果两头不讨好近处不够实远处不够柔整体呈现一种雾蒙蒙的脏灰色。真实的卡片阴影是分层的至少两层.tag-card { --shadow-near: 0 1px 2px rgba(17, 24, 39, 0.06); --shadow-far: 0 10px 24px -8px rgba(17, 24, 39, 0.14); background: #fff; border-radius: 12px; box-shadow: var(--shadow-near), var(--shadow-far); }近层偏移小1-2px、模糊小2-4px、不透明度低0.05-0.08负责勾勒卡片和背景的接触感让卡片“贴”在页面上。远层偏移大8-12px、模糊大20-30px、带负的扩散值、不透明度稍高0.1-0.15负责营造浮起的高度感。颜色上我不建议用纯黑rgba(0,0,0,x)。纯黑阴影叠在白底上就是灰叠在彩色背景上会发脏。换成带一点蓝调的深灰rgba(17, 24, 39, x)这是 Tailwind 的 gray-900 色值阴影会显得更“透”更干净。这是个很小的改动但视觉提升非常明显实测下来评委确实能感觉到差别。3.2 悬停位移改 transform别改 margin悬停时卡片上浮是标配交互但怎么写差别巨大。用margin-top: -4px做上浮的错误在于它会触发整个文档流的重新布局卡片周围的内容会跟着抖一下在同一行有多张卡片时尤其明显。正确做法是只改transform它走的是合成层不参与布局.tag-card { transition: transform 200ms cubic-bezier(0.2, 0.7, 0.3, 1), box-shadow 200ms ease; will-change: transform; } .tag-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-near), 0 16px 32px -10px rgba(17, 24, 39, 0.18); }悬停时同时加深远层阴影偏移和模糊都加大、扩散更负这个“位移 阴影同步变化”的组合才是浮起感的完整表达。只位移不加深阴影卡片看着像被人拎起来又放歪了。关于缓动曲线ease太软ease-in-out开头太慢我一直用cubic-bezier(0.2, 0.7, 0.3, 1)起步快、尾巴缓手感干脆。时长 180-220ms 之间比较合适超过 300ms 会有明显的迟滞感。另外will-change: transform别滥用。它会让浏览器提前把元素提升为独立图层元素一多显存就吃紧手机上容易掉帧。我的做法是只在:hover状态上加或者干脆不加——现代浏览器的 transform 动画本来就已经很快了。3.3 文本截断单行和多行是两套写法标签文本长度不可控截断必须做。单行截断是老生常谈.tag-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }多行截断则需要-webkit-box这套老牌方案.card-desc { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; word-break: break-word; }它虽然是私有前缀属性但各大浏览器的支持已经非常稳定赛场上放心用。真正需要提醒的是word-break: break-word这一行。如果不写遇到一长串没有空格的英文或数字比如一个超长 URL 或者连续数字 ID文本不会换行会直接把卡片撑破甚至把整个 grid 顶出屏幕。这个坑我在第一次做这类题时就踩了效果图里的数据全是短中文测试用例里塞了一个 50 字符的 ID页面直接横向滚动。还有一个更细的点-webkit-line-clamp需要行高配合才能算准高度。如果卡片高度是定死的行高又没设多行文本被裁掉的位置会随机漂移。我的习惯是给文本容器统一设line-height: 1.5同时用min-height: calc(1.5em * 2)兜住两行的高度这样即使只有一行文字卡片高度也不会塌。4. 交互层选中、删除、新增怎么写才不乱4.1 先定数据结构再碰 DOM这一步是分水岭。手写 DOM 的人遇到“删除一条”就开始removeChild遇到“新增一条”就innerHTML 拼字符串写到第三个交互就彻底失控。正确的顺序是先想清楚状态形状再写渲染函数。我在这类组件里通常用一个极简的状态对象const state { tags: [ { id: t-1, label: 前端, selected: false }, { id: t-2, label: 样式, selected: true } ], keyword: };只有两个字段但足以支撑所有交互。id是必须的别用数组下标当 key——删除中间一条之后下标全部后移选中态会错位到别的卡片上这是非常典型的 bug。id用自增或时间戳加随机数都行只要保证唯一。渲染函数写成一个纯函数输入状态、输出 DOMfunction render(listEl, tags) { listEl.innerHTML ; const frag document.createDocumentFragment(); tags.forEach(tag { const li document.createElement(li); li.className tag-card (tag.selected ? is-selected : ); li.dataset.id tag.id; li.innerHTML span classtag-card__label${escapeHtml(tag.label)}/span button classtag-card__close typebutton >const escapeHtml (s) String(s).replace(/[]/g, (c) ({ : amp;, : lt;, : gt;, : quot;, : #39; }[c]));4.2 事件委托动态节点不要逐个绑卡片是动态渲染的如果每次渲染后都querySelectorAll(.close).forEach(btn btn.addEventListener(...))会有两个问题一是每次重渲染都要重新绑一遍容易漏二是旧节点被清空时监听器虽然会被回收但代码里到处是绑定语句读起来很乱。统一挂到容器上用事件委托一份监听解决所有listEl.addEventListener(click, (e) { const card e.target.closest(.tag-card); if (!card) return; const id card.dataset.id; if (e.target.closest([data-actionremove])) { removeTag(id); return; } toggleSelect(id); });closest()是关键它从点击位置向上找最近的匹配祖先这样即使标签文字外面还包了一层 span点击也能正确命中。判断顺序也要注意先判删除再判选中因为删除按钮是卡片内部的一个子元素点击它会同时命中卡片如果先判选中就会出现“点 × 顺手把卡片选中了”的联动 bug。选中态的切换还要注意别直接改 class 字符串而是改状态再重渲染function toggleSelect(id) { const tag state.tags.find(t t.id id); if (!tag) return; tag.selected !tag.selected; render(listEl, state.tags); }状态和视图严格单向流动这样无论后面加多少交互逻辑都不会乱。4.3 删除动效先播完动画再删数据直接removeTag然后重渲染卡片是“啪”一下消失的观感很硬。加个离场动画不难但顺序千万不能错——很多人的写法是先改数据再重渲染然后就找不到要动画的那个节点了。正确的顺序是找到节点 → 加离场 class → 等transitionend→ 再改数据重渲染。function removeTag(id) { const el listEl.querySelector([data-id${id}]); if (!el) return; el.classList.add(is-leaving); el.addEventListener(transitionend, () { state.tags state.tags.filter(t t.id ! id); render(listEl, state.tags); }, { once: true }); }对应的 CSS.tag-card { transition: transform 180ms ease, opacity 180ms ease; } .tag-card.is-leaving { transform: scale(0.92); opacity: 0; pointer-events: none; }这里有两个细节值得说。第一{ once: true }必须加否则transitionend会因为卡片上其他过渡属性比如悬停的 transform触发多次导致重复删除。第二pointer-events: none也别省离场动画期间用户手快再点一次会触发两次删除第二次找不到节点控制台一片红。如果列表是网格布局删除之后其他卡片会瞬移重排视觉上很跳。要做得更细一点可以在删除时记录被删卡片的位置用 FLIP 思路做位移动画。但说实话赛场上时间紧这个优化收益有限我一般只保证离场动画重排就让它瞬间完成评委通常不会在这一层扣分。5. 边界兜底小屏、超长内容、空列表和键盘5.1 断点不必多两三个够用这类组件的响应式其实很简单我通常只处理三档/* 默认桌面grid auto-fill列宽 220px 起 */ .card-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } /* 平板列宽收窄 */ media (max-width: 768px) { .card-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; } } /* 手机单列 */ media (max-width: 480px) { .card-grid { grid-template-columns: 1fr; gap: 10px; } .tag-card { padding: 12px; } }三档就够别写七八个断点。断点越多漏改的概率越高而且 768 和 750 这种相邻断点在里面混着写自己都记不清哪个生效。如果你的目标浏览器支持container用容器查询会更精准组件放进侧栏也能自适应但赛场上不必冒险媒体查询的兼容性没有悬念。这里有个经验先保证 375px 宽度下不出横向滚动条。把浏览器拖到手机宽度截个图这一条几乎每场都会检查。5.2 空状态是白送的分但很多人不做删除到只剩零条如果什么都不显示页面就是一片空白外加一个看不见边框的容器观感极差。空状态实现成本极低if (state.tags.length 0) { listEl.innerHTML li classtag-empty暂无标签点击上方按钮添加/li; return; }配一段 CSS 给它虚线边框和灰字就够了。就这么十几行能实打实拿到“交互完整”的分。同样容易被忽略的还有超长标签的兜底单个标签超过 12 个字符时要么截断要么限制卡片最大宽度max-width: 100%加overflow-wrap: anywhere。我见过最惨的情况是一条 200 字符的数据直接把整个页面撑到 4000px 宽横滚条拉都拉不完。5.3 键盘可达性隐形但值钱的加分项这条技巧知道的人不多但确实有效。卡片要可点击就别在div上绑 click改用button或者在 div 上补tabindex0和rolebutton再监听 Enter/Spacecard.addEventListener(keydown, (e) { if (e.key Enter || e.key ) { e.preventDefault(); toggleSelect(card.dataset.id); } });再加一条焦点样式别用outline: none把默认焦点环干掉之后什么都不留——这是典型的可访问性事故.tag-card:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }用:focus-visible而不是:focus鼠标点击时不会出现难看的焦点框键盘 Tab 时又能清楚看到位置。这一条写上去代码的专业含量立刻不一样。另外补一个容易被忽略的规范如果组件里有动画加一段media (prefers-reduced-motion: reduce)把过渡时长降到 0.01ms。三行代码表达的是“我考虑过不同用户的使用习惯”这种细节在评分表里往往对应“代码规范”那一栏。6. 现场时间分配与提交前自检6.1 时间怎么切比你会多少更重要这类组件的赛场时间通常在两到三小时我给自己定的切法是阶段时长目标读题 量图10 min确认形态、提取间距/圆角/字号静态骨架 视觉还原40 min硬编码几条数据先把画面做对数据驱动改造25 min数组渲染、事件委托跑通交互闭环30 min选中、删除、新增、空状态自适应 兜底25 min三档断点、长文本、键盘自检与修复20 min按清单逐项过关键在于先把视觉做对再做数据。反过来做的人往往在数据和样式纠缠的时候反复返工。硬编码几条 mock 数据把画面调准这个阶段产出的 CSS 是后面所有工作的地基。还有一点不要在单个细节上死磕。阴影调了十分钟还不满意就先标记一下往下走最后自检阶段再回来。赛场上最贵的成本是时间不是完美。6.2 提交前逐项过一遍的清单我在最后二十分钟一定会按这张表走一遍每项都实际操作不靠回忆把浏览器宽度从最大拖到 375px全程无横向滚动条卡片不重叠、不溢出。删除到 0 条确认空状态出现再从空状态新增一条确认能正常渲染。连续快速点击删除按钮三次控制台无报错验证{ once: true }和重复删除问题。塞一条 50 字符的长文本确认截断生效且不撑破卡片。只用 Tab 键走完全部交互确认焦点可见、回车能触发。打开开发者工具的 Console确认没有任何报错和警告。检查所有 class 命名是否成体系有没有box1div2这类临时名。我个人的体会是最后二十分钟的这轮自检平均能捞回 10 分左右。丢分的地方几乎从来不是“不会写”而是“写了但没验证”。曾经有一次我删到空列表后新增因为重渲染时判断空状态的逻辑写在了return之前新增之后列表一直显示“暂无标签”肉眼盯着代码看了半天没看出来最后靠手动点一遍才发现。这种 bug 只能靠操作暴露看代码是看不出来的。最后分享一个小技巧把状态对象在控制台里挂到window上比如window.__state state。赛场上调试数据渲染时随时敲一行就能看到当前数据的真实形状比在代码里到处console.log高效得多。提交前记得删掉虽然不影响功能但留在代码里会显得不够干净。