从 0 搭建 React 待办应用:状态管理、副作用与双向绑定模拟

React 作为前端主流框架,其单向数据流 组件化 状态驱动视图的设计理念,看似抽象却能通过一个简单的 TodoList 案例彻底吃透。本文不只是 "解释代码",而是从设计初衷、底层逻辑、实际价值 三个维度,拆解 useState useEffect、受控组件模拟双向绑定、父子通信等核心知识点,让你不仅 "会用",更 "懂为什么这么用"。

一、案例整体架构:先懂 "拆分逻辑",再看 "代码细节"

在动手写代码前,React 开发的第一步是组件拆分------ 遵循单一职责原则,把复杂页面拆成独立、可复用的小组件,这是 React 组件化思想的核心。

本次 TodoList 的组件拆分如下:

组件名 核心职责 核心交互
App(根组件) 全局状态管理 + 核心逻辑封装 定义新增 / 删除 / 切换待办、数据持久化等方法
TodoInput 待办输入 + 提交 收集用户输入,触发 "新增待办" 逻辑
TodoList 待办列表渲染 展示待办项,转发 "删除 / 切换完成状态" 事件
TodoStats 待办数据统计 展示总数 / 已完成 / 未完成数,触发 "清除已完成" 逻辑

这种拆分的核心价值:每个组件只做一件事,便于维护、复用和调试(比如后续想改输入框样式,只动 TodoInput 即可,不影响列表和统计逻辑)。

二、核心 API 深度拆解:不止 "会用",更懂 "为什么这么设计"

1. useState:React 状态管理的 "灵魂"

React 中所有可变数据 都必须通过**状态(State)**管理,而 useState 是最基础、最核心的状态钩子 ------ 它解决了 "函数组件无法拥有自身状态" 的问题,也是 "状态驱动视图" 的核心载体。

(1)基础原理:为什么需要 useState?

纯函数组件本身是 "无状态" 的(执行完就销毁,无法保存数据),而用户交互(比如输入待办、切换完成状态)必然需要 "保存可变数据"。useState 本质是给函数组件提供了持久化的状态存储空间,且这个存储空间和组件渲染周期绑定:

  • 状态更新 → 组件重新渲染 → 视图同步更新;
  • 状态不更新 → 组件不会重复渲染,保证性能。

(2)两种初始化方式:普通初始化 vs 惰性初始化

jsx 复制代码
// 方式1:普通初始化(适合简单、无计算的初始值)
const [count, setCount] = useState(0);

// 方式2:惰性初始化(重点! TodoList 中用的就是这种)
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

关键区别与设计初衷

  • 普通初始化:useState(初始值) 中,初始值表达式会在组件每次渲染时都执行(哪怕状态没变化);
  • 惰性初始化:useState(() => { ... }) 中,传入的函数仅在**组件首次渲染*时执行一次,后续渲染不会再跑。

TodoList 中用惰性初始化的核心原因:localStorage.getItem('todos') 是浏览器本地读取操作,虽然开销小,但如果放在普通初始化里,每次组件渲染(比如新增 / 删除待办)都会重复读取本地存储,完全没必要;而惰性初始化只执行一次,既拿到了初始数据,又避免了性能浪费 ------ 这是 React 性能优化的 "小细节",也是理解 useState 设计的关键。

(3)状态更新的 "不可变原则":为什么必须返回新值?

React 规定:状态是只读的,修改状态必须返回新值,不能直接修改原状态。比如这里的 "新增待办" 逻辑:

jsx 复制代码
const addTodo = (text) => {
  // 错误写法:直接修改原数组(React 无法检测到状态变化,视图不更新)
  // todos.push({ id: Date.now(), text, completed: false });
  // setTodos(todos);

  // 正确写法:解构原数组 + 新增项,返回新数组
  setTodos([...todos, {
    id: Date.now(),
    text,
    completed: false
  }]);
};

底层逻辑 :React 判断状态是否变化的依据是引用是否改变 。数组 / 对象是引用类型,直接修改原数组(todos.push),数组的引用没变化,React 会认为 "状态没改",因此不会触发组件重新渲染;而通过 [...todos] 解构生成新数组,引用变了,React 才能检测到状态变化,进而更新视图。

这也是 React "单向数据流" 的核心体现:状态更新是 "不可变" 的,每一次状态变化都会生成新值,便于追踪数据流转(比如调试时能清晰看到每次状态更新的前后值)。

2. useEffect:副作用处理的 "专属管家"

React 组件的核心职责是根据状态渲染视图 ,而像 "读取本地存储、发送网络请求、绑定事件监听、修改 DOM" 这类不直接参与渲染,但又必须执行的操作,统称为 "副作用(Side Effect)"。useEffect 是 React 专门为处理副作用设计的钩子,替代了类组件中 componentDidMount componentDidUpdate componentWillUnmount 等生命周期方法,且逻辑更集中。

(1)核心语法与执行机制

jsx 复制代码
useEffect(() => {
  // 副作用逻辑:比如保存数据到本地存储
  localStorage.setItem('todos', JSON.stringify(todos));

  // 可选的清理函数(比如取消事件监听、清除定时器)
  return () => {
    // 组件卸载/依赖变化前执行
  };
}, [todos]); // 依赖数组:决定副作用的执行时机

执行时机的深度解析

  • 依赖数组为空 []:仅在组件首次渲染完成后 执行一次(对应类组件 componentDidMount);
  • 依赖数组有值 [todos]:组件首次渲染执行 + 每次依赖项(todos)变化后执行(对应 componentDidMount + componentDidUpdate);
  • 无依赖数组:组件每次渲染完成后都执行(极少用,易导致性能问题);
  • 清理函数:组件卸载前 / 下一次副作用执行前触发(比如监听窗口大小变化后,卸载组件时要取消监听,避免内存泄漏)。

(2)在 TodoList 中的核心应用:数据持久化

代码中,useEffect 用来将 todos 同步到 localStorage,这是前端 "数据持久化" 的经典场景,我们拆解其价值:

jsx 复制代码
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
  • 为什么 localStorage 只能存字符串? localStorage 是浏览器提供的本地存储 API,其底层设计只支持字符串键值对 存储,因此存储数组 / 对象时,必须用 JSON.stringify 转为字符串;读取时用 JSON.parse 转回原数据类型,这是前端本地存储的通用规则。

(3)useEffect 在这里的核心价值(为什么非它不可)

1. 精准触发:只在需要时执行,保证性能

useEffect 的第二个参数(依赖数组 [todos])是关键:

  • 组件首次渲染时,执行一次(把初始的 todos 保存到本地);
  • 只有 todos 发生实际变化 时,才会再次执行(新增 / 删除 / 切换状态 / 清除已完成,只要 todos 变了,就同步保存);
  • todos 没变化时(比如组件因其他状态重新渲染),完全不执行,避免无效操作。

对比 "写在组件顶层" 的无差别执行,useEffect 实现了 "按需执行",既保证数据同步,又不浪费性能。

2. 时机正确:拿到最新的状态,避免数据不一致

useEffect 的执行时机是「组件渲染完成后」------ 也就是说,当 useEffect 里的代码执行时,setTodos 已经完成了状态更新,todos 一定是最新的。

比如新增待办时:

  1. 调用 addTodo → 执行 setTodos → 组件重新渲染(todos 变为新值);
  2. 渲染完成后,useEffect 检测到 todos 变化 → 执行保存逻辑 → 拿到的是最新的 todos

这就避免了 "异步更新导致保存旧值" 的问题,保证本地存储的数据和组件状态完全一致。

3. 逻辑聚合:一处监听,全场景生效

不管是新增、删除、切换状态、清除已完成,只要最终导致 todos 变化,useEffect 都会自动触发保存 ------ 无需在每个修改 todos 的函数里重复写保存逻辑,代码简洁、易维护,后续新增修改 todos 的逻辑(比如批量修改),完全不用动保存代码,天然符合 "开闭原则"。

(4)useEffect 的设计价值:分离 "渲染逻辑" 与 "副作用逻辑"

React 追求 "组件核心逻辑纯净"------ 组件顶层只关注 "根据状态渲染什么",副作用全部交给 useEffect 处理,这样:

  • 代码结构更清晰:渲染和副作用分离,一眼能区分 "视图相关" 和 "非视图相关" 逻辑;
  • 便于调试:副作用的执行时机由依赖数组明确控制,能精准定位 "什么时候执行、为什么执行";
  • 避免内存泄漏:通过清理函数可优雅处理 "组件卸载后仍执行副作用" 的问题(比如请求数据时组件卸载了,清理函数可取消请求)。

3. 受控组件:模拟双向绑定的底层逻辑

Vue 中用 v-model 就能实现 "表单值 ↔ 数据" 的双向绑定,但 React 没有内置的双向绑定语法 ------ 不是 "做不到",而是 React 坚持单向数据流,通过 "受控组件" 手动模拟双向绑定,虽然代码多了几行,但能完全掌控数据流转。

(1)双向绑定的本质:视图 ↔ 数据同步

不管是 Vue 的 v-model 还是 React 的受控组件,双向绑定的核心是两件事:

  1. 数据 → 视图:数据(状态)变化,视图(输入框)自动更新;
  2. 视图 → 数据:视图(用户输入)变化,数据(状态)自动更新。

(2)React 受控组件的实现:拆解每一步

以 TodoInput 组件为例,逐行解析双向绑定的实现逻辑:

jsx 复制代码
const TodoInput = ({ onAdd }) => {
  // 步骤1:定义状态存储输入框值(数据层)
  const [inputValue, setInputValue] = useState('');

  // 步骤2:处理表单提交
  const handleSubmit = (e) => {
    // 关键:阻止表单默认提交行为
    e.preventDefault();
    // 输入内容校验:去除首尾空格,避免空提交
    const text = inputValue.trim();
    if (!text) return;
    // 步骤3:将输入内容传给父组件(父子通信)
    onAdd(text);
    // 步骤4:清空输入框(修改状态 → 视图清空)
    setInputValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        // 核心1:数据 → 视图(状态控制输入框显示)
        value={inputValue}
        // 核心2:视图 → 数据(输入变化同步更新状态)
        onChange={e => setInputValue(e.target.value)}
        placeholder="请输入待办事项..."
      />
      <button type="submit">Add</button>
    </form>
  );
};

逐点深度解析

  • 数据 → 视图value={inputValue} 是 "单向绑定" 的核心 ------ 输入框显示的内容完全由 inputValue 状态决定,而非 DOM 自身的 value。比如执行 setInputValue('')inputValue 变为空,输入框就会立刻清空,这是 "状态驱动视图" 的体现。
  • 视图 → 数据onChange 事件监听输入框的每一次字符变化,e.target.value 是输入框当前的 DOM 取值,通过 setInputValue 将其同步到 inputValue 状态 ------ 这一步是 "手动补全" 双向绑定的反向流程,也是 React 与 Vue 的核心区别(Vue 把这一步封装成了 v-model,React 让开发者手动控制,更灵活)。
  • e.preventDefault() :表单的默认行为是 "提交并刷新页面",而 React 是单页应用,刷新页面会导致所有状态丢失,因此必须阻止这个默认行为 ------ 这是前端开发的通用知识点,也是 React 处理表单的 "必做步骤"。
  • 为什么用 form + onSubmit 而非 button + onClick 除了点击按钮提交,用户在输入框按回车键也能触发 onSubmit,而单纯的 onClick 无法响应回车提交,这是语义化 + 用户体验的双重考量。

(3)受控组件的核心优势:完全可控

相比 Vue 的 v-model 黑盒封装,React 受控组件的 "手动操作" 带来了两个核心价值:

  • 可校验性 :在 onChangehandleSubmit 中可随时对输入内容做校验(比如禁止输入特殊字符、限制长度、去除空格),比如在代码中 inputValue.trim() 就是简单的校验,若需要更复杂的校验(比如手机号格式),可直接在这一步处理;
  • 可追溯性 :输入框的每一次值变化都必须通过 setInputValue 触发,在调试工具中能清晰看到 inputValue 的每一次更新记录,便于定位 "输入异常" 问题(比如输入框值不变,可直接查 setInputValue 是否执行)。

4. 父子组件通信:单向数据流的极致体现

React 的 "单向数据流" 不是 "限制",而是 "保障"------ 数据只能从父组件通过 props 流向子组件,子组件不能直接修改父组件的状态,只能通过父组件传递的回调函数 "通知" 父组件修改状态。这种设计让数据流转路径清晰,避免了 "多个组件随意修改数据导致的混乱"。

(1)通信流程:以 "清除已完成任务" 为例

  1. 父组件(App) :定义状态修改逻辑 + 传递回调函数
jsx 复制代码
// 步骤1:父组件定义修改状态的核心逻辑
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed));
};

// 步骤2:通过 props 将回调函数传递给子组件
<TodoStats 
  total={todos.length}
  completed={completedCount}
  active={activeCount}
  onClearCompleted={clearCompleted} // 传递回调
/>
  1. 子组件(TodoStats) :接收回调函数 + 触发回调
jsx 复制代码
const TodoStats = ({ total, completed, active, onClearCompleted }) => {
  return (
    <div>
      <p>Total: {total}</p>
      <p>Completed: {completed}</p>
      <p>Active: {active}</p>
      {/* 条件渲染:有已完成任务才显示按钮 */}
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          清除已完成任务
        </button>
      )}
    </div>
  );
};

深度解析

  • 子组件 TodoStats 只负责 "展示数据 + 触发交互",不关心 "清除已完成任务" 的具体逻辑 ------ 哪怕后续修改清除逻辑(比如加确认弹窗),只需改父组件的 clearCompleted,子组件完全不用动,符合 "开闭原则"。
  • 回调函数是 "子组件通知父组件" 的唯一方式:子组件无法直接访问父组件的 todos 状态,也不能直接调用 setTodos,只能通过父组件传递的 onClearCompleted 回调,触发父组件的状态修改逻辑 ------ 这就是 "单向数据流":数据向下传(父→子),事件向上传(子→父),所有状态修改都集中在父组件,便于追踪和调试。

(2)props 的本质:只读的 "数据桥梁" (后面会单独来讲)

props 是父子组件通信的唯一桥梁,但有一个核心规则:子组件不能修改 props 。比如 TodoStats 接收的 completed total 等 props,子组件只能读取,不能修改 ------ 因为 props 是父组件状态的 "快照",修改 props 会导致数据源头混乱(比如子组件改了 completed,父组件的 completedCount 却没变化,数据不一致)。

三、核心设计思想:从 TodoList 看 React 的底层逻辑

通过这个 TodoList 案例,我们能提炼出 React 最核心的 4 个设计思想,这也是理解 React 的关键:

1. 状态驱动视图

React 中 "视图是什么样" 完全由 "状态是什么样" 决定,没有 "手动操作 DOM" 的场景(比如不用 document.getElementById 改输入框值,不用 appendChild 加待办项)。所有视图变化,都是先修改状态,再由 React 自动更新 DOM------ 这避免了手动操作 DOM 的繁琐和易出错,也让代码更易维护(只需关注状态变化,不用关注 DOM 变化)。

2. 单向数据流

数据只有一个流向:父组件 → 子组件,状态只有一个修改入口:定义状态的组件(比如 todos 定义在 App,只有 App 能改,子组件只能通过回调通知 App 改)。这种设计让数据流转 "可预测"------ 不管项目多复杂,都能顺着 props 找到数据的源头,顺着回调找到状态修改的地方。

3. 组件化与单一职责

每个组件只做一件事:TodoInput 只处理输入,TodoList 只渲染列表,TodoStats 只展示统计。这种拆分让组件 "高内聚、低耦合":

  • 高内聚:组件内部逻辑围绕核心职责展开,不掺杂其他功能;
  • 低耦合:组件之间通过 props 通信,修改一个组件不会影响其他组件。

4. 副作用与渲染分离

useEffect 将 "副作用逻辑"(比如本地存储)与 "渲染逻辑"(比如展示待办列表)分离,让组件的核心逻辑(根据状态渲染视图)保持 "纯净"------ 纯净的组件逻辑更易测试、更易复用,这也是 React 推崇的 "函数式编程" 思想的体现。

四、总结:从 TodoList 到 React 核心能力

这个看似简单的 TodoList,实则涵盖了 React 日常开发的核心知识点:

  • useState 实现状态管理,理解 "不可变更新" 和 "惰性初始化";
  • useEffect 处理副作用,理解 "依赖数组" 和 "数据持久化";
  • 受控组件模拟双向绑定,理解 "状态驱动视图" 和 "单向数据流";
  • 父子组件通信,理解 props 的 "只读特性" 和回调函数的作用。
相关推荐
林太白2 小时前
vue3这些常见指令你封装了吗
前端·javascript
独自归家的兔2 小时前
面试实录:三大核心问题深度拆解(三级缓存 + 工程规范 + 逻辑思维)
java·后端·面试·职场和发展
傻啦嘿哟2 小时前
Python在Excel中创建与优化数据透视表的完整指南
java·前端·spring
白露与泡影2 小时前
春招 Java 面试大纲:Java+ 并发 +spring+ 数据库 +Redis+JVM+Netty 等
java·数据库·面试
拜晨2 小时前
用流式 JSON 解析让 AI 产品交互提前
前端·javascript
浩男孩2 小时前
🍀vue3 + Typescript +Tdesign + HiPrint 打印下载解决方案
前端
andwhataboutit?2 小时前
LANGGRAPH
java·服务器·前端
无限大62 小时前
为什么"Web3"是下一代互联网?——从中心化到去中心化的转变
前端·后端·程序员
cypking2 小时前
CSS 常用特效汇总
前端·css