第二章: React 核心原理深度转译
第二章: React 核心原理深度转译
Prorise第二章: React 核心原理深度转译
摘要: 本章是为 Vue 工程师量身定制的 React “翻译词典”。我们将剥离所有第三方库的干扰,专注于 React 框架自身的“第一性原理”。我们将逐一解构 React 的核心概念——组件、Props、State、生命周期与 Hooks,并将每一个概念都精确地与您所熟知的 Vue 3 Composition API 进行对等映射。学完本章,您将建立起从 Vue 到 React 的核心心智模型,为后续学习整个生态打下最坚实的基础。
在本章中,我们将像探索一幅画卷一样,循序渐进地揭开 React 的核心面纱:
- 首先,我们将从 组件定义 和 JSX 语法 开始,这是从 Vue 的模板系统到 React 声明式 UI 的第一次“范式转移”。
- 接着,我们将深入 Props 系统,理解 React 中单向数据流和组件通信的机制。
- 然后,我们将直面最核心的 State 与不可变性,将
useState
与 Vue 的ref
进行深度对比。 - 最后,我们将攻克 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 | <script setup lang="ts"> |
Greeting.tsx
1 | function 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 | const element = React.createElement( |
理解 JSX 的本质是 React.createElement()
的语法糖,是从 Vue 思维转向 React 思维的关键一步。这意味着,你在 JSX 中能做的一切,都受限于 JavaScript 的语法规则和能力。
在 JSX 中嵌入表达式
由于 JSX 就是 JavaScript,我们可以用 {}
在其中无缝嵌入任何有效的 JavaScript 表达式。
1 | function UserInfo() { |
1
2
3
4
5
<div>
<h2>Score Details</h2>
<p>Your score is: 95</p>
<p>Grade: A</p>
</div>
JSX 也是表达式
React.createElement()
函数的返回值是一个普通的 JavaScript 对象,这个对象被称为 “React 元素”。因此,JSX 本身也可以被当作一个值来使用——可以把它赋值给变量、作为函数参数传递,或者在 if
语句和 for
循环中使用。
1 | function getGreeting(isLoggedIn: boolean) { |
在掌握了 JSX 的基本语法后,我们来解决两个最常见的动态 UI 场景:如何根据条件显示或隐藏内容,以及如何渲染一个数据列表。这两种场景将进一步深化您对“React 使用纯 JavaScript 解决问题”这一核心思想的理解。
本小节核心知识点:
- React 中 没有
v-if
或v-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 | <script setup lang="ts"> |
LoginStatus.tsx
1 | function LoginStatus() { |
场景二:仅 if
(对标 v-if
/ v-show
)
当您只想在满足某个条件时才渲染某个元素,否则什么都不渲染时,逻辑与 (&&
) 运算符 是最优雅的捷径。
这是利用了 JavaScript 的“短路”特性:如果 &&
左侧的表达式为 false
,则整个表达式的结果就是 false
,React 不会渲染任何东西;如果左侧为 true
,则表达式的结果为 &&
右侧的 JSX 元素,React 会将其渲染出来。
Mailbox.vue
1 | <script setup lang="ts"> |
Mailbox.tsx
1 | function Mailbox() { |
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 | <script setup lang="ts"> |
TodoList.tsx
1 | function TodoList() { |
必须提供 key
Propkey
是 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 的 interface
或 type
来定义这个 props
对象的“形状”(Shape),从而实现比 Vue 更原生、更强大的类型约束。
让我们通过一个用户卡片组件来对比这个过程。
UserCard.vue
1 | <script setup lang="ts"> |
UserCard.tsx
1 | // 1. 使用 interface 定义 props 的类型契约 |
如何使用这个组件:
无论是在 Vue 还是 React 中,父组件使用子组件并传递 props
的方式都非常相似。
App.tsx (父组件)
1 | import UserCard from './UserCard' |
{}
在 JSX 属性中的使用规则:
- 传递字符串:
<UserCard name="Guest" />
- 传递其他类型 (数字, 布尔值, 对象, 变量等): 必须使用花括号
{}
,例如age={99}
,isPremiumUser={false}
。
2.2.2. 特殊的 Prop: children
在 Vue 中,我们使用 <slot>
机制来让父组件向子组件分发内容。React 中有一个更简单、更符合直觉的对等概念,那就是一个名为 children
的特殊 prop
。
当你在一个组件的起始标签和结束标签之间放置任何内容时,这些内容都会被收集起来,并通过一个名为 children
的 prop
传递给该组件。
核心对标: React 的 props.children
精确对标 Vue 的 默认插槽 (<slot />
)。
让我们创建一个通用的 Card
组件来演示 children
的用法。
Card.tsx
1 | import React from 'react' // 引入 React 以使用 ReactNode 类型 |
现在,我们可以在 App
组件中使用这个 Card
组件来包裹任意内容。
App.tsx
1 | import Card from './Card' |
props.children 的机制是 React 组合优于继承 设计哲学的核心体现。通过它,我们可以构建出高度灵活和可复用的布局组件(如 Card, Modal, Sidebar),而无需关心它们内部具体要渲染什么内容。
2.2.3. 属性钻探:问题识别及其对架构的影响
Props 是组件通信的基础,但如果滥用,它会引发一个常见且棘手的架构问题——属性钻探 (Prop Drilling)。
核心概念: 属性钻探 是指,为了将某个 prop 从顶层父组件传递给深层嵌套的子组件,不得不让所有中间层级的组件都去接收并向下传递这个 prop,即使这些中间组件本身根本不需要使用它。
痛点背景:
想象一个组件树结构:App -> UserProfile -> UserAvatar -> UserImage
。现在,App
组件持有一个 imageUrl
,但只有最深层的 UserImage
组件需要它来显示图片。
1 | - App |
为了让 imageUrl
到达 UserImage
,我们必须:
App
将imageUrl
作为 prop 传给UserProfile
。UserProfile
不使用imageUrl
,但必须接收它,再原封不动地传给UserAvatar
。UserAvatar
同样不使用imageUrl
,但必须接收它,再传给UserImage
。
这种层层传递就像用钻头打井一样,将属性“钻”过一个个组件层级,因此得名。
为什么这是一个问题?
- 代码冗余与耦合: 中间组件 (
UserProfile
,UserAvatar
) 的props
接口被迫包含了它们本不关心的属性,导致组件职责不清,与顶层数据源产生了不必要的耦合。 - 重构困难: 如果未来
UserImage
不再需要imageUrl
,或者需要一个新的 prop,你需要修改整条传递链路上的所有组件,维护成本极高。 - 可读性差: 当你阅读
UserProfile
的代码时,看到一个imageUrl
prop,你无法立即判断它是否被当前组件使用,还是仅仅是一个“二传手”。
何时应该警惕 Prop Drilling?
当一个 prop 的传递深度 超过两层,并且中间组件完全不使用它时,就应该将其视为一个需要解决的架构“坏味道”。
Prop Drilling 本身并不是一种错误,而是一种需要权衡的模式。对于浅层(1-2 层)的传递,它依然是最简单直接的方案。本节的目的是让您能够 识别 出过度钻探的场景,并了解我们将在后续章节中介绍的解决方案(如 Context
和状态管理库)。
2.3. State 与不可变性:从 ref
到 useState
如果说 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 | import { ref } from 'vue' |
思想:创建并替换
1 | import { useState } from 'react' |
2.3.2. useState
实战:构建一个计数器
让我们通过一个经典的计数器案例,来精确对比 ref
和 useState
的用法。
Counter.vue
1 | <script setup lang="ts"> |
Counter.tsx
1 | import { useState } from 'react'; |
2.3.3. 状态更新的进阶技巧(重要)
状态更新的异步性与函数式更新
一个常见的误解是认为调用 setCount(count + 1)
后,count
变量会立即更新。实际上,React 的状态更新可能是 异步的 和 批量处理的 (batched)。React 可能会将多次状态更新合并为一次,以优化性能。
这就带来一个问题:如果你基于当前 state 计算下一个 state,可能会因为 state 尚未更新而得到错误的结果。
错误的示例:
1 | function handleTripleIncrement() { |
解决方案:函数式更新
为了解决这个问题,状态更新函数可以接收一个 函数 作为参数。这个函数会自动接收 最新的、待处理的 state 作为其参数,并返回新的 state。
1 | function handleTripleIncrement() { |
最佳实践: 当你的新状态需要依赖于前一个状态时,总是 使用函数式更新的形式。
更新对象与数组状态
不可变性的原则在处理对象和数组时尤为重要。
更新对象:
1 | import { useState } from 'react'; |
更新数组:
1 | import { useState } from 'react'; |
常用的不可变数组操作包括:
- 添加:
setTodos([...todos, newTodo])
- 移除:
setTodos(todos.filter(todo => todo !== itemToRemove))
- 修改:
setTodos(todos.map(todo => todo === itemToUpdate ? updatedItem : todo))
2.3.4. useReducer
钩子:处理复杂状态逻辑
当一个组件的状态逻辑变得复杂,或者下一个状态依赖于前一个状态的多个部分时,useState
可能会变得笨拙和难以维护。此时,我们需要一个更强大的工具来组织状态变更。
本小节核心知识点:
useReducer
是useState
的一种替代方案,专为管理 复杂的状态对象和状态转换逻辑 而设计。- 它将 更新逻辑 (如何更新) 从组件的事件处理函数中抽离出来,集中到一个名为
reducer
的纯函数中,使得状态管理更加可预测和可测试。 - 精确对标: `useReducer` 的思想精确对标 Vuex/Pinia 在组件内部进行状态管理的模式 (
(state, action) => newState
)。
痛点背景:当 useState
变得力不从心
想象一个稍微复杂一点的计数器,它不仅能增加,还能减少、重置,甚至根据一个步长来增加。如果用 useState
来管理这个计数器的值和步长,代码可能会是这样:
1 | function ComplexCounter() { |
当操作类型更多(例如: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 | import { useReducer } from 'react'; |
优势总结:
- 逻辑内聚: 所有的状态转换逻辑都被收敛到了
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 | <table> |
如果我们的 Columns
组件为了满足“单一根元素”的规则,用一个 <div>
包裹了多个 <td>
,那么最终渲染出的 DOM 结构将是无效的,并很可能导致表格布局错乱。
错误的做法:
1 | // Columns.tsx |
解决方案:使用 Fragment
Fragment
就像一个看不见的包装器,它在组件的返回值中满足了“单一元素”的语法要求,但在最终的 DOM 渲染中会完全消失。
Columns.tsx
1 | function Columns() { |
最终渲染的有效 HTML:
1 | <tr> |
Columns.tsx
1 | import React from 'react'; // 需要导入 React |
何时使用长语法?
唯一的场景是当你需要为一个 Fragment
提供 key
prop 时,例如在循环渲染中使用 Fragment
。短语法 <></>
不支持任何属性。
1 | function Glossary({ items }) { |
总结: 在日常开发中,当你需要从组件返回多个并列元素时,优先使用 <></>
。它简洁且能解决绝大多数问题。只有在列表渲染需要 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 | <style scoped> |
Vue 的编译器会做两件事:
- 给你的组件模板中的每个元素添加一个唯一的
data
属性,例如<h1 class="title" data-v-f3f3eg9>
. - 将你的 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 | .title { |
MyComponent.tsx:
1 | // 1. 导入 .module.css 文件 |
优点: 编译时处理,零运行时开销。实现了和 scoped
同样的效果。
缺点: 类名需要通过 styles.xxx
的方式引用,稍微有点繁琐。
方案二:CSS-in-JS (e.g., Styled Components, Emotion)
这是在 React 社区非常流行的一种“万物皆 JS”的哲学体现。你直接在 JavaScript 文件中用模板字符串或对象来写 CSS。
工作方式: 你使用库提供的函数(如 styled
)来创建一个附加了样式的 React 组件。
Button.tsx (使用 Styled Components):
1 | import styled from 'styled-components'; |
优点: 样式的动态能力极强,可以访问组件的 props
和 state
。组件和它的样式被真正绑定在一起,实现了高内聚。
缺点: 存在一定的运行时性能开销(尽管现代库已经优化得很好)。
方案三:Utility-First CSS (Tailwind CSS - 2025 年推荐方案)
这是目前业界最受推崇的方案。它颠覆了传统的为组件编写独立 CSS 的思路。
工作方式: 你不再为组件写 CSS 类,而是直接在 JSX 中组合大量预设的、功能单一的 原子化 class。
UserProfile.tsx (使用 Tailwind CSS):
1 | function UserProfile({ name, role, imageUrl }) { |
优点: 开发速度极快,无需在 JS 和 CSS 文件间切换。样式高度一致。最终打包体积非常小(因为它会移除所有未使用的 class)。
缺点: 初学者可能会觉得 JSX “不干净”。需要一个适应过程来记忆常用的 class。
路线图建议:
- 如果您想快速找到一个与 Vue
scoped
体验最相似的替代品,请从 CSS Modules 开始。 - 如果您准备拥抱现代 React 生态的最佳实践,并追求极致的开发效率,我们强烈建议您直接学习并使用 Tailwind CSS。在我们后续的实战章节中,也将以 Tailwind CSS 作为主要的样式解决方案。
2.6. 事件处理:响应用户交互
到目前为止,我们已经学会了如何用 State 驱动 UI 变化,但变化的“扳机”——用户的交互——我们还未系统学习。本节将深入 React 的事件处理机制,完成从“静态”到“交互”的关键一步。
本小节核心知识点:
- React 的事件绑定遵循 小驼峰命名法 (camelCase),例如
onClick
、onCopy
、onMouseOver
。 - 传递给事件处理器的 必须是一个函数引用,而不是函数调用。例如,
onClick={handleClick}
是正确的,而onClick={handleClick()}
是错误的。 - 若要向事件处理函数传递参数,需要使用一个 内联箭头函数 进行包装,例如
onClick={() => handleDelete(id)}
。 - React 的事件对象是一个 合成事件 (SyntheticEvent) 对象,它抹平了主流浏览器之间的差异。
2.6.1. 核心语法:从 @click
到 onClick
痛点背景: 在 Vue 中,我们习惯于使用 @
符号(或 v-on:
)来监听 DOM 事件,语法简洁明了,如 @click="handleClick"
。React 的事件绑定在形式上更接近原生 JavaScript DOM 的 onclick
属性,但有一些关键区别。
范式转变:JSX 属性与函数引用
在 React 中,事件监听器是作为 JSX 元素的一个属性来提供的。你需要记住两个核心转换规则:
- 命名: 所有事件名都采用小驼峰式命名,例如
onclick
变为onClick
,onmouseover
变为onMouseOver
。 - 值: 传递给事件属性的值不再是字符串,而是一个用花括号
{}
包裹的 函数引用。
让我们通过以下提供的代码,将 Vue 和 React 的事件处理进行一次精确对比。
Button.vue
1 | <script setup> |
Button.tsx
1 | function Button() { |
React 支持所有标准 DOM 事件,我们只需要将它们转换为小驼峰命名即可。
Copy.tsx
1 | function Copy() { |
Move.tsx
1 | function Move() { |
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 | import { useState } from 'react' |
2.6.4. React 的合成事件对象
当你需要访问原生 DOM 事件对象时(例如,event.preventDefault()
或获取输入框的值 event.target.value
),React 会提供一个 合成事件 (SyntheticEvent) 对象。
这个对象是 React 对原生浏览器事件的跨浏览器包装器,它的接口与原生事件几乎完全相同,但保证了在所有浏览器中的行为一致性。
1 | function Form() { |
事件处理总结:
- 事件名使用
on
+EventName
的小驼峰形式。 - 处理器属性的值必须是
{函数引用}
。 - 传递参数需使用
={() => 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-index
与 overflow
的陷阱
想象一下,你正在构建一个位于深层嵌套组件中的“复制成功”提示框。这个父组件可能应用了一些 CSS 样式,比如 overflow: hidden
或 position: relative
,这会创建一个新的堆叠上下文 (stacking context)。
在这种情况下,即使你给提示框设置了很高的 z-index
,它的显示范围和层级也会被其父容器的样式所限制,导致它被意外裁剪或遮挡。
我们的目标:无论组件在 React 树中嵌套得多深,我们都希望它的视觉产物(例如一个模态框或提示)能够被渲染到顶层的 <body>
标签下,从而在视觉上覆盖页面的所有其他内容,不受父级 CSS 的影响。
2.7.2. 解决方案:使用 createPortal
React DOM 提供了一个名为 createPortal
的函数,它允许我们实现这种“传送”。
createPortal
接收两个参数:
child
: 任何可被渲染的 React 子元素,例如一段 JSX。container
: 一个真实存在的 DOM 节点,这是child
将被传送并挂载的目标位置。
现在,一步步实现一个“点击复制后在页面底部弹出提示”的功能。
第一步:准备 HTML “传送门”目标
首先,我们需要在 index.html
中预留一个 DOM 节点,作为我们提示框的渲染目标。
文件路径: index.html
1 |
|
现在,我们的 DOM 结构中有两个独立的“根”,#root
用于主应用,#portal-popup
专门用于接收被传送过来的内容。
第二步:创建 Portal 组件
接下来,我们创建 PopupContent
组件。这个组件的核心就是调用 createPortal
。
文件路径: src/components/PopupContent.jsx
1 | import { createPortal } from "react-dom"; |
第三步:在父组件中正常使用
Portal 最奇妙的一点在于:尽管 PopupContent
的 DOM 被渲染到了别处,但它在 React 组件树 中仍然是 CopyInput
的子组件。这意味着它可以正常接收来自 CopyInput
的 props(如 copied
),并且事件可以正常地从 PopupContent
冒泡到 CopyInput
。
文件路径: src/components/CopyInput.jsx
1 | import { useState } from 'react' |
核心洞见:React 树 vs. DOM 树Portal
的使用让我们清晰地看到了两个“树”的分离:
- React 组件树:
App -> CopyInput -> PopupContent
。逻辑关系是父子,props 和事件流都遵循这个结构。 - DOM 树:
PopupContent
渲染出的<div>
并不在CopyInput
渲染出的<div>
内部,而是作为#portal-popup
的子节点,与#root
处于同一层级。
这种分离,让我们可以将组件的 状态逻辑 与其 视觉呈现 在 DOM 中的位置解耦。
Portal 总结:
当你需要构建一个在视觉上需要“弹出”并覆盖其他元素的组件时(最典型的就是模态框),Portal
是最干净、最符合 React 官方推荐的实现方式。它能让你在享受组件化带来的便利的同时,彻底摆脱 CSS 堆叠上下文带来的烦恼。