用 TailwindCSS 重构待办事项项目:从手写 CSS 到原子类

一篇写给自己的学习笔记:把课程里的待办事项项目从头到尾用 Tailwind 重写了一遍,记下改了什么、踩了什么坑、以及我为什么最后觉得它值得。
一、起因
待办事项项目功能很简单:输入、添加、勾选、删除、过滤、统计。但停下来看 style.css 的时候,我发现一件很尴尬的事——功能越加越多,CSS 越来越不敢动。
大致是这么几个症状:
- 类名越起越敷衍。
.item、.item2、.item-active、.item-active-new……同一个列表项,四个类名,谁在用什么全靠记忆。 - 想改一个"删除按钮只在 hover 时出现"的效果,得在三处加样式:
.item:hover .del、.del、.del:hover,还要考虑触屏设备没有 hover。 - 响应式是一堆重复。同一个卡片,桌面一套、手机一套,改个圆角要改两个地方。
- 最要命的是:我打开
style.css找样式的时间,比写业务逻辑还长。
于是我决定用 Tailwind 重写一遍。不是为了追新,是想验证一个说法:样式应该待在它该待的地方,也就是模板里。
二、重构前长什么样
旧版本的结构大概是这样的:
点击查看代码
<div class="todo-box">
<div class="todo-input-wrap">
<input class="todo-input" />
<button class="todo-btn">添加</button>
</div>
<ul class="todo-list">
<li class="todo-item">
<input type="checkbox" class="todo-check" />
<span class="todo-text">买牛奶</span>
<button class="todo-del">删除</button>
</li>
</ul>
</div>
配套的 CSS 里,.todo-item 有 11 条声明,.todo-del 有 7 条,加上 hover、focus、响应式,一个列表项占了 40 多行。
三、Tailwind 到底解决了什么
用了两周,我把它解决的问题归纳成三句人话:
- 不用起名了。 以前最痛苦的是"这个 div 该叫什么"。现在它叫什么不重要,长什么样写在 class 里。
- 改了就走,不用全局搜。 改一个按钮的圆角,只改用到它的那一行模板,不会牵连别处。
- 删代码是安全的。 删掉一个组件,它的样式跟着消失,不会在 CSS 里留下"不知道还有没有人用"的死规则。
代价也很明显:类名会很长,模板会变"吵"。这一点后面讲我怎么处理的。
四、把环境搭起来(Tailwind v4)
这一步比 v3 时代简单很多,因为 v4 不再需要 tailwind.config.js 和 postcss.config.js。
点击查看代码
npm install tailwindcss @tailwindcss/vite
点击查看代码
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
})
点击查看代码
/* src/style.css —— 就这一行 */
@import "tailwindcss";
三处改动就能跑。这是我第一次觉得"配置比想象中少"的前端工具。
顺带记一个新东西:v4 的主题变量直接写在 CSS 里,不用开 JS 配置文件。
点击查看代码
@import "tailwindcss";
@theme {
--color-brand: #4a90e2;
--radius-card: 12px;
}
之后就能用 bg-brand、rounded-card 这样的类名,跟内置工具类混着写,没有区别。
五、开始重构,一个组件一个组件改
5.1 外壳:从 .todo-box 到一行工具类
点击查看代码
<main class="mx-auto flex min-h-screen w-full max-w-xl flex-col gap-4 bg-slate-50 px-4 py-10">
原来这段对应 6 条 CSS(宽度、最小高度、flex 方向、间距、背景、内边距),现在一行读完就知道它长什么样——这就是原子类最大的好处:代码即效果图。
5.2 输入区
点击查看代码
<form class="flex gap-2" @submit.prevent="add">
<input
v-model="text"
placeholder="今天要做什么?"
class="flex-1 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-slate-700
outline-none transition placeholder:text-slate-400
focus:border-blue-400 focus:ring-4 focus:ring-blue-100" />
<button
class="rounded-xl bg-blue-500 px-4 py-2.5 font-medium text-white
transition hover:bg-blue-600 active:scale-95">
添加
</button>
</form>
focus:ring-4 这种写法替代了我以前手写的 box-shadow: 0 0 0 4px rgba(...),而且焦点态和边框色是同时声明的,不会像以前那样漏掉一个。
5.3 列表项:一个 group 解决 hover 联动
这是重构里我最喜欢的部分。以前"鼠标移到整行,删除按钮才出现",要靠后代选择器:
点击查看代码
.todo-item .todo-del { opacity: 0; transition: opacity .2s; }
.todo-item:hover .todo-del { opacity: 1; }
Tailwind 里给父级加 group,子元素用 group-hover:
点击查看代码
<li class="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white
px-4 py-3 transition hover:border-slate-300 hover:shadow-sm">
<input type="checkbox" class="h-4 w-4 accent-blue-500" v-model="todo.done" />
<span
class="flex-1 text-slate-700 transition"
:class="todo.done ? 'line-through text-slate-400' : ''">
{{ todo.text }}
</span>
<button
class="rounded-lg px-2 py-1 text-sm text-slate-400 opacity-0 transition
group-hover:opacity-100 hover:bg-red-50 hover:text-red-500">
删除
</button>
</li>
group-hover 的语义非常明确:"我所在的组被 hover 时",不用再关心 DOM 层级,也就不会出现"多加一层 div 样式就失效"的问题。
这里还顺手用 accent-blue-500 改了原生复选框的颜色——以前这是要写 appearance: none 再自己画一个的。
5.4 空状态与底部统计
点击查看代码
<!-- 空状态 -->
<div class="flex flex-col items-center gap-2 rounded-xl border border-dashed
border-slate-200 py-12 text-slate-400">
<span class="text-3xl">🗒️</span>
<p class="text-sm">还没有待办,先加一条吧</p>
</div>
<!-- 底部 -->
<footer class="flex items-center justify-between text-sm text-slate-500">
<span>共 {{ total }} 条,已完成 {{ doneCount }}</span>
<button class="hover:text-blue-500 hover:underline underline-offset-4">
清除已完成
</button>
</footer>
六、响应式:一个前缀 vs 一段媒体查询
旧代码:
点击查看代码
.todo-box { max-width: 640px; padding: 40px 16px; }
@media (max-width: 480px) {
.todo-box { padding: 24px 12px; }
.todo-title { font-size: 20px; }
.todo-btn { padding: 8px 12px; }
}
新代码,直接写在原类名旁边:
点击查看代码
<main class="px-4 py-10 sm:px-6">
<h1 class="text-xl font-semibold sm:text-2xl">待办事项</h1>
</main>
sm: 是"≥640px 生效",默认样式就是移动端。这个方向我觉得比 max-width 更省脑子——先写手机,再往上加。
七、踩过的坑
这部分是我真正花时间的地方,记下来备查。
7.1 动态拼接的类名不生效
点击查看代码
// ❌ 这样写,样式不会生成
:class="`bg-${color}-500`"
Tailwind 是静态扫描源码文本的,它看到的是 bg-${color}-500 这个字符串,不是 bg-blue-500。所以必须写完整类名:
点击查看代码
// ✅ 用映射表,每个值都是完整类名
const colorMap = {
blue: 'bg-blue-500',
red: 'bg-red-500',
green: 'bg-green-500',
}
7.2 v4 里没有 tailwind.config.js
我照着 v3 的教程找了一晚上配置文件。v4 的做法是 @theme 写变量,@source 指定扫描范围(默认自动扫)。找不到那个文件不是装错了。
7.3 @apply 一用就回到老路
点击查看代码
/* 看着很爽,但用多了等于换了个地方写 CSS */
.todo-btn { @apply rounded-xl bg-blue-500 px-4 py-2.5 text-white; }
我的规则是:只有"确实在三个以上组件里重复出现、且语义固定"的东西才抽,其余都留在模板里。
7.4 类名太长,读不下去
我现在的处理方式:
- 按
布局 → 间距 → 排版 → 颜色 → 状态的固定顺序写,扫一眼能定位; - 装
prettier-plugin-tailwindcss,保存时自动排序,团队里不会出现两种顺序; - 超过三行的类名,说明这个块该拆成组件了。
7.5 浏览器兼容
v4 用了新的 CSS 特性,Chrome 111+ / Safari 16.4+ / Firefox 128+ 才支持。如果项目要兼容老浏览器,老老实实用 v3。
八、结果
| 对比项 | 重构前 | 重构后 |
|---|---|---|
style.css 行数 |
||
| 自定义类名数量 | ||
| 响应式写法 | 3 段 @media |
类名前的 sm: 前缀 |
| 改一个按钮圆角 | 全局搜 3 处 | 改 1 处 |
主观感受上最大的变化不是"代码变短",而是我敢删了。以前删一个组件要想"CSS 里那堆还有没有别处在用",现在删掉模板,样式就一起没了。
九、什么时候我不推荐 Tailwind
诚实说,它不适合所有场景:
- 静态页面、一页到底的宣传页:手写 CSS 更直接,没必要引入构建链。
- 已有成熟组件库的项目:Tailwind 的
preflight会重置基础样式,和 Element Plus / Ant Design 混用要额外处理冲突。 - 团队没有统一规范:原子类缺乏约束时,五个人能写出五种风格,反而更难维护。
但对我这个待办事项项目——组件少、样式改得勤、就我一个人写——它非常合适。
十、下一步
参考
- TailwindCSS 官方文档:https://tailwindcss.com/docs
- Tailwind 中文站:https://www.tailwindcss.cn/
- Vite 官方文档:https://cn.vitejs.dev/

浙公网安备 33010602011771号