一、先看现象:这个 Todo 应用到底在做什么?
先别急着看代码,我们用一个生活化的比喻来理解这个应用的设计初衷。
把 React 组件想象成一个餐厅后厨:
useState= 食材储存架。厨师(组件)需要随时知道还有多少番茄(状态),用完要更新库存。useEffect= 排烟系统。每次炒菜(状态更新)后自动开启,厨师不需要手动按开关——这就是“副作用”的自动触发机制。localStorage= 后厨的保鲜冰箱。打烊后(页面刷新)食材还在,明天开业能继续用。
这个 Todo 应用的核心流程就是:用户增删改待办事项 → 状态自动更新 → 副作用自动把数据存进冰箱 → 下次打开页面数据复原。
二、痛点直击:为什么我们需要这些技术?
2.1 为什么需要 useState?
在 Class 组件时代,状态管理依赖 this.state 和 this.setState,代码分散在多个生命周期函数中,逻辑复用极其困难。
javascript
// Class 组件的痛点:逻辑分散
class TodoApp extends React.Component {
state = { todos: [] };
componentDidMount() {
// 加载数据
const saved = localStorage.getItem('todos');
this.setState({ todos: JSON.parse(saved) });
}
componentDidUpdate(prevProps, prevState) {
// 保存数据
if (prevState.todos !== this.state.todos) {
localStorage.setItem('todos', JSON.stringify(this.state.todos));
}
}
// ... 更多方法
}
问题:加载和保存的逻辑被割裂在两个生命周期里,而且 componentDidUpdate 中需要手动比较状态是否变化,非常容易出错。
useState 的解决方案:将状态声明、初始化和更新逻辑集中在函数组件顶部,配合 useEffect 实现关注点分离。
2.2 为什么需要 useEffect?
想象一个场景:用户点击“添加待办”,你需要:
- 更新 UI 列表
- 保存到 localStorage
- 更新页面标题
- 发送埋点数据
如果用 Class 组件,这些逻辑会散落在 componentDidMount、componentDidUpdate、componentWillUnmount 三个地方。
useEffect 的威力:把“和渲染无关的操作”统一管理,并且可以通过依赖数组精准控制执行时机。
三、重难点深度剖析(核心章节)
重难点 1:useState 的“惰性初始化”到底是什么?
观察这段代码:
javascript
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos')) || [];
});
很多新手会写成这样:
javascript
// ❌ 错误示范:每次渲染都执行 localStorage 读取
const [todos, setTodos] = useState(JSON.parse(localStorage.getItem('todos')) || []);
两者的本质区别:
| 写法 | 执行时机 | 性能影响 |
|---|---|---|
useState(直接传值) | 每次渲染都会执行初始化表达式 | 每次都读 localStorage,浪费性能 |
useState(() => 函数) | 仅首次渲染执行一次 | 函数只执行一次,性能最优 |
为什么要设计成“函数形式”?
React 团队在设计 useState 时,考虑到初始化逻辑可能涉及复杂计算(比如这里读取 localStorage、或者对大数组进行排序、或者执行加密解密)。如果每次渲染都重新执行这些操作,会严重影响性能。
攻克思路:记住一个口诀—— “初始化有开销,惰性函数来帮忙” 。但凡初始值需要通过计算获得,都用函数形式。
底层原理:React 内部维护了一个 “记忆化链表” (memoizedState)。首次渲染时,React 执行初始化函数并将结果存入链表;后续渲染时,React 直接读取链表中的值,完全跳过初始化函数。
重难点 2:useEffect 的四种形态,你真的用对了吗?
原代码中出现了四种不同的 useEffect 写法,我们逐一解剖:
形态一:挂载后执行一次(空依赖数组)
javascript
useEffect(() => {
console.log('挂载后执行二')
}, []); // 依赖项数组为空
执行时机:组件首次渲染到 DOM 后执行一次,之后永不执行。
典型场景:订阅外部数据源、设置定时器、发送埋点 PV。
面试官最爱问:[] 和 不传第二个参数 的区别是什么?
答:[] 表示“没有依赖”,只在挂载后执行一次;不传第二个参数表示“依赖所有状态”,每次渲染都执行。
形态二:依赖特定状态(传入依赖数组)
javascript
useEffect(() => {
console.log('挂载后执行三')
console.log('todos改变执行');
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
执行时机:挂载后执行一次 + 每次 todos 变化后执行。
这是最常用的形态,完美解决了 Class 组件中需要手动比较 prevState 的痛点。
设计者为什么这么写?
这是 “响应式编程” 思想的体现。开发者只需要声明“当 todos 变化时,执行这个副作用”,React 自动帮你做 diff 和调度。这大大降低了心智负担。
形态三:每次更新都执行(不传依赖数组)
javascript
useEffect(() => {
console.log('挂载后执行');
console.log('每次更新都执行');
});
执行时机:挂载后执行 + 每次任何状态更新后都执行。
⚠️ 高危警告:这种写法极易造成 无限循环。如果你在里面又调用了 setState,就会进入“渲染 → 副作用 → setState → 重新渲染 → 副作用 → ...”,直到浏览器崩溃。
形态四:带清理函数的副作用(代码中未展示,但必须掌握)
虽然原代码没有,但这是面试必考点:
javascript
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
// 清理函数:组件卸载时执行
return () => {
clearInterval(timer);
console.log('清理定时器,防止内存泄漏');
};
}, []);
为什么需要清理? 防止内存泄漏、取消订阅、清除定时器。这是 React 函数组件替代 Class 组件 componentWillUnmount 的关键设计。
重难点 3:localStorage 的“同步陷阱”
观察这段看似正常的代码:
javascript
// 保存数据
localStorage.setItem('todos', JSON.stringify(todos));
问题:localStorage.setItem 是同步阻塞操作,虽然大部分情况下很快,但如果数据量很大(比如成千上万条待办),会阻塞主线程,导致页面卡顿。
更严重的问题:JSON.stringify 在序列化大对象时同样会阻塞主线程。
设计者为什么这么写?
这个案例中,由于 Todo 数据量通常很小,使用同步操作简单直接,代码可读性更高。这是一种 “工程权衡” ——在性能影响可控的前提下,优先保证代码的简洁性。
如果数据量变大怎么办?
javascript
// 最佳实践:使用 requestIdleCallback 在浏览器空闲时保存
useEffect(() => {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
localStorage.setItem('todos', JSON.stringify(todos));
});
} else {
// 降级方案:用 setTimeout
setTimeout(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, 0);
}
}, [todos]);
关于 localStorage 你必须知道的 5 个事实:
- 容量限制:通常 5MB,不同浏览器略有差异
- 同步阻塞:所有操作都是同步的,大数据会卡顿
- 只能存字符串:存对象必须
JSON.stringify,取出来必须JSON.parse - 同源策略:同协议、同域名、同端口才能共享
- 永久存储:除非手动清除,否则不会过期
四、避坑指南:新手最常踩的 4 个坑
坑 1:直接修改状态(引用类型大忌)
javascript
// ❌ 错误:直接修改原数组
const addTodo = (text) => {
todos.push({ id: Date.now(), text, completed: false });
setTodos(todos); // React 检测不到变化,不会重新渲染
}
// ✅ 正确:创建新数组
const addTodo = (text) => {
setTodos([
{ id: Date.now(), text, completed: false },
...todos
]);
}
原理:React 使用 Object.is 进行浅比较,如果引用没变,就认为状态没变。
坑 2:在 useEffect 中遗漏依赖
javascript
// ❌ 错误:使用了 count 但没声明依赖
useEffect(() => {
console.log(count); // 这里用了 count
}, []); // 但依赖数组是空的!
// ✅ 正确:把用到的变量都加入依赖
useEffect(() => {
console.log(count);
}, [count]);
原理:React 依赖 “闭包陷阱” 。不声明依赖,effect 函数会捕获首次渲染时的旧值,导致“过期闭包”问题。
ESLint 救你一命:使用 eslint-plugin-react-hooks 的 exhaustive-deps 规则,它会自动检测缺失的依赖。
坑 3:在条件语句中使用 Hooks
javascript
// ❌ 错误:在 if 中使用 Hook
if (condition) {
useEffect(() => { ... }, []); // 破坏 Hook 调用顺序
}
// ✅ 正确:Hooks 必须在顶层调用
useEffect(() => {
if (condition) {
// 逻辑放在里面
}
}, [condition]);
原理:React 依赖 Hooks 的调用顺序来关联状态。在条件中使用会打乱顺序,导致状态错乱。
坑 4:忘记清理副作用
javascript
// ❌ 错误:添加了事件监听但没有清理
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []); // 组件卸载后监听还在,造成内存泄漏
// ✅ 正确:返回清理函数
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
五、面试高频考点(建议背诵)
Q1:useState 的 setter 是异步还是同步?为什么?
答:异步更新。React 会批量处理状态更新以优化性能。在事件处理函数中多次调用 setState,React 会合并成一次更新。
javascript
// 示例:多次 setState 会被批量处理
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 最终 count 只增加 1,不是 3!
}
// 解决方案:使用函数式更新
const handleClick = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// 最终 count 增加 3
}
Q2:useEffect 和 useLayoutEffect 有什么区别?
答:
| 特性 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制后异步执行 | 浏览器绘制前同步执行 |
| 是否阻塞渲染 | 否 | 是 |
| 使用场景 | 大部分场景,如数据获取、订阅 | 需要同步读取 DOM 布局、动画 |
| 性能影响 | 较小 | 较大,使用不当会卡顿 |
优先使用 useEffect,除非你遇到了 DOM 闪烁问题。
Q3:为什么 React 要设计 Hooks?
答:三个核心原因:
- 逻辑复用:Class 组件复用逻辑需要 HOC 或 Render Props,导致“嵌套地狱”。Hooks 可以自定义,优雅复用。
- 关注点分离:Class 组件将相关逻辑分散在多个生命周期中。Hooks 按功能组织代码,一个 effect 负责一件事。
- 简化心智模型:函数组件 + Hooks 比 Class 组件更接近 React 的设计理念(UI = f(state)),上手难度更低。
六、最佳实践总结
基于 10 年经验和这份代码的启示,我给出几条铁律:
✅ DO(应该做的)
- 所有状态都用
useState管理,不要直接操作 DOM - 副作用全部放在
useEffect,保持渲染函数纯净 - 依赖数组要完整,用 ESLint 规则自动检查
- 惰性初始化:有开销的初始值用函数形式
- 清理副作用:订阅、定时器、事件监听都要清理
❌ DON'T(不该做的)
- 不要在循环、条件、嵌套函数中调用 Hooks
- 不要直接修改状态(引用类型)
- 不要在
useEffect中遗漏依赖(闭包陷阱) - 不要过度使用
useEffect,能用计算属性解决的别用副作用
七、完整代码流程图
用一张图总结整个应用的执行流程: