React 版本:
react@18.x
组件类型
类组件 - Class Components(已过时)
React 早期用类组件,ES6 class 语法,通过生命周期方法管理状态和副作用。现在基本不用了,了解一下就行。
import { Component } from "react";
import { createConnection } from "./chat.js";
export default class ChatRoom extends Component {
state = {
serverUrl: "https://localhost:1234",
};
// 挂载后调用
componentDidMount() {
this.setupConnection();
}
// 组件更新调用
componentDidUpdate(prevProps, prevState) {
if (
this.props.roomId !== prevProps.roomId ||
this.state.serverUrl !== prevState.serverUrl
) {
this.destroyConnection();
this.setupConnection();
}
}
componentWillUnmount() {
this.destroyConnection();
}
setupConnection() {
this.connection = createConnection(this.state.serverUrl, this.props.roomId);
this.connection.connect();
}
destroyConnection() {
this.connection.disconnect();
this.connection = null;
}
// 渲染函数
render() {
return (
<>
<label>
Server URL:{" "}
<input
value={this.state.serverUrl}
onChange={(e) => {
this.setState({
serverUrl: e.target.value,
});
}}
/>
</label>
<h1>欢迎来到 {this.props.roomId} 聊天室!</h1>
</>
);
}
}
函数式组件 - Functional Components
现在都用函数式组件,用函数定义,代码更简洁,复用性也更强。这是 React 推荐的写法。
function Profile() {
return <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" />;
}
export default function Gallery() {
return (
<section>
<h1>了不起的科学家</h1>
<Profile />
<Profile />
<Profile />
</section>
);
}
注意:不要在函数组件内部再声明组件,会有性能问题。
为什么都用函数式组件?
Vue 和 React 都抛弃了 class 组件,主要是为了解决逻辑复用时的命名冲突和依赖注入问题。
- 函数式提供底层抽象能力,面向对象提供业务组织能力
- 函数式负责提高复用能力,简化代码实现,提升代码的信息密度
- 面向对象负责描述组件/模块之间的关系和业务逻辑,提高代码可读性
组件基础
组件概念
组件就是可复用的 UI 元素,每个 UI 模块都可以是一个组件。
React 组件本质上是 JavaScript 函数,但有两个特点:
- 名字必须以大写字母开头(小写会被当作 HTML 标签)
- 返回 JSX 标签
JSX
React 中 HTML 代码都要写成 JSX 格式。
JSX 的几个要点:
只能返回一个根元素:JSX 底层会被转成 JavaScript 对象,一个函数不能返回多个对象,所以多个 JSX 标签必须用一个父元素或 Fragment 包裹。
标签必须正确闭合:这个和 HTML 一样。
属性用驼峰命名:因为 JSX 会被转成 JavaScript 对象,属性名要符合 JS 变量命名规则。比如
class要写成className,onclick要写成onClick。
<img
src="https://i.imgur.com/yXOvdOSs.jpg"
alt="Hedy Lamarr"
className="photo"
/>
子组件
组件的 prop 通过函数参数传入。
子组件会通过 children 属性传递,这个属性会自动包含在组件的 props 里。
function Card({ title, children }) {
return (
<div className="card">
<div className="card-content">
<h1>{title}</h1>
{children}
</div>
</div>
);
}
// 子组件通过 children 属性传递进来并渲染,子组件采用嵌套方式编写,而不是声明在属性中
export default function Profile() {
return (
<div>
<Card title="Photo">
<img
className="avatar"
src="https://i.imgur.com/OKS67lhm.jpg"
alt="Aklilu Lemma"
width={70}
height={70}
/>
</Card>
<Card title="About">
<p>
Aklilu Lemma was a distinguished Ethiopian scientist who discovered a
natural treatment to schistosomiasis.
</p>
</Card>
</div>
);
}
条件表达式
JSX 可以像条件判断那样分开渲染不同的内容。
function Item({ name, isPacked }) {
let itemContent = name;
if (isPacked) {
itemContent = <del>{name + " ✔"}</del>;
}
return <li className="item">{itemContent}</li>;
}
export default function PackingList() {
return (
<section>
<h1>Sally Ride 的行李清单</h1>
<ul>
<Item isPacked={true} name="宇航服" />
<Item isPacked={true} name="带金箔的头盔" />
<Item isPacked={false} name="Tam 的照片" />
</ul>
</section>
);
}
条件渲染的几种方式:
- 可以用
if语句选择性地返回 JSX - 可以把 JSX 赋值给变量,然后用
{变量}嵌入 - 三元表达式:
{cond ? <A /> : <B />}- 条件为真渲染 A,否则渲染 B - 逻辑与:
{cond && <A />}- 条件为真渲染 A,否则不渲染 - 三元和逻辑与比较常用,但用
if也可以,看个人习惯
事件处理
事件基础
事件处理就是把函数作为 prop 传给元素,比如 <button onClick={handleClick}>。
重要:传的是函数本身,不是函数调用!onClick={handleClick} 是对的,onClick={handleClick()} 是错的。
事件处理函数可以在组件内部定义,也可以从父组件传下来。事件名用 onXXXX 格式,XXXX 就是浏览器事件名(首字母大写)。
事件会冒泡,用 e.stopPropagation() 阻止。阻止默认行为用 e.preventDefault()。
export default function Button() {
function handleClick() {
alert("你点击了我!");
}
return <button onClick={handleClick}>点我</button>;
}
事件传递
事件处理函数可以作为 prop 传给子组件,prop 的名字可以自己随便起,比如 onSmash、onClick 都可以。
function Button({ onSmash, children }) {
return <button onClick={onSmash}>{children}</button>;
}
export default function App() {
return (
<div>
<Button onSmash={() => alert("正在播放!")}>播放电影</Button>
<Button onSmash={() => alert("正在上传!")}>上传图片</Button>
</div>
);
}
捕获被阻止冒泡的事件
如果子组件用 stopPropagation() 阻止了冒泡,父组件可以用 onXXXCapture 在捕获阶段捕获事件。
事件流程:捕获阶段 → 目标阶段 → 冒泡阶段。Capture 在捕获阶段执行。
<div
onClickCapture={() => {
/* 这会首先执行 */
}}
>
<button onClick={(e) => e.stopPropagation()} />
<button onClick={(e) => e.stopPropagation()} />
</div>
useState Hook
useState 是最常用的 Hook,用来存储和修改组件的数据。
组件里的普通变量每次渲染都会重新定义,所以要用 useState 来保存状态。
要点:
- 需要在多次渲染间"记住"数据就用 state
- Hook 是以
use开头的函数,必须在组件顶层调用,不能在条件语句里 -
useState返回[当前值, 更新函数] - 一个组件可以有多个 state,React 按调用顺序匹配
- 每个组件实例的 state 是独立的
实现原理示例
let componentHooks = [];
let currentHookIndex = 0;
// useState 在 React 中是如何工作的(简化版)
function useState(initialState) {
let pair = componentHooks[currentHookIndex];
if (pair) {
// 这不是第一次渲染
// 所以 state pair 已经存在
// 将其返回并为下一次 hook 的调用做准备
currentHookIndex++;
return pair;
}
// 这是我们第一次进行渲染
// 所以新建一个 state pair 然后存储它
pair = [initialState, setState];
function setState(nextState) {
// 当用户发起 state 的变更,
// 把新的值放入 pair 中
pair[0] = nextState;
updateDOM();
}
// 存储这个 pair 用于将来的渲染
// 并且为下一次 hook 的调用做准备
componentHooks[currentHookIndex] = pair;
currentHookIndex++;
return pair;
}
function Gallery() {
// 每次调用 useState() 都会得到新的 pair
const [index, setIndex] = useState(0);
const [showMore, setShowMore] = useState(false);
function handleNextClick() {
setIndex(index + 1);
}
function handleMoreClick() {
setShowMore(!showMore);
}
let sculpture = sculptureList[index];
// 这个例子没有使用 React,所以
// 返回一个对象而不是 JSX
return {
onNextClick: handleNextClick,
onMoreClick: handleMoreClick,
header: `${sculpture.name} by ${sculpture.artist}`,
counter: `${index + 1} of ${sculptureList.length}`,
more: `${showMore ? "Hide" : "Show"} details`,
description: showMore ? sculpture.description : null,
imageSrc: sculpture.url,
imageAlt: sculpture.alt,
};
}
function updateDOM() {
// 在渲染组件之前
// 重置当前 Hook 的下标
currentHookIndex = 0;
let output = Gallery();
// 更新 DOM 以匹配输出结果
// 这部分工作由 React 为你完成
nextButton.onclick = output.onNextClick;
header.textContent = output.header;
moreButton.onclick = output.onMoreClick;
moreButton.textContent = output.more;
image.src = output.imageSrc;
image.alt = output.imageAlt;
if (output.description !== null) {
description.textContent = output.description;
description.style.display = "";
} else {
description.style.display = "none";
}
}
关于 state
state 每次渲染时都会存储当前 state 渲染值的快照,所以在同个渲染内多次更改 state 值,最终结果只是执行一次的作用效果。
一个 state 变量的值永远不会在一次渲染的内部发生变化。
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}
>
+3
</button>
</>
);
}
// 结果只递增一次,即为1
重要理解:
- setState 会触发重新渲染
- state 存在组件外面,React 帮你管理
- 每次渲染时,
useState返回的是这次渲染的 state 快照 - 每次渲染都是全新的,变量和函数都会重新创建
- 事件处理函数"看到"的是创建它那次渲染的 state 值
- 可以把 state 想象成每次渲染的快照,不会在渲染过程中改变
处理多次添加 - 更新函数
如果要在一次事件中多次更新 state,用更新函数:setNumber(n => n + 1) 而不是 setNumber(number + 1)。
- setState 不会立即改变当前渲染的值,只是请求重新渲染
- React 会把事件处理函数里的所有 setState 批量处理(批处理)
- 用更新函数可以基于最新的 state 计算新值
示例一:
import { useState } from "react";
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setNumber((n) => n + 1);
}}
>
增加数字
</button>
</>
);
}
React 会这样处理:
-
setNumber(number + 5):当前 number 是 0,所以是setNumber(5),React 把"替换为 5"加入队列 -
setNumber(n => n + 1):这是更新函数,React 把函数加入队列
下次渲染时,React 按顺序处理队列:
| 更新队列 | n |
返回值 |
|---|---|---|
"替换为 5" |
0(未使用) |
5 |
n => n + 1 |
5 |
5 + 1 = 6 |
React 会保存 6 为最终结果并从 useState 中返回。
示例二:
import { useState } from "react";
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setNumber((n) => n + 1);
setNumber(42);
}}
>
增加数字
</button>
</>
);
}
React 处理过程:
-
setNumber(number + 5)→ 加入队列:"替换为 5" -
setNumber(n => n + 1)→ 加入队列:更新函数 -
setNumber(42)→ 加入队列:"替换为 42"
下次渲染时处理队列:
| 更新队列 | n |
返回值 |
|---|---|---|
"替换为 5" |
0(未使用) |
5 |
n => n + 1 |
5 |
5 + 1 = 6 |
"替换为 42" |
6(未使用) |
42 |
然后 React 会保存 42 为最终结果并从 useState 中返回。
总结:
- 传更新函数(如
n => n + 1)→ 会加入队列,基于最新值计算 - 传普通值(如
5)→ 会加入队列,直接替换(后面的会覆盖前面的)
事件处理函数执行完后,React 才会触发重新渲染并处理队列。更新函数必须是纯函数,不能有副作用。严格模式下 React 会执行两次更新函数(但只用第二次结果)来帮助发现错误。
命名习惯
更新函数的参数通常用 state 变量名的首字母:
setEnabled((e) => !e);
setLastName((ln) => ln.reverse());
setFriendCount((fc) => fc * 2);
状态队列实现伪代码
export function getFinalState(baseState, queue) {
let finalState = baseState;
for (let update of queue) {
if (typeof update === "function") {
// TODO: 调用更新函数
finalState = update(finalState);
} else {
// TODO: 替换 state
finalState = update;
}
}
return finalState;
}
对象更新
对象不能直接修改属性,必须创建新对象。
- state 里的对象都是不可变的,不能直接改
- 直接改对象不会触发重渲染,还会破坏 state 快照
- 要用展开运算符创建新对象:
{...obj, key: newValue} - 展开运算符是浅拷贝,只复制一层
- 嵌套对象要一层层创建新对象,比较麻烦
- 可以用 Immer 库简化嵌套对象的更新
setPerson({
...person, // 复制上一个 person 中的所有字段
firstName: e.target.value, // 但是覆盖 firstName 字段
});
或者使用 Immer:
基于 Proxy 实现。
updatePerson((draft) => {
draft.artwork.city = "Lagos";
});
为什么不推荐直接修改 state?
几个原因:
-
调试方便:不直接修改的话,
console.log能看到每次渲染的 state 变化 -
性能优化:React 用
prevObj === obj判断是否需要重新渲染,直接修改会破坏这个机制 - 支持新功能:React 的一些新功能依赖 state 快照机制,直接修改会有问题
- 功能扩展:撤销/恢复、历史记录等功能需要保存 state 的历史版本,直接修改就做不到了
- 实现简单:不需要用 Proxy 劫持属性,性能更好
数组更新
不能直接用 push、pop、splice 这些会修改原数组的方法,要创建新数组。
常用方法:
- 添加:
[...arr, newItem]或arr.concat(newItem) - 删除:
arr.filter(item => item.id !== id) - 修改:
arr.map(item => item.id === id ? newItem : item) - 排序:先复制
[...arr].sort()
嵌套数组更新比较麻烦,可以用 Immer 库。
| 避免使用(会改变原始数组) | 推荐使用(会返回一个新数组) | |
|---|---|---|
| 添加元素 |
push,unshift
|
concat,[...arr] 展开语法 |
| 删除元素 |
pop,shift,splice
|
filter,slice
|
| 替换元素 |
splice,arr[i] = ... 赋值 |
map |
| 排序 |
reverse,sort
|
先将数组复制一份 |
兄弟组件共享状态
兄弟组件要共享状态,把 state 提升到共同的父组件,然后通过 props 传下去。
步骤:
- 把 state 移到父组件
- 通过 props 传给子组件
- 把更新函数也传下去,让子组件能改父组件的 state
组件分为两种:
- 受控组件:由父组件通过 props 控制
- 不受控组件:自己管理 state
import { useState } from "react";
export default function Accordion() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<>
<h2>哈萨克斯坦,阿拉木图</h2>
<Panel
title="关于"
isActive={activeIndex === 0}
onShow={() => setActiveIndex(0)}
>
阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997
年间都是首都。
</Panel>
<Panel
title="词源"
isActive={activeIndex === 1}
onShow={() => setActiveIndex(1)}
>
这个名字来自于 <span lang="kk-KZ">алма</span>
,哈萨克语中"苹果"的意思,经常被翻译成"苹果之乡"。事实上,阿拉木图的周边地区被认为是苹果的发源地,
<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
</Panel>
</>
);
}
function Panel({ title, children, isActive, onShow }) {
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? <p>{children}</p> : <button onClick={onShow}>显示</button>}
</section>
);
}
组件保留和重置
React 根据组件在树中的位置来缓存组件,位置不变就不会重新创建。
想让组件重置,可以:
- 改变组件在树中的位置
- 给组件加不同的
key
key 和 Vue 里的 key 一样,用来区分组件。切换 key 会强制组件重新渲染。
注意:
- state 和组件在树中的位置绑定,不是和 JSX 标签绑定
- 不要嵌套定义组件(在组件内部定义另一个组件),会导致 state 意外重置
useReducer
useReducer 用来管理复杂的状态逻辑,类似 Vuex 的思路。可以代替 useState,传入两个参数:
-
reducer:处理函数,根据 action 返回新 state -
initialState:初始值
useReducer 伪实现
import { useState } from "react";
export function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState);
function dispatch(action) {
setState(reducer(state, action));
}
return [state, dispatch];
}
返回 [state, dispatch],dispatch 用来触发 reducer,参数是 action 对象,传给 reducer 处理。
dispatch({
type: "added", // 通常添加 type 来指定行为类型
id: nextId++,
text: text,
});
reducer 实现示例
function tasksReducer(tasks, action) {
switch (action.type) {
case "added": {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case "changed": {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case "deleted": {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error("未知 action: " + action.type);
}
}
}
使用步骤:
- 写一个 reducer 函数,接收
(state, action)返回新 state - 用
useReducer替换useState - 在事件处理函数里
dispatch(action)
注意:
- reducer 必须是纯函数,不能有副作用
- 每个 action 代表一个用户操作
- reducer 代码会多些,但调试和测试更方便
- 嵌套对象更新可以用 Immer 简化
useState vs useReducer
简单对比:
-
代码量:
useState代码少,useReducer需要写 reducer 和 action。但多个地方用相似方式改 state 时,useReducer更简洁 -
可读性:简单逻辑用
useState,复杂逻辑用useReducer更清晰 -
调试:
useReducer可以在 reducer 里打日志,看每个 action 做了什么,更容易定位问题 - 测试:reducer 是纯函数,可以单独测试
- 个人习惯:看个人喜好,可以混用
什么时候用 useReducer:
- 状态更新逻辑复杂
- 多个地方用相似方式改 state
- 需要更好的调试体验
一个组件里可以同时用 useState 和 useReducer。
写 reducer 的注意事项
两点:
reducer 必须是纯函数:输入相同输出相同,不能有副作用(异步请求、定时器等)。要用不可变的方式更新对象和数组。
一个 action 代表一个用户操作:比如表单重置,dispatch 一个
reset_formaction,而不是分别 dispatch 五个set_fieldaction。这样日志清晰,方便调试。
useContext
useContext 类似 Vue 的 provide/inject,用来跨组件传递数据。
三部分:
-
createContext:创建 context -
context.Provider:包裹组件,提供值(类似 provide) -
useContext:获取值(类似 inject)
import { createContext } from "react";
export const LevelContext = createContext(0);
context.Provider 使用
useContext 获取的是最近一层 Provider 的 value 值。
import { useContext } from "react";
import { LevelContext } from "./LevelContext.js";
export default function Section({ children }) {
const level = useContext(LevelContext);
return (
<section className="section">
<LevelContext.Provider value={level + 1}>
{children}
</LevelContext.Provider>
</section>
);
}
useContext 使用
import { useContext } from "react";
import { LevelContext } from "./LevelContext.js";
export default function Heading({ children }) {
const level = useContext(LevelContext);
switch (level) {
case 0:
throw Error("Heading 必须在 Section 内部!");
case 1:
return <h1>{children}</h1>;
case 2:
return <h2>{children}</h2>;
case 3:
return <h3>{children}</h3>;
case 4:
return <h4>{children}</h4>;
case 5:
return <h5>{children}</h5>;
case 6:
return <h6>{children}</h6>;
default:
throw Error("未知的 level:" + level);
}
}
如果组件递归使用且包含 Provider,嵌套的子组件会获取到最近的 Provider 的值。比如 <Section> 组件,每层都会把 level + 1 传给子组件。
<Section>
假如 level 为 1,Provider 传递了 level + 1 = 2
<Section>此处 useContext 获取到的 level 为 2,传递下去的 level 为 3</Section>
</Section>
使用步骤:
-
export const MyContext = createContext(defaultValue)创建 context - 在父组件用
<MyContext.Provider value={...}>包裹子组件 - 在子组件用
useContext(MyContext)获取值
Context 会穿透中间所有组件,直接传给需要的组件。
注意:能用 props 传的就用 props,context 适合跨多层传递的场景。
useContext 的替代方法
- 递归传 props:思路清晰但麻烦
-
用 children:父组件通过
childrenprop 传递子组件
应用场景
- 主题切换:在顶层放 theme context,需要的地方读取
- 用户信息:登录状态、用户信息等全局数据
- 路由:大多数路由库内部都用 context 保存当前路由
-
状态管理:配合
useReducer做全局状态管理
Context 可以传动态值,Provider 的 value 变化时,所有使用该 context 的组件都会更新。所以经常和 state 一起用。
useRef
React 中的渲染内容
直接在函数里计算,值变化会触发重新渲染。
function App() {
const val = val1 + val2; // 相当于 vue 的 computed
return (
<>
<div>{val}</div>
</>
);
}
val1 或 val2 变化时,组件会重新渲染。
useRef 避免视图更新
useRef 用来存值,修改不会触发重新渲染,是 React 的"脱围机制"。
返回一个对象 { current: initialValue },通过修改 current 属性来存取值。
const ref = useRef(0);
ref.current = 5;
console.log(ref.current); // 5
useRef 实现伪代码
// React 内部
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
ref 和 state 的区别
ref 可以直接改 current,state 必须用 setState。大部分情况用 state,ref 是特殊情况下的"脱围机制"。
对比:
| ref | state |
|---|---|
useRef(initialValue)返回 { current: initialValue }
|
useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数 ([value, setValue]) |
| 更改时不会触发重新渲染 | 更改时触发重新渲染。 |
可变——你可以在渲染过程之外修改和更新 current 的值。 |
"不可变"——你必须使用 state 设置函数来修改 state 变量,从而排队重新渲染。 |
你不应在渲染期间读取(或写入) current 值。 |
你可以随时读取 state。但是,每次渲染都有自己不变的 state快照。 |
useRef 使用场景
- 存定时器 ID
- 存 DOM 元素引用
- 存不需要触发渲染的值
原则:需要触发渲染用 state,不需要触发渲染用 ref。
useRef 注意事项
- ref 是脱围机制:主要用于外部系统或浏览器 API,不要过度依赖
-
不要在渲染时读写
ref.current:会导致行为不可预测。唯一例外是初始化:if (!ref.current) ref.current = new Thing()
ref 和 state 的区别:
- state 是快照,不会同步更新
- ref 的
current是实时的,改了立即生效
ref.current = 5;
console.log(ref.current); // 5
这是因为ref 本身是一个普通的 JavaScript 对象,所以它的行为就像对象那样。
useRef 操作 DOM
类似 Vue 的 ref,可以获取 DOM 元素。
// 1
import { useRef } from 'react';
// 2
const myRef = useRef(null);
// 3
<div ref={myRef}>
// 4
myRef.current.scrollIntoView();
操作列表
操作列表时,ref 属性可以传回调函数,参数是 DOM 节点。用 Map 存多个节点的引用。
// 创建ref
const itemsRef = useRef(null);
// ref 获取函数,用于初始化和后续操作
function getMap() {
if (!itemsRef.current) {
// 首次运行时初始化 Map。
itemsRef.current = new Map();
}
return itemsRef.current;
}
// 修改ref保证 map 内存储的节点被实时更新
<li
key={cat.id}
ref={node => {
const map = getMap();
if (node) {
// 添加到 Map
map.set(cat.id, node);
} else {
// 从 Map 删除
map.delete(cat.id);
}
}}
>
如果只需要一个节点,可以用三元表达式判断赋值给哪个节点。
const selectedRef = useRef(null);
// 通过三元表达式判断 selectedRef 赋值给那个单独的元素节点
{
catList.map((cat, i) => (
<li key={cat.id} ref={index === i ? selectedRef : null}>
<img
className={index === i ? "active" : ""}
src={cat.imageUrl}
alt={"猫猫 #" + cat.id}
/>
</li>
));
}
flushSync 实现实时更新渲染
有时候用 ref 操作 DOM 时,需要获取 state 更新后的最新值。但 setState 不会立即更新 state,会在下次渲染时更新,所以更新前 state 还是旧值。
用 flushSync 包裹 setState,可以同步更新,让 ref 能拿到最新的值。
类似 Vue 的 nextTick,但 Vue 是微任务队列,React 是同步更新。
import { useRef, useState, flushSync } from "react";
export default function CatFriends() {
const selectedRef = useRef(null);
const [index, setIndex] = useState(0);
return (
<>
<nav>
<button
onClick={() => {
flushSync(() => {
// flushSync 实现 state 的同步更新
if (index < catList.length - 1) {
setIndex(index + 1);
} else {
setIndex(0);
}
});
// selectedRef.current 获取到的是 index 更新后的值,保证渲染正常
selectedRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}}
>
下一步
</button>
</nav>
<div>
<ul>
{catList.map((cat, i) => (
<li key={cat.id} ref={index === i ? selectedRef : null}>
<img
className={index === i ? "active" : ""}
src={cat.imageUrl}
alt={"猫猫 #" + cat.id}
/>
</li>
))}
</ul>
</div>
</>
);
}
forwardRef 传递 ref 到子组件
Vue 里 ref 绑组件获取的是组件实例,要用 expose 暴露方法。
React 用 forwardRef 包裹组件,ref 作为第二个参数传入,然后赋值给内部的 DOM 元素。
import { useRef } from "react";
import SearchButton from "./SearchButton.js";
import SearchInput from "./SearchInput.js";
export default function Page() {
const inputRef = useRef(null);
return (
<>
<nav>
<SearchButton
onClick={() => {
inputRef.current.focus();
}}
/>
</nav>
<SearchInput ref={inputRef} />
</>
);
}
这样父组件就能通过 ref 操作子组件的 DOM 元素了。
export default function SearchButton({ onClick }) {
return <button onClick={onClick}>搜索</button>;
}
import { forwardRef } from "react";
export default forwardRef(function SearchInput(props, ref) {
return <input ref={ref} placeholder="找什么呢?" />;
});
注意:
- ref 主要用于保存 DOM 元素引用
- 传给元素:
<div ref={myRef}>,React 会把 DOM 节点放到myRef.current - 通常用于非破坏性操作:聚焦、滚动、测量等
- 组件默认不暴露 DOM,用
forwardRef才能暴露 - 不要直接改 React 管理的 DOM,除非是 React 不会更新的部分(比如用 ref 改 input 的 value)
useEffect
useEffect 类似 Vue 的 watchEffect,用来处理副作用。
注意:开发环境严格模式下会执行两次,这是正常的,用来检查清理函数是否正确。生产环境只执行一次。
useEffect 在渲染之后执行,所以里面不能有触发渲染的代码,否则会死循环。
useEffect 三种用法
- 没有依赖数组:每次渲染后都执行(类似 watchEffect)
useEffect(() => {
// 每次渲染后执行
});
- 空依赖数组:只在挂载时执行一次
useEffect(() => {
// 只在组件挂载时执行
}, []);
- 有依赖数组:依赖变化时执行
useEffect(() => {
// a 或 b 变化时执行
}, [a, b]);
添加清理函数
类似 Vue 的 watch/watchEffect 返回清理函数,useEffect 返回的函数会在组件卸载或依赖变化前执行。
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect(); // 清理函数
};
}, []);
适用场景:
- 添加/移除事件监听器
- 创建/清除定时器
- 取消未完成的请求(避免竞态条件)
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
// 设置 ignore 去阻止执行
ignore = true;
};
}, [userId]);
不适用场景:
- 更新渲染列表(应该在事件处理函数里做)
- 响应事件更新 UI(effect 在渲染后执行,会导致两次渲染)
清理函数会在下次 effect 执行前或组件卸载时执行。
effect 替代方案
在 Effect 里用 fetch 请求数据很常见,但有几个问题:
- 不能在服务端执行(SSR 不友好)
- 容易产生网络瀑布(父组件请求完,子组件再请求)
- 无法预加载和缓存
- 需要处理竞态条件
更好的方案:
- 用框架的数据获取机制(Next.js、Remix 等)
- 用数据获取库:React Query、useSWR、React Router v6.4+
- 自己实现缓存逻辑
什么时候不用 Effect:
- 能在渲染期间计算的,直接用变量
- 缓存计算结果用
useMemo,不用useEffect - 重置 state 用不同的
key - 响应事件更新 UI,用事件处理函数
原则:Effect 用于"显示时执行"的副作用,其他情况优先考虑其他方案。
useMemo 代替 useEffect
Vue 用 computed 做计算属性,React 用 useMemo。需要缓存计算结果时用 useMemo。
console.time("筛选数组");
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // 如果 todos 或 filter 没有发生变化将跳过执行
}, [todos, filter]);
console.timeEnd("筛选数组");
useMemo vs useEffect
- 执行时机:useMemo 在渲染前,useEffect 在渲染后
- 用途:useMemo 缓存计算结果(类似 computed),useEffect 处理副作用(类似 watch/watchEffect)
useEffectEvent 添加无需依赖的响应式值
useEffectEvent 是 React 18 新增的,用来在 useEffect 中读取最新值但不触发重新执行。
问题:Effect 里读取的值必须加依赖,但有时只想读取最新值,不想响应变化。
例子:只在 url 变化时记录访问,但需要读取最新的 shoppingCart 长度。
function Page({ url, shoppingCart }) {
const onVisit = useEffectEvent((visitedUrl) => {
// 这里可以读取最新的 shoppingCart,但不会触发 Effect 重新执行
logVisit(visitedUrl, shoppingCart.length);
});
useEffect(() => {
onVisit(url);
}, [url]); // 只有 url 变化时才执行
// ...
}
注意:useEffectEvent 返回的函数不是响应式的,不要加到依赖数组里。
自定义 Hook
自定义 Hook 就是封装逻辑的函数,类似 Vue 的 composable。通常把重复的逻辑提取出来,不包含业务代码。
规则:
- 名字必须以
use开头 - 可以在组件间共享逻辑
- 共享的是逻辑,不是状态本身
- Hook 之间可以传值,会保持最新
- 每次渲染都会重新运行
- 必须是纯函数
- 事件处理函数用 useEffectEvent 包裹
- 不要写太通用的 Hook(如
useMount),保持具体
使用:把 useEffect 里的逻辑提取成自定义 Hook,让组件更简洁。
思考
1. 为什么都用函数式组件?
解决了逻辑复用时的命名冲突和依赖注入问题。函数式提供底层抽象,面向对象提供业务组织。函数式代码更简洁,复用性更好。
2. useState 更新函数和直接传值的区别?
直接传值会替换整个 state,更新函数基于当前值计算。多次 setState 时,更新函数会依次执行,直接传值后面的会覆盖前面的。
3. useEffect 和 useMemo 的区别?
- useMemo:渲染前执行,缓存计算结果(类似 computed)
- useEffect:渲染后执行,处理副作用(类似 watch/watchEffect)
计算值用 useMemo,副作用用 useEffect。
4. 什么时候用 useReducer?
状态更新逻辑复杂,或多个地方用相似方式改 state 时。useReducer 把逻辑分离出来,代码更清晰,也更好调试。
总结
- 组件:函数式组件 + Hooks,解决了类组件的逻辑复用问题
- 状态:useState 简单状态,useReducer 复杂状态,useContext 跨组件传值
- 副作用:useEffect 处理副作用,useMemo 缓存计算
- DOM:useRef 获取 DOM 引用,不触发渲染
- 复用:自定义 Hook 封装逻辑