React 脱围机制 [2]
约 8388 字大约 28 分钟
2025-11-29
移除不必要的 Effect
Effect 的唯一正当用途:
让 React 组件与外部系统同步(external system)
外部系统例子:浏览器 DOM、非 React 库(如 jQuery、Chart.js)、WebSocket、网络请求、第三方组件等。
如果你只是在 根据 props 或 state 计算新数据,或者 响应用户事件,绝不要使用 Effect
使用 Effect 会导致多余的重渲染、代码复杂、容易出错。
两种最常见的「不必要 Effect」模式(必须移除!)
| 场景 | 错误做法(用 Effect) | 正确做法(直接在渲染中计算 / 事件处理函数中处理) |
|---|---|---|
| 根据 props/state 更新 state(派生状态) | 在 Effect 中 setXxx(基于其他 state 计算) | 直接在组件顶层计算变量(不设为 state) |
| 响应用户事件(如发送请求、弹 toast) | 在 Effect 中监听 state 变化来发请求 | 直接在 onClick / onSubmit 等事件处理函数中执行 |
经典错误示例 → 正确重构
// 错误:多余的 state + 不必要的 Effect(会导致两次渲染!)
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState(''); // 多余的 state
useEffect(() => {
setFullName(firstName + ' ' + lastName); // 引发额外渲染
}, [firstName, lastName]);
return <>{fullName}</>;
}// 推荐:直接计算,零成本、零 bug
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = firstName + ' ' + lastName; // 渲染时自动计算
return <>{fullName}</>;
}同理适用于:
- 过滤列表:const filteredList = list.filter(...);
- 计算总数、百分比、格式化日期等 → 全都在渲染中直接算
事件也要放在事件处理函数里
// 错误:用 Effect 响应「购买」状态
const [purchased, setPurchased] = useState(false);
useEffect(() => {
if (purchased) {
fetch('/api/buy', { method: 'POST' });
showToast('购买成功!');
setPurchased(false);
}
}, [purchased]);
// 正确:直接在点击时处理
function handleBuy() {
fetch('/api/buy', { method: 'POST' });
showToast('购买成功!');
}
return <button onClick={handleBuy}>购买</button>;什么时候真的需要 Effect?
只有在和「外部系统」同步时才用:
// 正确用法示例
useEffect(() => {
// 与浏览器 DOM 同步
document.title = `你有 ${unreadCount} 条新消息`;
}, [unreadCount]);
useEffect(() => {
// 与非 React 库同步
$('#mySlider')[0].value = reactValue;
}, [reactValue]);
useEffect(() => {
// 传统的「组件挂载后请求数据」(现代推荐用框架内置数据获取)
fetchSearchResults(query).then(setResults);
}, [query]);总结
- 能算出来的,就别存 state
- 用户点了按钮才做的事,就写在 onClick 里
- 只有要跟「React 管不到的东西」同步,才用 Effect
React 设计哲学:
数据改变 → 组件函数重新执行 → 所有局部变量自动重新计算
这才是真正的「单向数据流 + 声明式渲染」的正确打开方式。
很多人写出下面这种代码,其实就是在用「命令式 + 双向绑定」的 jQuery 思维写 React:
// 典型的「jQuery 思维」残留
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState(''); // ← 以为必须有个“字段”存着
useEffect(() => {
setFullName(firstName + ' ' + lastName); // ← 以为必须手动“同步”
}, [firstName, lastName]);这段代码暴露出的思维误区有三个层次:
| 误区层级 | 具体表现 | 正确认知 |
|---|---|---|
| Level 1 | 认为 fullName 必须是一个 state 才能显示 | 任何可以从现有 state/props 算出的值都不该是 state |
| Level 2 | 认为「数据变了之后要手动去更新另一个 state」 | React 的组件函数本身就是「纯函数式的计算过程」 |
| Level 3 | 用 Effect 来“保持两个 state 同步” | 组件函数会整个重新运行! 局部变量天然就是最新的 |
总结:数据更新后,组件自动重新执行,在组件执行的时候内部的就会重新计算,完全不需要 effect 中写,因为组件本身就可以在数据发生变化的时候重新执行
这也是官方最想让你拥抱的心智模型(Thinking in React 的终极形态)。
“If you're using useEffect to keep two state variables synchronized, you're probably doing it wrong.”
“If a value is derived from existing state or props — just calculate it during rendering. No Effect needed. Ever.”
—— 来自 React 官方文档《You Might Not Need an Effect》
props / state 改变
↓
React 重新调用你的组件函数(整个函数体重新执行)
↓
所有 let/const 变量全部重新计算(天然就是最新值)
↓
返回新的 JSX → React 对比 → 更新真实 DOM相关信息
「幻觉中的全量 DOM 摧毁与重建」恐惧症
state 变了 → 组件函数重新执行 → return 了一个全新的 <div><span>XXX</span></div> → React 傻乎乎地把原来的整个 DOM 节点删光 → 再从头插入一模一样的结构 → 浏览器疯狂重排重绘 → 卡!卡!卡!
就下意识心虚:「天啊,每次 firstName 改一个字母,整个 h1 都要重建?太浪费了吧!」
真相是:React 根本就没有你想象的那种「删光重插」操作!
真实发生的事情是这样的(极简版):
- 你 return 了一个新的 React element(就是 JS 对象,不是真实 DOM) → 它超级轻量,创建成本几乎为 0
- React 拿新旧两棵 React element 树做 diff(Fiber 算法)
- 发现只有文本节点从 “Taylor Swift” 变成了 “Taylor Swift!”(加了个感叹号)
- React 直接调用浏览器原生 API: textNode.nodeValue = "Taylor Swift!" → 只改了一个文本节点的内容,连重排重绘都几乎没有!
总结
“你只管老老实实、大大方方地 return 你想要的 JSX 就行了。 到底是复用旧节点、改文本、加属性、删节点、插节点……这些我 React 比你门儿清。 我保证把真实 DOM 的操作压到理论最低,你不用替我省那点性能。”
重要
DOM 突变(DOM mutation)就是:真正动浏览器真实 DOM 的那些操作。
一句话总结:只要浏览器真的要新增、删除、移动、改属性一个真实 DOM 节点,就是一次 DOM 突变。
常见 DOM 突变例子(这些才真正耗性能):
| 操作 | 例子代码 | 耗性能程度 |
|---|---|---|
| 插入节点 | document.body.appendChild(div) | 高 |
| 删除节点 | node.parentNode.removeChild(node) | 高 |
| 移动节点 | parentA.appendChild(nodeFromParentB) | 高 |
| 修改属性/样式 | el.className = 'xxx' / el.style.color = 'red' | 中 |
| 修改文本内容 | textNode.nodeValue = '新文字' | 低 |
关键点:
- 你 return 100 次 JSX → 0 次 DOM 突变(只是 JS 对象)
- React 最终可能只用 1 次 node.textContent = '新名字' 就搞定 → 只有 1 次轻量级 DOM 突变
所以 React 的口号就是:
“You can re-render as much as you want. Re-rendering is cheap. What’s expensive is mutating the actual DOM.”
「你随便 return JSX 多少次都没事, 真正贵的是 DOM 突变,我会拼命把它压到最少。」
记住:DOM 突变 = 真·动浏览器 DOM = 真·贵,其他都是小儿科。
缓存昂贵的计算
从「使用场景」和「结果」上看,useMemo 几乎就相当于 Vue 中的 computed(计算属性),但底层机制和「你该不该用」的心智模型差别很大。
| 对比项 | Vue computed | React useMemo | 实际建议 |
|---|---|---|---|
| 写法 | fullName: computed(() => first.value + ' ' + last.value) | const fullName = useMemo(() => firstName + ' ' + lastName, [firstName, lastName]) | 两者几乎一样 |
| 是否缓存 | 依赖不变时直接返回上一次结果,完全不重新计算 | 依赖不变时直接返回上一次结果,完全不重新计算 | 完全一样 |
| 依赖检测 | 自动追踪(魔法) | 必须手动写依赖数组 | React 更麻烦 |
| 典型用途 | 派生数据、过滤列表、格式化等 | 派生数据、过滤列表、格式化等 | 用途一样 |
「useMemo 就相当于 Vue 的计算属性」在 99% 的业务场景下,这样理解完全没问题,
但是!React 官方的一句话是:
“useMemo 是一个性能优化钩子,不是语义保证。 请先写出正确的代码,再在出现性能问题时才加 useMemo。”
翻译成大白话就是:
在 React 里,绝大多数时候你根本不需要 useMemo! 直接在渲染里算就行,React 足够快 + diff 足够聪明。
// 写法 A:顶层直接算(官方最推荐)
const fullName = firstName + ' ' + lastName;
// 写法 B:useMemo(我倾向的写法)
const fullName = useMemo(
() => firstName + ' ' + lastName,
[firstName, lastName]
);写法 B 确实做到:
- 依赖没变 → 完全不执行加法和字符串拼接,直接返回缓存的字符串
- 依赖变了 → 才重新算一次
所以在「避免无意义的重复计算」这个维度上,写法 B 确实更省 CPU,尤其是当计算真的有点重的时候(比如过滤 5000 条数据、JSON.parse 大字符串、复杂正则等)。
所以现在的做法完全可行,而且在很多公司的大项目里就是这么干的,没人会因为你这么写扣你绩效。
但 React 官方为什么还是「别用 useMemo 做派生数据」? 原因其实不是技术问题,而是 心智模型 + 维护成本 + 99% 场景的收益为零 这三把刀:
| 维度 | 顶层直接算(官方推) | 加 useMemo(你现在的习惯) | 真实差距 |
|---|---|---|---|
| 正确性 | 天然正确,永远不可能 stale | 必须写全依赖数组,一漏就 stale、一多就白写 | useMemo 更容易出 bug |
| 代码简洁度 | 最少代码 | 多写 5~10 行 + 依赖数组 | 直接算完胜 |
| 维护成本 | 谁来看都秒懂 | 后端同学/新人:这玩意儿为啥包一层?依赖漏了没? | 直接算完胜 |
| 性能收益(99%场景) | 字符串拼接、简单 filter、格式化日期等 | 几微秒 vs 零微秒,肉眼 + 性能面板完全看不出来 | 几乎为 0 |
| 性能收益(1%场景) | 慢 | 真的快 | useMemo 胜 |
真实世界的数据(来自 Dan Abramov 和 React 团队内部统计):
- 99.9% 的 useMemo 在生产环境里省下来的 CPU 时间 < 0.1ms
- 但因为依赖数组写错导致的 bug 占所有 React 状态 bug 的前 5 名
所以请直接在渲染里算,除非你用性能工具(Profiler)真的看到这里是瓶颈了,再补 useMemo。
总结:“我用 useMemo 做派生数据,但仅限于计算真的很重、或者我已经用 React Profiler 确认这里是瓶颈的时候。”
“随便写都行,但只要用了 useMemo,就请像个成年人一样负责,写全依赖、加好注释,别给同事挖坑。”
当 props 变化时自动重置全部
场景:
<ProfilePage userId="user-123" /> → 切换到 → <ProfilePage userId="user-456" />期望:切换用户后,评论框、tab 状态、输入框、列表展开状态……全部自动清空。
错误做法
useEffect(() => {
setComment('');
setActiveTab('posts');
setDraft('');
// ……还有 10 个 state 都要手动写
}, [userId]);缺点:
- 闪一下旧内容(先渲染旧 state → 再渲染新)
- 子组件里的 state 也得一个个写 effect
- 容易漏掉,维护地狱
最佳实践:用 key 强制「重新挂载」
// 路由层只管传 userId,不用关心 key
export default function ProfilePage({ userId }: { userId: string }) {
return <ProfileContent userId={userId} key={userId} />;
}
// 真正渲染内容的组件(内部可以随便用多少 state)
function ProfileContent({ userId }: { userId: string }) {
const [comment, setComment] = useState('');
const [tab, setTab] = useState<'posts' | 'replies'>('posts');
const [likesOpen, setLikesOpen] = useState(false);
// ……这里有 100 个 state 也没事!
return (/* 页面内容 */);
}原理
- 相同位置 + 相同组件类型 + key 相同 → React 复用旧组件实例 → state 保留
- key 不同 → React 直接销毁旧组件,创建新组件 → 所有 state 自动重置为初始值
- 连带所有子组件一起重置,连一个 useEffect 都不用写
key 不是保留字,是一个普通的特殊 prop,key 和 ref 一样,是 React 特殊保留的 prop,普通代码里你可以随便传,组件里照样可以正常解构接收
可以传,也可以不传,不传 key 是最常见的,React 会默认把这棵子树当作「同一个组件」来复用
是否重置 state 的决定性条件之一就是 key:
React 的 reconciler 就是这么判断的:
位置相同?
类型(组件函数)相同?
key 相同?
三者都满足 → 复用旧实例(state 保留),只要 key 不同 → 一定销毁旧组件 + 创建新组件(state 重置)
不传 key 时,即使 props 完全不同,state 也不会销毁
经典例子:
<ProfilePage userId="A" /> → <ProfilePage userId="B" />
如果 ProfilePage 内部的 state 直接写在自己身上,state 永远不会清,就是因为 key 没变
key 必须稳定、可预测、唯一(最好用 ID,不要用 index)
// 错误:用 index 可能导致不必要的重置或不重置
{list.map((item, index) => <Todo key={index} />)}
// 正确
{list.map(item => <Todo key={item.id} />)}key 变了,连 DOM 都会尽量复用,但 state 一定重置
- React 会尝试把老 DOM 节点“挪”给新组件(性能优化)
- 但组件实例是全新创建的,所有 useState、useRef(ref 除外)都会回到初始值
| 步骤 | React 内部实际行为 | 对开发者可见的结果 |
|---|---|---|
| 1 | 发现 key 不同(比如从 key="A" → key="B") | 立刻判定:这是两个完全不同的组件实例 |
| 2 | 立刻 unmount 旧的组件实例(旧 ProfileContent) | 触发 useEffect cleanup、componentWillUnmount 等 |
| 3 | 所有 useState、useReducer、useMemo、useCallback 等全部重置为初始值 | state 清空,就是你想要的效果 |
| 4 | 开始 mount 全新组件实例 | 重新执行组件函数体,重新执行所有 useState('') 等 |
| 5 | 生成新的虚拟 DOM 树 | 这时 React 会对比新旧两棵虚拟 DOM(diff) |
| 6 | 发现很多 DOM 节点其实 type + className + 结构都一样 | 例如: 结构没变 |
| 7 | 就把旧的真实 DOM 节点直接复用(reuse)过来 | 不销毁也不重新创建 DOM 节点,只更新必要的属性(比如 value="") |
| 8 | 只有真正有变化的属性/文本才触发真实 DOM 操作 | 所以你看到输入框瞬间清空,但没有闪白或重新聚焦(性能极好) |
总结:key 变了 → 组件实例一定全新(state 100% 重置) 但真实 DOM 节点只要结构兼容,React 还是会尽量复用(只更新必要的属性)
import { useRef } from 'react';
function MyInput() {
// 这行代码的意思是:创建一个 ref,初始值是 null
const inputRef = useRef<HTMLInputElement>(null);
// 真正的绑定写在原生标签上
return <input ref={inputRef} />;
}- useRef
<HTMLInputElement>(null)意思是:我准备让这个ref稍后指向一个 HTMLinput元素,当前还不知道指向谁,先写null ref={inputRef}才是真正把ref“挂”到这个input标签上的地方
写完这两行后,渲染完第1次,inputRef.current 就会变成真实的 DOM 节点(比如 这个对象),以后你就可以用 inputRef.current.focus() 来操作它。
总结:“当 key 改变导致组件重建时,所有普通 state 一定重置,唯有 ref(指向 DOM 时)是唯一被复用的东西,因为 React 把真实的 DOM 节点留下来重复用了,顺手把新组件的 ref 重新指向它。”
当 prop 变化时调整部分 state
当某个 prop(例如列表 items)变化时,你只想重置部分 state(例如 selection),而不是整个组件状态。
React 官方推荐的方式不是唯一,但需要知道什么方式更高效、更易维护、更符合 React 的数据流理念。
方式一:在 Effect 中根据 props 更新 state(避免)
useEffect(() => {
setSelection(null);
}, [items]);这种写法的问题:
- 旧 selection 值会闪一下 —— 组件和其子组件先用旧 state 渲染一次。
- DOM 更新后才执行 Effect,再触发二次渲染。
- 导致 不必要的二次渲染 和额外的子组件重渲染。
- 破坏了组件的“纯渲染”理念(渲染 → DOM → Effect → setState → 再渲染)。
方式二:在渲染期间检测 prop 改变并立即更新 state(可用但不推荐滥用)
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
}优点
- 更高效:React 在渲染中检测到更新,会立即重新渲染当前组件,不会让子组件渲染“旧 state”。
- 避免了 Effect 的副作用延迟。
限制与风险
- 只能更新 自身组件的 state —— 否则会出现渲染期间更新别的组件的警告。
- 容易让数据流复杂、调试困难。
- 必须加条件判断,否则会无限循环。
适合:少量场景、有明确原因、性能关键节点。
重要
在渲染期间(组件执行 JSX 计算期间)调用 setState,React 会 立即 放弃当前这次渲染并重新开始一次新的渲染。
也就是说:不是等当前渲染执行完 JSX 再开始下一次渲染,而是中断当前渲染,重新执行当前组件。
但 React 的“立即”是不触发 DOM 更新、也不运行 Effects,只是丢弃渲染树并重新走一遍计算逻辑。
步骤 1:React 开始一次渲染,调用 List() 开始构建 Fiber。
步骤 2:执行到 setSelection(null)
React 内部检查:你在 渲染阶段(render phase) 调用了 setState。
这是一个特殊的情况,React 的策略如下:
允许更新当前组件的 state(同一 fiber),并立即重新调度当前组件的渲染。
即:
- 这次渲染被取消
- 新的渲染立即开始
- React 会使用 setSelection(null) 后的最新 state
React 不会等到当前 JSX return 之后再更新,而是:
丢弃当前结果并重新执行该组件的函数。
官网:React 会丢弃已经返回的 JSX,并立即进行重新渲染。
重要
React 的渲染中断机制(简化版)
- 进入渲染 → 执行组件
- 遇到 setState(在 render 中)
- 标记:需要重新渲染这个 fiber
- 当前渲染丢弃(不会 push 到 Fiber 树)
- React 立刻重新执行 List()(同步)
- 渲染新结果(没有旧 selection)
整个过程在 commit 阶段之前完成 → 所以不会对 DOM 产生任何影响 → 不会渲染旧 UI → 不会触发副作用
重要
为什么 React 要立即中断?
因为如果让旧值渲染出来然后再更新,会导致:
- UI 闪动
- 子组件渲染旧值
- 逻辑混乱
React 的目标:
渲染出来的 UI 必须是根据 props 和 state 对应的最新稳定值。
所以:
“渲染中更新 state” = “刷新当前渲染,保持 UI 一致性”
重要
总结
React 在渲染期间遇到 setState,会立即中断当前渲染,重新渲染当前组件,使渲染结果始终基于一致的最新 state。
方式三(最佳实践):避免调整 state,而是在渲染期间“计算”所需内容
替代方案:不要保存整个 selection,而改为保存 selectedId。
function List({ items }) {
const [selectedId, setSelectedId] = useState(null);
// 根据 items 和 selectedId 计算 selection
const selection = items.find(item => item.id === selectedId) ?? null;
}为什么这是最好的方式?
- 完全不需要调整 state。
- 数据流高度可预测:UI = f(state, props)。
- items 中该 id 不存在 → selection 自然是 null。
- items 改变但选中项仍存在 → 自动保留选中状态。
- 没有 Effect、没有额外渲染、更易理解。
这是 React 所说的:通过计算而非存储,减少冗余状态。
总结:
Effect 用来执行副作用,不用来“修补渲染逻辑”
- 调整 state 属于渲染逻辑
- 不是副作用
- 不应该放进 Effect
渲染期间根据 props 调整 state:能做,但非必要尽量避免
- 更高性能
- 但增加理解成本
最佳方案是减少冗余 state
- state 只保存 最少且必要 的信息
- selection 不需要存,selectedId 才是最小表达
- 通过 key 强制重置也可以
例如你希望 items 改变时整个 List 重置:
<List key={itemsKey} items={items} />在事件处理函数中共享逻辑
React 中最常见的误区之一,就是把 事件特定的逻辑 写进 Effect。这是典型的“把事件逻辑和渲染逻辑混在一起”,会导致行为异常。
useEffect(() => {
if (product.isInCart) {
showNotification(`已添加 ${product.name} 进购物车!`);
}
}, [product]);你希望:只有用户点击添加按钮后才显示通知
但副作用发生了:
- 页面刷新
- product.isInCart 初始就为 true → Effect 又触发通知!
这说明 Effect 做了一件“不是因为组件显示需要,而是因为用户操作需要”的事。
通知应该与“事件”绑定,而不是与“渲染”或“数据变化”绑定。
正确做法:把共享逻辑放在事件处理函数中
function buyProduct() {
addToCart(product);
showNotification(`已添加 ${product.name} 进购物车!`);
}然后两个按钮都用:
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}总结:这是共享逻辑的抽离,但更重要的是:React 要求你把“用户行为逻辑”放在事件里,而不是放在 Effect。因为 Effect 的执行时机和语义决定它不适合处理“事件特定”的逻辑。
请求事件逻辑
Effect 用来处理因为 “组件可见 / 渲染” 而产生的副作用,事件处理函数用来处理因为 “用户行为” 而产生的副作用。
分析请求(必须放在 Effect 中)
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);理由:
- 这个请求应该在 表单显示 时触发
- 属于“渲染副作用”(组件挂载 → 发请求)
- 不是用户触发的行为
所以放在 Effect 中完全正确。
注册请求(不能放在 Effect 中)
错误版本:
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);正确版本:放回事件处理函数
function handleSubmit(e) {
e.preventDefault();
post('/api/register', { firstName, lastName });
}链式计算
链式 Effect 的反例,其实就是在告诉你一句话:
React 并不是一个「自动流程引擎」,渲染本身就是一次纯计算,不需要你用 Effect 来串联逻辑。
React 的哲学很简单:
- 渲染 = 根据当前 state 计算 UI
- 事件处理函数 = 根据用户行为更新 state
- Effect = 只有当 UI 显示出这状态时必须做的事(同步外部系统)
效果:React 程序就是 从上往下执行 → 渲染 UI → 等待事件 → 重新渲染。
为什么链式 Effect 会破坏 React 的模型?
因为链式 Effect 等于把 React 当成“运行时工作流引擎”。
比如:
- card 变了 → goldCardCount 改
- goldCardCount 改 → round 改
- round 改 → isGameOver 改
- isGameOver 改 → 弹窗
这让 React 的渲染不再“纯粹”,渲染过程变成了副作用连锁触发系统。
而 React 的设计是:
渲染应该是纯函数,不应包含有业务副作用的流程逻辑。
这就是为什么 React 会建议:
- 不要把业务流程拆成一段一段的 Effect
- 而是把业务流程写成 一次性、同步、纯粹的逻辑计算
所有由事件驱动的业务逻辑,都应该放在事件处理函数里,而不是分散到 Effect 中。
代码就是从上往下执行的,所以链式计算不需要那么麻烦。
初始化逻辑
开发环境中 React 会「故意」让组件 mount → unmount → 再 mount 一次
用于检查你的代码是否真正“可重复”的(strict mode 保护)。
如果你的初始化逻辑不是幂等的,就会变成:
loadData() // 本来应该一次
checkAuth() // 又被执行了两次比如:
- 刷新 token → 被刷新两次 → token 失效
- 从 localStorage 读取数据 → 又触发一次写入 → 逻辑错乱
- 发送一次初始化 fetch → 被发两次 → 后端收到重复请求
React 的意思很简单:“组件逻辑必须可以重复执行,否则你未来会有 bug。”
正确写法:使用模块作用域的「一次性变量」
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
}效果:
- strict mode mount 两次 → 第二次跳过
- production 只跑一次
- 保持逻辑稳定、可控
更推荐的写法:直接在模块顶层执行(真正只执行一次)
if (typeof window !== 'undefined') {
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
return <UI />;
}通知父组件与状态提升
在 React 中,数据流是 单向的:父组件 → 子组件
当我们试图 “从子组件往父组件传值” 时,很容易犯一个常见错误:把处理放进 Effect 里。
React 新文档明确指出:凡是能在事件中做的,就不要放到 Effect 中做。
下面总结几个关键点。
错误示例:不要用 Effect 通知父组件 state 变化
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
useEffect(() => {
onChange(isOn);
}, [isOn, onChange]);
}为什么不好?
- 通知父组件的时机太晚(在渲染后才发生)
- 父组件接收到数据后会 setState → 再触发一次新的渲染流程
- 变成两次渲染: setIsOn → 渲染 → useEffect → onChange → 父组件 setState → 渲染,不必要的链式渲染
正确方式:在事件里一次性更新孩子与父组件
React 的事件是「批处理阶段」,在事件内部更新 state,React 会自动批量合并:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
setIsOn(nextIsOn);
onChange(nextIsOn); // 通知父组件
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
updateToggle(isCloserToRightEdge(e));
}
}优势:
- 单次事件 → 单次渲染(React 批量更新)
- 通知父组件的时机正确:和 state 更新发生在同一个事件中
- 没有 Effect,多余逻辑减少
重要
执行细节
假设用户点击按钮 → handleClick 被调用:
handleClick() {
updateToggle(!isOn);
}然后调用:
updateToggle(nextIsOn) {
setIsOn(nextIsOn); // 更新子组件自己的 state
onChange(nextIsOn); // 通知父组件更新 state
}React 内部的 setState 流程
setIsOn(nextIsOn)
- React 记录这是子组件的一次状态更新。
- 不会立即重新渲染组件,而是进入 批处理队列(batching)。
- 在事件处理函数中,React 会把同一事件里的多个 setState 合并为一次更新。
onChange(nextIsOn)
- 这个函数假设是父组件的 setState:
function handleToggleChange(newVal) {
setParentState(newVal);
}- 同样地,React 会把父组件的状态更新加入 同一个批处理队列。
批量更新(Batching)
React 在同一个事件处理函数里会把 所有 setState 批量处理:
- 更新子组件 state (
setIsOn) - 更新父组件 state (
setParentState)
然后再统一触发渲染。
注意:在 React 18+ 中,所有浏览器事件处理函数里 setState 都会自动批量处理。
一次事件 → 一次渲染
React 保证 子组件 state 和父组件 state 的更新顺序 按事件顺序发生
在渲染阶段,你在子组件里仍然能读取旧的 isOn(快照),除非用 useEffect 来观察
这是 React 的设计:事件内部读取的是当前渲染的快照,setState 不会立即改变变量值。
相关信息
回顾
setState 的行为
当你在 React 组件里调用 setIsOn(nextIsOn)
发生了几件事:
React 不会立即修改 isOn 变量。
当前渲染函数里读取 isOn,仍然是旧值。
React 会把这次状态更新 加入内部的更新队列(update queue)。
批处理(Batching)
- 在同一个事件处理函数里,多次
setState会被 合并成一次更新。 - React 在事件处理函数执行完毕后,会统一处理这个队列。
setIsOn(true); 和 setOtherState(123); 只会触发一次渲染
下一轮渲染前
- React 会计算最新的 state(队列里的所有更新都会应用),然后重新渲染组件。
- 新渲染函数执行时,
useState读取的就是最新值。 - 最终 React 会更新 DOM,把变化一次性提交给浏览器。
事件触发
↓
组件事件处理函数开始
↓
调用 setState → 更新加入队列(isOn、父组件 state 等)
↓
事件函数执行完毕
↓
React 批量处理队列 → 计算最新 state
↓
触发重新渲染组件
↓
更新 DOM更进一步:让父组件完全控制子组件(状态提升)
有时候,你甚至不需要 child 自己管理状态,直接让父组件管理 isOn:
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
onChange(isCloserToRightEdge(e));
}
}优势:
- 单一来源的数据
- 无需担心父子 state 同步问题
- 父组件对交互逻辑有完全掌控
React 官方推荐:只要你发现你在同步两个 state,就应该把它们合并成一个,或者把状态提升。
订阅外部 Store
在 React 组件中,有时我们需要获取 React state 之外的数据,例如浏览器 API、第三方库或者全局状态管理库的数据。这些数据可能在 React 不可感知的情况下发生变化,因此需要在组件中手动订阅更新。
传统做法:在 Effect 中订阅
示例:订阅浏览器的在线状态 (navigator.onLine)
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}特点与问题:
- Effect 会在客户端执行,不在服务端渲染期间触发。
- 初始 state 可能不准确(服务端渲染时
isOnline永远为 true)。 - 每次订阅和取消订阅都需要手动管理,容易出错。
- 如果多个组件都使用类似逻辑,代码重复。
React 18+ 推荐做法:useSyncExternalStore
React 提供了内置 Hook useSyncExternalStore 来 安全、高效地订阅外部 store。
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // 订阅函数,只要传同一个函数,React 不会重复订阅
() => navigator.onLine, // 获取客户端最新值
() => true // 服务端渲染时的初始值
);
}优点:
- 简化逻辑:不再需要手动管理 Effect 和事件监听。
- 减少错误:自动处理订阅和取消订阅。
- 支持服务端渲染(SSR):可提供服务端初始值。
- 可封装为自定义 Hook:多组件复用,避免重复代码。
function ChatIndicator() {
const isOnline = useOnlineStatus();
return (
<div>
{isOnline ? '在线' : '离线'}
</div>
);
}当浏览器的在线状态发生变化时,ChatIndicator 会自动重新渲染。
不需要额外的 Effect 或手动同步 state。
useSyncExternalStore
useSyncExternalStore 是 React 18 引入的 Hook,用于 安全订阅外部 store(不一定是 React state),并保证:
- 在服务端渲染(SSR)中有确定的初始值
- 订阅在组件挂载和卸载时自动管理
- 避免渲染竞态和不一致的问题
典型用途:全局状态管理库(Redux、Zustand 等)、浏览器 API、WebSocket 状态等。
函数签名
const state = useSyncExternalStore(
subscribe: (callback: () => void) => () => void,
getSnapshot: () => any,
getServerSnapshot?: () => any
): any参数说明:
| 参数 | 类型 | 作用 |
|---|---|---|
subscribe | (callback) => () => void | 订阅 store 的函数。当 store 数据发生变化时,调用 callback 通知 React 重新渲染。返回一个函数用于取消订阅。 |
getSnapshot | () => any | 获取当前 store 的最新值(客户端)。React 在渲染时调用它来得到组件需要的 state。 |
getServerSnapshot | () => any(可选) | SSR 时获取初始值的函数。服务端渲染时不会调用 subscribe,仅用它获取 snapshot。 |
返回值:
- 返回 store 的当前值(即
getSnapshot()的返回值) - 当 store 变化并触发订阅回调时,组件会重新渲染,并返回最新值
使用示例
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe(() => {
alert(`网络状态变化: ${navigator.onLine ? "在线" : "离线"}`);
}),
() => navigator.onLine,
() => true // SSR 初始值
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
return <div>{isOnline ? '在线' : '离线'}</div>;
}执行机制
渲染阶段
- React 调用
useSyncExternalStore。 - React 调用
getSnapshot()获取当前 store 值,并缓存为本次渲染使用。 - 如果返回值与上一次渲染值不同,会触发重新渲染。
订阅阶段(挂载后)
- React 会调用
subscribe(callback):- 你提供的
callback会在 store 变化时被调用 - React 会重新调用
getSnapshot()获取最新值并更新组件
- 你提供的
- 当组件卸载时,
subscribe返回的取消函数会自动执行,移除订阅
服务端渲染(SSR)
- 不会调用
subscribe(因为浏览器事件不存在) - 调用
getServerSnapshot()获取初始值 - 确保 SSR 渲染的一致性
注意:
订阅函数必须是稳定的
- 每次渲染传入不同的函数,React 会重复取消和重新订阅
不要在 getSnapshot 中引入副作用
- 只能读取 store 的值,不能触发网络或 DOM 更新
适合“同步外部 store”
- 如果 store 更新频率很高,React 会批量处理重新渲染
获取数据
在 React 应用中,Effect 常用于发起数据获取请求,但使用时需要注意一些常见问题和最佳实践。
当组件可见时需要同步外部数据(例如从 API 获取搜索结果)。
数据的来源(用户输入、URL、state 等)变化时,需要保持组件的状态与最新数据一致。
这种逻辑通常不适合放在事件处理函数中,因为数据获取不是由单次用户操作触发的,而是组件展示所需。
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
}快速连续更新 query 或 page 时,可能会触发多次异步请求。如果较早请求晚于后续请求返回,可能导致 显示错误的结果。
解决方式:使用清理函数忽略过期请求
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);- 当 effect 被清理(组件卸载或依赖变化)时,将
ignore置为true。 - 仅最后一次请求的返回结果会生效,避免显示旧数据。
数据获取逻辑的封装
将获取数据逻辑提取到 自定义 Hook 可以使组件更简洁、可复用,并为后续优化(如缓存、服务端渲染)提供方便。
示例:
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(res => res.json())
.then(json => {
if (!ignore) setData(json);
});
return () => { ignore = true; };
}, [url]);
return data;
}
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
}- 自定义 Hook 将 Effect 和异步逻辑封装起来,组件只需关注结果。
- 可以在 Hook 内部添加 加载状态、错误处理、缓存 等扩展逻辑。
- 组件中 useEffect 调用越少,维护越容易。