摘要
以Todo应用拆解React组件化:useState惰性初始化与不可变更新、useEffect四种依赖模式、props单向数据流、localStorage持久化,附完整代码与内存清理实践。
一、一个Todo应用,藏着React的半壁江山
Todo应用是前端领域的"Hello World Plus"------它足够简单,让你十分钟就能跑起来;又足够复杂,能覆盖React最核心的机制:状态管理、生命周期、组件通信、本地存储。本文以一个完整的Vite + React Todo项目为样本,拆解其中的每一行代码,让你不止会用,更懂为什么。
这个项目只有四个组件,总代码量不过150行,却完整呈现了React函数式组件开发的标准范式。读完本文,你将理解:
- useState的惰性初始化和不可变更新模式
- useEffect依赖数组的四种配置及其对应的生命周期语义
- 父组件持有状态、子组件通过props读取、通过回调函数申请修改的单向数据流
- localStorage与React生命周期结合实现数据持久化
二、项目结构:Vite脚手架下的工程化起点
项目使用Vite作为构建工具,这是当下React生态中最主流的方案。先看入口文件:
javascript
// main.jsx
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<App />
)
createRoot 是React 18引入的新API,它替代了旧版的 ReactDOM.render,为并发特性提供了底层支持。整个应用的启动链路:index.html → main.jsx → App.jsx,简洁清晰。
Vite配置文件也保持了最小化:
javascript
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
@vitejs/plugin-react 插件负责JSX转译和Fast Refresh热更新,无需额外配置即可获得完整的React开发体验。
项目目录结构展示了组件化的基本组织方式:
bash
src/
├── main.jsx # 入口文件
├── App.jsx # 根组件(状态持有者)
├── App.css # 全局样式
├── index.css # CSS变量与基础样式
└── components/
├── TodoInput.jsx # 输入组件
├── TodoList.jsx # 列表组件
└── TodoStats.jsx # 统计组件
这种"一个组件一个文件"的组织方式,让每个组件的职责一目了然。在Vibe Coding场景下,清晰的目录结构本身就是最好的prompt------AI看到目录名就能推断出每个文件的大致内容。
三、组件树设计:单一职责与单向数据流
React官方从未明确定义"组件应该拆多细",但业界有一条黄金法则:一个组件只做一件事。如果组件名需要用"和"来连接两个职责,就该拆了。
本项目的四个组件正好对应Todo应用的四个功能域:
| 组件 | 职责 | 类型 |
|---|---|---|
| App | 状态持有者,管理todos数组和count计数器 | 容器组件(有状态) |
| TodoInput | 输入框与表单提交 | 展示组件(有局部状态) |
| TodoList | 列表渲染、勾选切换、删除 | 展示组件(无状态) |
| TodoStats | 统计数字与批量清除 | 展示组件(无状态) |
这里有一个关键设计原则:数据状态由父组件(App)统一持有和管理,子组件通过props读取数据,通过回调函数通知父组件修改。子组件本身不直接修改父组件的数据------这是React单向数据流的核心约束。
bash
App (状态持有者)
├── TodoInput ← onAdd回调
├── TodoList ← todos数据 + onToggle/onDelete回调
└── TodoStats ← total/active/completed数据 + onClearCompleted回调
这种模式让数据流变得可预测:任何状态变更都发生在App组件内部,子组件只是"UI的投影"。当某个Todo被勾选,数据流是 TodoList → onToggle(id) → App.setTodos() → 重新渲染TodoList,形成一个闭环。
四、useState:从惰性初始化到不可变更新
4.1 惰性初始化:避免每次渲染都执行昂贵计算
App组件中todos的初始化写法值得细看:
javascript
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos')) || []
});
这里传入的是一个函数,而不是函数调用的结果。区别在于:
javascript
// 写法A:每次渲染都会执行JSON.parse
const [todos, setTodos] = useState(JSON.parse(localStorage.getItem('todos')) || []);
// 写法B:只在组件首次挂载时执行
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos')) || []
});
写法A虽然语法更短,但每次组件重新渲染时,JSON.parse(localStorage.getItem(...)) 都会执行,即使返回值被丢弃。对于localStorage读取这种同步操作,影响不大;但如果初始化逻辑涉及大量计算,惰性初始化能避免不必要的开销。React会在首次渲染时调用这个函数,并将返回值作为初始状态,后续渲染中直接忽略它。
4.2 不可变更新:每次setState都是"全新的状态"
观察App组件中修改todos的三个方法,它们都遵循同一个模式:不修改原数组,而是创建一个新数组传给setTodos。
javascript
// 添加Todo:创建新数组,新项在前
const addTodo = (text) => {
if (text.trim() === "") return;
setTodos([
{ id: +Date.now(), text: text, completed: false },
...todos
]);
}
// 切换完成状态:map返回新数组,匹配项用展开运算符创建新对象
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}
// 删除Todo:filter返回新数组
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
}
这里为什么不能直接修改原数组?比如 todos[0].completed = true; setTodos(todos);?
React通过 Object.is() 比较新旧状态来判断是否需要重新渲染。如果直接修改原数组再传入,React会认为新旧状态是同一个引用,从而跳过渲染。不可变更新确保了每次状态变更都会产生新的引用,React能准确检测到变化并触发更新。
三种操作对应的不可变工具方法:
| 操作 | 不可变方法 | 说明 |
|---|---|---|
| 添加 | 展开运算符 [...todos, newItem] |
创建新数组,追加元素 |
| 更新 | map() + 展开运算符 |
匹配项创建新对象,其余原样返回 |
| 删除 | filter() |
返回不包含目标元素的新数组 |
4.3 局部状态:TodoInput中的controlled input
TodoInput组件有自己的局部状态 inputValue,这是表单交互的经典模式------controlled component:
javascript
const [inputValue, setInputValue] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
onAdd(inputValue); // 通知父组件
setInputValue(''); // 清空输入框
return false;
}
// JSX
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="What needs to be done?"
autoFocus
/>
input的 value 属性绑定到 inputValue 状态,onChange 事件更新这个状态。React中,表单元素的值完全由state控制,而非DOM自身维护,这就是"受控组件"的含义。
注意 handleSubmit 中的流程:先调用 onAdd(inputValue) 将输入内容传给父组件,再调用 setInputValue('') 清空本地状态。这个顺序很重要------如果先清空再传值,传给父组件的永远是空字符串。
五、useEffect:四种依赖模式的完整图景
App组件中写了四个useEffect,清晰地展示了依赖数组的四种配置方式:
javascript
// 模式1:空数组 [] ------ 仅在挂载后执行一次
useEffect(() => {
console.log('只会在挂载后执行')
}, []);
// 模式2:[count] ------ 挂载后执行,count变化后也执行
useEffect(() => {
console.log('count 更新后会执行')
}, [count]);
// 模式3:[todos] ------ 挂载后执行,todos变化后也执行
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos]);
// 模式4:不传第二个参数 ------ 挂载后执行,每次渲染后都执行
useEffect(() => {
console.log('每次更新都执行')
});
这四种模式对应不同的生命周期语义:
| 依赖数组 | 执行时机 | 对应类组件 |
|---|---|---|
[] |
挂载后执行一次 | componentDidMount |
[dep] |
挂载后 + 依赖项变化后 | componentDidMount + componentDidUpdate |
| 不传 | 挂载后 + 每次渲染后 | componentDidMount + componentDidUpdate(每次) |
| return函数 | 组件卸载前 / 依赖项变化前 | componentWillUnmount |
5.1 localStorage持久化:依赖数组的精妙之处
最关键的useEffect是持久化todos到localStorage:
javascript
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos]);
这段代码只有三行,却实现了"数据随状态自动同步到本地存储"的完整功能。只要 todos 数组发生任何变化(添加、切换、删除、清空),这个effect就会自动触发,将最新的todos序列化写入localStorage。不需要在addTodo、toggleTodo、deleteTodo、clearCompleted每个方法中重复写存储逻辑------这就是依赖数组带来的"少写好多业务代码"的效果。
5.2 清理函数:防止内存泄漏
Demo组件展示了useEffect的清理函数机制:
javascript
const Demo = () => {
useEffect(() => {
const interval = setInterval(() => {
console.log('interval, is here')
}, 1000);
return () => {
console.log('组件卸载前执行, 内存清理工作');
clearInterval(interval);
}
}, [])
return <>Demo</>
}
组件卸载时,React会调用useEffect中return的函数。这里清除了定时器,消除了潜在的内存泄漏------如果不清除,即使组件已从DOM中移除,定时器仍会继续运行,持有对组件作用域的引用,导致垃圾回收器无法回收这部分内存。
App组件中通过条件渲染控制了Demo的挂载与卸载:{count % 2 === 0 && <Demo />}。当count从偶数变为奇数时,Demo组件被卸载,清理函数自动执行,定时器被清除。
六、组件通信:props向下,事件向上
React的数据流是严格单向的。父组件通过props将数据传递给子组件,子组件通过回调函数通知父组件修改数据。这条规则没有例外。
6.1 父→子:props传递数据
App组件同时向三个子组件传递数据:
javascript
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
TodoList接收的 todos 是完整的数组,因为列表渲染需要遍历所有项。TodoStats接收的是 total、active、completed 三个派生值,因为它只需要展示统计数字,不需要知道每个Todo的具体内容。这种"按需传递"的设计遵循了最小权限原则------子组件只知道自己需要知道的信息。
6.2 子→父:回调函数通知
TodoList中,用户点击勾选框或删除按钮时,子组件并不直接修改todos,而是调用父组件传下来的回调:
javascript
const TodoList = ({ todos, onToggle, onDelete }) => {
return (
<ul className="todo-list">
{todos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
</label>
<button onClick={() => onDelete(todo.id)}>删除</button>
</li>
))}
</ul>
)
}
onChange={() => onToggle(todo.id)} 用箭头函数包装了一层,目的是在调用时传入 todo.id 参数。如果写成 onChange={onToggle},事件对象会被当作参数传入,而非todo的id。
6.3 状态提升:派生状态的计算位置
App组件中,activeCount 和 completedCount 不是在子组件中计算的,而是在父组件中通过 filter 派生:
javascript
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.length - activeCount;
这种做法称为状态提升------将需要被多个子组件共享的派生数据提升到最近的公共父组件中计算。如果TodoStats和TodoList都需要知道activeCount,放在App中计算一次,然后分别传给两个子组件,比各自计算一遍更高效,也保证了数据的一致性。
七、CSS变量与响应式设计
index.css中定义了一套CSS自定义属性(变量),实现了亮色/暗色主题的自动切换:
css
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, ...;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
/* ... 暗色主题变量覆盖 */
}
}
@media (prefers-color-scheme: dark) 检测系统级主题偏好,当用户系统设置为暗色模式时,自动覆盖CSS变量值。所有使用 var(--text)、var(--bg) 等变量的元素都会自动切换颜色,无需修改任何组件代码。
这种方案的优雅之处在于:主题切换完全由CSS层处理,React组件不感知当前主题。组件只需使用语义化的CSS变量名,无需在JSX中做条件渲染或传递主题状态。
八、总结:从Todo到真实项目的跃迁
回到这个Todo应用,它用不到150行代码演示了React函数式组件开发的完整范式:
- 组件拆分遵循单一职责:App管理状态,三个子组件各司其职
- 状态管理遵循不可变更新:不修改原数组/对象,每次创建新引用
- 数据流遵循单向传递:props向下,回调向上
- 副作用集中在useEffect中处理:localStorage持久化、定时器管理等
- CSS变量实现主题切换,与组件逻辑解耦
当你从Todo应用跃迁到真实业务项目时,这些原则不会改变------只是状态结构更复杂、组件树更深、副作用更多样。但底层的"数据驱动视图"模型始终如一:你只负责描述"状态应该是什么样",React负责把状态渲染成DOM。
理解了这个模型,你就理解了React设计的核心哲学:UI = f(state),一个纯函数,输入状态,输出界面。剩下的所有API------useState、useEffect、props、context------都是为了让这个函数更强大、更灵活而存在的辅助工具。