《别再零散学 React!史上最全生态笔记:核心 + 周边 + 实战案例,帮你构建完整技术体系》

序章:一份通往 React 高手之路的现代地图

摘要: 欢迎来到这本为 Vue 工程师及所有寻求 React 精通之路的开发者量身打造的深度笔记。我们深知,学习一门新技术的最终目的是掌握其解决实际问题的能力,并借此提升职业价值。因此,本系列笔记只有一个承诺:我们将为您铺设一条从 React 基础到全栈专家,乃至能够胜任一线技术岗位所需的最完整、最实战的学习路径。


我们为何如此确信?

这份自信并非空穴来风,它源于多年来在真实项目开发与技术教学中所沉淀的经验。我们见证了 React 生态的变迁,筛选出了在 2025 年 最具生产力、最受业界认可的技术组合。本笔记摒弃了简单的 API 罗列,转而采用以 海量实战项目驱动 的教学模式,确保您学习的每一个知识点,都能在具体的业务场景中落地生根。


这份笔记将为您呈现的知识全景

在本系列笔记中,我们将共同探索一片广阔的技术版图。这不仅是 React 本身,更是整个现代前端的黄金生态圈。

2025

第一阶段: 奠定坚实基础

我们将从 React 核心基础 出发,通过大量的实战练习,将组件、Props、State、Hooks 等核心概念内化为您的第二天性。随后,我们将全面拥抱 TypeScript,学习如何在 React 项目中构建坚不可摧的类型安全体系。

  • React 基础
  • React Hooks (基础与高阶)
  • TypeScript 深度集成

第二阶段: 掌握现代生态

在掌握了核心之后,我们将立即进入由业界最佳实践构成的“应用层”生态。您将精通:

  • 状态管理: 使用轻量而强大的 Zustand,体验“React 版 Pinia”的简洁与高效。
  • 表单处理: 借助 React Hook Form 与 Zod,构建高性能、类型安全的复杂表单。
  • UI 与样式: 拥抱 Tailwind CSS 与 DaisyUI,体验原子化 CSS 带来的开发效率革命。
  • 数据请求: 精通 TanStack Query,以声明式的方式优雅地管理服务端状态。
  • 路由控制: 掌握 TanStack Router,体验类型安全的现代化路由解决方案。
  • 动画实现: 运用 Framer Motion,为您的应用注入流畅、迷人的动效。

第三阶段: 迈向高级工程化

具备了构建复杂应用的能力后,我们将向更高级的工程化领域进军,内容涵盖:

  • React 设计模式
  • 设计系统 (Design Systems)
  • React 自动化测试
  • Redux Toolkit (适用于大型项目)

第四阶段: 探索全栈与跨端

最后,我们将打破前端的边界,探索 React 的无限可能:

  • 全栈开发: 深入学习业界领先的元框架 Next.js,从入门到精通,构建生产级的全栈应用。
  • 移动端开发: 涉足 React Native,将您的 React 技能无缝迁移到移动端 App 开发。

开始前的准备:您的起点

为了确保您能从本笔记中获得最大收益,我们期望您具备以下基础知识:
学习前提:

  • 熟练掌握 HTMLCSS
  • 具备扎实的 JavaScript 基础(无需精通面向对象 OOP 部分)。
  • 了解 TypeScript 的基本语法和类型系统。
  • (推荐)对 Tailwind CSS 有初步了解,这将让您在项目实践中更加得心应手。

我们即将启程。请准备好,这不仅是一次学习,更是一次将您打造为 React 高手的深度实践之旅。让我们开始吧。


第一章:本地开发环境搭建与工程化配置

摘要: 一个高效、规范的本地开发环境是快速掌握新技术的前提。在本章中,我们将回归开发的本质,优先在 Windows 中搭建一个快如闪电的本地 React 开发环境。我们将从使用 pnpm 初始化 Vite 项目开始,深入配置路径别名、ESLint 与 Prettier,并与 Cursor / VS Code 深度集成,实现保存即格式化的“心流”体验。本章的最终目标,是为您构建一个与专业团队对齐的、高度规范化的本地开发起点,为后续学习扫清一切障碍。


在本章中,我们将循序渐进地完成以下核心任务:

  1. 首先,我们将在 Windows 环境中准备好 Node.js 和 pnpm。
  2. 接着,我们将使用 pnpm create vite 初始化一个纯净的 React + TypeScript 项目。
  3. 然后,我们将解剖项目的初始结构与启动流程,精确理解其工作原理。
  4. 之后,我们将深入 vite.config.tstsconfig.json,配置路径别名等开发效率优化。
  5. 最后,我们将引入并配置 ESLint 和 Prettier,建立一套现代化的代码质量与风格保障体系,并与编辑器深度集成。

1.1. 基础环境准备:配置 Windows 与 Node.js

对于现代前端开发而言,一个配置正确的本地环境是高效工作的基石。我们将直接在 Windows 系统上进行环境搭建。

1.1.1. 在 Windows 中安装 Node.js

首先,您需要访问 Node.js 官方网站,下载适用于 Windows 的 长期支持版 (LTS) 安装程序。

下载后,双击运行 .msi 安装文件,按照安装向导的提示,使用默认选项一路“下一步”即可完成安装。安装程序会自动将 node.exenpm 添加到系统的 PATH 环境变量中。

安装完成后,您可以打开一个新的 PowerShell 或命令提示符窗口,执行以下命令来验证安装是否成功:

1
2
node -v
npm -v

如果能看到对应的版本号输出,则代表 Node.js 环境已准备就绪。

1.1.2. 安装 pnpm

pnpm 是一个快速、节省磁盘空间的包管理器。我们推荐使用它来替代 npm。在安装好 Node.js 之后,于终端中执行以下命令进行全局安装:

1
2
# 全局安装 pnpm
npm install -g pnpm

1.2. 初始化项目:从纯净的 Vite 模板开始

我们将在 Windows 的文件系统中创建项目。

打开您的终端,导航到您希望创建项目的目录(例如 D:\projects),然后运行:

1
2
# 在 projects 目录下创建我们的项目
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
# prorise-react-guide/
├── public/
│ └── vite.svg
├── src/
│ ├── assets/
│ │ └── react.svg
│ ├── App.css
│ ├── App.tsx # 根组件
│ ├── index.css
│ ├── main.tsx # 应用的入口文件
│ └── vite-env.d.ts
├── .eslintrc.cjs
├── .gitignore
├── index.html # SPA 的 HTML 宿主页面
├── package.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

启动流程追溯:

  1. index.html (宿主页面): 浏览器加载的第一个文件,是整个单页应用(SPA)的“舞台”。它的 <body> 中包含一个关键的 <div> 元素:<div id="root"></div>,这是 React 应用将会被注入的目标位置。同时,它通过 <script type="module" src="/src/main.tsx"></script> 引入了应用的入口脚本。
  2. src/main.tsx (应用入口): 这是 React 应用的启动引擎。它的核心职责是:
    • 导入 React 和 ReactDOM 库。
    • 导入根组件 App
    • 使用 ReactDOM.createRoot() 找到 index.html 中的 <div id="root"> 元素,并创建一个 React 渲染根。
    • 调用 root.render() 方法,将 <App /> 组件及其所有子组件渲染到该 DOM 节点中。
  3. 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' // 引入 Node.js 'path' 模块
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 新增 resolve 配置
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,
// React 相关的专属配置
{
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", // React 17+ 无需在作用域中引入 React
},
},
// 必须放在最后,用于关闭与 Prettier 冲突的规则
eslintConfigPrettier
);

1.5.3. 配置 Prettier 与编辑器集成

  1. 安装 Prettier:
1
pnpm add -D prettier eslint-plugin-prettier
  1. 创建 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.jspostcss.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
/** @type {import('tailwindcss').Config} */
export default {
// 关键:将 content 数组更新为以下内容
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 的核心面纱:

  1. 首先,我们将从 组件定义JSX 语法 开始,这是从 Vue 的模板系统到 React 声明式 UI 的第一次“范式转移”。
  2. 接着,我们将深入 Props 系统,理解 React 中单向数据流和组件通信的机制。
  3. 然后,我们将直面最核心的 State 与不可变性,将 useState 与 Vue 的 ref 进行深度对比。
  4. 最后,我们将攻克 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>
<!-- 模板层:在 <template> 块中声明式地渲染 UI -->
<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>;
// -> <h1> Hello, Prorise Blog! </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;
}

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>; // 返回一个 JSX 元素
}
return <h1>Please sign up.</h1>; // 返回另一个 JSX 元素
}

function App() {
const isLoggedIn = true;

// 将 JSX 表达式的调用结果直接在模板中渲染
return (
<div>
{getGreeting(isLoggedIn)}
</div>
);
}

在掌握了 JSX 的基本语法后,我们来解决两个最常见的动态 UI 场景:如何根据条件显示或隐藏内容,以及如何渲染一个数据列表。这两种场景将进一步深化您对“React 使用纯 JavaScript 解决问题”这一核心思想的理解。

本小节核心知识点:

  • React 中 没有 v-ifv-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!' },
];

// 1. 使用 .map() 将数据数组转换为 JSX 元素数组
const listItems = todos.map(todo =>
// 2. [关键] 为每个列表项提供一个 `key` prop
<li key={todo.id}>
{todo.text}
</li>
);

return (
// 3. 在 JSX 中渲染这个元素数组
<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 的 interfacetype 来定义这个 props 对象的“形状”(Shape),从而实现比 Vue 更原生、更强大的类型约束。

让我们通过一个用户卡片组件来对比这个过程。

UserCard.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
// 使用泛型参数为 props 定义类型
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
// 1. 使用 interface 定义 props 的类型契约
interface UserCardProps {
name: string;
age: number;
isPremiumUser: boolean;
}

// 2. 在函数参数中应用类型,并使用解构赋值
// 这使得在组件内部可以直接使用 name, age 等变量
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

当你在一个组件的起始标签和结束标签之间放置任何内容时,这些内容都会被收集起来,并通过一个名为 childrenprop 传递给该组件。

核心对标: 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' // 引入 React 以使用 ReactNode 类型

// 1. 在 props 接口中定义 children 的类型
// React.ReactNode 是一个非常通用的类型,它可以是任何可渲染的内容:
// 字符串、数字、JSX 元素、null、undefined、或者一个由它们组成的数组。
interface CardProps {
children: React.ReactNode
}

function Card({ children }: CardProps) {
// 2. 在 JSX 中渲染 children prop
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,我们必须:

  1. AppimageUrl 作为 prop 传给 UserProfile
  2. UserProfile 不使用 imageUrl,但必须接收它,再原封不动地传给 UserAvatar
  3. UserAvatar 同样不使用 imageUrl,但必须接收它,再传给 UserImage

这种层层传递就像用钻头打井一样,将属性“钻”过一个个组件层级,因此得名。

为什么这是一个问题?

  • 代码冗余与耦合: 中间组件 (UserProfile, UserAvatar) 的 props 接口被迫包含了它们本不关心的属性,导致组件职责不清,与顶层数据源产生了不必要的耦合。
  • 重构困难: 如果未来 UserImage 不再需要 imageUrl,或者需要一个新的 prop,你需要修改整条传递链路上的所有组件,维护成本极高。
  • 可读性差: 当你阅读 UserProfile 的代码时,看到一个 imageUrl prop,你无法立即判断它是否被当前组件使用,还是仅仅是一个“二传手”。

何时应该警惕 Prop Drilling?
当一个 prop 的传递深度 超过两层,并且中间组件完全不使用它时,就应该将其视为一个需要解决的架构“坏味道”。

Prop Drilling 本身并不是一种错误,而是一种需要权衡的模式。对于浅层(1-2 层)的传递,它依然是最简单直接的方案。本节的目的是让您能够 识别 出过度钻探的场景,并了解我们将在后续章节中介绍的解决方案(如 Context 和状态管理库)。


2.3. State 与不可变性:从 refuseState

如果说 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)

// 直接修改 .value 属性
// Vue 的响应式系统会拦截这个操作
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() {
// 这是错误的!React 会忽略这个修改。
// count++

// 正确方式:用一个新值替换旧值
setCount(count + 1)
}

2.3.2. useState 实战:构建一个计数器

让我们通过一个经典的计数器案例,来精确对比 refuseState 的用法。

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() {
// 1. 使用 useState 定义状态
// - 0 是初始值
// - count 是当前状态的只读引用
// - setCount 是用于更新 count 的函数
const [count, setCount] = useState(0);

// 2. 定义事件处理函数
function handleIncrement() {
// 3. 调用更新函数来触发状态变更
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); // 此时 count 仍然是旧值
setCount(count + 1); // 这里的 count 还是那个旧值
setCount(count + 1); // 这里的 count 依然是那个旧值
}
// 结果:count 只会增加 1!

解决方案:函数式更新
为了解决这个问题,状态更新函数可以接收一个 函数 作为参数。这个函数会自动接收 最新的、待处理的 state 作为其参数,并返回新的 state。

1
2
3
4
5
6
7
function handleTripleIncrement() {
// 使用函数式更新,确保每次都是基于最新的 state 进行计算
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
}
// 结果:count 会正确地增加 3!

最佳实践: 当你的新状态需要依赖于前一个状态时,总是 使用函数式更新的形式。

更新对象与数组状态

不可变性的原则在处理对象和数组时尤为重要。

更新对象:

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() {
// 错误: 直接修改对象属性
// user.age++;
// setUser(user); // React 会认为 user 引用没变,不重新渲染

// 正确: 使用展开语法(...)创建一个新对象,并覆盖需要修改的属性
setUser({
...user, // 复制 user 对象的所有属性
age: user.age + 1 // 用新值覆盖 age 属性
});
}

return (/*... JSX ...*/);
}

更新数组:

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';

// 错误: 使用 .push() 修改了原数组
// todos.push(newTodo);
// setTodos(todos); // 引用没变,不重新渲染

// 正确: 使用展开语法创建一个新数组
setTodos([
...todos, // 复制旧数组的所有项
newTodo // 在末尾添加新项
]);
}

return (/*... JSX ...*/);
}

常用的不可变数组操作包括:

  • 添加: setTodos([...todos, newTodo])
  • 移除: setTodos(todos.filter(todo => todo !== itemToRemove))
  • 修改: setTodos(todos.map(todo => todo === itemToUpdate ? updatedItem : todo))

2.3.4. useReducer 钩子:处理复杂状态逻辑

当一个组件的状态逻辑变得复杂,或者下一个状态依赖于前一个状态的多个部分时,useState 可能会变得笨拙和难以维护。此时,我们需要一个更强大的工具来组织状态变更。

本小节核心知识点:

  • useReduceruseState 的一种替代方案,专为管理 复杂的状态对象和状态转换逻辑 而设计。
  • 它将 更新逻辑 (如何更新) 从组件的事件处理函数中抽离出来,集中到一个名为 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);

// ... 更多的 JSX 来控制 step 的变化
// 这里的状态更新逻辑散落在各个事件处理器中
}

当操作类型更多(例如: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';

// 1. 定义状态的类型接口
interface CounterState {
count: number;
}

// 2. 定义 Action 的类型
// 使用联合类型来精确描述所有可能发生的 Action
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' };

// 3. 定义初始状态
const initialState: CounterState = { count: 0 };

// 4. 编写 Reducer 纯函数,集中处理所有状态转换逻辑
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:
// 对于未知的 action 类型,保持状态不变
throw new Error('Unknown action type');
}
}

function ComplexCounter() {
// 5. 在组件中使用 useReducer
// state 是当前状态对象
// dispatch 是用来派发 action 的函数
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
// Columns.tsx
function Columns() {
// 错误!因为 JSX 表达式必须返回单个元素,
// 我们用了一个 div 来包裹。
return (
<div>
<td>Column 1</td>
<td>Column 2</td>
</div>
);
}
// 渲染出的 HTML: <tr> <div> <td>...</td> </div> </tr> (无效!)

解决方案:使用 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>
</>
);
}

// 在 App.tsx 中使用
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'; // 需要导入 React

function Columns() {
// 使用 <React.Fragment>...</React.Fragment> 长语法
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 的编译器会做两件事:

  1. 给你的组件模板中的每个元素添加一个唯一的 data 属性,例如 <h1 class="title" data-v-f3f3eg9>.
  2. 将你的 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
// 1. 导入 .module.css 文件
import styles from './MyComponent.module.css';

function MyComponent() {
// 2. 从 styles 对象中获取唯一的类名
// styles.title 的值可能是 " MyComponent_title__aB3xY "
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';

// 创建一个 <Button> 组件,它自带样式
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>
);
}

优点: 样式的动态能力极强,可以访问组件的 propsstate。组件和它的样式被真正绑定在一起,实现了高内聚。
缺点: 存在一定的运行时性能开销(尽管现代库已经优化得很好)。

方案三: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 (
// 你通过组合这些 utility class 来构建 UI
<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),例如 onClickonCopyonMouseOver
  • 传递给事件处理器的 必须是一个函数引用,而不是函数调用。例如,onClick={handleClick} 是正确的,而 onClick={handleClick()} 是错误的。
  • 若要向事件处理函数传递参数,需要使用一个 内联箭头函数 进行包装,例如 onClick={() => handleDelete(id)}
  • React 的事件对象是一个 合成事件 (SyntheticEvent) 对象,它抹平了主流浏览器之间的差异。

2.6.1. 核心语法:从 @clickonClick

痛点背景: 在 Vue 中,我们习惯于使用 @ 符号(或 v-on:)来监听 DOM 事件,语法简洁明了,如 @click="handleClick"。React 的事件绑定在形式上更接近原生 JavaScript DOM 的 onclick 属性,但有一些关键区别。

范式转变:JSX 属性与函数引用

在 React 中,事件监听器是作为 JSX 元素的一个属性来提供的。你需要记住两个核心转换规则:

  1. 命名: 所有事件名都采用小驼峰式命名,例如 onclick 变为 onClickonmouseover 变为 onMouseOver
  2. : 传递给事件属性的值不再是字符串,而是一个用花括号 {} 包裹的 函数引用

让我们通过以下提供的代码,将 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('你点击了我');

// 1. 事件名为小驼峰 onClick
// 2. 值为用 {} 包裹的函数引用 handleClick
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>
);
}

事件处理总结:

  1. 事件名使用 on + EventName 的小驼峰形式。
  2. 处理器属性的值必须是 {函数引用}
  3. 传递参数需使用 ={() => 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-indexoverflow 的陷阱

想象一下,你正在构建一个位于深层嵌套组件中的“复制成功”提示框。这个父组件可能应用了一些 CSS 样式,比如 overflow: hiddenposition: relative,这会创建一个新的堆叠上下文 (stacking context)。

在这种情况下,即使你给提示框设置了很高的 z-index,它的显示范围和层级也会被其父容器的样式所限制,导致它被意外裁剪或遮挡。

我们的目标:无论组件在 React 树中嵌套得多深,我们都希望它的视觉产物(例如一个模态框或提示)能够被渲染到顶层的 <body> 标签下,从而在视觉上覆盖页面的所有其他内容,不受父级 CSS 的影响。

2.7.2. 解决方案:使用 createPortal

React DOM 提供了一个名为 createPortal 的函数,它允许我们实现这种“传送”。

createPortal 接收两个参数:

  1. child: 任何可被渲染的 React 子元素,例如一段 JSX。
  2. 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>
<!-- React 应用的主挂载点 -->
<div id="root"></div>
<!-- Portal 的专用挂载点 -->
<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 }) => {
// 调用 createPortal
return createPortal(
// 第一个参数:要渲染的 JSX
<section>
{copied && (
<div
style={{
position: 'fixed',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
background: 'black',
color: 'white',
padding: '10px',
borderRadius: '5px',
}}
>
已复制到剪贴板
</div>
)}
</section>,
// 第二个参数:传送的目标 DOM 节点
document.querySelector('#portal-popup')
)
}

export default PopupContent;

第三步:在父组件中正常使用

Portal 最奇妙的一点在于:尽管 PopupContentDOM 被渲染到了别处,但它在 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 (
// 注意,父组件的样式不会影响到 Portal
<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 在组件生命周期和响应式方面的核心认知。

在本章中,我们将循序渐进地完成一次从“有状态组件”到“有副作用组件”的升级:

  1. 首先,我们将从一个 简单的计数器 开始,并提出一个新需求:将计数器的值同步到浏览器的标题栏上。
  2. 接着,我们将引入 useEffect 来 解决这个“副作用”问题,并在此过程中解构其核心组成:效应函数与依赖数组。
  3. 然后,我们将深入探索 依赖数组 的三种核心用法,将它与 Vue 的 onMounted 和 watch 进行精确对标。
  4. 最后,我们将通过一个 新的定时器案例,学习 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 来处理副作用
useEffect(() => {
// 这里的代码会在每次组件渲染完成后执行
console.log('副作用函数正在运行...')
document.title = '你点击了' + count + '次'
}) // 暂时忽略掉第二个参数

return (
<div>
<p>你点击了 {count} 次</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
}

export default CounterWithTitle;

现在,当你运行这个组件时,会发现:

  1. 组件首次加载时,标题会更新为 “你点击了 0 次”。
  2. 每次点击按钮,count 增加,标题也会同步更新。

useEffect 完美地解决了我们的问题。它就像一个我们安置在组件渲染流程之外的“特殊区域”,专门用来执行那些不方便在主函数体中进行的操作


3.1.2. 解构 useEffect:效应函数与依赖数组

上面的代码 useEffect(() => { … }); 是 useEffect 最基本的形式,但它并不完美(打开控制台,你会发现 “Effect function is running!” 在每次渲染时都会打印)。为了精确控制副作用的执行时机,我们需要理解它的完整结构:

  1. setup 函数: 第一个参数,一个函数。我们称之为“效应函数”。你的副作用逻辑就写在这里(例如,document.title = …)。
  2. dependencies 数组: 第二个参数,一个 可选的 数组。我们称之为“依赖数组”。这是 useEffect 的灵魂所在,它告诉 React:“只有当这个数组里的值发生变化时,才需要重新执行 setup 函数。

现在,让我们用依赖数组来优化我们的组件,让 effect 只在 count 变化时执行:

1
2
3
4
5
6
// 👇 使用 useEffect 来处理副作用
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 是一个获取数据的函数
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 来处理副作用
useEffect(() => {
// 这里的代码会在每次组件渲染完成后执行
console.log('副作用函数正在运行...')
document.title = '你点击了' + count + '次'
},[count]) // 👈 关键:在这里传入依赖数组,表示当 count 变化时会自动执行 setup 函数

一个常见的陷阱: 如果你不提供依赖数组(useEffect(() => { ... })),setup 函数会在 每一次渲染后 都执行。这等价于 Vue 的 onUpdated 加上 onMounted,通常会导致性能问题或无限循环,是你应该极力避免的模式。


3.3. 清理机制:对标 onUnmounted

副作用通常需要“清理”。例如,如果你设置了一个定时器,或者添加了一个全局事件监听,你需要在组件被销毁时取消它们,以防止内存泄漏或 bug。

解决方案: useEffectsetup 函数可以 返回另一个函数。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(() => {
// 使用函数式更新,避免依赖 seconds state
setSeconds(s => s + 1);
}, 1000);

// 👇 返回一个清理函数
return () => {
console.log('Cleanup function is running! Clearing timer.');
clearInterval(timerId);
};
}, []); // 👈 空数组,意味着 setup 只在挂载时运行一次,cleanup 只在卸载时运行一次

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;

发生了什么?

  1. <input> 元素是一个 非受控组件,它自己在内部管理着用户输入的值。
  2. 当你在输入框里输入一些文字时,这些文字被保存在这个 <input> 实例的内部状态中。
  3. 当你点击“切换”按钮时,sw 的值改变,导致 <input>key prop 从 "light" 变成了 "dark"
  4. React 发现 key 变了,它不会去更新旧的输入框,而是直接 销毁 旧的 <input> 实例(连同它内部保存的输入文字),然后创建一个 全新的、状态为空的 <input> 实例并挂载到 DOM 上。

🤔 思考一下
请亲自尝试一下这个效果:

  1. 在输入框中随意输入一些文字。
  2. 点击“切换”按钮。
  3. 观察输入框,你会发现里面的文字消失了!这正是因为 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
// 繁琐的 useEffect 方案
function UserProfileForm({ userId }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// ... 还有 5 个其他的 state

useEffect(() => {
// 每次 userId 变化,都需要手动重置所有 state
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
// UserProfileForm 组件现在可以非常纯净,
// 它不再需要复杂的 useEffect 来处理重置逻辑。
function UserProfileForm({ userId }) {
const [user, setUser] = useState(null);

// 只需要一个简单的 effect 来加载初始数据
useEffect(() => {
fetchUserData(userId).then(data => setUser(data));
}, [userId]); // 依赖 userId,但只在组件首次挂载时运行

// ... 表单的 JSX
}

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 的高级能力:

  1. 首先,我们将重新审视在 2.2.3 节提出的 “属性钻探” (Prop Drilling) 问题。
  2. 接着,我们将引入 React 官方的解决方案 Context APIuseContext Hook,学习如何在组件树中进行“大范围”的状态共享,这精确对标 Vue 的 provide/inject
  3. 然后,我们将解决另一个常见需求:如何在 React 中直接操作 DOM 元素。我们将学习 useRef Hook 来应对这类场景,它对标 Vue 的 模板引用 (template refs)
  4. 最后,也是本章的最高潮,我们将学习如何将前面学到的所有 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
# src/
├── components/
│ ├── Greeting.tsx # <-- 最终需要 user 数据的组件
│ ├── UserInfoCard.tsx # <-- 中间组件 B
│ └── UserProfilePage.tsx # <-- 中间组件 A
└── App.tsx # <-- 提供 user 数据的顶层组件

App.tsx (数据源)

1
2
3
4
5
6
7
8
import UserProfilePage from './components/UserProfilePage';

function App() {
const currentUser = { name: 'Prorise' };

// App 必须把 currentUser 传给 UserProfilePage
return <UserProfilePage user={currentUser} />;
}

UserProfilePage.tsx (中间人 A)

1
2
3
4
5
6
7
8
9
10
11
import UserInfoCard from './UserInfoCard';

// UserProfilePage 自己不用 user,但必须接收并继续向下传递
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'

// UserInfoCard 自己也不用 user,但必须接收并继续向下传递
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
// 只有 Greeting 组件真正使用了 user.name
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';

// 定义我们希望在 Context 中共享的数据的类型
interface User {
name: string;
}

// 创建 Context 对象,可以提供一个默认值
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 (
// 1. 用 Provider 包裹子组件
// 2. 将要共享的数据通过 value 属性传递下去
<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() {
// 使用 useContext Hook 直接“注入” UserContext 的值
const user = useContext(UserContext);

// user 可能为 null (如果我们没有提供 Provider),最好做个判断
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 (
// 使用 Consumer 组件,它的子元素必须是一个函数
<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


最终成果:解耦的中间组件

现在,我们的中间组件 UserProfilePageUserInfoCard 不再需要关心 user prop,它们变得干净、独立且高度可复用。

UserProfilePage.tsx (重构后)

1
2
3
4
5
6
7
8
9
10
11
import UserInfoCard from './UserInfoCard';

// 不再需要接收和传递 user prop!
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 有两大核心用途:
    1. 访问 DOM 节点,这精确对标 Vue 的 模板引用
    2. 存储一个不触发组件重新渲染的可变值,类似于 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() {
// 第一步:创建一个 Ref 对象来持有 DOM 节点
// 最佳实践:使用 TypeScript 泛型来指定它将持有的元素类型
const inputRef = useRef<HTMLInputElement>(null);

const handleFocusClick = () => {
// 第三步:通过 .current 属性访问真实的 DOM 节点
// 最佳实践:在使用前检查 .current 是否存在
if (inputRef.current) {
inputRef.current.focus(); // 调用 DOM 元素的 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);
// 使用 useRef 来存储 interval ID,它的变化不会触发重渲染
const intervalRef = useRef<number | null>(null);

useEffect(() => {
// 在 .current 中保存 setInterval 返回的 ID
intervalRef.current = window.setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);

// 组件卸载时,从 .current 中取出 ID 并执行清理
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []); // 空依赖数组,确保 effect 只在挂载和卸载时运行一次

const handleStopTimer = () => {
// 用户点击按钮时,同样可以从 .current 中取出 ID 并清除
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>;

// ... 渲染 posts ...
}

现在,如果另一个组件 CommentList 也需要获取评论数据,我们就得把上面这一大段 useStateuseEffect 的逻辑原封不动地复制粘贴过去,只改一下 URL。这显然违反了 DRY (Don’t Repeat Yourself) 原则,难以维护。

4.3.2. 解决方案:创建你的第一个自定义 Hook

自定义 Hook 就是为了解决这类问题而生的。它让我们能将这部分可复用的 状态逻辑 封装到一个函数中。
自定义 Hook 的两大黄金法则:

  1. 必须以 use 开头: 这是 React Linter 用来识别一个函数是否为 Hook 的硬性规定,例如 useFetchuseToggle
  2. 内部可以调用其他 Hooks: 这是自定义 Hook 的超能力所在,它能组合 useStateuseEffect 等,创造出新的、更强大的 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";

// 一个自定义 Hook,用于管理一个布尔状态
const useToggle = (initialValue = false) => {
// 内部使用了 useState
const [value, setValue] = useState(initialValue);

// 封装了状态切换的逻辑
const toggle = () => setValue((prevValue) => !prevValue);

// 返回状态和操作函数,其 API 模仿了 useState
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 = () => {
// 像使用 useState 一样使用我们自己的 Hook
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

示例二:useInput - 简化表单处理

这个 Hook 封装了处理受控输入框 valueonChange 的通用逻辑。

文件路径: src/hooks/useInput.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useState } from 'react'

// 一个自定义 Hook,用于管理输入框状态
const useInput = (initialValue: string = '') => {
const [value, setValue] = useState(initialValue)

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value)
}

// 返回一个对象,包含 input 元素需要的所有 props
// 这样我们可以用 ... 扩展操作符方便地绑定
return {
value,
onChange: handleChange,
}
}

export default useInput

当你写 {…name} 时,实际上是在展开 useInput Hook 返回的对象。让我们看看这个过程:

1
2
3
4
5
6
7
8
// useInput 返回的对象
const name = {
value: "当前输入值",
onChange: handleChange函数
}

// 当你写 <input {...name} /> 时,相当于:
<input value={name.value} onChange={name.onChange} />

文件路径: src/components/FormComponent.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 useInput from '../hooks/useInput'

const FormComponent = () => {
// 为每个输入框独立使用 useInput Hook
const name = useInput('')
const email = useInput('')

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
alert(`姓名: ${name.value}, 邮箱: ${email.value}`)
}

return (
<form
onSubmit={handleSubmit}
className="p-4 border rounded-md my-4 space-y-2"
>
<label>
姓名: <input type="text" {...name} className="border p-1" />
</label>
<label>
邮箱: <input type="email" {...email} className="border p-1" />
</label>
<button type="submit" className="bg-blue-500 text-white p-2 rounded">
提交
</button>
</form>
)
}

export default FormComponent;

示例三: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'

// 一个自定义 Hook,用于从 URL 获取数据
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 组件和小型应用的能力。


在本章中,我们将遵循一条精心设计的技能升级路径,逐步解锁更复杂的应用场景:

  1. 阶段一:状态管理基石: 我们将从最核心的 useState 开始,通过构建 计数器待办事项列表,彻底掌握对数字、数组等基础数据结构的状态管理。
  2. 阶段二:交互式 UI 构建: 接着,我们将挑战 颜色切换器隐藏式搜索框 等项目,专注于 UI 状态的管理,创造更丰富的用户交互。
  3. 阶段三:异步数据流与 API 交互: 然后,我们将通过 餐饮 API 项目,首次引入 useEffect 处理网络请求,打通 React 应用与服务器的数据链路。

请相信我,每一节我都安排来不同的知识点,完全遵循最佳实践与之前学习过的所有知识点


5.1. 阶段一:状态管理基石

5.1.1. 项目实战:计数器 (Counter)

这是我们 React 实战之旅的第一站。计数器虽小,却蕴含了 React 数据驱动视图的核心思想。我们将通过它,将 useState 和事件处理的理论知识,转化为指尖上的代码。同时,我们将引入并实践 SCSS Modules,这是一种能将 SCSS 的强大功能与组件化样式隔离完美结合的最佳实践。

image-20250924150625150

实战准备:为项目添加 SCSS Modules 支持

1.6 节,我们已经为项目配置了 Tailwind CSS。现在,我们将学习另一种强大的样式方案:SCSS Modules。它允许我们在组件层面编写 SCSS,并自动确保样式不会泄露到其他组件,完美对标 Vue 的 <style scoped>

第一步:安装 SCSS 编译器
如果尚未安装,请确保您的项目已添加 sass 依赖。

1
pnpm add -D sass

第二步:理解 SCSS Modules 的工作方式
Vite 已为我们内置了 CSS Modules 的支持。我们只需遵循一个简单的命名约定:将样式文件命名为 [ComponentName].module.scss

当你这样做时:

  1. Vite 会将这个 SCSS 文件中的所有类名进行哈希处理,生成一个独一无二的类名(例如 .title 变成 .Counter_title__aB3xY)。
  2. 当你 import 这个文件时,它会返回一个 JavaScript 对象,键是你原始的类名,值是哈希后的唯一类名。

这种机制从根本上解决了 CSS 全局污染的问题。

项目目标

我们将构建一个简单的计数器应用,包含一个显示的数字、一个“增加”按钮和一个“减少”按钮。

  • useState: 用于管理计数器的数字状态。
  • 事件处理: onClick 事件绑定与处理函数的编写。
  • SCSS Modules: 实现组件级别的样式封装。

项目结构与代码解析

我们将采用“组件文件夹”的最佳实践来组织代码,将与 Counter 组件相关的所有文件都放在同一个地方。

1
2
3
4
5
6
# src/
├── 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
/* Counter.module.scss */

.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 { // SCSS 的 & 嵌套语法
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";
// 导入 SCSS Modules 文件,styles 是一个包含唯一类名的对象
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 (
// 使用 styles 对象中的类名
<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 (
// 使用 Tailwind CSS 提供全局样式
<main className="bg-black min-h-screen flex flex-col justify-center items-center">
<Counter />
</main>
);
};

export default App;

🤔 思考与扩展

现在你已经完成了一个使用 SCSS Modules 的计数器,尝试挑战一下自己:

  1. 添加重置功能: 增加一个“重置”按钮,点击后让计数器归零。
  2. 设置边界: 修改 decrement 函数,使得计数器的值不能小于 0。
  3. 动态样式: 当 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; // 绿色,当count > 10时
}

&.negative {
color: #ef4444; // 红色,当count < 0时
}
}
}

修改代码内容为:

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'
// 导入 SCSS Modules 文件,styles 是一个包含唯一类名的对象
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 (
// 使用 styles 对象中的类名
<div className={styles.container}>
<h1 className={getNumberDisplayClass()}>{count}</h1>
</div>
)
}

export default Counter

5.1.2. 项目实战:待办事项列表 (Todo List)

如果说“计数器”是 useState 的入门,那么“待办事项列表”就是我们掌握数组状态管理的第一次大考。在这个项目中,我们将学会如何以 React 的方式(不可变地)对一个列表进行增加和删除操作,这是构建动态应用的核心技能。

image-20250924180025036

项目目标

我们将构建一个经典的 Todo List 应用。用户可以:

  1. 在输入框中输入任务。
  2. 点击“提交”按钮,将新任务添加到列表中。
  3. 点击每项任务旁的“X”按钮,从列表中删除该任务。

核心概念巩固

  • useState: 管理输入框的字符串状态,以及待办事项的数组状态。
  • 数组的不可变更新: 使用 concatfilter 等方法来更新数组,而非直接修改。
  • 列表渲染: 使用 .map() 方法动态渲染列表,并为每一项提供唯一的 key
  • 受控组件: 将 input 输入框的 value 与 React state 绑定。

项目结构与代码解析
我们将继续遵循“组件文件夹”的最佳实践。

1
2
3
4
5
6
7
8
# src/
├── 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; // 移除 ul 的默认 padding
}

.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'

// 定义 Todo 项的类型,这是 TypeScript 的最佳实践
interface TodoItem {
id: number
text: string
}

function Todo() {
// 状态1: 管理待办事项列表,初始为空数组
const [todos, setTodos] = useState<TodoItem[]>([])
// 状态2: 管理输入框的值,初始为空字符串
const [input, setInput] = useState('')

// 处理添加新 Todo 的逻辑
const handleAddTodo = () => {
// 防止添加空任务
if (!input.trim()) return

const newTodo: TodoItem = {
text: input,
// 注意:在真实应用中,绝不能使用随机数做 ID!
// 这里为了简化,我们使用时间戳作为唯一 ID。
id: Date.now(),
}

// 使用 concat 创建一个新数组来更新 state,保证不可变性
setTodos(todos.concat(newTodo))

// 清空输入框
setInput('')
}

// 处理删除 Todo 的逻辑
const removeTodo = (id: number) => {
// 使用 filter 创建一个不包含目标 id 的新数组
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 (
// 使用 Tailwind CSS 提供全局样式
<main className="bg-gray-100 min-h-screen flex justify-center items-center">
<Todo />
</main>
);
}

export default App;

🤔 思考与扩展
这个 Todo List 已经具备了核心功能,但我们还可以让它更强大。试试看:

  1. 切换完成状态:为每个 TodoItem 增加一个 completed 属性。点击任务文本时,切换其完成状态,并给已完成的任务添加一条删除线样式。
  2. 显示任务计数:在列表上方或下方,显示“总共有 X 个任务”或“还剩 Y 个未完成任务”。

第一步:修改 Todo.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Todo.module.scss */
.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; // 新增 completed 状态
}

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 样式构建。

img

第一步:配置 Tailwind CSS 的深色模式策略

Tailwind 的 dark: 变体默认使用 prefers-color-scheme 媒体查询,跟随用户的操作系统设置。为了实现手动切换,我们需要将其配置为,在V4版本最新的配置方法变为了在css中配置,所以我们也按照他的规范来

打开项目根目录的 index.css 文件,并修改它:

文件路径: index.css

1
2
3
4
@import 'tailwindcss';

/* Tailwind CSS v4 暗黑模式配置 */
@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() {
// 最佳实践:从 localStorage 初始化 state,避免页面刷新时闪烁
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';
});

// 关键的 Effect:同步 React state 到 DOM 和 localStorage
useEffect(() => {
const root = document.documentElement; // 获取 <html> 元素
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]); // 依赖项是 theme,每当 theme 变化时,此 effect 就会重新运行

const handleThemeToggle = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};

return (
// 现在,样式切换完全由 Tailwind 根据 <html> 上的 .dark 类自动处理
<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.tsxindex.html
这部分保持不变,App.tsx 负责渲染 ThemeToggler

1
2
3
4
5
6
7
8
import ThemeToggler from "./components/ThemeToggler/ThemeToggler";

function App() {
// App 组件返回 ThemeToggler,使其成为页面上唯一显示的内容。
return <ThemeToggler />;
}

export default App;

🤔 思考与扩展
我们已经实现了一个生产级的、可持久化的主题切换器。现在的代码已经非常优秀,但作为追求卓越的开发者,我们还能再优化一步吗?

  1. 提取为自定义 Hook: 目前,主题管理的逻辑(useStateuseEffectlocalStorage)都耦合在 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() {
// 最佳实践:从 localStorage 初始化 state,避免页面刷新时闪烁
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'
})

// 关键的 Effect:同步 React state 到 DOM 和 localStorage
useEffect(() => {
const root = document.documentElement // 获取 <html> 元素
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
localStorage.setItem('theme', theme)
}, [theme]) // 依赖项是 theme,每当 theme 变化时,此 effect 就会重新运行

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'; // 导入自定义 Hook

function ThemeToggler() {
// 组件的逻辑被极大地简化了!它现在只关心“有什么”和“做什么”。
// 组件的逻辑被极大地简化了!它现在只关心“有什么”和“做什么”。
const { theme, handleThemeToggle } = useTheme()
}

export default ThemeToggler;

通过自定义 Hook,我们实现了逻辑与视图的终极分离useTheme Hook 现在是一个完全独立的、可移植的、可在任何组件中使用的“主题管理引擎”。


5.2.2. 项目实战:隐藏式搜索框

在掌握了如何通过状态切换整个页面主题后,我们现在将注意力集中到一个更具体的交互上:如何通过点击一个图标,平滑地、动态地展示一个输入框。这个项目是练习 React 状态与 CSS 过渡动画 相结合的绝佳机会。

img

项目目标
我们将构建一个初始状态只显示一个搜索图标的界面。当用户点击该图标时,图标消失,一个输入框以平滑的过渡效果出现,同时背景变为深色。点击输入框以外的区域,则恢复初始状态。

核心概念巩固

  • useState: 管理 UI 的可见性状态(显示图标还是输入框)。
  • 条件渲染: 使用三元运算符在 JSX 中根据状态渲染不同的元素。
  • 事件处理: onClick 事件的精确使用,包括事件冒泡的处理。
  • Tailwind CSS: 熟练运用其过渡 (transition)、透明度 (opacity) 和宽度 (width) 等工具类,以纯 CSS 的方式实现动画效果。

项目结构与代码解析
我们将继续使用 Tailwind CSS,保持组件的内聚性。

1
2
3
4
5
# src/
├── componebghnts/
│ └── HiddenSearchBar/
│ └── HiddenSearchBar.tsx # 唯一的组件文件
└── App.tsx # 应用主入口

实战准备:安装图标库
为了使用搜索图标,我们需要一个图标库。react-icons 是一个非常流行且易于使用的选择。

1
pnpm add 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"; // 从 react-icons 库导入图标

function HiddenSearchBar() {
// 最佳实践:使用单一、清晰的状态来控制 UI 模式
const [isActive, setIsActive] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

// 当搜索框被激活时,自动聚焦到输入框
useEffect(() => {
if (isActive && inputRef.current) {
inputRef.current.focus();
}
}, [isActive]);

return (
// 使用 Tailwind 来处理背景色和过渡效果
// `group` 类是关键,它允许子元素根据父元素的状态改变样式
<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;

代码重构与最佳实践

  1. 单一状态来源: 原始代码使用了两个 useState (showInput, bgColor) 来管理本应由一个状态控制的 UI。我们将其重构为单一的 isActive 状态,使逻辑更清晰。
  2. CSS > JS: 原始代码通过 style 属性和 JS 来控制背景色,我们将其完全交给 Tailwind 的 dark: 变体(或在本例中是条件类名),让 CSS 负责样式,JS 负责状态,实现关注点分离。
  3. 动画实现: 我们放弃了原始 CSS 文件中的过渡,完全使用 Tailwind 的 transition, duration, ease-in-out, opacity, 和 width 工具类,以纯声明式的方式在 JSX 中实现了更复杂的动画,无需离开组件文件。
  4. 自动聚焦: 通过 useRefuseEffect,我们实现了在搜索框出现时自动聚焦的交互优化,提升了用户体验。

2. App.tsx (应用入口)

1
2
3
4
5
6
7
import HiddenSearchBar from "./components/HiddenSearchBar/HiddenSearchBar";

function App() {
return <HiddenSearchBar />;
}

export default App;

🤔 思考与扩展
这个组件已经非常酷了,但还有一个交互细节可以完善:

  1. 点击外部关闭: 目前,一旦搜索框被激活,只能通过再次点击图标来关闭。更符合用户直觉的行为是:点击搜索框以外的任何区域,都应该能关闭它。你能否实现这个功能?

是的,我们可以通过在根 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); // Ref for the main container

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.targetinputsvg 元素,条件不满足,状态不会改变。这就完美地实现了我们的目标。


5.3. 阶段三:异步数据流与 API 交互

5.3.1. 项目实战:餐饮 API 项目 (Meals API Project)

欢迎来到我们实战之旅的全新阶段!到目前为止,我们构建的应用都只在“自己的世界里”运行,处理着我们预设的数据。现在,我们将打破这层壁垒,通过发起真实的 API 请求,让我们的 React 应用首次与广阔的互联网世界对话,获取并展示动态数据。

img

项目目标

我们将构建一个从 TheMealDB API 获取海鲜菜品数据,并以精美的卡片网格形式展示的页面。

核心概念巩固

  • useEffect: 用于在组件首次渲染后执行数据获取这一“副作用”。
  • useState: 精准管理异步流程中的三种关键状态:loading(加载状态)、error(错误状态)和 data(成功获取的数据)。
  • 异步操作: 引入并使用 axios 库,以 async/await 的现代语法发起网络请求。
  • 条件渲染: 根据 loadingerror 状态,为用户提供清晰的界面反馈。
  • TypeScript 接口: 为 API 返回的数据定义类型,让我们的代码更健壮、更易于维护。
  • Tailwind CSS: 完全使用原子化类名来构建一个响应式的卡片网格布局。

项目结构与代码解析
我们将继续采用简洁、内聚的组件结构。

1
2
3
4
5
# src/
├── components/
│ └── Meals/
│ └── Meals.tsx # 唯一的组件文件
└── App.tsx # 应用主入口

实战准备:安装 Axios
为了更便捷地处理网络请求,我们将安装广受欢迎的 axios 库。

1
pnpm add 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'

// 最佳实践:为从 API 获取的数据定义 TypeScript 接口
// 这能提供强大的类型检查和编辑器自动补全,极大提升代码质量
interface Meal {
idMeal: string
strMeal: string
strMealThumb: string
}

function Meals() {
// 状态1: 存储从 API 获取的菜品列表,并指定其类型
const [meals, setMeals] = useState<Meal[]>([])

// 状态2: 管理加载状态,为用户提供清晰的反馈
const [loading, setLoading] = useState<boolean>(true)

// 状态3: 管理可能发生的错误
const [error, setError] = useState<Error | null>(null)

// useEffect 用于处理组件挂载后的数据获取副作用
useEffect(() => {
// 在 Effect 内部定义一个异步函数来获取数据
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 {
// 无论请求成功或失败,最后都将加载状态设置为 false
setLoading(false)
}
}

fetchMeals()
}, []) // 空依赖数组 `[]` 确保此 effect 仅在组件首次挂载时运行一次

// 根据加载状态进行条件渲染
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 (
// 使用 Tailwind CSS Grid 布局来创建响应式网格
// sm:小屏幕下,2列,lg:中屏幕下,3列,xl:大屏幕下,4列
<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 应用的关键一步。现在,我们可以思考如何让这个页面变得更强大:

  1. 添加搜索功能: 增加一个输入框,允许用户输入菜品名称进行搜索。当用户点击搜索按钮时,向 https://www.themealdb.com/api/json/v1/1/search.php?s=YOUR_SEARCH_QUERY 发起新的 API 请求,并更新列表。
  2. 提取自定义 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';

// 我们可以让 Hook 变得更通用,通过泛型来接收任意数据类型
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(() => {
// 如果 url 为空,则不发起请求
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); // 适配 meals API 的数据结构
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};

fetchData();
}, [url]); // 依赖项是 url,每当 url 变化时,Hook 会自动重新获取数据

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"; // 导入我们的自定义 Hook

interface Meal {
idMeal: string;
strMeal: string;
strMealThumb: string;
}

function Meals() {
const [searchTerm, setSearchTerm] = useState<string>("Seafood");
const [query, setQuery] = useState<string>("Seafood");

// 根据 searchTerm 构建 API URL
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 这层铠甲将为我们带来:

  1. 代码的健壮性: 在代码 运行前(编译阶段)就能发现大量的潜在错误,而不是等到用户在浏览器中遇到问题。
  2. 开发体验的飞跃: 享受无与伦比的编辑器自动补全、类型提示和重构能力,让我们写代码更快、更自信。
  3. 团队协作的基石: 类型定义本身就是最精准、最不会过时的“文档”。任何接手我们代码的同事都能立即明白一个组件需要什么数据,返回什么结果。

在本章中,我们将系统性地学习如何将 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
// 这是一个没有类型定义的组件,存在风险
// function User(props) {
// return (
// <main>
// <h2>{props.name}</h2>
// <p> 年龄: {props.age}</p>
// </main>
// );
// }

// 方式一:内联类型定义 (Inline Typing)
// 我们可以直接在函数参数后面使用 `:` 来定义 props 对象的形状
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
}

// 我们也可以使用 `interface`,在定义组件 props 时,两者几乎等效
// interface 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 开发中的 黄金标准:代码既简洁又类型安全。

6.1.3. 实战练习:创建一个带类型的 Button 组件

现在,让我们亲手实践一下。我们将创建一个可复用的 Button 组件,它需要接收 labelonClickdisabled 三个 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';

// 1. 使用 `type` 或 `interface` 为 Button 的 props 定义类型
type ButtonProps = {
label: string;
onClick: () => void; // `onClick` 是一个不接收参数、无返回值的函数
disabled?: boolean; // `?` 表示 `disabled` 是一个可选的 prop
};

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` 关键字表示我们只导入类型信息

type CardProps = {
children: ReactNode; // `ReactNode` 可以是任何 React 能渲染的东西
};

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, emailuser 对象。

如果我们为每个组件都重复定义一次这个 user 对象的类型,就会违反 DRY (Don’t Repeat Yourself) 原则,导致代码冗余和维护困难。本节,我们将学习如何创建可复用的、可组合的类型,从根本上解决这个问题。

6.2.1. 第一步:创建全局类型定义文件

最佳实践是将那些需要在多个地方共享的类型,抽离到一个或多个专门的 .ts 文件中。这通常放在一个 typesinterfaces 目录下。

让我们首先创建一个全局的类型定义文件。

文件路径: 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` 将这些类型导出,以便在其他文件中使用
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'
// 从我们的全局类型文件中导入 `Info` 类型
import { type Info } from '../types'

// 定义 UserInfo 组件的 props 类型
// 它需要一个 `user` prop,这个 prop 的类型必须符合我们导入的 `Info` 接口
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'
// 从全局类型文件中导入 `AdminInfoList` 类型
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 = () => {
// 创建一个符合 `Info` 类型的用户数据
const user: Info = {
id: 1,
name: '张三',
email: 'zhangsan@example.com',
}

// 创建一个符合 `AdminInfoList` 类型的管理员数据
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 = () => {
// 场景1:初始值为数字 `0`
// TypeScript 自动推断出 `count` 的类型是 `number`
// `setCount` 的类型是 `React.Dispatch<React.SetStateAction<number>>`
// 这意味着 `setCount` 只能接收数字或一个返回数字的函数
const [count, setCount] = useState(0);

const increment = () => {
setCount((prevCount) => prevCount + 1);
};

// 尝试传递一个字符串给 setCount,会立即得到一个类型错误
// setCount("hello"); // ❌ 类型“string”的参数不能赋给类型“SetStateAction<number>”的参数

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. 进阶:显式指定复杂状态的类型

当我们的状态是一个复杂的对象或数组时,最佳实践是先使用 interfacetype 定义这个状态的“形状”,然后将它作为泛型参数传递给 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";

// 1. 定义状态对象的形状
interface UserProfileData {
name: string;
age: number;
email: string;
}

const UserProfile = () => {
// 2. 将 UserProfileData 作为泛型传递给 useState
// 并提供一个符合该类型的初始值
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) {
// 2. 将 `Todo[]` (表示一个 Todo 对象的数组) 作为泛型传递给 useState
const [todos, setTodos] = useState<Todo[]>([])

const addTodo = (task: string) => {
const newTodo: Todo = {
id: Date.now(), // 使用时间戳作为更可靠的唯一 ID
task,
completed: false,
}
// 现在 TypeScript 知道 newTodo 符合 Todo 类型,可以安全地添加到数组中
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
// 假设我们有一个 User 类型
interface User {
id: number;
name: string;
}

function UserData() {
// 我们必须明确告诉 TypeScript,`user` 的类型
// 可能是 `User` 对象,也可能是 `null`
const [user, setUser] = useState<User | null>(null);

useEffect(() => {
fetchUserData().then(data => {
setUser(data); // `data` 符合 `User` 类型,可以赋值
});
}, []);

if (!user) {
return <p>加载中...</p>;
}

// 在这里,TypeScript 知道 `user` 不再是 null,可以安全地访问 `user.name`
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 的精确类型

我们已经学会了如何为 propsstate 添加类型,现在我们将把类型安全的“保护网”撒向 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 = () => {
// 关键:为 useRef 提供一个泛型,指明它将引用一个 HTMLInputElement 元素。
// 初始值为 `null`,因为在组件首次渲染时,DOM 元素还不存在。
const inputRef = useRef<HTMLInputElement>(null)
const handleFocus = () => {
// 使用可选链操作符 `?.` 是一个好习惯。
// 它能确保即使 `inputRef.current` 为 `null`,代码也不会报错。
inputRef.current?.focus()
// 现在,当您输入 `inputRef.current.` 时,
// 编辑器会自动提示 `.focus()`, `.value`, `.select()` 等所有 input 元素的方法和属性!
}

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'; // 必须导入 React 才能使用其内置的事件类型

const EventHandling = () => {
// 为鼠标点击事件提供精确类型:React.MouseEvent
// 泛型 `<HTMLButtonElement>` 指明了事件源自于一个 button 元素
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log("按钮被点击了!", e.currentTarget);
// 现在 `e.` 会提示所有鼠标事件相关的属性,如 `e.clientX`, `e.preventDefault()` 等
};

// 为鼠标移入事件提供类型
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'

// 1. 为表单的 state 定义类型
interface ContactFormState {
name: string
email: string
}

const ContactForm = () => {
// 2. 将类型应用于 useState
const [formData, setFormData] = useState<ContactFormState>({
name: '',
email: '',
})

// 3. 为输入框的 `onChange` 事件提供精确类型
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 首先从事件对象 e.target 中解构出 name 和 value 属性,这两个属性分别代表了输入框的名称和用户输入的值。
const { name, value } = e.target
// 使用这种语法的原因是,它可以动态地根据输入框的 name 属性来更新对应的 formData 对象中的值。这样可以避免写多个 if-else 语句来处理不同输入框的变化,使代码更加简洁和易于维护。
// 例如,如果 name 变量的值是 "username",那么 [name]: value 就相当于 username: value;如果 name 是 "email",就相当于 email: value。
setFormData((prevState) => ({ ...prevState, [name]: value }))
}

// 4. 为表单的 `onSubmit` 事件提供精确类型
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
console.log('表单已提交:', formData)
// 在这里可以处理表单提交逻辑,例如发送数据到 API
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 APIuseReducer。为它们提供类型,就像是为我们应用的“数据高速公路”和“状态机引擎”设置了精准的交通规则,能从根本上保证数据流动的安全与可预测性。

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";

// 1. 定义 Context 将要共享的数据和方法的“形状”
interface CounterContextProps {
count: number;
increment: () => void;
decrement: () => void;
}

// 2. 创建 Context,并为其提供一个符合接口的默认值
// 这个默认值主要用于类型推断和在没有 Provider 的情况下单独测试消费者组件
export const CounterContext = createContext<CounterContextProps>({
count: 0,
increment: () => {},
decrement: () => {},
});

// 3. 创建一个类型化的 Provider 组件
// 它负责管理真实的状态,并将状态通过 Context.Provider 传递下去
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 (
// 在应用的顶层(或任何需要共享状态的子树的根部)包裹 Provider
<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 = () => {
// `useContext` 会返回我们在 Provider 的 value 中传递的对象
// TypeScript 知道它的类型是 `CounterContextProps`
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";

// 1. 定义 Context 数据的类型
type SharedInputContextData = {
value: string;
setValue: (newValue: string) => void;
};

// 2. 创建 Context,初始值为 undefined
// 我们明确告诉 TypeScript,这个 Context 的值可能是 `MyContextData`,也可能是 `undefined`
const SharedInputContext = createContext<SharedInputContextData | undefined>(undefined);

// 3. 创建 Provider 组件(与之前类似)
type SharedInputContextProviderProps = {
children: ReactNode;
};

export const SharedInputContextProvider: FC<SharedInputContextProviderProps> = ({ children }) => {
const [value, setValue] = useState<string>("");
return (
<SharedInputContext.Provider value={{ value, setValue }}>
{children}
</SharedInputContext.Provider>
);
};

// 4. (最佳实践) 创建一个自定义 Hook 来消费 Context
// 这个 Hook 内部处理了 undefined 的情况,让消费者组件更干净
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"; // 导入自定义 Hook

const SharedInputDisplay = () => {
// 使用我们的自定义 Hook,不再需要自己处理 undefined 的情况
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 在处理复杂状态逻辑时的优势。现在,我们将为它的三个核心要素——stateactionreducer 函数——都加上精确的类型定义。这能将 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
// 1. 定义状态 state 的“形状”
export type CounterState = {
count: number;
};

// 2. 使用“可辨识联合类型” (Discriminated Union) 来定义所有可能的 action
// 这是一种强大的 TypeScript 模式,`type` 属性就是那个“可辨识”的字段
type IncrementAction = {
type: "INCREMENT";
};

type DecrementAction = {
type: "DECREMENT";
};

// 如果有需要 payload 的 action,可以这样定义:
// type AddAction = {
// type: "ADD";
// payload: number;
// };

// 3. 将所有 action 类型合并为一个联合类型
export type CounterAction = IncrementAction | DecrementAction; // | AddAction

第二步:创建类型化的 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
// 1. 定义状态 state 的“形状”
export type CounterState = {
count: number
}

// 2. 使用“可辨识联合类型” (Discriminated Union) 来定义所有可能的 action
// 这是一种强大的 TypeScript 模式,`type` 属性就是那个“可辨识”的字段
type IncrementAction = {
type: 'INCREMENT'
}

type DecrementAction = {
type: 'DECREMENT'
}

// 如果有需要 payload 的 action,可以这样定义:
// type AddAction = {
// type: "ADD";
// payload: number;
// };

// 3. 将所有 action 类型合并为一个联合类型
export type CounterAction = IncrementAction | DecrementAction // | AddAction

// 4. 创建 reducer 函数,并为其参数和返回值提供精确的类型
export const counterReducer = (
state: CounterState,
action: CounterAction
): CounterState => {
switch (action.type) {
case 'INCREMENT':
// TypeScript 在这里知道 action 的类型是 IncrementAction
// 并且知道返回值必须符合 CounterState 的形状
return { count: state.count + 1 }

case 'DECREMENT':
return { count: state.count - 1 }

default:
// 如果 action.type 不匹配任何 case,保持状态不变
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";
// 导入 reducer 函数和 state 类型
import { counterReducer, CounterState } from "../reducers/counterReducer";

// 定义组件的初始状态,它必须符合 `CounterState` 类型
const initialState: CounterState = { count: 0 };

const Counter = () => {
// `useReducer` 会根据 `counterReducer` 和 `initialState` 的类型,
// 自动推断出 `state` 的类型是 `CounterState`,
// `dispatch` 的类型是 `React.Dispatch<CounterAction>`
const [state, dispatch] = useReducer(counterReducer, initialState);

const increment = () => {
// `dispatch` 的参数必须符合我们定义的 `CounterAction` 联合类型
dispatch({ type: "INCREMENT" });
// dispatch({ type: "INCREASE" }); // ❌ 类型错误! "INCREASE" 不在联合类型中
};

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 获取用户列表,并以表格的形式展示出来。整个过程将是完全类型安全的。

image-20250925133719947

核心概念巩固

  • interface: 为 API 返回的数据结构定义清晰的类型契约。
  • useState 泛型: 使用联合类型 (User[] | null) 来处理异步数据的不同阶段。
  • useEffect: 在组件挂载时安全地执行异步数据获取。
  • 类型断言与守卫: 在 catch 块中安全地处理错误类型。
  • Tailwind CSS: 为表格添加简洁、美观的样式。

项目结构

1
2
3
4
5
# src/
├── 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";

// 1. 使用 `interface` 定义 API 返回的用户数据结构
interface User {
id: number;
name: string;
username: string;
email: string;
phone: string;
}

const UserList = () => {
// 2. 为所有状态提供精确的类型
const [users, setUsers] = useState<User[]>([]); // 状态可以是 User 对象的数组
const [loading, setLoading] = useState<boolean>(true); // 加载状态是布尔值
const [error, setError] = useState<string | null>(null); // 错误状态可以是字符串或 null

useEffect(() => {
const fetchUsers = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
if (!response.ok) {
throw new Error("网络响应失败,请稍后再试");
}
// 3. 告诉 TypeScript `response.json()` 的返回值将是 `User[]` 类型
const data: User[] = await response.json();
setUsers(data);
} catch (error) {
// 4. 安全地处理错误类型
// 使用 `instanceof Error` 来检查捕获到的 `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 应用。

我们为 propsstatehooksevents 都添加了精确的类型。这层“类型铠甲”将在未来的开发中,为您抵挡无数潜在的 Bug,提升代码的可维护性,并最终让您成为一名更自信、更高效的 React 工程师。