⚛️ React 19 组件化实战 —— 从零搭建 Todo List 并吃透组件通信

⚛️ React 19 组件化实战 ------ 从零搭建 Todo List 并吃透组件通信

前言 :上篇文章我们通过 state-demo 用户列表案例深入剖析了 useState 的核心用法。本篇文章将更进一步,带你从零搭建一个完整的 Todo List 多组件应用,彻底搞懂 React 组件化开发中最关键的几个概念:组件拆分、Props 传递、状态提升、不可变数据更新、表单处理、条件渲染 。同时我们还会通过一段原生 JS 的 DocumentFragment 代码,帮你建立对 React 批处理底层原理的直觉。

全文 9000+ 字,建议收藏后在电脑上边读边敲。


📦 一、项目总览:从单组件到多组件架构

1.1 与上一篇文章的承接

上一篇文章的 state-demo 项目,所有逻辑都塞在一个 App.jsx 里。这在学习阶段没问题,但真实项目中,动辄几百行的"巨石组件"是维护噩梦。todos 项目展示了正确的组件拆分方式:

css 复制代码
state-demo(上篇)                    todos(本篇)
─────────────────────         ───────────────────────────
src/                          src/
├── main.jsx                  ├── main.jsx
├── App.jsx  ← 所有逻辑在这里   ├── App.jsx        ← 状态管理中心
├── App.css                   ├── App.css
└── index.css                 ├── index.css
                              └── components/      ← 关注点分离
                                  ├── TodoInput.jsx
                                  ├── TodoList.jsx
                                  └── TodoStats.jsx

1.2 组件树拓扑图

graph TD App[&#34;App<br/>📦 状态持有者<br/>todos / addTodo / toggleTodo / deleteTodo / clearCompleted&#34;] App -->|&#34;onAdd&#34;| TodoInput[&#34;TodoInput<br/>📝 表单输入<br/>本地状态: inputValue&#34;] App -->|&#34;todos, onToggle, onDelete&#34;| TodoList[&#34;TodoList<br/>📋 列表渲染<br/>纯展示组件&#34;] App -->|&#34;total, active, completed, onClearCompleted&#34;| TodoStats[&#34;TodoStats<br/>📊 统计面板<br/>纯展示组件&#34;]

🎯 核心规律 :状态(State)只存在于 App ,通过 Props 向下流动;子组件通过回调函数向上报告事件。这就是 React 的单向数据流


🏗️ 二、App.jsx 深度剖析 ------ 状态管理中心

App.jsx 是整个应用的大脑,承载了所有业务状态和操作逻辑。

2.1 完整代码

jsx 复制代码
import { useState } from 'react';
import TodoInput from './components/TodoInput.jsx';
import TodoList from './components/TodoList.jsx';
import TodoStats from './components/TodoStats.jsx';
import './App.css'

const App = () => {
    const [todos, setTodos] = useState(() => [
        { id: 1, text: '学习React', completed: false },
        { id: 2, text: '睡觉', completed: false },
        { id: 3, text: '吃饭', completed: true },
    ]);

    const addTodo = (text) => {
        if (text.trim() === '') return;
        setTodos([
            { id: +Date.now(), text, completed: false },
            ...todos,
        ]);
    };

    const toggleTodo = (id) => {
        setTodos(todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        ));
    };

    const deleteTodo = (id) => {
        setTodos(todos.filter(todo => todo.id !== id));
    };

    const clearCompleted = () => {
        setTodos(todos.filter(todo => !todo.completed));
    };

    const activeCount = todos.filter(todo => !todo.completed).length;
    const completedCount = todos.filter(todo => todo.completed).length;

    return (
        <div>
            <h1>My Todo List</h1>
            <TodoInput onAdd={addTodo} />
            <TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
            <TodoStats
                total={todos.length}
                active={activeCount}
                completed={completedCount}
                onClearCompleted={clearCompleted}
            />
        </div>
    );
};
export default App;

2.2 两种组件定义方式:Function Declaration vs Arrow Function

jsx 复制代码
// 上篇 state-demo:函数声明
function App() { ... }

// 本篇 todos:箭头函数
const App = () => { ... };
方式 代码 特点
函数声明 function App() {} 支持 hoisting(提升),可以在定义前引用
箭头函数 const App = () => {} 无自己的 this,更短,不可提升

💡 社区趋势:新项目越来越多使用箭头函数定义组件,因为它与 const 配合更符合"组件是不可变引用"的语义。

2.3 初始状态设计:用真实数据模拟业务

jsx 复制代码
const [todos, setTodos] = useState(() => [
    { id: 1, text: '学习React', completed: false },
    { id: 2, text: '睡觉', completed: false },
    { id: 3, text: '吃饭', completed: true },
]);

这里的三个设计要点:

字段 类型 说明
id number 唯一标识,用于 key 和定位操作目标
text string 待办事项内容
completed boolean 完成状态,驱动 UI 样式和统计计算

🔑 关键设计todos 是一个对象数组 而不是简单字符串数组。这为后续的 toggle(切换完成状态)和 delete(删除)操作提供了必要的数据支撑。


🧩 三、不可变状态更新 ------ React 的核心哲学

⚠️ 最重要的原则 :React 中永远不要直接修改 state !必须返回一个全新的对象或数组。

3.1 addTodo:数组头部插入(Spread 运算符)

jsx 复制代码
const addTodo = (text) => {
    if (text.trim() === '') return;  // 空白字符守卫
    setTodos([
        { id: +Date.now(), text, completed: false },  // 新元素在前
        ...todos,                                      // 展开旧数组在后
    ]);
};

逐步拆解:

vbnet 复制代码
初始状态:[{id:1}, {id:2}, {id:3}]

调用 addTodo("新任务"):

Step 1: +Date.now() → 1690000000000(转数字的毫秒时间戳)
Step 2: 创建新对象 { id: 1690000000000, text: "新任务", completed: false }
Step 3: [...todos] 展开旧数组
Step 4: 拼成新数组 [{新}, {旧1}, {旧2}, {旧3}]
Step 5: setTodos(新数组) → React 检测到引用变化 → 重新渲染 ✅
flowchart LR subgraph old[&#34;旧数组(不可变)&#34;] A[&#34;{id:1}&#34;] --- B[&#34;{id:2}&#34;] --- C[&#34;{id:3}&#34;] end subgraph new[&#34;新数组(全新引用)&#34;] D[&#34;{id: Date.now()}&#34;] --- E[&#34;{id:1}&#34;] --- F[&#34;{id:2}&#34;] --- G[&#34;{id:3}&#34;] end old -->|&#34;setTodos([新, ...旧])&#34;| new

技巧点:

  • +Date.now()Date.now() 返回数字类型的毫秒时间戳,前面的 + 是冗余但显式的转数字操作,保证 ID 的唯一性(适合 Demo)
  • text.trim() === '':边界守卫防止添加空白 todo,体现了防御式编程

3.2 toggleTodo:数组中某一项的更新(map + Spread)

jsx 复制代码
const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
        todo.id === id
            ? { ...todo, completed: !todo.completed }   // 匹配:浅拷贝 + 翻转
            : todo                                       // 不匹配:原样保留
    ));
};

这是 React 中最经典的数组元素更新模式,逐层分析:

bash 复制代码
原数组:[{id:1, completed:false}, {id:2, completed:false}, {id:3, completed:true}]

调用 toggleTodo(2):

map 遍历:
  id=1: 不匹配 → 返回原对象(引用不变)
  id=2: 匹配!  → { ...todo, completed: !false } → { id:2, completed: true }(新对象!)
  id=3: 不匹配 → 返回原对象(引用不变)

结果:[原{id:1}, 新{id:2, completed:true}, 原{id:3}]
       ↑ 引用不变     ↑ 新引用                ↑ 引用不变
flowchart TB subgraph before[&#34;更新前&#34;] A1[&#34;{id:1, completed:false}&#34;] A2[&#34;{id:2, completed:false}&#34;] A3[&#34;{id:3, completed:true}&#34;] end subgraph after[&#34;更新后&#34;] B1[&#34;同 id:1(复用引用)&#34;] B2[&#34;{id:2, completed:true} ⬅️ 新对象&#34;] B3[&#34;同 id:3(复用引用)&#34;] end before -->|&#34;todos.map(...)&#34;| after

🎯 核心理解{ ...todo, completed: !todo.completed } 这行代码做的是浅拷贝 (Shallow Copy)------创建一个新对象,复制原对象的所有属性,然后覆盖 completed 字段。这种模式保证 React 能检测到引用变化,同时最小化不必要的对象创建。

3.3 deleteTodo:数组过滤(filter)

jsx 复制代码
const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
};

filter 天然返回新数组,是删除操作的最佳选择:

bash 复制代码
原数组:[{id:1}, {id:2}, {id:3}]
调用 deleteTodo(2):

filter 条件:todo.id !== 2
  {id:1} → true  ✅ 保留
  {id:2} → false ❌ 丢弃
  {id:3} → true  ✅ 保留

结果:[{id:1}, {id:3}]

常见陷阱对比:

jsx 复制代码
// ❌ 错误:splice 直接修改原数组
todos.splice(index, 1);
setTodos(todos);  // 引用没变,React 不更新!

// ❌ 错误:直接赋值修改属性
todos[0].completed = true;
setTodos(todos);  // 引用没变,React 不更新!

// ✅ 正确:filter 返回新数组
setTodos(todos.filter(t => t.id !== id));

3.4 clearCompleted:批量删除(filter)

jsx 复制代码
const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed));
};

3.5 不可变更新模式速查表

操作 模式 核心 API
新增 创建新数组,头/尾部插入 [...old, newItem][newItem, ...old]
修改某项 map + 浅拷贝覆盖 arr.map(item => match ? {...item, k: v} : item)
删除某项 过滤掉目标 arr.filter(item => item.id !== targetId)
批量删除 过滤满足条件的 arr.filter(item => !condition)
清空 直接设空数组 setArr([])

📝 四、TodoInput ------ 表单交互与父子通信

TodoInput.jsx 展示了 React 中表单处理的经典模式。

4.1 完整代码

jsx 复制代码
import { useState } from 'react';

const TodoInput = ({ onAdd }) => {
    const [inputValue, setInputValue] = useState('');

    const handleSubmit = (e) => {
        e.preventDefault();          // 阻止表单默认跳转行为
        onAdd(inputValue);           // 把数据"报告"给父组件
        setInputValue('');           // 清空输入框
    };

    return (
        <form className="todo-input" onSubmit={handleSubmit}>
            <input
                type="text"
                autoFocus                             // 自动聚焦
                placeholder="What needs to be done?"
                value={inputValue}                    // 受控组件
                onChange={(e) => setInputValue(e.target.value)}
            />
            <button type="submit">Add</button>
        </form>
    );
};

export default TodoInput;

4.2 父子通信模型:Props 下传,事件上报

sequenceDiagram participant App as 🧠 App(父) participant Input as 📝 TodoInput(子) App->>Input: onAdd={addTodo}(传递回调函数) Note over Input: 用户输入 &#34;学习React&#34; Input->>Input: setInputValue(&#34;学习React&#34;) Note over Input: 用户点击 Add 按钮 Input->>Input: e.preventDefault() Input->>App: onAdd(&#34;学习React&#34;) App->>App: setTodos([新todo, ...旧todos]) App->>Input: 重新渲染(Props 未变) Input->>Input: setInputValue(&#34;&#34;)(清空输入框)

关键洞察:

  • 父组件 App 不知道子组件的表单细节(用的是什么输入方式、如何提交)
  • 子组件 TodoInput 不知道父组件如何处理数据(存到哪里、怎么更新列表)
  • 它们之间唯一的约定是 onAdd(text) 这个接口

这就是 React 组件设计的精髓:高内聚,低耦合

4.3 受控组件模式详解

jsx 复制代码
<input
    value={inputValue}                              // ① 值由 React 状态控制
    onChange={(e) => setInputValue(e.target.value)} // ② 输入变化 → 更新状态 → 重新渲染 → 更新 value
/>

受控组件的闭环数据流:

css 复制代码
用户输入 "a"
    ↓
onChange 事件触发
    ↓
setInputValue("a")
    ↓
组件重新渲染
    ↓
<input value="a" />   ← 输入框显示 "a"

受控 vs 非受控:

受控组件 (Controlled) 非受控组件 (Uncontrolled)
数据源 React State DOM 自身
获取值 inputValue 变量 inputRef.current.value
适用场景 实时验证、条件禁用、动态格式化 简单表单、文件上传
代码量 略多 更少

4.4 e.preventDefault() 为什么必不可少?

jsx 复制代码
const handleSubmit = (e) => {
    e.preventDefault();  // 🔑 阻止 <form> 的默认提交行为
    // ...
};

<form onSubmit={...}> 中,如果没有 e.preventDefault()

  • 浏览器会执行默认的表单提交
  • 页面会刷新跳转action URL
  • 整个 React 应用被销毁重建
  • 所有状态丢失

记忆口诀 :React 中的 <form>e.preventDefault() 永远是第一行。

4.5 autoFocus 属性

jsx 复制代码
<input autoFocus />

页面加载后,该输入框自动获得焦点,键盘可直接输入------提升用户体验的小细节。


📋 五、TodoList ------ 条件渲染与列表渲染

TodoList.jsx 是最能体现 React 声明式编程思想的组件。

5.1 完整代码

jsx 复制代码
const TodoList = ({ todos, onToggle, onDelete }) => {
    return (
        <ul className="todo-list">
            {
                todos.length === 0 ? (
                    <li className="empty">No todos yet!</li>
                ) : (
                    todos.map(todo => (
                        <li key={todo.id}
                            className={todo.completed ? 'completed' : ''}>
                            <label>
                                <input type="checkbox"
                                    checked={todo.completed}
                                    onChange={() => onToggle(todo.id)}
                                />
                            </label>
                            <span>{todo.text}</span>
                            <button onClick={() => onDelete(todo.id)}>删除</button>
                        </li>
                    ))
                )
            }
        </ul>
    );
};
export default TodoList;

5.2 条件渲染:三元表达式

jsx 复制代码
todos.length === 0 ? (
    <li className="empty">No todos yet!</li>   // 👈 空状态
) : (
    todos.map(...)                               // 👈 有数据
)

React 中条件渲染的 4 种方式:

方式 语法 适用场景
三元表达式 cond ? <A/> : <B/> 二选一(如空/非空)
逻辑与 && cond && <A/> 条件为真时显示
if/else 在函数体中提前 return 复杂条件,提前返回
对象映射 {key: <Comp/>}[status] 多分支枚举

5.3 动态 className

jsx 复制代码
<li className={todo.completed ? 'completed' : ''}>

配合 App.css 中的样式:

css 复制代码
.completed {
    text-decoration: line-through;  /* 删除线 */
}

classnames 工具库版本(项目中常这样写):

jsx 复制代码
import classnames from 'classnames';

<li className={classnames({ completed: todo.completed })}>

5.4 受控 Checkbox

jsx 复制代码
<input type="checkbox"
    checked={todo.completed}           // 值由 Props 控制
    onChange={() => onToggle(todo.id)} // 变化 → 通知父组件
/>

这是受控组件的又一个实例:

css 复制代码
Checkbox 勾选
    ↓
onChange → onToggle(id)
    ↓
App: setTodos(map(...{ completed: !todo.completed }))
    ↓
todos 数组更新 → TodoList 重新渲染
    ↓
checked={新的 completed 值} → Checkbox 显示新状态

5.5 列表渲染的 key 属性再强调

jsx 复制代码
todos.map(todo => (
    <li key={todo.id}>   {/* 使用业务唯一 ID,不要用 index */}
        ...
    </li>
))

为什么不用 index 作为 key? 考虑这个场景:

vbnet 复制代码
初始:[{id:1, text:"A"}, {id:2, text:"B"}]
      key=0             key=1

删除 A 后:[{id:2, text:"B"}]
           key=0

问题:React 看到 key=0 从 "A" 变成了 "B",
     认为 DOM 节点可以复用,只更新文本内容。
     但如果 B 的 <input> 中有未提交的用户输入,这个输入会错误地保留在 A 的位置!

📊 六、TodoStats ------ 纯展示组件的典范

TodoStats.jsx 是最简单的组件,但体现了重要的设计原则。

6.1 完整代码

jsx 复制代码
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
    return (
        <div className="todo-stats">
            <p>Total: {total} | Active: {active} | Completed: {completed}</p>
            {
                completed > 0 && (
                    <button onClick={onClearCompleted} className="clear-btn">
                        Clear Completed
                    </button>
                )
            }
        </div>
    );
};
export default TodoStats;

6.2 逻辑与 && 条件渲染

jsx 复制代码
{
    completed > 0 && (
        <button onClick={onClearCompleted}>Clear Completed</button>
    )
}

这是 && 条件渲染的典型应用:

ini 复制代码
completed = 3  → true  → 渲染按钮 ✅
completed = 0  → false → 不渲染(短路求值) ❌

⚠️ 注意0NaN&& 时会被渲染到页面上!因为 React 会把 0 当作文本节点。安全写法:

jsx 复制代码
{completed > 0 ? <button ... /> : null}     // ✅ 明确的三元
{!!completed && <button ... />}             // ✅ 显式转布尔

6.3 "纯展示组件"的特点

TodoStats 是一个典型的无状态组件(Stateless Component)

  • ✅ 没有自己的 useState
  • ✅ 只依赖 Props 渲染
  • ✅ 不产生副作用
  • ✅ 相同的 Props 永远渲染相同的 UI

这种组件:

  • 易测试:给定 Props,断言渲染结果
  • 易复用:不依赖特定上下文
  • 易理解:输入输出明确

🔄 七、状态提升(Lifting State Up)------ 最重要的组件通信模式

7.1 为什么要把状态放在 App 中?

假设我们把 todos 放在 TodoList 组件中:

jsx 复制代码
// ❌ 糟糕的设计
const TodoList = () => {
    const [todos, setTodos] = useState([]);
    // ...
};
// TodoInput 和 TodoStats 无法访问 todos!

问题TodoInput 需要添加 todo,TodoStats 需要统计 todo------它们都需要访问同一个 todos 数据。但 React 的 state 是组件私有的,无法被兄弟组件直接访问。

解决方案状态提升------将共享状态放到最近的公共祖先组件中。

css 复制代码
❌ 各自为政(状态分散)               ✅ 状态提升(单一数据源)
                                    
     App                                App ← 状态在这里
    /   \                              / | \
Input  List                          Input List Stats
[本地] [本地]                        各自通过 Props 获取数据

7.2 单向数据流总览

flowchart LR subgraph App[&#34;App(状态持有者)&#34;] T[&#34;todos&#34;] A[&#34;addTodo()&#34;] G[&#34;toggleTodo()&#34;] D[&#34;deleteTodo()&#34;] C[&#34;clearCompleted()&#34;] end subgraph Input[&#34;TodoInput&#34;] IV[&#34;inputValue(本地状态)&#34;] end T -->|&#34;todos&#34;| List[&#34;TodoList&#34;] G -->|&#34;onToggle&#34;| List D -->|&#34;onDelete&#34;| List A -->|&#34;onAdd&#34;| Input T -->|&#34;total/active/completed&#34;| Stats[&#34;TodoStats&#34;] C -->|&#34;onClearCompleted&#34;| Stats

7.3 数据流三定律

定律 描述
第一定律 State 只能属于一个组件(Single Source of Truth)
第二定律 Props 是只读的,子组件不能修改 Props
第三定律 子组件通过回调函数向上通知变化

记住这三条,React 组件通信就不会出错。


🌐 八、原生 JS 对照:DocumentFragment 与 React 批处理

text.html 中,有一段纯粹的原生 JS 代码,它揭示了 React 虚拟 DOM 批处理的思想源头。

8.1 完整代码

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <ul id="list"></ul>
    <script>
        const data = ["任务1", "任务2", "任务3"];
        const olist = document.querySelector('#list');

        // 📦 文档碎片:没有实体标签,存在于内存中
        const fragment = document.createDocumentFragment();

        for (const task of data) {
            const item = document.createElement('li');  // JS 运算(快)
            item.innerText = task;
            fragment.appendChild(item);                 // 添加到内存(不触发渲染)
        }

        // 🚀 一次性挂载,只触发一次页面绘制
        olist.appendChild(fragment);
    </script>
</body>
</html>

8.2 DocumentFragment 是什么?

DocumentFragment 是一个轻量级的"虚拟容器"

  • 没有实体 DOM 标签 (不像 <div> 会多一层嵌套)
  • 只存在于内存
  • 当它被插入 DOM 时,插入的是它的所有子节点,而不是它自身

8.3 逐次插入 vs 文档碎片 ------ 性能对比

javascript 复制代码
// ❌ 方式一:逐次插入(每次插入触发一次回流 + 重绘)
for (const task of data) {
    const item = document.createElement('li');
    item.innerText = task;
    olist.appendChild(item);  // 第 1 次 → 回流 → 重绘
                               // 第 2 次 → 回流 → 重绘
                               // 第 3 次 → 回流 → 重绘
}

// ✅ 方式二:文档碎片(只触发一次回流 + 重绘)
const fragment = document.createDocumentFragment();
for (const task of data) {
    const item = document.createElement('li');
    item.innerText = task;
    fragment.appendChild(item); // 在内存中操作,不触发渲染
}
olist.appendChild(fragment);   // 🚀 一次性提交 → 只触发一次回流 + 重绘
ini 复制代码
┌─────────────────────────────────────────────────┐
│          浏览器渲染管线的代价                       │
│                                                  │
│  JS → Style → Layout → Paint → Composite        │
│         ↑                 ↑                     │
│      样式计算          绘制(最耗时)               │
│                                                  │
│  逐次插入:Layout × 3 + Paint × 3 = 慢           │
│  文档碎片:Layout × 1 + Paint × 1 = 快           │
└─────────────────────────────────────────────────┘

8.4 与 React 的类比

flowchart TB subgraph vanilla[&#34;原生 JS&#34;] A[&#34;DocumentFragment&#34;] B[&#34;批量操作内存中的节点&#34;] C[&#34;一次性 appendChild&#34;] A --> B --> C end subgraph react[&#34;React&#34;] D[&#34;Virtual DOM&#34;] E[&#34;批量计算 Diff&#34;] F[&#34;一次性 Patch 到真实 DOM&#34;] D --> E --> F end vanilla -.->|&#34;思想同源&#34;| react
概念 原生 JS React
操作对象 DocumentFragment Virtual DOM
计算位置 内存 内存
提交时机 手动 appendChild React 自动批量提交
优化效果 减少回流重绘 减少回流重绘 + Diff 最小化更新

💡 核心理解 :React 的 useState 批处理机制(同一事件中多次 setState 只触发一次渲染),本质上和 DocumentFragment 的"攒起来一起提交"是同一个思想------减少与实际 DOM 的交互次数


🧠 九、用对 React.memo 与 useCallback ------ 性能优化初探

当 Todo List 项目逐渐变复杂时,你可能会遇到不必要的子组件重渲染问题。

9.1 问题场景

jsx 复制代码
// App 每次渲染都会创建新的函数引用
const addTodo = (text) => { ... };   // 新函数
const toggleTodo = (id) => { ... };  // 新函数
const deleteTodo = (id) => { ... };  // 新函数

// TodoInput 收到"新"的 onAdd → 认为 Props 变了 → 重渲染
<TodoInput onAdd={addTodo} />

9.2 解决方案

jsx 复制代码
import { useState, useCallback } from 'react';

const App = () => {
    const [todos, setTodos] = useState(...);

    // ✅ useCallback 保持函数引用不变(依赖为空时)
    // 但这里函数依赖 todos,所以每次 todos 变还是会更新引用
    const addTodo = useCallback((text) => {
        if (text.trim() === '') return;
        setTodos(prev => [{ id: +Date.now(), text, completed: false }, ...prev]);
    }, []);  // 空依赖!因为用了函数式更新 prev => ...

    // 同样优化 toggleTodo 和 deleteTodo
    const toggleTodo = useCallback((id) => {
        setTodos(prev => prev.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        ));
    }, []);

    const deleteTodo = useCallback((id) => {
        setTodos(prev => prev.filter(todo => todo.id !== id));
    }, []);

    // ...
};

关键升级:setTodos(count + 1)setTodos(prev => ...)

使用函数式更新 prev => ... 后,函数不再依赖外部 todos 变量,因此 useCallback 的依赖数组可以为空 [],函数引用永远不变!

9.3 配合 React.memo

jsx 复制代码
// TodoStats 是纯展示组件,用 memo 包裹
const TodoStats = React.memo(({ total, active, completed, onClearCompleted }) => {
    console.log('TodoStats 渲染');  // 只在 Props 变化时打印
    return (
        <div className="todo-stats">
            <p>Total: {total} | Active: {active} | Completed: {completed}</p>
            {completed > 0 && <button onClick={onClearCompleted}>Clear Completed</button>}
        </div>
    );
});

React.memo 对传入的 Props 做浅比较,如果没变化就跳过渲染。


🎯 十、完整项目文件清单与运行指南

10.1 文件结构

bash 复制代码
todos/
├── index.html                    # Vite 入口 HTML
├── package.json                  # React 19 + Vite 8
├── vite.config.js                # Vite 配置(React 插件)
├── eslint.config.js              # ESLint 扁平化配置
└── src/
    ├── main.jsx                  # 应用入口(createRoot)
    ├── App.jsx                   # 主组件(状态管理中心)
    ├── App.css                   # 组件样式(completed 删除线)
    ├── index.css                 # 全局样式(CSS 变量 + 暗色模式)
    └── components/
        ├── TodoInput.jsx         # 输入组件(受控 input + 表单)
        ├── TodoList.jsx          # 列表组件(条件渲染 + map)
        └── TodoStats.jsx         # 统计组件(纯展示)

10.2 快速运行

bash 复制代码
cd todos
npm install
npm run dev
# 浏览器打开 http://localhost:5173

📐 十一、知识体系总结

11.1 本文核心能力矩阵

python 复制代码
┌──────────────────────────────────────────────────────────┐
│                  React 组件化开发知识地图                    │
├──────────────────────────────────────────────────────────┤
│  🏗️ 组件架构                                              │
│  ├── 单组件 → 多组件拆分原则                                │
│  ├── 状态提升(Lifting State Up)                           │
│  ├── 展示组件 vs 容器组件的区分                              │
│  └── 组件树设计与 Props 接口定义                            │
├──────────────────────────────────────────────────────────┤
│  🔄 数据流                                                │
│  ├── 单向数据流:Props 下传,事件上报                        │
│  ├── 不可变更新:spread / map / filter                     │
│  └── 回调函数作为 Props(onAdd / onToggle / onDelete)     │
├──────────────────────────────────────────────────────────┤
│  🎨 UI 模式                                               │
│  ├── 受控组件:input / checkbox                           │
│  ├── 条件渲染:三元 / && / 提前 return                     │
│  ├── 列表渲染:map + 稳定的 key                            │
│  └── 动态 className                                       │
├──────────────────────────────────────────────────────────┤
│  ⚡ 性能理解                                              │
│  ├── DocumentFragment → Virtual DOM 思想对照               │
│  ├── useCallback 稳定函数引用                              │
│  ├── React.memo 跳过不必要渲染                             │
│  └── 函数式更新 prev => ... 减少依赖                       │
└──────────────────────────────────────────────────────────┘

11.2 不可变更新速查(打印贴桌面版)

javascript 复制代码
// 📌 新增
setTodos([newItem, ...todos]);                         // 头部插入
setTodos([...todos, newItem]);                         // 尾部追加

// 📌 修改某项
setTodos(todos.map(t => t.id === id
    ? { ...t, field: newValue }                        // 匹配:浅拷贝修改
    : t                                                // 不匹配:原样
));

// 📌 删除某项
setTodos(todos.filter(t => t.id !== id));

// 📌 批量删除
setTodos(todos.filter(t => !t.completed));

// 📌 对象类型的 state 同理
setUser({ ...user, name: 'newName' });                 // 修改字段
setUser({ ...user, address: { ...user.address, city: 'HZ' } }); // 深层修改

11.3 下一步学习路线

序号 主题 说明
1 useEffect 副作用处理:数据请求、订阅、定时器
2 useRef DOM 引用、保存可变值不触发渲染
3 自定义 Hook 将本文的 useTodos 抽离为自定义 Hook
4 useReducer 复杂状态逻辑的替代方案
5 Context API 跨层级数据传递,告别 Props Drilling
6 React 19 新特性 useActionStateuseOptimisticuse

📝 写在最后 :从 state-demo 的单个 useStatetodos 的多组件协作,我们完成了 React 从"入门"到"能写项目"的关键一跃。如果上篇文章帮你理解了 React 的"细胞"(State),那这篇文章帮你建立了 React 的"组织架构"(组件通信)。建议将两篇文章对照阅读,效果更佳。

相关推荐
bonechips1 小时前
React 组件通信:一套 TodoList 讲清 props 与回调
前端·react.js
用户7562101332531 小时前
02. 从0开始学习硅基智能 - buddy-mlir deepseek模型导入代码解析
人工智能
渣波1 小时前
别把 TodoList 写成面条代码!一文吃透 React 组件通信与状态驱动的核心内功
前端
2601_964702891 小时前
Claude Opus 5 API 开发实战:对话、文本生成与结构化输出
java·服务器·前端
聚焦前沿1 小时前
水动力优化导流罩:原理、数据与实船验证
大数据·服务器·数据库·人工智能
cellurw1 小时前
20260724 六组件编译收尾与全局构建验证
前端
wWYy.1 小时前
如何设计多Agent的协作与动态切换机制?
人工智能·agent
用户2930750976691 小时前
用React 完成Todos 的 组件应用:深入理解单向数据流与组件通信
前端