React 脱围机制(二):重新理解 useEffect——它不是生命周期,而是同步外部系统

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 才算真正学会了。

相关推荐
卡布鲁43 分钟前
React useMemo 与 useCallback 完全指南:原理、场景与避坑
前端·react.js
右耳朵猫AI6 小时前
Web前端周刊2026W38 | React 19.3 发布、StyleX 深潜、jsdom 30.1 提速
前端·javascript·react.js·typescript·node.js
晚安日记wanna1 天前
React 为何不做双端 diff?Vue 与 React 更新逻辑的三层差异
前端·react.js·面试
光影少年1 天前
setImmediate 和 setTimeout(0) 的区别
android·前端·react.js·ios·前端框架
用户298698530142 天前
前端如何把 TXT 文本文件转成 Word 文档:React 实践
前端·javascript·react.js
moMo3 天前
React Hooks 与闭包
前端·react.js
张明君644513 天前
接口返回 6 条,页面却有 7 篇:从源码看 React 和 Vue 的重复 key
react.js
师傅别念了3 天前
React Hooks 踩坑合集,全是血泪教训
react.js