基础篇 - 现代化 React 开发入门
开发环境与核心概念
React 思想与前端演进
在 Web 开发的早期,开发者们使用 JavaScript 和 jQuery 等库来为静态的 HTML 页面注入活力。这种方式在处理简单的交互时卓有成效,其核心思路是“命令式”的:开发者需要精确地告诉浏览器“第一步,找到这个 DOM 元素;第二步,修改它的样式;第三步,替换它的文本内容”。当应用程序的规模和复杂度不断攀升时,这种直接操作 DOM 的方式很快就会导致代码逻辑混乱,UI 状态与数据状态的同步变得异常困难,代码最终演变成难以维护的“面条代码”。
为了解决这一困境,前端社区引入了 MVC 和 MVVM 等设计模式,诞生了像 AngularJS 和早期 Vue 这样的框架。它们通过数据绑定的方式,将开发者从繁琐的 DOM 操作中解放出来,这是一个巨大的进步。然而,React 的出现,带来了一种更为纯粹和强大的心智模型。
React 的核心思想可以被精炼为一个优雅的公式:UI = f(State)。
这个公式的含义是,用户界面(UI)仅仅是应用程序状态(State)的一个函数(f)。你不再需要思考当数据变化时,应该如何分步去修改界面。你唯一需要做的,就是清晰地描述出“在任何特定状态下,你的界面应该是什么样子”。当状态发生改变时,React 会像一个高效的管家,自动地、以最优的方式去计算出新旧界面之间的差异,并更新真实 DOM 中需要变化的部分。
这种编程范式被称为“声明式编程”。它与“命令式编程”形成了鲜明对比。
一个类比:
命令式就像是你给朋友导航,告诉他:“从这里出发,开 500 米后左转,经过三个红绿灯后右转...”。
声明式则是你直接告诉他你家的地址,让他自己使用 GPS 规划路线。你只关心“结果”(What),而不关心“过程”(How)。
使用 Vite 搭建 React + TypeScript 开发环境
理论的种子需要实践的土壤才能发芽。要将 React 的思想付诸实践,我们首先需要一个现代化的开发环境。在过去,这通常意味着与复杂的 Webpack 配置进行一番搏斗。但现在,我们有了更优的选择:Vite。
Vite 是一个颠覆性的前端构建工具,它极大地提升了前端的开发体验。其核心优势在于,它利用了现代浏览器原生支持 ES Module 的特性,在开发阶段无需对所有代码进行打包,从而实现了几乎瞬时的服务器启动和快如闪电的热模块更新(HMR)。这意味着您修改代码后,几乎可以立即在浏览器中看到变化,这极大地加速了开发和调试的反馈循环。
搭建一个基于 Vite 的 React + TypeScript 项目非常简单。整个过程始于您的终端(或命令行工具)。
首先,运行创建项目的命令:
1 | npm create vite@latest |
执行该命令后,Vite 会启动一个交互式的脚手架,引导您完成项目的配置。您需要依次输入项目名称,然后使用键盘方向键选择 React 作为开发框架,再选择 TypeScript 作为变体。
当脚手架完成文件生成后,根据终端的提示,进入项目目录并安装所需的依赖包:
1 | # 切换到你的项目目录,例如 "my-react-app"cd my-react-app |
万事俱备,现在只需启动开发服务器:
1 | npm run dev |
片刻之后,您会看到终端输出一个本地服务器地址(通常是 http://localhost:5173 )
在浏览器中打开它,一个崭新的React 应用便呈现在您眼前。
这个由 Vite 生成的项目结构清晰明了。我们大部分的工作都将在 src 目录下进行,其中 App.tsx 是应用的根组件,而 main.tsx 则是将根组件挂载到 index.html 页面上的入口文件。
深入 JSX 语法与实践技巧
初次接触 React 代码时,最引人注目的无疑是 JSX。它是一种允许我们在 JavaScript 文件中编写类似 HTML 结构代码的语法扩展。看到它,请务必记住一个关键点:JSX 不是 HTML,而是 JavaScript 的一种特殊语法。
实际上,我们编写的每一行 JSX 代码,在经过编译后,都会被转换为一个普通的 JavaScript 函数调用——React.createElement()。例如,这样一行 JSX:
1 | <h1 className="title">Hello, React</h1> |
其本质是下面这行代码的“语法糖”:
1 | React.createElement('h1', { className: 'title' }, 'Hello, React'); |
理解了 JSX 的本质是 JavaScript,我们就能更好地掌握它的语法规则。因为它是 JS,所以它具备了 JavaScript 的全部动态能力。我们可以使用大括号 {} 在 JSX 中嵌入任何有效的 JavaScript 表达式,无论是变量、数学运算,还是函数调用。
1 | const user = { name: 'Alice', level: 5 }; |
在使用 JSX 描述界面结构时,有几条核心规则需要遵守。首先,一个组件返回的 JSX 必须拥有一个单一的根元素。如果想返回并列的多个元素,可以用一个外层 div 包裹它们。但更好的做法是使用 Fragment (<>...</>),它允许我们对元素进行分组,而不会在最终的 DOM 结构中添加任何额外的节点。
其次,由于 JSX 最终会被编译成 JavaScript,一些 HTML 属性的写法需要调整以避免与 JavaScript 的保留关键字冲突。最常见的例子就是 class 属性需要写成 className,for 属性需要写成 htmlFor。同样,事件名也遵循驼峰命名法,如 onclick 变为 onClick。
函数式组件与 Class 组件对比
在 React 中,定义组件主要有两种历史悠久的方式:Class 组件和函数式组件。虽然您在一些旧的项目或文档中仍会见到 Class 组件的身影,但理解它们之间的差异,将帮助您清晰地认识到为何整个 React 生态已经全面拥抱了函数式组件。
Class 组件是早期 React 中创建可复用、有状态组件的主要方式。它基于 ES6 的 class 语法,需要继承 React.Component,并且通过 this.state 来管理内部状态,通过 this.setState() 来更新状态,UI 的描述则必须放在 render() 方法中。
让我们看一个 Class 组件实现的计数器:
1 | class ClassCounter extends React.Component { |
这种写法的结构相对固定,但也带来了额外的“模板代码”和对 this 关键字的复杂心智负担。
与此相对,函数式组件则是一个更为简洁和直观的范式。在早期,函数式组件仅仅是接收 props 并返回 JSX 的“哑”组件,无法拥有自己的状态。然而,自 React 16.8 引入 Hooks 之后,一切都改变了。
Hooks(例如 useState)让函数式组件也能拥有状态和其他 React 特性。现在,我们可以用一种更简单、更符合 JavaScript 函数式编程思想的方式来重写上面的计数器:
1 | import { useState } from 'react'; |
两相对比,函数式组件的优势显而易见。它更加简洁,代码量更少,并且完全消除了 this 关键字带来的困扰。更重要的是,Hooks 的设计使得状态逻辑的复用变得异常简单,我们可以轻松地将相关逻辑封装在自定义 Hook 中,这在 Class 组件的时代是难以想象的。
正是由于这些压倒性的优势,函数式组件与 Hooks 已成为现代 React 开发的绝对标准。在本课程的后续所有章节中,我们都将完全采用这种现代化的范式来构建我们的应用。
组件化开发核心
现在,我们将正式进入组件化开发的世界。组件是 React 应用的基石,理解如何构建组件、如何让它们之间有效通信,是掌握 React 的关键。本章将深入探讨组件的输入(Props)、内部状态(State)以及如何响应用户交互。
Props 与组件通信
任何有意义的 React 应用都是由多个组件构成的组件树。这些组件并非孤立存在,它们需要相互协作,传递信息,共同构成完整的用户界面。实现组件间通信最基础、最核心的机制,就是 Props。
Props,是 “properties” 的缩写,其作用与 JavaScript 函数的参数非常相似。如果说组件是一个函数,那么 Props 就是这个函数接收的参数。父组件通过 Props 将数据和功能传递给子组件,从而实现对子组件的配置和控制。
让我们来看一个简单的例子。假设我们有一个 Welcome 组件,我们希望它能向不同的用户显示欢迎信息。
1 | // Welcome.tsx (子组件) |
在这个例子中,App 组件作为父组件,三次渲染了 Welcome 子组件。每次渲染时,它都通过一个名为 name 的 Prop,向 Welcome 组件传递了不同的值。Welcome 组件则在其函数参数中接收这个 props 对象,并读取 props.name 来动态地渲染内容。
关于 Props,有一个至关重要的原则必须牢记:Props 是只读的(Read-Only)。子组件绝不能尝试修改它接收到的 Props。所有 Props 都使得组件的输出仅依赖于输入,这使得组件的行为变得非常可预测。这种自顶而下的数据流动方式,通常被称为“单向数据流”。数据就像瀑布一样,从组件树的顶端流向末端,这使得追踪数据的来源和变化变得非常简单,极大地降低了应用的复杂度。
除了传递自定义数据,React 还提供了一个特殊的 Prop:children。这个 Prop 的值不是通过属性赋值,而是通过组件的闭合标签之间的内容来决定的。它使得我们可以轻松地创建具有“插槽”功能的容器类组件。
设想一个 Card 组件,它需要一个统一的边框和阴影样式,但内部的内容是灵活多变的。
1 | // Card.tsx |
通过 props.children,Card 组件就好像一个相框,它定义了相框的样式,但里面的照片(内容)则由使用它的父组件来决定。这是实现组件组合和复用的强大模式。
使用 TypeScript 定义 Props 类型
随着应用规模的扩大,组件的 Props 可能会变得越来越复杂。如果我们不小心传递了错误类型的数据,或者遗漏了某个必需的 Prop,程序就可能在运行时出错。为了在开发阶段就避免这类问题,我们引入了 TypeScript。
为组件的 Props 添加类型定义,就像是为组件签署了一份“契约”。这份契约明确规定了该组件需要哪些 Props,以及每个 Prop 的数据类型是什么。这不仅能提供强大的编辑器自动补全和错误检查,还让代码本身成为了最好的文档。
在 TypeScript 中,我们通常使用 type 或 interface 关键字来定义 Props 的类型。对于组件 Props 而言,两者在功能上几乎可以互换,选择哪一个更多是团队的风格偏好。
让我们为一个更复杂的用户资料卡片组件 UserProfile 添加类型定义。
1 | // UserProfile.tsx |
在上述代码中,UserProfileProps 类型契约清晰地描述了 UserProfile 组件的“API”:它必须接收 name, age, isVerified 三个 Prop,并且它们的类型分别是 string, number, boolean。同时,它还可以选择性地接收一个名为 hobbies 的字符串数组。
如果我们在使用 UserProfile 组件时违反了这个契约,TypeScript 编译器和我们的代码编辑器会立刻给出错误提示,例如:
• <UserProfile name="Jane" isVerified={false} />
• <UserProfile name="Jane" age="25" isVerified={false} />
这种即时的反馈机制极大地提升了代码的健壮性和开发效率。
事件处理与合成事件系统
我们已经了解了数据如何通过 Props 从父组件流向子组件。但如果子组件需要将信息传递回父组件呢?比如,当用户点击子组件中的一个按钮时,父组件的状态需要发生改变。这种自下而上的通信,通常通过事件处理来实现。
在 React 中处理事件的方式与在原生 DOM 中非常相似,但有几个细微的差别:
- React 事件的命名采用驼峰式(camelCase),而不是纯小写。例如,
onclick变为onClick。 - 我们传递的是一个函数作为事件处理程序,而不是一个字符串。
一个基本的事件处理如下所示:
1 | const Button = () => { |
这里,我们将 handleClick 函数作为 Prop 传递给了 <button> 元素的 onClick 属性。当用户点击按钮时,React 会调用这个函数。
要实现子组件到父组件的通信,核心思想就是将父组件中的函数作为 Prop 传递给子组件。父组件定义了行为(做什么),子组件则决定了何时触发该行为(何时调用该函数)。
让我们构建一个场景:父组件 Dashboard 需要知道其子组件 LoginButton 何时被点击。
1 | // LoginButton.tsx (子组件) |
通过这种模式,LoginButton 组件保持了其通用性,它只负责渲染一个按钮并报告点击事件,而不关心点击后具体会发生什么。所有的业务逻辑都保留在了父组件 Dashboard 中,实现了清晰的职责分离。
值得一提的是,我们传递给 onClick 等事件处理程序的事件对象 e,并不是原生的浏览器事件对象,而是一个 合成事件(SyntheticEvent) 对象。这是 React 对原生事件的一个跨浏览器包装器。它抹平了不同浏览器在事件系统上的差异,使得我们写的事件处理代码能够在所有浏览器中表现一致,无需担心兼容性问题。
状态管理入门:useState Hook 详解
Props 是从外部传入且不可变的,而 State 则是组件内部自己管理的数据,并且它是可变的。在函数式组件中,我们用来赋予组件状态能力的工具,就是 React 最基础也最重要的 Hook 之一:useState。useState 的调用本身非常简单,它接收一个参数作为状态的初始值,然后返回一个包含两个元素的数组。我们通常使用 JavaScript 的数组解构语法来接收这两个值:
1 | import { useState } from 'react'; |
让我们来仔细解读解构出来的这两个成员:
- count:这是状态变量。它是在每次组件渲染时,持有当前状态值的常量。在上面的例子中,它第一次渲染时的值是我们传入的初始值 0。
- setCount:这是更新函数。它是我们用来改变 count 状态的唯一途径。直接修改 count 的值(例如 count = count + 1)是无效的,并且严重违反了 React 的原则。
当我们调用更新函数(如 setCount(1))时,React 会做两件重要的事情: - 它会计划一次对状态的更新,将新的状态值保存起来。
- 它会触发该组件的一次重新渲染(re-render)。
在下一次渲染发生时,useState 会返回更新后的最新状态值。正是这个“状态更新 → 触发重新渲染 → 使用新状态渲染UI”的循环,构成了 React 动态交互的核心。
1 | import { useState } from 'react'; |
在使用 useState 时,一个核心原则是状态的不可变性(Immutability)。对于对象或数组这样的引用类型,我们不应该直接修改它们内部的属性,而应该总是创建一个新的对象或数组来替换旧的。这是因为 React 通过浅比较来判断状态是否发生了变化。如果你只是修改了原对象的属性,对象的引用地址并未改变,React 可能会认为状态没有变化,从而跳过重新渲染。
错误的做法 (直接修改) ❌:
1
2
3
4
5 const [user, setUser] = useState({ name: 'Alice', age: 25 });
const handleAgeIncrement = () => {
user.age += 1; // 直接修改了原对象
setUser(user); // 传入的还是旧的引用,React 可能不会更新
};正确的做法 (创建新对象) ✅:
1
2
3
4
5
6 const [user, setUser] = useState({ name: 'Alice', age: 25 });
const handleAgeIncrement = () => {
// 使用展开语法(...)创建一个新对象,并覆盖 age 属性
const newUser = { ...user, age: user.age + 1 };
>. setUser(newUser);
};
此外,更新函数还支持接收一个函数作为参数,这种形式被称为函数式更新。这个函数会接收到前一个状态作为参数,并返回新的状态。当新的状态依赖于旧的状态时,使用函数式更新是更安全、更推荐的做法,它可以避免在快速连续的更新中由于闭包导致的状态陈旧问题。
1 | // 当新状态依赖于旧状态时,推荐使用函数式更新 |
条件渲染与列表渲染 (key 的重要性)
现在我们的组件已经拥有了可以变化的状态,下一步就是根据这些状态来动态地决定界面应该呈现什么内容。这就是动态渲染,它主要分为两种场景:条件渲染和列表渲染。
条件渲染,顾名思义,是根据不同的条件来渲染不同的 JSX。由于 JSX 本身就是 JavaScript,我们可以自如地运用 JavaScript 的条件控制语句。
在 JSX 中,最常用的是三元运算符 (? :) 和逻辑与运算符 (&&)。
三元运算符非常适合处理 if-else 这样的二选一场景。例如,根据用户是否登录,显示不同的信息:
1 | const AuthStatus = () => { |
而逻辑与 && 运算符则是一个巧妙的捷径,用于处理“如果条件为真,则渲染某个元素,否则什么都不渲染”的场景。
1 | const Mailbox = ({ unreadMessages }) => { |
当 unreadMessages.length > 0 为真时,表达式会返回 && 右侧的 <h2> 元素;如果为假,则表达式直接返回 false,而 React 不会渲染布尔值 false。
列表渲染则涉及将数组中的每一项数据转换并渲染为一组 UI 元素。在 JavaScript 中,将数组转换为另一个数组最自然的方式就是使用 Array.prototype.map() 方法,React 中的列表渲染正是利用了这一点。
假设我们有一个待办事项数组,需要将它们渲染成一个列表:
1 | const todos = [ |
在上面的代码中,请特别注意 <li> 元素上的 key 属性。这是列表渲染中一个至关重要且不可或缺的部分。
key 的重要性
key 是 React 用来识别列表中各个元素的“身份证”。当列表数据发生变化(例如增、删、改、排序)时,React 的协调算法(Reconciliation)会通过 key 来高效地比对新旧两棵虚拟 DOM 树。它会根据 key 来判断哪些元素是新创建的、哪些被删除了、哪些只是移动了位置。
- 一个稳定且唯一的
key能帮助 React 最大限度地复用已有的 DOM 元素和组件实例,从而极大地提升性能。 - 如果不提供
key,React 会在控制台给出警告,并且在列表更新时可能会出现不可预测的 UI bug 和性能问题。 - 使用数组的索引 (index) 作为
key是一种常见的反模式,应当极力避免。因为当列表项的顺序发生改变时(例如在数组开头插入一个新元素),所有后续元素的索引都会改变。这会让 React 误以为是元素自身的内容发生了大规模变化,从而导致不必要的重新渲染,甚至丢失组件内部的状态(如输入框的内容)。最理想的
key值,是数据项中本身就带有的、独一无二且不随时间变化的字符串或数字,比如数据库中的id
深入 Hooks 与生命周期
在前两章中,我们聚焦于一个组件的“纯粹”职责:接收 Props,管理 State,并根据它们返回一段描述 UI 的 JSX。这个过程是封闭且可预测的。然而,在真实的应用程序中,组件常常需要与“外部世界”进行通信——它可能需要从服务器获取数据,需要直接操作浏览器 DOM,或者需要设置定时器和订阅事件。这些与组件渲染主流程无关的操作,我们称之为“副作用”(Side Effects)。本章将深入探讨用于管理这些副作用的核心 Hook——useEffect,以及其他几个功能强大的 Hooks。
副作用处理:useEffect Hook 详解 (挂载、更新、卸载)
useEffect 是 React 提供给我们的一个“逃生舱口”,它允许我们在函数式组件中执行副作用操作。其设计的核心理念是将副作用逻辑与渲染逻辑分离开来,并确保这些副作用操作不会在渲染期间阻塞浏览器,而是在组件完成渲染之后异步执行。
一个 useEffect 的基本结构包含一个回调函数和(可选的)一个依赖项数组:
1 | useEffect(() => { /* 副作用逻辑 / }, [/ 依赖项 */]); |
通过控制第二个参数——依赖项数组,我们可以精确地模仿传统 Class 组件中生命周期方法的行为,如组件的挂载、更新和卸载。
模拟组件挂载 (Mount)
当我们需要副作用仅仅在组件第一次渲染到屏幕上之后执行一次,且之后不再重复执行时,我们可以向 useEffect 传递一个空的依赖项数组 []。这等同于 Class 组件中的 componentDidMount。
这是执行一次性设置操作的理想场所,例如:从 API 获取初始数据,或者设置一个全局的事件监听器。
1 | import { useState, useEffect } from 'react'; |
模拟组件更新 (Update)
在某些场景下,我们希望副作用在每次组件渲染或特定数据变化后都重新执行。例如,我们希望网页的标题能实时反映出当前计数器的值。如果不提供第二个参数,effect 将在每次渲染后都执行。如果提供了依赖项数组,则只有在数组中的值发生变化时,effect 才会再次执行。这部分我们将在下一节详述。
模拟组件卸载 (Unmount) 与 清理副作用
副作用操作常常会产生一些需要“清理”的后续工作,以避免内存泄漏或不必要的行为。例如,如果我们设置了一个定时器,或者订阅了一个事件,就需要在组件被销毁时取消定时器或退订事件。useEffect 通过其回调函数的返回值来优雅地解决了这个问题。如果 useEffect 的回调函数返回了另一个函数,那么这个返回的函数就被视为清理函数。React 会在组件从 UI 中移除(卸载)之前,以及在下一次 effect 即将重新执行之前,调用这个清理函数。这等同于 Class 组件中的 componentWillUnmount。
1 | import { useState, useEffect } from 'react'; |
在这个例子中,即使用户切换页面导致 Timer 组件被销毁,我们也能确保定时器被妥善清理,不会在后台继续运行。
useEffect 的依赖项数组与常见陷阱
依赖项数组是 useEffect 的“指挥官”,它精确地告诉 React:“请在这些值发生变化时,才重新执行我的副作用逻辑”。正确地使用依赖项数组是编写健壮、高效的 React 组件的关键。
数组中的每个值,在每次组件渲染后都会被 React 进行一次浅比较(使用 Object.is)。只有当至少一个值与上一次渲染时的值不同时,effect 才会重新运行。
让我们回顾一下它的三种行为模式:
- 不提供数组:
useEffect(() => { ... })-> 每次渲染后都执行。 - 提供空数组:
useEffect(() => { ... }, [])-> 仅在第一次渲染后执行。 - 提供含值的数组:
useEffect(() => { ... }, [propA, stateB])-> 第一次渲染后执行,并且在 propA 或 stateB 发生变化后的每次渲染中再次执行。
核心原则: 依赖项数组应该包含所有在 effect 函数内部被引用的、且来自于组件作用域的变量(如 props, state, 或自定义函数)。
忽略这个原则会导致一些非常隐蔽和难以调试的 bug,其中最常见的有两个:
陷阱一:无限循环
当你在一个 effect 中更新了某个 state,而这个 state 又恰好是该 effect 的依赖项时,就会产生一个无限循环。
错误示例 ❌:
1
2
3
4
5 const [count, setCount] = useState(0);
useEffect(() => {
// 每次 count 变化,都会执行这里,然后又导致 count 变化...
setCount(count + 1);
}, [count]); // 依赖于 count其执行流程是:
1 `count` 变化 -\> 触发渲染 -\> 渲染后执行 effect -\> `setCount` 更新 `count` -\> `count` 变化 -\> 无限循环...
陷阱二:陈旧的闭包
这是更隐蔽的一个问题。如果你的 effect 引用了某个 state 或 prop,但你忘记将它加入依赖项数组,那么 effect 函数将“捕获”该变量在第一次渲染时的值,并且永远不会获取到它最新的值。
错误示例 ❌:
1
2
3
4
5
6
7
8
9 const ChatRoom = ({ roomId }) => {
useEffect(() => {
console.log(`Connecting to room ${roomId}...`);
// ... 连接逻辑 ...
return () => {
console.log(`Disconnecting from room ${roomId}...`); // 这里的 roomId 永远是旧的
};
}, []); // 忘记将 roomId 加入依赖项
};
1 在这个例子中,如果 `roomId` 这个 prop 发生了变化,组件虽然会重新渲染,但由于依赖项是 `[]`,旧的 effect 不会被清理,新的 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 节点。
1 | import { useRef, useEffect } from 'react'; |
重要提示:应该在 useEffect 或事件处理函数中访问 .current,以确保 DOM 节点已经被创建并附加。
存储任意可变值(实例变量)
useRef 的 .current 属性就像是 Class 组件中的一个实例属性。它是一个“通用容器”,可以在组件的整个生命周期内持久保存任何值,且对它的修改不会触发重新渲染。
这在需要存储定时器 ID、WebSocket 连接实例或任何与渲染无关的数据时非常有用。
1 | import { useRef, useState, useEffect } from 'react'; |
在这个防抖搜索的例子中,我们用 useRef 跨渲染周期地“记住”了定时器 ID,而无需在每次 ID 变化时都触发不必要的组件刷新。
TypeScript 与 Hooks 的类型推断与显式声明
将 TypeScript 与 Hooks 结合使用,可以为我们的组件状态和副作用逻辑提供强大的类型安全保障。
useState 的类型
在大多数情况下,TypeScript 能够根据传入 useState 的初始值推断出状态的类型,我们无需额外操作。
1 | // TS 推断出 count 是 number 类型 |
然而,当一个状态的初始值是 null,或者它可以是多种类型之一时,我们就需要显式地通过泛型来声明它的类型。
1 | type User = { id: string; name: string }; |
useRef 的类型
为 useRef 提供类型也遵循相似的逻辑。
- 用于 DOM 元素时,我们需要指定它将附加到的 HTML 元素的具体类型,并将初始值设为 null。
1 | // ref 将指向一个 HTMLInputElement 元素 |
- 用于存储可变值时,我们只需在泛型中声明该值的类型。
1 | // ref 将用于存储一个数字类型的定时器 ID |
通过为 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() 了。
1 | // 传统的表单处理方式 |
这种方式不仅代码更简洁,语义也更清晰:这个表单的“行为”(action)就是执行 submitAction 函数。
服务端 Actions 与客户端 Actions
Action 可以是定义在客户端的普通异步函数(客户端 Action),也可以是结合了“use server”指令、在服务端执行的函数(服务端 Action)。服务端 Actions 是 React Server Components 架构下的一个强大特性(通常在 Next.js 等全栈框架中使用),它允许前后端代码以前所未有的方式集成,实现无缝的 RPC 调用。在本课程中,我们将主要聚焦于客户端 Actions 的应用。
使用 useActionState 处理 Pending/Error/Success 状态
Actions 的真正威力在于它内置了对异步流程状态的管理能力。useActionState (在早期版本中被称为 useFormState) Hook 是专门为此设计的。它接收一个 Action 函数和初始状态,然后返回一个包含了当前状态、一个可被调用的新 Action 以及一个pending 状态的数组。
1 | import { useActionState } from 'react'; |
观察上述代码,useActionState 极大地简化了状态管理。我们不再需要手动创建 useState 来管理 isLoading, error, successMessage。React 已经为我们处理好了一切:
- 当表单提交时,
isPending自动变为 true。 - Action 函数执行完毕后,
isPending自动变回 false。 - Action 函数的返回值会成为
state的新值,从而触发 UI 更新。
使用 useFormStatus 优化用户体验useActionState 管理的是整个表单的状态,但有时我们希望表单内的某个子组件(比如提交按钮)能够独立地响应表单的提交状态,而无需通过 props 逐层传递 isPending。useFormStatus Hook 正是为了解决这个问题而生。
它只能在 <form> 组件的子组件中使用,并且会返回其所在表-单的当前状态信息,包括 pending, data, method 等。
1 | import { useFormStatus } from 'react-dom'; |
通过 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”的模式。
1 | import { Suspense, use } from 'react'; |
这种模式被称为“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 进行包装。
1 | // 旧方式 |
React Compiler (理念篇)
在 React 的世界里,性能优化一直是一个重要课题。当应用变得复杂,组件树层级加深时,不必要的重新渲染会成为性能瓶颈。为了解决这个问题,React 提供了 React.memo, useMemo 和 useCallback 等一系列手动优化的工具。然而,这些工具在带来性能提升的同时,也引入了新的复杂性。React Compiler 的诞生,正是为了将开发者从这种手动优化的困境中解放出来。
手动优化的痛点:useMemo, useCallback 的困境
让我们先回顾一下为何需要手动优化。在 React 中,当一个父组件的状态或 Props 发生变化时,它会默认重新渲染其所有的子组件,即使传递给某些子组件的 Props 并未发生任何改变。为了避免这种浪费,我们可以使用 React.memo 来包裹子组件,使其只有在 Props 真正发生变化时才重新渲染。
这听起来很美好,但问题随之而来。如果父组件传递给子组件的 Props 是一个对象、数组或函数,那么在每次父组件渲染时,它们都会被重新创建,导致引用地址发生变化。从 React.memo 的角度看,这等同于 Props 发生了变化,从而导致优化失效。
为了解决这个问题,我们被迫引入了 useMemo 来缓存对象或复杂计算的结果,以及 useCallback 来缓存函数实例。
1 | // 一个需要手动优化的场景 |
这种手动优化的模式带来了诸多痛点:
- 代码污染与心智负担:
useMemo和useCallback的大量使用,让组件的业务逻辑变得不再纯粹,代码可读性下降。开发者必须时刻思考“这里是否需要缓存?”,“那个函数是否需要用useCallback包裹?”。 - 依赖项数组的陷阱:管理依赖项数组是极其繁琐且容易出错的。忘记添加依赖项会导致“陈旧闭包”的 bug;添加了不必要的依赖项则可能导致缓存频繁失效,失去优化的意义。
- 偏离声明式初心:React 的核心魅力在于其声明式编程。我们本应只关心“UI 该是什么样”,但手动优化却迫使我们不断地向 React 发出命令式的指令:“请记住这个值”,“请不要重新创建这个函数”,这在一定程度上违背了 React 的设计哲学。
React Compiler (“Forget”) 的设计哲学与目标
面对手动优化的种种困境,React 团队提出了一个釜底抽薪的解决方案:一个名为 React Compiler 的先进编译器,其内部代号为 “Forget”。
“Forget” 这个名字精准地传达了它的设计哲学:它的目标是让开发者可以“忘记”手动性能优化这件事。
React Compiler 的核心理念是,React 本就应该是默认具备高性能反应能力的(Reactive by default)。开发者应该能够编写最直白、最简洁的 JavaScript 和 React 代码,而由工具链来自动处理那些复杂的性能优化工作。它旨在将 React 从一个需要开发者手动提示才能实现最优性能的库,转变为一个足够智能、能够自动进行精细化优化的框架。
其主要目标包括:
- 自动化记忆化(Memoization):自动分析代码,并智能地包裹那些可以在多次渲染间复用的值、计算和组件,等效于自动插入
useMemo,useCallback和React.memo。 - 提升开发者体验:将开发者从管理依赖项的苦差事中解放出来,让代码回归业务逻辑本身,使其更易于编写、阅读和维护。
- 保持 JavaScript 语意:编译器在进行优化时,会严格遵守 JavaScript 的语言规则,确保编译后的代码行为与源代码完全一致。
Compiler 如何实现自动记忆化 (Memoization)
React Compiler 并非 React 运行时库的一部分,而是一个编译时工具(通常作为 Babel 插件)。它在项目构建打包的过程中,对源代码进行深度分析和重写。
它的工作原理(在一个较高的层次上)可以这样理解:
- 深度静态分析:编译器会像一个经验丰富的 React 开发者一样“阅读”你的组件代码。但它比任何人都更严谨、更不知疲倦。它能够理解 React 的规则(比如 props 和 state 的不可变性),也能够理解 JavaScript 的语义。
- 建模与依赖追踪:它会构建出组件内所有值、对象、函数之间的依赖关系图。它能精确地知道,当某个 state 或 prop 变化时,到底会影响到哪些下游的计算和值。
- 智能代码重写:基于分析结果,编译器会识别出那些计算成本较高或作为 props 传递且在多次渲染中可能保持不变的部分。然后,它会自动地、安全地将这些部分用缓存机制(类似于
useMemo)包裹起来。由于它拥有全局的依赖视图,它生成的“依赖项数组”远比手动维护的要精确。
本质上,React Compiler 将性能优化的职责从开发者转移到了工具链。它通过在编译时进行一次性的、深入的分析,来换取运行时的高效以及开发时的简洁。
对现有代码库的影响与迁移策略
对于这样一个颠覆性的工具,开发者最关心的莫过于它对现有项目的影响。React Compiler 在设计上充分考虑了兼容性和渐进式采用。
对现有代码的影响
- 向后兼容:React Compiler 被设计为完全向后兼容的。它足够智能,能够理解并尊重代码中已有的
useMemo和useCallback。你现有的代码库在启用编译器后,行为不会发生改变。 - 可选加入(Opt-in):它不是一个强制性的功能。你可以选择是否在你的项目中启用它,甚至可以配置为只对项目的特定部分生效。
迁移策略
迁移到由 React Compiler 驱动的开发模式是一个平滑且渐进的过程:
- 逐步启用:对于现有的大型项目,可以先在一些非核心或新增的功能模块中启用编译器,验证其效果和稳定性。
- 移除冗余优化:在确认编译器工作正常后,可以开始逐步地、有信心地移除代码中手写的
useMemo和useCallback。这个过程可以让代码库变得越来越整洁。 - 遵循编译器友好的代码模式:虽然编译器足够强大,但编写清晰、符合 React 最佳实践(如保持数据不可变性)的代码,将有助于编译器做出更有效的优化。
长远来看,React Compiler 的目标是让 useMemo 和 useCallback 成为只有在极少数、编译器无法自动处理的边缘场景下才需要动用的“专家级”工具。对于绝大多数日常开发而言,我们将可以彻底“忘记”它们的存在。
高级 Hooks 与状态管理
随着应用功能的日益复杂,简单的 useState 已经不足以应对所有的状态管理需求。组件之间的数据共享、复杂状态的逻辑流转、以及伴随而来的性能问题,都对我们提出了更高的要求。本章将深入探讨 React 提供的更高级的状态管理工具,以及如何封装和复用我们的状态逻辑。
复杂状态逻辑:useReducer vs useState
我们已经熟练掌握了 useState,它非常适合处理简单的、独立的状态,如布尔值、字符串或数字。但当一个状态对象包含多个关联的字段,或者状态的下一个值依赖于前一个值的复杂计算时,useState 的更新逻辑就会散落在各个事件处理函数中,变得难以维护。
为了应对这种场景,React 提供了另一个内置的 Hook:useReducer。它借鉴了 Redux 的思想,是一种将状态更新逻辑从组件中分离出来并集中管理的模式。
useReducer 接收一个 reducer 函数 和一个初始状态,返回当前的状态和一个 dispatch 函数。
- Reducer 函数: 这是一个纯函数,它接收当前的状态(
state)和一个动作(action)对象作为参数,然后返回一个全新的状态。所有状态如何变化的逻辑都定义在这里。 - Action 对象: 这是一个普通的 JavaScript 对象,通常包含一个
type字段来描述操作类型,以及一个可选的 payload 字段来传递数据。 - Dispatch 函数: 我们在事件处理函数中调用
dispatch(action)来“派发”一个动作。这会触发 React 调用我们的reducer函数,用它返回的新状态来更新 UI。
让我们用一个经典的购物车计数器例子来对比两者:
1 | // 使用 useReducer 来管理更复杂的 state |
何时选择 useReducer?
- 当状态逻辑非常复杂,涉及多个子值时。
- 当下一个状态严重依赖于前一个状态时。
- 当你想将状态变更的逻辑提取出组件,以便于独立测试和维护时。
- 在多人协作的大型项目中,useReducer 提供了更可预测和严格的状态流。
对于简单场景,useState 依然是更简洁的选择。
全局状态管理:useContext 与性能陷阱
当多个散落在组件树不同位置的组件需要共享同一个状态时(例如,当前登录的用户信息、全局的主题设置),逐层通过 props 传递数据会变得非常繁琐和低效,这种现象被称为“Prop Drilling”(属性钻探)。
为了解决这个问题,React 提供了 Context API。它允许我们创建一个全局的数据“广播站”,任何在这个“广播站”覆盖范围内的组件,都可以直接“收听”到这份数据,而无需关心它们在组件树中的层级深度。
使用 Context 主要分为三步:
createContext: 在应用的上层创建一个 Context 对象。- Provider: 使用
MyContext.Provider组件将需要共享的数据通过value属性“广播”出去。这个 Provider 会包裹住所有可能需要这份数据的子组件。 useContext: 在任何一个子组件中,通过useContext(MyContext)Hook 来“订阅”并获取这份数据。
1 | // 1. 创建一个 ThemeContext |
性能陷阱
Context 虽然强大,但它有一个重要的性能特征:当 Provider 的 value 发生变化时,所有消费该 Context 的组件(即调用了 useContext 的组件)都会被强制重新渲染。即使这些组件只关心 value 对象中的一小部分数据,只要 value 的引用地址发生变化,它们也会被波及。
如果 value 是一个复杂的对象,并且在父组件的每次渲染中都被重新创建,这可能会导致不必要的性能开销。解决方案通常是结合 useMemo 来缓存 value 对象,或者将一个大的 Context 拆分成多个更细粒度的 Context。
手动性能优化:React.memo, useMemo, useCallback 的正确使用场景
正如第五章所述,React Compiler 的目标是自动化性能优化。但在它完全成熟和普及之前,或者在某些需要精细调优的场景下,理解手动优化的工具仍然是一项必备技能。
React.memo: 这是一个高阶组件,用于包裹你的函数式组件。它会对传入的 props 进行浅比较,只有在 props 发生变化时,才会重新渲染被包裹的组件。这是防止因父组件渲染而导致的不必要子组件渲染的主要工具。useMemo: 这个 Hook 用于“记忆化”一个计算结果。它接收一个函数和一个依赖项数组,只有在依赖项发生变化时,才会重新执行该函数并返回新的值。它主要用于两个场景:- 缓存开销巨大的计算结果,避免在每次渲染时都重复计算。
- 当向一个被
React.memo包裹的子组件传递对象或数组作为 prop 时,使用useMemo来保证该 prop 的引用稳定性。
useCallback: 这个 Hook 用于“记忆化”一个函数实例。它与useMemo类似,但专门用于函数。主要使用场景是:当向一个被React.memo包裹的子组件传递函数作为prop时,使用useCallback来保证该函数 prop 的引用稳定性。核心原则:不要过早优化。这些 API 自身也有成本(内存占用和比较开销)。只在你通过 React DevTools Profiler 等工具确认了某个组件存在性能瓶颈时,才应该考虑使用它们进行优化
自定义 Hooks:封装逻辑与实现复用 (含 TS 泛型)
自定义 Hook 是现代 React 中实现逻辑复用的基石。它是一个以 use 开头的 JavaScript 函数,其内部可以调用其他的 Hooks(如 useState, useEffect 等)。通过自定义 Hook,我们可以将组件中可复用的状态逻辑(而非 UI)提取出来,让组件本身保持简洁,只关注于渲染。
假设我们有多个组件都需要从本地存储(LocalStorage)中读取和写入数据,我们可以创建一个 useLocalStorageState 的自定义 Hook。
1 | import { useState, useEffect } from 'react'; |
在这个例子中,我们使用了 TypeScript 的泛型 T。这使得我们的 useLocalStorageState Hook 可以适用于任何可被 JSON 序列化的数据类型,无论是字符串、数字还是对象,同时保持了完整的类型安全。这就是自定义 Hook 结合 TypeScript 的强大之处:创建高度可复用、类型安全且与 UI 无关的逻辑单元。
TypeScript 高级应用
在前面的章节中,我们已经将 TypeScript 应用于 Props 和 Hooks,奠定了类型安全的基础。本章我们将更进一步,探索 TypeScript 在 React 开发中的一些高级用法,学习如何利用泛型和类型工具来构建更加灵活、健壮且易于维护的组件和 Hooks。
泛型组件与泛型 Hooks
我们已经在自定义 Hook 中见识了泛型的威力,同样的能力也可以被应用在泛型组件上。泛型组件允许我们在定义组件时不预先写死其处理的数据类型,而是由使用该组件的父组件来指定。
这在创建可复用的列表、表格、下拉菜单等 UI 模式时非常有用。例如,我们可以创建一个可以渲染任何类型数据数组的 List 组件。
1 | import React from 'react'; |
通过泛型,我们创建了一个高度抽象且完全类型安全的 List 组件,它将“渲染什么”的逻辑(renderItem)交给了调用者,而自己只负责“如何渲染”(列表结构)的逻辑。
React 事件对象的精确类型
在事件处理函数中,为事件对象 e 提供精确的类型,可以帮助我们安全地访问特定于该事件的属性(如 e.target.value),并获得编辑器的智能提示。@types/react 包为我们预定义了丰富的事件类型。
放弃使用宽泛的 any 或 React.SyntheticEvent,转而使用更具体的类型是一个好习惯:
- 鼠标事件:
React.MouseEvent<HTMLElement>
1 | const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { |
- 表单元素变化事件:
React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
1 | const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
- 表单提交事件:
React.FormEvent<HTMLFormElement>
1 | const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { |
使用精确的事件类型,可以让我们的代码更加健壮,有效防止因访问不存在的属性而导致的运行时错误。
结合 Zod 进行运行时类型校验
TypeScript 的类型系统在编译时为我们提供了强大的保护,但这份保护在应用的“边界”处会失效——尤其是当我们从外部 API 接收数据时。API 返回的数据结构可能与我们预期的 TypeScript 类型不符,这可能导致运行时错误。
为了弥补这一短板,我们引入运行时类型校验库,其中 Zod 是当前最流行和强大的选择。Zod 允许我们定义一个数据的 schema(模式),然后用它来解析(parse)未知来源的数据。
1 | import { z } from 'zod'; |
“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的行为。
1 | import React from 'react'; |
通过这种方式,我们创建的 CustomButton 组件既拥有了强类型的自定义 props (variant, onClick),又继承了原生按钮的所有能力,同时保持了极高的灵活性和类型安全。
实战篇 - 构建企业级项目
理论是基石,实践是高塔。本部分将带领您亲手将蓝图变为现实。我们将模拟真实的企业开发流程,构建一个 Trello/Jira 类的项目管理看板。我们将在这个过程中,熟练掌握现代化 React 项目的全生命周期,从最初的需求分析与技术选型,到核心功能的开发,再到最终的质量保障与自动化部署。
项目启动与架构设计
项目需求分析与技术选型
在编写第一行代码之前,我们必须明确我们要构建的是什么,以及我们将使用什么工具来构建。
项目需求分析 (Trello/Jira 看板)
我们的目标是创建一个项目管理看板应用,其核心功能应包括:
- 用户认证:支持用户注册和登录。
- 看板空间 (Workspace):用户可以创建和加入多个看板空间。
- 看板 (Board):在空间内,可以创建多个看板,每个看板有自己的背景和设置。
- 列表 (List):每个看板由多个列表组成(如 “待办”、“进行中”、“已完成”)。
- 卡片 (Card):卡片是最小的工作单元,可以在列表之间拖拽。
- 卡片详情:点击卡片可以打开模态框,查看和编辑卡片的详细信息(描述、附件、评论等)。
- 拖拽功能:支持列表和卡片的拖拽排序。
技术选型
基于我们的需求和之前学习的现代化技术,我们确定以下技术栈:
- 构建工具:Vite - 提供极致的开发体验。
- 核心框架:React 19 + TypeScript - 享受最新的 React 特性与类型安全。
- 路由管理:React Router v6+ - 强大的客户端路由解决方案。
- 全局状态管理:Zustand - 轻量、简洁、对 TypeScript 支持良好。
- 服务端状态管理:React Query (TanStack Query) - 优雅地处理数据获取、缓存和同步。
- UI 与样式:Tailwind CSS - 高效的 Utility-First CSS 框架。
- 表单处理:React Hook Form + Zod - 兼顾性能与运行时校验。
- 组件库:shadcn/ui - 提供灵活、可定制、易于接入的 UI 组件。
- 拖拽库:dnd-kit - 专为 React 设计的现代化、可访问性高的拖拽库。
搭建项目结构与配置代码规范
一个清晰、可预测的项目结构对于长期维护至关重要。我们将采用功能优先 (Feature-based) 的目录结构,将相关的文件(组件、Hooks、API 调用等)组织在一起。
项目目录结构示例:
1 | /src |
配置代码规范
为了保证团队协作的顺畅和代码质量的一致性,我们需要配置代码规范工具:
- ESLint: 一个可配置的 JavaScript/TypeScript 代码检查工具,用于发现并修复代码中的问题。
- Prettier: 一个“有主见”的代码格式化工具,它会强制统一的代码风格。
- Husky + lint-staged: 这两个工具组合可以在我们每次提交代码到 Git 仓库之前,自动运行 ESLint 和 Prettier,确保所有提交的代码都符合规范。
通过在 package.json 中配置脚本并安装相应的依赖,我们可以实现保存文件时自动格式化、提交代码前自动检查的开发流程。
路由管理:React Router v6+
React Router 是 React 应用中路由管理的事实标准。在 v6.4 之后,它引入了全新的数据路由 API (createBrowserRouter),这与 React 19 的 Actions 和 Suspense 等特性结合得更加紧密。
我们将使用 createBrowserRouter 来定义应用的全部路由规则。
1 | // src/app/router.tsx |
动态路由与嵌套路由
- 动态路由 (
path: 'board/:boardId') 允许我们为同一类型的页面创建不同的 URL,例如/board/1和/board/2都会渲染 BoardPage 组件。在 BoardPage 组件中,我们可以通过useParamsHook 来获取boardId的值。 - 嵌套路由 (通过
children属性) 非常适合实现共享的页面布局。在上面的例子中,所有/下的子页面都会被渲染在AppLayout组件内部的一个<Outlet />占位符中,从而轻松实现共享的导航栏或侧边栏。
懒加载 (Lazy Loading)
为了优化应用的初始加载性能,我们不应该一次性加载所有页面的代码。通过 React.lazy 和 <Suspense>,我们可以实现路由级别的代码分割。
1 | import React, { Suspense } from 'react'; |
这样配置后,只有当用户访问 /board/...路径时,浏览器才会去下载 BoardPage 组件及其相关的代码,从而显著减小了首屏加载的体积。
核心功能开发
状态管理: Zustand - 轻量、高效的全局状态管理方案
Zustand 是一个极简但功能强大的全局状态管理库。它基于 Hooks,无需像 Redux 那样编写大量的模板代码,也无需用 Provider 包裹整个应用。
我们将使用 Zustand 来管理那些需要在多个不相关组件间共享的客户端状态,例如 UI 的状态(侧边栏是否展开)或当前用户的认证信息。
创建一个 Store 非常简单:
1 | // src/store/authStore.ts |
在任何组件中,我们都可以像使用普通 Hook 一样来使用这个 Store:
1 | import { useAuthStore } from '../store/authStore'; |
Zustand 的简洁性和对 TypeScript 的原生支持,使其成为现代 React 项目中处理全局客户端状态的绝佳选择。
数据请求: React Query - 管理服务端状态
React Query (现已更名为 TanStack Query) 是一个用于管理服务端状态的库。它彻底改变了我们处理数据获取、缓存、同步和更新的方式,让我们不再需要将 API 数据塞进全局状态管理器中。
使用 useQuery 获取数据useQuery 用于获取(GET)数据。它接收一个唯一的查询键(Query Key)和一个异步的获取函数。
1 | import { useQuery } from '@tanstack/react-query'; |
React Query 会自动为我们处理加载状态、错误状态、数据缓存、后台自动刷新等所有繁琐的工作。
使用 useMutation 处理数据变更useMutation 用于创建(POST)、更新(PUT/PATCH)或删除(DELETE)数据。
1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; |
通过 invalidateQueries,我们可以轻松地实现当数据变更后,自动更新页面上的相关数据,确保 UI 与服务端状态的同步。
UI 与样式: Tailwind CSS
Tailwind CSS 是一个 “Utility-First” 的 CSS 框架。我们不再编写传统的 CSS 文件,而是直接在 JSX 中通过组合原子化的 CSS 类名来构建界面。
1 | <div class="p-6 max-w-sm mx-auto bg-white rounded-xl shadow-lg flex items-center space-x-4"><div class="shrink-0"><img class="h-12 w-12" src="/logo.svg" alt="ChitChat Logo"></div><div><div class="text-xl font-medium text-black">ChitChat</div><p class="text-slate-500">You have a new message!</p></div></div> |
这种方式的优势在于:
- 开发速度极快:无需在 JSX 和 CSS 文件之间来回切换。
- 响应式设计:内置强大的响应式前缀(如
md:, lg:),轻松构建适配多端设备的界面。 - 高度可定制:可以通过
tailwind.config.js文件轻松定制颜色、间距、字体等所有设计元素。 - 无需担心命名冲突:因为我们几乎不写自定义的 CSS 类名。
表单处理: React Hook Form + Zod
React Hook Form 是一个高性能、灵活且易于使用的表单库。它通过非受控组件的方式来减少不必要的渲染,从而在处理复杂表单时获得极佳的性能。
我们将它与 Zod 结合,打造出既高性能又类型安全的表单。
1 | import { useForm } from 'react-hook-form'; |
这个组合拳让我们仅需维护一份 Zod schema,就同时搞定了表单的类型定义、运行时校验和错误信息管理。
组件库: shadcn/ui
shadcn/ui 不是一个传统的组件库(如 Ant Design 或 MUI)。它不提供预先打包好的 npm 包,而是提供了一系列你可以直接复制粘贴到自己项目中的、制作精良的 React 组件。
它的核心优势在于:
- 你拥有代码:组件的代码直接存在于你的项目中(通常在
src/components/ui),你可以随心所欲地修改它,而不必担心库的更新破坏你的样式。 - 基于 Tailwind CSS:所有组件都使用 Tailwind CSS 构建,与我们的技术栈完美契合,定制样式变得异常简单。
- 可访问性:组件底层基于 Radix UI,保证了良好的可访问性(WAI-ARIA 标准)。
通过其提供的 CLI 工具,我们可以轻松地将所需的组件(如 Button, Dialog, Input)添加到我们的项目中,然后像使用自己的组件一样使用和定制它们,极大地加速了高质量 UI 的开发进程。
质量保障与部署(拓展)
单元测试与集成测试 (Jest + React Testing Library)
编写测试是保障应用质量、防止未来代码迭代破坏现有功能的基石。
- 单元测试 (Unit Testing):专注于测试应用中最小的独立单元,比如一个自定义 Hook 或一个简单的 UI 组件,确保其在各种输入下都能返回预期的输出。
- 集成测试 (Integration Testing):测试多个组件协同工作的场景,确保它们组合在一起时能够正确地完成一项功能,例如,测试一个完整的表单提交流程。
在 React 生态中,最主流的测试组合是 Jest 和 React Testing Library (RTL)。
- Jest:一个功能全面的测试运行器(Test Runner),它提供了测试环境、断言库(Assertion Library)和 Mocking(模拟)等能力。
- React Testing Library:一个专注于测试组件行为的库。它的核心哲学是“像用户一样去测试”。我们不应该关心组件的内部实现细节(如 state 的值),而应该关心它在用户交互下渲染出的最终结果。例如,我们会测试“当点击这个按钮后,屏幕上是否出现了‘加载中...’的文本?”而不是“点击按钮后,组件的 isLoading state 是否变成了 true?”。
这种面向用户行为的测试方法,使得我们的测试用例更加健壮,即使未来重构了组件的内部逻辑,只要其对外行为不变,测试就依然能够通过。
端到端测试 (Cypress/Playwright) 简介
端到端(End-to-End, E2E)测试是最高层级的测试,它从头到尾完整地模拟一个真实用户的操作路径。E2E 测试会在一个真实的浏览器环境中自动执行一系列操作,如访问页面、输入文本、点击按钮、验证页面跳转和内容显示,从而确保整个应用(包括前端、后端接口、数据库等)作为一个整体能够正常工作。
对于我们的看板项目,一个典型的 E2E 测试用例可能是:
- 自动打开浏览器并访问登录页面。
- 输入用户名和密码并点击登录。
- 验证页面是否跳转到了看板主页。
- 点击“创建新列表”按钮,输入列表名称并确认。
- 验证新的列表是否出现在了看板上。
Cypress 和 Playwright 是当前最流行的 E2E 测试框架。它们提供了强大的 API 来驱动浏览器进行自动化操作和断言,并附带了优秀的调试工具,可以让我们清晰地看到每一步的执行情况。
Vite 构建与打包优化
我们在开发过程中使用的 npm run dev 命令启动的是一个为快速反馈而优化的开发服务器。当需要将应用部署到线上时,我们必须执行生产环境构建。
通过运行 npm run build 命令,Vite 会将我们的源代码进行一系列优化处理,生成一套高度优化的
静态文件(HTML, CSS, JavaScript),以便在用户的浏览器中实现最佳性能。这个过程主要包括:
- 打包 (Bundling):将多个模块文件合并成少数几个文件,以减少网络请求次数。
- 代码分割 (Code Splitting):智能地将代码拆分成多个小块(chunks),实现按需加载(例如,访问某个页面才加载该页面的代码)。
- Tree Shaking:自动移除代码中从未被使用过的“死代码”,减小打包体积。
- 压缩 (Minification):移除代码中的空格、注释,并缩短变量名,进一步压缩文件大小。
Vite 的构建过程开箱即用且性能卓越,为我们的应用上线提供了坚实的性能基础。
CI/CD 与自动化部署 (GitHub Actions, Vercel/Netlify)
手动将打包后的文件上传到服务器是一种低效且容易出错的部署方式。现代化的工作流采用 CI/CD(持续集成/持续部署)来实现部署的自动化。
- 持续集成 (Continuous Integration, CI):当开发者将代码推送到 GitHub 等代码仓库时,会自动触发一系列预设的检查流程,例如运行代码规范检查(ESLint)和自动化测试(Jest)。只有所有检查都通过,代码才被认为是“可集成的”。
- 持续部署 (Continuous Deployment, CD):一旦 CI 流程成功通过,会自动将应用构建并部署到生产服务器上,实现新版本的发布。
我们可以使用 GitHub Actions 来搭建 CI 流程。只需在项目中创建一个 YAML 配置文件,即可定义在代码推送时需要执行的命令。
而 Vercel 和 Netlify 则是极受欢迎的现代化前端应用托管平台。它们与 GitHub 等代码仓库深度集成。
我们只需将项目仓库授权给 Vercel/Netlify,它们就会自动完成所有部署工作:
- 监听到我们向主分支推送了新的代码。
- 拉取最新的代码。
- 自动执行 npm run build 命令进行打包。
- 将打包后的静态文件部署到其全球 CDN(内容分发网络)上。
- 新版本上线,全程无需人工干预。
源码与原理篇 - 迈向专家之路
React 核心工作流
你是否曾好奇,当你在组件中调用 setState 后,React 内部究竟发生了怎样一番天翻地覆的变化,才最终将新的 UI 精准地呈现在屏幕上?本章将为你揭示这个核心工作流,从虚拟 DOM 的概念,到开创性的 Fiber 架构,再到完整的协调过程,让你对 React 的每一次更新都了然于胸。
虚拟 DOM (Virtual DOM) 与 Diffing 算法
直接操作浏览器的真实 DOM 是一项成本高昂的操作。频繁地增删改查 DOM 元素,会引发浏览器的重排(Reflow)和重绘(Repaint),严重影响页面性能。为了解决这个问题,React 引入了一个设计——虚拟 DOM (Virtual DOM)。
虚拟 DOM,顾名思义,它并非真实的 DOM。它是一个存在于内存中的、轻量级的 JavaScript 对象,是真实 DOM 结构的一份“蓝图”或“快照”。
当组件的状态发生变化时,React 并不会立即去操作真实 DOM,而是会执行以下步骤:
- 根据新的状态,在内存中构建一棵新的虚拟 DOM 树。
- 将这棵新树与上一次渲染时保存的旧虚拟 DOM 树进行比较。
- 这个比较的过程,就是著名的 Diffing 算法。它能够高效地计算出两棵树之间的最小差异。
- 最后,React 将这些计算出的“差异”,以最优的方式、一次性地应用到真实的 DOM 上,从而最大限度地减少了对真实 DOM 的操作。
Diffing 算法之所以高效,是因为它建立在几个合理的启发式策略之上:
- 不同类型的元素会产生不同的树:如果一个元素的类型从
<div>变成了<span>,React 不会去尝试比较它们内部的差异,而是直接销毁旧的 div 及其所有子节点,然后创建一个全新的<span>。 - 可以通过
key属性来暗示元素的稳定性:在渲染一个列表时,key属性扮演着至关重要的角色。它就像是每个元素的“身份证”。通过key,React 能够识别出哪些元素只是移动了位置,而不是被删除和重新创建,从而进行高效的移动操作,而非销毁重建。这正是我们强调列表渲染中 key 重要性的根本原因。
深入 Fiber 架构:可中断、可恢复的渲染单元
在 React 16 之前,协调过程是同步且不可中断的。当一个大型组件树需要更新时,React 会递归地遍历整棵树,这个过程会长时间占用浏览器的主线程。如果此时用户进行了输入或点击,页面将无法响应,出现卡顿。
为了解决这个问题,React 团队重写了整个协调引擎,推出了 Fiber 架构。
Fiber 的核心思想是将渲染/更新过程拆分为许多小的、可独立处理的工作单元。每一个工作单元就是一个 Fiber 节点。React 不再是一口气递归地完成整个更新,而是以 Fiber 节点为单位进行处理。每完成一个或几个单元的工作后,React 都会将主线程的控制权交还给浏览器,让浏览器有机会去处理更高优先级的任务(如用户输入)。随后,React 会在下一次浏览器空闲时,从上次中断的地方继续它的工作。
一个 Fiber 节点是一个包含了组件类型、props、state、以及指向其父节点、子节点和兄弟节点的指针的 JavaScript 对象。这些指针将整个组件树连接成一个链表结构,使得遍历和工作调度可以随时暂停和恢复。
一个类比:
旧的协调机制就像是一次性把一幅长画卷从头画到尾,中途不能停笔。而 Fiber 架构则像是将画卷分割成无数个小方格,每次只画一格,画完一格就抬头看看有没有更紧急的事情,如果没有,再继续画下一格。
正是这种可中断、可恢复的特性,为 React 后续实现并发渲染等高级功能奠定了基础。
Reconciliation (协调) 过程详解
在 Fiber 架构下,从 setState 被调用到最终 UI 更新的完整协调过程,被清晰地划分为两个阶段:
第一阶段:Render Phase (渲染阶段)
- 这个阶段是异步的、可中断的。
- React 从根 Fiber 节点开始,遍历整个 Fiber 树。对于每一个节点,它会调用组件的渲染函数,执行 Diffing 算法,计算出需要进行的 DOM 变更(如“新增节点”、“更新属性”、“删除节点”等),并将这些变更信息记录在 Fiber 节点上。
- 由于这个阶段只是在内存中进行计算,并不会产生任何对用户可见的副作用,所以即使它被更高优先级的任务(如用户输入)打断,然后被丢弃或重做,也不会造成 UI 的不一致。
第二阶段:Commit Phase (提交阶段)
- 这个阶段是同步的、不可中断的。
- 一旦 Render Phase 成功完成,React 就拿到了一个完整的“变更清单”。它会进入 Commit Phase,将这个清单中的所有变更,一次性地、同步地应用到真实 DOM 上。
- 这个阶段必须是同步且不可中断的,以确保用户不会看到渲染到一半的、不完整的 UI 状态。这个过程通常非常快。
通过这两个阶段的分离,React 实现了既能处理耗时更新任务而又不阻塞用户界面的目标。
并发渲染揭秘
基于 Fiber 架构的可中断特性,React 19 正式将并发(Concurrency)作为其核心能力。并发不是一个具体的功能,而是一种更底层的渲染机制,它使得 React 应用能够更加流畅和智能地响应用户交互。
什么是并发?它解决了什么问题?
首先需要明确,并发不是并行。并行是指在同一时刻同时执行多个任务(需要多核处理器)。而并发是指一种能够同时处理多个任务,并在它们之间根据优先级进行切换的机制。
它解决的核心问题是渲染阻塞。设想一个场景:在一个搜索框中输入文字,下方会根据输入实时过滤一个巨大的列表。在没有并发的模式下,每一次按键都会触发列表的重新渲染。如果列表渲染耗时较长(比如 200ms),那么用户的输入操作就会被阻塞,感觉到明显的延迟和卡顿。
在并发模式下,React 能够更智能地处理这种情况。它会将用户的输入(高优先级)和列表的渲染(低优先级)识别为两个不同的更新任务。当用户正在输入时,React 会优先处理按键的反馈(让字符立即显示在输入框中),同时它可以中断正在进行的、耗时的列表渲染,等用户输入完毕后,再以最新的搜索词恢复列表的渲染工作。这使得应用始终保持高度的响应性。
startTransition 和 useTransition 的原理与应用
为了让开发者能够利用并发的能力,React 提供了 startTransition 和 useTransition 这两个 API。它们的作用是,让我们可以将某些状态更新标记为“非紧急”的。
- startTransition:这是一个函数,你可以将一个或多个 setState 调用包裹在其中。被包裹的更新将被视为“过渡更新”(Transition Update),React 会以较低的优先级来处理它,并且在渲染过程中允许被更高优先级的更新打断。
- useTransition: 这是一个 Hook,它返回一个 isPending 状态和一个 startTransition 函数。isPending 状态可以让我们在过渡更新正在进行时,向用户显示一个加载指示器,从而优化用户体验。
1 | import { useState, useTransition } from 'react'; |
在这个例子中,即使用户快速输入,输入框的响应也始终是流畅的,因为 setFilter 的更新被降级处理,不会阻塞主线程。
Lane 模型:任务优先级的内部实现
那么,React 内部是如何管理这些不同优先级的任务的呢?其核心机制就是 Lane 模型(在早期版本中是 ExpirationTime 模型)。
这是一个相对底层的概念,我们可以将其理解为一个任务优先级的表示系统。
- 每一个更新任务(如一次点击事件触发的 setState,或一次 startTransition 中的更新)都会被分配到一个或多个 “Lane”(车道)上。
- 不同的 Lane 代表了不同的优先级。例如,同步的、由用户交互直接触发的更新会分配到高优先级的 Lane,而过渡更新则会分配到低优先级的 Lane。
- React 的调度器在工作时,会像一个交通调度员一样,总是检查所有“车道”,并优先处理最高优先级车道上的任务。
Hooks 实现原理
Hooks 的出现极大地简化了 React 的组件逻辑,但它看似神奇的背后,依赖于一套简单而严格的规则和巧妙的内部实现。理解其原理,将彻底打消你对 Hooks 的所有困惑。
Hooks 为什么必须在顶层调用?
这是 React 面试中最经典的问题之一,答案也直指 Hooks 的核心实现机制。React Hooks 的规则是:禁止在循环、条件或嵌套函数中调用 Hooks。
原因在于:React 是依靠 Hooks 在每次渲染时的稳定调用顺序来识别和关联它们的状态的。
在 React 内部,它会为每个组件维护一个存储 Hooks 数据的链表(或数组)。当组件第一次渲染时,每调用一个 Hook (useState, useEffect 等),就会向这个链表中添加一个节点来存储该 Hook 的数据。
当组件重新渲染时,React 会重置一个指向链表头部的内部指针。每当再次执行到 Hooks 调用时,它就将指针后移一位,并读取该节点的数据。
1 | // 伪代码,描绘 React 内部的工作方式 |
如果我们将 Hooks 放在一个条件语句中,当条件在不同渲染中发生变化时,Hooks 的调用顺序就会被打乱。React 的指针就会指向错误的 Hook 数据节点,导致状态错乱,引发严重的 bug。因此,React 强制要求 Hooks 的调用顺序在每次渲染中都必须保持绝对一致。
useState 和 useEffect 的内部数据结构 (链表)
如上所述,每个函数式组件的 Fiber 节点上,都有一个 memoizedState 属性,它指向一个由该组件所有 Hooks 构成的链表的头节点。
- 对于 useState,链表中的每个节点(一个 Hook 对象)大致包含:
- memoizedState: 存储当前的状态值。
- queue: 一个用于存放待处理更新的队列(当你调用 setState 时,更新任务就被放入这个队列)。
- next: 指向下一个 Hook 节点的指针。
- 对于 useEffect,其对应的 Hook 对象则会存储:
- create: 副作用函数本身。
- deps: 依赖项数组。
- destroy: 上一次副作用返回的清理函数。
- next: 指向下一个 Hook 节点的指针。
正是这个在组件 Fiber 节点上稳定存在的链表结构,让函数式组件在多次渲染之间拥有了持久化状态和副作用的能力。
从源码角度理解 Hooks 的工作机制
我们无需深入源码的每一行,但理解其关键的函数调用流程,可以建立起一个清晰的心智模型。
当 React 渲染一个函数式组件时,它实际上是在调用一个名为 renderWithHooks 的内部函数。这个函数在执行我们编写的组件代码之前,会进行一系列准备工作,最主要的就是设置好当前正在渲染的组件上下文,并将内部的 Hooks 指针(cursor)重置到链表头部。
然后,它开始执行我们的组件函数。当我们的代码调用 useState 或 useEffect 时,这些 Hooks 函数能够从准备好的上下文中读取到当前组件的 Fiber 节点和正确的 Hooks 链表节点,从而返回正确的状态或注册副作用。
当我们的组件函数执行完毕后,renderWithHooks 还会进行一些收尾工作。这个“准备-执行-收尾”的包装过程,就是 Hooks 能够在看似普通的 JavaScript 函数调用中,与 React 的内部状态管理机制安全交互的秘密所在。
相关面试题
基础部分
- React 中的 key 有什么作用?为什么它在列表中是必须的?
- useState 和 useEffect 的作用分别是什么?请描述 useEffect 的依赖数组如何工作。
- 什么是 JSX?它和普通的 HTML 有什么区别?它最终会被编译成什么?
- 函数式组件和类组件有什么区别?你为什么更倾向于使用函数式组件?
- 如何在 React 中处理事件?React 的合成事件系统(SyntheticEvent)是什么?
进阶与 React 19 部分
- [React 19] 什么是 React Actions?它解决了传统表单处理的哪些痛点?
- [React 19] useActionState 和 useFormStatus 分别是用来做什么的?它们之间有什么关系?
- [React 19] 请解释 use Hook 的作用。它和 await 在异步函数中有什么异同?
- [React 19] 什么是乐观更新(Optimistic UI)?useOptimistic Hook 是如何帮助我们实现它的?
- [React 19] 你对 React Compiler 有什么了解?它试图解决什么问题?这对我们编写 React 代码的方式会有什么影响?
- useMemo 和 useCallback 的区别和使用场景是什么?在有了 React Compiler 之后,我们还需要手动使用它们吗?
- 什么是自定义 Hook?请举例说明你如何创建一个自定义 Hook 来复用逻辑。
- 如何在 React + TypeScript 项目中为组件的 Props 定义类型?React.FC 有什么优缺点?
实战与生态部分
- 请比较几种常见的 React 状态管理方案(如 Redux, Zustand, Context API)。
- 你在项目中使用过哪些样式方案?请谈谈 Tailwind CSS (Utility-First) 和 CSS-in-JS (如 styled-components) 的优缺点。
- React Router 是如何实现客户端路由的?Link 组件和 标签有什么不同?
- 什么是 React Testing Library?它的核心测试哲学是什么?
- 如何对一个 React 应用进行性能优化?请列举至少 3 种方法。
原理部分
- 请简述 React 的 Virtual DOM 和 Diffing 算法的工作原理。
- 什么是 React Fiber?它为什么被引入?
- 什么是并发渲染 (Concurrent Rendering)?它给用户体验带来了哪些提升?
- Hooks 的实现原理是什么?为什么 Hooks 不能在条件语句或循环中调用?
