useEffect 大概是 React Hooks 中最容易被滥用的一个。
很多人的第一印象来自类组件:
jsx
componentDidMount
componentDidUpdate
componentWillUnmount
于是 Hooks 出现以后,很自然地把:
jsx
useEffect(() => {}, []);
理解成:
jsx
componentDidMount
把:
jsx
useEffect(() => {});
理解成:
jsx
componentDidUpdate
这种理解在简单情况下"好像能用"。
但是它会让你越来越难理解 Effect。
因为 React 官方现在希望我们建立的心智模型根本不是:
Effect 是函数组件里的生命周期。
而是:
Effect 用来让 React 和外部系统保持同步。
这句话才是真正理解 Effect 的钥匙。
一、React 本身其实并不需要 Effect
想象一个最普通的组件:
jsx
function Greeting({ name }) {
return <h1>Hello {name}</h1>;
}
整个过程只有:
props
↓
render
↓
JSX
↓
DOM
这里没有 Effect。
甚至大部分 React 代码本来就不应该需要 Effect。
Effect 出现的原因是:
现实世界不只有 React。
例如:
css
React state
↓
HTML5 Video API
WebSocket
第三方地图 SDK
浏览器事件
IntersectionObserver
Canvas
服务器连接
这些东西并不由 React 控制。
React 只能通过 Effect:
在 render 完成以后,将 React 当前状态同步给外部系统。
二、一个经典的视频播放器例子
假设:
jsx
<VideoPlayer isPlaying={true} />
我们希望:
ini
isPlaying = true
→ video.play()
isPlaying = false
→ video.pause()
可能会写:
jsx
function VideoPlayer({ isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
return <video ref={ref} />;
}
这是错误思路。
因为 render 阶段:
csharp
ref.current
甚至可能还不存在。
更重要的是:
render 应该描述 UI,而不应该执行外部副作用。
所以:
jsx
function VideoPlayer({ isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
return <video ref={ref} />;
}
这里整个流程变成:
css
React render
↓
DOM 已经更新
↓
Effect 执行
↓
读取 isPlaying
↓
同步 video.play / pause
这就叫:
csharp
Synchronizing with Effects
使用 Effect 进行同步
三、Effect 和事件处理函数有什么区别?
这是非常重要的一组概念。
假设:
jsx
<button onClick={handleSubmit}>
提交
</button>
handleSubmit 为什么执行?
因为:
用户点击了按钮
它的触发原因是:
一个具体的用户交互。
而 Effect:
jsx
useEffect(() => {
connect(roomId);
}, [roomId]);
为什么执行?
不是因为用户具体点击了哪个按钮。
而是因为:
ini
组件现在处于 roomId = xxx 的状态
外部系统也必须同步到 xxx
因此:
vbnet
Event Handler
"用户做了什么?"
Effect
"组件现在处于什么状态,
外部系统需要同步成什么状态?"
这是判断代码应该写在哪里的重要方法。
四、Effect 的真正生命周期
很多人说:
sql
组件生命周期:
mount
update
unmount
这没错。
但 Effect 更适合用另一套生命周期理解:
markdown
开始同步
↓
停止同步
例如聊天室:
jsx
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
不要理解成:
arduino
mount → connect
unmount → disconnect
更准确地理解:
ini
roomId = general
开始同步:
connect(general)
后来:
ini
roomId = music
那么旧同步已经不正确。
所以:
scss
停止旧同步:
disconnect(general)
开始新同步:
connect(music)
于是 Effect 的真实过程是:
scss
connect(general)
↓
roomId 改变
↓
disconnect(general)
↓
connect(music)
↓
组件卸载
↓
disconnect(music)
所以 Effect 并不是简单:
执行一次
而是:
建立一个同步关系,并在依赖变化时重新建立同步关系。
五、cleanup 到底是什么?
Effect:
scss
useEffect(() => {
subscribe();
return () => {
unsubscribe();
};
}, []);
这里返回的函数叫:
cleanup
可以理解为:
"如何撤销刚才 Effect 建立的同步关系。"
例如:
arduino
addEventListener
↔ removeEventListener
connect
↔ disconnect
setInterval
↔ clearInterval
subscribe
↔ unsubscribe
这种代码天生是成对出现的。
例如:
jsx
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener(
'resize',
handleResize
);
};
}, []);
如果只有:
addEventListener
没有:
removeEventListener
组件反复挂载时就可能不断增加监听器。
六、为什么开发环境 Effect 会执行两次?
这是很多 React 初学者第一次碰到都会怀疑人生的问题:
jsx
useEffect(() => {
console.log('connect');
}, []);
结果开发环境:
arduino
connect
connect
第一反应:
React 有 Bug?
其实这是开发模式故意帮助你发现 cleanup 问题。
例如:
jsx
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
开发环境可能表现成:
arduino
connect
disconnect
connect
React 想验证的是:
如果组件经历一次挂载 → 卸载 → 再挂载,你的代码还能不能正常工作?
真正的问题不是:
Effect 为什么执行两遍?
而是:
为什么我的 Effect 执行第二次就出问题了?
如果 Effect 写得正确:
arduino
setup
cleanup
setup
理论上应该仍然正常。
七、Effect 为什么需要 dependencies?
来看:
jsx
useEffect(() => {
const connection =
createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
这里:
roomId
是一个响应式值。
Effect 使用了:
roomId
所以:
roomId 发生变化
↓
旧 Effect 已经和现实不同步
↓
需要重新执行
依赖数组其实是在告诉 React:
这个同步关系依赖哪些响应式值
但这里有一个非常重要的观念:
dependencies 不是让开发者随便挑的配置项。
很多人看到 Effect 执行次数太多,就想:
arduino
// 把依赖删掉不就好了?
例如:
jsx
useEffect(() => {
console.log(roomId);
}, []);
eslint:
yaml
Missing dependency: roomId
于是:
arduino
// eslint-disable-next-line
直接压掉。
这通常是在掩盖问题。
因为代码明明使用:
roomId
Effect 却声称:
我不依赖 roomId
这两个事实发生矛盾。
八、Effect 本质上是一段"响应式代码"
假设:
jsx
function ChatRoom({ roomId }) {
useEffect(() => {
connect(roomId);
}, [roomId]);
}
可以把它想象成:
只要 roomId 改变
就重新同步
所以 Effect 其实是一段:
Reactive Logic
响应式逻辑
它跟随 React 中的数据变化自动重新执行。
这也解释了为什么:
perl
props
state
组件中声明的变量
可能成为依赖。
因为它们可能随着 render 改变。
九、一个容易忽略的问题:有些代码并不应该响应
假设聊天室有:
roomId
theme
代码:
jsx
useEffect(() => {
const connection =
createConnection(roomId);
connection.on('connected', () => {
showNotification(
'连接成功',
theme
);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
现在切换主题:
light
↓
dark
Effect 重新执行。
于是:
arduino
disconnect
connect
聊天室居然重新连接了一次。
但我们的真正需求是:
roomId 改变
→ 重新连接
theme 改变
→ 不重新连接
问题在于:
theme
虽然被 Effect 中的代码读取了,
但我们不希望它控制这个同步关系。
十、React 的新思路:Effect Event
现在 React 提供:
useEffectEvent
可以把:
"Effect 内需要最新值,但不应该使 Effect 重新同步的逻辑"
抽出来。
例如:
jsx
const onConnected = useEffectEvent(() => {
showNotification(
'连接成功',
theme
);
});
然后:
jsx
useEffect(() => {
const connection =
createConnection(roomId);
connection.on(
'connected',
onConnected
);
connection.connect();
return () =>
connection.disconnect();
}, [roomId]);
现在:
roomId 改变
→ Effect 重新连接
theme 改变
→ onConnected 能读取新 theme
→ 但 Effect 不重新连接
这是一种很重要的思想:
响应式逻辑
和
非响应式事件逻辑
应该分开
十一、Event Handler、Effect、Effect Event 怎么区分?
可以这样理解。
Event Handler
jsx
function handleClick() {
sendMessage();
}
触发原因:
用户做了某件事
Effect
jsx
useEffect(() => {
connect(roomId);
}, [roomId]);
触发原因:
组件处于某种状态,
外部系统需要与它同步
Effect Event
jsx
const onConnected =
useEffectEvent(() => {
showNotification(theme);
});
作用:
属于 Effect 过程的一部分,
但不希望它自身成为
重新同步 Effect 的原因。
十二、什么时候真的需要 useEffect?
判断标准其实非常简单。
问自己:
我要同步的东西是不是 React 外部系统?
例如:
WebSocket
浏览器 DOM API
播放器
地图 SDK
analytics
第三方组件
订阅
网络连接
浏览器事件
通常可能需要 Effect。
例如:
jsx
useEffect(() => {
const socket = new WebSocket(url);
return () => socket.close();
}, [url]);
十三、什么时候可能不需要 Effect?
如果你的需求只是:
css
A state 改变
↓
计算 B
例如:
firstName
lastName
↓
fullName
那通常不是同步外部系统。
这种情况很可能根本不需要 Effect。
所以看到:
useEffect
以后第一反应不应该是:
dependency 怎么写?
而应该先问:
这里为什么需要 Effect?我到底在同步哪个外部系统?
如果答不上来,
这个 Effect 很可能就不应该存在。
十四、重新建立 useEffect 的心智模型
以前:
diff
useEffect
≈
componentDidMount
+
componentDidUpdate
+
componentWillUnmount
这种理解虽然能帮助类组件用户入门,
但越往后越容易出问题。
推荐换成:
csharp
render
负责计算 UI
event
负责响应用户操作
effect
负责同步外部世界
进一步:
erlang
Effect 生命周期
开始同步
↓
依赖变化
↓
停止旧同步
↓
开始新同步
↓
...
↓
最终停止同步
这样理解以后:
scss
依赖数组
cleanup
Effect 重执行
Strict Mode
useEffectEvent
都会突然变得顺理成章。
总结
真正理解 Effect,只需要记住三句话。
第一句:
Effect 不是用来"监听变量变化"的。
第二句:
Effect 用来把 React 当前状态和外部系统同步。
第三句:
Effect 的生命周期不是 mount/update/unmount,而是开始同步和停止同步。
于是你以后看到:
jsx
useEffect(() => {
...
}, [...]);
真正应该思考的是:
我在同步什么?
什么时候这个同步关系应该失效?
如何 cleanup?
哪些值变化应该重新同步?
哪些值只是需要读取最新值,
却不应该触发重新同步?
能回答这些问题,
useEffect 才算真正学会了。