
字节笔记本
2026年10月7日 · 约 11 分钟读完
Tailwind 打造移动端每日打卡列表:从勾选到录音
打卡类界面是移动端的高频形态:习惯打卡、日程回顾、语音日记,长得都差不多——日期在左侧纵向排列,每个日期下面挂一组当天的条目。某项目里正好需要这样一个组件,而且要求更进一步:条目不是简单的复选框,而是一条条可以播放的音频,还能现场录音补录。本文复盘这个组件从静态布局到录音落地的渐进式实现,技术栈只有两样:React 加 Tailwind CSS。

布局骨架:日期居左,条目居右
需求的第一句话通常是「日期在左、列表项在右,一个日期对应多个条目」。落到数据结构上,就是一个 date 字段配一个数组:
const dailyData = [
{ date: '2024-09-12', items: ['晨跑 5 公里', '冥想 15 分钟', '读书 30 分钟'] },
{ date: '2024-09-11', items: ['健身 45 分钟', '学习编程 1 小时', '写日记'] },
];这类界面的本质是时间轴与清单的复合体:日期是锚点,条目是挂在锚点下的内容,锚点必须醒目、内容必须可扫读。实现方案上,表格布局太重,float 布局要手工处理清除浮动,一行 flex 就能把左右两栏摆平——这也正是 Tailwind 的舒适区:不写一行独立的 CSS 文件,类名即样式,调整时不用在 JSX 和样式表之间来回跳。
布局本身用 flex 一刀切开:
<li className="flex bg-white rounded-lg shadow-md overflow-hidden">
<div className="bg-blue-500 text-white p-4 flex items-center justify-center w-24">
<span className="text-sm font-semibold">{day.date}</span>
</div>
<ul className="flex-1 p-4">
{day.items.map((item, i) => (
<li key={i} className="flex items-center mb-2 last:mb-0">
<input type="checkbox" className="mr-2 h-5 w-5" />
<span>{item}</span>
</li>
))}
</ul>
</li>左侧日期块固定 w-24 并用 bg-blue-500 撞色,右侧 flex-1 吃掉剩余宽度;条目间距交给 mb-2 配 last:mb-0 收尾,避免末尾多出一截。整个列表外层再套 max-w-md mx-auto,在桌面浏览器里把宽度锁在移动端尺寸,调试移动页面时这比开 DevTools 的设备模拟更直观。
两个细节值得提:列表项的 key 尽量用 day.date 而不是数组下标,日期天然唯一且稳定;条目容器保持 ul/li 的语义结构,屏幕阅读器才能把它当列表读出来。
交互加码:从复选框到迷你播放器
第二步行内跳转:在每张卡片右侧加一个 ChevronRight 箭头按钮,onClick 里接路由跳转,记得补上 aria-label="查看详情"——纯图标按钮没有文字,可访问性全靠这个属性兜底。
真正的变化是把条目从「待办」升级成「音频」:每个条目变成一个 TrackPlayer 子组件,带播放/暂停按钮和进度条。单个条目的状态很轻,两个 useState 就够:isPlaying 控制图标切换(Play 与 Pause 图标来自 lucide-react),progress 驱动进度条。播放时的视觉反馈用模板字符串拼条件类名,一行解决:
<div className={`flex items-center p-2 rounded-md ${isPlaying ? 'bg-blue-100' : 'bg-white'}`}>进度条是双层 div:外层 bg-gray-200 定轨道,内层 bg-blue-600 按百分比撑宽度,配 transition-all duration-300 让推进变得平滑。
如果是纯前端演示,可以用 setInterval 模拟进度递增。但这类演示代码藏着两个经典隐患,值得直接写进 review 清单:
- 暂停只是把 isPlaying 翻转,定时器并没有被清除,进度条会自顾自走完——「暂停」名存实亡;
- interval 句柄存在局部变量里,组件卸载时无人清理,内存泄漏加越界 setState。
正确姿势是用 useRef 存定时器,在 useEffect 的清理函数里 clearInterval;真实场景则应该让 <audio> 的 timeupdate 事件驱动进度,模拟器只在还没有真实音频文件时凑数。
还有一个容易被忽略的产品细节:多个 TrackPlayer 各管各的状态,两行可以同时处于「播放中」。真实的音频场景需要互斥——把「当前播放条目的 id」提升到父组件,子组件只接收 isPlaying 与 onToggle 两个 props,互斥判断收在一处。这也是 React 受控组件模式的典型应用:状态上提之后,UI 只做忠实渲染,测试面也从 N 个播放器缩到一个父组件。
再往上加头部操作区就轻车熟路了:日期条改成 flex justify-between items-center,左边日期、右边两个圆形图标按钮(Share2 与 Plus),hover:bg-blue-600 提供按压反馈。
独立新增页:模态框还是整页
新增音频的表单包含标题、备注和录音按钮。第一版做成了居中模态框——fixed inset-0 半透明遮罩加一张白色卡片,几行 Tailwind 就很精神。但移动端表单页更常见的做法是整页跳转:输入法弹起、内容滚动、录音途中返回,整页的层级更干净,也符合「列表页与编辑页相互独立」的直觉。实现上就是独立组件配独立路由,页面顶部放返回箭头加标题的导航条,返回逻辑按各自的路由方案接入。
表单控件统一用受控写法:input 与 textarea 的 value 绑定 state,onChange 同步回写,保存时直接用 state 组装数据,不必手动读 DOM。标题、备注、录音状态各占一个 useState,彼此互不干扰;后面要加校验,也只是在保存回调里多几行判断的事。

录音落地:MediaRecorder 全链路
录音部分是整个组件技术密度最高的地方,好在浏览器原生 API 已经足够,整条链路五步:
navigator.mediaDevices.getUserMedia({ audio: true })申请麦克风,用户拒绝授权时要把 catch 到的错误转成界面提示,而不是静默失败;new MediaRecorder(stream)后调用start(),ondataavailable 回调持续把二进制分片推进 chunks 数组;stop()触发 onstop 回调,把分片合并成new Blob(chunks, { type: 'audio/webm' }),再用URL.createObjectURL生成可回放的临时地址;- 界面按三态切换:未录音时显示蓝色「开始录音」大按钮,录音中变红并显示「停止录音」,录完后换成播放与重录两个按钮;
- 回放复用一个
<audio ref>元素,src 指向生成的 object URL,onEnded 里把播放态复位;重录则是清掉已录音频、回到录音态。
三态按钮是这段体验的关键:录音、回放、重录各占一个状态位,用条件渲染让同一块区域在不同阶段只出现当前可用的操作,比把所有按钮平铺出来清晰得多。
还有两个前置条件值得单独提醒。一是安全上下文:getUserMedia 只在 HTTPS 或 localhost 环境下开放,HTTP 部署的页面会直接报权限错误,真机联调时经常在这里撞墙。二是 iOS 的播放限制:音频必须由用户手势触发,程序化自动播放会被静默拦截,把 play() 挂在按钮的点击回调里就能绕开。
生产化之前还有几张必须补的票:
- 释放设备:
stop()之外还要stream.getTracks().forEach(t => t.stop()),否则麦克风的系统占用指示不会熄灭; - 释放内存:object URL 用完应当
URL.revokeObjectURL,否则 blob 会一直挂在内存里; - 兼容性:MediaRecorder 在 iOS Safari 14.1 之后才可用,且默认产出 mp4 而非 webm,先用
MediaRecorder.isTypeSupported探测再决定 mimeType; - 持久化:object URL 只在当前会话有效,保存时应把 blob 塞进 FormData 上传服务器,拿回真实 URL 再入库;
- 播放暂停:演示代码里暂停直接把进度归零,真机上应该靠
audio.pause()保留 currentTime,继续播放时从原处续上。
小结
回头看,这个组件的演进路径很典型:先用数据结构表达「一个日期对多条目」,再用状态表达播放中的视觉反馈,最后用浏览器原生能力补上录音。全程没有引入状态库或组件库,Tailwind 的原子类负责样式,React 的 useState 与 useRef 负责状态。剩下的扩展都顺理成章:把 TrackPlayer 的播放态提升到父组件,实现「同一时刻只播一条」;用 useReducer 收拢录音页的状态机;或者把 dailyData 换成后端分页接口——骨架搭对了,这些都不难。



