《别再零散学 React!史上最全生态笔记:核心 + 周边 + 实战案例,帮你构建完整技术体系》
《别再零散学 React!史上最全生态笔记:核心 + 周边 + 实战案例,帮你构建完整技术体系》
Prorise序章:一份通往 React 高手之路的现代地图
摘要: 欢迎来到这本为 Vue 工程师及所有寻求 React 精通之路的开发者量身打造的深度笔记。我们深知,学习一门新技术的最终目的是掌握其解决实际问题的能力,并借此提升职业价值。因此,本系列笔记只有一个承诺:我们将为您铺设一条从 React 基础到全栈专家,乃至能够胜任一线技术岗位所需的最完整、最实战的学习路径。
我们为何如此确信?
这份自信并非空穴来风,它源于多年来在真实项目开发与技术教学中所沉淀的经验。我们见证了 React 生态的变迁,筛选出了在 2025 年 最具生产力、最受业界认可的技术组合。本笔记摒弃了简单的 API 罗列,转而采用以 海量实战项目驱动 的教学模式,确保您学习的每一个知识点,都能在具体的业务场景中落地生根。
这份笔记将为您呈现的知识全景
在本系列笔记中,我们将共同探索一片广阔的技术版图。这不仅是 React 本身,更是整个现代前端的黄金生态圈。
2025
第一阶段: 奠定坚实基础
我们将从 React 核心基础 出发,通过大量的实战练习,将组件、Props、State、Hooks 等核心概念内化为您的第二天性。随后,我们将全面拥抱 TypeScript,学习如何在 React 项目中构建坚不可摧的类型安全体系。
- React 基础
- React Hooks (基础与高阶)
- TypeScript 深度集成
第二阶段: 掌握现代生态
在掌握了核心之后,我们将立即进入由业界最佳实践构成的“应用层”生态。您将精通:
- 状态管理: 使用轻量而强大的 Zustand,体验“React 版 Pinia”的简洁与高效。
- 表单处理: 借助 React Hook Form 与 Zod,构建高性能、类型安全的复杂表单。
- UI 与样式: 拥抱 Tailwind CSS 与 DaisyUI,体验原子化 CSS 带来的开发效率革命。
- 数据请求: 精通 TanStack Query,以声明式的方式优雅地管理服务端状态。
- 路由控制: 掌握 TanStack Router,体验类型安全的现代化路由解决方案。
- 动画实现: 运用 Framer Motion,为您的应用注入流畅、迷人的动效。
第三阶段: 迈向高级工程化
具备了构建复杂应用的能力后,我们将向更高级的工程化领域进军,内容涵盖:
- React 设计模式
- 设计系统 (Design Systems)
- React 自动化测试
- Redux Toolkit (适用于大型项目)
第四阶段: 探索全栈与跨端
最后,我们将打破前端的边界,探索 React 的无限可能:
- 全栈开发: 深入学习业界领先的元框架 Next.js,从入门到精通,构建生产级的全栈应用。
- 移动端开发: 涉足 React Native,将您的 React 技能无缝迁移到移动端 App 开发。
开始前的准备:您的起点
为了确保您能从本笔记中获得最大收益,我们期望您具备以下基础知识:
学习前提:
- 熟练掌握 HTML 与 CSS。
- 具备扎实的 JavaScript 基础(无需精通面向对象 OOP 部分)。
- 了解 TypeScript 的基本语法和类型系统。
- (推荐)对 Tailwind CSS 有初步了解,这将让您在项目实践中更加得心应手。
我们即将启程。请准备好,这不仅是一次学习,更是一次将您打造为 React 高手的深度实践之旅。让我们开始吧。
第一章:本地开发环境搭建与工程化配置
摘要: 一个高效、规范的本地开发环境是快速掌握新技术的前提。在本章中,我们将回归开发的本质,优先在 Windows 中搭建一个快如闪电的本地 React 开发环境。我们将从使用 pnpm 初始化 Vite 项目开始,深入配置路径别名、ESLint 与 Prettier,并与 Cursor / VS Code 深度集成,实现保存即格式化的“心流”体验。本章的最终目标,是为您构建一个与专业团队对齐的、高度规范化的本地开发起点,为后续学习扫清一切障碍。
在本章中,我们将循序渐进地完成以下核心任务:
- 首先,我们将在 Windows 环境中准备好 Node.js 和 pnpm。
- 接着,我们将使用
pnpm create vite初始化一个纯净的 React + TypeScript 项目。 - 然后,我们将解剖项目的初始结构与启动流程,精确理解其工作原理。
- 之后,我们将深入
vite.config.ts和tsconfig.json,配置路径别名等开发效率优化。 - 最后,我们将引入并配置 ESLint 和 Prettier,建立一套现代化的代码质量与风格保障体系,并与编辑器深度集成。
1.1. 基础环境准备:配置 Windows 与 Node.js
对于现代前端开发而言,一个配置正确的本地环境是高效工作的基石。我们将直接在 Windows 系统上进行环境搭建。
1.1.1. 在 Windows 中安装 Node.js
首先,您需要访问 Node.js 官方网站,下载适用于 Windows 的 长期支持版 (LTS) 安装程序。
下载后,双击运行 .msi 安装文件,按照安装向导的提示,使用默认选项一路“下一步”即可完成安装。安装程序会自动将 node.exe 和 npm 添加到系统的 PATH 环境变量中。
安装完成后,您可以打开一个新的 PowerShell 或命令提示符窗口,执行以下命令来验证安装是否成功:
1 | node -v |
如果能看到对应的版本号输出,则代表 Node.js 环境已准备就绪。
1.1.2. 安装 pnpm
pnpm 是一个快速、节省磁盘空间的包管理器。我们推荐使用它来替代 npm。在安装好 Node.js 之后,于终端中执行以下命令进行全局安装:
1 | # 全局安装 pnpm |
1.2. 初始化项目:从纯净的 Vite 模板开始
我们将在 Windows 的文件系统中创建项目。
打开您的终端,导航到您希望创建项目的目录(例如 D:\projects),然后运行:
1 | # 在 projects 目录下创建我们的项目 |
Vite 会引导您完成几个选择:
1 | ✔ Select a framework: › React |
按照提示,进入项目目录,安装依赖,并启动它:
1 | cd prorise-react-guide |
Vite 启动后,会提供一个本地 URL,如 http://localhost:5173/。您可以在 Windows 的浏览器中打开此地址。看到旋转的 React Logo,代表您的项目地基已成功铺设。
1.3. 项目初探:解剖 React 应用的启动流程
理解现有文件如何协同工作,是进行任何修改前的必要步骤。
初始项目结构:
1 | # prorise-react-guide/ |
启动流程追溯:
index.html(宿主页面): 浏览器加载的第一个文件,是整个单页应用(SPA)的“舞台”。它的<body>中包含一个关键的<div>元素:<div id="root"></div>,这是 React 应用将会被注入的目标位置。同时,它通过<script type="module" src="/src/main.tsx"></script>引入了应用的入口脚本。src/main.tsx(应用入口): 这是 React 应用的启动引擎。它的核心职责是:- 导入 React 和 ReactDOM 库。
- 导入根组件
App。 - 使用
ReactDOM.createRoot()找到index.html中的<div id="root">元素,并创建一个 React 渲染根。 - 调用
root.render()方法,将<App />组件及其所有子组件渲染到该 DOM 节点中。
src/App.tsx(根组件): 这是组件树的最顶层。在初始模板中,它是一个标准的函数组件,返回一段 JSX(JavaScript XML),这段 JSX 定义了我们在浏览器中看到的初始界面。
1.4. 提升开发效率:配置路径别名
随着项目复杂度的增加,深层相对路径 (../../../) 会严重影响代码的可读性和可维护性。我们将配置路径别名,用 @ 符号直达 src 目录。
1.4.1. 配置 Vite
首先,修改 vite.config.ts 文件,告知 Vite 如何解析这个别名。
文件路径: vite.config.ts (修改)
1 | import { defineConfig } from 'vite' |
1.4.2. 配置 TypeScript
接着,修改 tsconfig.json 文件,让 TypeScript 的语言服务也能理解这个别名,从而提供正确的类型检查和路径补全。
文件路径: tsconfig.json (修改)
1 | { |
关键一步: 修改 tsconfig.json 后,您需要重启 TypeScript 服务才能让更改生效。在 Cursor / VS Code 中,按下 Ctrl+Shift+P (或 Cmd+Shift+P),输入并选择 TypeScript: Restart TS Server。
1.5. 代码规范体系:ESLint 与 Prettier
我们将建立一套自动化的代码规范体系,确保代码质量和风格的一致性。
1.5.1. 安装核心依赖
Vite 的 React 模板已包含部分 ESLint 配置,但我们需要对其进行扩展和现代化。
1 | # 卸载模板自带的旧版插件,并安装我们需要的现代化套件 |
@eslint/js,typescript-eslint: ESLint 核心与 TypeScript 解析支持。eslint-plugin-react,eslint-plugin-react-hooks: React 官方推荐的规则集,用于检查 Hooks 的正确使用等。eslint-config-prettier: 用于关闭所有与 Prettier 冲突的 ESLint 规则。
1.5.2. 现代化 ESLint 配置 (eslint.config.js)
我们将放弃传统的 .eslintrc 文件,全面转向名为 eslint.config.js 的现代化扁平配置文件。
首先,删除项目根目录下的 .eslintrc.cjs 文件,然后新建 eslint.config.js。
文件路径: eslint.config.js (新建)
1 | import globals from "globals"; |
1.5.3. 配置 Prettier 与编辑器集成
- 安装 Prettier:
1 | pnpm add -D prettier eslint-plugin-prettier |
- 创建 Prettier 配置文件: 在项目根目录创建
.prettierrc.json。
文件路径: .prettierrc.json (新建)
1 | { |
1.6. 集成现代化样式方案:Tailwind CSS
为了实现极致的开发效率和保持样式的一致性,我们的整个 React 学习之旅将以 Tailwind CSS 作为首选样式解决方案。现在,我们将其集成到通过 Vite 创建的项目中。
1.6.1. 安装核心依赖
在您的项目根目录终端中,运行以下命令来安装 tailwindcss 及其对等依赖。
1 | pnpm add -D tailwindcss postcss autoprefixer |
tailwindcss: Tailwind CSS 核心库。postcss: 一个用 JavaScript 工具转换 CSS 的平台,Tailwind 依赖它来处理 CSS。autoprefixer: 一个 PostCSS 插件,可以自动为 CSS 规则添加浏览器厂商前缀。
1.6.2. 生成配置文件
接下来,运行 Tailwind CSS 的初始化命令,它会自动在项目根目录创建两个关键的配置文件:tailwind.config.js 和 postcss.config.js。
1 | pnpm tailwindcss init -p |
1.6.3. 配置 Tailwind 的模板路径
打开新生成的 tailwind.config.js 文件,我们需要告诉 Tailwind 去哪里扫描我们的文件,以便它能找到所有使用的原子类并生成对应的 CSS。
文件路径: tailwind.config.js (修改)
1 | /** @type {import('tailwindcss').Config} */ |
这个配置意味着 Tailwind 会去扫描根目录的 index.html 文件以及 src 目录下所有以 .js, .ts, .jsx, 或 .tsx 结尾的文件。
1.6.4. 引入 Tailwind 指令
现在,我们需要在我们的主 CSS 文件中引入 Tailwind 的三个核心指令层。
打开 src/index.css 文件,清空所有内容,然后替换为以下三行:
文件路径: src/index.css (修改)
1 | @tailwind base; |
1.6.5. 清理并验证
为了避免样式冲突,请确保 src/App.css 文件是空的,并且 src/App.tsx 中没有 import './App.css'。
现在,重新运行 pnpm run dev,并用以下代码替换 src/App.tsx 的内容来验证 Tailwind 是否生效:
文件路径: src/App.tsx (临时修改以验证)
1 | function App() { |
如果页面背景变为深灰色,文字变为白色且带有下划线,那么 Tailwind CSS 已成功集成!至此,一个专业、高效且规范化的本地 React 开发环境已经搭建完毕。它为您后续深入学习 React 的核心概念和生态工具,提供了一个坚如磐石的起点。
第二章: React 核心原理深度转译
摘要: 本章是为 Vue 工程师量身定制的 React “翻译词典”。我们将剥离所有第三方库的干扰,专注于 React 框架自身的“第一性原理”。我们将逐一解构 React 的核心概念——组件、Props、State、生命周期与 Hooks,并将每一个概念都精确地与您所熟知的 Vue 3 Composition API 进行对等映射。学完本章,您将建立起从 Vue 到 React 的核心心智模型,为后续学习整个生态打下最坚实的基础。
在本章中,我们将像探索一幅画卷一样,循序渐进地揭开 React 的核心面纱:
- 首先,我们将从 组件定义 和 JSX 语法 开始,这是从 Vue 的模板系统到 React 声明式 UI 的第一次“范式转移”。
- 接着,我们将深入 Props 系统,理解 React 中单向数据流和组件通信的机制。
- 然后,我们将直面最核心的 State 与不可变性,将
useState与 Vue 的ref进行深度对比。 - 最后,我们将攻克 React 中最重要也最易混淆的概念——副作用与
useEffect,将其与 Vue 的watch和生命周期钩子进行映射。
2.1. 组件定义与 JSX:从模板到函数的范式转移
在上一节中,我们已经成功初始化了项目。现在,让我们深入代码,探讨 React 世界最基本的构成单元——组件,以及它与 Vue 组件在思想和语法上的根本差异。
本小节核心知识点:
- React 组件本质上是一个返回 UI 描述的 JavaScript 函数。
- 按照约定,组件函数的名称必须以 大写字母开头。
- 组件返回的“类 HTML”语法被称为 JSX (JavaScript XML),它是 JavaScript 的一种语法扩展,而非字符串或 HTML。
- JSX 允许我们在“模板”中无缝地嵌入 JavaScript 逻辑(变量、表达式、函数调用)。
2.1.1. 核心差异:Vue SFC vs. React 函数组件
痛点背景: 习惯了 Vue 单文件组件 (SFC) 清晰的 <template>、<script>、<style> 三段式结构,初次接触 React 将所有内容都写在一个函数内的做法,会本能地感到困惑:“我的 HTML 结构在哪里?逻辑和视图混在一起,如何维护?”
范式转变:组件即函数
Vue 的 SFC 将“视图的结构”、“视图的逻辑”和“视图的样式”物理分离在三个标签中。而 React 的核心哲学是,UI (视图) 本身就是程序逻辑 (状态) 的一种映射结果。因此,将驱动 UI 的逻辑和 UI 的声明耦合在一起,被认为是一种 高内聚 的表现。一个 React 组件就是一个纯粹的函数,它接收一些输入 (Props),然后返回一段描述 UI 应该长什么样的“蓝图” (JSX)。
让我们通过一个最简单的组件来直观感受这种差异。
Greeting.vue
1 | <script setup lang="ts"> |
Greeting.tsx
1 | function App() { |
关键语法转译:
- Vue 中的指令 (如
:src,{{ user.name }}) 在 JSX 中被统一为使用花括号{}包裹的 JavaScript 表达式。 class属性在 JSX 中必须写成className,因为class是 JavaScript 的保留关键字。
2.1.2. JSX 深度解析:不是模板,而是 JavaScript
初学者最容易误解的一点是把 JSX 当成是 React 发明的“模板语言”。恰恰相反,JSX 是 JavaScript 语法的“超集”。你写的每一行 JSX 标签,最终都会被构建工具(如 Babel 或 Vite 内置的 SWC)转换为常规的 JavaScript 函数调用。
JSX 代码:
1 | const element = <h1 className="greeting">Hello, world</h1>; |
编译后的 JavaScript 代码 (示意):
1 | const element = React.createElement( |
理解 JSX 的本质是 React.createElement() 的语法糖,是从 Vue 思维转向 React 思维的关键一步。这意味着,你在 JSX 中能做的一切,都受限于 JavaScript 的语法规则和能力。
在 JSX 中嵌入表达式
由于 JSX 就是 JavaScript,我们可以用 {} 在其中无缝嵌入任何有效的 JavaScript 表达式。
1 | function UserInfo() { |
1
2
3
4
5
<div>
<h2>Score Details</h2>
<p>Your score is: 95</p>
<p>Grade: A</p>
</div>
JSX 也是表达式
React.createElement() 函数的返回值是一个普通的 JavaScript 对象,这个对象被称为 “React 元素”。因此,JSX 本身也可以被当作一个值来使用——可以把它赋值给变量、作为函数参数传递,或者在 if 语句和 for 循环中使用。
1 | function getGreeting(isLoggedIn: boolean) { |
在掌握了 JSX 的基本语法后,我们来解决两个最常见的动态 UI 场景:如何根据条件显示或隐藏内容,以及如何渲染一个数据列表。这两种场景将进一步深化您对“React 使用纯 JavaScript 解决问题”这一核心思想的理解。
本小节核心知识点:
- React 中 没有
v-if或v-for这样的模板指令。 - 条件渲染 通过标准的 JavaScript 表达式来实现,主要是 **三元运算符** 和 **逻辑与 (`&&`) 运算符**。
- 列表渲染 通过数组的 `.map()` 方法将数据项转换为一个 JSX 元素数组。
- 在列表渲染中,为每个列表项提供一个稳定且唯一的
keyprop 是至关重要的。
2.1.3. 条件渲染 (v-if vs. JavaScript 表达式)
痛点背景: Vue 的 v-if/v-else-if/v-else 指令提供了一套非常直观且功能完备的条件渲染语法,可以直接在模板中使用。React 如何用纯 JavaScript 实现等价的功能?
范式转变:UI 即函数返回值
回想一下 2.1.2 节的知识点:JSX 也是表达式。这意味着,我们可以将 JSX 元素作为 if 语句的返回值,或在三元表达式中使用。这赋予了我们用标准 JavaScript 流程控制来组织 UI 的能力。
场景一:if...else... (对标 v-if/v-else)
当您需要根据条件在两个 UI 块之间进行选择时,三元运算符 是最简洁、最常用的方式。
LoginStatus.vue
1 | <script setup lang="ts"> |
LoginStatus.tsx
1 | function LoginStatus() { |
场景二:仅 if (对标 v-if / v-show)
当您只想在满足某个条件时才渲染某个元素,否则什么都不渲染时,逻辑与 (&&) 运算符 是最优雅的捷径。
这是利用了 JavaScript 的“短路”特性:如果 && 左侧的表达式为 false,则整个表达式的结果就是 false,React 不会渲染任何东西;如果左侧为 true,则表达式的结果为 && 右侧的 JSX 元素,React 会将其渲染出来。
Mailbox.vue
1 | <script setup lang="ts"> |
Mailbox.tsx
1 | function Mailbox() { |
2.1.4. 列表渲染 (v-for vs. .map())
痛点背景: Vue 的 v-for 指令 (v-for="item in items" :key="item.id") 是渲染列表的直观语法。React 如何处理同样的需求?
范式转变:数据转换
React 将列表渲染视为一个标准的“数据转换”问题:我们有一个数据数组,需要将它 转换 成一个 React 元素(JSX)的数组。JavaScript 数组原生就提供了一个完美的方法来完成这个任务:.map()。
.map() 的基本用法与 key 的重要性
.map() 方法会遍历数组的每一项,并根据您提供的回调函数的返回值,创建一个 新数组。在 React 中,我们正是利用它来生成一个 JSX 元素列表。
TodoList.vue
1 | <script setup lang="ts"> |
TodoList.tsx
1 | function TodoList() { |
必须提供 key Propkey 是 React 用来识别列表中哪些项发生了变化(增、删、改、移动)的唯一标识。它帮助 React 的 “diffing” 算法能够高效地更新 UI。
key在 兄弟节点之间必须是唯一的。key应该是一个 稳定 的标识符,通常是数据项中的id。- 绝对不要 使用数组的索引
index作为key,除非列表是纯静态、永不重排或筛选的。否则会导致严重的性能问题和 state bug。
2.2. Props 系统:组件通信的契约
在 Vue 中,我们使用 props 来实现父组件向子组件的数据传递。React 同样拥有 props 的概念,其核心思想与 Vue 完全一致:数据是自上而下、单向流动的。
本小节核心知识点:
props(properties 的缩写) 是从父组件传递给子组件的数据。- 对于接收数据的子组件来说,`props` 是完全只读的 (read-only)。子组件绝对不能直接修改它接收到的
props对象。这保证了单向数据流的可预测性。 - 在函数组件中,
props是函数的 第一个参数。 - 结合 TypeScript,我们可以为
props定义清晰的类型接口,建立组件之间严格的数据“契约”。
2.2.1. Props 的传递与接收
痛点背景: 在 Vue 3 的 <script setup> 中,我们使用 defineProps 宏来清晰地声明一个组件期望接收哪些 props 及其类型。这种方式直观且类型安全。React 如何实现类似的功能?
范式转变:函数参数与类型接口
在 React 中,props 的概念与 JavaScript 函数的参数几乎等同。当你在 JSX 中使用一个组件并为其添加属性时,React 会将这些属性收集到一个对象中,并将这个对象作为第一个参数传递给你的组件函数。
当结合 TypeScript 时,我们不再需要像 defineProps 这样的宏。取而代之的是,我们使用 TypeScript 的 interface 或 type 来定义这个 props 对象的“形状”(Shape),从而实现比 Vue 更原生、更强大的类型约束。
让我们通过一个用户卡片组件来对比这个过程。
UserCard.vue
1 | <script setup lang="ts"> |
UserCard.tsx
1 | // 1. 使用 interface 定义 props 的类型契约 |
如何使用这个组件:
无论是在 Vue 还是 React 中,父组件使用子组件并传递 props 的方式都非常相似。
App.tsx (父组件)
1 | import UserCard from './UserCard' |
{} 在 JSX 属性中的使用规则:
- 传递字符串:
<UserCard name="Guest" /> - 传递其他类型 (数字, 布尔值, 对象, 变量等): 必须使用花括号
{},例如age={99},isPremiumUser={false}。
2.2.2. 特殊的 Prop: children
在 Vue 中,我们使用 <slot> 机制来让父组件向子组件分发内容。React 中有一个更简单、更符合直觉的对等概念,那就是一个名为 children 的特殊 prop。
当你在一个组件的起始标签和结束标签之间放置任何内容时,这些内容都会被收集起来,并通过一个名为 children 的 prop 传递给该组件。
核心对标: React 的 props.children 精确对标 Vue 的 默认插槽 (<slot />)。
让我们创建一个通用的 Card 组件来演示 children 的用法。
Card.tsx
1 | import React from 'react' // 引入 React 以使用 ReactNode 类型 |
现在,我们可以在 App 组件中使用这个 Card 组件来包裹任意内容。
App.tsx
1 | import Card from './Card' |
props.children 的机制是 React 组合优于继承 设计哲学的核心体现。通过它,我们可以构建出高度灵活和可复用的布局组件(如 Card, Modal, Sidebar),而无需关心它们内部具体要渲染什么内容。
2.2.3. 属性钻探:问题识别及其对架构的影响
Props 是组件通信的基础,但如果滥用,它会引发一个常见且棘手的架构问题——属性钻探 (Prop Drilling)。
核心概念: 属性钻探 是指,为了将某个 prop 从顶层父组件传递给深层嵌套的子组件,不得不让所有中间层级的组件都去接收并向下传递这个 prop,即使这些中间组件本身根本不需要使用它。
痛点背景:
想象一个组件树结构:App -> UserProfile -> UserAvatar -> UserImage。现在,App 组件持有一个 imageUrl,但只有最深层的 UserImage 组件需要它来显示图片。
1 | - App |
为了让 imageUrl 到达 UserImage,我们必须:
App将imageUrl作为 prop 传给UserProfile。UserProfile不使用imageUrl,但必须接收它,再原封不动地传给UserAvatar。UserAvatar同样不使用imageUrl,但必须接收它,再传给UserImage。
这种层层传递就像用钻头打井一样,将属性“钻”过一个个组件层级,因此得名。
为什么这是一个问题?
- 代码冗余与耦合: 中间组件 (
UserProfile,UserAvatar) 的props接口被迫包含了它们本不关心的属性,导致组件职责不清,与顶层数据源产生了不必要的耦合。 - 重构困难: 如果未来
UserImage不再需要imageUrl,或者需要一个新的 prop,你需要修改整条传递链路上的所有组件,维护成本极高。 - 可读性差: 当你阅读
UserProfile的代码时,看到一个imageUrlprop,你无法立即判断它是否被当前组件使用,还是仅仅是一个“二传手”。
何时应该警惕 Prop Drilling?
当一个 prop 的传递深度 超过两层,并且中间组件完全不使用它时,就应该将其视为一个需要解决的架构“坏味道”。
Prop Drilling 本身并不是一种错误,而是一种需要权衡的模式。对于浅层(1-2 层)的传递,它依然是最简单直接的方案。本节的目的是让您能够 识别 出过度钻探的场景,并了解我们将在后续章节中介绍的解决方案(如 Context 和状态管理库)。
2.3. State 与不可变性:从 ref 到 useState
如果说 props 是组件从外部接收的“指令”,那么 state 就是组件自己内部维护、可以随时间变化的数据。它是组件交互和动态更新的源泉。
在前一节中,我们掌握了如何通过 Props 实现父子组件间的静态数据传递。但一个应用的核心是交互和变化。现在,我们将深入探讨 React 中最核心的概念:State(状态),以及它与 Vue 响应式系统在心智模型上的根本区别。
本小节核心知识点:
useState是 React 提供的、用于在函数组件中添加和管理 内部状态 的核心 Hook。useState函数接收一个参数作为 初始状态,并返回一个包含两个元素的数组:[当前状态值, 状态更新函数].- React 的状态是 不可变的 (Immutable)。我们 绝不能 直接修改状态变量。
- 必须使用
useState返回的 状态更新函数 来替换旧的状态,从而触发组件的重新渲染。
2.3.1. 核心心智模型转换:从“直接修改”到“请求替换”
痛点背景: 在 Vue 3 中,我们通过 ref 创建响应式数据。其心智模型非常直观:我们通过修改 .value 属性来 直接改变 (mutate) 数据,框架会自动追踪这些变化并更新视图。例如:count.value++。这种方式符合大多数人的编程直觉。
范式转变:不可变性与状态替换
React 采取了截然不同的函数式编程思想。它认为状态应该是 不可变的。当你想要更新状态时,你不能在“原地”修改它,而是需要创建一个 新的状态值,然后调用状态更新函数来 “请求” React 用这个新值 替换 掉旧的值。
为什么是这样?
React 通过比较新旧两个状态对象的 引用地址 (Object Identity) 是否相同,来高效地判断是否需要触发组件的重新渲染。如果你直接修改了旧对象内部的属性,对象的引用地址并未改变,React 可能会认为没有任何变化,从而跳过渲染,导致 UI 不更新。
思想:直接修改
1 | import { ref } from 'vue' |
思想:创建并替换
1 | import { useState } from 'react' |
2.3.2. useState 实战:构建一个计数器
让我们通过一个经典的计数器案例,来精确对比 ref 和 useState 的用法。
Counter.vue
1 | <script setup lang="ts"> |
Counter.tsx
1 | import { useState } from 'react'; |
2.3.3. 状态更新的进阶技巧(重要)
状态更新的异步性与函数式更新
一个常见的误解是认为调用 setCount(count + 1) 后,count 变量会立即更新。实际上,React 的状态更新可能是 异步的 和 批量处理的 (batched)。React 可能会将多次状态更新合并为一次,以优化性能。
这就带来一个问题:如果你基于当前 state 计算下一个 state,可能会因为 state 尚未更新而得到错误的结果。
错误的示例:
1 | function handleTripleIncrement() { |
解决方案:函数式更新
为了解决这个问题,状态更新函数可以接收一个 函数 作为参数。这个函数会自动接收 最新的、待处理的 state 作为其参数,并返回新的 state。
1 | function handleTripleIncrement() { |
最佳实践: 当你的新状态需要依赖于前一个状态时,总是 使用函数式更新的形式。
更新对象与数组状态
不可变性的原则在处理对象和数组时尤为重要。
更新对象:
1 | import { useState } from 'react'; |
更新数组:
1 | import { useState } from 'react'; |
常用的不可变数组操作包括:
- 添加:
setTodos([...todos, newTodo]) - 移除:
setTodos(todos.filter(todo => todo !== itemToRemove)) - 修改:
setTodos(todos.map(todo => todo === itemToUpdate ? updatedItem : todo))
2.3.4. useReducer 钩子:处理复杂状态逻辑
当一个组件的状态逻辑变得复杂,或者下一个状态依赖于前一个状态的多个部分时,useState 可能会变得笨拙和难以维护。此时,我们需要一个更强大的工具来组织状态变更。
本小节核心知识点:
useReducer是useState的一种替代方案,专为管理 复杂的状态对象和状态转换逻辑 而设计。- 它将 更新逻辑 (如何更新) 从组件的事件处理函数中抽离出来,集中到一个名为
reducer的纯函数中,使得状态管理更加可预测和可测试。 - 精确对标: `useReducer` 的思想精确对标 Vuex/Pinia 在组件内部进行状态管理的模式 (
(state, action) => newState)。
痛点背景:当 useState 变得力不从心
想象一个稍微复杂一点的计数器,它不仅能增加,还能减少、重置,甚至根据一个步长来增加。如果用 useState 来管理这个计数器的值和步长,代码可能会是这样:
1 | function ComplexCounter() { |
当操作类型更多(例如:multiply, divide),或者状态对象更复杂({ count, step, max, min })时,这种分散的 setXXX 调用会变得难以管理。
解决方案:使用 useReducer 集中管理状态逻辑
useReducer 接收三个参数:reducer 函数、initialState 初始状态,以及一个可选的 init 函数。它返回当前状态和一个 dispatch 函数。
reducer函数: 一个形如(state, action) => newState的纯函数。它接收当前的状态和描述“要做什么”的action对象,然后计算并返回一个 全新的状态。dispatch函数: 当你想要更新状态时,你不再调用setXXX,而是调用dispatch({ type: 'SOME_ACTION', payload: ... })。React 会将当前state和你派发的action传递给你的reducer函数,并将reducer的返回值作为新的状态。
让我们用 useReducer 来重构上面的复杂计数器:
ComplexCounter.tsx
1 | import { useReducer } from 'react'; |
优势总结:
- 逻辑内聚: 所有的状态转换逻辑都被收敛到了
reducer函数中,组件本身只负责派发“意图”(actions),不再关心“如何”更新状态。 - 可测试性:
reducer是一个纯函数,它的输出只依赖于输入,不依赖于任何外部环境。这意味着我们可以脱离 React 组件,对它进行独立的单元测试。 - 可预测性: 当状态出现问题时,我们只需要关注
reducer函数和传入的action序列,极大地缩小了调试范围。
2.4. Fragments: 告别不必要的 <div> 包装
在 Vue 2 的时代,一个经典的规则是每个组件的 <template> 必须有一个唯一的根元素。如果你尝试返回多个并列的元素,编译器会报错。为了解决这个问题,我们常常被迫用一个不具备任何语义的 <div> 将它们包裹起来。
Vue 3 已经移除了这个限制,允许组件有多个根节点。React 从一开始就通过一个名为 Fragments 的特性来解决这个问题。
本小节核心知识点:
- Fragment 允许你将多个子元素组合在一起,而无需向 DOM 添加额外的节点。
- 它是解决因 JSX 表达式必须返回单个元素而引入不必要
<div>包装器的完美方案。 - Fragment 有两种语法:长语法
<React.Fragment>和更常用的短语法<></>。
痛点背景:破坏布局的额外 <div>
想象一下,你需要创建一个包含多列的表格行组件 Columns。HTML 的规范要求 <tr> 标签的直接子元素必须是 <td>。
1 | <table> |
如果我们的 Columns 组件为了满足“单一根元素”的规则,用一个 <div> 包裹了多个 <td>,那么最终渲染出的 DOM 结构将是无效的,并很可能导致表格布局错乱。
错误的做法:
1 | // Columns.tsx |
解决方案:使用 Fragment
Fragment 就像一个看不见的包装器,它在组件的返回值中满足了“单一元素”的语法要求,但在最终的 DOM 渲染中会完全消失。
Columns.tsx
1 | function Columns() { |
最终渲染的有效 HTML:
1 | <tr> |
Columns.tsx
1 | import React from 'react'; // 需要导入 React |
何时使用长语法?
唯一的场景是当你需要为一个 Fragment 提供 key prop 时,例如在循环渲染中使用 Fragment。短语法 <></> 不支持任何属性。
1 | function Glossary({ items }) { |
总结: 在日常开发中,当你需要从组件返回多个并列元素时,优先使用 <></>。它简洁且能解决绝大多数问题。只有在列表渲染需要 key 时,才换用 <React.Fragment>。
2.5. 样式方案概览:从 scoped 到“万物皆 CSS”
对于 Vue 开发者来说,样式的处理方式是固定的、内置的、且极其舒适的:在 .vue 文件中写一个 <style scoped> 标签,框架会自动处理好一切,保证样式不会泄露到其他组件。
React 在这方面则完全不同。它本身 没有任何内置的样式解决方案。这既是它的灵活性所在,也是初学者(尤其是从 Vue 过来的开发者)最感困惑的地方之一。你需要自己选择并配置一个样式方案。
本小节核心知识点:
- React 核心库不提供样式封装机制。
- 样式隔离是一个需要通过社区方案来解决的架构问题。
- 主流方案包括 CSS Modules, CSS-in-JS, 以及现代最推荐的 Utility-First CSS (Tailwind CSS)。
文化冲击:Vue 的 <style scoped> 是如何工作的?
在我们探讨 React 的方案之前,有必要先理解 Vue 的 scoped 做了什么。当你写下:
1 | <style scoped> |
Vue 的编译器会做两件事:
- 给你的组件模板中的每个元素添加一个唯一的
data属性,例如<h1 class="title" data-v-f3f3eg9>. - 将你的 CSS 选择器改写为
.title[data-v-f3f3eg9] { color: red; }。
通过这种属性选择器的方式,Vue 实现了精准的样式作用域隔离。
React 生态中的样式方案
以下是 React 生态中最主流的几种样式方案,我们将从最接近 scoped 体验的方案讲起。
方案一:CSS Modules (最接近 scoped 的体验)
这可能是 Vue 开发者过渡到 React 时最容易接受的方案。Vite 已内置支持。
工作方式: 你将 CSS 文件命名为 [name].module.css (例如 MyComponent.module.css)。当你 import 这个文件时,它不会全局注入样式,而是返回一个对象,该对象将你写的类名映射到一个哈希过的、保证唯一的类名上。
MyComponent.module.css:
1 | .title { |
MyComponent.tsx:
1 | // 1. 导入 .module.css 文件 |
优点: 编译时处理,零运行时开销。实现了和 scoped 同样的效果。
缺点: 类名需要通过 styles.xxx 的方式引用,稍微有点繁琐。
方案二:CSS-in-JS (e.g., Styled Components, Emotion)
这是在 React 社区非常流行的一种“万物皆 JS”的哲学体现。你直接在 JavaScript 文件中用模板字符串或对象来写 CSS。
工作方式: 你使用库提供的函数(如 styled)来创建一个附加了样式的 React 组件。
Button.tsx (使用 Styled Components):
1 | import styled from 'styled-components'; |
优点: 样式的动态能力极强,可以访问组件的 props 和 state。组件和它的样式被真正绑定在一起,实现了高内聚。
缺点: 存在一定的运行时性能开销(尽管现代库已经优化得很好)。
方案三:Utility-First CSS (Tailwind CSS - 2025 年推荐方案)
这是目前业界最受推崇的方案。它颠覆了传统的为组件编写独立 CSS 的思路。
工作方式: 你不再为组件写 CSS 类,而是直接在 JSX 中组合大量预设的、功能单一的 原子化 class。
UserProfile.tsx (使用 Tailwind CSS):
1 | function UserProfile({ name, role, imageUrl }) { |
优点: 开发速度极快,无需在 JS 和 CSS 文件间切换。样式高度一致。最终打包体积非常小(因为它会移除所有未使用的 class)。
缺点: 初学者可能会觉得 JSX “不干净”。需要一个适应过程来记忆常用的 class。
路线图建议:
- 如果您想快速找到一个与 Vue
scoped体验最相似的替代品,请从 CSS Modules 开始。 - 如果您准备拥抱现代 React 生态的最佳实践,并追求极致的开发效率,我们强烈建议您直接学习并使用 Tailwind CSS。在我们后续的实战章节中,也将以 Tailwind CSS 作为主要的样式解决方案。
2.6. 事件处理:响应用户交互
到目前为止,我们已经学会了如何用 State 驱动 UI 变化,但变化的“扳机”——用户的交互——我们还未系统学习。本节将深入 React 的事件处理机制,完成从“静态”到“交互”的关键一步。
本小节核心知识点:
- React 的事件绑定遵循 小驼峰命名法 (camelCase),例如
onClick、onCopy、onMouseOver。 - 传递给事件处理器的 必须是一个函数引用,而不是函数调用。例如,
onClick={handleClick}是正确的,而onClick={handleClick()}是错误的。 - 若要向事件处理函数传递参数,需要使用一个 内联箭头函数 进行包装,例如
onClick={() => handleDelete(id)}。 - React 的事件对象是一个 合成事件 (SyntheticEvent) 对象,它抹平了主流浏览器之间的差异。
2.6.1. 核心语法:从 @click 到 onClick
痛点背景: 在 Vue 中,我们习惯于使用 @ 符号(或 v-on:)来监听 DOM 事件,语法简洁明了,如 @click="handleClick"。React 的事件绑定在形式上更接近原生 JavaScript DOM 的 onclick 属性,但有一些关键区别。
范式转变:JSX 属性与函数引用
在 React 中,事件监听器是作为 JSX 元素的一个属性来提供的。你需要记住两个核心转换规则:
- 命名: 所有事件名都采用小驼峰式命名,例如
onclick变为onClick,onmouseover变为onMouseOver。 - 值: 传递给事件属性的值不再是字符串,而是一个用花括号
{}包裹的 函数引用。
让我们通过以下提供的代码,将 Vue 和 React 的事件处理进行一次精确对比。
Button.vue
1 | <script setup> |
Button.tsx
1 | function Button() { |
React 支持所有标准 DOM 事件,我们只需要将它们转换为小驼峰命名即可。
Copy.tsx
1 | function Copy() { |
Move.tsx
1 | function Move() { |
2.6.2. 关键陷阱:函数引用 vs. 函数调用
这是从 Vue 过来的开发者最容易犯的错误之一。
onClick={handleClick}: (正确) 我们将handleClick函数 本身 作为 prop 传递给了<button>。React 会持有这个函数的引用,并在用户点击按钮时 替我们调用它。onClick={handleClick()}: (错误) 这里我们 立即调用 了handleClick函数,并将它的 返回值 (undefined) 传递给了onClick。这意味着,在组件 渲染时,这个函数就会被执行一次,而用户实际点击时,什么都不会发生。
切记: 传递给事件监听器的必须是一个函数,而不是函数执行的结果。
2.6.3. 向事件处理函数传递参数
痛点背景: 在 Vue 中,如果需要传递参数,语法非常直观:@click="deleteItem(item.id)"。在 React 中,如果我们直接写 onClick={handleDelete(item.id)},就会掉入上面“函数调用”的陷阱。
解决方案:内联箭头函数
为了解决这个问题,我们需要在事件处理器外面再“包”一层函数。最简洁的方式就是使用内联箭头函数。
1 | import { useState } from 'react' |
2.6.4. React 的合成事件对象
当你需要访问原生 DOM 事件对象时(例如,event.preventDefault() 或获取输入框的值 event.target.value),React 会提供一个 合成事件 (SyntheticEvent) 对象。
这个对象是 React 对原生浏览器事件的跨浏览器包装器,它的接口与原生事件几乎完全相同,但保证了在所有浏览器中的行为一致性。
1 | function Form() { |
事件处理总结:
- 事件名使用
on+EventName的小驼峰形式。 - 处理器属性的值必须是
{函数引用}。 - 传递参数需使用
={() => handler(arg)}的箭头函数包装。
2.7. Portals:挣脱 DOM 束缚的传送门
通常情况下,一个组件返回的 JSX 会被挂载到其在 DOM 树中的父节点上。但有时,我们需要“打破常规”,将一个组件的视觉呈现“传送”到 DOM 树的其他位置——这正是 Portal 的用武之地。
本小节核心知识点:
Portal提供了一种将子节点渲染到存在于父组件 DOM 结构之外的 DOM 节点的官方解决方案。- 它的核心使用场景是处理那些需要在视觉上“脱离”其容器的组件,如:模态框 (Modals), 弹出式菜单 (Popups), 提示框 (Tooltips)。
- 核心 API 是
ReactDOM.createPortal(child, container)。 - 精确对标: React 的
Portal与 Vue 3 的<Teleport>组件在思想和用途上完全相同。
2.7.1. 痛点背景:CSS z-index 与 overflow 的陷阱
想象一下,你正在构建一个位于深层嵌套组件中的“复制成功”提示框。这个父组件可能应用了一些 CSS 样式,比如 overflow: hidden 或 position: relative,这会创建一个新的堆叠上下文 (stacking context)。
在这种情况下,即使你给提示框设置了很高的 z-index,它的显示范围和层级也会被其父容器的样式所限制,导致它被意外裁剪或遮挡。
我们的目标:无论组件在 React 树中嵌套得多深,我们都希望它的视觉产物(例如一个模态框或提示)能够被渲染到顶层的 <body> 标签下,从而在视觉上覆盖页面的所有其他内容,不受父级 CSS 的影响。
2.7.2. 解决方案:使用 createPortal
React DOM 提供了一个名为 createPortal 的函数,它允许我们实现这种“传送”。
createPortal 接收两个参数:
child: 任何可被渲染的 React 子元素,例如一段 JSX。container: 一个真实存在的 DOM 节点,这是child将被传送并挂载的目标位置。
现在,一步步实现一个“点击复制后在页面底部弹出提示”的功能。
第一步:准备 HTML “传送门”目标
首先,我们需要在 index.html 中预留一个 DOM 节点,作为我们提示框的渲染目标。
文件路径: index.html
1 |
|
现在,我们的 DOM 结构中有两个独立的“根”,#root 用于主应用,#portal-popup 专门用于接收被传送过来的内容。
第二步:创建 Portal 组件
接下来,我们创建 PopupContent 组件。这个组件的核心就是调用 createPortal。
文件路径: src/components/PopupContent.jsx
1 | import { createPortal } from "react-dom"; |
第三步:在父组件中正常使用
Portal 最奇妙的一点在于:尽管 PopupContent 的 DOM 被渲染到了别处,但它在 React 组件树 中仍然是 CopyInput 的子组件。这意味着它可以正常接收来自 CopyInput 的 props(如 copied),并且事件可以正常地从 PopupContent 冒泡到 CopyInput。
文件路径: src/components/CopyInput.jsx
1 | import { useState } from 'react' |
核心洞见:React 树 vs. DOM 树Portal 的使用让我们清晰地看到了两个“树”的分离:
- React 组件树:
App -> CopyInput -> PopupContent。逻辑关系是父子,props 和事件流都遵循这个结构。 - DOM 树:
PopupContent渲染出的<div>并不在CopyInput渲染出的<div>内部,而是作为#portal-popup的子节点,与#root处于同一层级。
这种分离,让我们可以将组件的 状态逻辑 与其 视觉呈现 在 DOM 中的位置解耦。
Portal 总结:
当你需要构建一个在视觉上需要“弹出”并覆盖其他元素的组件时(最典型的就是模态框),Portal 是最干净、最符合 React 官方推荐的实现方式。它能让你在享受组件化带来的便利的同时,彻底摆脱 CSS 堆叠上下文带来的烦恼。
第三章: 副作用与生命周期:useEffect 完全指南
摘要: 在上一章,我们掌握了如何使用 useState 来管理组件的内部状态,并驱动 UI 更新。然而,一个真实的组件不仅要渲染 UI,还需要与“外部世界”打交道。本章将通过一个具体的实战案例,引出 React 中用于处理这类“副作用”的统一解决方案——useEffect Hook。我们将彻底抛弃枯燥的语法讲解,从代码出发,精确地将 useEffect 的用法映射到您所熟知的 onMounted, watch 和 onUnmounted,真正打通从 Vue 到 React 在组件生命周期和响应式方面的核心认知。
在本章中,我们将循序渐进地完成一次从“有状态组件”到“有副作用组件”的升级:
- 首先,我们将从一个 简单的计数器 开始,并提出一个新需求:将计数器的值同步到浏览器的标题栏上。
- 接着,我们将引入 useEffect 来 解决这个“副作用”问题,并在此过程中解构其核心组成:效应函数与依赖数组。
- 然后,我们将深入探索 依赖数组 的三种核心用法,将它与 Vue 的 onMounted 和 watch 进行精确对标。
- 最后,我们将通过一个 新的定时器案例,学习 useEffect 的 清理机制,并将其与 Vue 的 onUnmounted 进行映射,形成完整的知识闭环
3.1. 第一次接触:当组件需要与外部世界对话
让我们从第二章结尾的 Counter 组件开始。它是一个纯粹的内部状态组件,只关心自己的 count 状态和 UI 渲染。
Counter.tsx (我们已有的知识)
1 | import { useState } from 'react'; |
新需求: 我们希望每当 count 变化时,浏览器的标签页标题也能同步更新为 “You clicked X times”。
思考一下: 这个操作应该放在组件的哪个部分?
- 不能 直接放在组件函数的主体里,因为那里的代码会在 每次渲染时 都执行,我们不希望在渲染过程中执行 DOM 操作。
- 不能 放在事件处理器里,因为我们希望在 组件加载完成时 也设置一次标题,而不仅仅是点击时。
这个“更新浏览器标题”的操作,就是一个典型的 副作用 (Side Effect)。它不直接计算和返回 JSX,而是去操作一个 React 组件之外的系统(在这里是浏览器 DOM)。
3.1.1. 解决方案:使用 useEffect 同步状态到外部
为了处理这种副作用,我们引入 useEffect。
CounterWithTitle.tsx (引入 useEffect)
1 | import { useState, useEffect } from 'react' |
现在,当你运行这个组件时,会发现:
- 组件首次加载时,标题会更新为 “你点击了 0 次”。
- 每次点击按钮,count 增加,标题也会同步更新。
useEffect 完美地解决了我们的问题。它就像一个我们安置在组件渲染流程之外的“特殊区域”,专门用来执行那些不方便在主函数体中进行的操作
3.1.2. 解构 useEffect:效应函数与依赖数组
上面的代码 useEffect(() => { … }); 是 useEffect 最基本的形式,但它并不完美(打开控制台,你会发现 “Effect function is running!” 在每次渲染时都会打印)。为了精确控制副作用的执行时机,我们需要理解它的完整结构:
- setup 函数: 第一个参数,一个函数。我们称之为“效应函数”。你的副作用逻辑就写在这里(例如,document.title = …)。
- dependencies 数组: 第二个参数,一个 可选的 数组。我们称之为“依赖数组”。这是 useEffect 的灵魂所在,它告诉 React:“只有当这个数组里的值发生变化时,才需要重新执行 setup 函数。”
现在,让我们用依赖数组来优化我们的组件,让 effect 只在 count 变化时执行:
1 | // 👇 使用 useEffect 来处理副作用 |
现在,useEffect 的行为变得更加智能:
- React 在每次渲染后,会比较 [count] 这次的值和上次渲染时的值。
- 如果 count 没变(例如,父组件的其他 state 变化导致本组件重渲),React 会跳过 setup 函数的执行。
- 只有当 count 的值确实发生了变化,setup 函数才会再次运行。
3.2. 精通依赖数组:从 onMounted 到 watch 的精确映射
通过控制依赖数组的内容,我们可以精确地模拟出 Vue 中几乎所有的生命周期和侦听行为。
用法一:空数组 [] —— 对标 onMounted
如果你提供一个 空的依赖数组 [],这意味着 setup 函数的依赖永远不会改变。因此,这个 setup 函数将 只在组件第一次渲染挂载后执行一次。
痛点背景: 在 Vue 中,我们需要在组件挂载后从服务器获取初始数据,我们会这样写:
1 | onMounted(async () => { |
在 React 中如何实现?
解决方案:
UserProfile.tsx
1 | import { useState, useEffect } from 'react'; |
用法二:包含值的数组 [dep1, dep2] —— 对标 watch
当你向依赖数组中提供一个或多个值时,useEffect 就会像 Vue 的 watch 一样工作:它会 “侦听” 这些值的变化,并在任何一个值改变后的下一次渲染完成后,执行 setup 函数。
痛点背景: 在 Vue 中,如果一个 prop (例如 userId) 变化了,我们需要重新获取数据。我们会使用 watch:
1 | watch(() => props.userId, (newUserId) => { |
这正是我们在 CounterWithTitle 示例中已经做过的事情。
1 | // 👇 使用 useEffect 来处理副作用 |
一个常见的陷阱: 如果你不提供依赖数组(useEffect(() => { ... })),setup 函数会在 每一次渲染后 都执行。这等价于 Vue 的 onUpdated 加上 onMounted,通常会导致性能问题或无限循环,是你应该极力避免的模式。
3.3. 清理机制:对标 onUnmounted
副作用通常需要“清理”。例如,如果你设置了一个定时器,或者添加了一个全局事件监听,你需要在组件被销毁时取消它们,以防止内存泄漏或 bug。
解决方案: useEffect 的 setup 函数可以 返回另一个函数。React 会将这个返回的函数保存下来,并在 下一次 effect 即将重新执行之前,或者 组件即将卸载时,自动调用它。这个返回的函数就是 清理函数。
痛点背景: 在 Vue 中,我们在 onUnmounted 钩子中执行清理工作。
1 | onMounted(() => { |
React 中的等价实现:
Timer.tsx
1 | import { useState, useEffect } from 'react'; |
通过将副作用的“创建”和“清理”逻辑放在同一个 useEffect 内部,React 让相关联的代码更加内聚,也更不容易忘记清理。
3.4. key 的深度应用:重置组件状态的艺术
在 2.1.4 节,我们已经知道 key 在列表渲染中是必不可少的,它帮助 React 识别哪些项被更改、添加或删除。然而,key 的作用远不止于此。它实际上是 React 用来标识一个组件 身份 (identity) 的核心线索。
本小节核心知识点:
- 当一个组件的
key发生变化时,React 不会去更新(diff)现有的组件实例。 - 相反,React 会认为这是一个 全新的组件,从而 卸载 (unmount) 旧的组件实例(包括其所有内部 state),并 挂载 (mount) 一个全新的实例。
- 改变
key是一种强大的、声明式的、用于完全重置一个组件及其子组件状态的策略
3.4.1. 原理速览:一个简单的 Switcher 示例
在深入探讨复杂的应用场景之前,让我们先通过一个极简的 Switcher 组件来直观地理解 key 是如何工作的。
文件路径: src/components/Switcher.tsx
1 | import { useState } from "react"; |
发生了什么?
<input>元素是一个 非受控组件,它自己在内部管理着用户输入的值。- 当你在输入框里输入一些文字时,这些文字被保存在这个
<input>实例的内部状态中。 - 当你点击“切换”按钮时,
sw的值改变,导致<input>的keyprop 从"light"变成了"dark"。 - React 发现
key变了,它不会去更新旧的输入框,而是直接 销毁 旧的<input>实例(连同它内部保存的输入文字),然后创建一个 全新的、状态为空的<input>实例并挂载到 DOM 上。
🤔 思考一下
请亲自尝试一下这个效果:
- 在输入框中随意输入一些文字。
- 点击“切换”按钮。
- 观察输入框,你会发现里面的文字消失了!这正是因为
key的改变导致了整个<input>组件的重置。
3.4.2. 实战场景:优雅地重置复杂表单
现在我们理解了原理,让我们回到一个更真实的痛点:
痛点背景: 你有一个用户资料编辑表单组件 (UserProfileForm),它接收一个 userId 作为 prop,内部有多个 useState。当 userId prop 变化时,我们期望整个表单被清空并重新加载新用户的数据。如果用 useEffect,代码会很繁琐:
1 | // 繁琐的 useEffect 方案 |
解决方案:用 key 声明式地重置
基于我们从 Switcher 中学到的知识,我们可以用 key 来极大地简化这个流程。
1 | function App() { |
1 | // UserProfileForm 组件现在可以非常纯净, |
总结与最佳实践:
当一个组件的“身份”与其某个核心 prop(通常是 ID)深度绑定时,将这个 prop 同时用作组件的 key 是一种极其强大且优雅的模式。它将“当 prop 变化时重置组件”这个命令式的逻辑,转换为了“这个 prop 就是组件的身份”这种声明式的表达,让父组件完全掌握了子组件的生命周期,代码更简洁,意图也更清晰。
第四章: 跨组件通信与逻辑复用
摘要: 在前几章,我们掌握了通过 Props 进行父子通信,以及在组件内部管理状态和副作用的核心能力。然而,当应用变得复杂,跨越多个层级的“远距离”通信和在组件间共享相似的逻辑就成了新的挑战。本章将直面这两个痛点,首先引入 Context 机制,彻底解决“属性钻探”问题;接着,我们将学习 useRef,掌握在 React 中与 DOM 交互及存储持久化变量的能力;最后,我们将所有知识融会贯通,学习 React 最强大的模式——自定义 Hooks,将组件逻辑提升到前所未有的可复用高度。
在本章中,我们将沿着一条清晰的“问题-解决方案”路径,解锁 React 的高级能力:
- 首先,我们将重新审视在
2.2.3节提出的 “属性钻探” (Prop Drilling) 问题。 - 接着,我们将引入 React 官方的解决方案 Context API 与
useContextHook,学习如何在组件树中进行“大范围”的状态共享,这精确对标 Vue 的provide/inject。 - 然后,我们将解决另一个常见需求:如何在 React 中直接操作 DOM 元素。我们将学习
useRefHook 来应对这类场景,它对标 Vue 的模板引用 (template refs)。 - 最后,也是本章的最高潮,我们将学习如何将前面学到的所有 Hooks 组合起来,创建属于我们自己的 自定义 Hooks (Custom Hooks),实现优雅、彻底的逻辑复用。
4.1. 解决属性钻探:Context API 与 useContext
本小节核心知识点:
Context提供了一种在组件树中共享“全局”数据的方式,而无需手动地在每一层组件中传递 props。- 它精确对标 Vue 的 provide 和 inject
React.createContext(): 用于创建一个 Context 对象。<MyContext.Provider value={...}>: Provider 组件,用于“提供”数据。它包裹的任何子组件都能访问到这个value。useContext(MyContext): Hook,用于在子组件中“注入”并读取 Provider 提供的value。
4.1.1. 痛点重现:语义化场景下的属性钻探
我们在 2.2.3 节已经理论上了解了属性钻探。现在,让我们在一个真实场景中感受它的痛苦。假设我们有以下组件结构,App 组件获取了当前登录的用户信息,但只有最深层的 Greeting 组件需要显示用户名。
1 | # src/ |
App.tsx (数据源)
1 | import UserProfilePage from './components/UserProfilePage'; |
UserProfilePage.tsx (中间人 A)
1 | import UserInfoCard from './UserInfoCard'; |
UserInfoCard.tsx (中间人 B)
1 | import Greeting from './Greeting' |
Greeting.tsx (最终消费者)
1 | // 只有 Greeting 组件真正使用了 user.name |
这种层层传递让中间组件变得臃肿且高度耦合,维护起来就是一场噩梦。
4.1.2. 解决方案:三步构建 Context
现在,我们用 Context 来彻底重构这个流程。
第一步:创建 Context 对象
最佳实践是为你的 Context 创建一个单独的文件。
文件路径: src/contexts/UserContext.ts (新建)
1 | import { createContext } from 'react'; |
第二步:在顶层提供 (Provide) Context
回到我们的 App.tsx,使用 <UserContext.Provider> 来包裹整个应用,并通过 value prop 将数据“注入”到组件树中。
文件路径: src/App.tsx (修改)
1 | import { useState } from 'react'; |
现在,被 Provider 包裹的所有后代组件,无论嵌套多深,都具备了直接访问 currentUser 数据的能力。
第三步:在深层组件中消费 (Consume) Context
这是最激动人心的一步。我们现在可以直接在 Greeting 组件中获取数据,而完全绕过中间组件。
文件路径: src/components/Greeting.tsx (修改)
1 | import { useContext } from 'react'; |
useContext 是迄今为止最简洁、最直观的消费 Context 的方式。
文件路径: src/components/Greeting.tsx (旧版写法,仅作了解)
1 | import { UserContext } from '../contexts/UserContext' |
<Context.Consumer> 是一种基于 Render Props 模式的旧方法。当需要消费多个 Context 时,它会导致多层嵌套(俗称“回调地狱”),可读性很差。在现代 React 开发中,应 始终优先使用 useContext Hook。
最终成果:解耦的中间组件
现在,我们的中间组件 UserProfilePage 和 UserInfoCard 不再需要关心 user prop,它们变得干净、独立且高度可复用。
UserProfilePage.tsx (重构后)
1 | import UserInfoCard from './UserInfoCard'; |
Context 总结:Context 是解决 React 中“跨级组件通信”问题的官方标准答案。它允许我们将一些“全局性”的数据(如用户身份、主题、语言设置等)从顶层注入,让任何深度的子组件都能按需、直接地获取,从而实现组件间的彻底解耦。
4.2. 引用与命令式操作:useRef 的双重角色
在 React 的声明式世界里,我们通常不直接操作 DOM。但总有一些场景,我们必须“命令式地”与 DOM 元素交互,比如让一个输入框聚焦。useRef 就是 React 为这类场景提供的官方“后门”。
本小节核心知识点:
useRef返回一个可变的 ref 对象,其.current属性被初始化为您传入的参数 (useRef(initialValue))。useRef有两大核心用途:- 访问 DOM 节点,这精确对标 Vue 的
模板引用。 - 存储一个不触发组件重新渲染的可变值,类似于 Vue 3
script setup中一个普通的、非响应式的变量。
- 访问 DOM 节点,这精确对标 Vue 的
- 改变
ref.current的值 不会 引起组件的重新渲染。这是它与useState的根本区别。
4.2.1. 核心用途一:访问 DOM 元素
痛点背景: 在 Vue 中,我们可以通过给元素添加 ref="myInput" 属性,然后在 <script setup> 中通过 const myInput = ref(null) 来获取该 DOM 元素的引用,并调用它的方法,如 myInput.value.focus()。React 如何实现同样的功能?
范式转变:从模板字符串到 Ref 对象
React 的实现方式思想一致,但语法上更贴近 JavaScript 的对象引用。总共分三步:创建 Ref -> 附加 Ref -> 访问 Ref。
让我们通过一个“点击按钮聚焦输入框”的经典案例来掌握它。
文件路径: src/components/FocusableInput.tsx
1 | import { useRef } from "react"; |
4.2.2. 核心用途二:存储持久化的可变值
痛点背景: 假设我们需要在一个组件中设置一个 setInterval 定时器。我们需要在某处存储这个定时器的 ID,以便在组件卸载或用户点击“停止”按钮时能够调用 clearInterval(timerId) 来清除它。
如果我们用 useState 来存储 timerId,会发生什么?const [timerId, setTimerId] = useState(null)。setInterval 返回 ID 后调用 setTimerId(id) 会导致组件不必要地重新渲染。我们只是想存个值,并不想因为这个值的改变而刷新界面。
解决方案:将 useRef 用作“实例变量”
useRef 完美地解决了这个问题。它创建的 ref 对象在组件的整个生命周期内都是同一个对象。我们可以把需要持久化,但又与渲染无关的值,存放在它的 .current 属性中。
文件路径: src/components/IntervalTimer.tsx
1 | import { useRef, useEffect, useState } from "react"; |
在这个例子中,intervalRef 就像一个忠诚的管家,它默默地为我们保管着 intervalId,无论组件因为 count 的变化重新渲染多少次,它都稳定地持有那个值,直到我们需要用它为止。useState vs. useRef 何时使用?
- 当你希望值的改变能够触发界面更新时 -> 使用
useState。这是驱动 React 声明式 UI 的核心。 - 当你需要访问 DOM 元素,或者需要一个值在多次渲染之间保持不变,但又不希望它的改变触发渲染时 -> 使用
useRef。
4.3. 逻辑复用的最佳实践:自定义 Hooks
到目前为止,我们已经掌握了 React 提供的所有基础 Hooks。但 React 最强大的地方在于,它允许我们将这些基础工具组合起来,创造出属于我们自己的、可复用的逻辑单元——这就是自定义 Hooks (Custom Hooks)。
本小节核心知识点:
- 自定义 Hook 是一个以
use开头的 JavaScript 函数,其内部可以调用其他的 Hooks (如useState,useEffect)。 - 它是 React 中 实现状态逻辑复用 的首选方式,完美替代了旧有的 HOC 和 Render Props 模式。
- 自定义 Hook 使得我们将组件中与 UI 无关的逻辑(如:数据请求、事件监听、表单处理)抽离成独立的、可测试的、可在多个组件间共享的单元。
4.3.1. 痛点背景:当组件逻辑开始重复
想象一下,我们应用中的很多组件都需要从 API 获取数据。按照我们已有的知识,每个组件可能都会包含类似下面这样的代码:
1 | import { useState, useEffect } from "react"; |
现在,如果另一个组件 CommentList 也需要获取评论数据,我们就得把上面这一大段 useState 和 useEffect 的逻辑原封不动地复制粘贴过去,只改一下 URL。这显然违反了 DRY (Don’t Repeat Yourself) 原则,难以维护。
4.3.2. 解决方案:创建你的第一个自定义 Hook
自定义 Hook 就是为了解决这类问题而生的。它让我们能将这部分可复用的 状态逻辑 封装到一个函数中。
自定义 Hook 的两大黄金法则:
- 必须以
use开头: 这是 React Linter 用来识别一个函数是否为 Hook 的硬性规定,例如useFetch、useToggle。 - 内部可以调用其他 Hooks: 这是自定义 Hook 的超能力所在,它能组合
useState、useEffect等,创造出新的、更强大的 Hook。
现在,让我们根据以下三个示例,由简到繁,一步步构建并使用三个实用的自定义 Hook。
示例一:useToggle - 封装最简单的状态切换
这是自定义 Hook 的 “Hello World”。它封装了一个常见的布尔值切换逻辑。
文件路径: src/hooks/useToggle.ts
1 | import { useState } from "react"; |
文件路径: src/components/ToggleComponent.tsx
1 | import useToggle from '../hooks/useToggle' |
示例二:useInput - 简化表单处理
这个 Hook 封装了处理受控输入框 value 和 onChange 的通用逻辑。
文件路径: src/hooks/useInput.ts
1 | import { useState } from 'react' |
当你写 {…name} 时,实际上是在展开 useInput Hook 返回的对象。让我们看看这个过程:
1 | // useInput 返回的对象 |
文件路径: src/components/FormComponent.tsx
1 | import useInput from '../hooks/useInput' |
示例三:useFetch - 封装异步数据获取(最强示例)
这正是我们最初那个痛点的完美解决方案。它封装了数据、加载状态、错误状态以及数据获取的整个 useEffect 逻辑。
文件路径: src/hooks/useFetch.js
1 | import { useEffect, useState } from 'react' |
文件路径: src/components/FetchComponent.jsx
1 | import useFetch from '../hooks/useFetch' |
4.3.3. 组合与应用
最后,我们可以在 App.jsx 中轻松地将这些由自定义 Hook 驱动的组件组合在一起,每个组件都只关心自己的 UI 呈现,而将复杂的逻辑“外包”给了 Hooks。
文件路径: src/App.jsx
1 | import FetchComponent from "./components/FetchComponent"; |
自定义 Hooks 总结:
自定义 Hooks 是 React 逻辑复用的基石。通过将有状态的逻辑从组件中抽离出来,我们获得了:
- 高度的可复用性:
useFetch可以在任何需要获取数据的组件中使用。 - 清晰的关注点分离: 组件可以专注于“做什么”(渲染 UI),而 Hook 则负责“怎么做”(状态管理的细节)。
- 更强的可读性和可维护性: 组件代码变得极其简洁和声明式。
- 独立的可测试性: 我们可以脱离 UI,单独对自定义 Hook 的逻辑进行单元测试。
掌握自定义 Hooks,是真正从 React “使用者”迈向“精通者”的关键一步。
第五章: 实战演练:从零构建 React 应用
摘要: 理论是基石,但只有通过亲手构建,知识才能真正内化为能力。在本章中,我们将告别孤立的知识点,进入一系列由简到繁的实战项目。我们的目标不是简单地“复刻”功能,而是通过每一个项目,有针对性地巩固、融合并深化前四章所学的核心概念——从 State 管理到副作用处理,再到自定义 Hooks 的应用。学完本章,您将具备独立构建功能完备的 React 组件和小型应用的能力。
在本章中,我们将遵循一条精心设计的技能升级路径,逐步解锁更复杂的应用场景:
- 阶段一:状态管理基石: 我们将从最核心的
useState开始,通过构建 计数器 和 待办事项列表,彻底掌握对数字、数组等基础数据结构的状态管理。 - 阶段二:交互式 UI 构建: 接着,我们将挑战 颜色切换器、隐藏式搜索框 等项目,专注于 UI 状态的管理,创造更丰富的用户交互。
- 阶段三:异步数据流与 API 交互: 然后,我们将通过 餐饮 API 项目,首次引入
useEffect处理网络请求,打通 React 应用与服务器的数据链路。
请相信我,每一节我都安排来不同的知识点,完全遵循最佳实践与之前学习过的所有知识点
5.1. 阶段一:状态管理基石
5.1.1. 项目实战:计数器 (Counter)
这是我们 React 实战之旅的第一站。计数器虽小,却蕴含了 React 数据驱动视图的核心思想。我们将通过它,将 useState 和事件处理的理论知识,转化为指尖上的代码。同时,我们将引入并实践 SCSS Modules,这是一种能将 SCSS 的强大功能与组件化样式隔离完美结合的最佳实践。

实战准备:为项目添加 SCSS Modules 支持
在 1.6 节,我们已经为项目配置了 Tailwind CSS。现在,我们将学习另一种强大的样式方案:SCSS Modules。它允许我们在组件层面编写 SCSS,并自动确保样式不会泄露到其他组件,完美对标 Vue 的 <style scoped>。
第一步:安装 SCSS 编译器
如果尚未安装,请确保您的项目已添加 sass 依赖。
1 | pnpm add -D sass |
第二步:理解 SCSS Modules 的工作方式
Vite 已为我们内置了 CSS Modules 的支持。我们只需遵循一个简单的命名约定:将样式文件命名为 [ComponentName].module.scss。
当你这样做时:
- Vite 会将这个 SCSS 文件中的所有类名进行哈希处理,生成一个独一无二的类名(例如
.title变成.Counter_title__aB3xY)。 - 当你
import这个文件时,它会返回一个 JavaScript 对象,键是你原始的类名,值是哈希后的唯一类名。
这种机制从根本上解决了 CSS 全局污染的问题。
项目目标
我们将构建一个简单的计数器应用,包含一个显示的数字、一个“增加”按钮和一个“减少”按钮。
useState: 用于管理计数器的数字状态。- 事件处理:
onClick事件绑定与处理函数的编写。 - SCSS Modules: 实现组件级别的样式封装。
项目结构与代码解析
我们将采用“组件文件夹”的最佳实践来组织代码,将与 Counter 组件相关的所有文件都放在同一个地方。
1 | # src/ |
1. Counter.module.scss (组件样式)
首先,我们来编写样式。注意看我们是如何使用 SCSS 的嵌套和变量功能的。
1 | /* Counter.module.scss */ |
2. Counter.tsx (核心组件)
现在,我们在组件逻辑中导入并使用这些模块化的样式。
1 | import React, { useState } from "react"; |
3. App.tsx (应用入口)
最后,App.tsx 的职责是渲染我们的 Counter 组件,并提供一个全局的背景色(这里我们可以使用 Tailwind,展示两种样式方案的共存)。
1 | import Counter from "./components/Counter/Counter"; |
🤔 思考与扩展
现在你已经完成了一个使用 SCSS Modules 的计数器,尝试挑战一下自己:
- 添加重置功能: 增加一个“重置”按钮,点击后让计数器归零。
- 设置边界: 修改
decrement函数,使得计数器的值不能小于 0。 - 动态样式: 当
count大于 10 时,让数字的颜色变为绿色;小于 0 时,变为红色。你需要动态地拼接styles对象中的类名。
5.1.2. 项目实战:待办事项列表 (Todo List)
如果说“计数器”是 useState 的入门,那么“待办事项列表”就是我们掌握数组状态管理的第一次大考。在这个项目中,我们将学会如何以 React 的方式(不可变地)对一个列表进行增加和删除操作,这是构建动态应用的核心技能。

项目目标
我们将构建一个经典的 Todo List 应用。用户可以:
- 在输入框中输入任务。
- 点击“提交”按钮,将新任务添加到列表中。
- 点击每项任务旁的“X”按钮,从列表中删除该任务。
核心概念巩固
useState: 管理输入框的字符串状态,以及待办事项的数组状态。- 数组的不可变更新: 使用
concat和filter等方法来更新数组,而非直接修改。 - 列表渲染: 使用
.map()方法动态渲染列表,并为每一项提供唯一的key。 - 受控组件: 将 input 输入框的
value与 React state 绑定。
项目结构与代码解析
我们将继续遵循“组件文件夹”的最佳实践。
1 | # src/ |
1. Todo.module.scss (组件样式)
首先,我们按照设计图将样式定义完毕
1 | .container { |
2. Todo.tsx (核心组件)
这是项目的核心,我们在这里处理所有的状态和交互逻辑。
1 | import { useState } from 'react' |
3. App.tsx (应用入口)
和上一个项目一样,App.tsx 的职责就是渲染我们的核心组件。
1 | import Todo from "./components/Todo/Todo"; |
🤔 思考与扩展
这个 Todo List 已经具备了核心功能,但我们还可以让它更强大。试试看:
- 切换完成状态:为每个
TodoItem增加一个completed属性。点击任务文本时,切换其完成状态,并给已完成的任务添加一条删除线样式。 - 显示任务计数:在列表上方或下方,显示“总共有 X 个任务”或“还剩 Y 个未完成任务”。
5.2. 阶段二:交互式 UI 构建
5.2.1. 项目实战:颜色切换器
在这个项目中,我们将学习如何以最地道、最高效的方式,利用 Tailwind CSS 内置的强大功能来构建一个可持久化的主题切换器。这将是一次深刻的范式转变,让我们告别繁琐的类名拼接,拥抱声明式的 UI 样式构建。

第一步:配置 Tailwind CSS 的深色模式策略
Tailwind 的 dark: 变体默认使用 prefers-color-scheme 媒体查询,跟随用户的操作系统设置。为了实现手动切换,我们需要将其配置为,在V4版本最新的配置方法变为了在css中配置,所以我们也按照他的规范来
打开项目根目录的 index.css 文件,并修改它:
文件路径: index.css
1 | @import 'tailwindcss'; |
这个简单的改动告诉 Tailwind:“当 <html> 元素上有一个 dark 类时,所有带 dark: 前缀的工具类都将生效。”
第二步:构建主题切换组件
现在,我们可以编写组件了。注意看 TSX 中的 className 有多么简洁和富有表现力。
文件路径: src/components/ThemeToggler/ThemeToggler.tsx
1 | import React, { useState, useEffect } from 'react'; |
第三步:App.tsx 和 index.html
这部分保持不变,App.tsx 负责渲染 ThemeToggler
1 | import ThemeToggler from "./components/ThemeToggler/ThemeToggler"; |
🤔 思考与扩展
我们已经实现了一个生产级的、可持久化的主题切换器。现在的代码已经非常优秀,但作为追求卓越的开发者,我们还能再优化一步吗?
- 提取为自定义 Hook: 目前,主题管理的逻辑(
useState、useEffect、localStorage)都耦合在ThemeToggler组件内部。我们能否将这整套逻辑提取到一个可复用的useTheme自定义 Hook 中,让ThemeToggler组件只负责 UI 呈现?
5.2.2. 项目实战:隐藏式搜索框
在掌握了如何通过状态切换整个页面主题后,我们现在将注意力集中到一个更具体的交互上:如何通过点击一个图标,平滑地、动态地展示一个输入框。这个项目是练习 React 状态与 CSS 过渡动画 相结合的绝佳机会。

项目目标
我们将构建一个初始状态只显示一个搜索图标的界面。当用户点击该图标时,图标消失,一个输入框以平滑的过渡效果出现,同时背景变为深色。点击输入框以外的区域,则恢复初始状态。
核心概念巩固
useState: 管理 UI 的可见性状态(显示图标还是输入框)。- 条件渲染: 使用三元运算符在 JSX 中根据状态渲染不同的元素。
- 事件处理:
onClick事件的精确使用,包括事件冒泡的处理。 - Tailwind CSS: 熟练运用其过渡 (
transition)、透明度 (opacity) 和宽度 (width) 等工具类,以纯 CSS 的方式实现动画效果。
项目结构与代码解析
我们将继续使用 Tailwind CSS,保持组件的内聚性。
1 | # src/ |
实战准备:安装图标库
为了使用搜索图标,我们需要一个图标库。react-icons 是一个非常流行且易于使用的选择。
1 | pnpm add react-icons |
1. HiddenSearchBar.tsx (核心组件)
我们将创建这个组件,完全利用 Tailwind CSS 的能力。
1 | import { useState, useRef, useEffect } from "react"; |
代码重构与最佳实践:
- 单一状态来源: 原始代码使用了两个
useState(showInput,bgColor) 来管理本应由一个状态控制的 UI。我们将其重构为单一的isActive状态,使逻辑更清晰。 - CSS > JS: 原始代码通过
style属性和 JS 来控制背景色,我们将其完全交给 Tailwind 的dark:变体(或在本例中是条件类名),让 CSS 负责样式,JS 负责状态,实现关注点分离。 - 动画实现: 我们放弃了原始 CSS 文件中的过渡,完全使用 Tailwind 的
transition,duration,ease-in-out,opacity, 和width工具类,以纯声明式的方式在 JSX 中实现了更复杂的动画,无需离开组件文件。 - 自动聚焦: 通过
useRef和useEffect,我们实现了在搜索框出现时自动聚焦的交互优化,提升了用户体验。
2. App.tsx (应用入口)
1 | import HiddenSearchBar from "./components/HiddenSearchBar/HiddenSearchBar"; |
🤔 思考与扩展
这个组件已经非常酷了,但还有一个交互细节可以完善:
- 点击外部关闭: 目前,一旦搜索框被激活,只能通过再次点击图标来关闭。更符合用户直觉的行为是:点击搜索框以外的任何区域,都应该能关闭它。你能否实现这个功能?
5.3. 阶段三:异步数据流与 API 交互
5.3.1. 项目实战:餐饮 API 项目 (Meals API Project)
欢迎来到我们实战之旅的全新阶段!到目前为止,我们构建的应用都只在“自己的世界里”运行,处理着我们预设的数据。现在,我们将打破这层壁垒,通过发起真实的 API 请求,让我们的 React 应用首次与广阔的互联网世界对话,获取并展示动态数据。

项目目标
我们将构建一个从 TheMealDB API 获取海鲜菜品数据,并以精美的卡片网格形式展示的页面。
核心概念巩固
useEffect: 用于在组件首次渲染后执行数据获取这一“副作用”。useState: 精准管理异步流程中的三种关键状态:loading(加载状态)、error(错误状态)和data(成功获取的数据)。- 异步操作: 引入并使用
axios库,以async/await的现代语法发起网络请求。 - 条件渲染: 根据
loading和error状态,为用户提供清晰的界面反馈。 - TypeScript 接口: 为 API 返回的数据定义类型,让我们的代码更健壮、更易于维护。
- Tailwind CSS: 完全使用原子化类名来构建一个响应式的卡片网格布局。
项目结构与代码解析
我们将继续采用简洁、内聚的组件结构。
1 | # src/ |
实战准备:安装 Axios
为了更便捷地处理网络请求,我们将安装广受欢迎的 axios 库。
1 | pnpm add axios |
1. Meals.tsx (核心组件)
在这个组件中,我们将完成从数据请求、状态管理到最终 UI 渲染的完整流程。
1 | import { useState, useEffect } from 'react' |
2. App.tsx (应用入口)
我们的 App 组件负责设置页面的整体布局和标题,并渲染 Meals 组件。
1 | import Meals from "./components/Meals/Meals"; |
🤔 思考与扩展
我们已经成功地从一个真实的 API 获取数据并展示出来,这是构建现代 Web 应用的关键一步。现在,我们可以思考如何让这个页面变得更强大:
- 添加搜索功能: 增加一个输入框,允许用户输入菜品名称进行搜索。当用户点击搜索按钮时,向
https://www.themealdb.com/api/json/v1/1/search.php?s=YOUR_SEARCH_QUERY发起新的 API 请求,并更新列表。 - 提取自定义 Hook: 我们刚刚在
Meals.tsx中编写的获取数据的逻辑(包含loading,error,data三个状态和useEffect),是不是非常通用?它完全可以被封装成一个可复用的useFetch自定义 Hook!尝试将这部分逻辑提取出来,让Meals组件变得更纯粹。
第六章: TypeScript:为 React 应用注入类型之魂
摘要: 在前面的章节中,我们已经使用 JavaScript 构建了多个功能完备的 React 应用。我们已经体会到了 React 的强大,但同时也可能感受到了 JavaScript 动态类型带来的一些“不安全感”。随着应用规模的扩大,我们如何确保传递给组件的数据总是正确的?如何避免因为一个简单的拼写错误或类型不匹配而导致的运行时 Bug?本章将给出答案:TypeScript。
为什么要在学完 React 基础后再深入 TypeScript?
这是一个经过精心设计的学习路径。我们之所以先用 JavaScript 学习 React,是为了让您能够 专注于 React 自身的核心思想——组件化、状态管理、Hooks 等,而不被类型定义的语法分散注意力。
现在,您已经对 React 的“骨架”了然于胸,是时候为其穿上“铠甲”了。TypeScript 这层铠甲将为我们带来:
- 代码的健壮性: 在代码 运行前(编译阶段)就能发现大量的潜在错误,而不是等到用户在浏览器中遇到问题。
- 开发体验的飞跃: 享受无与伦比的编辑器自动补全、类型提示和重构能力,让我们写代码更快、更自信。
- 团队协作的基石: 类型定义本身就是最精准、最不会过时的“文档”。任何接手我们代码的同事都能立即明白一个组件需要什么数据,返回什么结果。
在本章中,我们将系统性地学习如何将 TypeScript 的类型系统无缝融入到 React 开发的每一个环节,构建出真正生产级的应用程序。
6.1. Props 的类型艺术
React 组件的核心是通过 props 接收数据。那么,我们与 TypeScript 的第一次亲密接触,也自然从定义 props 的“形状”和“契约”开始。
6.1.1. 基础:为 Props 定义类型
在纯 JavaScript 中,我们无法限制一个组件能接收哪些 props,也无法限制这些 props 的类型。但在 TypeScript 中,我们可以像签订合同一样,精确地定义组件的“输入”。
让我们从一个简单的 User 组件开始。
文件路径: src/components/User.tsx
1 | // 这是一个没有类型定义的组件,存在风险 |
现在,当我们在 App.tsx 中使用这个组件时,TypeScript 和我们的编辑器会立刻成为我们的“守护神”。
文件路径: src/App.tsx
1 | import User from './components/User'; |
6.1.2. 优化:解构与自定义类型 (type / interface)
内联类型定义虽然直接,但有两个缺点:一是当 props 很多时会显得很冗长;二是我们每次都写 props.xxx 也很繁琐。我们可以通过 解构 和 自定义类型 来优化它。
文件路径: src/components/User.tsx (升级版)
1 | type UserProps = { |
这种写法是 React + TypeScript 开发中的 黄金标准:代码既简洁又类型安全。
6.1.3. 实战练习:创建一个带类型的 Button 组件
现在,让我们亲手实践一下。我们将创建一个可复用的 Button 组件,它需要接收 label、onClick 和 disabled 三个 props。
文件路径: src/components/Button.tsx
1 | import React from 'react'; |
文件路径: src/App.tsx (使用 Button 组件)
1 | import Button from './components/Button'; |
6.1.4. 特殊 Prop: children 的类型
我们知道,children 是一个特殊的 prop,代表了组件标签之间的内容。React 为它提供了一个专门的类型:React.ReactNode。
文件路径: src/components/Card.tsx (示例)
1 | import type { ReactNode } from 'react'; // `type` 关键字表示我们只导入类型信息 |
现在,我们可以在 App.tsx 中安全地使用 Card 组件来包裹任何内容。
1 | import Card from './components/Card'; |
总结:
为 props 添加类型,是我们从 JavaScript 迈向 TypeScript 的第一步,也是最重要的一步。它像一道坚固的防线,能拦截掉绝大多数因数据类型错误而引发的 Bug,并为我们提供了无与伦比的开发体验。
6.2. 类型的复用与组合
在上一节中,我们学会了为单个组件定义 props 类型。但随着应用变得复杂,我们会发现一个普遍现象:不同的组件之间,往往需要共享相似甚至相同的 props 结构。例如,一个 UserProfileCard 组件和一个 UserEditForm 组件可能都需要接收一个包含 id, name, email 的 user 对象。
如果我们为每个组件都重复定义一次这个 user 对象的类型,就会违反 DRY (Don’t Repeat Yourself) 原则,导致代码冗余和维护困难。本节,我们将学习如何创建可复用的、可组合的类型,从根本上解决这个问题。
6.2.1. 第一步:创建全局类型定义文件
最佳实践是将那些需要在多个地方共享的类型,抽离到一个或多个专门的 .ts 文件中。这通常放在一个 types 或 interfaces 目录下。
让我们首先创建一个全局的类型定义文件。
文件路径: src/types.ts
1 | // 定义一个基础的用户信息类型,包含所有用户共有的属性 |
type vs. interface for Extension:
- 使用
type: 我们通过交叉类型&来合并两个类型,例如type C = A & B。 - 使用
interface: 我们可以通过extends关键字来实现继承,例如interface C extends A { /* B's properties */ }。在大多数场景下,两者都能达到相似的效果,选择哪种主要取决于团队的编码规范和个人偏好。
6.2.2. 第二步:在组件中导入并使用共享类型
现在,我们可以在不同的组件中,像导入一个模块一样,导入并使用我们刚刚定义的共享类型。
创建 UserInfo 组件
这个组件只关心基础的用户信息。
文件路径: src/components/UserInfo.tsx
1 | import React from 'react' |
React.FC 是什么?React.FC (或 React.FunctionComponent) 是一个内置的 TypeScript 类型,用于定义函数组件。它提供了一些基础的类型定义(例如 children),虽然在现代 React 中,我们更推荐直接像 const MyComponent = ({...}: MyProps) => {} 这样定义组件,但在许多代码库中您仍然会看到 React.FC 的使用。
1 | const UserInfo = ({ user }: UserInfoProps) => { |
创建 AdminInfo 组件
这个组件需要更详细的管理员信息。
文件路径: src/components/AdminInfo.tsx
1 | import React from 'react' |
6.2.3. 第三步:在 App.tsx 中组合使用
最后,我们在主应用中创建符合这些类型的数据,并将其传递给相应的组件。TypeScript 会在后台默默地为我们检查所有的数据结构是否正确。
文件路径: src/App.tsx
1 | import UserInfo from './components/UserInfo' |
总结:
通过将共享的类型定义抽离到单独的文件中,我们实现了 类型层面的代码复用。这种做法极大地提升了大型应用的可维护性:
- 单一事实来源: 当用户数据结构需要变更时(例如增加一个
age字段),我们只需要修改src/types.ts文件,所有使用该类型的组件都会立刻得到类型检查的提示,确保我们不会遗漏任何需要修改的地方。 - 代码的清晰性与自文档化:
import { type Info } from '../types'这行代码清晰地告诉了任何阅读者,UserInfo组件依赖于一个全局定义的数据结构。
6.3. Hooks 的类型推断与约束
我们已经掌握了如何为组件的“输入” (props) 添加类型。现在,我们将焦点转向组件的“内部”:如何为 useState hook 管理的状态添加类型,确保我们的组件不仅接收的数据是正确的,其内部维护的数据也同样安全可靠。
6.3.1. 基础:useState 的类型推断
TypeScript 最强大的功能之一就是类型推断。在大多数简单场景下,我们甚至不需要为 useState 显式地指定类型,因为它足够“聪明”,能够根据我们提供的 初始值 自动推断出状态的类型。
文件路径: src/App.tsx
1 | import { useState } from "react"; |
对于字符串、布尔值等基础类型,TypeScript 的类型推断都能完美工作,我们无需做任何额外的事情。
6.3.2. 进阶:显式指定复杂状态的类型
当我们的状态是一个复杂的对象或数组时,最佳实践是先使用 interface 或 type 定义这个状态的“形状”,然后将它作为泛型参数传递给 useState。
场景一:状态为对象 (Object)
让我们构建一个用户个人资料的组件,其状态是一个包含多个字段的对象。
文件路径: src/components/UserProfile.tsx
1 | import { useState } from "react"; |
场景二:状态为对象数组 (Array of Objects)
现在,我们来构建一个 Todo List,其状态是一个由多个 Todo 对象组成的数组。
文件路径: src/components/TodoList.tsx
1 | import { useState } from 'react' |
6.3.3. 特殊场景:初始状态为 null
在处理异步数据时,我们常常遇到的一个场景是:数据在初始时不存在,需要等 API 返回。此时,状态的类型可能是 Data | null。这就必须使用显式泛型了。
1 | // 假设我们有一个 User 类型 |
- 对于 基础类型(
string,number,boolean),依赖useState的 类型推断 即可。 - 对于 对象和数组,最佳实践是先 定义类型 (
type/interface),然后 显式地将其作为泛型 传递给useState,如useState<MyType>({...})或useState<MyType[]>([])。 - 当状态的初始值和后续值的类型不完全一致时(最常见的是
null->object),必须使用联合类型泛型,如useState<User | null>(null)。
6.4. 事件、表单与 useRef 的精确类型
我们已经学会了如何为 props 和 state 添加类型,现在我们将把类型安全的“保护网”撒向 React 应用中负责交互的“神经末梢”——事件处理函数、表单以及用于 DOM 引用的 useRef。
6.4.1. useRef 的类型化
我们在 4.2 节已经学习了 useRef 的用法,但当时我们并未关注其类型。在 TypeScript 中,为 useRef 提供正确的类型至关重要,这能确保我们在访问 .current 属性时,能够获得该 DOM 元素所有的方法和属性的类型提示。
文件路径: src/components/FocusInput.tsx
1 | import { useRef } from 'react' |
6.4.2. 事件处理函数的精确类型
在之前的章节中,我们可能为了方便而忽略了事件对象的类型。现在,我们将学习如何从 @types/react 中导入精确的事件类型,告别 any,拥抱类型安全。
文件路径: src/components/EventHandling.tsx
1 | import React from 'react'; // 必须导入 React 才能使用其内置的事件类型 |
常用事件类型:
多敲多记即可
- 鼠标事件:
React.MouseEvent - 表单/输入框事件:
React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> - 表单提交事件:
React.FormEvent<HTMLFormElement> - 键盘事件:
React.KeyboardEvent - 焦点事件:
React.FocusEvent
6.4.3. 类型化的表单处理
现在,我们将所有知识融会贯通,构建一个完全类型安全的联系人表单。我们将为表单的 state、输入框的 onChange 事件以及表单的 onSubmit 事件都提供精确的类型。
文件路径: src/components/ContactForm.tsx
1 | import { useState } from 'react' |
总结:
通过为 useRef、事件和表单处理添加精确的类型,我们为应用的交互层构建了一道坚不可摧的“防火墙”。这不仅能防止因类型不匹配导致的运行时错误,更能极大地提升我们的开发效率——编辑器会成为我们最可靠的伙伴,为我们提供精准的自动补全和实时的错误检查。
6.5. 高级 Hooks 的类型化
在本章的最后一部分,我们将攻克那些负责“全局”和“复杂”逻辑的 Hooks 的类型化问题,特别是 Context API 和 useReducer。为它们提供类型,就像是为我们应用的“数据高速公路”和“状态机引擎”设置了精准的交通规则,能从根本上保证数据流动的安全与可预测性。
6.5.1. Context API 的类型化
我们在 4.1 节已经学习了如何使用 Context 来避免属性钻探。现在,我们将为其添加 TypeScript 类型,确保我们在整个应用中共享的数据始终符合我们预期的“契约”。
类型化 Context 通常分为三步:定义 Context 数据的形状 -> 创建带类型的 Context -> 创建带类型的 Provider。
场景一:简单的值共享 (计数器)
让我们先从一个共享简单计数值的 Context 开始。
文件路径: src/contexts/CounterContext.tsx
1 | import { createContext, useState, type ReactNode, type FC } from "react"; |
现在,我们可以在应用中使用这个类型安全的 Context。
文件路径: src/App.tsx (包裹 Provider)
1 | import CounterDisplay from "./components/CounterDisplay"; |
文件路径: src/components/CounterDisplay.tsx (消费 Context)
1 | import { useContext } from "react"; |
场景二:处理可能为 undefined 的 Context
在某些设计模式中,我们希望强制要求消费者组件必须被包裹在 Provider 内部,否则就应该报错。我们可以通过将 createContext 的初始值设为 undefined 来实现这一点。
文件路径: src/contexts/SharedInputContext.tsx
1 | import { createContext, type ReactNode, useState, FC, useContext } from "react"; |
现在,消费者组件的代码将变得更加优雅和健壮。
文件路径: src/components/SharedInputDisplay.tsx
1 | import { useSharedInput } from "../contexts/SharedInputContext"; // 导入自定义 Hook |
总结:
为 Context 提供类型,是构建可扩展、类型安全的 React 应用的基石。通过创建一个自定义 Hook 来消费 Context,我们可以将“检查 Context 是否存在”的逻辑封装起来,为所有消费者组件提供一个更简洁、更安全的 API。
6.5.2. useReducer 的类型化
我们在 2.3.4 节已经了解了 useReducer 在处理复杂状态逻辑时的优势。现在,我们将为它的三个核心要素——state、action 和 reducer 函数——都加上精确的类型定义。这能将 useReducer 的优势发挥到极致,让我们的状态管理代码变得坚如磐石。
类型化 useReducer 通常分为三步:定义 State 和 Action 的类型 -> 创建类型化的 Reducer 函数 -> 在组件中使用。
第一步:定义 State 和 Action 的类型
最佳实践是将 reducer 相关的类型和逻辑抽离到单独的文件中,这让我们的代码结构更清晰,也使得 reducer 逻辑可以被独立测试。
文件路径: src/reducers/counterReducer.ts
1 | // 1. 定义状态 state 的“形状” |
第二步:创建类型化的 Reducer 函数
现在,我们在同一个文件中创建 reducer 函数,并为它的参数和返回值应用我们刚刚创建的类型。
文件路径: src/reducers/counterReducer.ts (继续)
1 | // 1. 定义状态 state 的“形状” |
第三步:在组件中使用类型化的 useReducer
现在,我们在组件中使用 useReducer 时,TypeScript 会利用我们之前定义的类型,提供完美的类型推断和安全检查。
文件路径: src/components/Counter.tsx
1 | import { useReducer } from "react"; |
App.tsx (整合组件)
文件路径: src/App.tsx
1 | import React from "react"; |
总结:
为 useReducer 添加类型,是构建复杂、可预测、易于维护的状态管理系统的关键。通过使用“可辨识联合类型”来定义 Actions,我们可以在 reducer 函数的 switch 语句中享受到 TypeScript 强大的类型收窄能力,它能确保我们在处理每一种 action 时,都能安全地访问其特有的属性(如 payload),从而在编码阶段就杜绝大量的潜在逻辑错误。
6.5.3. useEffect 的类型化实践:构建类型安全的数据获取组件
到目前为止,我们已经为 props, state, events, refs, context, 和 reducer 都添加了类型。现在,我们将把这些能力整合起来,构建一个完全类型安全的异步数据获取组件。
useEffect 本身不需要特殊的类型定义,但它所 引发 的副作用——特别是那些与 useState 交互的副作用——正是 TypeScript 大显身手的地方。
项目目标
我们将构建一个 UserList 组件,它会在挂载时从 JSONPlaceholder API 获取用户列表,并以表格的形式展示出来。整个过程将是完全类型安全的。

核心概念巩固
interface: 为 API 返回的数据结构定义清晰的类型契约。useState泛型: 使用联合类型 (User[] | null) 来处理异步数据的不同阶段。useEffect: 在组件挂载时安全地执行异步数据获取。- 类型断言与守卫: 在
catch块中安全地处理错误类型。 - Tailwind CSS: 为表格添加简洁、美观的样式。
项目结构
1 | # src/ |
1. UserList.tsx (核心组件)
在这个组件中,我们将完成从定义数据类型到获取、存储和渲染数据的完整闭环。
1 | import { useEffect, useState } from "react"; |
2. App.tsx (应用入口)
1 | import UserList from "./components/UserList/UserList"; |
第六章总结:TypeScript 的价值
恭喜您!我们已经完成了从 JavaScript 到 TypeScript 的全面升级。通过本章的学习,我们不再仅仅是“使用”React,而是以一种更专业、更严谨、更安全的方式来“构建”React 应用。
我们为 props、state、hooks 和 events 都添加了精确的类型。这层“类型铠甲”将在未来的开发中,为您抵挡无数潜在的 Bug,提升代码的可维护性,并最终让您成为一名更自信、更高效的 React 工程师。
第七章:React 性能优化与高级架构模式
摘要: 欢迎来到本教程的进阶篇章。在前六章,我们掌握了如何“正确地”构建应用。从本章开始,我们将学习如何“卓越地”构建应用。我们将深入 React 的性能优化核心,学习构建在生产环境中稳定、不会轻易崩溃的健壮应用,并掌握多种高级组件设计模式,让您具备架构复杂 UI 系统的能力。本章是您从“能够实现功能”迈向“能够构建高质量、可维护、高性能应用”的关键一跃。
7.1 性能优化:让你的应用快如闪电
我们热爱 React,因为它能让我们通过状态驱动 UI,轻松构建交互丰富的应用。但这种开发的便捷性背后,隐藏着一个默认行为:当一个组件的状态更新时,它会重新渲染,并且默认情况下,它内部渲染的所有子组件也会一同重新渲染。
对于一个简单的应用,这毫无问题。但当你的应用变得复杂,组件树层级加深,任何一个顶层组件的微小状态变化(比如切换主题、打开一个模态框),都可能像投入湖面的石子,激起一圈圈不必要的渲染涟漪,最终导致整个应用变得迟钝和卡顿。
本节,我们将化身性能侦探,通过一个实战案例,亲手揪出这些“渲染刺客”,并学习使用 React 提供的三把原生“手术刀”——React.memo, useCallback, useMemo,来精准地“切除”它们。
第一步:暴露问题——亲眼看见“不必要的渲染”
在优化之前,我们必须先学会如何定位问题。让我们搭建一个简单的“仪表盘”应用场景。
1. 创建项目文件结构
首先,在 src/components 目录下,创建一个新的 Dashboard 文件夹,并建立以下文件:
1 | # src/components/ |
2. 编写子组件代码
我们的子组件非常简单,它们唯一的“任务”就是在被 React 重新渲染时,在控制台打印一条日志,让我们能清楚地看到发生了什么。
文件路径: src/components/Dashboard/Header.tsx
1 | const Header = () => { |
文件路径: src/components/Dashboard/PerformanceReport.tsx
1 | const PerformanceReport = () => { |
3. 编写父组件 Dashboard.tsx
父组件将包含一个可以改变的内部状态——theme(主题),以及一个切换主题的按钮。
文件路径: src/components/Dashboard/Dashboard.tsx
1 | import { useState } from 'react' |
4. 在 App.tsx 中使用并观察
修改你的 App.tsx 来渲染这个仪表盘。
文件路径: src/App.tsx
1 | import Dashboard from './components/Dashboard/Dashboard' |
现在,运行你的应用,并打开浏览器控制台。当你反复点击“切换主题”按钮时,你会看到如下输出:
1 | 仪表盘(Dashboard)父组件正在被渲染 |
这就是我们的痛点! Header 和 PerformanceReport 组件根本不关心 theme 的变化,它们的内容是完全静态的。然而,仅仅因为父组件 Dashboard 的 theme 状态更新导致其自身重渲染,这两个无辜的子组件也被迫进行了完全不必要的重渲染。当这些子组件变得复杂时,就会造成严重的性能浪费。
第二步:第一把手术刀 React.memo —— 阻断渲染
React.memo 是一个高阶组件(HOC),你可以用它来包裹你的函数组件。它像一个“守卫”,在组件准备重渲染之前,会对其接收到的新旧 props 进行一次 浅比较。如果 props 没有变化,memo 就会阻止这次重渲染,并直接复用上一次的渲染结果。
让我们来为 PerformanceReport 组件动一次“手术”。
文件路径: src/components/Dashboard/PerformanceReport.tsx (修改)
1 | import React from 'react' // 引入 React 来使用 memo |
刷新页面,再次点击“切换主题”按钮。现在控制台的输出变成了:
1 | 仪表盘(Dashboard)父组件正在被渲染 |
成功了!PerformanceReport 的渲染日志消失了。因为它没有接收任何 props,memo 每次都判断 props 未变,从而成功阻止了重渲染。但是,Header 组件仍在被渲染,为什么?因为我们还没对它做任何处理。
什么是浅比较?
对于原始类型(string, number, boolean),浅比较会检查值是否相等('a' === 'a')。对于复杂类型(object, array, function),它只比较 引用地址 是否相同,而不会深入比较内部的内容。这就是问题的关键所在。
第三步:第二把手术刀 useCallback —— 稳定函数
现在,我们给 Header 组件增加一个功能:它接收一个 onRefresh 函数,点击按钮时调用。
1. 修改 Header 组件
文件路径: src/components/Dashboard/Header.tsx (修改)
1 | import React from 'react' |
2. 在 Dashboard 中传递函数
文件路径: src/components/Dashboard/Dashboard.tsx (修改)
1 | // ... |
刷新页面,再次点击“切换主题”按钮。你会绝望地发现,Header 的渲染日志又回来了!
新的痛点出现了:明明我们已经给 Header 加了 React.memo,为什么它还在重渲染?
原因就在于“浅比较”。Dashboard 组件每次重渲染时,const handleRefresh = () => {...} 这行代码都会创建一个 全新的函数对象。虽然这两个函数长得一模一样,但在 JavaScript 的世界里,它们的内存地址是不同的(() => {} !== () => {})。因此,React.memo 认为 onRefresh 这个 prop 每次都在“变化”,优化就此失效。
useCallback 正是解决这个问题的“函数稳定器”。它会缓存一个函数定义,在组件多次渲染之间返回同一个函数实例,除非它的依赖项改变了。
3. 使用 useCallback 修复问题
文件路径: src/components/Dashboard/Dashboard.tsx (最终修复)
1 | import { useState, useCallback } from 'react' // 引入 useCallback |
现在,一切都完美了。再次点击“切换主题”,Header 和 PerformanceReport 的渲染日志都消失了。我们通过 React.memo 和 useCallback 的组合拳,实现了对子组件渲染的精准控制。
第四步:第三把手术刀 useMemo —— 缓存结果
我们已经解决了不必要的 组件渲染,但还有一种性能浪费:不必要的 昂贵计算。
新的痛点: 假设我们的 PerformanceReport 组件需要根据一些复杂数据计算出一个最终得分,这个计算过程非常耗时。
1. 升级 PerformanceReport 组件
文件路径: src/components/Dashboard/PerformanceReport.tsx (引入昂贵计算)
1 | import React, { useState } from 'react' |
现在,PerformanceReport 不会再因为父组件 Dashboard 的主题切换而重渲染了。但是,请点击它自己新增的“强制组件内部刷新”按钮。你会发现,UI 会有明显的卡顿,并且控制台每次都会打印“正在执行极其耗时的分数计算…”。
这显然是错误的。我们的 reportData 根本没有变,但这个耗时的计算却在每次内部刷新时都被执行了一遍。
useMemo 就是解决这个问题的“结果缓存器”。它会执行一个函数,并将其 返回值 缓存起来。只有当其依赖项发生变化时,它才会重新执行函数并缓存新的结果。
2. 使用 useMemo 修复问题
文件路径: src/components/Dashboard/PerformanceReport.tsx (最终修复)
1 | import React, { useState, useMemo } from 'react' // 引入 useMemo |
刷新页面,再次点击“强制组件内部刷新”按钮。现在,UI 响应瞬间完成,控制台也只在首次渲染时打印了一次计算日志。我们成功地避免了不必要的计算!
本章小结
在本节中,我们通过一个层层递进的案例,掌握了 React 性能优化的三驾马车:
- 当问题是“不必要的组件重渲染”时,首先想到的是用
React.memo来包裹子组件。 - 当
React.memo因函数/对象 props 而失效时,使用useCallback来稳定函数引用,或使用useMemo来稳定对象/数组引用。 - 当问题是“组件内部有昂贵的计算”时,使用
useMemo来缓存计算结果,确保它只在必要时才执行。
掌握它们,是区分 React 新手和资深玩家的重要分水岭。请务必牢记:不要过度优化。只在你通过分析(如 console.log 或 React DevTools)确认存在性能瓶颈时,才去使用这些工具。
7.2 应用健壮性:构建生产级的稳定应用
一个“能用”的应用和一个“健壮”的应用之间,隔着两条鸿沟:
- 当应用遇到未预期的错误时,是“一键崩溃”还是能“优雅降级”?
- 当应用功能越来越庞大时,是让用户“耐心等待”一个巨大的文件加载,还是“按需加载”,提供流畅的访问体验?
本节,我们将学习 React 原生提供的两大“法宝”—— 错误边界(Error Boundaries) 和 代码分割(React.lazy + Suspense),来跨越这两条鸿沟。
第一步:制造一场“生产事故”——体验白屏崩溃
为了理解“健壮性”的重要性,我们必须先亲手制造一次“灾难”。我们将创建一个故意会出错的组件。
1. 创建一个会崩溃的组件
在 src/components/Dashboard 文件夹中,创建一个新文件 BuggyWidget.tsx。这个组件在点击按钮后,会尝试渲染一个 null 值的属性,这在 JavaScript 中会立即导致一个运行时错误。
文件路径: src/components/Dashboard/BuggyWidget.tsx (新建)
1 | import { useState } from 'react' |
我们在 data!.message 中使用了 ! (非空断言操作符),这是在告诉 TypeScript:“我确定 data 在这里不会是 null”。这本身就是一种危险的信号,为我们的“事故”埋下了伏笔。
2. 将“定时炸弹”放入仪表盘
现在,我们将这个危险的组件添加到我们的 Dashboard 中。
文件路径: src/components/Dashboard/Dashboard.tsx (修改)
1 | import BuggyWidget from "./BuggyWidget"; |
3. 运行并触发“爆炸”
刷新你的应用。仪表盘看起来一切正常。现在,勇敢地点一下那个红色的“点我制造一个错误”按钮。
BOOM! 你的整个应用瞬间白屏。
打开浏览器控制台,你会看到一个红色的错误,类似 TypeError: Cannot read properties of null (reading 'message')。
这就是我们的痛点:React 的默认行为是,任何一个组件在渲染期间发生的未被捕获的 JavaScript 错误,都会导致整个 React 组件树被卸载,最终呈现给用户的就是一片空白。这是一个极其糟糕的用户体验。我们绝不希望因为一个小组件的 Bug,导致整个页面都无法使用。
第二步:构建“安全气囊”——错误边界 (Error Boundaries)
为了防止这种“一损俱损”的情况,React 提供了错误边界(Error Boundaries)这一原生机制。它像一个“安全气囊”或“防火墙”,你可以用它包裹你的任何组件。当被包裹的组件及其子组件发生渲染错误时,错误会被这个“边界”捕获,同时,它会渲染一个你预设好的“降级 UI”,而不会让错误继续传播导致整个应用崩溃。
1. 创建 ErrorBoundary 组件
一个非常关键的点是:截止到目前,错误边界只能通过类组件来实现。这是 React 中少数几个函数组件无法完全替代类组件的场景之一。
在 src/components 目录下创建一个新文件 ErrorBoundary.tsx。
文件路径: src/components/ErrorBoundary.tsx (新建)
1 | import React, { Component, type ErrorInfo, type ReactNode } from "react"; |
这个可复用的 ErrorBoundary 组件现在就像一个装备了安全气囊的座椅,我们可以把它放在任何我们认为可能“颠簸”的地方。
2. 安装“安全气囊”
回到 Dashboard.tsx,用我们刚创建的 ErrorBoundary 把“危险”的 BuggyWidget 包裹起来。
文件路径: src/components/Dashboard/Dashboard.tsx (最终修复)
1 | import BuggyWidget from "./BuggyWidget"; |
3. 再次触发“事故”并观察
刷新页面,再次点击“点我制造一个错误”按钮。
这一次,奇迹发生了!页面不再白屏崩溃。BuggyWidget 的位置被我们预设的黄色警告框替代了,而应用的其余部分——头部、切换主题按钮、性能报告——完好无损,功能完全正常!
我们成功地将错误的“火情”控制在了“防火墙”内部,保证了应用主体的健壮性。
在现代的 React 元框架(如 Next.js)中,通常提供了基于文件的、更强大的错误处理机制(如 error.js 文件),但其底层思想与 React 的 Error Boundary 完全一致。掌握它,你就掌握了 React 错误处理的精髓。
第三步:为应用“减负”—— React.lazy 与 Suspense
我们已经解决了“崩溃”的问题,现在来解决“缓慢”的问题。
新的痛点: 我们的 PerformanceReport 组件现在非常成功,产品经理要求在里面加入一个巨大的、功能复杂的图表库(比如 D3.js, Chart.js, ECharts)。这将导致 PerformanceReport.tsx 及其依赖的库文件变得非常庞大。
问题在于,即使用户根本不看性能报告,甚至它还没出现在屏幕上,它的全部代码(包括那个巨大的图表库)在用户首次访问仪表盘页面时,就已经被打包进主文件并下载了。这严重拖慢了我们应用的初始加载速度,对于网络环境不好的用户尤其不友好。
解决方案:代码分割
代码分割是一种将代码拆分成多个小包(chunks),然后按需或并行加载它们的技术。React 原生提供了 React.lazy 和 <Suspense> 这一对“黄金搭档”来实现组件级别的代码分割。
React.lazy: 一个函数,它允许你像渲染普通组件一样渲染一个动态导入(dynamicimport())的组件。<Suspense>: 一个组件,它允许你在等待懒加载组件下载完成时,声明式地指定一个加载状态(fallback)。
1. 改造 Dashboard 以实现懒加载
我们将改造 Dashboard.tsx,让 PerformanceReport 组件只在它需要被渲染时才开始下载。
文件路径: src/components/Dashboard/Dashboard.tsx (懒加载改造)
1 | import React, { lazy, Suspense } from "react"; // 1.引入 lazy 和 Suspense |
2. 观察效果
刷新你的应用。这一次,你会看到“性能报告”区域先是显示“正在加载性能报告…”,然后很快(在本地开发环境中可能一闪而过)替换为真正的组件内容。
如果你打开浏览器的开发者工具,切换到“网络(Network)”面板并刷新页面,你会看到除了主文件外,还有一个新的 .js 文件在稍后被加载——这正是被我们分离出去的 PerformanceReport 组件的代码块!
我们成功地为应用实现了“减负”。现在,初始加载时用户只需下载核心功能代码,而像性能报告这样的重量级、非首屏关键组件,则被推迟到真正需要时才加载,极大地优化了应用的启动性能和用户体验。
本章小结
在本节中,我们为应用装备了两大“安全系统”,使其更加健壮和专业:
- 错误边界 (Error Boundaries): 我们的“安全气囊”,通过捕获渲染错误并提供降级 UI,防止了局部问题导致整个应用崩溃。
- 代码分割 (
React.lazy+<Suspense>): 我们的“智能加载器”,通过按需加载组件,显著提升了应用的初始加载性能。
掌握了这些,你的 React 应用不仅功能强大,而且在面对生产环境的各种复杂情况时,也能表现得像一个“不死小强”一样稳定可靠。
7.3 高级抽象:设计可复用且强大的组件
一个应用的质量,很大程度上取决于其基础组件的质量。本节,我们将深入学习 React 提供的高级 API 和社区沉淀的设计模式,让你具备构建“组件库”级别高质量组件的能力。
7.3.1 打破组件壁垒:Ref 转发与实例暴露
我们已经知道如何使用 useRef 来获取一个 DOM 元素的引用,但那仅限于在组件 内部。当你试图将 ref 传递给一个你自己的函数组件时,会发生什么?
痛点:ref 无法作为普通 prop 传递
让我们来创建一个自定义的输入框组件。
1. 创建 CustomInput 组件
文件路径: src/components/Dashboard/CustomInput.tsx (新建)
1 | type CustomInputProps = { |
2. 在 Dashboard 中尝试使用 ref
现在,我们希望在 Dashboard 父组件中,通过一个按钮来让这个自定义输入框聚焦。
文件路径: src/components/Dashboard/Dashboard.tsx (修改)
1 | import CustomInput from "./CustomInput"; |
运行应用,你会立刻在控制台看到一个 明确的警告:Dashboard.tsx: 8 Uncaught TypeError: Cannot read properties of null (reading 'focus') at focusCustomInput (Dashboard.tsx:8:28)
当你点击按钮时,应用会崩溃。这就是痛点:React 为了避免组件内部实现被意外暴露,默认禁止将 ref 直接传递给函数组件。
解决方案一:React.forwardRef
React.forwardRef 像一个“管道”,它能接收父组件传递过来的 ref,并将其“转发”到组件内部的某个具体 DOM 元素上。
1. 改造 CustomInput 组件
文件路径: src/components/Dashboard/CustomInput.tsx (修改)
1 | import { forwardRef } from 'react' // 1. 引入 forwardRef |
现在,Dashboard 组件中的代码无需任何修改,再次点击“聚焦自定义输入框”按钮,它就能完美工作了!
新的痛点:过度暴露
forwardRef 很棒,但它直接暴露了整个 input DOM 节点。这意味着父组件现在可以为所欲为,比如 customInputRef.current.style.backgroundColor = 'red'。这破坏了组件的封装性。我们希望只暴露我们想让父组件调用的方法,比如 focus(),或者一个自定义的 shake() 动画。
解决方案二:useImperativeHandle
这个 Hook 让你在使用 ref 时,可以 自定义 暴露给父组件的实例值。它总是和 forwardRef 一起使用。
1. 再次升级 CustomInput
文件路径: src/components/Dashboard/CustomInput.tsx (最终升级)
1 | import { forwardRef, useImperativeHandle, useRef, useState } from "react"; // 1. 引入 forwardRef |
现在,父组件的 ref 只能访问到我们明确定义的 focus 和 shake 方法,实现了完美的 封装 和 API 设计。
7.3.2 优雅的 API 设计:复合组件模式
当你需要构建一组相互关联、共同协作的组件时(比如一个选项卡 Tabs 系统),如何设计它们的 API?
痛点: 一种常见的方式是“配置式”,通过庞大的 props 对象来传递所有数据:<Tabs tabs={[{title: 'Tab 1', content: '...'}, ...]} />
这种方式简单,但 极不灵活。用户无法自定义单个 Tab 的样式,也无法在 Tab 标题中插入图标或其他组件。
解决方案:复合组件模式
这种模式模仿了 HTML <select> 和 <option> 的工作方式:将状态管理和逻辑放在父组件中,并通过 Context 在内部共享给子组件。这让用户可以通过 组合 的方式来构建 UI,获得了极大的灵活性。
1. 创建 Tabs 组件家族
我们将创建 Tabs, TabList, Tab, TabPanels, TabPanel 几个组件。在 src/components 下新建 Tabs 文件夹并创建以下文件。
文件路径: src/components/Tabs/TabsContext.tsx
1 | import { createContext, useContext } from 'react' |
文件路径: src/components/Tabs/Tabs.tsx
1 | import { useState } from 'react' |
2. 在 Dashboard 中使用复合组件
现在,我们可以在 Dashboard 中以一种极其声明式和灵活的方式来使用 Tabs 组件。
文件路径: src/components/Dashboard/Dashboard.tsx (添加 Tabs)
1 | // ... |
现在,我们的仪表盘拥有了一个功能齐全且 API 优美的选项卡系统。这种模式将 状态管理(在 Tabs 内部)和 UI 渲染(由用户自由组合)完美分离,是构建可复用组件库的 核心思想。
7.3.3 防患于未然:严格模式 <React.StrictMode>
最后,我们来了解一个不渲染任何 UI,但在开发过程中至关重要的“纪律委员”——严格模式。
痛点: 我们在开发时,可能会不自觉地使用一些过时的 API,或者编写出带有“不纯”副作用的函数,这些都是未来应用的潜在隐患。
解决方案: <React.StrictMode> 是一个辅助组件,它会为其后代组件开启额外的检查和警告(仅在开发模式下生效)。
它能帮助你发现:
- 不安全的生命周期方法。
- 过时的
refAPI 用法。 - 意料之外的副作用(它会故意调用两次渲染相关的函数来帮助你发现不纯的操作)。
- 过时的
contextAPI。
如何使用?
你只需要在应用的根部,用 <React.StrictMode> 包裹你的 <App /> 组件即可。这个操作通常在项目的入口文件 main.tsx 中完成,一般来说我们创建 vite 项目时他已经帮我们配置好了
文件路径: src/main.tsx (修改)
1 | import React from 'react' |
启用后,它不会带来任何可见的 UI 变化,但它会在你的开发控制台中,像一位严格的导师一样,指出你代码中不规范或有风险的地方。开启严格模式是所有现代 React 项目的最佳实践。
本章小结
在本节中,我们完成了从“组件使用者”到“组件设计者”的思维转变:
- 通过
forwardRef和useImperativeHandle,我们学会了如何设计组件的命令式 API,在封装和暴露之间找到完美平衡。 - 通过 复合组件模式,我们掌握了构建灵活、声明式组件家族的强大武器,将状态和视图彻底解耦。
- 通过
<React.StrictMode>,我们为项目聘请了一位免费的、全天候的代码质量“审查员”。
至此,您已经掌握了构建高质量、可维护、可扩展的 React 组件所需的绝大部分原生高级知识。













