React 核心概念实战:从组件化思维到本地存储持久化

4 阅读17分钟

本文通过一个完整的 Todo 应用项目,由浅入深地解析 React 的核心概念——组件化、响应式、Hooks、组件通信以及前端本地存储的工程化实践。


目录

  1. 引言:一个 Todo 应用涵盖的 React 知识体系
  2. React 基础核心知识
  3. Hooks 深度解析
  4. 组件通信与单向数据流
  5. 前端本地存储实践
  6. 开发流程与组件化思维
  7. 项目工程化概览
  8. 总结

1. 引言:一个 Todo 应用涵盖的 React 知识体系

Todo 应用是前端学习中的经典项目——看似简单,实则涵盖了 React 生态中最核心的知识体系。本项目基于 Vite + React 19 构建,以纯 JavaScript(非 TypeScript)实现,聚焦于 React 本身的机制与思想,帮助开发者在无类型系统干扰的情况下,深入理解 React 的运行原理。

项目的 README 将知识点梳理为三条主线:

  • React 知识:组件化、响应式、数据驱动、Hooks(useState / useEffect)、JSX、Fragment、组件通信;
  • 开发流程:组件思想 → 组件树规划 → 父子通信设计 → 状态管理布局;
  • 本地存储:localStorage 的读写操作,以及与 useEffect 结合实现自动持久化。

下面,我们沿着这三条主线,结合项目源码逐层展开。


2. React 基础核心知识

2.1 组件化:一切皆组件

React 的核心哲学是 组件化——将界面拆分为独立、可复用的组件,每个组件负责一个明确的功能区域。本项目的组件结构如下:

App(父组件,状态持有者)
├── TodoStats    统计栏:展示总数、活跃数、已完成数
├── TodoList     列表:渲染每一条待办事项
└── TodoInput    输入框:新增待办事项的表单

每个组件是一个独立的 .jsx 文件,拥有自己的 JSX 结构、样式引用和导出声明。这种拆分方式带来的好处是显而易见的:

  • 单一职责TodoStats 只负责展示统计数字,不关心数据来源;TodoInput 只负责收集用户输入,不关心数据最终如何渲染;
  • 可测试性:每个组件可以独立测试,输入确定的 props,预期确定的渲染结果;
  • 可维护性:当统计栏需要增加"已完成百分比"时,只需修改 TodoStats 一个文件,不会波及其他组件。

2.2 响应式与数据驱动

React 是 响应式 框架——当数据(state)发生变化时,界面会自动重新渲染,开发者无需手动操作 DOM。这就是 数据驱动视图 的核心理念。

App.jsx 中,todos 数组是整个应用的数据核心:

const [todos, setTodos] = useState([{
    id: 1,
    text: '做饭',
    completed: false
}, {
    id: 2,
    text: '学习',
    completed: false
}, {
    id: 3,
    text: '打豆豆',
    completed: false
}]);

setTodos 被调用时(新增、删除、切换完成状态),React 会自动对比新旧状态,计算出最小的 DOM 修改量并高效更新界面。开发者只需要关注"数据是什么",而非"如何修改 DOM"。

衍生状态是数据驱动思想的进一步体现——某些值不需要单独存储,而是从已有状态计算得出:

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

activeCountcompletedCount 不是 useState 声明的独立状态,而是每次渲染时从 todos 实时计算出来的。这保证了它们与 todos 永远保持一致,不会出现"数据改了但统计没变"的 bug——这就是单一数据源的优势。

2.3 JSX 与 Fragment

JSX 是 JavaScript 的语法扩展,允许在 JS 代码中书写类似 HTML 的标记。它本质上是对 React.createElement() 的语法糖,经过 Babel/Vite 编译后生成标准的 JavaScript 对象。

TodoList.jsx 中,JSX 与 JavaScript 逻辑无缝交织:

<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)}
                        />
                        <span>{todo.text}</span>
                    </label>
                    <button onClick={() => onDelete(todo.id)}>删除</button>
                </li>
            ))
        )
    }
</ul>

这段代码展示了 JSX 的几个关键特性:

  • 表达式嵌入{} 内可以写任意 JavaScript 表达式——三元运算符、数组 map、函数调用等;
  • 条件渲染todos.length === 0 ? ... : ... 实现了空状态的 UI 分支;
  • 列表渲染todos.map(...) 将数据数组映射为 JSX 元素数组,每个元素需要唯一的 key 属性来帮助 React 高效 diff;
  • 事件绑定onChangeonClick 直接绑定处理函数,遵循驼峰命名(而非 HTML 的 onchange);
  • className:使用 className 而非 class,因为 class 是 JavaScript 的保留字。

Fragment(片段)是另一个实用的 React 概念——当组件需要返回多个兄弟元素,但又不想引入额外的 DOM 节点时,可以用 <></><React.Fragment> 包裹。虽然本项目未显式使用 Fragment,但理解其存在对组件开发很有价值。


3. Hooks 深度解析

3.1 useState:状态管理基石

useState 是 React 中最基础、最常用的 Hook,用于在函数组件中声明响应式状态。项目中几乎所有可变数据都由 useState 管理。

基本用法

// App.jsx —— 对象数组状态
const [todos, setTodos] = useState([{ id: 1, text: '做饭', completed: false }]);

// TodoInput.jsx —— 字符串状态
const [inputValue, setInputValue] = useState('');

不可变更新模式是理解 useState 的关键。React 要求状态更新必须是全新的对象引用,而非修改原对象:

// ✅ 正确:创建新数组,新对象排在最前面
const addTodo = (text) => {
    if (text.trim() === '') return;
    setTodos([
        { id: +Date.now(), text, completed: false },
        ...todos
    ]);
};

// ✅ 正确:map 返回新数组,只替换匹配项
const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
        todo.id === id
            ? { ...todo, completed: !todo.completed }
            : todo
    ));
};

// ✅ 正确:filter 返回新数组(过滤掉目标项)
const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
};

三种操作对应三种不可变模式:展开运算符 + 新元素map + 展开filter。注意,这些方法始终返回新数组,原数组不会被修改——这是 React 正确触发重渲染的前提。

懒执行是 README 特别提到的一个细节。useState 支持传入一个函数作为初始值计算器:

// 普通写法:每次渲染都执行 expensiveComputation()
const [data, setData] = useState(expensiveComputation());

// 懒执行:只在首次渲染时执行一次
const [data, setData] = useState(() => expensiveComputation());

当初始值的计算成本较高时(如从 localStorage 读取并解析 JSON),使用函数形式可以避免每次渲染重复计算,提升性能。项目中 useState(localStorage.getItem('key') || defaultValue) 就是一个典型的适用场景。

3.2 useEffect:副作用与生命周期

useEffect 是 React 管理"副作用"的入口。所谓副作用,是指在组件渲染之外的任何操作——包括网络请求、DOM 操作、定时器、以及本例中的 localStorage 写入

项目在 App.jsx 中有意写入了四个 useEffect 示例,用于展示不同依赖数组下的行为差异:

// 一:依赖 [count] —— 挂载后执行,且 count 每次变化后也执行
useEffect(() => {
    console.log('挂载后执行');
    console.log('count 更新后执行');
}, [count]);

// 二:依赖 [] —— 只在挂载后执行一次
useEffect(() => {
    console.log('只会在挂载后执行');
}, []);

// 三:依赖 [todos] —— 挂载后执行,且 todos 每次变化后也执行
//    核心功能:将 todos 自动同步到 localStorage
useEffect(() => {
    console.log('挂载后执行');
    console.log('todos 更新后执行');
    localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

// 四:不传第二个参数 —— 挂载后执行,且每次更新都执行(很少使用)
useEffect(() => {
    console.log('只会在挂载后执行');
    console.log('每次更新都执行');
});

这四个例子分别对应了 useEffect 的四类生命周期行为:

依赖数组执行时机对应类组件生命周期典型场景
[dep]挂载后 + 依赖项变化后componentDidMount + componentDidUpdate(条件)数据变更后同步到存储、重新请求接口
[]仅挂载后componentDidMount初始化请求数据、注册全局事件监听
不传挂载后 + 每次更新后componentDidMount + componentDidUpdate极少使用,性能风险大
返回清理函数卸载前 / 依赖变化前componentWillUnmount清除定时器、取消订阅、关闭连接

README 中"副作用"一词的含义在此得以印证:渲染副作用不是"负面影响",而是"伴随渲染产生的附带操作"。把 todos 存到 localStorage 并不直接影响界面,但它是应用完整功能链中必不可少的一环——这就是副作用的典型场景。

useEffect 的执行时序也值得注意:React 会在浏览器完成 DOM 更新后,才异步执行 useEffect 的回调。这意味着副作用不会阻塞页面渲染——用户始终先看到界面,后发生副作用的处理逻辑。


4. 组件通信与单向数据流

4.1 核心原则:子组件不能直接修改父组件状态

React 的数据流是严格单向的:数据从父组件通过 props 流向子组件,子组件不能直接修改父组件的状态。如果子组件需要触发数据变更,必须通过父组件传入的回调函数来"通知"父组件,由父组件自己执行修改。

这条原则在 README 中被反复强调:

子组件不可以直接修改父组件的数据状态的,只能通过自定义事件通知父组件,父组件修改后,子组件自动更新。

这是 React 保证数据一致性的核心机制——状态始终由持有它的组件负责修改,其他组件只能"申请修改"。

4.2 三大子组件的通信实践

整个应用的状态管理围绕着 todos 数组展开,父组件 App 是数据的唯一持有者,三个子组件各司其职:

(1)TodoStats —— 纯数据展示 + 附带操作

// 父组件传入:
<TodoStats
    total={todos.length}
    active={activeCount}
    completed={completedCount}
    onClearCompleted={clearCompleted}
/>

// TodoStats 内部:
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>
    );
};

TodoStats 是一个典型的展示型组件:它自身没有状态,所有数据通过 props 传入。它唯一触发的操作是"清除已完成"——通过调用父组件传入的 onClearCompleted 回调,将意图告知父组件,由父组件执行实际的 setTodos 操作。

(2)TodoList —— 列表渲染 + 双回调

// 父组件传入:
<TodoList
    todos={todos}
    onToggle={toggleTodo}
    onDelete={deleteTodo}
/>

// TodoList 内部:遍历渲染,每个 item 绑定两个操作
{todos.map(todo => (
    <li key={todo.id} className={todo.completed ? 'completed' : ''}>
        <label>
            <input type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
            />
            <span>{todo.text}</span>
        </label>
        <button onClick={() => onDelete(todo.id)}>删除</button>
    </li>
))}

TodoList 是典型的列表容器组件。它接收完整的 todos 数组负责渲染,但修改操作(切换完成、删除)都必须通过 onToggleonDelete 回调返回父组件。注意回调传递的是 todo.id,而非整个 todo 对象——接口越精简,耦合度越低。

(3)TodoInput —— 私有状态 + 提交通知

// 表单交互组件
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"
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
                placeholder="What needs to be done?"
                autoFocus
            />
            <button type="submit">Add</button>
        </form>
    );
};

TodoInput 的设计值得仔细分析——它是唯一拥有私有状态的子组件:

  • inputValue 是输入框的临时文本,它不需要被其他组件共享,因此放在子组件内部是最合适的选择;
  • 当表单提交时,handleSubmit 调用 e.preventDefault() 阻止页面刷新,然后通过 onAdd(inputValue) 将文本传给父组件,最后清空输入框;
  • 这种"内部管理临时状态,提交时通知父组件"的模式,在不需要实时共享编辑状态时是最佳实践,因为它将输入框的交互细节封装在了组件内部。

对比前文中 TypeScript 项目的三版演进,这里的 TodoInput 正对应了"第二版:子组件私有状态自治"的架构选择。

通信示意图

                        ┌─────────────────────────────┐
                        │          App(父组件)         │
                        │                              │
                        │  useState(todos)              │
                        │  addTodo / toggleTodo         │
                        │  deleteTodo / clearCompleted  │
                        │  activeCount / completedCount │
                        │                              │
                        └──┬──────────┬──────────┬─────┘
              props + 回调  │    props │    props │  props + 回调
        ┌──────────────────┴┐  ┌───────┴──────┐  ┌───┴──────────────┐
        │    TodoInput      │  │   TodoList   │  │    TodoStats     │
        │                   │  │              │  │                  │
        │ useState(input)   │  │ 展示列表     │  │  展示统计数字     │
        │ 提交 → onAdd()    │  │ 切换 → onToggle()│  清除 → onClear()│
        └───────────────────┘  └──────────────┘  └──────────────────┘

4.3 组件树:架构规划先行

README 将"规划组件树"提升为开发流程的第二步,位于"组件思想"之后、实际编码之前。这不是偶然的——在写代码之前先画好组件结构,能够显著减少后期的重构成本,对 Vibe Coding(AI 辅助编程)尤其重要:清晰的结构描述能帮助 AI 更准确地理解意图。

本项目的组件树简洁而清晰:

App
 ├── TodoStats    —— 统计信息栏
 ├── TodoList     —— 待办列表(依赖 todos/onToggle/onDelete)
 └── TodoInput    —— 输入表单(依赖 onAdd)

职责分配原则:

  • App:唯一的数据持有者和操作者,负责所有业务逻辑;
  • 子组件:纯粹的视图层,通过 props 接收数据,通过回调发出修改请求。

5. 前端本地存储实践

5.1 localStorage 基础操作

浏览器提供了多种客户端存储方案,本项目涉及两种:

存储方案容量数据格式适用场景
localStorage约 5MBkey-value 字符串配置信息、关键数据、用户偏好
IndexedDB远大于 5MB结构化对象大量数据、复杂查询、离线应用

本项目使用 localStorage 持久化 todos 数据,核心操作只有两个 API:

// 写入:将 JavaScript 对象转为 JSON 字符串后存储
localStorage.setItem('todos', JSON.stringify(todos));

// 读取:取出字符串后解析为 JavaScript 对象
const savedTodos = JSON.parse(localStorage.getItem('todos'));

JSON.stringifyJSON.parse 是 localStorage 使用中必不可少的一对搭档——localStorage 只能存储字符串,任何对象都需要序列化。

一个值得注意的细节是:localStorage.getItem 在 key 不存在时返回 null,而 JSON.parse(null) 也返回 null,因此项目中可以安全地使用 JSON.parse(localStorage.getItem('todos')) 而无需额外的 null 检查。但在生产实践中,建议提供一个回退默认值:

const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : defaultTodos;
});

这里同时运用了 useState懒执行特性——初始化函数只在组件首次挂载时执行一次,避免了每次渲染都读取 localStorage 的性能浪费。

5.2 useEffect + localStorage:自动持久化

读取只是初始化的一环,自动写入才是持久化的灵魂。项目通过 useEffecttodos 的依赖监听,实现了"数据变化即保存"的自动同步:

useEffect(() => {
    console.log('挂载后执行');
    console.log('todos 更新后执行');
    localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

工作流程

  1. 组件挂载时useState 从 localStorage 读取初始数据;
  2. 用户操作时addTodo / toggleTodo / deleteTodo / clearCompleted 等函数调用 setTodos,更新内存中的状态;
  3. React 重渲染后useEffect 检测到 [todos] 依赖项已变化,执行回调,将最新数据写入 localStorage;
  4. 下次访问时:步骤 1 读取到上次保存的数据,实现"刷新不丢失"。

这种模式无需在每次 setTodos 调用后手动写 localStorage.setItem——响应式机制保证了一处状态变更,自动触发持久化,既简洁又不容易遗漏。

useEffect 在此处的第二个参数 [todos] 也非常关键:如果写成 [count],则只在 count 变化时存储,todos 变化不会触发;如果写成 [],则只在挂载时执行一次,后续的数据变更不再持久化;如果不传,则每次渲染都写入(包括那些与 todos 无关的渲染),这会导致不必要的 I/O 开销。选择合适的依赖数组,既是正确性的保证,也是性能优化的重要手段。


6. 开发流程与组件化思维

README 将开发流程总结为一条清晰的路线:

组件思想 → 规划组件树 → 父子通信设计 → 编码实现

这个流程的核心价值在于 "先设计,后编码"

  1. 组件思想:接受"界面 = 组件树"的世界观,将页面看作组件的嵌套组合;
  2. 规划组件树:画出目录结构和组件嵌套关系——本项目中 src/compontents/ 目录下三个文件各对应一个子组件,结构一目了然;
  3. 父子通信设计:确定哪些状态放在父组件(共享状态),哪些放在子组件(私有状态),以及通过哪些回调函数连接两端;
  4. 编码实现:在前三步的基础上编写代码,此时每个文件的职责已经明确,编码过程变成"填充细节"而非"边写边想"。

这四步流程对 Vibe Coding(AI 辅助编程)尤其有价值。在与 AI 协作时,如果给出一个结构清晰的设计方案("我需要三个子组件,分别负责统计、列表和输入,状态由父组件统一管理"),AI 能够一次性生成高质量的代码;而如果需求模糊,AI 生成的代码往往需要多轮迭代修正。清晰的规划是高效协作的前提。


7. 项目工程化概览

本项目使用 Vite 作为构建工具,配置极为简洁:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
});

与 TypeScript 项目不同,这是一个纯 JavaScript 项目,不需要 tsconfig 和类型声明文件。这在小型项目、快速原型和教学场景中带来了更低的心智负担——开发者可以专注于 React 本身,而不用分心处理类型系统。

package.json 脚本

"scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview"
}

build 命令中只有 vite build,没有 TypeScript 项目的 tsc -b 类型检查步骤——JS 项目无需编译类型,可以直接打包。这使得开发反馈循环更短,适合快速迭代和原型验证。

入口文件 main.jsx 中的注释:

createRoot(document.getElementById('root')).render(
    // 做一次执行,再做一次测试 同样的数据,组件一摸一样
    <App />
);

这段注释指向了 React 18+ 的 StrictMode 行为——在开发模式下,React 会故意执行两次渲染来检测副作用是否正确。如果代码中有不恰当的副作用(如在渲染函数体内修改全局变量),第二次执行会暴露问题。项目未显式使用 <StrictMode> 包裹,这是一个教学取舍:先理解核心机制,再学习严格模式的检测能力。


8. 总结

通过这个 Todo 应用的完整代码,本文梳理了 React 核心开发的完整知识链:

  1. 组件化是 React 的根基。将界面拆分为职责单一的组件,每个组件对应一个清晰的 UI 功能区域,既便于开发也便于维护。

  2. 响应式与数据驱动让开发者从 DOM 操作中解放出来。关注数据的变化,界面会自动跟随更新。衍生状态(如 activeCount)进一步减少了冗余状态,保证了数据一致性。

  3. useState 是状态管理的基石。不可变更新模式(新数组、新对象)是正确触发重渲染的前提;懒执行特性可用于优化初始化性能。

  4. useEffect 统一了副作用管理。通过不同依赖数组的组合,覆盖了挂载后、更新后、卸载前等场景。useEffect + localStorage 的模式实现了数据的自动持久化,简洁而高效。

  5. 单向数据流是 React 的数据铁律。父组件持有状态并负责修改,子组件通过 props 接收数据、通过回调发出修改请求——这条规则保证了应用状态在任何时刻都是可追溯、可预测的。

  6. 先设计后编码的开发流程,在 AI 辅助编程时代愈加重要。清晰的组件树规划和通信设计,是高效协作的前提。

  7. localStorage + useEffect 的组合,为前端应用提供了轻量级的持久化方案——从初始化读取到变更自动写入,整个链路简洁闭环。


本文基于项目 README.md 及全部源代码文件编写,所有代码示例均提取自该项目的实际文件。