ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

一次只做一件事:用React构建单任务聚焦的Todo应用

一次只做一件事:用React构建单任务聚焦的Todo应用 打开手机里的任何一款 TODO 应用你很容易撞见一个奇怪的悖论我们明明是想借工具减轻负担结果打开应用先看到一整页待办甚至工作还没开始大脑已经开始焦虑。今天要聊的这个小产品名字叫One Thing at a Time来自 Hacker News 的 Show HN 板块。它的设计理念可以浓缩成一句话一次只展示一件事把其它任务全部藏起来。这个卖点看起来像是“少了一个功能”但实际上它解决的是一个很深层的问题任务切换带来的认知损耗。传统 TODO 工具的默认逻辑是“记录、整理、总览”而它的默认逻辑是“过滤、聚焦、隔离”。这个差异不是功能多寡的区别而是产品定位上的分水岭。这篇文章会从三个层面展开先拆解 One Thing at a Time 的设计理念为什么“隐藏列表”是一个刻意设计而不是偷懒再用 React 实现一个最小可用的单任务聚焦 Todo 应用最后讨论这类产品在真实工程中的边界、误区和最佳实践。如果你正在做工具类产品或者你长期被各种待办清单压得喘不过气这篇文章值得看完。1. 这篇文章真正要解决的问题1.1 为什么传统 Todo 应用会越用越累先说一个很多人的体感Todo 应用的核心价值应该是“不要让我记住事情”但大多数 Todo 应用在完成记录之后会让用户面对一个更大的负担——那个列表本身。你记录的事项越多列表越长每次打开应用要做的“决策”就越多先做哪件哪件可以拖到明天哪件其实已经不重要了人脑的判断资源是有限的把这些资源花在“给任务排序”上必然会挤占真正用于“完成任务”的精力。行为科学里有一个概念叫“注意力残留”当你在一堆任务之间反复切换时即使切换成功了大脑的一部分注意力仍然停留在上一个任务上。这就是为什么很多人工作时不断刷新待办清单越刷越焦虑最后干脆关掉应用。1.2 真正要解决的不是“效率”而是“决策成本”One Thing at a Time 这个产品最大的价值在于它把“待办管理”从一项需要持续维护的工作变成了一种自动执行的约束。传统方案要求用户自己维护优先级你要判断哪个任务更重要要自己去拖拽排序要不断调整计划。而这个产品把“下一个做什么”这个决策直接夺走了。你只能看到当前这个任务没有选择余地反而减少了内心冲突。更关键的是它隐藏列表不等于删除列表。任务数据仍然存在只是默认不可见。这个产品让你在“专注时刻”完全隔离在“回顾时刻”才重新看到全貌。这篇文章要解决的就是帮你理解这种设计为什么成立并且用代码把它落地成自己的小工具。2. One Thing at a Time 的核心设计理念2.1 “隐藏列表”到底是什么简单说这个应用的界面只有三层信息当前正在做的唯一任务。一个可以手动展开的完整任务列表。一个新增任务的输入框。“隐藏”并不是让任务凭空消失而是让它们在默认状态下不出现。这种设计与 GTDGetting Things Done里提倡的“清空大脑”不同。GTD 强调把任务全部外化到系统里然后依靠系统帮你管理One Thing at a Time 则更进一步它认为任务外化之后系统应该主动帮你隔离开而不是把全部任务重新砸回你的视野。心理学中有一个常见现象人对于“未完成的任务”有天然的记忆残留这种残留会干扰当前工作。如果你能看到列表哪怕你不去点它它也会占用一部分注意力。隐藏列表的意义就在于用视觉上的阻断来减少心理上的干扰。2.2 与传统 Todo 应用的对比对比维度传统 Todo 应用One Thing at a Time打开应用后看到什么完整任务列表当前唯一任务用户的核心操作排序、分类、规划专注当前、完成当前默认状态展示全部隐藏全部心理负担高需要持续决策低不需要选择任务回顾随时可见需要手动展开适合的人群喜欢全局掌控的人容易分心、希望强制聚焦的人这个表格可以看得出来传统应用强调“管理”而 One Thing at a Time 强调“执行”。它默认用户已经完成了规划剩下的任务就是一件事把当前的事情做完。2.3 小结论从产品设计角度看“隐藏”是一种有意为之的机制它限制了用户的选择反而给了用户更大的自由。对开发者来说这个理念也提醒我们功能列表不等于产品价值克制有时候比添加更重要。3. 产品功能拆解最小可用版本长什么样3.1 核心功能闭环从 Show HN 的产品描述来看这个应用的功能闭环非常短用户新增一个待办任务。如果当前没有正在进行中的任务新任务自动成为当前任务。界面只显示这个任务。用户完成后当前任务被标记为完成。系统从剩余未完成任务中取出一个继续显示。如果全部完成则显示完成提示。这个闭环里没有复杂的标签、优先级、截止时间、提醒、协作者。它把 Todo 应用精简到了“一个输入框 一张卡片 一个完成按钮”。这个精简不是能力的缺失而是对“专注”这一目标的坚持。3.2 哪些功能应该刻意不做对一个单任务聚焦产品来说以下功能不仅不是加分项反而可能破坏体验优先级排序一旦允许用户排序就会重新引入决策成本。花哨的提醒通知通知会把用户的注意力从“当前任务”拉回“外部信息”。复杂的数据统计统计适合回顾不适合在专注过程中展示。多列表分类分类属于任务管理不属于任务执行。这就是为什么实现这样的应用时必须对每一个新功能先问一句“它会不会让用户重新面对整个列表”如果答案是会那这个功能就不该进首版。4. 技术方案选型与项目结构4.1 前端技术栈如果需要用一个 Demo 来跑通这个产品思路前端技术栈可以按非常轻量级的方式来选框架React用函数组件和 Hooks 管理状态。构建工具Vite启动速度快配置少。数据存储localStorage不需要后端服务。样式可以先用普通 CSS保持演示简单。当然用 Vue、Svelte 或者原生 JavaScript 也能实现同样的功能本文选择 React 是因为它在 CSDN 读者中覆盖面广而且 Hooks 的写法非常适合表达“当前任务”的状态流转。4.2 为什么优先用 localStorage 起步localStorage 看起来简单但对这个场景来说是够用的。数据量不大就是一堆文本任务和状态标记访问频率低不需要数据库的并发控制没有账号体系没有任何跨端需求的时候localStorage 可以把实现成本降到最低。它的缺点也很明显浏览器清除缓存会导致数据丢失换设备不会同步多标签页同时打开时也可能出现状态不一致。这些问题等产品真正需要成长时再引入服务端也不迟首版最重要的目标是快速验证“隐藏列表能不能改变你的使用习惯”。4.3 项目目录结构建议结构如下one-thing-at-a-time/ ├── index.html ├── package.json ├── src/ │ ├── App.jsx │ ├── main.jsx │ ├── components/ │ │ ├── TaskCard.jsx │ │ ├── AddTask.jsx │ │ └── TaskList.jsx │ └── hooks/ │ └── useLocalStorage.js这个结构足够简单也保留了后续扩展的余地。hooks目录用来放自定义 Hook可以把 localStorage 的读写逻辑从组件里抽离出来。5. 完整代码实现React 单任务聚焦 Todo在这个小节里我用一个最小示例把整个产品思路跑通。代码不是原版应用的真实源码而是通用的实现思路。先看最核心的 localStorage Hook。5.1 本地持久化 Hook// 文件路径src/hooks/useLocalStorage.js import { useState } from react; function useLocalStorage(key, initialValue) { const [storedValue, setStoredValue] useState(() { try { const item window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.error(读取本地存储失败, error); return initialValue; } }); const setValue (value) { try { const valueToStore value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); window.localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (error) { console.error(写入本地存储失败, error); } }; return [storedValue, setValue]; } export default useLocalStorage;这个 Hook 的逻辑很简单初始化时从 localStorage 读取值调用setValue时同步写入。它封装了序列化和反序列化的过程让组件层不需要关心 JSON 字符串的细节。5.2 应用主组件// 文件路径src/App.jsx import { useState } from react; import useLocalStorage from ./hooks/useLocalStorage; import TaskCard from ./components/TaskCard; import AddTask from ./components/AddTask; import TaskList from ./components/TaskList; function App() { const [tasks, setTasks] useLocalStorage(ott-tasks, []); const [currentTaskId, setCurrentTaskId] useLocalStorage(ott-current-task, null); const [showHistory, setShowHistory] useState(false); const addTask (title) { const task { id: Date.now().toString(), title, createdAt: new Date().toISOString(), done: false, completedAt: null }; setTasks([...tasks, task]); if (!currentTaskId) { setCurrentTaskId(task.id); } }; const completeCurrent () { setTasks(tasks.map(t t.id currentTaskId ? { ...t, done: true, completedAt: new Date().toISOString() } : t )); const pending tasks.find(t t.id ! currentTaskId !t.done); setCurrentTaskId(pending ? pending.id : null); }; const pickTask (id) { setCurrentTaskId(id); setShowHistory(false); }; const currentTask tasks.find(t t.id currentTaskId); return ( div classNameapp h1一次只做一件事/h1 {currentTask ? ( TaskCard task{currentTask} onComplete{completeCurrent} / ) : ( p classNameempty-tip当前的待办任务已全部完成/p )} button onClick{() setShowHistory(v !v)} {showHistory ? 收起列表 : 查看完整列表} /button {showHistory ( TaskList tasks{tasks} currentTaskId{currentTaskId} onPick{pickTask} / )} AddTask onAdd{addTask} / /div ); } export default App;这段代码的核心逻辑是currentTaskId只指向一个任务界面默认只展示currentTask。showHistory控制完整列表是否展开。这样就从状态层保证了“隐藏列表”是默认行为。5.3 三个子组件TaskCard 负责展示当前任务和完成按钮// 文件路径src/components/TaskCard.jsx function TaskCard({ task, onComplete }) { return ( div classNametask-card p{task.title}/p button onClick{onComplete}完成并放开/button /div ); } export default TaskCard;AddTask 负责新增任务// 文件路径src/components/AddTask.jsx import { useState } from react; function AddTask({ onAdd }) { const [title, setTitle] useState(); const submit (e) { e.preventDefault(); const value title.trim(); if (!value) return; onAdd(value); setTitle(); }; return ( form onSubmit{submit} input value{title} onChange{e setTitle(e.target.value)} placeholder添加一个待办 / button typesubmit添加/button /form ); } export default AddTask;TaskList 负责在用户主动展开时展示全部任务并提供切换当前任务的入口// 文件路径src/components/TaskList.jsx function TaskList({ tasks, currentTaskId, onPick }) { return ( ul classNametask-list {tasks.map(task ( li key{task.id} className{task.done ? done : } span{task.title}/span {!task.done task.id ! currentTaskId ( button onClick{() onPick(task.id)}设为当前任务/button )} /li ))} /ul ); } export default TaskList;这里有一个容易被忽略的细节已完成任务不会显示“设为当前任务”按钮未完成但已经是当前任务的项目也不会显示按钮。这能防止用户在使用完整列表时误操作也算是对核心产品理念的坚守。5.4 项目初始化命令项目本身可以用 Vite 快速生成npm create vitelatest one-thing-at-a-time -- --template react cd one-thing-at-a-time npm install npm run dev这里的版本以你运行命令时 Vite 实际生成的版本为准重点是目录结构。如果你不想用 Vite直接用 create-react-app 或者其他脚手架也可以核心代码思路不变。6. 数据模型与状态流转6.1 数据模型设计这个应用的本地存储结构非常直观核心是两个键{ ott-tasks: [ { id: 1700000000000, title: 整理项目文档, createdAt: 2024-01-01T10:00:00.000Z, done: false, completedAt: null }, { id: 1700000000001, title: 给组件写单元测试, createdAt: 2024-01-01T11:00:00.000Z, done: true, completedAt: 2024-01-01T12:00:00.000Z } ], ott-current-task: 1700000000000 }字段设计遵循最小原则id用时间戳足以在单机场景下保证唯一done标记是否完成completedAt记录完成时间方便以后做统计回看。这里不需要updatedAt因为当前版本没有编辑功能。6.2 状态流转整个应用只有三种状态无任务ott-tasks为空界面显示空状态提示。存在当前任务界面只显示一个 TaskCard。当前任务完成系统从剩余任务中自动选一个作为新的当前任务。注意completeCurrent的逻辑中我使用的是tasks.find(...)而不是setTasks后的回调结果。这是因为 React 的状态更新是异步的当前tasks变量仍然是旧值。在这个场景下tasks至少包含当前任务所以先找到下一个未完成任务再更新列表逻辑是没有问题的。但如果任务量很大这种写法在极端条件下可能选择不到最新的未完成任务更稳妥的做法是在设置新状态前先基于同一个数据源计算出新列表然后用同一个列表结果去更新两个状态。6.3 隐藏列表的实现方式隐藏列表不是把列表数据丢到后端也不是把数据删除而是使用showHistory控制 DOM 渲染。这是一种非常低成本的方式默认情况下列表 DOM 不渲染用户看不到任何干扰。用户点击“查看完整列表”列表才渲染出来。用户切完任务后pickTask会立刻把showHistory置为false列表再次隐藏。这个交互在视觉上会形成“瞥一眼全貌然后回到专注”的节奏比让列表常驻更符合产品定位。7. 运行验证与效果确认7.1 启动应用在项目根目录执行npm run dev正常启动后Vite 会在终端输出一个本地地址例如VITE v4.0.0 ready in 500 ms ➜ Local: http://localhost:5173/在浏览器打开这个地址就能看到应用界面。7.2 手动测试清单建议按下面的顺序验证一遍完整流程打开应用确认界面只显示标题和输入框没有任务列表。输入“写一篇文章”点击“添加”。确认新任务自动成为当前任务并显示在 TaskCard 上。再次添加“做 PPT”和“回复邮件”两个任务。此时界面仍然只显示“写一篇文章”列表处于隐藏状态。点击“查看完整列表”确认能看到三条任务且已完成任务没有“设为当前任务”按钮。点击“完成并放开”确认当前任务变为“做 PPT”列表自动收起。刷新浏览器确认当前任务仍然是“做 PPT”不会回到初始状态。第 8 步特别重要它验证的是 localStorage 持久化逻辑是否生效。如果刷新后数据丢失问题基本出在useLocalStorage的读取阶段或者浏览器禁用了本地存储。7.3 如何判断产品的机制是否生效判断代码是否写对看功能是否正常判断产品理念是否落地看用户的注意力是否真的被约束住了。一个简单的验证办法是连续使用十分钟观察自己是不是比用传统 Todo 应用更少去划拉列表。如果发现“查看完整列表”按钮被频繁点击并且每次点开都忍不住切换任务那说明这个产品不适合你或者需要进一步增加隔离强度。如果发现自己更专注了说明“隐藏列表”这个设计确实有效。8. 常见问题与排查方法问题现象可能原因排查方式解决方案刷新后任务全部丢失localStorage 中没有写入成功打开浏览器开发者工具查看 Application 面板中 Local Storage 是否有ott-tasks键检查useLocalStorage的写入逻辑确认没有抛出异常添加任务后当前任务为空添加时currentTaskId没有写入查看 Local Storage 中ott-current-task的值确认addTask中if (!currentTaskId)的逻辑执行到了点击完成后没有任何变化completeCurrent没有更新正确在控制台打印tasks和currentTaskId检查map中t.id currentTaskId是否匹配注意字符串类型是否一致多个浏览器标签页数据不同步localStorage 不会跨标签页自动通知在两个标签页中分别修改任务观察数据覆盖使用storage事件监听同步或改用后端存储浏览器提示存储已满localStorage 容量限制查看控制台输出的QuotaExceededError压缩存储内容或移除历史超长文本8.1 为什么刷新后列表还是空的这是使用 localStorage 最常踩的坑。最常见的原因是代码在服务端渲染环境下执行window对象不存在导致初始化时读取失败。但在纯客户端渲染的 Vite 项目里这个坑相对少见。更常见的原因是测试时用的浏览器开启了无痕模式或者浏览器的“站点数据”被设置为“关闭浏览器时清除”。如果代码逻辑确认没问题先换一个普通窗口再测。8.2 为什么当前任务一直不变completeCurrent中更新当前任务依赖tasks.find如果剩余任务中刚好没有任何未完成任务pending就是null界面会进入空状态提示这是正常行为。如果你希望“当前任务完成”后自动选择“创建时间最早”的任务可以在find之前先对tasks按createdAt排序。不过对日常使用来说保持数组原始顺序通常已经足够。9. 工程化建议、适用边界与延伸思考9.1 从 Demo 到生产要注意什么如果要把这个思路做成正式产品第一个要补充的是账号体系和后端存储。localStorage 适合个人使用但没法支撑多设备场景。后端存储的选型可以很轻比如 SQLite 起步或者用云数据库的免费额度重点是数据模型不要复杂化仍然是“任务表 当前任务指针”这样的结构。第二个要补充的是数据导出。任何单任务聚焦类工具本质上是替用户保管了一部分记忆。如果用户某天想退出他需要能方便地把所有任务导出成 Markdown 或 CSV。这个功能看似不性感但它是信任的基础。第三个要补充的是“误操作”防护。这个产品只有一个核心按钮“完成”一旦误触用户可能会失去当前任务的上下文。生产级设计应该提供撤销机制或者在完成前增加一步轻量确认。这不会破坏专注体验反而会让用户更放心。9.2 这类产品不适合谁One Thing at a Time 并非适合所有人群。如果你的工作模式本身就是“多项目并行事件驱动”比如客服、运维值班、线下调度那么强制只显示一个任务反而会带来困扰。它更适用于以下场景内容创作需要长时间不受干扰地写作或编码。备考复习需要把注意力集中在单一科目。深度工作习惯的培养用于逐步降低切换频率。换句话说它解决的是“任务之间互相干扰”的问题而不是“任务太多”的问题。任务多但切换不频繁的人也能从中受益任务少但频繁切换的人需要先改变工作方式这个工具只是辅助。9.3 这个设计还可以怎么扩展在保持“隐藏列表”原则不变的前提下可以做几个方向的迭代定时放开设置一个番茄钟时间到达后再显示下一个任务把专注时间和任务绑定。每日回顾提醒晚上固定时间一次性展示当天完成情况而不是在专注时展示。多用户共享当前任务适合两个人远程协作时大家只看到一个共同目标。“搁置”功能允许当前任务暂时放回列表并随机或按顺序选择下一个任务注意这个功能会重新引入决策成本设计时要谨慎。这些方向都是在不破坏核心机制的前提下扩展使用场景而不是重新走回传统 Todo 应用的老路。如果让我用一句话总结这个产品One Thing at a Time 真正的创新不是减少了待办数量而是用产品机制强行扭转了用户和清单之间的关系。对开发者来说它最大的启发是工具类应用的功能稀疏往往是刻意的取舍而不是能力不足。你可以在理解原理后用上面的代码跑一遍感受它在使用节奏上和传统 Todo 应用的区别。如果你正在被“一打开清单就焦虑”困住这个模式值得保留在你的工具清单里。
返回列表