
字节笔记本
2026年10月7日 · 约 8 分钟读完
手写 CSS 与 Tailwind:把一张答题卡片写两遍
「该不该用 Tailwind」大概是前端圈最经久不衰的口水战之一:一方说工具类让样式一目了然,再也不用为类名发愁;另一方说 HTML 被 class 撑爆,可读性倒退十年。与其停留在立场之争,不如把同一个组件用两种方式各写一遍。正好有一个趁手的练手对象——语言学习应用里最常见的那张答题卡片:顶部一条进度条,中间一枚橙色圆形发音按钮,下面 2×2 的选项格子,底部一枚通栏的「下一题」。整个组件零依赖,一个 HTML 文件就能跑;先用手写 CSS 把它从静态做到可交互,再用 Tailwind 重写同一张卡片。写完两遍,差异和坑都比想象中具体。
先把卡片立起来
第一版不碰任何交互,只解决「长什么样」。卡片拆开其实只有五块:灰底圆角的进度条槽、蓝色的填充条、圆形发音按钮、grid 两列的选项区、通栏主按钮。布局用 flex 垂直居中,卡片固定 300px 宽,配 20px 圆角加一层浅阴影,页面铺淡蓝底色。
值得单独一说的是那枚白色播放三角形。它既没用图标库也没用 SVG,而是纯 CSS 的 border 技巧:元素宽高设为零,只给左边框上色,上下边框透明(border-width: 10px 0 10px 20px),两条透明斜边自然切出一个三角。这是 CSS 时代的经典手艺,如今的项目多半会直接内联一个 SVG 图标,但看懂这个写法,排查老代码时依然受用。
这一步的原则只有一句:交互还没影的时候,先把 DOM 结构和视觉层级定死,后面加状态才有地方挂。
三个变量的最小状态机
交互版的核心是一个极小的状态机,几个变量就撑起整个答题循环:
const questions = [
{ options: ['er', 'd', 'ei', 'iu'], correct: 'er' },
{ options: ['an', 'en', 'in', 'un'], correct: 'an' },
];
let currentQuestion = 0;
let selectedOption = null;
再配三个函数,行为就完整了:loadQuestion(index) 负责清空选项容器、按题库动态创建按钮并绑定事件,同时把进度条宽度设为 (index + 1) / questions.length * 100%;selectOption 负责先摘掉所有选项的选中类、再挂给当前项,并解除「下一题」的禁用;「下一题」的点击处理负责判分、切题,到末尾就提示完成并把索引归零。
两个容易被忽略的体验细节藏在这里。其一,「下一题」默认禁用,选中选项后才点亮,相当于逼用户先做选择,杜绝空答案刷进度;其二,进度条加了 0.5 秒的缓动过渡,切题时有平滑的推进感。答题类界面靠的正是这种细碎的即时反馈撑住「再做一题」的动机,多邻国把这一点打磨成了护城河,原理并无秘密可言。
手写 CSS 在这一步还显出一个朴素的好处:类名是语义的。.option.selected 这样的选择器自带文档价值,三个月后回看代码,不运行也能猜出这行样式管什么。
换一套 class,DOM 一字不动
Tailwind 重写时 DOM 结构原样保留,改的只有 class。对比一目了然:原生版一个选项按钮的样式全部住在 <style> 块里;Tailwind 版直接把 bg-blue-50 py-4 px-6 rounded-xl hover:bg-blue-100 transition-colors 写在标签上。选中态的切换也从「加减一个 selected 类」变成「加减一组工具类」——移除 bg-blue-50 和 text-gray-700,换上 bg-blue-500 和 text-white。

工具类还有一处值得注意的表达:状态前缀。手写版里 .option:hover、.next-button:hover 各占一条规则;Tailwind 把状态编进类名,hover:bg-blue-600、disabled:opacity-50 这样的变体直接跟元素走,常态、悬停态、禁用态收在同一段 class 里。可读性确实好,但一串 class 只会越长越长,这也是社区普遍把它封进组件模板的原因之一。
两种写法并排放着,取舍立刻具体起来。Tailwind 的好处:不离开 HTML 就能读出按钮的完整长相,不用发明类名,调间距是改一个数字而不是追进样式表,原型阶段试错飞快。手写 CSS 的好处:HTML 干净,同类样式集中一处可整批调整,选择器天然可复用;一旦配合 CSS 变量做设计令牌,比如把主题蓝定义成 --primary,换主题只动几行。
真正的坑:JS 里拼出来的类名,Tailwind 不认
双写过程中暴露出的最有价值的一个坑,值得单独拎出来。原生版切换选中态只要 classList.add('selected');Tailwind 版却必须把完整的类名字符串逐字写进 JS 去加减。
原因在 Tailwind 的工作方式:构建时扫描源码,只为它「看见过」的类名生成 CSS。如果写成 btn.className = 'bg-' + color + '-500',扫描器拼不出 bg-blue-500 这个完整字面量,生产构建里就不会有这条规则,上线后按钮会莫名其妙没了背景。更迷惑的是开发环境往往一切正常,dev 模式按需即时生成,一到 build 就翻车,典型的「本地好的、上线坏了」。规避办法只有把类名写全:要么在代码里逐字出现,要么列进 safelist 配置兜底。
示例里用的 CDN 引入是同一类问题:那是浏览器内即时编译的开发版脚本,官方明确不建议用于生产。原型阶段图快无妨,正式项目得走构建管线。
还有一处更隐蔽的耦合:为了让两版共用一份交互逻辑,示例脚本用 .next-button, [class*="bg-blue-500"] 这种属性选择器去抓 Tailwind 版的按钮。按样式类名反查 DOM,意味着改个配色就可能把交互改断。交互钩子应该挂在 id 或 data-* 属性这类语义锚点上,样式类名只管长相。这个道理两种方案下都成立,只是 Tailwind「类名即样式」,更容易诱导人犯这个错。
怎么选
顺带一提,「同题双写」本身也是团队里性价比很高的决策方法:与其在会议上各执一词,不如让两派各花一小时实现同一个小部件,并排放进评审。讨论对象从观点变成代码,分歧会从「我喜欢哪个」收敛到「我们在乎什么」,是上手速度、可维护性,还是重构成本,一个下午就能有结论。
落到决策,结论并不复杂。一次性 demo、单文件原型、发出去给人快速预览,Tailwind 明显更快,连样式表都不用维护;会长期演进的项目,两条路都通:要么走语义类名加设计令牌的老派做法,要么 Tailwind 配组件抽象,把成串工具类封进组件内部,别让 class 属性占据模板的主要篇幅。两边共同的雷区,是让工具类散落在几十个模板文件里,改一次主色得全局搜正则。
至于那张答题卡片本身,补上语音合成的发音、把弹窗提示换成行内对错反馈、用 localStorage 记住进度,就是一个能天天用的拼音练习页了。题库也只是一个 JSON 数组,换成生词卡、单词拼写或乐理音程,同一副壳子照用不误,那是另一篇文章的事。这次真正值得带走的只有一句:同一个组件写两遍,比读十篇《X 与 Y 怎么选》都长认知。



