首页 / React 学习笔记 / 08

REACT · Vol.I · LESSON 08 · 核心原语

第一章实战:手写一个待办清单组件

实战核心#实战#综合

用「后端思维」先拆需求

第一章学了这么多概念,是时候把它们拧成一股绳了。我们要写一个待办清单(Todo),功能不花哨,但恰好覆盖本章全部知识点:

  • 一个输入框,可以新增待办;
  • 每条待办可以勾选完成删除
  • 底部显示剩余未完成数

用你熟悉的「后端拆接口」套路,先想清楚数据模型和状态,别急着写 UI:

先建模,再写视图(对应先建表,再写接口)
// 一条待办的数据结构
type Todo = {
  id: number;        // 唯一标识(就是 list 的 key)
  text: string;      // 内容
  done: boolean;     // 是否完成
};

// 组件需要的 state
// 1. todos: Todo[]   —— 待办列表
// 2. input: string   —— 输入框当前文字
关键心法

「先定 state、再写 JSX」是最重要的分水岭动作。就像后端「先建表结构、再写 Controller」——state 就是前端的「表结构」。state 定清楚了,界面只是它的投影(还记得第 1 篇的 UI = f(state) 吗?)。

顺带验证一下上一站学的「哪些数据不配进 state」:剩余条数能由 todos 推导,不进;输入框内容会被用户持续修改、且参与渲染,进。

五个动作,逐个攻破

整个组件其实就是 5 个「状态操作」,每个都对应一个知识点:

TodoApp.tsx · 完整实现
import { useState } from "react";

type Todo = { id: number; text: string; done: boolean };

// 计数器放在组件外:模块只加载一次,不会随重渲染重置
let nextId = 1;

function TodoApp() {
  // ── 状态(先定 state)──
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");

  // ── ① 新增:不可变更新,append 新项 ──
  function addTodo() {
    const text = input.trim();
    if (!text) return;              // 空输入拦截(对应后端参数校验)
    setTodos([...todos, { id: nextId++, text, done: false }]);
    setInput("");                   // 清空输入框
  }

  // ── ② 勾选完成:map 置换目标项 ──
  function toggle(id) {
    setTodos(todos.map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    ));
  }

  // ── ③ 删除:filter 过滤目标项 ──
  function remove(id) {
    setTodos(todos.filter(t => t.id !== id));
  }

  // ── ④ 派生值:不要存进 state,直接算 ──
  const remaining = todos.filter(t => !t.done).length;

  // ── ⑤ 视图:状态 → 界面 ──
  return (
    <div className="todo">
      <form onSubmit={e => { e.preventDefault(); addTodo(); }}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="输入待办…"
        />
        <button type="submit">添加</button>
      </form>

      <ul>
        {todos.map(t => (
          <li key={t.id}>
            <label>
              <input
                type="checkbox"
                checked={t.done}
                onChange={() => toggle(t.id)}
              />
              <span style={{ textDecoration: t.done ? "line-through" : "none" }}>
                {t.text}
              </span>
            </label>
            <button onClick={() => remove(t.id)}>删除</button>
          </li>
        ))}
      </ul>

      <p>剩余 {remaining} 项</p>
    </div>
  );
}

建议你在本地把它跑起来,依次验证:回车能添加(onSubmit 在发力)、空内容添加不了、勾选后文字划线、底部数字跟着变。

注意 let nextId = 1 的位置——它在组件函数外面。如果写进函数里,每次重渲染都会被重置回 1,新条目的 id 就会和旧条目撞车,key 一重复,第 7 篇讲的所有灾难都会上演。这是「变量作用域」这个最基础的知识在 React 里的投影:组件函数会反复执行,想跨渲染保存的值,要么交给 state / ref,要么放到函数外面。真实项目里,id 通常来自后端数据库;纯前端临时用 crypto.randomUUID() 也很稳。

这段代码里藏着的「第一章全部精华」

把它和前面 7 篇一一对应,你会看到每一篇都不是白学的:

代码位置对应知识点来源章节
return (<div>...</div>)声明式:只写「状态 → 界面」的映射第 1 篇
<input value={input} onChange=...>JSX{} 插值、驼峰属性、受控组件第 2 篇
function TodoApp() / 无任何 props组件:一个函数第 3 篇
useState + [...todos, x] 不可变更新State:不直接 mutate,返回新数组第 4 篇
remaining = todos.filter(...)派生值不存 state(渲染模型的「纯函数」思想)第 4、5 篇
onSubmit + preventDefaultonChange={() => toggle(t.id)}事件:拦截默认行为、箭头函数传参第 6 篇
key={t.id}列表 + key:稳定唯一标识第 7 篇

进阶:把 TodoItem 拆成子组件

上面的版本能跑,但列表项的 JSX 还嵌在大组件里。按第 3 篇的标准——「这块能用一句话描述吗?会被复用吗?」——TodoItem 完全够格独立成组件。拆:

TodoItem.tsx · 子组件:只收数据 + 回调
function TodoItem({ todo, onToggle, onRemove }) {
  return (
    <li>
      <label>
        <input
          type="checkbox"
          checked={todo.done}
          onChange={() => onToggle(todo.id)}
        />
        <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
          {todo.text}
        </span>
      </label>
      <button onClick={() => onRemove(todo.id)}>删除</button>
    </li>
  );
}
TodoApp.tsx · 父组件:列表渲染变成一行
<ul>
  {todos.map(t => (
    <TodoItem key={t.id} todo={t} onToggle={toggle} onRemove={remove} />
  ))}
</ul>

停下来品一品这次拆分里的每个决定,它们全是前几篇讲过的规矩:

  • key 挂在 TodoItem——它是 map 直接生成的最外层元素(第 7 篇);
  • 子组件自己不持有任何 state——数据全部来自父组件的 props,自己只是「投影」;
  • 想改数据?调用传进来的 onToggle / onRemove——数据向下流、动作向上传,子组件永远不越权改数据(第 3 篇)。
后端类比

这就是前端版的「Controller 不含业务逻辑,只做转发」:TodoItem 是无状态的展示层,所有「写操作」都上抛给持有数据的父组件处理。谁持有数据谁负责改——职责边界清清楚楚,这正是卷二「状态提升」要系统化的东西,你已经提前用上了。

给自己留三道「课后题」

把这个 Todo 组件在你自己的项目里跑起来,然后依次完成三个小目标:

  1. 加「全部完成」按钮:一键把所有 done 设为 true(练 map 批量置换)。
    提示:setTodos(todos.map(t => ({ ...t, done: true })))
  2. 加「编辑」功能:双击待办文字变成输入框,回车保存(练「受控组件 + 局部状态」)。
    提示:在 TodoItem 里加一个 editing state 控制两种样子,别忘了第 4 篇的「界面有几种样子」。
  3. 加「筛选」功能:全部 / 未完成 / 已完成三个页签(练「派生值」)。
    提示:新 state 只有一个 filter;展示的列表是 todos + filter 的派生值,就地 filter 出来再 map,别存进 state。

第一章总结

你已经完成了从「后端思维」到「React 心智」的第一次完整跨越:

  • 声明式UI = f(state),先穷举状态再画映射;
  • JSX:HTML 的外壳、JavaScript 的骨,条件渲染三板斧;
  • 组件 + Props:函数 + 不可变单向参数,数据向下、动作向上;
  • State:组件字段,不可变更新、函数式更新、快照心智;
  • 渲染模型:虚拟 DOM 三阶段,重渲染 ≠ 重写 DOM;
  • 事件 + 列表 key:委托分发 + 稳定身份。

这些就是 React 的「地基」。第 2 卷开始,我们进入真正让 React 强大的部分——Hooks 精讲:先从那个坑倒无数人的 useEffect 开始。

Comments · 评论