React19 + Typescript 指南

7 阅读29分钟

第一部分:基础篇 - 现代化 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/elsefor 循环等,但更好的做法是使用更简洁的 &&||、三元运算符和 map 来进行条件渲染和列表渲染。

需要注意的是,JSX 最终会被编译成 JavaScript 对象,因此在顶层不能有多个并列的 JSX 元素,必须将它们包裹在一个父容器中 (如 <div><> 片段)。同时,JSX 中所有的属性名都采用驼峰式 (camelCase),例如 class 要写成 classNametabindex 要写成 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.childrenCard 组件就像一个相框,它定义了相框的样式,但里面的照片 (内容) 则由使用它的父组件来决定。这是实现组件组合和复用的强大模式。

使用 TypeScript 定义 Props 类型

随着应用规模的扩大,组件的 Props 可能会变得越来越复杂。如果我们不小心传递了错误类型的数据,或者遗漏了某个必需的 Prop,程序就可能在运行时出错。为了在开发阶段就避免这类问题,我们引入了 TypeScript。

为组件的 Props 添加类型定义,就像是为组件签署了一份"编程契约"。这份契约明确规定了该组件需要哪些 Props,以及每个 Prop 的数据类型是什么。这不仅能提供强大的编辑器自动补全和错误检查,还让代码本身成为了最好的文档。

在 TypeScript 中,我们通常使用 typeinterface 关键字来定义 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 中非常相似,但有几个细微的差别:

  1. React 事件的命名采用驼峰式 (camelCase) ,而不是纯小写。例如,onclick 变为 onClick
  2. 我们传递的是一个函数作为事件处理程序,而不是一个字符串

一个基本的事件处理如下所示:

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 之一: useStateuseState 的调用本身非常简单,它接收一个参数作为状态的初始值,然后返回一个包含两个元素的数组。我们通常使用 JavaScript 的数组解构语法来接收这两个值:

import { useState } from 'react';

const Counter = () => {
  // 1. 调用 useState,传入初始值 0
  // 2. 解构返回的数组
  const [count, setCount] = useState(0);

  // ...
};

让我们来仔细解读解构出来的这两个成员:

  1. count: 这是状态变量。它是在每次组件渲染时,持有当前状态值的常量。在上面的例子中,它第一次渲染时的值是我们传入的初始值 0
  2. setCount: 这是更新函数。它是我们用来改变 count 状态的唯一途径。直接修改 count 的值 (例如 count = count + 1) 是无效的,并且严重违反了 React 的原则。

当我们调用更新函数 (如 setCount(1)) 时,React 会做两件重要的事情:

  1. 它会计划一次对状态的更新,将新的值保存起来。
  2. 它会触发该组件的一次重新渲染 (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 > 0true 时,表达式会返回 && 右侧的 <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]) -> 第一次渲染后执行,并且在 propAstateB 发生变化后的每次渲染中再次执行。

核心原则: 依赖项数组应该包含所有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 引用了某个 stateprop,但你忘记将它加入依赖项数组,那么 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 和存储可变值

useStateuseEffect 满足了我们大部分的需求,但还有一类特殊场景: 当我们需要一个值在多次渲染之间保持持久,但它的改变不应该触发组件的重新渲染时。为了应对这个场景,React 提供了 useRef Hook。

useRef 返回一个可变的 ref 对象,该对象只有一个 .current 属性。你可以将任何值存放在 myRef.current 中。

useRef 主要有两个用途:

访问 DOM 元素

这是 useRef 最常见的用途。在某些情况下,我们确实需要跳出 React 的声明式世界,去直接操作一个底层的 DOM 节点,例如: 管理表单焦点的切换、触发动画、或者集成一个需要传入 DOM 节点的第三方库。

操作步骤如下:

  1. 使用 useRef 创建一个 ref 对象。
  2. 通过 JSX 的 ref 属性,将这个 ref 对象附加到目标 DOM 元素上。
  3. 当组件渲染完成后,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 逐层传递 isPendinguseFormStatus 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 会重新尝试渲染该组件,此时 use Hook 会返回 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>
  );
};

这种手动优化的方式带来了诸多痛点:

  1. 代码冗余与心智负担: useMemouseCallback 的大量出现,让代码变得冗长且难以维护。你必须仔细阅读代码,才能判断哪些值被缓存了,以及它们的依赖项是什么。

  2. 依赖项地狱: 开发者必须时刻思考并管理依赖项数组,容易出现因遗漏依赖而导致的 bug,或添加了不必要的依赖项,导致缓存失效,失去优化的意义。

  3. 偏离声明式初心: React 的核心魅力在于其声明式编程,我们本应只关心"UI 是什么样",但手动优化却迫使我们不断地问自己: "这个值会变吗?请记住这个值","请不要重新创建这个函数",这在一定程度上违背了 React 的设计哲学。

React Compiler ("Forget") 的设计哲学与目标

为了从根本上解决这个问题,React 团队推出了一个底层的解决方案 —— React Compiler (也被称为 Forget)

"Forget" 这个名字精准地传达了它的设计哲学: 它的目标是让开发者可以"忘记"手动性能优化这件事。

React Compiler 是最直白、最简洁的 JavaScript 和 React 代码,而由高性能 React 来的 (Reactive by default) 工作。它旨在将 React 从一个需要开发者手动优化的库,转变为一个"默认就能用、能够自动进行精细化优化"的框架。

其主要目标包括:

  • 自动记忆化 (Memoization) : 自动分析 useMemo, useCallbackuseRef 可以在多次渲染间复用的值、计算和组件,开箱即用。

  • 提升开发体验: 将开发者从管理依赖项的苦海中解放出来,让代码回归业务逻辑本身,使其更易于编写、阅读和维护。

  • 保障 JavaScript 语意: 编译器在优化时,会严格遵守 JavaScript 的语言规则,确保编译后的代码行为与原代码完全一致。

Compiler 如何实现自动记忆化 (Memoization)

React Compiler 并非一个简单的"查找并替换"工具,它是一个强大的静态分析工具。它在编译时就对代码进行深度分析,理解组件的渲染逻辑和数据依赖。

它的核心优势体现在:

  1. 深度依赖分析: 编译器会像一个经验丰富的 React 开发者一样阅读你的代码。但它比任何人都更彻底、更细致。它能够理解组件中复杂的状态、对象和函数之间的依赖关系。
  2. 更精确的依赖追踪: 它能精确地知道哪些变量、函数或对象的某个属性真正发生了变化,从而避免不必要的重渲染。
  3. 智能代码重写: 基于分析结果,编译器会识别出哪些计算、成本较高或作为 props 传递且在多渲染中可能保持不变的部分。然后,它会自动地、安全地将这些部分用缓存机制 (类似于 useMemo) 包裹起来。由于它拥有全局的分析视角,它生成的缓存策略比开发者手动编写的要精确得多。

本质上,React Compiler 将组件的渲染逻辑从手工优化转移到了自动优化。它通过在编译时进行一次性的、深入的分析,来换取运行时的高效以及开发时的简洁。

对现有代码库的影响与迁移策略

对于这样一个颠覆性的工具,开发者最关心的莫过于它对现有项目的影响。React Compiler 在设计上充分考虑了兼容性:

  • 向后兼容: React Compiler 被设计为完全向后兼容的。它不会智能地理解并尊重代码中已有的 useMemouseCallback。即它与现有的代码行为是一致的。
  • 可选加入 ( Opt-in ) : 它是一个强制性的功能。你可以选择是否在你的项目中启用它,甚至可以配置为只对特定的组件或文件生效。

迁移到 React Compiler 的开发模式是一个平滑且渐进的过程:

  1. 逐步启用: 对于现有的大型项目,可以先在一些非核心或新增的功能模块中启用编译器,验证其效果和稳定性。
  2. 移除冗余优化: 在这个过程中,代码库会变得越来越整洁。你可以小心地移除掉手工写的 useMemouseCallback,让编译器来做这些工作,而代码可以继续清晰、符合 React 最佳实践且保持性能不变。
  3. 逐渐消除依赖: 最终,编译器会成为默认的优化方式,而 useMemouseCallback 成为只有在极少数、编译器无法自动处理的边缘场景下才需要动用的"专家级"工具。对于绝大多数日常开发而言,我们可以彻底"忘记"它们的存在。

第六章:高级 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 主要由三部分构成:

  1. createContext: 使用 createContext 创建一个 Context 对象。
  2. Provider: 使用 MyContext.Provider 包裹组件树,将需要共享的数据通过 value 属性广播出去。
  3. 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 有一个重要的性能问题: 只要 Providervalue 发生变化,所有消费了这个 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 泛型,使其可以适用于任何数据类型。我们可以在任何组件中轻松地复用它,而无需重复编写 useStateuseEffect 的代码。

第七章: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 包为我们预定义了丰富的事件类型。

放弃使用宽泛的 anyReact.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),又继承了原生按钮的所有能力,同时保持了极高的灵活性和类型安全。