从本地存储到副作用,一套代码吃透 useState 和 useEffect

47 阅读9分钟

一、先看现象:这个 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?

想象一个场景:用户点击“添加待办”,你需要:

  1. 更新 UI 列表
  2. 保存到 localStorage
  3. 更新页面标题
  4. 发送埋点数据

如果用 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 直接读取链表中的值,完全跳过初始化函数。

image.png

重难点 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 和调度。这大大降低了心智负担。

image.png

形态三:每次更新都执行(不传依赖数组)

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 个事实:

  1. 容量限制:通常 5MB,不同浏览器略有差异
  2. 同步阻塞:所有操作都是同步的,大数据会卡顿
  3. 只能存字符串:存对象必须 JSON.stringify,取出来必须 JSON.parse
  4. 同源策略:同协议、同域名、同端口才能共享
  5. 永久存储:除非手动清除,否则不会过期

四、避坑指南:新手最常踩的 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 有什么区别?

答:

特性useEffectuseLayoutEffect
执行时机浏览器绘制后异步执行浏览器绘制前同步执行
是否阻塞渲染否是
使用场景大部分场景,如数据获取、订阅需要同步读取 DOM 布局、动画
性能影响较小较大,使用不当会卡顿

优先使用 useEffect,除非你遇到了 DOM 闪烁问题。

Q3:为什么 React 要设计 Hooks?

答:三个核心原因:

  1. 逻辑复用:Class 组件复用逻辑需要 HOC 或 Render Props,导致“嵌套地狱”。Hooks 可以自定义,优雅复用。
  2. 关注点分离:Class 组件将相关逻辑分散在多个生命周期中。Hooks 按功能组织代码,一个 effect 负责一件事。
  3. 简化心智模型:函数组件 + Hooks 比 Class 组件更接近 React 的设计理念(UI = f(state)),上手难度更低。

六、最佳实践总结

基于 10 年经验和这份代码的启示,我给出几条铁律:

✅ DO(应该做的)

  1. 所有状态都用 useState 管理,不要直接操作 DOM
  2. 副作用全部放在 useEffect,保持渲染函数纯净
  3. 依赖数组要完整,用 ESLint 规则自动检查
  4. 惰性初始化:有开销的初始值用函数形式
  5. 清理副作用:订阅、定时器、事件监听都要清理

❌ DON'T(不该做的)

  1. 不要在循环、条件、嵌套函数中调用 Hooks
  2. 不要直接修改状态(引用类型)
  3. 不要在 useEffect 中遗漏依赖(闭包陷阱)
  4. 不要过度使用 useEffect,能用计算属性解决的别用副作用

七、完整代码流程图

用一张图总结整个应用的执行流程:

image.png