第一部分:基础篇 - 现代化 React 开发入门
第一章:开发环境与核心概念
React 思想与前端演进
在 Web 开发的早期,开发者们使用 JavaScript 和 jQuery 等来为静态的 HTML 页面注入活力。这种 DOM 操作方式很容易导致代码逻辑混乱,UI 状态与数据状态同步变得异常困难,代码最终演变成难以维护的"面条代码"。
为了解决这一困境,前端社区引入了 MVC 和 MVVM 等设计模式,诞生了像 Angular、Vue 和 React 这样的框架。它们通过数据驱动的方式,将开发者从繁琐的 DOM 操作中解放出来,这是一个巨大的进步。然而,React 的出现,带来了一种更为纯粹和强大的思想。
React 的核心思想可以被精炼为一个优雅的公式: UI = f(State) 。
你不再需要去手动修改 DOM,当 State 发生变化时,React 会自动帮你计算出最新的 UI,并高效地更新到浏览器中。你的界面应该是什么样子,由你的状态来决定;你唯一需要做的就是,清晰地描述在不同 State 下,你的界面应该呈现出什么样式。当 State 发生变化时,React 会像一个高效的画家,自动地以最优的方式去计算新旧界面之间的差异,并更新视图。这种"声明式"的编程方式,与"命令式"编程形成了鲜明对比。
- 命令式: 你告诉计算机每一步具体的操作 ("先这里出发,开 500 米后左转,经过三个红绿灯后右转...")
- 声明式: 你直接告诉计算机你想要的结果 ("我要去北京天安门"),而不关心具体过程
React 正是以这样的方式转变,带来了前所未有的可预测性和可维护性,使得构建大型、复杂的前端应用变得更加从容。
使用 Vite 搭建 React + TypeScript 开发环境
现代前端开发需要高效的工具链。要学习 React 19 的时代,我们首先需要一个现代化的开发环境。在过去,这种脚手架的实现是 Webpack。而如今 React 的官方推荐,我们选择了更快、更轻量化的 Vite。
Vite 是一个原生的前端构建工具,它极大地提升了前端的开发体验。其核心优势在于,它利用了现代浏览器原生支持 ES Module (ESM) 的特性,在开发阶段无需将所有代码打包即可在浏览器中看到变化,极大地加速了启动和热调试的反馈循环。
搭建一个基于 Vite 的 React + TypeScript 项目非常简单。整个过程于终端 (或命令行工具)。
# 使用 npm 创建 Vite 项目
npm create vite@latest
# 可以采用一键直达的命令
npm create vite@latest my-app -- --template react-ts
# 或者使用 yarn
yarn create vite
# 或者使用 pnpm (推荐)
pnpm create vite
执行之后,您会看到终端输出一个本地服务地址 (通常是 http://localhost:5173)。在浏览器中打开它,一个全新的 React 应用便呈现在眼前。我们的大部分工作都将在 src 目录下进行,其中 App.tsx 是应用的根组件,而 main.tsx 则是将根组件挂载到 index.html 页面上的入口文件。
深入 JSX 语法与实践技巧
初识接触 React 代码时,最引人注目的无疑是 JSX。它不是 HTML ,而是 JavaScript 中的一种类 HTML 结构。 实际上,我们编写的每一行 JSX 代码,在经过编译后,都会被转换为一个普通的 JavaScript 函数调用——React.createElement()。例如,这样一行 JSX:
<h1 className="title">Hello, React</h1>
其本质是下面这个代码的"语法糖":
React.createElement('h1', { className: 'title' }, 'Hello, React');
理解这一点,能帮助我们更好地掌握 JSX 的语法规则。因为 JSX 本质上只是 JavaScript 的语法扩展,所以我们可以使用{} 在 JSX 中嵌入任何有效的 JavaScript 表达式,比如变量、数字、运算,甚至是函数。
const user = { name: 'Alice', level: 5 };
const greeting = (
<div>
<h1>Welcome, {user.name}!</h1>
<p>Your access level is {user.level + 1}.</p>
</div>
);
在 JSX 中,我们依然可以使用 if/else、for 循环等,但更好的做法是使用更简洁的 &&、||、三元运算符和 map 来进行条件渲染和列表渲染。
需要注意的是,JSX 最终会被编译成 JavaScript 对象,因此在顶层不能有多个并列的 JSX 元素,必须将它们包裹在一个父容器中 (如 <div> 或 <> 片段)。同时,JSX 中所有的属性名都采用驼峰式 (camelCase),例如 class 要写成 className,tabindex 要写成 tabIndex。
函数式组件与 Class 组件对比
在 React 中,定义组件主要有两种历史悠久的方式: Class 组件和函数式组件。虽然您在一些旧的项目或文档中仍会见到 Class 组件的身影,但理解它们之间的差异,将帮助您清晰地认识到为何整个 React 生态已经全面拥抱了函数式组件。
Class 组件是早期 React 中创建可复用、有状态组件的主要方式。它基于 ES6 的 class 语法,需要继承 React.Component,并且通过 this.state 来管理内部状态,通过 this.setState() 来更新状态,UI 的描述则必须放在 render() 方法中。
让我们看一个 Class 组件实现的计数器:
class ClassCounter extends React.Component {
state = { count: 0 };
handleIncrement = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.handleIncrement}>Increment</button>
</div>
);
}
}
这种写法的结构相对固定,但也带来了额外的"模板代码"和对 this 关键字的复杂心智负担。
与此相对,函数式组件则是一个更为简洁和直观的范式。在早期,函数式组件仅仅是接收 props 并返回 JSX 的"哑"组件,无法拥有自己的状态。然而,自 React 16.8 引入 Hooks 之后,一切都改变了。
Hooks (例如 useState) 让函数式组件也能拥有状态和其他 React 特性。现在,我们可以用一种更简单、更符合 JavaScript 函数式编程思想的方式来重写上面的计数器:
import { useState } from 'react';
const FunctionalCounter = () => {
const [count, setCount] = useState(0);
const handleIncrement = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
};
两相对比,函数式组件的优势显而易见。它更加简洁,代码量更少,并且完全消除了 this 关键字带来的困扰。更重要的是,Hooks 的设计使得状态逻辑的复用变得异常简单,我们可以轻松地将相关逻辑封装在自定义 Hook 中,这在 Class 组件的时代是难以想象的。
正是由于这些压倒性的优势,函数式组件与 Hooks 已成为现代 React 开发的绝对标准。在本课程的后续所有章节中,我们都将完全采用这种现代化的范式来构建我们的应用。
第二章:组件化开发核心
在上一章中,我们已经搭建好了开发环境,并对 React 的核心思想及 JSX 语法有了初步的认识。现在,我们将正式进入组件化开发的世界。组件是 React 应用的基石,理解如何构建组件、如何让它们之间有效通信,是掌握 React 的关键。本章将深入探讨组件的输入 (Props)、内部状态 (State) 以及如何响应用户交互。
Props 与组件通信
任何有意义的 React 应用都是由多个组件构成的组件树。这些组件并非孤立存在,它们需要相互协作,传递信息,共同构成完整的用户界面。实现组件间通信最基础、最核心的机制,就是 Props。
Props,是 "properties" 的缩写,其作用与 JavaScript 函数的参数非常相似。如果说组件是一个函数,那么 Props 就是这个函数接收的参数。父组件通过 Props 将数据和功能传递给子组件,从而实现对子组件的配置和控制。
让我们来看一个简单的例子。假设我们有一个 Welcome 组件,我们希望它能向不同的用户显示欢迎信息。
// Welcome.tsx (子组件)
type WelcomeProps = {
name: string;
};
const Welcome = (props: WelcomeProps) => {
return <h1>Hello, {props.name}!</h1>;
};
// App.tsx (父组件)
const App = () => {
return (
<div>
<Welcome name="Sara" />
<Welcome name="Cahal" />
<Welcome name="Edite" />
</div>
);
};
在这个例子中,App 组件作为父组件,三次渲染了 Welcome 子组件。每次渲染时,它都通过一个名为 name 的 Prop,向 Welcome 组件传递了不同的值。Welcome 组件则在其函数参数中接收这个 props 对象,并读取 props.name 来动态地渲染内容。
关于 Props,有一个至关重要的原则必须牢记: Props 是 只读 的 ( Read-Only ) 。子组件绝不能尝试修改它接收到的 Props。所有 Props 都使得组件的输出仅依赖于输入,这使得组件的行为变得非常可预测。这种自顶向下的数据流动方式,通常被称为"单向数据流"。数据就像瀑布一样,从组件树的顶端流向末端,使得追踪数据的来源和变化变得非常简单,极大地降低了应用的复杂度。
除了传递自定义数据,React 还提供了一个特殊的 Prop: children。这个 Prop 的值不是通过属性赋值,而是通过组件的闭合标签之间的内容来决定的。它使得我们可以轻松地创建具有"插槽"功能的容器类组件。
设想一个 Card 组件,它需要一个统一的边框和阴影样式,但内部的内容是灵活多变的。
// Card.tsx
import React from 'react';
// React.PropsWithChildren 是一个辅助类型,它自动包含了 children prop
type CardProps = React.PropsWithChildren<{}>;
const Card = ({ children }: CardProps) => {
return (
<div style={{ border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}>
{children}
</div>
);
};
// App.tsx
const App = () => {
return (
<Card>
{/* 👇这里的所有内容都会被作为children Props 传递给Card 组件 */}
<h2>Article Title</h2>
<p>This is the content of the article inside the card.</p>
</Card>
);
};
通过 props.children,Card 组件就像一个相框,它定义了相框的样式,但里面的照片 (内容) 则由使用它的父组件来决定。这是实现组件组合和复用的强大模式。
使用 TypeScript 定义 Props 类型
随着应用规模的扩大,组件的 Props 可能会变得越来越复杂。如果我们不小心传递了错误类型的数据,或者遗漏了某个必需的 Prop,程序就可能在运行时出错。为了在开发阶段就避免这类问题,我们引入了 TypeScript。
为组件的 Props 添加类型定义,就像是为组件签署了一份"编程契约"。这份契约明确规定了该组件需要哪些 Props,以及每个 Prop 的数据类型是什么。这不仅能提供强大的编辑器自动补全和错误检查,还让代码本身成为了最好的文档。
在 TypeScript 中,我们通常使用 type 或 interface 关键字来定义 Props 的类型。对于组件 Props 而言,两者在功能上几乎可以互换,选择哪一个更多是团队的风格偏好。
让我们为一个更复杂的用户资料卡片组件 UserProfile 添加类型定义。
// UserProfile.tsx
type UserProfileProps = {
name: string;
age: number;
isVerified: boolean;
hobbies?: string[];// '?' 这是一个可选的 Prop
};
const UserProfile = (props: UserProfileProps) => {
const { name, age, isVerified, hobbies } = props;
return (
<div>
<div>
<h3>{name}, {isVerified ? ' ✔️' : ''}</h3>
<p>Age:{age}</p>
{hobbies && hobbies.length > 0 && (
<p>Hobbies: {hobbies.join(', ')}</p>
)}
</div>
</div>
);
};
// App.tsx
const App = () => {
return (
<UserProfile
name="John Doe"
age={30}
isVerified={true}
hobbies={['reading', 'coding']}
/>
);
};
在上述代码中,UserProfileProps 类型清晰地描述了 UserProfile 组件的"API"。它必须接收 name (string), age (number), isVerified (boolean) 三个 Prop,并且它们的类型分别是 string, number, boolean。同时,它还可以选择性地接收一个名为 hobbies 的字符串数组。
如果我们在使用 UserProfile 组件时违反了这个契约,TypeScript 编译器和我们的代码编辑器会立刻给出错误提示。例如:
// 错误: 属性 "age" 在类型中缺失
<UserProfile name="Jane" isVerified={false} />
// 错误: 不能将类型 "string" 分配给类型 "number"
<UserProfile name="Jane" age="25" isVerified={false} />
这种即时的反馈机制极大地提升了代码的健壮性和开发效率。
事件处理与合成事件系统
我们已经了解了数据如何通过 Props 从父组件流向子组件。但如果子组件需要将信息传递回父组件呢?比如,当用户点击子组件中的一个按钮时,父组件的状态需要发生改变。这种自下而上的通信,通常通过事件处理来实现。
在 React 中处理事件的方式与在原生 DOM 中非常相似,但有几个细微的差别:
- React 事件的命名采用驼峰式 (camelCase) ,而不是纯小写。例如,
onclick变为onClick。 - 我们传递的是一个函数作为事件处理程序,而不是一个字符串。
一个基本的事件处理如下所示:
const Button = () => {
const handleClick = () => {
alert('Button clicked!');
};
return (
<button onClick={handleClick}>
Click Me
</button>
);
};
这里,我们将 handleClick 函数作为 Prop 传递给了 <button> 元素的 onClick 属性。当用户点击按钮时,React 会调用这个函数。
要实现子组件到父组件的通信,核心思想就是将父组件中的函数作为 Prop 传递给子组件。父组件定义了行为 (做什么),子组件则决定了何时触发该行为 (何时调用该函数)。
让我们构建一个场景: 父组件 Dashboard 需要知道其子组件 LoginButton 何时被点击。
// LoginButton.tsx (子组件)
type LoginButtonProps = {
// 我们定义一个 onLoginClick 的 Prop,它的类型是一个不接收参数、无返回值的函数
onLoginClick: () => void;
};
const LoginButton = ({ onLoginClick }: LoginButtonProps) => {
// 当按钮被点击时,调用从父组件传来的 onLoginClick 函数
return <button onClick={onLoginClick}>Login</button>;
};
// Dashboard.tsx (父组件)
const Dashboard = () => {
const handleUserLogin = () => {
console.log('User is trying to log in from the Dashboard!');
// 在这里可以处理登录逻辑,比如更新父组件的状态
};
return (
<div>
<h1>Welcome to the Dashboard</h1>
{/* 将 handleUserLogin 函数作为 Prop 传递给子组件 */}
<LoginButton onLoginClick={handleUserLogin} />
</div>
);
};
通过这种模式,LoginButton 组件保持了其通用性,它只负责渲染一个按钮并报告点击事件,而不关心点击后具体会发生什么。所有的业务逻辑都保留在了父组件 Dashboard 中,实现了清晰的职责分离。
值得一提的是,我们传递给 onClick 等事件处理程序的事件对象 e,并不是原生的浏览器事件对象,而是一个合成事件 (SyntheticEvent) 对象。这是 React 对原生事件的一个跨浏览器包装器。它抹平了不同浏览器在事件在系统上的差异,使得我们写的事件处理代码能够在所有的浏览器中表现得一致,无需担心兼容性问题。
状态管理入门: useState Hook 详解
Props 是从外部传入且不可变的,而 State 则是组件内部自己管理的数据,并且它是可变的。在函数式组件中,我们用来赋予组件状态能力的工具,就是 React 最基础也最重要的 Hook 之一: useState。 useState 的调用本身非常简单,它接收一个参数作为状态的初始值,然后返回一个包含两个元素的数组。我们通常使用 JavaScript 的数组解构语法来接收这两个值:
import { useState } from 'react';
const Counter = () => {
// 1. 调用 useState,传入初始值 0
// 2. 解构返回的数组
const [count, setCount] = useState(0);
// ...
};
让我们来仔细解读解构出来的这两个成员:
count: 这是状态变量。它是在每次组件渲染时,持有当前状态值的常量。在上面的例子中,它第一次渲染时的值是我们传入的初始值0。setCount: 这是更新函数。它是我们用来改变count状态的唯一途径。直接修改count的值 (例如count = count + 1) 是无效的,并且严重违反了 React 的原则。
当我们调用更新函数 (如 setCount(1)) 时,React 会做两件重要的事情:
- 它会计划一次对状态的更新,将新的值保存起来。
- 它会触发该组件的一次重新渲染 (re-render)。
在下一次渲染发生时,useState 会返回更新后的最新状态值。正是这个"状态更新 → 触发重新渲染 → 使用新状态渲染 UI"的循环,构成了 React 动态交互的核心。
import { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
const handleIncrement = () => {
// 调用更新函数,传入新的状态值
setCount(count + 1);
};
return (
<div>
<p>Current count is: {count}</p>
{/* 点击按钮时,会触发状态更新和组件重新渲染 */}
<button onClick={handleIncrement}>Increment</button>
</div>
);
};
在使用 useState 时,一个核心原则是状态的不可变性 (Immutability) 。对于对象或数组这样的引用类型,我们不应该直接修改它们内部的属性,而应该总是创建一个新的对象或数组来替换旧的。这是因为 React 通过浅比较来判断状态是否发生了变化。如果你只是修改了原对象的属性,对象的引用地址并未改变,React 可能会认为状态没有变化,从而跳过重渲染。
错误的做法 (直接修改) ❌:
const [user, setUser] = useState({ name: 'Alice', age: 25 });
const handleAgeIncrement = () => {
user.age += 1; // 直接修改了原对象
setUser(user); // 传入的还是旧的引用,React 可能不会更新
};
正确的做法 (创建新对象) ✅:
const [user, setUser] = useState({ name: 'Alice', age: 25 });
const handleAgeIncrement = () => {
// 使用展开语法(...)创建一个新对象,并覆盖 age 属性
const newUser = { ...user, age: user.age + 1 };
setUser(newUser);
};
此外,更新函数还支持接收一个函数作为参数,这种形式被称为函数式更新。这个函数会接收到前一个状态作为参数,并返回新的状态。当新的状态依赖于旧的状态时,使用函数式更新是更安全、更推荐的做法,它可以避免在快速连续的更新中由于闭包导致的状态陈旧问题。
// 当新状态依赖于旧状态时,推荐使用函数式更新
const handleIncrementByTwo = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};
条件渲染与列表渲染 (key 的重要性)
现在我们的组件已经拥有了可以变化的状态,下一步就是根据这些状态来动态地决定界面应该呈现什么内容,这就是动态渲染,它主要分为两种场景: 条件渲染和列表渲染。
条件渲染:顾名思义,就是根据不同的条件来渲染不同的 JSX。由于 JSX 本身就是 JavaScript,我们可以自如地运用 JavaScript 中的条件控制语句。
在 JSX 中,最常用的是三元运算符 ( ? : ) 和逻辑与运算符 ( && ) 。
三元运算符非常适合处理 if-else 这样的二选一场景。例如,根据用户是否登录,显示不同的信息。
const AuthStatus = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
return (
<div>
{isLoggedIn ? (
<p>Welcome back!</p>
) : (
<button onClick={() => setIsLoggedIn(true)}>Please log in</button>
)}
</div>
);
};
而逻辑与 && 运算符则是一个巧妙的捷径,用于处理"如果条件为真,则渲染某个元素,否则什么都不渲染"的场景。
const Mailbox = ({ unreadMessages }) => {
return (
<div>
<h1>Hello!</h1>
{unreadMessages.length > 0 && (
<h2>
You have {unreadMessages.length} unread messages.
</h2>
)}
</div>
);
};
当 unreadMessages.length > 0 为 true 时,表达式会返回 && 右侧的 <h2> 元素;如果为 false,则表达式直接返回 false,React 不会渲染任何内容。
列表渲染: 则是将数组中的每一项数据转换并渲染为一组 UI 元素。在 JavaScript 中,将数组转换为另一个数组最自然的方式就是使用 Array.prototype.map() 方法。React 中的列表渲染正是利用了这一点。
假设我们有一个待办事项数组,需要将它们渲染成一个列表:
const todos = [
{ id: 't1', text: 'Learn React' },
{ id: 't2', text: 'Build a project' },
{ id: 't3', text: 'Deploy the project' },
];
const TodoList = () => {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text}
</li>
))}
</ul>
);
};
在上面的代码中,请特别注意 <li> 元素上的 key 属性。这是列表渲染中一个至关重要且不可或缺的部分。
key 的重要性
key 是 React 用来识别列表中各个元素的身份标识。当列表数据发生变化 (例如 增、删、改、排序) 时,React 要重新创建、删除、移动 DOM 元素,这时候它必须能够识别出哪些元素是原来就有的,哪些是新的,哪些只是移动了位置。
- 一个稳定且唯一的
key能帮助 React 最大限度地复用已有的 DOM 元素和组件实例,从而极大地提升性能。 - 如果不提供
key,React 会在控制台给出警告,并且在列表更新时可能会出现不可预测的 UI bug 和性能问题。 - 使用数组的索引 (
index) 作为key是一种常见的反模式,应当极力避免。因为当列表项的顺序发生改变时 (例如在数组开头插入一个新元素),所有元素的key都会改变,这会让 React 认为所有的内容都发生了变化,从而导致不必要的重新渲染,甚至丢失子组件内部的状态 (如输入框的内容)。
最理想的
key值,是数据项本身就带有的、独一无二且随时间变化的字符串或数字,比如数据库中的id。
第三章:深入 Hooks 与生命周期
在前两章中,我们聚焦于一个组件的"纯粹"职责: 接收 Props,管理 State,并根据它们返回一段描述 UI 的 JSX。这个过程是封闭且可预测的。然而,在真实的应用中,组件常常需要与"外部世界"进行通信——它可能需要从服务端获取数据,需要操作 DOM,或者需要设置定时器和订阅事件。这些与组件渲染主流程无关的操作,我们称之为副作用 (Side Effects) 。本章将深入探讨用于管理这些副作用的核心 Hook —— useEffect,以及其他几个功能强大的 Hooks。
副作用处理: useEffect Hook 详解 (挂载、更新、卸载)
useEffect 是 React 提供给我们的一个"逃生舱口",它允许我们在函数式组件中执行副作用操作。其设计的核心理念是将执行逻辑与渲染逻辑分离开来,并确保这些副作用操作不会在渲染期间阻塞浏览器,而是在组件完成渲染之后再执行。
一个 useEffect 的基本结构包含一个回调函数和可选的一个依赖项数组:
useEffect(() => {
// 副作用逻辑 / / / 代码块 / / /
// 可选: 返回一个清理函数
return () => { /* 清理逻辑 */ };
}, [/* 依赖项数组 */]);
通过控制第二个参数——依赖项数组,我们可以精确地控制 useEffect 在组件生命周期中的执行行为,分别对应于挂载、更新和卸载。
模拟组件挂载 (Mount)
当我们不提供第二个参数,或者传入一个空数组 [] 且不再有变化时,我们可以让 useEffect 只在组件第一次渲染到屏幕上之后执行一次,且之后不再重复执行。这非常适合执行一次性的初始化操作。这等同于 Class 组件中的 componentDidMount 生命周期。
这是执行一次性获取数据的理想场所,例如: 从 API 获取初始数据,或者设置一个全局的事件监听器。
import { useState, useEffect } from 'react';
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
console.log('组件已挂载,开始获取数据...');
const fetchData = async () => {
setIsLoading(true);
// 模拟从 API 获取用户数据的异步函数
const data = await fetchUser(userId);
setUser(data);
setIsLoading(false);
};
fetchData();
// 依赖项数组为空,此 effect 仅在初次渲染后运行一次
}, []);
if (isLoading) {
return <div>Loading...</div>;
}
return <h1>{user.name}</h1>;
};
模拟组件更新 (Update)
如果当我们向依赖项数组中传入一些值,useEffect 将在每次渲染后都执行。如果我们提供了依赖项数组,则只有在数组中的值发生变化时,effect 才会再次执行。这部分我们将在下一节详述。
模拟组件卸载 (Unmount) 与清理作用
副作用常常会产生一些需要"清理"的后续工作,以避免内存泄漏或不必要的行为。例如,如果我们设置了一个定时器,或者订阅了一个事件,就需要在组件被销毁时取消定时器或订阅。
useEffect 的回调函数返回一个函数时,这个返回的函数就被视为清理函数。React 会在组件卸载时 useEffect,以及在下一次 effect 即将执行之前,调用这个清理函数。这等同于Class组件中的componentWillUnmount。
import { useState, useEffect } from 'react';
const Timer = () => {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
console.log('Timer started');
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// 返回一个清理函数,在组件卸载时执行
return () => {
console.log('Timer cleared');
clearInterval(intervalId); // 在卸载时清除定时器
};
}, []);
return <div>Timer: {seconds}s</div>;
};
在这个例子中,useEffect 在组件挂载时启动了一个定时器,并返回了一个清理函数来清除它。当 Timer 组件被移除时,React 会调用这个清理函数,防止定时器继续在后台运行,造成内存泄漏。
useEffect 的依赖项数组与常见陷阱
依赖项数组是 useEffect 的"指挥官",它精确地告诉 React: "请在这些值发生变化时,才重新执行我的副作用逻辑"。正确地使用依赖项数组是编写健壮、高效的 React 组件的关键。
一次渲染时的每个值,在每次组件渲染后都会被 React 进行一次浅比较 (使用 Object.is)。只有当至少一个值与上一次渲染时的值不同时,effect 才会重新运行。
让我们回顾一下它的三种行为模式:
- 不提供数组:
useEffect(() => { ... })-> 每次渲染后都执行。 - 提供空数组:
useEffect(() => { ... }, [])-> 仅在第一次渲染后执行。 - 提供含值的数组:
useEffect(() => { ... }, [propA, stateB])-> 第一次渲染后执行,并且在propA或stateB发生变化后的每次渲染中再次执行。
核心原则: 依赖项数组应该包含所有在 effect 函数内部被引用的、且来自于组件作用域的变量 (如 props, state, 或自定义函数)。
忽略这个原则会导致一些非常隐蔽和难以调试的 bug,其中最常见的有两个:
陷阱一: 无限循环
当你在一个 effect 中更新了某个 state,而这个 state 又恰好是该 effect 的依赖项时,就会产生一个无限循环。
错误示例 ❌:
const [count, setCount] = useState(0); useEffect(() => { // 每次 count 变化,都会执行这里,然后又导致 count 变化... setCount(count + 1); }, [count]); // 依赖于 count其执行流程是:
count变化 -> 触发渲染 -> 渲染后执行effect->setCount更新count->count变化... 如此往复。
陷阱二: 陈旧的闭包
这是更隐蔽的一个问题。如果你的 effect 引用了某个 state 或 prop,但你忘记将它加入依赖项数组,那么 effect 函数将"捕获"该变量在第一次渲染时的值,并且永远不会获取到它最新的值。
错误示例 ❌:
const ChatRoom = ({ roomId }) => { useEffect(() => { console.log(`Connecting to room ${roomId}...`); // ... 连接逻辑 ... return () => { console.log(`Disconnecting from room ${roomId}...`); // 这里的 roomId 永远是旧的 }; }, []); // 忘记将 roomId 加入依赖项 };在这个例子中,如果
roomId这个prop发生了变化,组件虽然会重新渲染,但由于依赖项是[],旧的effect清理函数依然会使用第一次捕获的roomId值,导致错误地断开了错误的房间连接。
幸运的是,我们不必手动检查依赖项。官方的 eslint-plugin-react-hooks 插件能够自动分析你的 useEffect 代码,并以警告或错误的形式提示你添加缺失的依赖项,或移除多余的依赖项。强烈建议在所有项目中启用此 ESLint 规则。
使用 useRef 访问 DOM 和存储可变值
useState 和 useEffect 满足了我们大部分的需求,但还有一类特殊场景: 当我们需要一个值在多次渲染之间保持持久,但它的改变不应该触发组件的重新渲染时。为了应对这个场景,React 提供了 useRef Hook。
useRef 返回一个可变的 ref 对象,该对象只有一个 .current 属性。你可以将任何值存放在 myRef.current 中。
useRef 主要有两个用途:
访问 DOM 元素
这是 useRef 最常见的用途。在某些情况下,我们确实需要跳出 React 的声明式世界,去直接操作一个底层的 DOM 节点,例如: 管理表单焦点的切换、触发动画、或者集成一个需要传入 DOM 节点的第三方库。
操作步骤如下:
- 使用
useRef创建一个ref对象。 - 通过 JSX 的
ref属性,将这个ref对象附加到目标 DOM 元素上。 - 当组件渲染完成后,
ref对象的.current属性就会指向这个 DOM 节点。
import { useRef, useEffect } from 'react';
const FocusInput = () => {
// 1. 创建一个 ref 来存放 input 元素
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// 3. 在 effect 中访问 ref.current,确保 DOM 已挂载
// ?. 是可选链操作符,防止 inputRef.current 为 null 时报错
inputRef.current?.focus();
}, []);
return (
// 2. 将 ref 附加到 input 元素
<input ref={inputRef} type="text" placeholder="I will be focused" />
);
};
重要提示: 应该在 useEffect 或事件处理函数中访问 .current,以确保 DOM 节点已经被创建并附加。
存储任意可变值 (实例变量)
useRef 的 .current 属性就像是 Class 组件中的一个实例属性。它是一个"通用容器",可以在组件的整个生命周期内持久保存任何值,且对它的修改不会触发重新渲染。
这在需要存储定时器 ID、WebSocket 连接实例或任何与渲染无关的数据时非常有用。
import { useRef, useState, useEffect } from 'react';
const DebouncedSearch = () => {
const [query, setQuery] = useState('');
// 使用 ref 来存储定时器 ID
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
// 清除上一个定时器
clearTimeout(timeoutRef.current);
// 设置一个新的定时器
timeoutRef.current = setTimeout(() => {
// 这里的逻辑只会在用户停止输入 500ms 后执行
console.log(`Searching for: ${query}`);
}, 500);
// 返回清理函数,在组件卸载或 query 变化时清除定时器
return () => clearTimeout(timeoutRef.current);
}, [query]); // 依赖于 query
return <input value={query} onChange={e => setQuery(e.target.value)} />;
};
在这个防抖搜索的例子中,我们用 useRef 跨渲染周期地"记住"了定时器 ID,而无需在每次 ID 变化时都触发不必要的组件重新刷新。
TypeScript 与 Hooks 的类型推断与显式声明
将 TypeScript 与 Hooks 结合使用,可以为我们的组件状态和副作用逻辑提供强大的类型安全保障。
useState 的类型
在大多数情况下,TypeScript 能够根据传入 useState 的初始值推断出状态的类型,我们无需额外操作。
// TS 推断出 count 是 number 类型
const [count, setCount] = useState(0);
// TS 推断出 name 是 string 类型
const [name, setName] = useState('React');
然而,当一个状态的初始值是 null,或者它可以是多种类型之一时,我们就需要显式地通过泛型来声明它的类型。
type User = { id: string; name: string; };
// 状态可以是 User 对象,或者在加载完成前是 null
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser().then(data => {
// TS 知道 data 是 User 类型,否则 TS 会报错
setUser(data);
// setUser(1); // 错误: 只能接受 User 类型或 null,否则 TS 会报错
});
}, []);
useRef 的类型
useRef 提供类型也是遵循相似的逻辑。
-
用于 DOM 元素时: 我们需要指定它将附加到的 HTML 元素的具体类型,并将初始值设为
null。-
// ref 将指向一个 HTMLInputElement 元素 const inputRef = useRef<HTMLInputElement>(null);
-
-
用于存储可变值时: 我们只需在泛型中声明该值的类型
-
// ref 将用于存储一个数字类型的定时器 ID const timerRef = useRef<number | null>(null);
-
通过为 Hooks 提供准确的类型,我们不仅能在编码阶段捕捉到潜在的错误,还能让代码的意图更加清晰,可读性和可维护性都得到显著提升。
第二部分:进阶篇 - 探索 React 19 新范式
第四章:React 19 核心特性
Actions: 表单交互的革命
长久以来,处理 Web 表单一直是一项繁琐的任务。开发者需要手动管理 loading 状态、错误信息、成功反馈,并用 e.preventDefault() 来阻止浏览器的默认行为。React 19 引入的 Actions 彻底颠覆了这一传统模式,将表单的异步交互与状态管理无缝集成到框架底层。
使用 <form> 的 action 属性简化数据提交
在 React 19 中,我们可以直接将一个异步函数 (即 Action) 传递给原生 <form> 元素的 action 属性。当你提交这个表单时,React 会自动处理事件,处理表单数据的序列化 (FormData),并调用你提供的 Action 函数。这意味着,我们可以告别 onSubmit 事件处理函数和 preventDefault()。
// 传统的表单处理方式
const OldForm = () => {
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
// ... 手动提交逻辑
};
return <form onSubmit={handleSubmit}>...</form>;
};
// React 19 的新方式
const NewForm = () => {
const submitAction = async (formData: FormData) => {
const name = formData.get('name')
console.log(`Submitting name:${name}`);
// ... 异步提交逻辑
await api.post('/users', { name });
};
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit">Submit</button>
</form>
);
};
这种方式不仅代码更简洁,语义也更清晰: 这个表单的"行为" (action) 就是执行 submitAction 函数。
服务端 Actions 与客户端 Actions
Action 可以是定义在客户端的普通异步函数 (客户端 Action),也可以是一个由 use server 指令、在服务端中定义的函数 (服务端 Action)。服务端 Actions 是 React 19 的核心亮点之一,它允许我们将代码以以前所有的方式集成,实现无缝的 RPC 调用。在本课程中,我们将主要聚焦于客户端 Actions 的应用。
使用 useFormStatus 处理 Pending/Error/Success 状态
Actions 的真正威力在于处理 API 请求流转时的各种状态。useFormStatus (在早期版本中称为 useFormState) 是专门为此设计的 Hook。
这个 Hook 专门为一个 pending 状态接收一个 Action 函数和初始状态,然后返回一个包含了当前状态、可调用的 action 函数以及 pending 状态的数组。
import { useFormStatus } from 'react';
const AddToCartForm = ({ productId }) => {
// 定义一个action,他接收前一个状态和formData
const addToCartAction = async (prevState, formData) => {
const quantity = formData.get('quantity');
const result = await api.addToCart(productId, quantity);
if(result.success){
// 返回新的成功状态
return { message:'Item added to cart!'}
}else{
// 返回错误的状态
return { message:`ERROR:${resule.error}`}
}
},
);
// 使用useActionState
const [state, submitAction, isPending] = useActionState(addToCartAction )
return (
<form action={submitAction}>
<input type="number" name="quantity" defaultValue="1" />
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add to Cart'}
</button>
{state?.message&& <p>Error: {state.error}</p>}
</form>
);
};
观察上述代码,useActionState 极大地简化了状态管理。我们不再需要手动创建 useState 来管理 isLoading 或错误信息,React 已经为我们处理好了一切:
- 当表单提交时,
isPending自动变为true。 - 当
Action函数执行完毕后,isPending自动变回false。 Action函数的返回值会成为state的新值,从而触发 UI 更新。
使用 useFormStatus 优化用户体验
useActionState 管理的是整个表单的状态,但有时我们希望表单内的某个子组件 (比如提交按钮) 能够独立地响应表单的提交状态,而无需通过 props 逐层传递 isPending。useFormStatus Hook 正是为了解决这个问题而诞生的。
它只能嵌套在 <form> 组件的子组件中使用,并且会返回其所在表单的当前状态信息,包括 pending, data, method 等。
import { useFormStatus } from 'react-dom';
// 独立的、可复用的提交按钮组件
const SubmitButton = () => {
// useFormStatus 获取父级 <form> 的状态
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
};
// 在表单中使用
const MyForm = () => {
const submitAction = async (formData) => { /* ... */ };
return (
<form action={submitAction}>
<input type="text" name="field" />
<SubmitButton />
</form>
);
};
通过 useFormStatus,我们创建了一个高度解耦且可复用的 SubmitButton 组件。它能自动响应任何包裹它的 <form> 的提交状态,代码组织更加清晰。
并发与 use Hook
并发 (Concurrency) 是 React 近年来最重要的底层升级,它允许 React 在渲染过程中处理多个状态更新,并根据优先级中断或恢复渲染任务。在 React 19 中,并发特性通过一个全新的、极其强大的 use Hook 得到了更直观的体现。
use Hook: 在渲染中读取 Promise 和 Context
use Hook 是一个可以在渲染期间"解包"数据源的 Hook。目前它支持两种数据源: Promise 和 Context。
与其他的 Hooks 不同,use 可以在条件语句、循环或普通函数中调用,这赋予了它前所未有的灵活性。
当 use 被用于一个 Promise 时,他会做一个神奇的事情:
- 如果Promise正在
Pending,它会"抛出"这个 Promise: - 这个"抛出"的行为会被最近的
<Suspense>边界捕获,并显示 fallback UI。 - 当 Promise
resolve后,React 会重新尝试渲染该组件,此时useHook 会返回 Promise 的结果值。 - 如果 Promise
reject,错误会被最近的<ErrorBoundary>捕获。
结合 Suspense 实现优雅的数据加载 UI
use 和 <Suspense> 的结合,是 React 官方推荐的、用于在客户端获取数据的方式,它彻底改变了"Fetch-on-render" 的模式。
import { Suspense, use } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
// 生产获取数据的函数,返回一个 Promise
const fetchMessage = () => {
return new Promise(resolve => setTimeout(() => resolve("Hello from the future!"), 2000));
};
// Message 组件在渲染时"读取" Promise
const Message = ({ messagePromise }) => {
const message = use(messagePromise);
return <p>Message:{message}</p>;
};
// App 组件管理 Promise 的创建和 Suspense 边界
const App = () => {
// 在渲染开始前就获取 Promise
const messagePromise = fetchMessage();
return (
<div>
<h1>My App</h1>
<ErrorBoundary fallback={<p>Oops, something went wrong.</p>}>
<Suspense fallback={<p>Loading...</p>}>
<Message messagePromise={messagePromise} />
</Suspense>
</ErrorBoundary>
</div>
);
};
这种模式被称为 "Render-as-you-fetch" 。我们不再需要在 useEffect 中获取数据,也无需手动管理 loading 状态。组件则声明式地等待数据就位。这避免了网络请求的瀑布流问题,使得获取数据的 UI 逻辑变得异常简洁和健壮。
其他新特性
除了 Actions 和 use Hook,React 19 还带来了一系列旨在提升开发体验和应用性能的新功能。
useOptimistic: 实现乐观更新,提升交互体验
在与服务器交互时,为了让用户感觉更"快",我们常常使用乐观更新 (Optimistic Updates) 技术。即在操作的请求还未得到服务器确认时,就先假设它会成功,并立即更新 UI。
useOptimistic Hook 就是为了实现这种模式而设计的。它接收一个当前状态,并返回一个该状态的"乐观"副本以及一个更新函数。在异步操作期间,你可以调用更新函数来设置一个临时的、乐观的状态值。当异步操作结束后,无论是成功还是失败,React 都会自动将 UI 回滚到原始的、与服务器一致的状态。
Asset Loading: 通过 Suspense 管理资源加载
在过去,我们常常会遇到因样式闪烁(FOUC)或字体未加载完成而导致的布局抖动。React 19 将样式、字体、脚本等资源的加载也整合进了 Suspense 机制。
现在,React 能够自动检测到组件渲染所依赖的样式表或字体,并在这些资源加载完成之前,暂停渲染并显示 Suspense 的 fallback UI。这从根本上保证了用户看到的永远是内容与样式完全匹配的、完整的界面,极大地提升了用户体验的稳定性。
ref 作为 Prop: 简化 forwardRef
forwardRef 是 React 中用于将 ref 从父组件转发到子组件内部 DOM 节点的 API,但它的写法相对冗长和不直观。在 React 19 中,这个过程被大大简化了。现在,ref 可以像普通 prop 一样直接传递给函数式组件,无需再用 forwardRef 进行包装。
// 旧方式
const MyInput = React.forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});
// React 19 新方式
const MyInput = (props) => {
return <input ref={props.ref} {...props} />;
};
// 使用时
const App = () => {
const inputRef = useRef();
return <MyInput ref={inputRef} />; // 直接传递 ref
};
第五章:React Compiler (理念篇)
在 React 的世界里,性能优化一直是一个重要课题。当应用变得复杂,组件树层级加深时,不必要的重渲染会成为性能的瓶颈。为了应对这个问题,React 手动提供了 useMemo, useCallback 等 API。但长期以来,开发者从这种手动优化的困境中解放出来。React Compiler 应运而生,正是为了将开发者从这种手动优化的困境中解放出来。
手动优化的痛点: useMemo, useCallback 的困境
让我们先回顾一下为何需要手动优化。在 React 中,当一个父组件的状态或 Props 发生变化时,它会默认重新渲染其所有子组件,即使子组件的 Props 真正未发生变化。为了避免这种浪费,我们可以使用 React.memo 来包裹子组件,使其只有在 Props 真正发生变化时才重新渲染。
然而,当 Props 发生变化时,比如传递一个对象或函数,那么每次父组件渲染时,都会创建一个新的对象或函数,导致子组件的 Props 发生了变化,从而导致次优化失效。
为了解决这个问题,我们被迫引入了 useMemo 来缓存对象或复杂计算的结果,以及 useCallback 来缓存函数。
const ParentComponent = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// 必须使用 useMemo,否则 ParentComponent 每次渲染,user 对象都会被重建
const user = useMemo(() => ({
name: 'Alice',
count: count,
}), [count]); // 只有 count 变化时才重新创建 user 对象
// 必须使用 useCallback,否则 ParentComponent 每次渲染,handleClick 函数都会被重建
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]); // 只有 count 变化时才重新创建 handleClick 函数
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
{/* MemoizedChild 只有在user 或者 handleClick 变化的时候才重新渲染 */}
<MemoizedChild user={user} onClick={handleClick} />
</div>
);
};
这种手动优化的方式带来了诸多痛点:
-
代码冗余与心智负担:
useMemo和useCallback的大量出现,让代码变得冗长且难以维护。你必须仔细阅读代码,才能判断哪些值被缓存了,以及它们的依赖项是什么。 -
依赖项地狱: 开发者必须时刻思考并管理依赖项数组,容易出现因遗漏依赖而导致的 bug,或添加了不必要的依赖项,导致缓存失效,失去优化的意义。
-
偏离声明式初心: React 的核心魅力在于其声明式编程,我们本应只关心"UI 是什么样",但手动优化却迫使我们不断地问自己: "这个值会变吗?请记住这个值","请不要重新创建这个函数",这在一定程度上违背了 React 的设计哲学。
React Compiler ("Forget") 的设计哲学与目标
为了从根本上解决这个问题,React 团队推出了一个底层的解决方案 —— React Compiler (也被称为 Forget) 。
"Forget" 这个名字精准地传达了它的设计哲学: 它的目标是让开发者可以"忘记"手动性能优化这件事。
React Compiler 是最直白、最简洁的 JavaScript 和 React 代码,而由高性能 React 来的 (Reactive by default) 工作。它旨在将 React 从一个需要开发者手动优化的库,转变为一个"默认就能用、能够自动进行精细化优化"的框架。
其主要目标包括:
-
自动记忆化 (Memoization) : 自动分析
useMemo,useCallback和useRef可以在多次渲染间复用的值、计算和组件,开箱即用。 -
提升开发体验: 将开发者从管理依赖项的苦海中解放出来,让代码回归业务逻辑本身,使其更易于编写、阅读和维护。
-
保障 JavaScript 语意: 编译器在优化时,会严格遵守 JavaScript 的语言规则,确保编译后的代码行为与原代码完全一致。
Compiler 如何实现自动记忆化 (Memoization)
React Compiler 并非一个简单的"查找并替换"工具,它是一个强大的静态分析工具。它在编译时就对代码进行深度分析,理解组件的渲染逻辑和数据依赖。
它的核心优势体现在:
- 深度依赖分析: 编译器会像一个经验丰富的 React 开发者一样阅读你的代码。但它比任何人都更彻底、更细致。它能够理解组件中复杂的状态、对象和函数之间的依赖关系。
- 更精确的依赖追踪: 它能精确地知道哪些变量、函数或对象的某个属性真正发生了变化,从而避免不必要的重渲染。
- 智能代码重写: 基于分析结果,编译器会识别出哪些计算、成本较高或作为 props 传递且在多渲染中可能保持不变的部分。然后,它会自动地、安全地将这些部分用缓存机制 (类似于
useMemo) 包裹起来。由于它拥有全局的分析视角,它生成的缓存策略比开发者手动编写的要精确得多。
本质上,React Compiler 将组件的渲染逻辑从手工优化转移到了自动优化。它通过在编译时进行一次性的、深入的分析,来换取运行时的高效以及开发时的简洁。
对现有代码库的影响与迁移策略
对于这样一个颠覆性的工具,开发者最关心的莫过于它对现有项目的影响。React Compiler 在设计上充分考虑了兼容性:
- 向后兼容: React Compiler 被设计为完全向后兼容的。它不会智能地理解并尊重代码中已有的
useMemo或useCallback。即它与现有的代码行为是一致的。 - 可选加入 ( Opt-in ) : 它是一个强制性的功能。你可以选择是否在你的项目中启用它,甚至可以配置为只对特定的组件或文件生效。
迁移到 React Compiler 的开发模式是一个平滑且渐进的过程:
- 逐步启用: 对于现有的大型项目,可以先在一些非核心或新增的功能模块中启用编译器,验证其效果和稳定性。
- 移除冗余优化: 在这个过程中,代码库会变得越来越整洁。你可以小心地移除掉手工写的
useMemo和useCallback,让编译器来做这些工作,而代码可以继续清晰、符合 React 最佳实践且保持性能不变。 - 逐渐消除依赖: 最终,编译器会成为默认的优化方式,而
useMemo和useCallback成为只有在极少数、编译器无法自动处理的边缘场景下才需要动用的"专家级"工具。对于绝大多数日常开发而言,我们可以彻底"忘记"它们的存在。
第六章:高级 Hooks 与状态管理
随着应用的日益复杂,简单的 useState 已经不足以应对所有的状态管理需求。组件之间的数据共享、复杂状态逻辑的流转,以及长期未响应的性能问题,都对我们提出了更高的要求。本章将深入 React 提供的更高级的状态管理工具,以及从组件中抽离状态的模式。
复杂状态逻辑: useReducer vs useState
当状态变得非常复杂,或者状态的下一个值依赖于前一个值的复杂计算时,useState 的更新逻辑就会分散在各个事件处理函数中,变得难以维护。
为了应对这种场景,React 提供了另一个内置的 Hook: useReducer。它借鉴了 Redux 的思想,是一种将状态更新逻辑进行中心化处理的模式。
useReducer 接收一个 reducer 函数和一个初始的 state,返回当前的 state 和一个 dispatch 函数。
-
Reducer函数: 这是一个纯函数,它接收当前的状态 (state) 和一个动作 (action) 对象作为参数,然后返回一个新的状态。 -
Action对象: 这是一个普通的 JavaScript 对象,通常包含一个type字段来描述操作类型,以及一个可选的payload字段来传递操作所需的数据。 -
Dispatch函数:通过dispatch(action)来"派发"一个动作,这会触发 React 调用你的reducer函数,用它返回的新状态来更新 UI。
让我们用一个经典的购物车计数器例子来看看:
// 使用useReducer来管理更复杂的state
import { useReducer } from 'react';
// 1. 定义初始状态
const initialState = { count: 0, lastAction: null };
// 2. 定义 action 的类型
type Action =
| { type: 'increment'; payload?: number }
| { type: 'decrement'; payload?: number }
| { type: 'reset' };
// 3. 编写 reducer 函数,集中处理所有状态变更逻辑
function reducer(state: typeof initialState, action: Action) {
switch (action.type) {
case 'increment':
return { count: state.count + (action.payload || 1), lastAction: 'increment' };
case 'decrement':
return { count: state.count - (action.payload || 1), lastAction: 'decrement' };
case 'reset':
return { count: 0, lastAction: 'reset' };
default:
throw new Error('Unknown action type');
}
}
const Counter = () => {
// 4. 使用 useReducer
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count} Last action: {state.lastAction}</p>
<button onClick={() => dispatch({ type: 'increment',payload:1 })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement', payload: 1 })}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
};
何时选择 useState ,何时选择 useReducer ?
- 当状态结构简单,且多个更新逻辑独立时,使用
useState。 - 当状态逻辑非常复杂,设计多个子值时;
- 当下一个状态严重依赖于前一个状态时;
- 当状态结构复杂(对象或数组嵌套深),且多个更新逻辑相互依赖时,使用
useReducer。 - 当你想将状态更新逻辑从组件中抽离,便于测试和复用,使用
useReducer。
在多人协作的大型项目中,useReducer 提供了更可预测和严格的状态流。
全局状态管理: useContext 与性能陷阱
当多个组件需要共享数据时(例如,当前登录的用户信息、全局的主题设置),逐层传递 Props 会变得非常繁琐和脆弱,这种现象被称为 "Props Drilling" (属性钻探)。
为了解决这个问题,React 提供了 Context API,它允许我们建立一个全局的"数据广播",任何在这个"广播"覆盖范围内的子组件,都可以直接订阅和访问这份数据。
Context API 主要由三部分构成:
createContext: 使用createContext创建一个Context对象。Provider: 使用MyContext.Provider包裹组件树,将需要共享的数据通过value属性广播出去。useContext: 在任何一个子组件中,通过useContext(MyContext)Hook 来订阅这份数据。
// ThemeContext.tsx
import { createContext, useContext, useState } from 'react';
// 1. 创建一个 Context
const ThemeContext = createContext<'light' | 'dark'>('light');
// App.tsx
const App = () => {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
// 2. 使用 Provider 提供数据
<ThemeContext.Provider value={theme}>
<Toolbar/>
<button
onToggle={() => setTheme(theme === 'light' ? 'dark' : 'light')}
/>
</ThemeContext.Provider>
);
};
// Toolbar.tsx (无需传递 theme)
const Toolbar = ({ onToggle }) => {
return <ThemeButton onToggle={onToggle} />;
};
// ThemeButton.tsx (最终消费数据的子组件)
const ThemeButton = ({ onToggle }) => {
// 3. 直接通过 useContext 获取 theme
const theme = useContext(ThemeContext);
return (
<button
style={{ background: theme === 'dark' ? '#333' : '#FFF' }}
onClick={onToggle}
>
I am a themed button
</button>
);
};
性能陷阱
Context 有一个重要的性能问题: 只要 Provider 的 value 发生变化,所有消费了这个 Context 的子组件都会被强制重新渲染。即使它们只关心 value 对象中的一小部分数据,只要 value 是一个新的引用,它们就会被重新渲染。
如果 value 是一个对象或数组,并且每次父组件渲染时都创建一个新的 Context,这可能会导致不必要的性能问题。
解决这个问题的方法包括:
- 将
Context拆分成更小的粒度,只共享必要的数据。 - 使用
useMemo缓存value对象,确保只有在真正需要时才创建新的引用。 - 使用更高效的第三方状态管理库 (如 Zustand, Jotai),它们内部优化了 Context 的订阅机制。
手动性能优化: React.memo, useMemo, useCallback 的正确使用场景
优化性能的核心思想是: 减少不必要的重渲染。在 React 中,我们有三个主要工具:
React.memo: 这是一个高阶组件,用于包裹一个组件。它会对组件的 Props 进行浅比较,只有在 Props 真正发生变化时才会重新渲染被包裹的组件。这是防止因父组件渲染而导致子组件不必要渲染的主要工具。useMemo: 这个 Hook 用于缓存一个计算结果或对象。它接收一个函数和一个依赖项数组,只有当依赖项发生变化时,才会重新执行函数并返回新的结果。useCallback: 这个 Hook 用于缓存一个函数。它接收一个函数和一个依赖项数组,只有当依赖项发生变化时,才会返回新的函数引用。
最佳实践:
-
当一个被
React.memo包裹的子组件的 Props 是一个对象或函数时,使用useCallback来保证函数引用稳定,不被频繁创建。这些优化不是"银弹",也不应该被滥用。只有在你通过 React Dev Tools Profiler 工具发现了明确的性能问题时,才应该考虑使用它们。过早的优化往往是万恶之源。
自定义 Hooks: 封装逻辑与实现复用 (含 TS 泛型)
Hooks 的真正威力在于它们的可组合性。我们可以将组件中复用的状态逻辑(而非 UI 逻辑)抽离出来,封装成自定义 Hook。
自定义 Hook 本质上就是一个普通函数,只是它的名字必须以 use 开头,并且在函数内部可以调用其他的 Hooks。我们可以创建一个 useLocalStorage Hook,来封装从 localStorage 中读取和写入数据的逻辑。
import { useState, useEffect } from 'react';
// 一个泛型的 useLocalStorage Hook
function useLocalStorage<T>(key: string, initialValue: T): [T, React.Dispatch<React.SetStateAction<T>>] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// 使用示例
const Profile = () => {
const [name, setName] = useLocalStorage<string>('username', 'Guest');
const [age, setAge] = useLocalStorage<number>('age', 18);
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<input type="number" value={age} onChange={e => setAge(Number(e.target.value))} />
</div>
);
};
这个 useLocalStorage Hook 封装了与 localStorage 交互的所有逻辑,并且通过 TypeScript 泛型,使其可以适用于任何数据类型。我们可以在任何组件中轻松地复用它,而无需重复编写 useState 和 useEffect 的代码。
第七章:TypeScript 高级应用
TypeScript 为 React 开发提供了强大的类型安全保障。本章将深入探讨一些更高级的类型和类型工具,构建更灵活、健壮且易于维护的 React 组件和 Hooks。
泛型组件与泛型 Hooks
我们已经在自定义 Hook 中见识了泛型的威力,同样的能力也可以被应用在泛型组件上。泛型组件允许我们在定义组件时不预先写死其处理的数据类型,而是由使用该组件的父组件来指定。
这在创建可复用的列表、表格、下拉菜单等 UI 模式时非常有用。例如,我们可以创建一个可以渲染任何类型数据数组的 List 组件。
import React from 'react';
// 定义泛型 Props 类型
// <T> 是一个类型变量,在使用组件时会被具体类型替换
type ListProps<T> = {
items: T[]; // items 是一个 T 类型的数组
renderItem: (item: T) => React.ReactNode; // renderItem 函数接收一个 T 类型的参数
};
// 定义泛型组件 List<T>
export function List<T>({ items, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
// 这里 item 的类型被正确推断为 T
<li key={index}>{renderItem(item)}</li>
))}
</ul>
);
}
type User = { id: number; name: string };
const users: User[] = [{ id: 1, name: 'Alice' }];
type Product = { sku: string; price: number };
const products: Product[] = [{ sku: 'X123', price: 99.9 }];
const App = () => (
<>
{/* 此时,T 被指定为 User */}
<List<User>
items={users}
renderItem={(user) => <span>{user.name}</span>}
/>
{/* 此时,T 被指定为 Product */}
<List<Product>
items={products}
renderItem={(product) => <span>{product.sku}: ${product.price}</span>}
/>
</>
);
通过泛型,我们创建了一个高度抽象且完全类型安全的 List 组件,它将"渲染什么"的逻辑 (renderItem) 交给了调用者,而自己只负责"如何渲染"(列表结构)的逻辑。
React 事件对象的精确类型
在事件处理函数中,为事件对象 e 提供精确的类型,可以帮助我们安全地访问特定于该事件的属性(如 e.target.value),并获得编辑器的智能提示。@types/react 包为我们预定义了丰富的事件类型。
放弃使用宽泛的 any 或 React.SyntheticEvent,转而使用更具体的类型是一个好习惯:
- 鼠标事件:
React.MouseEvent<HTMLElement> - 表单元素变化事件:
React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> - 表单提交事件:
React.FormEvent<HTMLFormElement> - 焦点事件:
React.FocusEvent<HTMLElement> - 键盘事件:
React.KeyboardEvent<HTMLElement>
示例代码
// 鼠标事件示例
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.currentTarget.tagName); // 类型为 HTMLButtonElement
};
// 输入框变化事件示例
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value); // 类型被正确推断为 string
};
// 表单提交事件示例
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log("Form submitted");
};
使用精确的事件类型,可以让我们的代码更加健壮,有效防止因访问不存在的属性而导致的运行时错误。
结合 Zod 进行运行时类型校验
TypeScript 的类型系统在编译时为我们提供了强大的保护,但这份保护在应用的"边界"处会失效——尤其是当我们从外部 API 接收数据时。API 返回的数据结构可能与我们预期的 TypeScript 类型不符,这可能导致运行时错误。
为了弥补这一短板,我们引入运行时类型校验库,其中 Zod 是当前最流行和强大的选择。Zod 允许我们定义一个数据的 schema (模式),然后用它来解析 (parse) 未知来源的数据。
import { z } from 'zod';
// 1. 使用 Zod 定义 User 的 schema
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
isAdmin: z.boolean().optional(),
});
// 2. 从 Zod schema 推断出 TypeScript 类型
type User = z.infer<typeof UserSchema>;
// 在数据获取函数中使用
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
try {
// 3. 使用 schema.parse 来校验和解析数据
// 如果数据结构不符合 schema,这里会抛出一个详细的错误
const user = UserSchema.parse(data);
return user;
} catch (error) {
console.error('API data validation failed:', error);
throw new Error('Invalid user data received from server.');
}
}
"TypeScript + Zod"是一个黄金组合。我们只需维护一份 Zod schema,就可以同时获得运行时的安全校验和编译时的静态类型提示(通过 z.infer),极大地提升了处理外部数据的健壮性。
高级类型工具 (Utility Types) 在组件 Props 中的应用
TypeScript 内置了一系列高级类型工具 (Utility Types) ,它们就像是操作类型的函数,可以基于已有类型创建出新的、衍生出的类型。在定义复杂的组件 Props 时,它们非常有用,可以帮助我们避免重复定义,保持类型的一致性。
一些在 React 中常用的高级类型工具包括:
Partial<Type>: 将Type中的所有属性变为可选。Required<Type>: 将Type中的所有属性变为必选。Pick<Type, Keys>: 从Type中挑选出指定的Keys属性来创建一个新类型。Omit<Type, Keys>: 从Type中排除掉指定的Keys属性来创建一个新类型。
结合 React 自带的 ComponentProps 类型,我们可以实现非常灵活的 Props 定义。例如,创建一个自定义 Button 组件,它继承原生 <button> 的所有属性,但我们想自定义 onClick 的行为。
import React from 'react';
// 使用 Omit 来排除原生的 onClick,因为我们要自定义它
type ButtonProps = Omit<React.ComponentProps<'button'>, 'onClick'> & {
// 自定义我们的 onClick prop
onClick: (source: 'custom-button') => void;
variant: 'primary' | 'secondary';
};
export const CustomButton = ({ onClick, variant, ...rest }: ButtonProps) => {
const handleClick = () => {
onClick('custom-button');
};
return (
<button
onClick={handleClick}
// 其他所有原生 button 属性都通过 ...rest 透传下去
{...rest}
style={{ backgroundColor: variant === 'primary' ? 'blue' : 'gray' }}
/>
);
};
通过这种方式,我们创建的 CustomButton 组件既拥有了强类型的自定义 props (variant, onClick),又继承了原生按钮的所有能力,同时保持了极高的灵活性和类型安全。