第二章: React 核心原理深度转译

第二章: React 核心原理深度转译

摘要: 本章是为 Vue 工程师量身定制的 React “翻译词典”。我们将剥离所有第三方库的干扰,专注于 React 框架自身的“第一性原理”。我们将逐一解构 React 的核心概念——组件、Props、State、生命周期与 Hooks,并将每一个概念都精确地与您所熟知的 Vue 3 Composition API 进行对等映射。学完本章,您将建立起从 Vue 到 React 的核心心智模型,为后续学习整个生态打下最坚实的基础。


在本章中,我们将像探索一幅画卷一样,循序渐进地揭开 React 的核心面纱:

  1. 首先,我们将从 组件定义JSX 语法 开始,这是从 Vue 的模板系统到 React 声明式 UI 的第一次“范式转移”。
  2. 接着,我们将深入 Props 系统,理解 React 中单向数据流和组件通信的机制。
  3. 然后,我们将直面最核心的 State 与不可变性,将 useState 与 Vue 的 ref 进行深度对比。
  4. 最后,我们将攻克 React 中最重要也最易混淆的概念——副作用与 useEffect,将其与 Vue 的 watch 和生命周期钩子进行映射。

2.1. 组件定义与 JSX:从模板到函数的范式转移

在上一节中,我们已经成功初始化了项目。现在,让我们深入代码,探讨 React 世界最基本的构成单元——组件,以及它与 Vue 组件在思想和语法上的根本差异。

本小节核心知识点:

  • React 组件本质上是一个返回 UI 描述的 JavaScript 函数
  • 按照约定,组件函数的名称必须以 大写字母开头
  • 组件返回的“类 HTML”语法被称为 JSX (JavaScript XML),它是 JavaScript 的一种语法扩展,而非字符串或 HTML。
  • JSX 允许我们在“模板”中无缝地嵌入 JavaScript 逻辑(变量、表达式、函数调用)。

2.1.1. 核心差异:Vue SFC vs. React 函数组件

痛点背景: 习惯了 Vue 单文件组件 (SFC) 清晰的 <template><script><style> 三段式结构,初次接触 React 将所有内容都写在一个函数内的做法,会本能地感到困惑:“我的 HTML 结构在哪里?逻辑和视图混在一起,如何维护?”

范式转变:组件即函数

Vue 的 SFC 将“视图的结构”、“视图的逻辑”和“视图的样式”物理分离在三个标签中。而 React 的核心哲学是,UI (视图) 本身就是程序逻辑 (状态) 的一种映射结果。因此,将驱动 UI 的逻辑和 UI 的声明耦合在一起,被认为是一种 高内聚 的表现。一个 React 组件就是一个纯粹的函数,它接收一些输入 (Props),然后返回一段描述 UI 应该长什么样的“蓝图” (JSX)。

让我们通过一个最简单的组件来直观感受这种差异。

Greeting.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts">
// 逻辑层:在 <script> 块中定义数据和逻辑
const user = {
name: 'Prorise',
avatarUrl: 'https://placekitten.com/g/64/64'
}
</script>

<template>
<!-- 模板层:在 <template> 块中声明式地渲染 UI -->
<div class="greeting-card">
<img :src="user.avatarUrl" alt="User Avatar" />
<h1>Hello, {{ user.name }}!</h1>
</div>
</template>

<style scoped>
/* 样式层:在 <style> 块中定义样式 */
.greeting-card {
display: flex;
align-items: center;
}
</style>

Greeting.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
const user = {
name: 'Prorise',
avatarUrl: 'https://picsum.photos/64/64',
}

return (
(
<div className="Card">
<img src={user.avatarUrl} alt="" />
<h1>Hello {user.name}</h1>
</div>
)
)
}

export default App

关键语法转译:

  • Vue 中的指令 (如 :src, {{ user.name }}) 在 JSX 中被统一为使用花括号 {} 包裹的 JavaScript 表达式。
  • class 属性在 JSX 中必须写成 className,因为 class 是 JavaScript 的保留关键字。

2.1.2. JSX 深度解析:不是模板,而是 JavaScript

初学者最容易误解的一点是把 JSX 当成是 React 发明的“模板语言”。恰恰相反,JSX 是 JavaScript 语法的“超集”。你写的每一行 JSX 标签,最终都会被构建工具(如 Babel 或 Vite 内置的 SWC)转换为常规的 JavaScript 函数调用。

JSX 代码:

1
const element = <h1 className="greeting">Hello, world</h1>;

编译后的 JavaScript 代码 (示意):

1
2
3
4
5
const element = React.createElement(
'h1',
{ className: 'greeting' },
'Hello, world'
);

理解 JSX 的本质是 React.createElement() 的语法糖,是从 Vue 思维转向 React 思维的关键一步。这意味着,你在 JSX 中能做的一切,都受限于 JavaScript 的语法规则和能力。

在 JSX 中嵌入表达式

由于 JSX 就是 JavaScript,我们可以用 {} 在其中无缝嵌入任何有效的 JavaScript 表达式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function UserInfo() {
const user = {
firstName: 'Prorise',
lastName: 'Blog'
};

function formatName(user) {
return user.firstName + ' ' + user.lastName;
}

const element = <h1>Hello, {formatName(user)}!</h1>;
// -> <h1> Hello, Prorise Blog! </h1>

const score = 95;
const gradeInfo = (
<div>
<h2>Score Details</h2>
<p>Your score is: {score}</p>
<p>Grade: {score > 90 ? 'A' : 'B'}</p>
</div>
);

return gradeInfo;
}

JSX 也是表达式

React.createElement() 函数的返回值是一个普通的 JavaScript 对象,这个对象被称为 “React 元素”。因此,JSX 本身也可以被当作一个值来使用——可以把它赋值给变量、作为函数参数传递,或者在 if 语句和 for 循环中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getGreeting(isLoggedIn: boolean) {
if (isLoggedIn) {
return <h1>Welcome back!</h1>; // 返回一个 JSX 元素
}
return <h1>Please sign up.</h1>; // 返回另一个 JSX 元素
}

function App() {
const isLoggedIn = true;

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

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

本小节核心知识点:

  • React 中 没有 v-ifv-for 这样的模板指令。
  • 条件渲染 通过标准的 JavaScript 表达式来实现,主要是 **三元运算符** 和 **逻辑与 (`&&`) 运算符**。
  • 列表渲染 通过数组的 `.map()` 方法将数据项转换为一个 JSX 元素数组。
  • 在列表渲染中,为每个列表项提供一个稳定且唯一的 key prop 是至关重要的。

2.1.3. 条件渲染 (v-if vs. JavaScript 表达式)

痛点背景: Vue 的 v-if/v-else-if/v-else 指令提供了一套非常直观且功能完备的条件渲染语法,可以直接在模板中使用。React 如何用纯 JavaScript 实现等价的功能?

范式转变:UI 即函数返回值

回想一下 2.1.2 节的知识点:JSX 也是表达式。这意味着,我们可以将 JSX 元素作为 if 语句的返回值,或在三元表达式中使用。这赋予了我们用标准 JavaScript 流程控制来组织 UI 的能力。

场景一:if...else... (对标 v-if/v-else)

当您需要根据条件在两个 UI 块之间进行选择时,三元运算符 是最简洁、最常用的方式。

LoginStatus.vue

1
2
3
4
5
6
7
8
9
10
<script setup lang="ts">
const isLoggedIn = true;
</script>

<template>
<div>
<h1 v-if="isLoggedIn">Welcome back!</h1>
<h1 v-else>Please sign up.</h1>
</div>
</template>

LoginStatus.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function LoginStatus() {
const isLoggedIn = true;

return (
<div>
{/* 在 JSX 中嵌入三元表达式 */}
{isLoggedIn ? (
<h1>Welcome back!</h1>
) : (
<h1>Please sign up.</h1>
)}
</div>
);
}

场景二:仅 if (对标 v-if / v-show)

当您只想在满足某个条件时才渲染某个元素,否则什么都不渲染时,逻辑与 (&&) 运算符 是最优雅的捷径。

这是利用了 JavaScript 的“短路”特性:如果 && 左侧的表达式为 false,则整个表达式的结果就是 false,React 不会渲染任何东西;如果左侧为 true,则表达式的结果为 && 右侧的 JSX 元素,React 会将其渲染出来。

Mailbox.vue

1
2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts">
const unreadMessages = ['React is awesome', 'Vue is great too'];
</script>

<template>
<div>
<h1>Hello!</h1>
<h2 v-if="unreadMessages.length > 0">
You have {{ unreadMessages.length }} unread messages.
</h2>
</div>
</template>

Mailbox.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Mailbox() {
const unreadMessages = ['React is awesome', 'Vue is great too'];

return (
<div>
<h1>Hello!</h1>
{/*
如果 unreadMessages.length > 0 为 true,
则渲染 <h2> 标签。
否则,整个表达式为 0 (number),React 不会渲染 0。
*/}
{unreadMessages.length > 0 && (
<h2>
You have {unreadMessages.length} unread messages.
</h2>
)}
</div>
);
}

2.1.4. 列表渲染 (v-for vs. .map())

痛点背景: Vue 的 v-for 指令 (v-for="item in items" :key="item.id") 是渲染列表的直观语法。React 如何处理同样的需求?

范式转变:数据转换

React 将列表渲染视为一个标准的“数据转换”问题:我们有一个数据数组,需要将它 转换 成一个 React 元素(JSX)的数组。JavaScript 数组原生就提供了一个完美的方法来完成这个任务:.map()

.map() 的基本用法与 key 的重要性

.map() 方法会遍历数组的每一项,并根据您提供的回调函数的返回值,创建一个 新数组。在 React 中,我们正是利用它来生成一个 JSX 元素列表。

TodoList.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
const todos = [
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Build an app' },
{ id: 3, text: 'Ship it!' },
];
</script>

<template>
<ul>
<li v-for="todo in todos" :key="todo.id">
{{ todo.text }}
</li>
</ul>
</template>

TodoList.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function TodoList() {
const todos = [
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Build an app' },
{ id: 3, text: 'Ship it!' },
];

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

return (
// 3. 在 JSX 中渲染这个元素数组
<ul>{listItems}</ul>
);
}

必须提供 key Prop
key 是 React 用来识别列表中哪些项发生了变化(增、删、改、移动)的唯一标识。它帮助 React 的 “diffing” 算法能够高效地更新 UI。

  • key兄弟节点之间必须是唯一的
  • key 应该是一个 稳定 的标识符,通常是数据项中的 id
  • 绝对不要 使用数组的索引 index 作为 key,除非列表是纯静态、永不重排或筛选的。否则会导致严重的性能问题和 state bug。

2.2. Props 系统:组件通信的契约

在 Vue 中,我们使用 props 来实现父组件向子组件的数据传递。React 同样拥有 props 的概念,其核心思想与 Vue 完全一致:数据是自上而下、单向流动的

本小节核心知识点:

  • props (properties 的缩写) 是从父组件传递给子组件的数据。
  • 对于接收数据的子组件来说,`props` 是完全只读的 (read-only)。子组件绝对不能直接修改它接收到的 props 对象。这保证了单向数据流的可预测性。
  • 在函数组件中,props 是函数的 第一个参数
  • 结合 TypeScript,我们可以为 props 定义清晰的类型接口,建立组件之间严格的数据“契约”。

2.2.1. Props 的传递与接收

痛点背景: 在 Vue 3 的 <script setup> 中,我们使用 defineProps 宏来清晰地声明一个组件期望接收哪些 props 及其类型。这种方式直观且类型安全。React 如何实现类似的功能?

范式转变:函数参数与类型接口

在 React 中,props 的概念与 JavaScript 函数的参数几乎等同。当你在 JSX 中使用一个组件并为其添加属性时,React 会将这些属性收集到一个对象中,并将这个对象作为第一个参数传递给你的组件函数。

当结合 TypeScript 时,我们不再需要像 defineProps 这样的宏。取而代之的是,我们使用 TypeScript 的 interfacetype 来定义这个 props 对象的“形状”(Shape),从而实现比 Vue 更原生、更强大的类型约束。

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

UserCard.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
// 使用泛型参数为 props 定义类型
defineProps<{
name: string
age: number
isPremiumUser: boolean
}>()
</script>

<template>
<div class="user-card">
<h2>{{ name }} ({{ age }})</h2>
<p v-if="isPremiumUser">✨ Premium Member</p>
</div>
</template>

UserCard.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. 使用 interface 定义 props 的类型契约
interface UserCardProps {
name: string;
age: number;
isPremiumUser: boolean;
}

// 2. 在函数参数中应用类型,并使用解构赋值
// 这使得在组件内部可以直接使用 name, age 等变量
function UserCard({ name, age, isPremiumUser }: UserCardProps) {
return (
<div className="user-card">
<h2>{name} ({age})</h2>
{/* 使用逻辑与 (&&) 操作符进行条件渲染 */}
{isPremiumUser && <p>✨ Premium Member</p>}
</div>
);
}

export default UserCard;

如何使用这个组件:
无论是在 Vue 还是 React 中,父组件使用子组件并传递 props 的方式都非常相似。

App.tsx (父组件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import UserCard from './UserCard'

function App() {
const userData = {
name: 'Prorise',
age: 3,
isPremiumUser: true,
}
return (
<div>
<h1>User List</h1>
<UserCard
name={userData.name}
age={userData.age}
isPremiumUser={userData.isPremiumUser}
/>
<UserCard name="Guest" age={99} isPremiumUser={false} />
</div>
)
}

export default App;

{} 在 JSX 属性中的使用规则:

  • 传递字符串: <UserCard name="Guest" />
  • 传递其他类型 (数字, 布尔值, 对象, 变量等): 必须使用花括号 {},例如 age={99}isPremiumUser={false}

2.2.2. 特殊的 Prop: children

在 Vue 中,我们使用 <slot> 机制来让父组件向子组件分发内容。React 中有一个更简单、更符合直觉的对等概念,那就是一个名为 children 的特殊 prop

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

核心对标: React 的 props.children 精确对标 Vue 的 默认插槽 (<slot />)。

让我们创建一个通用的 Card 组件来演示 children 的用法。

Card.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from 'react' // 引入 React 以使用 ReactNode 类型

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

function Card({ children }: CardProps) {
// 2. 在 JSX 中渲染 children prop
return <div className="card-container">{children}</div>
}

export default Card;

现在,我们可以在 App 组件中使用这个 Card 组件来包裹任意内容。

App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Card from './Card'
import UserCard from './UserCard'

function App() {
return (
<div>
{/* 用法 1: 包裹简单的 JSX 元素 */}
<Card>
<h1>这是一个标题</h1>
<p>这是一个段落</p>
</Card>
{/* 用法 2: 包裹其他组件 */}
<Card>
<UserCard name="Prorise" age={3} isPremiumUser={true} />
</Card>
</div>
)
}

export default App

props.children 的机制是 React 组合优于继承 设计哲学的核心体现。通过它,我们可以构建出高度灵活和可复用的布局组件(如 Card, Modal, Sidebar),而无需关心它们内部具体要渲染什么内容。


2.2.3. 属性钻探:问题识别及其对架构的影响

Props 是组件通信的基础,但如果滥用,它会引发一个常见且棘手的架构问题——属性钻探 (Prop Drilling)

核心概念: 属性钻探 是指,为了将某个 prop 从顶层父组件传递给深层嵌套的子组件,不得不让所有中间层级的组件都去接收并向下传递这个 prop,即使这些中间组件本身根本不需要使用它。

痛点背景:
想象一个组件树结构:App -> UserProfile -> UserAvatar -> UserImage。现在,App 组件持有一个 imageUrl,但只有最深层的 UserImage 组件需要它来显示图片。

1
2
3
4
- App
- UserProfile
- UserAvatar
- UserImage

为了让 imageUrl 到达 UserImage,我们必须:

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

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

为什么这是一个问题?

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

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

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


2.3. State 与不可变性:从 refuseState

如果说 props 是组件从外部接收的“指令”,那么 state 就是组件自己内部维护、可以随时间变化的数据。它是组件交互和动态更新的源泉。

在前一节中,我们掌握了如何通过 Props 实现父子组件间的静态数据传递。但一个应用的核心是交互和变化。现在,我们将深入探讨 React 中最核心的概念:State(状态),以及它与 Vue 响应式系统在心智模型上的根本区别。

本小节核心知识点:

  • useState 是 React 提供的、用于在函数组件中添加和管理 内部状态 的核心 Hook。
  • useState 函数接收一个参数作为 初始状态,并返回一个包含两个元素的数组:[当前状态值, 状态更新函数].
  • React 的状态是 不可变的 (Immutable)。我们 绝不能 直接修改状态变量。
  • 必须使用 useState 返回的 状态更新函数 来替换旧的状态,从而触发组件的重新渲染。

2.3.1. 核心心智模型转换:从“直接修改”到“请求替换”

痛点背景: 在 Vue 3 中,我们通过 ref 创建响应式数据。其心智模型非常直观:我们通过修改 .value 属性来 直接改变 (mutate) 数据,框架会自动追踪这些变化并更新视图。例如:count.value++。这种方式符合大多数人的编程直觉。

范式转变:不可变性与状态替换

React 采取了截然不同的函数式编程思想。它认为状态应该是 不可变的。当你想要更新状态时,你不能在“原地”修改它,而是需要创建一个 新的状态值,然后调用状态更新函数来 “请求” React 用这个新值 替换 掉旧的值。

为什么是这样?
React 通过比较新旧两个状态对象的 引用地址 (Object Identity) 是否相同,来高效地判断是否需要触发组件的重新渲染。如果你直接修改了旧对象内部的属性,对象的引用地址并未改变,React 可能会认为没有任何变化,从而跳过渲染,导致 UI 不更新。

思想:直接修改

1
2
3
4
5
6
7
8
9
import { ref } from 'vue'

const count = ref(0)

// 直接修改 .value 属性
// Vue 的响应式系统会拦截这个操作
function increment() {
count.value++
}

思想:创建并替换

1
2
3
4
5
6
7
8
9
10
11
12
import { useState } from 'react'

const [count, setCount] = useState(0)

// 调用更新函数,传入一个全新的值
function increment() {
// 这是错误的!React 会忽略这个修改。
// count++

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

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

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

Counter.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

function handleIncrement() {
count.value++
}
</script>

<template>
<div>
<p>Count: {{ count }}</p>
<button @click="handleIncrement">+</button>
</div>
</template>

Counter.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState } from 'react';

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

// 2. 定义事件处理函数
function handleIncrement() {
// 3. 调用更新函数来触发状态变更
setCount(count + 1);
}

return (
<div>
<p>Count: {count}</p>
{/* 在 onClick 中绑定事件处理函数 */}
<button onClick={handleIncrement}>+</button>
</div>
);
}

export default Counter;

2.3.3. 状态更新的进阶技巧(重要)

状态更新的异步性与函数式更新

一个常见的误解是认为调用 setCount(count + 1) 后,count 变量会立即更新。实际上,React 的状态更新可能是 异步的批量处理的 (batched)。React 可能会将多次状态更新合并为一次,以优化性能。

这就带来一个问题:如果你基于当前 state 计算下一个 state,可能会因为 state 尚未更新而得到错误的结果。

错误的示例:

1
2
3
4
5
6
function handleTripleIncrement() {
setCount(count + 1); // 此时 count 仍然是旧值
setCount(count + 1); // 这里的 count 还是那个旧值
setCount(count + 1); // 这里的 count 依然是那个旧值
}
// 结果:count 只会增加 1!

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

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

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

更新对象与数组状态

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

更新对象:

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

function UserProfile() {
const [user, setUser] = useState({ name: 'Prorise', age: 3 });

function handleAgeIncrease() {
// 错误: 直接修改对象属性
// user.age++;
// setUser(user); // React 会认为 user 引用没变,不重新渲染

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

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

更新数组:

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

function TodoList() {
const [todos, setTodos] = useState(['Learn React', 'Learn Hooks']);

function handleAddTodo() {
const newTodo = 'Master State';

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

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

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

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

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

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

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

本小节核心知识点:

  • useReduceruseState 的一种替代方案,专为管理 复杂的状态对象和状态转换逻辑 而设计。
  • 它将 更新逻辑 (如何更新) 从组件的事件处理函数中抽离出来,集中到一个名为 reducer 的纯函数中,使得状态管理更加可预测和可测试。
  • 精确对标: `useReducer` 的思想精确对标 Vuex/Pinia 在组件内部进行状态管理的模式 ((state, action) => newState)。

痛点背景:当 useState 变得力不从心

想象一个稍微复杂一点的计数器,它不仅能增加,还能减少、重置,甚至根据一个步长来增加。如果用 useState 来管理这个计数器的值和步长,代码可能会是这样:

1
2
3
4
5
6
7
8
9
10
11
function ComplexCounter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);

const handleIncrement = () => setCount(c => c + step);
const handleDecrement = () => setCount(c => c - step);
const handleReset = () => setCount(0);

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

当操作类型更多(例如:multiply, divide),或者状态对象更复杂({ count, step, max, min })时,这种分散的 setXXX 调用会变得难以管理。

解决方案:使用 useReducer 集中管理状态逻辑

useReducer 接收三个参数:reducer 函数、initialState 初始状态,以及一个可选的 init 函数。它返回当前状态和一个 dispatch 函数。

  • reducer 函数: 一个形如 (state, action) => newState 的纯函数。它接收当前的状态和描述“要做什么”的 action 对象,然后计算并返回一个 全新的状态
  • dispatch 函数: 当你想要更新状态时,你不再调用 setXXX,而是调用 dispatch({ type: 'SOME_ACTION', payload: ... })。React 会将当前 state 和你派发的 action 传递给你的 reducer 函数,并将 reducer 的返回值作为新的状态。

让我们用 useReducer 来重构上面的复杂计数器:

ComplexCounter.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { useReducer } from 'react';

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

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

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

// 4. 编写 Reducer 纯函数,集中处理所有状态转换逻辑
function reducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
// 返回一个全新的状态对象
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return initialState;
default:
// 对于未知的 action 类型,保持状态不变
throw new Error('Unknown action type');
}
}

function ComplexCounter() {
// 5. 在组件中使用 useReducer
// state 是当前状态对象
// dispatch 是用来派发 action 的函数
const [state, dispatch] = useReducer(reducer, initialState);

return (
<div>
<p>Count: {state.count}</p>
{/* 6. 在事件处理中,调用 dispatch 来表达“意图” */}
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}

export default ComplexCounter;

优势总结:

  • 逻辑内聚: 所有的状态转换逻辑都被收敛到了 reducer 函数中,组件本身只负责派发“意图”(actions),不再关心“如何”更新状态。
  • 可测试性: reducer 是一个纯函数,它的输出只依赖于输入,不依赖于任何外部环境。这意味着我们可以脱离 React 组件,对它进行独立的单元测试。
  • 可预测性: 当状态出现问题时,我们只需要关注 reducer 函数和传入的 action 序列,极大地缩小了调试范围。

2.4. Fragments: 告别不必要的 <div> 包装

在 Vue 2 的时代,一个经典的规则是每个组件的 <template> 必须有一个唯一的根元素。如果你尝试返回多个并列的元素,编译器会报错。为了解决这个问题,我们常常被迫用一个不具备任何语义的 <div> 将它们包裹起来。

Vue 3 已经移除了这个限制,允许组件有多个根节点。React 从一开始就通过一个名为 Fragments 的特性来解决这个问题。

本小节核心知识点:

  • Fragment 允许你将多个子元素组合在一起,而无需向 DOM 添加额外的节点
  • 它是解决因 JSX 表达式必须返回单个元素而引入不必要 <div> 包装器的完美方案。
  • Fragment 有两种语法:长语法 <React.Fragment> 和更常用的短语法 <></>

痛点背景:破坏布局的额外 <div>

想象一下,你需要创建一个包含多列的表格行组件 Columns。HTML 的规范要求 <tr> 标签的直接子元素必须是 <td>

1
2
3
4
5
6
7
<table>
<tbody>
<tr>
{/* 这里必须直接是 <td> */}
</tr>
</tbody>
</table>

如果我们的 Columns 组件为了满足“单一根元素”的规则,用一个 <div> 包裹了多个 <td>,那么最终渲染出的 DOM 结构将是无效的,并很可能导致表格布局错乱。

错误的做法:

1
2
3
4
5
6
7
8
9
10
11
12
// Columns.tsx
function Columns() {
// 错误!因为 JSX 表达式必须返回单个元素,
// 我们用了一个 div 来包裹。
return (
<div>
<td>Column 1</td>
<td>Column 2</td>
</div>
);
}
// 渲染出的 HTML: <tr> <div> <td>...</td> </div> </tr> (无效!)

解决方案:使用 Fragment

Fragment 就像一个看不见的包装器,它在组件的返回值中满足了“单一元素”的语法要求,但在最终的 DOM 渲染中会完全消失。

Columns.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Columns() {
// 使用 <>...</> 短语法
return (
<>
<td>Column 1</td>
<td>Column 2</td>
</>
);
}

// 在 App.tsx 中使用
function App() {
return (
<table>
<tbody>
<tr>
<Columns />
</tr>
</tbody>
</table>
)
}

最终渲染的有效 HTML:

1
2
3
4
<tr>
<td>Column 1</td>
<td>Column 2</td>
</tr>

Columns.tsx

1
2
3
4
5
6
7
8
9
10
11
import React from 'react'; // 需要导入 React

function Columns() {
// 使用 <React.Fragment>...</React.Fragment> 长语法
return (
<React.Fragment>
<td>Column 1</td>
<td>Column 2</td>
</React.Fragment>
);
}

何时使用长语法?
唯一的场景是当你需要为一个 Fragment 提供 key prop 时,例如在循环渲染中使用 Fragment。短语法 <></> 不支持任何属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Glossary({ items }) {
return (
<dl>
{items.map(item => (
// 在 map 循环中,Fragment 需要一个 key
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</React.Fragment>
))}
</dl>
);
}

总结: 在日常开发中,当你需要从组件返回多个并列元素时,优先使用 <></>。它简洁且能解决绝大多数问题。只有在列表渲染需要 key 时,才换用 <React.Fragment>


2.5. 样式方案概览:从 scoped 到“万物皆 CSS”

对于 Vue 开发者来说,样式的处理方式是固定的、内置的、且极其舒适的:在 .vue 文件中写一个 <style scoped> 标签,框架会自动处理好一切,保证样式不会泄露到其他组件。

React 在这方面则完全不同。它本身 没有任何内置的样式解决方案。这既是它的灵活性所在,也是初学者(尤其是从 Vue 过来的开发者)最感困惑的地方之一。你需要自己选择并配置一个样式方案。

本小节核心知识点:

  • React 核心库不提供样式封装机制。
  • 样式隔离是一个需要通过社区方案来解决的架构问题
  • 主流方案包括 CSS Modules, CSS-in-JS, 以及现代最推荐的 Utility-First CSS (Tailwind CSS)

文化冲击:Vue 的 <style scoped> 是如何工作的?

在我们探讨 React 的方案之前,有必要先理解 Vue 的 scoped 做了什么。当你写下:

1
2
3
<style scoped>
.title { color: red; }
</style>

Vue 的编译器会做两件事:

  1. 给你的组件模板中的每个元素添加一个唯一的 data 属性,例如 <h1 class="title" data-v-f3f3eg9>.
  2. 将你的 CSS 选择器改写为 .title[data-v-f3f3eg9] { color: red; }

通过这种属性选择器的方式,Vue 实现了精准的样式作用域隔离。

React 生态中的样式方案

以下是 React 生态中最主流的几种样式方案,我们将从最接近 scoped 体验的方案讲起。

方案一:CSS Modules (最接近 scoped 的体验)

这可能是 Vue 开发者过渡到 React 时最容易接受的方案。Vite 已内置支持。

工作方式: 你将 CSS 文件命名为 [name].module.css (例如 MyComponent.module.css)。当你 import 这个文件时,它不会全局注入样式,而是返回一个对象,该对象将你写的类名映射到一个哈希过的、保证唯一的类名上。

MyComponent.module.css:

1
2
3
4
.title {
color: blue;
font-size: 24px;
}

MyComponent.tsx:

1
2
3
4
5
6
7
8
// 1. 导入 .module.css 文件
import styles from './MyComponent.module.css';

function MyComponent() {
// 2. 从 styles 对象中获取唯一的类名
// styles.title 的值可能是 " MyComponent_title__aB3xY "
return <h1 className={styles.title}>Hello, CSS Modules!</h1>;
}

优点: 编译时处理,零运行时开销。实现了和 scoped 同样的效果。
缺点: 类名需要通过 styles.xxx 的方式引用,稍微有点繁琐。

方案二:CSS-in-JS (e.g., Styled Components, Emotion)

这是在 React 社区非常流行的一种“万物皆 JS”的哲学体现。你直接在 JavaScript 文件中用模板字符串或对象来写 CSS。

工作方式: 你使用库提供的函数(如 styled)来创建一个附加了样式的 React 组件。

Button.tsx (使用 Styled Components):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import styled from 'styled-components';

// 创建一个 <Button> 组件,它自带样式
const Button = styled.button`
background-color: palevioletred;
color: white;
font-size: 1em;
padding: 0.25em 1em;
border: 2px solid palevioletred;
border-radius: 3px;

/* 可以引用 props 动态改变样式 */
${props => props.primary && `
background-color: white;
color: palevioletred;
`}
`;

function App() {
return (
<div>
<Button>Normal</Button>
<Button primary>Primary</Button>
</div>
);
}

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

方案三:Utility-First CSS (Tailwind CSS - 2025 年推荐方案)

这是目前业界最受推崇的方案。它颠覆了传统的为组件编写独立 CSS 的思路。

工作方式: 你不再为组件写 CSS 类,而是直接在 JSX 中组合大量预设的、功能单一的 原子化 class

UserProfile.tsx (使用 Tailwind CSS):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function UserProfile({ name, role, imageUrl }) {
return (
// 你通过组合这些 utility class 来构建 UI
<div className="p-6 max-w-sm mx-auto bg-white rounded-xl shadow-lg flex items-center space-x-4">
<div className="shrink-0">
<img className="h-12 w-12 rounded-full" src={imageUrl} alt="User Avatar" />
</div>
<div>
<div className="text-xl font-medium text-black">{name}</div>
<p className="text-slate-500">{role}</p>
</div>
</div>
);
}

优点: 开发速度极快,无需在 JS 和 CSS 文件间切换。样式高度一致。最终打包体积非常小(因为它会移除所有未使用的 class)。
缺点: 初学者可能会觉得 JSX “不干净”。需要一个适应过程来记忆常用的 class。
路线图建议:

  • 如果您想快速找到一个与 Vue scoped 体验最相似的替代品,请从 CSS Modules 开始。
  • 如果您准备拥抱现代 React 生态的最佳实践,并追求极致的开发效率,我们强烈建议您直接学习并使用 Tailwind CSS。在我们后续的实战章节中,也将以 Tailwind CSS 作为主要的样式解决方案。

2.6. 事件处理:响应用户交互

到目前为止,我们已经学会了如何用 State 驱动 UI 变化,但变化的“扳机”——用户的交互——我们还未系统学习。本节将深入 React 的事件处理机制,完成从“静态”到“交互”的关键一步。

本小节核心知识点:

  • React 的事件绑定遵循 小驼峰命名法 (camelCase),例如 onClickonCopyonMouseOver
  • 传递给事件处理器的 必须是一个函数引用,而不是函数调用。例如,onClick={handleClick} 是正确的,而 onClick={handleClick()} 是错误的。
  • 若要向事件处理函数传递参数,需要使用一个 内联箭头函数 进行包装,例如 onClick={() => handleDelete(id)}
  • React 的事件对象是一个 合成事件 (SyntheticEvent) 对象,它抹平了主流浏览器之间的差异。

2.6.1. 核心语法:从 @clickonClick

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

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

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

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

让我们通过以下提供的代码,将 Vue 和 React 的事件处理进行一次精确对比。

Button.vue

1
2
3
4
5
6
7
8
9
10
<script setup>
function handleClick() {
console.log('你点击了我');
}
</script>

<template>
<!-- 使用 @ 语法,值为函数调用字符串 -->
<button @click="handleClick">点击</button>
</template>

Button.tsx

1
2
3
4
5
6
7
function Button() {
const handleClick = () => console.log('你点击了我');

// 1. 事件名为小驼峰 onClick
// 2. 值为用 {} 包裹的函数引用 handleClick
return <button onClick={handleClick}>点击</button>;
}

React 支持所有标准 DOM 事件,我们只需要将它们转换为小驼峰命名即可。

Copy.tsx

1
2
3
4
5
6
7
8
9
10
11
function Copy() {
function copyHandler() {
console.log("请勿复制我的内容。");
}

return (
<p onCopy={copyHandler}>
这是一段受版权保护的文本,请勿随意复制。当你尝试复制时,控制台会输出一条信息。
</p>
);
}

Move.tsx

1
2
3
4
5
6
7
8
9
10
11
12
function Move() {
function moveHandler() {
alert("鼠标悬停事件触发");
console.log("鼠标悬停事件触发");
}

return (
<p onMouseOver={moveHandler}>
将你的鼠标移动到这段文字上,会触发一个 onMouseOver 事件,并弹出一个提示框。
</p>
);
}

2.6.2. 关键陷阱:函数引用 vs. 函数调用

这是从 Vue 过来的开发者最容易犯的错误之一。

  • onClick={handleClick}: (正确) 我们将 handleClick 函数 本身 作为 prop 传递给了 <button>。React 会持有这个函数的引用,并在用户点击按钮时 替我们调用它
  • onClick={handleClick()}: (错误) 这里我们 立即调用handleClick 函数,并将它的 返回值 (undefined) 传递给了 onClick。这意味着,在组件 渲染时,这个函数就会被执行一次,而用户实际点击时,什么都不会发生。

切记: 传递给事件监听器的必须是一个函数,而不是函数执行的结果。


2.6.3. 向事件处理函数传递参数

痛点背景: 在 Vue 中,如果需要传递参数,语法非常直观:@click="deleteItem(item.id)"。在 React 中,如果我们直接写 onClick={handleDelete(item.id)},就会掉入上面“函数调用”的陷阱。

解决方案:内联箭头函数

为了解决这个问题,我们需要在事件处理器外面再“包”一层函数。最简洁的方式就是使用内联箭头函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useState } from 'react'

function ItemList() {
const [items, setItems] = useState([
{ id: 1, name: '学习React' },
{ id: 2, name: '学习Hooks' },
])

function handleDelete(id: number) {
alert(`准备删除ID为${id}的项目`)
setItems(items.filter((item) => item.id !== id))
}

return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
<button onClick={() => handleDelete(item.id)}>删除</button>
</li>
))}
</ul>
)
}

export default ItemList

2.6.4. React 的合成事件对象

当你需要访问原生 DOM 事件对象时(例如,event.preventDefault() 或获取输入框的值 event.target.value),React 会提供一个 合成事件 (SyntheticEvent) 对象。

这个对象是 React 对原生浏览器事件的跨浏览器包装器,它的接口与原生事件几乎完全相同,但保证了在所有浏览器中的行为一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Form() {
// 事件处理函数会自动接收合成事件对象作为第一个参数
function handleSubmit(event: React.FormEvent) {
event.preventDefault(); // 阻止表单默认的提交刷新行为
console.log('表单已提交,但页面未刷新!');
}

return (
<form onSubmit={handleSubmit}>
<button type="submit">提交</button>
</form>
);
}

事件处理总结:

  1. 事件名使用 on + EventName 的小驼峰形式。
  2. 处理器属性的值必须是 {函数引用}
  3. 传递参数需使用 ={() => handler(arg)} 的箭头函数包装。

2.7. Portals:挣脱 DOM 束缚的传送门

通常情况下,一个组件返回的 JSX 会被挂载到其在 DOM 树中的父节点上。但有时,我们需要“打破常规”,将一个组件的视觉呈现“传送”到 DOM 树的其他位置——这正是 Portal 的用武之地。

本小节核心知识点:

  • Portal 提供了一种将子节点渲染到存在于父组件 DOM 结构之外的 DOM 节点的官方解决方案。
  • 它的核心使用场景是处理那些需要在视觉上“脱离”其容器的组件,如:模态框 (Modals), 弹出式菜单 (Popups), 提示框 (Tooltips)
  • 核心 API 是 ReactDOM.createPortal(child, container)
  • 精确对标: React 的 Portal 与 Vue 3 的 <Teleport> 组件在思想和用途上完全相同。

2.7.1. 痛点背景:CSS z-indexoverflow 的陷阱

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

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

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

2.7.2. 解决方案:使用 createPortal

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

createPortal 接收两个参数:

  1. child: 任何可被渲染的 React 子元素,例如一段 JSX。
  2. container: 一个真实存在的 DOM 节点,这是 child 将被传送并挂载的目标位置。

现在,一步步实现一个“点击复制后在页面底部弹出提示”的功能。

第一步:准备 HTML “传送门”目标

首先,我们需要在 index.html 中预留一个 DOM 节点,作为我们提示框的渲染目标。

文件路径: index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ... -->
<title>Prorise React Guide</title>
</head>
<body>
<!-- React 应用的主挂载点 -->
<div id="root"></div>
<!-- Portal 的专用挂载点 -->
<div id="portal-popup"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

现在,我们的 DOM 结构中有两个独立的“根”,#root 用于主应用,#portal-popup 专门用于接收被传送过来的内容。

第二步:创建 Portal 组件

接下来,我们创建 PopupContent 组件。这个组件的核心就是调用 createPortal

文件路径: src/components/PopupContent.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { createPortal } from "react-dom";


const PopupContent = ({ copied }: { copied: boolean }) => {
// 调用 createPortal
return createPortal(
// 第一个参数:要渲染的 JSX
<section>
{copied && (
<div
style={{
position: 'fixed',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
background: 'black',
color: 'white',
padding: '10px',
borderRadius: '5px',
}}
>
已复制到剪贴板
</div>
)}
</section>,
// 第二个参数:传送的目标 DOM 节点
document.querySelector('#portal-popup')
)
}

export default PopupContent;

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

Portal 最奇妙的一点在于:尽管 PopupContentDOM 被渲染到了别处,但它在 React 组件树 中仍然是 CopyInput 的子组件。这意味着它可以正常接收来自 CopyInput 的 props(如 copied),并且事件可以正常地从 PopupContent 冒泡到 CopyInput

文件路径: src/components/CopyInput.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { useState } from 'react'
import PopupContent from './PopupContent'

const CopyInput = () => {
const [inputValue, setInputValue] = useState('你好, Prorise!')
const [copied, setCopied] = useState(false)

const handleCopy = () => {
navigator.clipboard.writeText(inputValue).then(() => {
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 2000)
})
}

return (
// 注意,父组件的样式不会影响到 Portal
<div
style={{
position: 'relative',
marginTop: '6rem',
border: '1px solid red',
overflow: 'hidden',
}}
>
<input
placeholder='请输入内容'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button onClick={handleCopy}>复制</button>

{/* 从 CopyInput 的角度看,PopupContent 就是一个普通的子组件 */}
<PopupContent copied={copied} />
</div>
)
}

export default CopyInput

核心洞见:React 树 vs. DOM 树
Portal 的使用让我们清晰地看到了两个“树”的分离:

  • React 组件树: App -> CopyInput -> PopupContent。逻辑关系是父子,props 和事件流都遵循这个结构。
  • DOM 树: PopupContent 渲染出的 <div> 并不在 CopyInput 渲染出的 <div> 内部,而是作为 #portal-popup 的子节点,与 #root 处于同一层级。

这种分离,让我们可以将组件的 状态逻辑 与其 视觉呈现 在 DOM 中的位置解耦。

Portal 总结:
当你需要构建一个在视觉上需要“弹出”并覆盖其他元素的组件时(最典型的就是模态框),Portal 是最干净、最符合 React 官方推荐的实现方式。它能让你在享受组件化带来的便利的同时,彻底摆脱 CSS 堆叠上下文带来的烦恼。