序章:一份通往 React 高手之路的现代地图
摘要: 欢迎来到这本为 Vue 工程师及所有寻求 React 精通之路的开发者量身打造的深度笔记。我们深知,学习一门新技术的最终目的是掌握其解决实际问题的能力,并借此提升职业价值。因此,本系列笔记只有一个承诺:我们将为您铺设一条从 React 基础到全栈专家,乃至能够胜任一线技术岗位所需的最完整、最实战的学习路径。
我们为何如此确信?
这份自信并非空穴来风,它源于多年来在真实项目开发与技术教学中所沉淀的经验。我们见证了 React 生态的变迁,筛选出了在 2025 年 最具生产力、最受业界认可的技术组合。本笔记摒弃了简单的 API 罗列,转而采用以 海量实战项目驱动 的教学模式,确保您学习的每一个知识点,都能在具体的业务场景中落地生根。
这份笔记将为您呈现的知识全景
在本系列笔记中,我们将共同探索一片广阔的技术版图。这不仅是 React 本身,更是整个现代前端的黄金生态圈。
我们将从 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 或命令提示符窗口,执行以下命令来验证安装是否成功:
如果能看到对应的版本号输出,则代表 Node.js 环境已准备就绪。
1.1.2. 安装 pnpm
pnpm
是一个快速、节省磁盘空间的包管理器。我们推荐使用它来替代 npm
。在安装好 Node.js 之后,于终端中执行以下命令进行全局安装:
1.2. 初始化项目:从纯净的 Vite 模板开始
我们将在 Windows 的文件系统中创建项目。
打开您的终端,导航到您希望创建项目的目录(例如 D:\projects
),然后运行:
1 2
| pnpm create vite prorise-react-guide
|
Vite
会引导您完成几个选择:
1 2
| ✔ Select a framework: › React ✔ Select a variant: › TypeScript
|
按照提示,进入项目目录,安装依赖,并启动它:
1 2 3
| cd prorise-react-guide pnpm install pnpm run dev
|
Vite 启动后,会提供一个本地 URL,如 http://localhost:5173/
。您可以在 Windows 的浏览器中打开此地址。看到旋转的 React Logo,代表您的项目地基已成功铺设。
1.3. 项目初探:解剖 React 应用的启动流程
理解现有文件如何协同工作,是进行任何修改前的必要步骤。
初始项目结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| ├── public/ │ └── vite.svg ├── src/ │ ├── assets/ │ │ └── react.svg │ ├── App.css │ ├── App.tsx │ ├── index.css │ ├── main.tsx │ └── vite-env.d.ts ├── .eslintrc.cjs ├── .gitignore ├── index.html ├── package.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts
|
启动流程追溯:
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 2 3 4 5 6 7 8 9 10 11 12 13
| import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path'
export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, })
|
1.4.2. 配置 TypeScript
接着,修改 tsconfig.json
文件,让 TypeScript 的语言服务也能理解这个别名,从而提供正确的类型检查和路径补全。
文件路径: tsconfig.json
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13
| { "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ], "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }
|
关键一步: 修改 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 2 3
| pnpm remove eslint-plugin-react-refresh && \ pnpm add -D @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-react eslint-config-prettier globals
|
@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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| import globals from "globals"; import eslintJs from "@eslint/js"; import tseslint from "typescript-eslint"; import eslintPluginReact from "eslint-plugin-react"; import eslintPluginReactHooks from "eslint-plugin-react-hooks"; import eslintConfigPrettier from "eslint-config-prettier";
export default tseslint.config( { ignores: ["dist", "node_modules", "*.config.js", "public"], }, eslintJs.configs.recommended, ...tseslint.configs.recommended, { files: ["src/**/*.{ts,tsx}"], plugins: { react: eslintPluginReact, "react-hooks": eslintPluginReactHooks, }, languageOptions: { parserOptions: { ecmaFeatures: { jsx: true, }, }, globals: { ...globals.browser, }, }, rules: { ...eslintPluginReact.configs.recommended.rules, ...eslintPluginReactHooks.configs.recommended.rules, "react/react-in-jsx-scope": "off", }, }, eslintConfigPrettier );
|
1.5.3. 配置 Prettier 与编辑器集成
- 安装 Prettier:
1
| pnpm add -D prettier eslint-plugin-prettier
|
- 创建 Prettier 配置文件: 在项目根目录创建
.prettierrc.json
。
文件路径: .prettierrc.json
(新建)
1 2 3 4 5 6 7
| { "semi": false, "singleQuote": true, "printWidth": 80, "tabWidth": 2, "trailingComma": "es5" }
|
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 2 3 4 5 6 7 8 9 10 11 12
| export default { content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], }
|
这个配置意味着 Tailwind 会去扫描根目录的 index.html
文件以及 src
目录下所有以 .js
, .ts
, .jsx
, 或 .tsx
结尾的文件。
1.6.4. 引入 Tailwind 指令
现在,我们需要在我们的主 CSS 文件中引入 Tailwind 的三个核心指令层。
打开 src/index.css
文件,清空所有内容,然后替换为以下三行:
文件路径: src/index.css
(修改)
1 2 3
| @tailwind base; @tailwind components; @tailwind utilities;
|
1.6.5. 清理并验证
为了避免样式冲突,请确保 src/App.css
文件是空的,并且 src/App.tsx
中没有 import './App.css'
。
现在,重新运行 pnpm run dev
,并用以下代码替换 src/App.tsx
的内容来验证 Tailwind 是否生效:
文件路径: src/App.tsx
(临时修改以验证)
1 2 3 4 5 6 7 8 9 10 11
| function App() { return ( <div className="bg-slate-800 text-white min-h-screen flex justify-center items-center"> <h1 className="text-3xl font-bold underline"> 你好, Tailwind CSS! </h1> </div> ) }
export default 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <script setup lang="ts"> // 逻辑层:在 <script> 块中定义数据和逻辑 const user = { name: 'Prorise', avatarUrl: 'https://placekitten.com/g/64/64' } </script>
<template> <div class="greeting-card"> <img :src="user.avatarUrl" alt="User Avatar" /> <h1>Hello, {{ user.name }}!</h1> </div> </template>
<style scoped> /* 样式层:在 <style> 块中定义样式 */ .greeting-card { display: flex; align-items: center; } </style>
|
Greeting.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function App() { const user = { name: 'Prorise', avatarUrl: 'https://picsum.photos/64/64', }
return ( ( <div className="Card"> <img src={user.avatarUrl} alt="" /> <h1>Hello {user.name}</h1> </div> ) ) }
export default 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 2 3 4 5
| const element = React.createElement( 'h1', { className: 'greeting' }, 'Hello, world' );
|
理解 JSX 的本质是 React.createElement()
的语法糖,是从 Vue 思维转向 React 思维的关键一步。这意味着,你在 JSX 中能做的一切,都受限于 JavaScript 的语法规则和能力。
在 JSX 中嵌入表达式
由于 JSX 就是 JavaScript,我们可以用 {}
在其中无缝嵌入任何有效的 JavaScript 表达式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| function UserInfo() { const user = { firstName: 'Prorise', lastName: 'Blog' };
function formatName(user) { return user.firstName + ' ' + user.lastName; }
const element = <h1>Hello, {formatName(user)}!</h1>;
const score = 95; const gradeInfo = ( <div> <h2>Score Details</h2> <p>Your score is: {score}</p> <p>Grade: {score > 90 ? 'A' : 'B'}</p> </div> );
return gradeInfo; }
|
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function getGreeting(isLoggedIn: boolean) { if (isLoggedIn) { return <h1>Welcome back!</h1>; } return <h1>Please sign up.</h1>; }
function App() { const isLoggedIn = true; return ( <div> {getGreeting(isLoggedIn)} </div> ); }
|
在掌握了 JSX 的基本语法后,我们来解决两个最常见的动态 UI 场景:如何根据条件显示或隐藏内容,以及如何渲染一个数据列表。这两种场景将进一步深化您对“React 使用纯 JavaScript 解决问题”这一核心思想的理解。
本小节核心知识点:
- React 中 没有
v-if
或 v-for
这样的模板指令。 - 条件渲染 通过标准的 JavaScript 表达式来实现,主要是 **三元运算符** 和 **逻辑与 (`&&`) 运算符**。
- 列表渲染 通过数组的 `.map()` 方法将数据项转换为一个 JSX 元素数组。
- 在列表渲染中,为每个列表项提供一个稳定且唯一的
key
prop 是至关重要的。
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 2 3 4 5 6 7 8 9 10
| <script setup lang="ts"> const isLoggedIn = true; </script>
<template> <div> <h1 v-if="isLoggedIn">Welcome back!</h1> <h1 v-else>Please sign up.</h1> </div> </template>
|
LoginStatus.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function LoginStatus() { const isLoggedIn = true;
return ( <div> {/* 在 JSX 中嵌入三元表达式 */} {isLoggedIn ? ( <h1>Welcome back!</h1> ) : ( <h1>Please sign up.</h1> )} </div> ); }
|
场景二:仅 if
(对标 v-if
/ v-show
)
当您只想在满足某个条件时才渲染某个元素,否则什么都不渲染时,逻辑与 (&&
) 运算符 是最优雅的捷径。
这是利用了 JavaScript 的“短路”特性:如果 &&
左侧的表达式为 false
,则整个表达式的结果就是 false
,React 不会渲染任何东西;如果左侧为 true
,则表达式的结果为 &&
右侧的 JSX 元素,React 会将其渲染出来。
Mailbox.vue
1 2 3 4 5 6 7 8 9 10 11 12
| <script setup lang="ts"> const unreadMessages = ['React is awesome', 'Vue is great too']; </script>
<template> <div> <h1>Hello!</h1> <h2 v-if="unreadMessages.length > 0"> You have {{ unreadMessages.length }} unread messages. </h2> </div> </template>
|
Mailbox.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| function Mailbox() { const unreadMessages = ['React is awesome', 'Vue is great too'];
return ( <div> <h1>Hello!</h1> {/* 如果 unreadMessages.length > 0 为 true, 则渲染 <h2> 标签。 否则,整个表达式为 0 (number),React 不会渲染 0。 */} {unreadMessages.length > 0 && ( <h2> You have {unreadMessages.length} unread messages. </h2> )} </div> ); }
|
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <script setup lang="ts"> const todos = [ { id: 1, text: 'Learn React' }, { id: 2, text: 'Build an app' }, { id: 3, text: 'Ship it!' }, ]; </script>
<template> <ul> <li v-for="todo in todos" :key="todo.id"> {{ todo.text }} </li> </ul> </template>
|
TodoList.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function TodoList() { const todos = [ { id: 1, text: 'Learn React' }, { id: 2, text: 'Build an app' }, { id: 3, text: 'Ship it!' }, ];
const listItems = todos.map(todo => <li key={todo.id}> {todo.text} </li> );
return ( <ul>{listItems}</ul> ); }
|
必须提供 key
Prop
key
是 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <script setup lang="ts">
defineProps<{ name: string age: number isPremiumUser: boolean }>() </script>
<template> <div class="user-card"> <h2>{{ name }} ({{ age }})</h2> <p v-if="isPremiumUser">✨ Premium Member</p> </div> </template>
|
UserCard.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| interface UserCardProps { name: string; age: number; isPremiumUser: boolean; }
function UserCard({ name, age, isPremiumUser }: UserCardProps) { return ( <div className="user-card"> <h2>{name} ({age})</h2> {/* 使用逻辑与 (&&) 操作符进行条件渲染 */} {isPremiumUser && <p>✨ Premium Member</p>} </div> ); }
export default UserCard;
|
如何使用这个组件:
无论是在 Vue 还是 React 中,父组件使用子组件并传递 props
的方式都非常相似。
App.tsx (父组件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import UserCard from './UserCard'
function App() { const userData = { name: 'Prorise', age: 3, isPremiumUser: true, } return ( <div> <h1>User List</h1> <UserCard name={userData.name} age={userData.age} isPremiumUser={userData.isPremiumUser} /> <UserCard name="Guest" age={99} isPremiumUser={false} /> </div> ) }
export default App;
|
{}
在 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import React from 'react'
interface CardProps { children: React.ReactNode }
function Card({ children }: CardProps) { return <div className="card-container">{children}</div> }
export default Card;
|
现在,我们可以在 App
组件中使用这个 Card
组件来包裹任意内容。
App.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import Card from './Card' import UserCard from './UserCard'
function App() { return ( <div> {/* 用法 1: 包裹简单的 JSX 元素 */} <Card> <h1>这是一个标题</h1> <p>这是一个段落</p> </Card> {/* 用法 2: 包裹其他组件 */} <Card> <UserCard name="Prorise" age={3} isPremiumUser={true} /> </Card> </div> ) }
export default App
|
props.children 的机制是 React 组合优于继承 设计哲学的核心体现。通过它,我们可以构建出高度灵活和可复用的布局组件(如 Card, Modal, Sidebar),而无需关心它们内部具体要渲染什么内容。
2.2.3. 属性钻探:问题识别及其对架构的影响
Props 是组件通信的基础,但如果滥用,它会引发一个常见且棘手的架构问题——属性钻探 (Prop Drilling)。
核心概念: 属性钻探 是指,为了将某个 prop 从顶层父组件传递给深层嵌套的子组件,不得不让所有中间层级的组件都去接收并向下传递这个 prop,即使这些中间组件本身根本不需要使用它。
痛点背景:
想象一个组件树结构:App -> UserProfile -> UserAvatar -> UserImage
。现在,App
组件持有一个 imageUrl
,但只有最深层的 UserImage
组件需要它来显示图片。
1 2 3 4
| - App - UserProfile - UserAvatar - UserImage
|
为了让 imageUrl
到达 UserImage
,我们必须:
App
将 imageUrl
作为 prop 传给 UserProfile
。UserProfile
不使用 imageUrl
,但必须接收它,再原封不动地传给 UserAvatar
。UserAvatar
同样不使用 imageUrl
,但必须接收它,再传给 UserImage
。
这种层层传递就像用钻头打井一样,将属性“钻”过一个个组件层级,因此得名。
为什么这是一个问题?
- 代码冗余与耦合: 中间组件 (
UserProfile
, UserAvatar
) 的 props
接口被迫包含了它们本不关心的属性,导致组件职责不清,与顶层数据源产生了不必要的耦合。 - 重构困难: 如果未来
UserImage
不再需要 imageUrl
,或者需要一个新的 prop,你需要修改整条传递链路上的所有组件,维护成本极高。 - 可读性差: 当你阅读
UserProfile
的代码时,看到一个 imageUrl
prop,你无法立即判断它是否被当前组件使用,还是仅仅是一个“二传手”。
何时应该警惕 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 2 3 4 5 6 7 8 9
| import { ref } from 'vue'
const count = ref(0)
function increment() { count.value++ }
|
思想:创建并替换
1 2 3 4 5 6 7 8 9 10 11 12
| import { useState } from 'react'
const [count, setCount] = useState(0)
function increment() {
setCount(count + 1) }
|
2.3.2. useState
实战:构建一个计数器
让我们通过一个经典的计数器案例,来精确对比 ref
和 useState
的用法。
Counter.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <script setup lang="ts"> import { ref } from 'vue'
const count = ref(0)
function handleIncrement() { count.value++ } </script>
<template> <div> <p>Count: {{ count }}</p> <button @click="handleIncrement">+</button> </div> </template>
|
Counter.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import { useState } from 'react';
function Counter() { const [count, setCount] = useState(0);
function handleIncrement() { setCount(count + 1); }
return ( <div> <p>Count: {count}</p> {/* 在 onClick 中绑定事件处理函数 */} <button onClick={handleIncrement}>+</button> </div> ); }
export default Counter;
|
2.3.3. 状态更新的进阶技巧(重要)
状态更新的异步性与函数式更新
一个常见的误解是认为调用 setCount(count + 1)
后,count
变量会立即更新。实际上,React 的状态更新可能是 异步的 和 批量处理的 (batched)。React 可能会将多次状态更新合并为一次,以优化性能。
这就带来一个问题:如果你基于当前 state 计算下一个 state,可能会因为 state 尚未更新而得到错误的结果。
错误的示例:
1 2 3 4 5 6
| function handleTripleIncrement() { setCount(count + 1); setCount(count + 1); setCount(count + 1); }
|
解决方案:函数式更新
为了解决这个问题,状态更新函数可以接收一个 函数 作为参数。这个函数会自动接收 最新的、待处理的 state 作为其参数,并返回新的 state。
1 2 3 4 5 6 7
| function handleTripleIncrement() { setCount(prevCount => prevCount + 1); setCount(prevCount => prevCount + 1); setCount(prevCount => prevCount + 1); }
|
最佳实践: 当你的新状态需要依赖于前一个状态时,总是 使用函数式更新的形式。
更新对象与数组状态
不可变性的原则在处理对象和数组时尤为重要。
更新对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { useState } from 'react';
function UserProfile() { const [user, setUser] = useState({ name: 'Prorise', age: 3 });
function handleAgeIncrease() {
setUser({ ...user, age: user.age + 1 }); } return (); }
|
更新数组:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { useState } from 'react';
function TodoList() { const [todos, setTodos] = useState(['Learn React', 'Learn Hooks']);
function handleAddTodo() { const newTodo = 'Master State';
setTodos([ ...todos, newTodo ]); }
return (); }
|
常用的不可变数组操作包括:
- 添加:
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 2 3 4 5 6 7 8 9 10 11
| function ComplexCounter() { const [count, setCount] = useState(0); const [step, setStep] = useState(1);
const handleIncrement = () => setCount(c => c + step); const handleDecrement = () => setCount(c => c - step); const handleReset = () => setCount(0);
}
|
当操作类型更多(例如: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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| import { useReducer } from 'react';
interface CounterState { count: number; }
type CounterAction = | { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };
const initialState: CounterState = { count: 0 };
function reducer(state: CounterState, action: CounterAction): CounterState { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return initialState; default: throw new Error('Unknown action type'); } }
function ComplexCounter() { const [state, dispatch] = useReducer(reducer, initialState);
return ( <div> <p>Count: {state.count}</p> {/* 6. 在事件处理中,调用 dispatch 来表达“意图” */} <button onClick={() => dispatch({ type: 'increment' })}>+</button> <button onClick={() => dispatch({ type: 'decrement' })}>-</button> <button onClick={() => dispatch({ type: 'reset' })}>Reset</button> </div> ); }
export default ComplexCounter;
|
优势总结:
- 逻辑内聚: 所有的状态转换逻辑都被收敛到了
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 2 3 4 5 6 7
| <table> <tbody> <tr> {/* 这里必须直接是 <td> */} </tr> </tbody> </table>
|
如果我们的 Columns
组件为了满足“单一根元素”的规则,用一个 <div>
包裹了多个 <td>
,那么最终渲染出的 DOM 结构将是无效的,并很可能导致表格布局错乱。
错误的做法:
1 2 3 4 5 6 7 8 9 10 11 12
| function Columns() { return ( <div> <td>Column 1</td> <td>Column 2</td> </div> ); }
|
解决方案:使用 Fragment
Fragment
就像一个看不见的包装器,它在组件的返回值中满足了“单一元素”的语法要求,但在最终的 DOM 渲染中会完全消失。
Columns.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| function Columns() { return ( <> <td>Column 1</td> <td>Column 2</td> </> ); }
function App() { return ( <table> <tbody> <tr> <Columns /> </tr> </tbody> </table> ) }
|
最终渲染的有效 HTML:
1 2 3 4
| <tr> <td>Column 1</td> <td>Column 2</td> </tr>
|
Columns.tsx
1 2 3 4 5 6 7 8 9 10 11
| import React from 'react';
function Columns() { return ( <React.Fragment> <td>Column 1</td> <td>Column 2</td> </React.Fragment> ); }
|
何时使用长语法?
唯一的场景是当你需要为一个 Fragment
提供 key
prop 时,例如在循环渲染中使用 Fragment
。短语法 <></>
不支持任何属性。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function Glossary({ items }) { return ( <dl> {items.map(item => ( // 在 map 循环中,Fragment 需要一个 key <React.Fragment key={item.id}> <dt>{item.term}</dt> <dd>{item.description}</dd> </React.Fragment> ))} </dl> ); }
|
总结: 在日常开发中,当你需要从组件返回多个并列元素时,优先使用 <></>
。它简洁且能解决绝大多数问题。只有在列表渲染需要 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 2 3
| <style scoped> .title { color: red; } </style>
|
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 2 3 4
| .title { color: blue; font-size: 24px; }
|
MyComponent.tsx:
1 2 3 4 5 6 7 8
| import styles from './MyComponent.module.css';
function MyComponent() { return <h1 className={styles.title}>Hello, CSS Modules!</h1>; }
|
优点: 编译时处理,零运行时开销。实现了和 scoped
同样的效果。
缺点: 类名需要通过 styles.xxx
的方式引用,稍微有点繁琐。
方案二:CSS-in-JS (e.g., Styled Components, Emotion)
这是在 React 社区非常流行的一种“万物皆 JS”的哲学体现。你直接在 JavaScript 文件中用模板字符串或对象来写 CSS。
工作方式: 你使用库提供的函数(如 styled
)来创建一个附加了样式的 React 组件。
Button.tsx (使用 Styled Components):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import styled from 'styled-components';
const Button = styled.button` background-color: palevioletred; color: white; font-size: 1em; padding: 0.25em 1em; border: 2px solid palevioletred; border-radius: 3px;
/* 可以引用 props 动态改变样式 */ ${props => props.primary && ` background-color: white; color: palevioletred; `} `;
function App() { return ( <div> <Button>Normal</Button> <Button primary>Primary</Button> </div> ); }
|
优点: 样式的动态能力极强,可以访问组件的 props
和 state
。组件和它的样式被真正绑定在一起,实现了高内聚。
缺点: 存在一定的运行时性能开销(尽管现代库已经优化得很好)。
方案三:Utility-First CSS (Tailwind CSS - 2025 年推荐方案)
这是目前业界最受推崇的方案。它颠覆了传统的为组件编写独立 CSS 的思路。
工作方式: 你不再为组件写 CSS 类,而是直接在 JSX 中组合大量预设的、功能单一的 原子化 class。
UserProfile.tsx (使用 Tailwind CSS):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function UserProfile({ name, role, imageUrl }) { return ( <div className="p-6 max-w-sm mx-auto bg-white rounded-xl shadow-lg flex items-center space-x-4"> <div className="shrink-0"> <img className="h-12 w-12 rounded-full" src={imageUrl} alt="User Avatar" /> </div> <div> <div className="text-xl font-medium text-black">{name}</div> <p className="text-slate-500">{role}</p> </div> </div> ); }
|
优点: 开发速度极快,无需在 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 2 3 4 5 6 7 8 9 10
| <script setup> function handleClick() { console.log('你点击了我'); } </script>
<template> <button @click="handleClick">点击</button> </template>
|
Button.tsx
1 2 3 4 5 6 7
| function Button() { const handleClick = () => console.log('你点击了我'); return <button onClick={handleClick}>点击</button>; }
|
React 支持所有标准 DOM 事件,我们只需要将它们转换为小驼峰命名即可。
Copy.tsx
1 2 3 4 5 6 7 8 9 10 11
| function Copy() { function copyHandler() { console.log("请勿复制我的内容。"); }
return ( <p onCopy={copyHandler}> 这是一段受版权保护的文本,请勿随意复制。当你尝试复制时,控制台会输出一条信息。 </p> ); }
|
Move.tsx
1 2 3 4 5 6 7 8 9 10 11 12
| function Move() { function moveHandler() { alert("鼠标悬停事件触发"); console.log("鼠标悬停事件触发"); }
return ( <p onMouseOver={moveHandler}> 将你的鼠标移动到这段文字上,会触发一个 onMouseOver 事件,并弹出一个提示框。 </p> ); }
|
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import { useState } from 'react'
function ItemList() { const [items, setItems] = useState([ { id: 1, name: '学习React' }, { id: 2, name: '学习Hooks' }, ])
function handleDelete(id: number) { alert(`准备删除ID为${id}的项目`) setItems(items.filter((item) => item.id !== id)) }
return ( <ul> {items.map((item) => ( <li key={item.id}> {item.name} <button onClick={() => handleDelete(item.id)}>删除</button> </li> ))} </ul> ) }
export default ItemList
|
2.6.4. React 的合成事件对象
当你需要访问原生 DOM 事件对象时(例如,event.preventDefault()
或获取输入框的值 event.target.value
),React 会提供一个 合成事件 (SyntheticEvent) 对象。
这个对象是 React 对原生浏览器事件的跨浏览器包装器,它的接口与原生事件几乎完全相同,但保证了在所有浏览器中的行为一致性。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function Form() { function handleSubmit(event: React.FormEvent) { event.preventDefault(); console.log('表单已提交,但页面未刷新!'); }
return ( <form onSubmit={handleSubmit}> <button type="submit">提交</button> </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 2 3 4 5 6 7 8 9 10 11 12 13 14
| <!DOCTYPE html> <html lang="en"> <head> <title>Prorise React Guide</title> </head> <body> <div id="root"></div> <div id="portal-popup"></div> <script type="module" src="/src/main.tsx"></script> </body> </html>
|
现在,我们的 DOM 结构中有两个独立的“根”,#root
用于主应用,#portal-popup
专门用于接收被传送过来的内容。
第二步:创建 Portal 组件
接下来,我们创建 PopupContent
组件。这个组件的核心就是调用 createPortal
。
文件路径: src/components/PopupContent.jsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { createPortal } from "react-dom";
const PopupContent = ({ copied }: { copied: boolean }) => { return createPortal( <section> {copied && ( <div style={{ position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)', background: 'black', color: 'white', padding: '10px', borderRadius: '5px', }} > 已复制到剪贴板 </div> )} </section>, document.querySelector('#portal-popup') ) }
export default PopupContent;
|
第三步:在父组件中正常使用
Portal 最奇妙的一点在于:尽管 PopupContent
的 DOM 被渲染到了别处,但它在 React 组件树 中仍然是 CopyInput
的子组件。这意味着它可以正常接收来自 CopyInput
的 props(如 copied
),并且事件可以正常地从 PopupContent
冒泡到 CopyInput
。
文件路径: src/components/CopyInput.jsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| import { useState } from 'react' import PopupContent from './PopupContent'
const CopyInput = () => { const [inputValue, setInputValue] = useState('你好, Prorise!') const [copied, setCopied] = useState(false)
const handleCopy = () => { navigator.clipboard.writeText(inputValue).then(() => { setCopied(true) setTimeout(() => { setCopied(false) }, 2000) }) }
return ( <div style={{ position: 'relative', marginTop: '6rem', border: '1px solid red', overflow: 'hidden', }} > <input placeholder='请输入内容' type='text' value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> <button onClick={handleCopy}>复制</button>
{/* 从 CopyInput 的角度看,PopupContent 就是一个普通的子组件 */} <PopupContent copied={copied} /> </div> ) }
export default CopyInput
|
核心洞见: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 2 3 4 5 6 7 8 9 10 11 12 13 14
| import { useState } from 'react';
function Counter() { const [count, setCount] = useState(0);
return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
|
新需求: 我们希望每当 count 变化时,浏览器的标签页标题也能同步更新为 “You clicked X times”。
思考一下: 这个操作应该放在组件的哪个部分?
- 不能 直接放在组件函数的主体里,因为那里的代码会在 每次渲染时 都执行,我们不希望在渲染过程中执行 DOM 操作。
- 不能 放在事件处理器里,因为我们希望在 组件加载完成时 也设置一次标题,而不仅仅是点击时。
这个“更新浏览器标题”的操作,就是一个典型的 副作用 (Side Effect)。它不直接计算和返回 JSX,而是去操作一个 React 组件之外的系统(在这里是浏览器 DOM)。
3.1.1. 解决方案:使用 useEffect 同步状态到外部
为了处理这种副作用,我们引入 useEffect。
CounterWithTitle.tsx (引入 useEffect)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import { useState, useEffect } from 'react'
function CounterWithTitle() { const [count, setCount] = useState(0)
useEffect(() => { console.log('副作用函数正在运行...') document.title = '你点击了' + count + '次' })
return ( <div> <p>你点击了 {count} 次</p> <button onClick={() => setCount(count + 1)}>+</button> </div> ) }
export default CounterWithTitle;
|
现在,当你运行这个组件时,会发现:
- 组件首次加载时,标题会更新为 “你点击了 0 次”。
- 每次点击按钮,count 增加,标题也会同步更新。
useEffect 完美地解决了我们的问题。它就像一个我们安置在组件渲染流程之外的“特殊区域”,专门用来执行那些不方便在主函数体中进行的操作
3.1.2. 解构 useEffect:效应函数与依赖数组
上面的代码 useEffect(() => { … }); 是 useEffect 最基本的形式,但它并不完美(打开控制台,你会发现 “Effect function is running!” 在每次渲染时都会打印)。为了精确控制副作用的执行时机,我们需要理解它的完整结构:
- setup 函数: 第一个参数,一个函数。我们称之为“效应函数”。你的副作用逻辑就写在这里(例如,document.title = …)。
- dependencies 数组: 第二个参数,一个 可选的 数组。我们称之为“依赖数组”。这是 useEffect 的灵魂所在,它告诉 React:“只有当这个数组里的值发生变化时,才需要重新执行 setup 函数。”
现在,让我们用依赖数组来优化我们的组件,让 effect 只在 count 变化时执行:
1 2 3 4 5 6
| useEffect(() => { console.log('副作用函数正在运行...') document.title = '你点击了' + count + '次' },[count])
|
现在,useEffect 的行为变得更加智能:
- React 在每次渲染后,会比较 [count] 这次的值和上次渲染时的值。
- 如果 count 没变(例如,父组件的其他 state 变化导致本组件重渲),React 会跳过 setup 函数的执行。
- 只有当 count 的值确实发生了变化,setup 函数才会再次运行。
3.2. 精通依赖数组:从 onMounted 到 watch 的精确映射
通过控制依赖数组的内容,我们可以精确地模拟出 Vue 中几乎所有的生命周期和侦听行为。
用法一:空数组 [] —— 对标 onMounted
如果你提供一个 空的依赖数组 [],这意味着 setup 函数的依赖永远不会改变。因此,这个 setup 函数将 只在组件第一次渲染挂载后执行一次。
痛点背景: 在 Vue 中,我们需要在组件挂载后从服务器获取初始数据,我们会这样写:
1 2 3
| onMounted(async () => { userData.value = await fetchUserData(); });
|
在 React 中如何实现?
解决方案:
UserProfile.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { useState, useEffect } from 'react';
function UserProfile({ userId }) { const [user, setUser] = useState(null);
useEffect(() => { console.log('组件被挂载..'); fetchUserData(userId).then(data => { setUser(data); }); }, []);
if (!user) { return <div>Loading...</div>; }
return <div>Welcome, {user.name}</div>; }
|
用法二:包含值的数组 [dep1, dep2] —— 对标 watch
当你向依赖数组中提供一个或多个值时,useEffect 就会像 Vue 的 watch 一样工作:它会 “侦听”
这些值的变化,并在任何一个值改变后的下一次渲染完成后,执行 setup 函数。
痛点背景: 在 Vue 中,如果一个 prop (例如 userId) 变化了,我们需要重新获取数据。我们会使用 watch:
1 2 3
| watch(() => props.userId, (newUserId) => { userData.value = await fetchUserData(newUserId); });
|
这正是我们在 CounterWithTitle
示例中已经做过的事情。
1 2 3 4 5 6
| useEffect(() => { console.log('副作用函数正在运行...') document.title = '你点击了' + count + '次' },[count])
|
一个常见的陷阱: 如果你不提供依赖数组(useEffect(() => { ... })
),setup
函数会在 每一次渲染后 都执行。这等价于 Vue 的 onUpdated
加上 onMounted
,通常会导致性能问题或无限循环,是你应该极力避免的模式。
3.3. 清理机制:对标 onUnmounted
副作用通常需要“清理”。例如,如果你设置了一个定时器,或者添加了一个全局事件监听,你需要在组件被销毁时取消它们,以防止内存泄漏或 bug。
解决方案: useEffect
的 setup
函数可以 返回另一个函数。React 会将这个返回的函数保存下来,并在 下一次 effect 即将重新执行之前,或者 组件即将卸载时,自动调用它。这个返回的函数就是 清理函数。
痛点背景: 在 Vue 中,我们在 onUnmounted
钩子中执行清理工作。
1 2 3 4 5 6 7
| onMounted(() => { const timerId = setInterval(() => { }, 1000); onUnmounted(() => { clearInterval(timerId); }); });
|
React 中的等价实现:
Timer.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import { useState, useEffect } from 'react';
function Timer() { const [seconds, setSeconds] = useState(0); useEffect(() => { console.log('Effect is setting up a timer.'); const timerId = setInterval(() => { setSeconds(s => s + 1); }, 1000);
return () => { console.log('Cleanup function is running! Clearing timer.'); clearInterval(timerId); }; }, []);
return <h1>{seconds} seconds have passed.</h1>; }
|
通过将副作用的“创建”和“清理”逻辑放在同一个 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| import { useState } from "react";
function Switcher() { const [sw, setSw] = useState(false);
return ( <div className="text-center p-8"> {sw ? ( <span className="bg-black text-white p-2 rounded m-2">暗黑模式</span> ) : ( <span className="bg-slate-300 text-black p-2 rounded m-2">明亮模式</span> )} <div className="mt-4"> <input type="text" placeholder="在这里输入..." className="border-2 border-gray-400 p-2 rounded" // 👇 这是本节的核心! // input 元素的 key 会根据 sw 的状态在 "dark" 和 "light" 之间切换 key={sw ? "dark" : "light"} /> <button className="ml-2 bg-blue-500 text-white p-2 rounded hover:bg-blue-700" onClick={() => setSw((s) => !s)} > 切换 </button> </div> </div> ); };
export default Switcher;
|
发生了什么?
<input>
元素是一个 非受控组件,它自己在内部管理着用户输入的值。- 当你在输入框里输入一些文字时,这些文字被保存在这个
<input>
实例的内部状态中。 - 当你点击“切换”按钮时,
sw
的值改变,导致 <input>
的 key
prop 从 "light"
变成了 "dark"
。 - React 发现
key
变了,它不会去更新旧的输入框,而是直接 销毁 旧的 <input>
实例(连同它内部保存的输入文字),然后创建一个 全新的、状态为空的 <input>
实例并挂载到 DOM 上。
🤔 思考一下
请亲自尝试一下这个效果:
- 在输入框中随意输入一些文字。
- 点击“切换”按钮。
- 观察输入框,你会发现里面的文字消失了!这正是因为
key
的改变导致了整个 <input>
组件的重置。
3.4.2. 实战场景:优雅地重置复杂表单
现在我们理解了原理,让我们回到一个更真实的痛点:
痛点背景: 你有一个用户资料编辑表单组件 (UserProfileForm
),它接收一个 userId
作为 prop,内部有多个 useState
。当 userId
prop 变化时,我们期望整个表单被清空并重新加载新用户的数据。如果用 useEffect
,代码会很繁琐:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| function UserProfileForm({ userId }) { const [name, setName] = useState(''); const [email, setEmail] = useState('');
useEffect(() => { setName(''); setEmail(''); fetchUserData(userId).then(data => { setName(data.name); setEmail(data.email); }); }, [userId]); }
|
解决方案:用 key
声明式地重置
基于我们从 Switcher
中学到的知识,我们可以用 key
来极大地简化这个流程。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function App() { const [currentUserId, setCurrentUserId] = useState('user-1');
return ( <div> <button onClick={() => setCurrentUserId('user-2')}> 切换到用户2 </button> {/* 每次都需要组件内部的 useEffect 来处理重置 */} <UserProfileForm userId={currentUserId} /> </div> ); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
|
function UserProfileForm({ userId }) { const [user, setUser] = useState(null);
useEffect(() => { fetchUserData(userId).then(data => setUser(data)); }, [userId]);
}
function App() { const [currentUserId, setCurrentUserId] = useState('user-1');
return ( <div> <button onClick={() => setCurrentUserId('user-2')}> 切换到用户2 </button> {/* 我们将 userId 同时作为 prop 和 key 传递进去。 当 currentUserId 变化时,key 随之变化, React 会自动销毁旧的表单实例,创建一个全新的实例。 新实例挂载时,它内部的 useEffect 会自动运行一次,加载新用户数据。 */} <UserProfileForm key={currentUserId} userId={currentUserId} /> </div> ); }
|
总结与最佳实践:
当一个组件的“身份”与其某个核心 prop
(通常是 ID)深度绑定时,将这个 prop
同时用作组件的 key
是一种极其强大且优雅的模式。它将“当 prop
变化时重置组件”这个命令式的逻辑,转换为了“这个 prop
就是组件的身份”这种声明式的表达,让父组件完全掌握了子组件的生命周期,代码更简洁,意图也更清晰。
第四章: 跨组件通信与逻辑复用
摘要: 在前几章,我们掌握了通过 Props 进行父子通信,以及在组件内部管理状态和副作用的核心能力。然而,当应用变得复杂,跨越多个层级的“远距离”通信和在组件间共享相似的逻辑就成了新的挑战。本章将直面这两个痛点,首先引入 Context
机制,彻底解决“属性钻探”问题;接着,我们将学习 useRef
,掌握在 React 中与 DOM 交互及存储持久化变量的能力;最后,我们将所有知识融会贯通,学习 React 最强大的模式——自定义 Hooks,将组件逻辑提升到前所未有的可复用高度。
在本章中,我们将沿着一条清晰的“问题-解决方案”路径,解锁 React 的高级能力:
- 首先,我们将重新审视在
2.2.3
节提出的 “属性钻探” (Prop Drilling) 问题。 - 接着,我们将引入 React 官方的解决方案 Context API 与
useContext
Hook,学习如何在组件树中进行“大范围”的状态共享,这精确对标 Vue 的 provide/inject
。 - 然后,我们将解决另一个常见需求:如何在 React 中直接操作 DOM 元素。我们将学习
useRef
Hook 来应对这类场景,它对标 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 2 3 4 5 6
| ├── components/ │ ├── Greeting.tsx │ ├── UserInfoCard.tsx │ └── UserProfilePage.tsx └── App.tsx
|
App.tsx (数据源)
1 2 3 4 5 6 7 8
| import UserProfilePage from './components/UserProfilePage';
function App() { const currentUser = { name: 'Prorise' };
return <UserProfilePage user={currentUser} />; }
|
UserProfilePage.tsx (中间人 A)
1 2 3 4 5 6 7 8 9 10 11
| import UserInfoCard from './UserInfoCard';
function UserProfilePage({ user }) { return ( <div> <h1 className="text-2xl font-bold">欢迎来到个人资料页</h1> <UserInfoCard user={user} /> </div> ); }
|
UserInfoCard.tsx (中间人 B)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import Greeting from './Greeting'
function UserInfoCard({ user }: { user: { name: string } }) { return ( <div className="p-4 border rounded-lg shadow-md"> <p>用户信息卡片</p> <Greeting user={user} /> </div> ) }
export default UserInfoCard;
|
Greeting.tsx (最终消费者)
1 2 3 4
| function Greeting({ user }) { return <p className="text-lg">你好, {user.name}!</p>; }
|
这种层层传递让中间组件变得臃肿且高度耦合,维护起来就是一场噩梦。
4.1.2. 解决方案:三步构建 Context
现在,我们用 Context 来彻底重构这个流程。
第一步:创建 Context 对象
最佳实践是为你的 Context 创建一个单独的文件。
文件路径: src/contexts/UserContext.ts
(新建)
1 2 3 4 5 6 7 8 9
| import { createContext } from 'react';
interface User { name: string; }
export const UserContext = createContext <User | null>(null);
|
第二步:在顶层提供 (Provide) Context
回到我们的 App.tsx
,使用 <UserContext.Provider>
来包裹整个应用,并通过 value
prop 将数据“注入”到组件树中。
文件路径: src/App.tsx
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { useState } from 'react'; import { UserContext } from './contexts/UserContext'; import UserProfilePage from './components/UserProfilePage';
function App() { const [currentUser] = useState({ name: 'Prorise' });
return ( <UserContext.Provider value={currentUser}> <UserProfilePage /> </UserContext.Provider> ); }
export default App;
|
现在,被 Provider
包裹的所有后代组件,无论嵌套多深,都具备了直接访问 currentUser
数据的能力。
第三步:在深层组件中消费 (Consume) Context
这是最激动人心的一步。我们现在可以直接在 Greeting
组件中获取数据,而完全绕过中间组件。
文件路径: src/components/Greeting.tsx
(修改)
1 2 3 4 5 6 7 8 9 10 11 12
| import { useContext } from 'react'; import { UserContext } from '../contexts/UserContext';
function Greeting() { const user = useContext(UserContext);
return <p className="text-lg"> 你好, {user ? user.name : '游客'}! </p>; }
export default Greeting;
|
useContext
是迄今为止最简洁、最直观的消费 Context 的方式。
文件路径: src/components/Greeting.tsx
(旧版写法,仅作了解)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import { UserContext } from '../contexts/UserContext'
function Greeting() { return ( <UserContext.Consumer> {(user) => ( // 这个函数接收 Context 的值,并返回 JSX <p className="text-2xl"> 你好, {user ? user.name : '游客'}! </p> )} </UserContext.Consumer> ) } export default Greeting;
|
<Context.Consumer>
是一种基于 Render Props 模式的旧方法。当需要消费多个 Context 时,它会导致多层嵌套(俗称“回调地狱”),可读性很差。在现代 React 开发中,应 始终优先使用 useContext
Hook。
最终成果:解耦的中间组件
现在,我们的中间组件 UserProfilePage
和 UserInfoCard
不再需要关心 user
prop,它们变得干净、独立且高度可复用。
UserProfilePage.tsx (重构后)
1 2 3 4 5 6 7 8 9 10 11
| import UserInfoCard from './UserInfoCard';
function UserProfilePage() { return ( <div> <h1 className="text-2xl font-bold"> 欢迎来到个人资料页 </h1> <UserInfoCard /> </div> ); }
|
Context 总结:
Context
是解决 React 中“跨级组件通信”问题的官方标准答案。它允许我们将一些“全局性”的数据(如用户身份、主题、语言设置等)从顶层注入,让任何深度的子组件都能按需、直接地获取,从而实现组件间的彻底解耦。
4.2. 引用与命令式操作:useRef
的双重角色
在 React 的声明式世界里,我们通常不直接操作 DOM。但总有一些场景,我们必须“命令式地”与 DOM 元素交互,比如让一个输入框聚焦。useRef
就是 React 为这类场景提供的官方“后门”。
本小节核心知识点:
useRef
返回一个可变的 ref 对象,其 .current
属性被初始化为您传入的参数 (useRef(initialValue)
)。useRef
有两大核心用途:- 访问 DOM 节点,这精确对标 Vue 的
模板引用
。 - 存储一个不触发组件重新渲染的可变值,类似于 Vue 3
script setup
中一个普通的、非响应式的变量。
- 改变
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| import { useRef } from "react";
function FocusableInput() { const inputRef = useRef<HTMLInputElement>(null);
const handleFocusClick = () => { if (inputRef.current) { inputRef.current.focus(); inputRef.current.value = "Prorise 教程真棒!"; } };
return ( <div className="text-center p-8"> {/* 第二步:使用 ref 属性将创建的 Ref 对象附加到 DOM 元素上 */} <input ref={inputRef} type="text" className="border-2 border-gray-400 p-2 rounded" placeholder="点击按钮来聚焦" /> <button className="ml-2 bg-blue-500 text-white p-2 rounded hover:bg-blue-700" onClick={handleFocusClick} > 聚焦并写入内容 </button> </div> ); };
export default FocusableInput;
|
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| import { useRef, useEffect, useState } from "react";
function IntervalTimer() { const [count, setCount] = useState(0); const intervalRef = useRef<number | null>(null);
useEffect(() => { intervalRef.current = window.setInterval(() => { setCount((prevCount) => prevCount + 1); }, 1000);
return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, []);
const handleStopTimer = () => { if (intervalRef.current) { clearInterval(intervalRef.current); } };
return ( <div className="text-center p-8"> <h1 className="text-2xl">计时器: {count} 秒</h1> <button className="mt-4 bg-red-500 text-white p-2 rounded hover:bg-red-700" onClick={handleStopTimer} > 停止计时器 </button> </div> ); };
export default IntervalTimer;
|
在这个例子中,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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import { useState, useEffect } from "react";
function PostList() { const [posts, setPosts] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { fetch("https://jsonplaceholder.typicode.com/posts") .then((res) => res.json()) .then((data) => setPosts(data)) .catch((err) => setError(err)) .finally(() => setLoading(false)); }, []);
if (loading) return <p>加载中...</p>; if (error) return <p>错误: {error.message}</p>;
}
|
现在,如果另一个组件 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { useState } from "react";
const useToggle = (initialValue = false) => { const [value, setValue] = useState(initialValue); const toggle = () => setValue((prevValue) => !prevValue); return [value, toggle]; };
export default useToggle;
|
文件路径: src/components/ToggleComponent.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import useToggle from '../hooks/useToggle'
const ToggleComponent = () => { const [isToggled, toggle] = useToggle(false)
return ( <div className="p-4 border rounded-md my-4"> <button className="bg-blue-500 text-white p-2 rounded-b-2xl" onClick={toggle} > {isToggled ? '显示' : '隐藏'} 信息 </button> {isToggled && <p className="mt-2">这是一条可以切换显示的消息!</p>} </div> ) }
export default ToggleComponent
|
这个 Hook 封装了处理受控输入框 value
和 onChange
的通用逻辑。
示例三:useFetch
- 封装异步数据获取(最强示例)
这正是我们最初那个痛点的完美解决方案。它封装了数据、加载状态、错误状态以及数据获取的整个 useEffect
逻辑。
文件路径: src/hooks/useFetch.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { useEffect, useState } from 'react'
const useFetch = (url: string) => { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState<Error | null>(null)
useEffect(() => { const fetchData = async () => { setLoading(true) try { const response = await fetch(url) if (!response.ok) { throw new Error('网络响应失败') } const data = await response.json() setData(data) } catch (error) { setError(error as Error) } finally { setLoading(false) } } fetchData() }, [url])
return { data, loading, error } }
export default useFetch
|
文件路径: src/components/FetchComponent.jsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import useFetch from '../hooks/useFetch'
const FetchComponent = () => { const { data , loading, error } = useFetch( 'https://jsonplaceholder.typicode.com/posts?_limit=10' )
if (loading) return <p>加载中...</p> if (error) return <p>错误: {error.message}</p>
return ( <div className="p-4 border rounded-md my-4"> <h2 className="font-bold text-lg">文章列表</h2> <ul className="list-disc pl-5"> {data?.map((post: any) => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ) }
export default FetchComponent
|
4.3.3. 组合与应用
最后,我们可以在 App.jsx
中轻松地将这些由自定义 Hook 驱动的组件组合在一起,每个组件都只关心自己的 UI 呈现,而将复杂的逻辑“外包”给了 Hooks。
文件路径: src/App.jsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import FetchComponent from "./components/FetchComponent"; import FormComponent from "./components/FormComponent"; import ToggleComponent from "./components/ToggleComponent";
function App() { return ( <div className="container mx-auto p-4"> <h1 className="text-3xl font-bold mb-4">React 自定义 Hooks 示例</h1> <ToggleComponent /> <FormComponent /> <FetchComponent /> </div> ); }
export default App;
|
自定义 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
依赖。
第二步:理解 SCSS Modules 的工作方式
Vite 已为我们内置了 CSS Modules 的支持。我们只需遵循一个简单的命名约定:将样式文件命名为 [ComponentName].module.scss
。
当你这样做时:
- Vite 会将这个 SCSS 文件中的所有类名进行哈希处理,生成一个独一无二的类名(例如
.title
变成 .Counter_title__aB3xY
)。 - 当你
import
这个文件时,它会返回一个 JavaScript 对象,键是你原始的类名,值是哈希后的唯一类名。
这种机制从根本上解决了 CSS 全局污染的问题。
项目目标
我们将构建一个简单的计数器应用,包含一个显示的数字、一个“增加”按钮和一个“减少”按钮。
useState
: 用于管理计数器的数字状态。- 事件处理:
onClick
事件绑定与处理函数的编写。 - SCSS Modules: 实现组件级别的样式封装。
项目结构与代码解析
我们将采用“组件文件夹”的最佳实践来组织代码,将与 Counter
组件相关的所有文件都放在同一个地方。
1 2 3 4 5 6
| ├── components/ │ └── Counter/ │ ├── Counter.tsx │ └── Counter.module.scss └── App.tsx
|
1. Counter.module.scss
(组件样式)
首先,我们来编写样式。注意看我们是如何使用 SCSS 的嵌套和变量功能的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
|
.container { text-align: center;
.numberDisplay { font-size: 6rem; color: #ffffff; } }
.buttonContainer { width: 40rem; display: flex; justify-content: space-around; margin-top: 5rem; }
.actionButton { padding: 10px 20px; border-radius: 50px; font-size: 2rem; background: #141517; color: #fff; cursor: pointer; border: none; min-width: 60px; &:hover { background: #2a2c2e; } }
|
2. Counter.tsx
(核心组件)
现在,我们在组件逻辑中导入并使用这些模块化的样式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import React, { useState } from "react";
import styles from "./Counter.module.scss";
function Counter() { const [count, setCount] = useState(0);
const increment = () => setCount(prevCount => prevCount + 1); const decrement = () => setCount(prevCount => prevCount - 1);
return ( <div className={styles.container}> <h1 className={styles.numberDisplay}>{count}</h1>
<section className={styles.buttonContainer}> <button onClick={increment} className={styles.actionButton}> + </button> <button onClick={decrement} className={styles.actionButton}> - </button> </section> </div> ); }
export default Counter;
|
3. App.tsx
(应用入口)
最后,App.tsx
的职责是渲染我们的 Counter
组件,并提供一个全局的背景色(这里我们可以使用 Tailwind,展示两种样式方案的共存)。
1 2 3 4 5 6 7 8 9 10 11 12
| import Counter from "./components/Counter/Counter";
function App() { return ( <main className="bg-black min-h-screen flex flex-col justify-center items-center"> <Counter /> </main> ); };
export default App;
|
🤔 思考与扩展
现在你已经完成了一个使用 SCSS Modules 的计数器,尝试挑战一下自己:
- 添加重置功能: 增加一个“重置”按钮,点击后让计数器归零。
- 设置边界: 修改
decrement
函数,使得计数器的值不能小于 0。 - 动态样式: 当
count
大于 10 时,让数字的颜色变为绿色;小于 0 时,变为红色。你需要动态地拼接 styles
对象中的类名。
修改样式为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| .container { text-align: center;
.numberDisplay { font-size: 6rem; color: #ffffff;
&.positive { color: #22c55e; }
&.negative { color: #ef4444; } } }
|
修改代码内容为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import React, { useState } from 'react'
import styles from './Counter.module.scss'
function Counter() { const getNumberDisplayClass = () => { let className = styles.numberDisplay
if (count > 10) { className += ` ${styles.positive}` } else if (count === 0) { className += ` ${styles.negative}` }
return className }
return ( <div className={styles.container}> <h1 className={getNumberDisplayClass()}>{count}</h1> </div> ) }
export default Counter
|
5.1.2. 项目实战:待办事项列表 (Todo List)
如果说“计数器”是 useState
的入门,那么“待办事项列表”就是我们掌握数组状态管理的第一次大考。在这个项目中,我们将学会如何以 React 的方式(不可变地)对一个列表进行增加和删除操作,这是构建动态应用的核心技能。

项目目标
我们将构建一个经典的 Todo List 应用。用户可以:
- 在输入框中输入任务。
- 点击“提交”按钮,将新任务添加到列表中。
- 点击每项任务旁的“X”按钮,从列表中删除该任务。
核心概念巩固
useState
: 管理输入框的字符串状态,以及待办事项的数组状态。- 数组的不可变更新: 使用
concat
和 filter
等方法来更新数组,而非直接修改。 - 列表渲染: 使用
.map()
方法动态渲染列表,并为每一项提供唯一的 key
。 - 受控组件: 将 input 输入框的
value
与 React state 绑定。
项目结构与代码解析
我们将继续遵循“组件文件夹”的最佳实践。
1 2 3 4 5 6 7 8
| ├── components/ │ ├── Counter/ │ │ └── ... │ └── Todo/ │ ├── Todo.tsx │ └── Todo.module.scss └── App.tsx
|
1. Todo.module.scss
(组件样式)
首先,我们按照设计图将样式定义完毕
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| .container { background: #fcfff3; padding: 50px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
input { padding: 15px; border: none; outline: none; background: #f5f9eb; width: 300px; margin-right: 10px; border-radius: 4px; }
button { background: #454545; padding: 10px 20px; outline: none; border: none; color: #fff; cursor: pointer; transition: background-color 0.2s;
&:hover { background: #606060; } } }
.todosList { margin-top: 3rem; padding-left: 0; }
.todoItem { list-style: none; display: flex; justify-content: space-between; align-items: center; background: #f5f9eb; padding: 7px 5px; margin-top: 10px; font-family: sans-serif; border-radius: 4px;
.closeButton { padding: 5px 10px; background: #e53e3e;
&:hover { background: #c53030; } } }
|
2. Todo.tsx
(核心组件)
这是项目的核心,我们在这里处理所有的状态和交互逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| import { useState } from 'react' import styles from './Todo.module.scss'
interface TodoItem { id: number text: string }
function Todo() { const [todos, setTodos] = useState<TodoItem[]>([]) const [input, setInput] = useState('')
const handleAddTodo = () => { if (!input.trim()) return
const newTodo: TodoItem = { text: input, id: Date.now(), }
setTodos(todos.concat(newTodo))
setInput('') }
const removeTodo = (id: number) => { setTodos(todos.filter((t) => t.id !== id)) }
return ( <div className={styles.container}> <input type="text" value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleAddTodo() } }} placeholder="新任务..." />
<button onClick={handleAddTodo}>提交</button>
<ul className={styles.todosList}> {todos.map(({ text, id }) => ( <li key={id} className={styles.todoItem}> <span>{text}</span> <button className={styles.closeButton} onClick={() => removeTodo(id)} > X </button> </li> ))} </ul> </div> ) }
export default Todo;
|
3. App.tsx
(应用入口)
和上一个项目一样,App.tsx
的职责就是渲染我们的核心组件。
1 2 3 4 5 6 7 8 9 10 11 12
| import Todo from "./components/Todo/Todo";
function App() { return ( <main className="bg-gray-100 min-h-screen flex justify-center items-center"> <Todo /> </main> ); }
export default App;
|
🤔 思考与扩展
这个 Todo List 已经具备了核心功能,但我们还可以让它更强大。试试看:
- 切换完成状态:为每个
TodoItem
增加一个 completed
属性。点击任务文本时,切换其完成状态,并给已完成的任务添加一条删除线样式。 - 显示任务计数:在列表上方或下方,显示“总共有 X 个任务”或“还剩 Y 个未完成任务”。
第一步:修改 Todo.module.scss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| .todoItem { .todoText { cursor: pointer; &.completed { text-decoration: line-through; color: #999; } } }
.taskCount { margin-top: 1rem; color: #777; }
|
第二步:升级 Todo.tsx
逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| import { useState } from "react"; import styles from "./Todo.module.scss";
interface TodoItem { id: number; text: string; completed: boolean; }
function Todo() { const [todos, setTodos] = useState<TodoItem[]>([]); const [input, setInput] = useState("");
const handleSubmit = () => { if (!input.trim()) return; const newTodo: TodoItem = { id: Date.now(), text: input, completed: false, }; setTodos(todos.concat(newTodo)); setInput(""); };
const removeTodo = (id: number) => { setTodos(todos.filter((t) => t.id !== id)); };
const toggleComplete = (id: number) => { setTodos( todos.map((todo) => todo.id === id ? { ...todo, completed: !todo.completed } : todo ) ); }; const incompleteCount = todos.filter(todo => !todo.completed).length;
return ( <div className={styles.container}> <input type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder="新任务..." /> <button onClick={handleSubmit}>提交</button>
{/* 显示任务计数 */} <p className={styles.taskCount}>还剩 {incompleteCount} 个任务未完成</p>
<ul className={styles.todosList}> {todos.map(({ text, id, completed }) => ( <li key={id} className={styles.todoItem}> <span className={`${styles.todoText} ${completed ? styles.completed : ''}`} onClick={() => toggleComplete(id)} > {text} </span> <button className={styles.closeButton} onClick={() => removeTodo(id)} > X </button> </li> ))} </ul> </div> ); }
export default Todo;
|
5.2. 阶段二:交互式 UI 构建
5.2.1. 项目实战:颜色切换器
在这个项目中,我们将学习如何以最地道、最高效的方式,利用 Tailwind CSS 内置的强大功能来构建一个可持久化的主题切换器。这将是一次深刻的范式转变,让我们告别繁琐的类名拼接,拥抱声明式的 UI 样式构建。

第一步:配置 Tailwind CSS 的深色模式策略
Tailwind 的 dark:
变体默认使用 prefers-color-scheme
媒体查询,跟随用户的操作系统设置。为了实现手动切换,我们需要将其配置为,在V4版本最新的配置方法变为了在css中配置,所以我们也按照他的规范来
打开项目根目录的 index.css
文件,并修改它:
文件路径: index.css
1 2 3 4
| @import 'tailwindcss';
@variant dark (&:is(.dark *));
|
这个简单的改动告诉 Tailwind:“当 <html>
元素上有一个 dark
类时,所有带 dark:
前缀的工具类都将生效。”
第二步:构建主题切换组件
现在,我们可以编写组件了。注意看 TSX 中的 className
有多么简洁和富有表现力。
文件路径: src/components/ThemeToggler/ThemeToggler.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| import React, { useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
function ThemeToggler() { const [theme, setTheme] = useState<Theme>(() => { const savedTheme = localStorage.getItem('theme') as Theme | null; if (savedTheme) { return savedTheme; } return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; });
useEffect(() => { const root = document.documentElement; if (theme === 'dark') { root.classList.add('dark'); } else { root.classList.remove('dark'); } localStorage.setItem('theme', theme); }, [theme]);
const handleThemeToggle = () => { setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light')); };
return ( <section className="h-screen w-screen flex flex-col justify-center items-center bg-white text-[#1b1b1b] dark:bg-[#1b1b1b] dark:text-[#ffa31a] transition-colors duration-500"> <button onClick={handleThemeToggle} className="absolute top-5 right-5 px-4 py-2 bg-transparent cursor-pointer rounded-md border-2 border-[#1b1b1b] dark:border-[#ffa31a] transition-colors duration-500" > {theme === 'light' ? "切换至暗黑主题" : "切换至明亮主题"} </button>
<div className="text-center"> <h1 className="text-6xl md:text-8xl leading-tight" style={{ fontFamily: "'Bungee Outline', cursive" }} > 欢迎来到 <br /> 真实世界.. </h1> </div> </section> ); }
export default ThemeToggler;
|
第三步:App.tsx
和 index.html
这部分保持不变,App.tsx
负责渲染 ThemeToggler
1 2 3 4 5 6 7 8
| import ThemeToggler from "./components/ThemeToggler/ThemeToggler";
function App() { return <ThemeToggler />; }
export default App;
|
🤔 思考与扩展
我们已经实现了一个生产级的、可持久化的主题切换器。现在的代码已经非常优秀,但作为追求卓越的开发者,我们还能再优化一步吗?
- 提取为自定义 Hook: 目前,主题管理的逻辑(
useState
、useEffect
、localStorage
)都耦合在 ThemeToggler
组件内部。我们能否将这整套逻辑提取到一个可复用的 useTheme
自定义 Hook 中,让 ThemeToggler
组件只负责 UI 呈现?
是的,这正是自定义 Hook 的完美应用场景!
第一步:创建 useTheme
自定义 Hook
文件路径: src/hooks/useTheme.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { useState, useEffect } from 'react'
type Theme = 'light' | 'dark'
export function useTheme() { const [theme, setTheme] = useState<Theme>(() => { const savedTheme = localStorage.getItem('theme') as Theme | null if (savedTheme) { return savedTheme } return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' })
useEffect(() => { const root = document.documentElement if (theme === 'dark') { root.classList.add('dark') } else { root.classList.remove('dark') } localStorage.setItem('theme', theme) }, [theme])
const handleThemeToggle = () => { setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light')) }
return { theme, handleThemeToggle } }
|
第二步:在 ThemeToggler.tsx
中使用自定义 Hook
1 2 3 4 5 6 7 8 9 10
| import React from 'react'; import { useTheme } from '../hooks/useTheme';
function ThemeToggler() { const { theme, handleThemeToggle } = useTheme() }
export default ThemeToggler;
|
通过自定义 Hook,我们实现了逻辑与视图的终极分离。useTheme
Hook 现在是一个完全独立的、可移植的、可在任何组件中使用的“主题管理引擎”。
5.2.2. 项目实战:隐藏式搜索框
在掌握了如何通过状态切换整个页面主题后,我们现在将注意力集中到一个更具体的交互上:如何通过点击一个图标,平滑地、动态地展示一个输入框。这个项目是练习 React 状态与 CSS 过渡动画 相结合的绝佳机会。

项目目标
我们将构建一个初始状态只显示一个搜索图标的界面。当用户点击该图标时,图标消失,一个输入框以平滑的过渡效果出现,同时背景变为深色。点击输入框以外的区域,则恢复初始状态。
核心概念巩固
useState
: 管理 UI 的可见性状态(显示图标还是输入框)。- 条件渲染: 使用三元运算符在 JSX 中根据状态渲染不同的元素。
- 事件处理:
onClick
事件的精确使用,包括事件冒泡的处理。 - Tailwind CSS: 熟练运用其过渡 (
transition
)、透明度 (opacity
) 和宽度 (width
) 等工具类,以纯 CSS 的方式实现动画效果。
项目结构与代码解析
我们将继续使用 Tailwind CSS,保持组件的内聚性。
1 2 3 4 5
| ├── componebghnts/ │ └── HiddenSearchBar/ │ └── HiddenSearchBar.tsx └── App.tsx
|
实战准备:安装图标库
为了使用搜索图标,我们需要一个图标库。react-icons
是一个非常流行且易于使用的选择。
1. HiddenSearchBar.tsx
(核心组件)
我们将创建这个组件,完全利用 Tailwind CSS 的能力。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| import { useState, useRef, useEffect } from "react"; import { FaSearch } from "react-icons/fa";
function HiddenSearchBar() { const [isActive, setIsActive] = useState(false); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (isActive && inputRef.current) { inputRef.current.focus(); } }, [isActive]);
return ( <div className={` group relative h-screen w-screen flex justify-center items-center transition-colors duration-500 ${isActive ? 'bg-gray-900' : 'bg-white'} `} > {/* 搜索容器 */} <div className="relative flex items-center"> {/* 输入框:使用 Tailwind 的 transition 和 apha/width 控制动画 */} <input ref={inputRef} type="text" placeholder="搜索..." className={` h-12 pl-5 pr-12 rounded-full outline-none text-lg bg-white transition-all duration-700 ease-in-out ${isActive ? 'w-80 shadow-lg' : 'w-0 border-transparent'} `} /> {/* 搜索图标:使用 Tailwind 的 absolute 定位 */} <FaSearch className={` absolute right-4 text-gray-500 cursor-pointer transition-transform duration-300 ${isActive && 'hover:scale-125'} `} size={20} onClick={() => setIsActive(!isActive)} // 点击图标切换状态 /> </div> </div> ); }
export default HiddenSearchBar;
|
代码重构与最佳实践:
- 单一状态来源: 原始代码使用了两个
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 2 3 4 5 6 7
| import HiddenSearchBar from "./components/HiddenSearchBar/HiddenSearchBar";
function App() { return <HiddenSearchBar />; }
export default App;
|
🤔 思考与扩展
这个组件已经非常酷了,但还有一个交互细节可以完善:
- 点击外部关闭: 目前,一旦搜索框被激活,只能通过再次点击图标来关闭。更符合用户直觉的行为是:点击搜索框以外的任何区域,都应该能关闭它。你能否实现这个功能?
是的,我们可以通过在根 div
上添加一个 onClick
事件处理器,并巧妙地利用事件冒泡来解决这个问题。
升级 HiddenSearchBar.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| import { useState, useRef, useEffect } from "react"; import { FaSearch } from "react-icons/fa";
function HiddenSearchBar() { const [isActive, setIsActive] = useState(false); const inputRef = useRef<HTMLInputElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (isActive && inputRef.current) { inputRef.current.focus(); } }, [isActive]);
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => { if (e.target === containerRef.current) { setIsActive(false); } };
return ( <div ref={containerRef} // 附加 Ref onClick={handleContainerClick} // 添加事件处理器 className={` relative h-screen w-screen flex justify-center items-center transition-colors duration-500 ${isActive ? 'bg-gray-900' : 'bg-white'} `} > <div className="relative flex items-center"> <input ref={inputRef} type="text" placeholder="搜索..." className={` h-12 pl-5 pr-12 rounded-full outline-none text-lg bg-white transition-all duration-700 ease-in-out ${isActive ? 'w-80 shadow-lg' : 'w-0 border-transparent'} `} /> <FaSearch className={` absolute right-4 text-gray-500 cursor-pointer transition-transform duration-300 ${isActive ? 'hover:scale-125' : ''} `} size={20} onClick={() => setIsActive(!isActive)} /> </div> </div> ); }
export default HiddenSearchBar;
|
现在,当用户点击灰色背景区域时,e.target
就是那个 div
本身,isActive
会被设为 false
。而当用户点击输入框或图标时,e.target
是 input
或 svg
元素,条件不满足,状态不会改变。这就完美地实现了我们的目标。
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 2 3 4 5
| ├── components/ │ └── Meals/ │ └── Meals.tsx └── App.tsx
|
实战准备:安装 Axios
为了更便捷地处理网络请求,我们将安装广受欢迎的 axios
库。
1. Meals.tsx
(核心组件)
在这个组件中,我们将完成从数据请求、状态管理到最终 UI 渲染的完整流程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| import { useState, useEffect } from 'react' import axios from 'axios'
interface Meal { idMeal: string strMeal: string strMealThumb: string }
function Meals() { const [meals, setMeals] = useState<Meal[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => { const fetchMeals = async () => { try { setLoading(true) const response = await axios.get( 'https://www.themealdb.com/api/json/v1/1/filter.php?c=Seafood' ) setMeals(response.data.meals) } catch (err) { setError(err as Error) } finally { setLoading(false) } }
fetchMeals() }, [])
if (loading) { return <p className="text-white text-2xl animate-pulse">正在加载菜品...</p> }
if (error) { return <p className="text-red-500 text-2xl">加载失败: {error.message}</p> }
return ( <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-4"> {meals.map(({ idMeal, strMeal, strMealThumb }) => ( <section key={idMeal} className="bg-white rounded-lg shadow-lg overflow-hidden transform transition-transform duration-300 hover:scale-105 cursor-pointer" > <img src={strMealThumb} alt={strMeal} className="w-full h-48 object-cover" /> <div className="p-4"> <p className="font-bold text-gray-800 truncate"> {strMeal} </p> <p className="text-sm text-gray-500 mt-1"> #{idMeal} </p> </div> </section> ))} </div> ) }
export default Meals
|
2. App.tsx
(应用入口)
我们的 App
组件负责设置页面的整体布局和标题,并渲染 Meals
组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import Meals from "./components/Meals/Meals";
function App() { return ( <main className="min-h-screen"> <div className="container mx-auto py-8"> <h1 className="text-4xl font-bold text-center text-black mb-8 tracking-wider"> 海鲜菜单 </h1> <Meals /> </div> </main> ); }
export default App;
|
🤔 思考与扩展
我们已经成功地从一个真实的 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
组件变得更纯粹。
第一步:创建 useFetch
自定义 Hook
文件路径: src/hooks/useFetch.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| import { useState, useEffect } from 'react'; import axios from 'axios';
export function useFetch<T>(url: string) { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState<boolean>(true); const [error, setError] = useState<Error | null>(null);
useEffect(() => { if (!url) { setLoading(false); return; }
const fetchData = async () => { setLoading(true); setError(null); try { const response = await axios.get(url); setData(response.data.meals || response.data); } catch (err) { setError(err as Error); } finally { setLoading(false); } };
fetchData(); }, [url]);
return { data, loading, error }; }
|
第二步:在 Meals.tsx
中使用自定义 Hook 并添加搜索功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| import { useState } from "react"; import { useFetch } from "../hooks/useFetch";
interface Meal { idMeal: string; strMeal: string; strMealThumb: string; }
function Meals() { const [searchTerm, setSearchTerm] = useState<string>("Seafood"); const [query, setQuery] = useState<string>("Seafood");
const apiUrl = searchTerm === "Seafood" ? `https://www.themealdb.com/api/json/v1/1/filter.php?c=Seafood` : `https://www.themealdb.com/api/json/v1/1/search.php?s=${searchTerm}`; const { data: meals, loading, error } = useFetch<Meal[]>(apiUrl);
const handleSearch = () => { setSearchTerm(query); };
return ( <> {/* 搜索栏 */} <div className="flex justify-center mb-8"> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="搜索菜品..." className="p-2 rounded-l-md w-1/3 text-black" /> <button onClick={handleSearch} className="bg-blue-500 text-white p-2 rounded-r-md"> 搜索 </button> </div>
{loading && <p className="text-white text-2xl animate-pulse">正在加载菜品...</p>} {error && <p className="text-red-500 text-2xl">加载失败: {error.message}</p>} {/* 当 meals 为 null 或空数组时显示提示 */} {!loading && !error && (!meals || meals.length === 0) && ( <p className="text-yellow-400 text-2xl text-center">没有找到相关菜品。</p> )}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-4"> {meals && meals.map(({ idMeal, strMeal, strMealThumb }) => ( <section key={idMeal} className="..."> {/* ... 卡片 JSX 保持不变 ... */} </section> ))} </div> </> ); }
export default Meals;
|
通过自定义 Hook,我们不仅让 Meals
组件的逻辑变得极其简洁,还创造了一个可以在应用中任何地方复用的数据获取“引擎”,这正是 React 组合能力的魅力所在。
第六章: 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
const User = (props: { name: string; age: number; isStudent: boolean }) => { return ( <main> <h2 className="text-xl font-bold">{props.name}</h2> <p>年龄: {props.age}</p> <p>是学生吗? {props.isStudent ? '是' : '否'}</p> </main> ); };
export default User;
|
现在,当我们在 App.tsx
中使用这个组件时,TypeScript 和我们的编辑器会立刻成为我们的“守护神”。
文件路径: src/App.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import User from './components/User';
function App() { return ( <div> {/* 正确使用 */} <User name="Prorise" age={3} isStudent={true} />
{/* 尝试传递错误类型,编辑器会立刻报错! */} {/* <User name="Prorise" age="三岁" isStudent={true} /> */} {/* ^^^^^^^^^^ 类型 'string' 不能赋值给类型 'number' */} {/* 尝试漏掉某个 prop,编辑器也会报错! */} {/* <User name="Prorise" isStudent={true} /> */} {/* `^^^^` 缺少属性 'age' */} </div> ); }
|
6.1.2. 优化:解构与自定义类型 (type
/ interface
)
内联类型定义虽然直接,但有两个缺点:一是当 props 很多时会显得很冗长;二是我们每次都写 props.xxx
也很繁琐。我们可以通过 解构 和 自定义类型 来优化它。
文件路径: src/components/User.tsx
(升级版)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| type UserProps = { name: string age: number isStudent: boolean }
const User = ({ name, age, isStudent }: UserProps) => { return ( <main> <h2 className="text-xl font-bold">{name}</h2> <p>年龄:{age}</p> <p>身份:{isStudent ? '学生' : '非学生'}</p> </main> ) }
export default User
|
这种写法是 React + TypeScript 开发中的 黄金标准:代码既简洁又类型安全。
现在,让我们亲手实践一下。我们将创建一个可复用的 Button
组件,它需要接收 label
、onClick
和 disabled
三个 props。
文件路径: src/components/Button.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import React from 'react';
type ButtonProps = { label: string; onClick: () => void; disabled?: boolean; };
const Button = ({ label, onClick, disabled = false }: ButtonProps) => { return ( <button onClick={onClick} disabled={disabled} className={` px-4 py-2 font-semibold text-white rounded-md transition-colors ${disabled ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-500 hover:bg-blue-600' } `} > {label} </button> ); };
export default Button;
|
文件路径: src/App.tsx
(使用 Button
组件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import Button from './components/Button';
function App() { const handlePrimaryClick = () => { alert('主按钮被点击了!'); };
return ( <div className="p-8 space-x-4"> <Button label="点我" onClick={handlePrimaryClick} /> <Button label="禁用按钮" onClick={() => alert('这个提示不会出现')} disabled={true} /> </div> ); }
export default App;
|
6.1.4. 特殊 Prop: children
的类型
我们知道,children
是一个特殊的 prop,代表了组件标签之间的内容。React 为它提供了一个专门的类型:React.ReactNode
。
文件路径: src/components/Card.tsx
(示例)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import type { ReactNode } from 'react';
type CardProps = { children: ReactNode; };
const Card = ({ children }: CardProps) => { return ( <div className="p-4 border rounded-lg shadow-md"> {children} </div> ); };
export default Card;
|
现在,我们可以在 App.tsx
中安全地使用 Card
组件来包裹任何内容。
1 2 3 4 5 6 7 8 9 10 11
| import Card from './components/Card'; import User from './components/User';
function App() { return ( <Card> {/* 这里的 User 组件就是 Card 的 children */} <User name="Prorise" age={3} isStudent={true} /> </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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| type Info = { id: number; name: string; email: string; };
type AdminInfoList = Info & { role: string; lastLogin: Date; };
export { type Info, type AdminInfoList };
|
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import React from 'react'
import { type Info } from '../types'
type UserInfoProps = { user: Info }
const UserInfo: React.FC<UserInfoProps> = ({ user }) => { return ( <div className="p-4 bg-gray-100 rounded-lg shadow-md mb-4"> <h2 className="text-xl font-bold text-gray-800">普通用户信息</h2> <p>ID: {user.id}</p> <p>姓名: {user.name}</p> <p>邮箱: {user.email}</p> </div> ) }
export default UserInfo
|
React.FC
是什么?
React.FC
(或 React.FunctionComponent
) 是一个内置的 TypeScript 类型,用于定义函数组件。它提供了一些基础的类型定义(例如 children
),虽然在现代 React 中,我们更推荐直接像 const MyComponent = ({...}: MyProps) => {}
这样定义组件,但在许多代码库中您仍然会看到 React.FC
的使用。
1 2 3
| const UserInfo = ({ user }: UserInfoProps) => { } export default UserInfo
|
创建 AdminInfo
组件
这个组件需要更详细的管理员信息。
文件路径: src/components/AdminInfo.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import React from 'react'
import { type AdminInfoList } from '../types'
type AdminInfoProps = { admin: AdminInfoList }
const AdminInfo = ({ admin }: AdminInfoProps) => { return ( <div className="p-4 bg-blue-100 rounded-lg shadow-md"> <h2 className="text-xl font-bold text-blue-800">管理员信息</h2> <p>ID: {admin.id}</p> <p>姓名: {admin.name}</p> <p>邮箱: {admin.email}</p> <p>角色: {admin.role}</p> <p>上次登录: {admin.lastLogin.toLocaleString('zh-CN')}</p> </div> ) }
export default AdminInfo
|
6.2.3. 第三步:在 App.tsx
中组合使用
最后,我们在主应用中创建符合这些类型的数据,并将其传递给相应的组件。TypeScript 会在后台默默地为我们检查所有的数据结构是否正确。
文件路径: src/App.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import UserInfo from './components/UserInfo' import AdminInfo from './components/AdminInfo'
import { type Info, type AdminInfoList } from './types'
const App = () => { const user: Info = { id: 1, name: '张三', email: 'zhangsan@example.com', }
const admin: AdminInfoList = { id: 2, name: '李四', email: 'lisi@example.com', role: '管理员', lastLogin: new Date(), }
return ( <div className="container mx-auto p-8"> <UserInfo user={user} /> <AdminInfo admin={admin} /> </div> ) }
export default App
|
总结:
通过将共享的类型定义抽离到单独的文件中,我们实现了 类型层面的代码复用。这种做法极大地提升了大型应用的可维护性:
- 单一事实来源: 当用户数据结构需要变更时(例如增加一个
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| import { useState } from "react"; import UserProfile from "./components/UserProfile"; import TodoList from "./components/TodoList";
const App = () => { const [count, setCount] = useState(0);
const increment = () => { setCount((prevCount) => prevCount + 1); };
return ( <div className="container mx-auto p-8 space-y-8"> <div> <h1 className="text-2xl font-bold">计数器: {count}</h1> <button onClick={increment} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"> 增加 </button> </div>
<UserProfile /> <TodoList /> </div> ); };
export default App;
|
对于字符串、布尔值等基础类型,TypeScript 的类型推断都能完美工作,我们无需做任何额外的事情。
6.3.2. 进阶:显式指定复杂状态的类型
当我们的状态是一个复杂的对象或数组时,最佳实践是先使用 interface
或 type
定义这个状态的“形状”,然后将它作为泛型参数传递给 useState
。
场景一:状态为对象 (Object
)
让我们构建一个用户个人资料的组件,其状态是一个包含多个字段的对象。
文件路径: src/components/UserProfile.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| import { useState } from "react";
interface UserProfileData { name: string; age: number; email: string; }
const UserProfile = () => { const [profile, setProfile] = useState<UserProfileData>({ name: "", age: 0, email: "", });
const updateName = (name: string) => { setProfile((prevProfile) => ({ ...prevProfile, name })); };
return ( <div className="p-4 bg-gray-100 rounded-lg shadow-md space-y-4"> <h2 className="text-xl font-bold text-gray-800">用户个人资料</h2> <div className="flex flex-col space-y-2"> <input type="text" placeholder="姓名" value={profile.name} onChange={(e) => updateName(e.target.value)} className="p-2 border rounded" /> <input type="number" placeholder="年龄" value={profile.age > 0 ? profile.age : ""} onChange={(e) => setProfile(p => ({...p, age: Number(e.target.value)}))} className="p-2 border rounded" /> <input type="email" placeholder="邮箱" value={profile.email} onChange={(e) => setProfile(p => ({...p, email: e.target.value}))} className="p-2 border rounded" /> </div> <div className="mt-4 p-2 bg-gray-200 rounded"> <h3 className="font-semibold">资料预览:</h3> <p>姓名: {profile.name}</p> <p>年龄: {profile.age}</p> <p>邮箱: {profile.email}</p> </div> </div> ); };
export default UserProfile;
|
场景二:状态为对象数组 (Array of Objects
)
现在,我们来构建一个 Todo List,其状态是一个由多个 Todo
对象组成的数组。
文件路径: src/components/TodoList.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import { useState } from 'react'
interface Todo { id: number task: string completed: boolean } function TodoList({ id, task, completed }: Todo) { const [todos, setTodos] = useState<Todo[]>([])
const addTodo = (task: string) => { const newTodo: Todo = { id: Date.now(), task, completed: false, } setTodos((prevTodos) => [...prevTodos, newTodo]) }
return ( <div className="p-4 bg-gray-100 rounded-lg shadow-md"> <h2 className="text-xl font-bold text-gray-800">待办事项列表</h2> <button onClick={() => addTodo('学习 TypeScript')} className="my-2 px-4 py-2 bg-green-500 text-white rounded" > 添加任务 </button> <ul className="list-disc pl-5"> {todos.map((todo) => ( <li key={todo.id} className={todo.completed ? 'line-through text-gray-500' : ''} > {todo.task} </li> ))} </ul> </div> ) }
export default TodoList
|
6.3.3. 特殊场景:初始状态为 null
在处理异步数据时,我们常常遇到的一个场景是:数据在初始时不存在,需要等 API 返回。此时,状态的类型可能是 Data | null
。这就必须使用显式泛型了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| interface User { id: number; name: string; }
function UserData() { const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetchUserData().then(data => { setUser(data); }); }, []);
if (!user) { return <p>加载中...</p>; } return <h1>欢迎, {user.name}</h1>; } **总结**:
|
- 对于 基础类型(
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| import { useRef } from 'react'
const FocusInput = () => { const inputRef = useRef<HTMLInputElement>(null) const handleFocus = () => { inputRef.current?.focus() }
return ( <div className="p-4 bg-gray-100 rounded-lg shadow-md"> <h2 className="text-xl font-bold text-gray-800 mb-2">聚焦搜索框</h2> <input type="text" ref={inputRef} placeholder="点击按钮来聚焦" className="p-2 border rounded mr-2" /> <button onClick={handleFocus} className="px-4 py-2 bg-blue-500 text-white rounded" > 聚焦输入框 </button> </div> ) }
export default FocusInput;
|
6.4.2. 事件处理函数的精确类型
在之前的章节中,我们可能为了方便而忽略了事件对象的类型。现在,我们将学习如何从 @types/react
中导入精确的事件类型,告别 any
,拥抱类型安全。
文件路径: src/components/EventHandling.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import React from 'react';
const EventHandling = () => { const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { console.log("按钮被点击了!", e.currentTarget); };
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => { console.log("鼠标进入了 div!", e.currentTarget); };
return ( <div onMouseEnter={handleMouseEnter} className="p-4 bg-gray-100 rounded-lg shadow-md mt-4" > <h2 className="text-xl font-bold text-gray-800 mb-2">事件类型示例</h2> <button onClick={handleClick} className="px-4 py-2 bg-green-500 text-white rounded" > 点我! </button> </div> ); };
export default EventHandling;
|
常用事件类型:
多敲多记即可
- 鼠标事件:
React.MouseEvent
- 表单/输入框事件:
React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
- 表单提交事件:
React.FormEvent<HTMLFormElement>
- 键盘事件:
React.KeyboardEvent
- 焦点事件:
React.FocusEvent
6.4.3. 类型化的表单处理
现在,我们将所有知识融会贯通,构建一个完全类型安全的联系人表单。我们将为表单的 state
、输入框的 onChange
事件以及表单的 onSubmit
事件都提供精确的类型。
文件路径: src/components/ContactForm.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| import { useState } from 'react'
interface ContactFormState { name: string email: string }
const ContactForm = () => { const [formData, setFormData] = useState<ContactFormState>({ name: '', email: '', })
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const { name, value } = e.target setFormData((prevState) => ({ ...prevState, [name]: value })) }
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() console.log('表单已提交:', formData) alert(`你好, ${formData.name}!`) }
return ( <form onSubmit={handleSubmit} className="p-4 bg-gray-100 rounded-lg shadow-md mt-4 space-y-4" > <h2 className="text-xl font-bold text-gray-800">类型化表单示例</h2> <div> <label className="block mb-1 font-semibold">姓名:</label> <input type="text" placeholder='请输入你的姓名' name="name" // `name` 属性必须与 state 中的键匹配 value={formData.name} onChange={handleChange} className="p-2 border rounded w-full" /> </div> <div> <label className="block mb-1 font-semibold">邮箱:</label> <input type="email" placeholder='请输入你的邮箱' name="email" value={formData.email} onChange={handleChange} className="p-2 border rounded w-full" /> </div> <button type="submit" className="w-full px-4 py-2 bg-purple-500 text-white rounded"> 提交 </button> </form> ); }
export default ContactForm;
|
总结:
通过为 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { createContext, useState, type ReactNode, type FC } from "react";
interface CounterContextProps { count: number; increment: () => void; decrement: () => void; }
export const CounterContext = createContext<CounterContextProps>({ count: 0, increment: () => {}, decrement: () => {}, });
interface CounterProviderProps { children: ReactNode; }
export const CounterProvider: FC<CounterProviderProps> = ({ children }) => { const [count, setCount] = useState(0);
const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1);
return ( <CounterContext.Provider value={{ count, increment, decrement }}> {children} </CounterContext.Provider> ); };
|
现在,我们可以在应用中使用这个类型安全的 Context
。
文件路径: src/App.tsx
(包裹 Provider)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import CounterDisplay from "./components/CounterDisplay"; import { CounterProvider } from "./contexts/CounterContext";
export default function App() { return ( <CounterProvider> <div className="container mx-auto p-8"> <h1 className="text-3xl font-bold mb-4">类型化的 Context 示例</h1> <CounterDisplay /> </div> </CounterProvider> ); }
|
文件路径: src/components/CounterDisplay.tsx
(消费 Context)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import { useContext } from "react"; import { CounterContext } from "../contexts/CounterContext";
const CounterDisplay: React.FC = () => { const { count, increment, decrement } = useContext(CounterContext);
return ( <div className="p-4 bg-gray-100 rounded-lg shadow-md"> <p className="text-2xl">Count: {count}</p> <div className="space-x-2 mt-2"> <button onClick={increment} className="px-4 py-2 bg-green-500 text-white rounded">Increment</button> <button onClick={decrement} className="px-4 py-2 bg-red-500 text-white rounded">Decrement</button> </div> </div> ); };
export default CounterDisplay;
|
场景二:处理可能为 undefined
的 Context
在某些设计模式中,我们希望强制要求消费者组件必须被包裹在 Provider 内部,否则就应该报错。我们可以通过将 createContext
的初始值设为 undefined
来实现这一点。
文件路径: src/contexts/SharedInputContext.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { createContext, type ReactNode, useState, FC, useContext } from "react";
type SharedInputContextData = { value: string; setValue: (newValue: string) => void; };
const SharedInputContext = createContext<SharedInputContextData | undefined>(undefined);
type SharedInputContextProviderProps = { children: ReactNode; };
export const SharedInputContextProvider: FC<SharedInputContextProviderProps> = ({ children }) => { const [value, setValue] = useState<string>(""); return ( <SharedInputContext.Provider value={{ value, setValue }}> {children} </SharedInputContext.Provider> ); };
export const useSharedInput = () => { const context = useContext(SharedInputContext); if (context === undefined) { throw new Error("useSharedInput must be used within a SharedInputContextProvider"); } return context; };
|
现在,消费者组件的代码将变得更加优雅和健壮。
文件路径: src/components/SharedInputDisplay.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { useSharedInput } from "../contexts/SharedInputContext";
const SharedInputDisplay = () => { const { value, setValue } = useSharedInput();
return ( <div className="p-4 bg-blue-100 rounded-lg shadow-md mt-4"> <p className="text-xl">共享的值: {value}</p> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} className="mt-2 p-2 border rounded w-full" placeholder="在这里输入..." /> </div> ); };
export default SharedInputDisplay;
|
总结:
为 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| export type CounterState = { count: number; };
type IncrementAction = { type: "INCREMENT"; };
type DecrementAction = { type: "DECREMENT"; };
export type CounterAction = IncrementAction | DecrementAction;
|
第二步:创建类型化的 Reducer 函数
现在,我们在同一个文件中创建 reducer
函数,并为它的参数和返回值应用我们刚刚创建的类型。
文件路径: src/reducers/counterReducer.ts
(继续)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| export type CounterState = { count: number }
type IncrementAction = { type: 'INCREMENT' }
type DecrementAction = { type: 'DECREMENT' }
export type CounterAction = IncrementAction | DecrementAction
export const counterReducer = ( state: CounterState, action: CounterAction ): CounterState => { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }
case 'DECREMENT': return { count: state.count - 1 }
default: return state } }
|
第三步:在组件中使用类型化的 useReducer
现在,我们在组件中使用 useReducer
时,TypeScript 会利用我们之前定义的类型,提供完美的类型推断和安全检查。
文件路径: src/components/Counter.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { useReducer } from "react";
import { counterReducer, CounterState } from "../reducers/counterReducer";
const initialState: CounterState = { count: 0 };
const Counter = () => { const [state, dispatch] = useReducer(counterReducer, initialState);
const increment = () => { dispatch({ type: "INCREMENT" }); };
const decrement = () => { dispatch({ type: "DECREMENT" }); };
return ( <div className="p-4 bg-gray-100 rounded-lg shadow-md mt-4"> <h2 className="text-2xl font-bold">Count: {state.count}</h2> <div className="space-x-2 mt-2"> <button onClick={increment} className="px-4 py-2 bg-green-500 text-white rounded">Increment</button> <button onClick={decrement} className="px-4 py-2 bg-red-500 text-white rounded">Decrement</button> </div> </div> ); };
export default Counter;
|
App.tsx
(整合组件)
文件路径: src/App.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13
| import React from "react"; import Counter from "./components/Counter";
const App: React.FC = () => { return ( <div className="container mx-auto p-8"> <h1 className="text-3xl font-bold">React + TypeScript: useReducer 示例</h1> <Counter /> </div> ); };
export default App;
|
总结:
为 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 2 3 4 5
| ├── components/ │ └── UserList/ │ └── UserList.tsx └── App.tsx
|
1. UserList.tsx
(核心组件)
在这个组件中,我们将完成从定义数据类型到获取、存储和渲染数据的完整闭环。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| import { useEffect, useState } from "react";
interface User { id: number; name: string; username: string; email: string; phone: string; }
const UserList = () => { const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState<boolean>(true); const [error, setError] = useState<string | null>(null);
useEffect(() => { const fetchUsers = async () => { try { const response = await fetch( "https://jsonplaceholder.typicode.com/users" ); if (!response.ok) { throw new Error("网络响应失败,请稍后再试"); } const data: User[] = await response.json(); setUsers(data); } catch (error) { if (error instanceof Error) { setError(error.message); } else { setError("发生了一个未知错误"); } } finally { setLoading(false); } };
fetchUsers(); }, []);
if (loading) return <div className="text-center p-8">加载中...</div>; if (error) return <div className="text-center p-8 text-red-500">错误: {error}</div>;
return ( <div className="overflow-x-auto"> <table className="min-w-full bg-white shadow-md rounded-lg"> <thead className="bg-gray-800 text-white"> <tr> <th className="py-3 px-4 text-left">姓名</th> <th className="py-3 px-4 text-left">用户名</th> <th className="py-3 px-4 text-left">邮箱</th> <th className="py-3 px-4 text-left">电话</th> </tr> </thead> <tbody> {users.map((user) => ( <tr key={user.id} className="border-b hover:bg-gray-100"> {/* TypeScript 知道 `user` 是 User 类型,所以可以安全地访问其属性 */} <td className="py-3 px-4">{user.name}</td> <td className="py-3 px-4">{user.username}</td> <td className="py-3 px-4">{user.email}</td> <td className="py-3 px-4">{user.phone}</td> </tr> ))} </tbody> </table> </div> ); };
export default UserList;
|
2. App.tsx
(应用入口)
1 2 3 4 5 6 7 8 9 10 11 12
| import UserList from "./components/UserList/UserList";
const App = () => { return ( <main className="container mx-auto p-8"> <h1 className="text-4xl font-bold mb-6 text-center"> 用户列表 </h1> <UserList /> </main> ); };
export default App;
|
第六章总结:TypeScript 的价值
恭喜您!我们已经完成了从 JavaScript 到 TypeScript 的全面升级。通过本章的学习,我们不再仅仅是“使用”React,而是以一种更专业、更严谨、更安全的方式来“构建”React 应用。
我们为 props
、state
、hooks
和 events
都添加了精确的类型。这层“类型铠甲”将在未来的开发中,为您抵挡无数潜在的 Bug,提升代码的可维护性,并最终让您成为一名更自信、更高效的 React 工程师。