React API 速查
约 4824 字大约 16 分钟
2025-12-17
useLayoutEffect
React 更新页面时大概是这样:
- React 算好虚拟 DOM
- DOM 已经改到页面上
- 浏览器 把页面画出来(paint)
- 才轮到
useEffect
useEffect 是 画完之后再跑,所以你有时候会看到页面「先闪一下,再变」
useLayoutEffect 它插队在第 3 步前面:
流程变成:
- React 算好虚拟 DOM
- DOM 已经改到页面上
- useLayoutEffect 立刻同步执行(还没画)
- 浏览器画页面
浏览器 必须等你这个 effect 跑完,才允许画页面
典型使用场景
你必须“读 DOM 或改 DOM”,而且不允许页面闪
比如:
- 量元素尺寸
- 立刻修正位置
- 同步滚动位置
- tooltip / 弹窗定位
- 根据真实 DOM 计算布局
useLayoutEffect(() => {
const width = ref.current!.offsetWidth
setWidth(width)
}, [])如果用 useEffect:
- 页面先画一次(尺寸不对)
- 再改
- 用户能看到抖动 / 闪一下
什么时候 千万别用
- 数据请求
- 日志
- 订阅
- 定时器
- 任何“跟布局没关系”的事
原因很简单:它会阻塞浏览器渲染,用多了直接卡页面
总结:useLayoutEffect:React 已经把 DOM 都更新完了,只差浏览器 paint,这时强行插队同步跑一段代码。
目的是:在用户看到页面之前,最后一次读 / 改 DOM。
useLayoutEffect 的“用法”和 useEffect 一模一样,区别只有“什么时候执行”。
你能做的事都一样:
- 依赖数组
- cleanup 函数
- 多个 effect
- 依赖变化触发
没有任何语法或写法差异。
唯一、也是全部的区别:执行时机
| 对比项 | useEffect | useLayoutEffect |
|---|---|---|
| DOM 是否已更新 | ✅ | ✅ |
| 浏览器是否已 paint | ❌ 已画 | ❌ 还没画 |
| 是否阻塞渲染 | ❌ | ✅ |
| 是否可能闪 | 有可能 | 不会 |
useCallback
useCallback 缓存的是“函数引用”(也就是同一个 function 对象),而不是缓存函数执行结果。
React 每次 render 时,你在组件里写的:
const onClick = () => setCount(c => c + 1)都会创建一个新的函数对象。如果你把这个函数作为 props 传给子组件,子组件就会看到 props 变了(引用变了),哪怕逻辑完全一样。
useCallback 的作用就是:在依赖不变时,返回同一个函数引用。
API 签名 & 参数
const memoizedFn = useCallback(callback, deps)参数 1:callback
- 类型:函数
- 语义:你想要稳定引用的那个函数
- 注意:React 不会自动给你“冻结”闭包里的变量,闭包读到的值取决于你怎么写依赖和函数体。
参数 2:deps(依赖数组)
- 类型:数组(
DependencyList) - 语义:当 deps 中任意一项发生变化时,React 会返回一个新的函数引用(也就是重新“生成”并记住这次的 callback)
- 规则:跟
useEffect、useMemo完全一样- 依赖比对是 Object.is(浅比较)
[]表示“只创建一次”(但仍要小心闭包读到的是否是“旧值”)- deps 里应包含 callback 中用到的所有来自组件作用域的变量/函数/props/state(除了一些“稳定”的东西)
使用场景
把 callback 传给 React.memo 的子组件,子组件用 React.memo 做了 props 浅比较,稳定引用能避免子组件无意义重渲染。
callback 是某个 hook 的依赖,比如 useEffect/useMemo/自定义 hook 依赖它,不稳定会导致 effect 每次都跑。
传给第三方组件/订阅系统的 handler 需要稳定,例如事件订阅、addEventListener、WebSocket 回调等(当然同时要正确处理闭包值)。
闭包与“旧值”坑
坑 1:[] 导致读到旧 state/props
const [count, setCount] = useState(0)
const inc = useCallback(() => {
setCount(count + 1) // count 永远是第一次 render 的 0
}, [])正确写法 1:把 count 放进 deps
const inc = useCallback(() => {
setCount(count + 1)
}, [count])正确写法 2(更推荐):用函数式更新,避免依赖 count
const inc = useCallback(() => {
setCount(c => c + 1)
}, []) // 这里可以安全用 []一般你要写“所有外部引用”,但有几类在 React 里通常被认为是稳定的:
setState(比如setCount)在同一组件生命周期内引用稳定dispatch(useReducer返回的)通常稳定ref对象本身(useRef返回的对象)稳定;但ref.current会变,这个是“值”不是“引用”- React 官方保证的一些 hook 返回值可能稳定
但实话:最稳妥是让 eslint 的 react-hooks/exhaustive-deps 帮你管,除非你非常确定。
React.memo
不用 React.memo 的普通组件
function Child({ name }) {
console.log('Child render')
return <div>{name}</div>
}只要 父组件重新 render,Child 就一定会重新 render,不管 props 有没有变。
用了 React.memo 的子组件
const Child = React.memo(function Child({ name }) {
console.log('Child render')
return <div>{name}</div>
})现在规则变成:
父组件 render 时,React 会先“对比这次和上次的 props”
- 如果 props 看起来一样 → 跳过 Child 的 render
- 如果 props 有变化 → 才 render Child
这一步就叫:props 浅比较
什么是“props 浅比较”?
浅比较 = 只比「引用」,不比「内容」
React.memo 默认的比较逻辑是:
Object.is(prevProps.xxx, nextProps.xxx)典型的例子
const Child = React.memo(function Child({ onAdd }) {
console.log('Child render')
return <button onClick={onAdd}>+1</button>
})
function Parent() {
const [count, setCount] = React.useState(0)
const [other, setOther] = React.useState(0)
const onAdd = () => {
setCount(c => c + 1)
}
return (
<>
<Child onAdd={onAdd} />
<button onClick={() => setOther(o => o + 1)}>
change other
</button>
</>
)
}你点 change other 会发生什么?
Parent的other变了 → Parent 重新 renderonAdd在 Parent 里重新定义了一次 新函数对象React.memo对比 props:prev.onAdd !== next.onAdd // true结论:Child 必须重新 render
即使:
onAdd代码一模一样count根本没变- Child 实际 UI 没任何变化
加上 useCallback(关键)
const onAdd = React.useCallback(() => {
setCount(c => c + 1)
}, [])现在再点 change other:
Parent render(没办法)
useCallback返回 同一个 onAdd 函数引用React.memo对比 props:prev.onAdd === next.onAdd // trueChild 直接跳过 render
useRef
useRef(initialValue) 返回一个稳定不变的容器对象:{ current: ... }
- 跨渲染保持同一个对象引用(identity 稳定)
- 修改
ref.current不会触发组件重新渲染 - 常见用途:拿 DOM / 组件实例、保存可变值、做“逃逸”状态、和事件/计时器结合
获取 DOM
function Demo() {
const inputRef = React.useRef(null);
React.useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}关键点
- 初始
current是null - 挂载后 React 会把 DOM 节点赋给
inputRef.current - 卸载时 React 会把
current设回null
useRef 的类型
DOM 节点
const divRef = useRef<HTMLDivElement | null>(null);保存可变业务值
const timerIdRef = useRef<number | null>(null); // 浏览器 setTimeout 返回 number回调 ref
<div ref={(node) => { /* node 是 DOM 节点或 null */ }} />回调参数 node 的含义
- 元素挂载/更新时:
node是对应的 DOM 节点(或类组件实例) - 元素卸载或 ref 被切换时:
node会变成null
你需要正确处理 null,否则容易报错。
示例:标准 List 组件
import { useRef, useCallback } from "react";
type Cat = {
id: number;
name: string;
};
export default function List() {
// 用 Map 保存多个 DOM 节点(key 必须稳定)
const itemRefMap = useRef<Map<number, HTMLLIElement>>(new Map());
const cats: Cat[] = [
{ id: 1, name: "Whiskers" },
{ id: 2, name: "Fluffy" },
];
// 稳定的 ref 工厂函数
const setItemRef = useCallback(
(id: number) => (node: HTMLLIElement | null) => {
if (node) {
// mount / attach
itemRefMap.current.set(id, node);
} else {
// unmount / detach(commit 阶段触发)
itemRefMap.current.delete(id);
}
},
[]
);
// 示例:使用保存的 DOM
const scrollTo = (id: number) => {
const el = itemRefMap.current.get(id);
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
};
return (
<>
<ul>
{cats.map((cat) => (
<li key={cat.id} ref={setItemRef(cat.id)}>
{cat.name}
</li>
))}
</ul>
<button onClick={() => scrollTo(2)}>
滚动到 Fluffy
</button>
</>
);
}回调 ref 可以返回一个清理函数,在节点卸载或 ref 变化时执行清理。
| 对比点 | useRef | useState |
|---|---|---|
| 更新是否触发渲染 | ❌ 不触发 | ✅ 触发 |
| 值是否跨渲染保留 | ✅ | ✅ |
| 适合存什么 | 可变、非 UI 驱动的值;DOM | UI 状态 |
| 更新方式 | ref.current = x | setState(x) |
闭包陷阱
问题:事件/定时器里读到旧 state
function Demo() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 可能一直是旧值
}, 1000);
return () => clearInterval(id);
}, []); // count 没进依赖
}解决:用 ref 保存最新值
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);常见坑
别在渲染阶段读写 ref 来驱动 UI,因为写 ref 不会触发渲染,UI 不会更新;读 ref 也可能造成逻辑不透明。UI 变化还是用 state。
不要把 ref.current 放进依赖数组指望它触发 effect, ref.current 变了不会触发重新渲染,所以依赖数组也不会“感知”。如果你需要“变化驱动 effect”,用 state 或者在写 ref 的地方顺便触发一些逻辑。
回调 ref 记得稳定引用(否则可能频繁置 null 再置 node),用 useCallback 包起来,特别是你在 ref 回调里做了昂贵操作时。
StrictMode 下回调/Effect 可能执行两次(开发环境),写 ref 相关副作用要幂等、可重复执行且能清理。
关于 ref 回调的清理逻辑
当元素被移除时:
- React 在 render 阶段算出「这个 JSX 对应的 Fiber 不再需要」
- 进入 commit 阶段,先更新真实 DOM(把该节点移除)
- 然后立刻调用该元素的 callback ref,并传入
null - 你的
ref={(node) => { ... }}收到node === null,执行清理逻辑
不是你“去判断 DOM 是否存在”,而是 React 主动告诉你:它已经不存在了。
整个过程
render 阶段(纯计算,无副作用)
旧 JSX: <li key=1 />
新 JSX: (没有 key=1)React 得出结论:
这个 Fiber 对应的 DOM 节点需要被删除
这里不会调用 ref
commit 阶段 · DOM 变更
React 执行:
parent.removeChild(li)现在这个 <li>:
- 已经不在 DOM 树中
real DOM状态已更新
commit 阶段 · ref 清理
React 执行 callback ref:
ref(null);于是你的代码被调用:
(node) => {
if (node) {
mapRef.current.set(cat, node);
} else {
mapRef.current.delete(cat); // ← 就在这里
}
}这一步发生在:
- DOM 已经移除之后
- effect cleanup 之前或同一轮 commit 中
细节
node === null≠ “组件重新 render 时顺便判断”node === null= React 在 commit 阶段明确通知你:这个节点已经不存在
总结:callback ref 的清理不是 render 触发的,而是 commit 阶段由 React 主动触发的;
当元素被移除时,React 会调用 ref(null),此时应当释放与该 DOM 相关的引用或副作用。
两种清理写法:if (node === null) vs return cleanup
在 callback ref 中,有两种等价的清理表达方式。
写法一:if (node === null)(经典 / 主协议)
ref={(node) => {
if (node) {
mapRef.current.set(cat.id, node);
} else {
mapRef.current.delete(cat.id);
}
}}语义:
node非空 → DOM 已挂载node === null→ DOM 已移除,执行清理
特点:
- callback ref 的原始协议(长期稳定)
- 语义清晰、直观
- 适合组件库 / 团队代码 / 需要长期维护的工程
写法二:return cleanup(React 19 新能力)
ref={(node) => {
if (node) {
mapRef.current.set(cat.id, node);
}
return () => {
mapRef.current.delete(cat.id);
};
}}语义:
- DOM 挂载时,React 记录返回的 cleanup
- DOM 移除 / ref 变化时,React 在 commit 阶段调用 cleanup
特点:
- 触发时机与
node === null本质相同(commit 阶段) - 语义更接近
useEffect的 setup / cleanup 模型 - 属于较新的能力,依赖 React 19 及相关运行时支持
forwardRef
forwardRef:让 函数组件 能“接住”父组件传下来的 ref
注意
在 React 19(以及 18 的新 JSX transform + 编译器支持)里:ref 已经可以像普通 prop 一样直接传给函数组件,不再强制需要 forwardRef。
import { useRef } from 'react';
function MyInput({ ref }) {
return <input ref={ref} />;
}
export default function MyForm() {
const inputRef = useRef(null);
function handleClick() {
// 使用父组件的 ref 访问子组件 DOM
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}useImperativeHandle
useImperativeHandle 用来自定义父组件通过 ref 能访问到的内容。
不用它:父组件拿到的通常是 真实 DOM 节点(或 class 实例)
用了它:父组件拿到的是你定义的 “命令句柄(handle)对象”,从而:
- 隐藏内部 DOM 细节
- 只暴露必要的命令(focus / clear / validate…)
- 让组件 API 更稳定、更安全
直觉类比:你在给组件做一个“对外接口”,ref 只是传输通道。
API 签名与参数
useImperativeHandle(ref, createHandle, deps?)ref
- 来自父组件传入的
ref - 在 React 19+ 可以直接从 props 里拿:
function C({ ref }) {} - 在需要兼容旧版本时通常来自
forwardRef((props, ref) => ...)
ref 可以是对象 ref(最常见) (回调 ref 也存在,但日常更建议对象 ref + useRef)
createHandle
一个函数,返回你希望暴露给父组件的对象:
() => ({
focus() {},
clear() {},
getValue() {},
})这个返回值会成为:ref.current
deps
控制“什么时候重新生成 ref.current”
不写 deps:会更频繁重建句柄(并发/严格模式下更容易踩坑)
常见写法:
- 句柄不依赖
props/state:[] - 句柄依赖某些值:
[a, b]
useImperativeHandle 不写 deps ≠ 写 []
- 不写
deps:每一次 render 都会重新创建 handle,并更新 ref - 写
[]:只在首次挂载时创建一次 handle
useImperativeHandle 不会帮你拿 DOM
DOM 通常仍由你内部用 useRef 自己拿到,然后在 handle 方法里间接操作。
function MyInput({ ref }) {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => realInputRef.current?.focus(),
}), []);
return <input ref={realInputRef} />;
}父组件拿到的是:
ref.current = { focus: f }而不是 <input>。
示例
只暴露命令(focus/clear),隐藏 DOM
import { useRef, useImperativeHandle } from "react";
function MyInput({ ref }) {
const realInputRef = useRef(null);
useImperativeHandle(
ref,
() => ({
focus() {
realInputRef.current?.focus();
},
clear() {
if (realInputRef.current) realInputRef.current.value = "";
},
}),
[]
);
return <input ref={realInputRef} />;
}父组件:
inputRef.current.focus();
inputRef.current.clear();暴露“只读信息”(getValue / isValid)
useImperativeHandle(ref, () => ({
getValue() {
return realInputRef.current?.value ?? "";
},
}), []);useEffect
useEffect 是 React 中的一个 Hook,允许你在函数组件中执行副作用操作。它主要用于处理在组件渲染后需要执行的操作,如数据获取、订阅、手动 DOM 操作等。
useEffect(() => {
// 你的副作用逻辑
return () => {
// 可选的清理函数
};
}, [dependencies]);- 第一个参数是一个函数,它包含你想要执行的副作用逻辑。
- 第二个参数是一个依赖数组(可选),用来控制副作用的执行时机。
第一个参数:副作用函数
副作用函数:在每次渲染后都会执行,如果依赖项变化或者组件重新渲染都会执行。
useEffect(() => {
console.log('组件渲染后执行');
});返回清理函数:如果副作用需要清理(例如订阅、定时器等),可以返回一个清理函数,它会在组件卸载或依赖项变化时执行。
useEffect(() => {
const timer = setTimeout(() => {
console.log('一秒钟过去了');
}, 1000);
// 清理定时器
return () => clearTimeout(timer);
}, []);第二个参数:依赖项数组
空数组 []:副作用只在组件首次渲染时执行一次(相当于 componentDidMount)。
useEffect(() => {
console.log('组件首次渲染时执行');
}, []);依赖项数组:副作用函数将在依赖项数组中的值发生变化时执行(类似于 componentDidUpdate)。
useEffect(() => {
console.log('name 或 age 变化时执行');
}, [name, age]);没有第二个参数:副作用函数将在每次渲染时执行(类似于 componentDidUpdate)。
useEffect(() => {
console.log('组件每次渲染后执行');
});执行时机
useEffect会在 DOM 更新后 执行,确保组件已经渲染并且可用。这意味着副作用逻辑不会阻塞 UI 渲染。- 它的执行顺序是在所有浏览器渲染更新之后。
清理函数:返回的清理函数会在每次副作用运行前或组件卸载时调用,用来清除副作用。例如,取消订阅或清除定时器。
useSyncExternalStore
useSyncExternalStore 是 React 18 引入的一个 Hook,用于订阅外部存储(比如一个全局状态管理库或外部数据源),并使组件在存储发生变化时同步更新。它的主要目的是替代一些旧的方式,如 useEffect 配合订阅逻辑,用于处理外部数据源的同步更新。这个 Hook 使得 React 在并发模式下也能更好地与外部状态同步。
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)subscribe:一个函数,负责订阅外部状态变化并返回一个取消订阅的函数。每当外部存储发生变化时,这个函数会重新渲染组件。它的签名是:
subscribe(callback: () => void): () => voidcallback: 当外部存储变化时需要调用的回调函数,用来触发组件重新渲染。
返回一个取消订阅的函数,通常用来清理副作用。
getSnapshot:一个函数,返回当前存储的快照(当前值)。每次渲染时,React 会调用此函数来获取当前的外部数据。它的签名是:
getSnapshot(): T返回当前的外部存储的值(T 代表值的类型)。
getServerSnapshot (可选):用于服务器渲染时提供的函数。在服务端渲染(SSR)时,它允许 React 提前获取外部存储的快照,避免了首次加载时的延迟。它的签名是:
getServerSnapshot(): T该参数是可选的,通常在 SSR 环境中使用。
示例:假设我们有一个简单的全局存储,它存储一个计数器值,并且有一个订阅功能。我们可以使用 useSyncExternalStore 来订阅这个外部存储并在组件中使用它。
import { useSyncExternalStore } from 'react';
// 假设我们有一个全局存储对象
const store = {
value: 0,
listeners: [],
subscribe(callback) {
this.listeners.push(callback);
return () => {
this.listeners = this.listeners.filter(listener => listener !== callback);
};
},
setValue(newValue) {
this.value = newValue;
this.listeners.forEach(listener => listener());
},
getSnapshot() {
return this.value;
}
};
function Counter() {
const count = useSyncExternalStore(
store.subscribe.bind(store),
store.getSnapshot.bind(store)
);
return <div>Count: {count}</div>;
}subscribe订阅了外部存储的变化。getSnapshot获取当前存储中的计数器值。
与 useEffect 的关系
useSyncExternalStore 和 useEffect 都可以用来处理副作用和异步操作,尤其在外部存储(如状态管理库、Web API 等)的场景中有类似的应用。区别在于:
useSyncExternalStore主要用于处理同步数据源的订阅和更新,且能保证 React 的渲染行为在并发模式下是安全的。useEffect用于副作用处理,比如异步请求、DOM 操作等。
相关信息
外部状态必须支持被订阅
为了让 useSyncExternalStore 工作,外部状态必须能以某种方式通知订阅者(即触发回调)。如果状态没有这种机制(没有 subscribe 和 getSnapshot),useSyncExternalStore 就无法正常工作,也无法保证 React 组件在状态更新时重新渲染。
subscribe:负责注册外部状态变化时的回调(即组件的重新渲染通知)。当外部状态改变时,React 会重新渲染组件。
getSnapshot:负责在每次渲染时获取当前的状态快照。React 通过它来获取最新的外部状态值,从而更新 UI。
当外部状态通过 subscribe 通知有变化时,React 会触发重新渲染。
然后,React 会调用第二个参数 getSnapshot,获取最新的外部状态值,并将其传递给组件。最终,组件 UI 会根据这个最新的状态值重新渲染。
flushSync
flushSync 用来强制 React 立即同步地处理一次更新(跳过批处理),并立刻把 DOM 更新完成。
常见用途:你“现在就要拿到最新 DOM / layout / scroll / 测量结果”,而不是等到 React 自己批量更新。
import { flushSync } from 'react-dom';
flushSync(fn: () => void): voidfn:一个同步函数
- 在这个函数里触发的 所有 state 更新
- 都会被 立刻执行、立刻 commit、立刻更新 DOM
无返回值(void)
示例
flushSync(() => {
setCount(c => c + 1);
});
// 这里 DOM 已经是最新的
divRef.current.scrollTop = divRef.current.scrollHeight;flushSync 之后:
- state 是新的
- DOM 是新的
- layout 是新的
- 可以安全读 DOM
为什么需要 flushSync?
默认行为
setState();
setState();
// 不一定立刻更新 DOM(自动批处理)React 会:
- 合并更新
- 延迟 commit
- 统一渲染(更快,但不“立即”)
flushSync 的作用
flushSync(() => {
setState();
});
// 这里 DOM 一定已经更新它就是一个打断批处理、立刻提交”的紧急按钮