React 脱围机制 [3]
约 6743 字大约 22 分钟
2025-12-06
Effect 的生命周期
Effect 的本质
在 React 中,组件有生命周期(挂载 / 更新 / 卸载),但 Effect 没有组件式的生命周期。
Effect 的核心只有两件事:
- 开始同步某些东西(setup)
- 结束同步这些东西(cleanup)
换句话说,Effect 是一种 订阅和取消订阅 的机制。
为什么 Effect 会重复执行?
当 Effect 依赖的 props 或 state 随时间变化 时,React 会在每次渲染后:
- 先执行 cleanup(停止上一轮同步)
- 再执行 setup(开始新的同步)
这样才能保证 Effect 始终与最新的数据保持一致。
第一次渲染:
setup()
state/props 变化 → 再渲染:
cleanup()
setup()
组件卸载:
cleanup()不要用「组件的生命周期」来思考 Effect,而要用「同步的生命周期」来思考。
Effect 的作用是:把外部系统(网络、订阅、定时器、第三方库等)与当前的 props 和 state 保持同步。
传统的组件生命周期是: 挂载 → 更新 → 卸载
但 Effect 的同步生命周期是: 开始同步 → (可能多次)停止同步 + 重新开始同步 → 最终停止同步
经典聊天室示例
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
// 开始同步
const connection = createConnection(serverUrl, roomId);
connection.connect();
// 返回清理函数 → 停止同步
return () => {
connection.disconnect();
};
}, [roomId]); // 依赖项
return <div>当前房间:{roomId}</div>;
}也就是说:只要依赖项数组里的值变了,React 就会:
- 先运行上一次 Effect 返回的清理函数(停止旧的同步)
- 再运行本次 Effect 的主体代码(开始新的同步)
这才是「Effect 的真实生命周期」: 同步 → 清理 → 重新同步 → 清理 → 重新同步 → … → 最终清理
几乎所有连接外部系统的 Effect 都需要清理
| 外部系统 | 开始同步 | 停止同步(清理函数) |
|---|---|---|
| WebSocket / Socket.io | connection.connect() | connection.disconnect() |
| setInterval | const id = setInterval(...) | clearInterval(id) |
| 事件订阅 | window.addEventListener | window.removeEventListener |
| 第三方图表库 | chart.render() | chart.destroy() |
总结
Effect 不是随着组件挂载/卸载跑一次,而是随着依赖项变化跑多次。 每次依赖变 → 先清理旧的 → 再建立新的。 最后卸载时 → 再清理一次。
正确的心智模型:
mount roomId: 'general' → 'music' → 'travel' unmount
│ │ │ │
▼ ▼ ▼ ▼
开始同步 清理 general → 开始 music 清理 music → 开始 travel 清理 travel这就是为什么切换房间时 Effect 运行了两次。
最佳实践提醒
- 尽量让依赖项精确、原始(别放对象/函数,除非用 useMemo/useCallback 包裹)
- 必须清理的 Effect 一定要返回清理函数
- 没必要清理的 Effect(如埋点)可以不返回清理函数
- 开发模式下 React 会故意多 mount/unmount 一次帮你验证清理是否正确(这就是为什么你经常看到 Effect 执行两遍)
记住了:Effect 是同步工具,不是生命周期钩子。
核心
Effect 不是「组件挂载时跑一次,卸载时清理一次」的生命周期钩子,而是「每当依赖的值不一样时,就必须停止旧同步、启动新同步」的声明式同步机制。
为什么同步必须多次进行?用聊天室彻底说透
| 时间点 | 用户看到的 UI | 当前应该连接的房间 | 实际还在连接的房间 | 必须做什么? |
|---|---|---|---|---|
| 初次打开 | 欢迎来到 general 房间! | general | 无 | → 开始同步 general |
| 用户切换到 travel | 欢迎来到 travel 房间! | travel | general(旧的) | → 必须先断开 general,再连接 travel |
| 用户又切换到 music | 欢迎来到 music 房间! | music | travel(旧的) | → 必须先断开 travel,再连接 music |
| 用户关闭聊天/切换页面 | ChatRoom 组件被卸载 | 无 | music(旧的) | → 最后一次断开 music |
如果不「多次同步」,就会出现灾难:
- 一直连着 general,却显示 travel 的消息 → 消息串房间
- 多个房间同时连接 → 服务器压力、重复消息、内存泄漏
所以 React 强制:只要依赖变了,就一定要「先清理旧的,再建立新的」
Effect 的真实执行顺序时间线
组件 mount
└── Effect 执行 → 连接 "general"
依赖 roomId 变化:general → travel
├── 1. 先执行上一次的清理函数 → 断开 "general"
└── 2. 再执行本次 Effect 主体 → 连接 "travel"
依赖 roomId 变化:travel → music
├── 1. 先执行上一次的清理函数 → 断开 "travel"
└── 2. 再执行本次 Effect 主体 → 连接 "music"
组件 unmount
└── 执行最后一次清理函数 → 断开 "music"useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // 只要这个值不变,我就保持连接;一变,我就停旧开新开发环境(StrictMode)会故意这样做:
mount → connect general
→ 立刻 disconnect general ← 故意触发清理
→ 立刻 connect general 再次 ← 验证你清理函数是否正确
然后才进入正常流程这是 React 在帮你做「压力测试」: 如果你的清理函数写错了(比如没断开连接、没清除定时器),这里就会立刻暴露 bug!
生产环境不会这样做,只会正常执行一次。
Effect 会“响应”于响应式值
| 值类型 | 是否响应式? | 是不是会变来变去? | 必须写进依赖数组吗? | 典型例子 |
|---|---|---|---|---|
| props | 是 | 会变 | 必须 | roomId, theme, user |
| state | 是 | 会变 | 必须 | count, serverUrl(如果用 useState) |
| 组件体内用 const 定义的变量/函数 | 否 | 每次渲染都重新创建,但值不变 | 不用 | const serverUrl = 'https://...'; const options = |
| 组件体内用 let 定义的变量 | 否 | 每次渲染都被重置 | 不用(但几乎没人这么干) | let count = 0 |
| 全局变量 / 外部常量 | 否 | 基本不变 | 不用 | window.API_URL |
| useMemo / useCallback 包裹的值 | 看情况 | 只有依赖变才变 | 看你想不想让 Effect 响应它 |
// 情况1:serverUrl 是常量 → 不响应式 → 不写依赖
const serverUrl = 'https://localhost:1234'; // ← 永远不变
function ChatRoom({ roomId }) {
useEffect(() => {
createConnection(serverUrl, roomId).connect();
return () => connection.disconnect();
}, [roomId]); // 正确!只写 roomId
}
// 情况2:serverUrl 是 state → 响应式 → 必须写依赖
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
createConnection(serverUrl, roomId).connect();
return () => connection.disconnect();
}, [roomId, serverUrl]); // 必须两个都写!否则大 bug
}| 写法 | 实际含义(React 视角) | 真实执行次数 | 典型用途 |
|---|---|---|---|
| useEffect(() => { ... }, []); | “我只在组件第一次挂载时同步一次,之后再也不管了” | 挂载一次 + 卸载清理 | 埋点、初始化 OneSignal、启动只需要一次的动画 |
| useEffect(() => { ... }, [a, b]); | “只要 a 或 b 变化,我就重新同步” | 可能很多次 | 聊天室、WebSocket、订阅、图表刷新 |
| useEffect(() => { ... }); // 故意不写依赖数组(不推荐) | “每次渲染我都要重新同步” | 几乎每次渲染都跑 |
- 「在 Effect 里读了什么响应式值(props / state / 其他组件的值),就必须把它写进依赖数组」 —— ESLint react-hooks/exhaustive-deps 规则就是干这个的,永远别关!
- 「如果一个值在组件外定义,或者在组件内用 const 定义且永不改变,就不用写」
- 「当你想让某个对象/函数不触发重新同步时,用 useMemo / useCallback 包起来」
每次渲染都会重新创建,但值不变
│
const serverUrl = 'https://...'; ← 不写依赖
const options = { mute: false }; ← 不写依赖
每次渲染值可能不一样
│
const [serverUrl, set..] = useState(..) ← 必须写依赖
const { roomId } = props ← 必须写依赖
const theme = useContext(ThemeContext) ← 必须写依赖
│
React:只要你变,我就重新同步!**依赖数组不是让你随便填的,而是你对 React 的承诺: “我这个 Effect 到底依赖哪些可能会变的值”。 **
凡是在组件函数体内声明的值(包括 props、state、const、let、计算值、函数、对象),默认全都是响应式的,必须写进依赖数组。 能不写的,只有这两种情况:
- 它被声明在组件外部(函数)外部
- 它被声明在 Effect 内部
| 值的声明位置 | 是否响应式 | 必须写进依赖吗 | 例子 |
|---|---|---|---|
| 组件外部(模块顶层) | 否 | 不写 | const serverUrl = 'https://...' |
| props | 是 | 必须 | function ChatRoom({ roomId }) |
| useState / useReducer | 是 | 必须 | const [serverUrl, setUrl] = useState(...) |
| useContext | 是 | 必须 | const theme = useContext(ThemeContext) |
| 组件体内 const/let 计算值 | 是 | 必须 | const url = selected ?? settings.defaultUrl |
| 组件体内创建的对象/数组/函数 | 是 | 必须(极易出错) | const options = { roomId }; const onMessage = () => {} |
| Effect 内部声明 | 否 | 不写 | useEffect(() => { const conn = create... }, []) |
| ref.current | 否 | 不能写 | ref.current.focus()(故意可变,不触发渲染) |
| 全局变量 / window.xxx / location.pathname | 否 | 不能写也没用 | 必须用 useSyncExternalStore 订阅 |
经典例子
// 1. 完全正确:常量在外面
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
createConnection(serverUrl, roomId).connect();
}, [roomId]); // 只写 roomId 就够
}
// 2. 完全正确:常量在 Effect 里面
function ChatRoom({ roomId }) {
useEffect(() => {
const serverUrl = 'https://localhost:1234'; // ← 每次 Effect 都重新创建,但值一样
createConnection(serverUrl, roomId).connect();
}, [roomId]);
}
// 3. 错误!计算值没写依赖(最常见 bug)
function ChatRoom({ roomId, selectedServer }) {
const settings = useContext(SettingsContext);
const serverUrl = selectedServer ?? settings.defaultUrl; // ← 响应式!
useEffect(() => {
createConnection(serverUrl, roomId).connect();
}, [roomId]); // 漏了 serverUrl → bug!
}
// 4. 正确写法
useEffect(() => {
createConnection(serverUrl, roomId).connect();
}, [roomId, serverUrl]); // 必须加上
// 5. 更狠的:对象/函数作为依赖(每次渲染都不同)
const options = { roomId, showTimestamp: true }; // ← 每次渲染都生成新对象
useEffect(() => {
subscribe(options);
}, [options]); // → 每次渲染都重新订阅!死循环风险
// 6. 正确做法:用 useMemo 让它稳定
const options = useMemo(() => ({ roomId, showTimestamp: true }), [roomId]);
useEffect(() => {
subscribe(options);
}, [options]); // 只有 roomId 变时才重新订阅ESLint 永久打开这个规则:"react-hooks/exhaustive-deps": "error",它会帮你自动抓住 99% 的依赖遗漏问题。
当你“不想”让它重新同步时,正确做法
| 你想干的事 | 错误做法(别干) | 正确做法(必须这样) |
|---|---|---|
| serverUrl 永远不变 | 放在组件里但不写依赖 | 移到组件外 或 移到 Effect 内部 |
| 只在挂载时连一次 | 写空依赖但里面读了 props/state | 把要用的值用 useRef 保存,或者拆成两个 Effect |
| 读取最新 props 但不希望重新同步 | 直接读,漏依赖 | 提取成 “Effect Event”(useEffectEvent,实验性) |
| 传对象/函数给 Effect | 直接传,每次都不同 | 用 useMemo / useCallback 包起来 |
| 读取 location.pathname / 全局状态 | 直接读,写进依赖 | 用 useSyncExternalStore 订阅 |
总结
- 组件函数体内 = 响应式世界 只要在函数 {} 里声明的,一律当它是会变的值,统统写依赖!
- 组件外 + Effect 内 = 非响应式安全区 只有在这两个地方声明的值,才可以安心不写依赖。
- ESLint 报红 = 一定有 bug 永远别 disable,改代码让它不红才是正道。
将事件从 Effect 中分开
Effect 是「响应式的」→只要依赖变就重新同步」 事件处理函数是「非响应式的」→只在用户真正操作时运行一次
有时候你需要把「响应式」和「非响应式」混在一起写 → 就用 useEffectEvent
| 需求 | 应该放哪里? | 是否响应式 | 依赖要不要写 | 典型代码位置 |
|---|---|---|---|---|
| 点击“发送”按钮发消息 | 事件处理函数 | 非响应式 | 不需要依赖 | onClick= |
| 切换 roomId 后自动重连聊天室 | Effect | 响应式 | 必须写 [roomId] | useEffect |
| 连接成功后弹“已连接!”的 toast | 想只在真正连接时弹一次 | 非响应式 | 不想写 [theme] | 错放 Effect 里会导致切换主题也重连 |
| 页面访问埋点,要带上当前购物车数量 | 想只在 url 变时埋一次 | 购物车数量是非响应式部分 | 不想写 [numberOfItems] | 错放 Effect 里会导致加购物车也重新埋点 |
| 鼠标移动实时更新坐标,但要受 canMove 控制 | 想只在 mount 时绑定一次监听 | canMove 是非响应式部分 | 不想写 [canMove] | 经典闭包 stale bug |
终极解决方案:useEffectEvent(实验性 API)
import { useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
// 这段逻辑是非响应式的!它只应该在“真正连接成功”那一刻运行一次
const onConnected = useEffectEvent(() => {
showNotification('已连接!', theme); // ← 永远能拿到最新 theme
});
useEffect(() => {
const conn = createConnection(serverUrl, roomId);
conn.on('connected', () => {
onConnected(); // ← 只在这里调用
});
conn.connect();
return () => conn.disconnect();
}, [roomId]); // ← 依赖里彻底删掉 theme!完美!
}另一个经典埋点例子
// 以前的错误写法(99% 的人都这么干过)
useEffect(() => {
logVisit(url, cartItems.length);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]); // 偷偷关掉警告 → 埋下大坑
// 正确写法(2025 年的推荐姿势)
const onVisit = useEffectEvent((visitedUrl) => {
logVisit(visitedUrl, cartItems.length); // 永远拿到最新购物车数量
});
useEffect(() => {
onVisit(url); // 只有 url 真的变了才埋点一次
}, [url]);| 场景 | 正确做法 |
|---|---|
| 只在「用户点了按钮」才做事 | 放事件处理函数(onClick、onSubmit…) |
| 只要 props/state 变就要重新同步外部系统 | 放 useEffect,依赖老老实实写全 |
| Effect 里有一小段代码「不想响应式」 | 提取成 useEffectEvent |
| 想读取最新 props/state,但又不想让 Effect 重跑 | 用 useEffectEvent 包起来 |
以前靠 // eslint-disable-next-line 跳过依赖 | 全部删掉!改用 useEffectEvent 或其他正确方案 |
自定义 Hook 最佳实践
function useChatRoom(roomId) {
const onMessage = useEffectEvent((msg) => {
setMessages(msgs => [...msgs, msg]); // 永远最新 state
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('message', onMessage);
conn.connect();
return () => conn.disconnect();
}, [roomId]); // 再也不用写 [roomId, onMessage]
}- Effect = 响应式 → 依赖必须写全
- 事件处理函数 = 非响应式 → 永远拿到最新值
- 想在 Effect 里写非响应式代码 → 提取成 useEffectEvent
| 问题 | 答案 | 原因 |
|---|---|---|
useEffectEvent 要不要写进依赖数组? | 永远不要!必须删掉! | 它故意被 React 设计成「非响应式」+「稳定身份」,永远 === 同一个函数 |
useEffectEvent 一般声明在哪儿? | 只能写在组件内部,写在 useEffect 同级或者更外层,绝不能写在 useEffect 内部 | 写在 Effect 内部会每次都创建新函数,失去意义 |
| 能不能把所有 useEffectEvent 抽到一个文件统一管理? | 绝对不行!严禁! | 它必须能读取到最新的 props/state,正是因为它「闭包」了组件作用域 |
正确的层级写法
function ChatRoom({ roomId, theme, userId }) {
// 正确位置:组件内部,和 useEffect 平级(可以稍微靠上一点更好阅读)
const onConnected = useEffectEvent(() => {
showNotification(`欢迎 ${userId} 进入房间!`, theme);
});
const onMessage = useEffectEvent((msg) => {
console.log('最新用户ID:', userId); // 永远是最新值,不是闭包旧值
setMessages(ms => [...ms, msg]);
});
const onKick = useEffectEvent(() => {
kickUser(userId); // 永远踢当前登录用户
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', onConnected);
conn.on('message', onMessage);
conn.on('kicked', onKick);
conn.connect();
return () => conn.disconnect();
}, [roomId]); // 依赖里根本不需要写 onConnected / onMessage / onKick
}错误写法
// 错误1:写在 useEffect 内部 → 每次 Effect 都重新创建,失去意义
useEffect(() => {
const onConnected = useEffectEvent(() => { ... }); // 每次都新函数,毫无用处
conn.on('connected', onConnected);
}, [roomId]);
// 错误2:抽成工具文件统一管理 → 拿不到最新 props/state
// utils/effectEvents.js
export const onVisit = useEffectEvent((url) => { ... }); // 报错!组件外不能用不了
// 错误3:依赖数组里写了 useEffectEvent
useEffect(() => {
// ...
}, [roomId, onConnected]); // 红叉!ESLint 会直接报错依赖与代码保持一致
对象和函数作为 Effect 依赖,几乎永远是错的。
因为在 JavaScript 里:
{} === {} // false
[] === [] // false
(() => {}) === (() => {}) // false哪怕内容一模一样,只要是这次渲染新创建的,React 就认为“值变了” → Effect 必须重新执行。
结果就是:
- 你在输入框打字 → 组件重渲染 → 创建了新对象 → Effect 重连聊天室
- 你切换暗黑模式 → 父组件重渲染 → 传下来一个新函数 → 子组件 Effect 重新订阅
- 你点一下无关按钮 → 整个页面重渲染 → 所有对象又变 → 所有定时器、WebSocket、订阅全炸
这不是你想要的“响应式”。
99.9% 的情况下,你的 useEffect 根本不需要依赖对象或函数
最好就是完全不依赖对象/函数,但是如果你确实必须传一个回调,但可以让它稳定
比如第三方库长这样:
player.on('progress', onProgressHandler); // 必须传函数const handleProgress = useCallback((info) => {
setProgress(info.percent);
log(info);
}, []); // 依赖为空,或者只放真正会变的原始值
useEffect(() => {
player.on('progress', handleProgress);
return () => player.off('progress', handleProgress);
}, [handleProgress]); // 允许依赖函数,但这个函数是稳定的!只要你用了 useCallback + 正确依赖,它永远稳定,Effect 永远不会因为“函数变了”而乱跑。
如果必须传 config 对象,且第三方库内部会频繁对比对象(比如 echarts.setOption)
const chartOption = useMemo(() => ({
tooltip: { trigger: 'axis' },
series: [{ data: chartData }],
color: theme === 'dark' ? darkColors : lightColors
}), [chartData, theme]);
useEffect(() => {
chart.current.setOption(chartOption, true); // echarts 自己会浅对比
}, [chartOption]);这里依赖对象是“允许的”,因为第三方库自己做了浅对比,只有真正的内容变了才会重绘,而且你已经用 useMemo 保证了只有必要时才创建新对象。
最差但偶尔能活:你真的完全没法避免,只能依赖裸对象/函数
极少数老旧库会把整个 config 存在内部,每次都要 === 全等对比。
唯一活法:
// 故意让它不稳定,但用 ref 存最新值
const optionsRef = useRef();
optionsRef.current = { roomId, token, retry: 3 };
useEffect(() => {
// 第三方库要求传对象,但我们永远传同一个对象
oldLib.init(optionsRef.current);
return () => oldLib.destroy();
}, []); // 空依赖!靠 ref 读最新或者配合 useEffectEvent(实验性,但未来会稳定):
const onEvent = useEffectEvent((data) => {
// 这里随便读 props/state 都是最新值
});
useEffect(() => {
thirdPartyLib.setCallback(onEvent); // 永远是同一个函数引用
}, []);总结:
- 普通项目:永远别让裸对象/裸函数出现在依赖数组里
- 必须传回调:包一层 useCallback
- 必须传 config 对象:包一层 useMemo
- 实在包不了的老古董库:用 ref 或 useEffectEvent 绕过去,依赖留空
移除不必要的依赖
特定交互要干的事 → 直接写在 onClick / onSubmit 里 (别用 Effect + submitted 状态)
一个 Effect 干了两件不相关的事 → 立刻拆成两个 Effect (国家选城市、城市选区域,必须分开)
用旧 state 算新 state → 改用 updater function
// 错
setMessages([...messages, msg])
// 对
setMessages(msgs => [...msgs, msg])
// 依赖里直接删掉 messages只想读最新值,但不想因为它变而重连/重订阅 → 用 useEffectEvent(实验性但已可放心用)
const onMessage = useEffectEvent((msg) => {
addMessage(msg);
if (!isMuted) playSound(); // 永远读最新 isMuted
if (notificationCount > 0) log(); // 永远读最新
});
useEffect(() => {
socket.on('message', onMessage);
return () => socket.off('message', onMessage);
}, [roomId]); // 依赖里彻底删掉 isMuted / notificationCount对象或函数导致“打字就断网” → 三种解法,按优先级选
// 解法A:直接在 Effect 里创建(最常用)
useEffect(() => {
const options = { serverUrl, roomId, token };
connect(options);
}, [roomId, token]);
// 解法B:必须传给第三方,用 useMemo/useCallback 包
const options = useMemo(() => ({ serverUrl, roomId }), [roomId]);
const onProgress = useCallback((e) => { ... }, []);
// 解法C:父组件每次传新对象/新函数,用上面同样的招数现场拆
function Child({ config, onEvent }) {
const { roomId, token } = config; // 现场拆
const onMsg = useEffectEvent(onEvent); // 或者包一层
useEffect(() => { ... }, [roomId, token]);
}只要你在 setState 的时候,需要用到“当前最新的 state 值”来算下一个值,就必须用 updater function 形式:setX(prev => 新值)
否则就会拿到“过期的、闭包里的旧值”,导致 bug。
经典错误例子
const [messages, setMessages] = useState([]);
// 场景:socket 连续收到 3 条消息
connection.on('message', (msg) => {
setMessages([...messages, msg]); // 错的!
});假设三条消息几乎同时到,你期望 messages 变成:
['hi', 'how are you', 'fine']但实际结果很可能是:
['hi', 'hi', 'hi']为什么?因为 Effect 只在 mount 时执行一次,里面的 messages 被闭包成初始的 []。 三次 setMessages 都基于同一个空数组展开,所以最终都是 ['hi']`。
正确写法(updater function)
connection.on('message', (msg) => {
setMessages(prevMsgs => [...prevMsgs, msg]); // 永远正确!
});React 会把这三次更新排队,依次执行:
- [] → ['hi']
- ['hi'] → ['hi', 'how are you']
- ['hi', 'how are you'] → ['hi', 'how are you', 'fine']
永远拿到最新值,永不踩坑。
[...messages, msg] // 把“闭包那一刻的 messages”展开 + 新 msg
[...msgs, msg] // 把“React 队列里当前最新 msgs”展开 + 新 msg总结
- 只要 setXXX 里面用到了当前 state 的值 → 改成
prev => - 连续多次更新同一个
state→ 必须用prev => - 用了
prev =>就能把这个state从 Effect 依赖里彻底删掉(因为你根本没读它)
回顾
在同一个事件循环(一次点击、一次 socket 消息、一次 interval)里, 你无论调用多少次 setState(普通值),React 都会把它们“合并”成一次,只取最后一次的值。
// 假设当前 count = 0
handleClick() {
setCount(count + 1); // React 想:好,他要基于 0 + 1 → 1
setCount(count + 1); // React 想:又来?他又要基于 0 + 1 → 1
setCount(count + 1); // 还是基于 0 → 1
// 最终结果:count 变成 1,而不是 3
}而你用 prev => 形式,React 就老老实实排队执行:
handleClick() {
setCount(c => c + 1); // 0 → 1
setCount(c => c + 1); // 1 → 2
setCount(c => c + 1); // 2 → 3
// 最终结果:count 变成 3
}| 场景 | 表面现象 | 根本原因 | 终极解法 |
|---|---|---|---|
| 连续收到 3 条 socket 消息 | 列表里只显示最后一条 | 闭包 + 批量更新 | msgs => [...msgs, msg] |
| 点一下按钮想 +3 | 实际只 +1 | 批量更新,读的是渲染时的旧值 | c => c + 1 三次 |
一句话总结:只要你这次 setState 的结果,依赖“上一次 setState 的结果”,就必须用 prev => … 形式。
自定义 Hook
自定义 Hook 的本质是:复用「状态逻辑」(stateful logic),而不是复用「状态本身」(state)。
经典案例:监听网络状态
原始写法(重复代码)
function StatusBar() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
window.addEventListener('online', () => setIsOnline(true));
window.addEventListener('offline', () => setIsOnline(false));
return () => {
window.removeEventListener('online', ...);
window.removeEventListener('offline', ...);
};
}, []);
return <h1>{isOnline ? 'Online' : 'Disconnected'}</h1>;
}每个需要网络状态的组件都要写一遍上面的代码 → 严重重复
提取为自定义 Hook(推荐写一次,到处用)
// useOnlineStatus.js
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
return isOnline;
}使用后组件变得极简清晰
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? 'Online' : 'Disconnected'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
return (
<button disabled={!isOnline}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}两个组件同时响应网络变化,但它们内部完全没有重复逻辑。
关键规则
组件名必须大写开头(如 StatusBar),React 通过首字母判断是否是组件
Hook 名必须以 use 开头 + 大写(如 useOnlineStatus),只有 Hook 和组件才能调用其他 Hook
Hook 内可以调用其他 Hook,普通函数内不能调用 Hook
// 正确
function useOnlineStatus() { useState(...) }
// 错误!普通函数不能调用 Hook
function getOnlineStatus() { useState(...) }自定义 Hook 不共享 state
function A() { const isOnline = useOnlineStatus(); ... }
function B() { const isOnline = useOnlineStatus(); ... }A 和 B 中的 isOnline 是两个完全独立的状态!
它们之所以“看起来同步”,是因为:
- 每个 Hook 内部的 Effect 监听了同一个外部事件(window.online/offline)
- 所以两个独立的状态被同一个外部信号同步更新了
真正想要共享同一个 state,请用「状态提升」(lift state up)或 Context、Redux、Zustand 等状态管理方案。
另一个经典例子:复用输入框逻辑
// useInput.js
export function useInput(initialValue) {
const [value, setValue] = useState(initialValue);
const handleChange = (e) => setValue(e.target.value);
return {
value,
onChange: handleChange
};
// 或者直接返回 [value, handleChange] 也行
}function Form() {
const firstName = useInput('Mary');
const lastName = useInput('Poppins');
return (
<>
<input {...firstName} placeholder="First name" />
<input {...lastName} placeholder="Last name" />
</>
);
}两个输入框各自有独立的 state,但共享了「受控组件」的完整逻辑。
同一个自定义 Hook 被多次调用时,React 会自动为「每一次调用」分配完全独立的一块状态空间。逻辑是完全共享的,状态是 100% 隔离的。
也就是:“自定义 Hook 共享的是「状态逻辑」(stateful logic),而不是状态本身(state itself)。”
在 Hook 之间传递响应值
核心结论
- 自定义 Hook 每次都会随组件一起重新执行,所以它能始终拿到最新的 props 和 state。
- 你可以把最新的 props / state / 函数作为参数传给自定义 Hook,Hook 内部的 Effect 会自动响应它们的变化。
- 把事件处理函数传给自定义 Hook 时,推荐用 experimental_useEffectEvent(未来叫 useEffectEvent)是目前最优解,可以避免把函数写进依赖数组导致不必要的重连/重新订阅。
把自定义 Hook 的执行方式彻底理解成「和组件一模一样的执行方式」就对了!
| 问题 | 答案(死记即可) |
|---|---|
| 自定义 Hook 什么时候运行? | 跟组件完全同步:每次组件渲染,Hook 的函数体就从头到尾再跑一遍 |
| 它能不能拿到最新的 props 和 state? | 100% 能,因为它就是在这轮渲染里执行的,自然拿到这轮最新的值 |
| 要不要手动把值“传进去”? | 必须传!React 不会自动把组件的变量注入到 Hook 里,就像子组件必须通过 props 拿值一样 |
| 怎么传最自然? | 直接当参数传,就像调用普通函数一样(因为它本质上就是普通函数) |
| 那它和直接写在组件里有什么区别? | 没有任何执行上的区别,只是把代码搬了个家,逻辑上更干净、可复用 |
什么时候该写 Hook
| 场景 | 是否该写自定义 Hook | 推荐做法 | 一句话判断标准 |
|---|---|---|---|
| 重复写 useState + useEffect(尤其是带 cleanup 的) | 强烈推荐 | 立刻提取 | 看到 Effect 就问自己:这玩意儿能不能复用? |
| 只是单纯包了一层 useState / useEffect(比如 useFormInput) | 不推荐 | 直接写在组件里 | 别为了“提取”而提取 |
| 和外部系统同步(WebSocket、Event Bus、localStorage、浏览器 API、第三方 SDK) | 必须提取 | 写 useXxx 系列 Hook | 所有“跳出 React”的代码都该进 Hook |
| 数据获取(fetch / GraphQL) | 强烈推荐 | useData(url) / useQuery | 未来 React 官方方案出来也能无缝迁移 |
| 事件打点、埋点、impression | 强烈推荐 | useImpressionLog / useTrack | 业务代码里只留一句声明式调用 |
| 动画、requestAnimationFrame、复杂计时器 | 建议提取 | useFadeIn、useInterval、useCountdown | 逻辑复杂到 10 行以上就该跑路 |
| 只在组件 mount 时跑一次的代码(旧的 useMount) | 禁止 | 直接写两个 useEffect | 这种“生命周期 Hook”是反模式 |
| 只是想少写依赖数组(useEffectOnce、useUpdateEffect) | 禁止 | 老老实实写依赖 | 逃避依赖就是在埋雷 |
要不要提取自定义 Hook?
每次你准备提取前,先问自己这三句话,答“是”越多越该提:
- 这段逻辑会在 ≥2 个组件里出现?
- 这段逻辑包含 Effect(尤其是带 cleanup 的)?
- 这段逻辑跟某个明确的“外部系统”或“业务场景”强相关?
推荐命名大全
| 用途 | 推荐 Hook 名字 | 示例 |
|---|---|---|
| 数据获取 | useData / useQuery / useFetch | useData(url) |
| 外部连接 | useSocket / useChatRoom / useConnection | useChatRoom(options) |
| 浏览器 API | useOnlineStatus / useMediaQuery / useWindowSize | useOnlineStatus() |
| 埋点打点 | useTrack / useImpressionLog / useAnalytics | useImpressionLog('view_home') |
| 动画 | useFadeIn / useTypewriter / useCountdown | useFadeIn(ref, 1000) |
| 复杂表单 | useForm / useField / useWizard | useForm(initialValues) |
| 第三方库 | useMapbox / useStripe / useSupabase | useSupabase(table) |
反模式红榜
| Hook 名字 | 为什么是反模式 | 正确做法 |
|---|---|---|
| useMount | 隐藏了响应式依赖 | 直接写两个 useEffect |
| useEffectOnce | 同上 | 老实写依赖数组 |
| useUpdateEffect | 假装聪明,其实在骗 lint | 写 [dep1, dep2] |
| useLocalStorageState | 名字太长且容易误导 | 改成 useLocalStorage(key, initial) |
| useAsync | 太通用,啥都能干 | 拆成 useData / useMutation |