序
我们在乘坐地铁或者公交车的时候,在地铁门或者公交车门的旁边往往会有「禁止依靠」这样的标识。目的是为了警告乘客不要依靠车门,否则开门的时候容易出现不可预测的危险。
但是如果我们仔细去分析这个危险的话,就会知道,他的真实情况是,在车辆运行过程中,车门紧闭,你依靠在车门上也并不会出现危险,我们在上班高峰期挤地铁的时候,大量的人也不得不紧靠车门,甚至有的人被挤扁压在车门上。
在制定团队项目规范时也会这样,例如,我在带领团队时,一定会制定一条规范,要求每次代码提交之前,个人必须检查你的代码里是否存在意外的修改,可能有的人在提交之前手抖往代码里输入了一个空格或者逗号,从而导致重大事故。这是一个低概率发生的事情,但是我仍然会要求每次提交都要检查。
那么,是不是也就意味着,如果不遵守我这个规范,就一定会发现不可预测的重大事故呢?其实并非如此,我们制定规范的目的是为了让程序变得可控,比如团队里面有10个人的习惯都比较好,从来不会出现意外内容提交的情况,但是只要有一个人出现两次因为手抖把意外的修改提交到了代码仓库,那么这条规范就会出现。
既然这条规范的出现是为了避免意外的发生,于是有一个项目成员就对我的规范提出了质疑,他认为可以在配置上增加 pre-commit 的代码规则检测,如果有意外的发生,那么代码规则检测不会通过,我们就不用每次在提交之前花费心力去检查每一条 diff 里的修改了。
虽然我最终没有同意他的提议,但这是一个非常好的思路
所以作为一个优秀的开发者,我们到底是应该只要遵循规范就是完事了,还是应该去看懂规范出现的背后逻辑,从而灵活的运用他,或者探寻更好的解决方案呢?
我的答案是后者。
如果在这个观念的基础之上我们能达成共识,我们再来一起结合 React 官方文档,对 useEffect 的使用规范进行深入探讨。
在这之前,我们要首先明确一下 useEffect 的语法规则,useEffect 的依赖项必须是 state 与 props,否则依赖项发生了变化,effect 也不会执行。所以有的人说:我不愿意把 state 放到依赖项里,甚至反感这样的行为,我认为是没有任何理论依据的。
1.计算属性
在 vue 和 mobx 里都有计算属性这样的概念。因此有的人就想,在 react hook 中,是否可以借助 useEffect 来达到计算属性的目的。
官方文档明确的建议是,不需要这样做
// 案例来自官方文档 // https://zh-hans.react.dev/learn/you-might-not-need-an-effect function Form() { const [firstName, setFirstName] = useState('Taylor'); const [lastName, setLastName] = useState('Swift'); // 🔴 避免:多余的 state 和不必要的 Effect const [fullName, setFullName] = useState(''); useEffect(() => { setFullName(firstName + ' ' + lastName); }, [firstName, lastName]); // ... }
实际上 react 的 re-render 机制表示 react hook 本身已经具备了计算属性的特性。当 state 发生变化,函数会重新执行,内部的代码也会重新执行,因此案例中的 fullName 就有一次重新计算结果的机会
function Form() { const [firstName, setFirstName] = useState('Taylor'); const [lastName, setLastName] = useState('Swift'); // ✅ 非常好:在渲染期间进行计算 const fullName = firstName + ' ' + lastName; // ... }
因此我们不必借助 useEffect 来实现计算属性,这是非常好的建议。
2.缓存计算结果
但是如果情况发生一些变化呢?fullName 这个案例的计算过程非常简单,如果这个计算过程非常复杂需要花费大量的时间呢?此时我们就不得不考虑要减少这个计算过程,只在他需要重新计算的时候计算一次
这个时候,这个案例的解决方案就不再适用了,他只适合简单的运算过程。复杂运算过程我们还需要借助别的手段来缓存计算结果。那么使用 useEffect 是否合适?
不合适。官方文档中,提供了一个更适合的 hook:useMemo 来缓存运算结果。
但是为什么呢?
因为执行时机的问题。事实上,useEffect 和 useMemo 都有记忆能力,他们的底层实现有部分相似之处,但是有一个不同之处导致了他们的差别非常大,那就是传入的第一个参数的执行时机。useMemo 在发现依赖项有改变之后,会立即重新运算缓存的函数并返回运算结果。但是 useEffect 则需要等待组件渲染完整之后才会开始执行缓存的函数。类似于
setTimeout(effect, 0)
也就意味着,当前一轮执行的 JSX 中无法得到 useEffect 的运算结果。除非我们将运算结果存储在一个 state 中,让 state 发生改变而得到一轮新的 render。
因此在这种场景之下,useMemo 会比 useEffect 更快更合适。
官方文档给我们提供了一个案例。
现在有一个复杂列表 todos,然后还有一个过滤条件 filter,todos 和 filter 运算之后可以得到一个列表 visibleTodos。
const visibleTodos = getFilteredTodos(todos, filter);
function TodoList({ todos, filter }) { const [newTodo, setNewTodo] = useState(''); // 🔴 避免:多余的 state 和不必要的 Effect // 假设 JSX中使用了 visibleTodos const [visibleTodos, setVisibleTodos] = useState([]); useEffect(() => { setVisibleTodos(getFilteredTodos(todos, filter)); }, [todos, filter]); // ... }
正是由于使用了 useEffect,因为执行时机的问题,如果不将运算结果存储在 state 中,当前一轮的 render,在 JSX 中无法得到新的运算结果,因此只有通过 state 的重新出发一次 render 的机会让渲染结果保持最新。
所以不推荐使用 useEffect,直接去掉就行了
function TodoList({ todos, filter }) { const [newTodo, setNewTodo] = useState(''); // ✅ 如果 getFilteredTodos() 的耗时不长,这样写就可以了。 const visibleTodos = getFilteredTodos(todos, filter); // ... }
但是由于此案例中设定的是 getFilteredTodos 是一个耗时操作,因此我们需要使用 useMemo 来缓存他的运算结果。这样就可以做到当其他 state 发生变化时,getFilteredTodos 不会重新执行。
import { useMemo, useState } from 'react'; function TodoList({ todos, filter }) { const [newTodo, setNewTodo] = useState(''); const visibleTodos = useMemo(() => { // ✅ 除非 todos 或 filter 发生变化,否则不会重新执行 return getFilteredTodos(todos, filter); }, [todos, filter]); // ... }
这个案例充分说明了 useMemo 的作用。
但是案例有一些不太合理的地方。例如,todos 和 fitler 都是外部传入的 props,也就是说,下面这一行代码更合理的方案是在组件外部计算好,因为他运算所需的条件都是外部条件
const visibleTodos = getFilteredTodos(todos, filter);
这样我们就完全不需要考虑因为 re-render 而处理他的冗余运算成本问题了。因为他的运算次数将会严格和 todos、filter 的变化保持一致。
3.与事件相关的争议
现在我们来思考一个类似的交互方案,依然是一个任务列表
给他们设定一个过滤条件,类别,例如有两个类别是工作类与旅游类,当类别发生变化的时候,部分任务会隐藏
此时你就会发现一个问题,如果类别也需要在 UI 中进行显示,那么我们就不得不把类别这个过滤条件存放在 state 中去触发 UI 的变化,与此同时,类别的变化还会导致 todos 也发生变化
这个时候就存在两种比较有争议的写法
第一种写法完全更符合语义和解耦的思考。从语义上来说,当我们点击了单选按钮切换了类别,此时只需要修改 fitler 即可,因为我们只做了这一个操作。但是 filter 的修改,还会造成别的改动:列表也会发生变化,这是一种额外的副作用。因此我们使用 useEffect 来处理这部分副作用逻辑。
从解耦的角度来说,当点击切换按钮时,我们不需要关注额外的逻辑,这对于开发而言是一种理解上的简化,因为我们在点击时只需要关注按钮本身,而不需要关注按钮切换之后的后续变化。这种解耦思路更有利于后续的封装
副作用:我的修改,导致了除我之外的 UI 发生变化
function TodoList({ todos }) { const [newTodos, setNewTodos] = state(todos) const [fitler, setFilter] = state(1) useEffect(() => { }, [ setNewTodos(getFilteredTodos(todos, filter)); ], [filter]) function onFilterChange(value) { setFilter(value) } }
但是这种更符合语义和解耦的方案,违背了刚才的规范。因为我们使用 useEffect 去监听一个 state,修改另外一个 state。不过刚才的规范的目的之一,是为了避免出现冗余的 state,本案例里面并没有冗余的 state,filter 也是必须存在的。那么看上去前提条件跟规范有一些出入
于是,React 官方文档还存在另外一条规范
react 官方文档把 useEffect 称为一种逃离方案「逃生舱」,我们应该在最后使用它。因此在这个情况下,官方文档建议把逻辑放到事件中处理,而不是 useEffect。
在这个案例中,下面的写法是官方文档更推荐的写法
function TodoList({todos}) { const [newTodos, setNewTodos] = state(todos) const [fitler, setFilter] = state(1) function onFilterChange(value) { setFilter(value) setNewTodos(getFilteredTodos(todos, value)) } // ... }
使用 useEffect 虽然更符合语义和解耦,但是他会额外执行一次 render,因此在执行速度上,这种写法是更快的。
useEffect 有更复杂的执行逻辑,如果你对其掌握得不够准确时,他很容易导致你的程序出现一些你无法理解的迷惑现象,因此在这两个基础之上,react 官方文档的意思就是,useEffect 能不用就不用。
但是如果我们已经对 useEffect 的运行机制非常清楚,并且他使用他付出的代价只是一次 re-render,我会更倾向于选择前者:更符合语义、解耦好更利于封装,而不是严格遵守规范。
事实上,只要你不乱来,一次 re-render 的成本很低,除非是在一些特殊场景,例如渲染大量的 input 或者高频渲染
如果在性能上还有争议的话,那么接下来我们把本次案例进行一个修改,新修改的交互将会更容易出现在我们的实践中。
当过滤条件发生变化,新的列表并不是从本地数据中运算得来,而是接口从服务端获取。
那么两种写法的代码就会变成
// 使用 useEffect function TodoList({ todos }) { const [newTodos, setNewTodos] = state(todos) const [fitler, setFilter] = state(1) useEffect(() => { }, [ api(filter).then(res => { setNewTodos(res.data) }) ], [filter]) function onFilterChange(value) { setFilter(value) } // ... }
// 不使用 useEffect function TodoList({ todos }) { const [newTodos, setNewTodos] = state(todos) const [fitler, setFilter] = state(1) function onFilterChange(value) { setFilter(value) api(filter).then(res => { setNewTodos(res.data) }) } // ... }
此时就会发现,使用 useEffect 的性能劣势消失不见了。因为即使我们在事件中请求了接口,但是由于异步事件的存在,导致 setFilter 与 setNewTodos 无法合并优化,他们只能在不同的时间里触发 re-render。
而第一种写法由于解耦做得比较好,因此他可以很容易在自定义 hook 的语法规则之下,简化组件的逻辑
function TodoList({ todos }) { const {newTodos, filter, setFilter} = useTodoList(api) function onFilterChange(value) { setFilter(value) } // ... }
这样我们就可以把 useEffect 和 异步逻辑通过封装的方式藏起来。这种情况之下的选择上,我更倾向于选择更好的语义和更好的解耦。他在性能上的牺牲非常非常小。
4.useEffectEvent
在官方文档中
https://zh-hans.react.dev/learn/separating-events-from-effects
介绍了一个实验性 api,useEffectEvent,用于从 Effect 中提取非响应式逻辑,他能够绕开闭包的困扰,读取到最新的 state 和 props
import { useEffect, useEffectEvent } from 'react'; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification('Connected!', theme); }); // ...
先介绍一下官方案例的交互:
首先我们要完成一个聊天室的切换功能。聊天室切换时,我们需要断开之前的连接,并接上新的连接。
聊天室在切换后连接成功时,需要有一个提示,表示我进入到了新的聊天室,并已经连接成功了。
与此同时,该案例设计了一个交互点,新增了一个配置,去修改提示组件的风格,让他可以切换到 dark 主题
当我选中 Use dark theme 时,那个提示组件也会弹出来露露脸。
事实上,实践中不应该出现这种交互,这里之所以出现是因为把他当成一个问题来解决的
在代码的设计中,isDark 被设计成为了一个响应数据。
const [isDark, setIsDark] = useState(false);
然后我们封装了一个 CharRoom,使用时将 roomId 与 theme 作为 props 传入
<ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} />
在封装 ChatRoom 时,由于 showNotification 的执行需要 theme 作为参数,于是,theme 就不得不作为 useEffect 的依赖项传入,否则 showNotification 无法获取最新的 theme 值
这是因为闭包的存在
// theme = isDark ? 'dark' : 'light' const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { showNotification('Connected!', theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); return <h1>Welcome to the {roomId} room!</h1> }
但是如果把 theme 作为依赖项之后,问题就产生了,由 roomId 切换导致的聊天室的断开和重连逻辑就变得混乱了,因为当你修改主题时,这段逻辑也会执行。这明显是不合理的。
因此,react 团队正在想办法设计一个 api,将 useEffect 的响应式逻辑与非响应式逻辑区分开。
解决代码如下:
function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification('Connected!', theme); }); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); // ✅ 声明所有依赖项 // ...
事实上,在现有的方案之下,我们也有适合的解决方案。
首先我们要考虑一个交互上的特性,主题的更改,对于提示组件的影响并非是实时的。也就是说,当我在修改主题时,我们并不需要一个提示组件出来露露脸。
因此,我们此时有机会考虑设计一个非响应式的数据来存储主题的更改。另一个角度,是否选中的 UI 样式的修改,是 input 组件内部自己的交互逻辑,因此此时也不需要外部提供一个响应式数据来控制 input 是否被选中。
const isDark = useRef(false);
完整的逻辑代码如下,该代码可在对应的官方案例中运行
import { useState, useEffect, useRef } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { showNotification('Connected!', theme.current ? 'dark' : 'light'); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); return <h1>Welcome to the {roomId} room!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const isDark = useRef(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <label> <input type="checkbox" onChange={e => (isDark.current = e.target.checked)} /> Use dark theme </label> <hr /> <ChatRoom roomId={roomId} theme={isDark} /> </> ); }
这样,在我们前面提到的数据驱动 UI 的哲学逻辑驱动之下,精确分析数据与 UI 的关系,我们也完美的解决了这个问题。
5.总结
react 官方文档在建议与规范的角度上会尽可能让大家避免使用 useEffect,我猜测大概是由于许多初学者在 useEffect 对于依赖项的使用会产生不少疑问而导致的。但并不代表在 useEffect 的思考上,就没有更合理的使用方式,他也不是一个反模式。
包括我们制定团队规范也是一样,团队规范保障的是整个项目的底线,并不一定能代表项目上限,也不一定能代表最佳实践。
因此,我更倡导大家在学习规范时,去充分理解规范出现的背后逻辑,灵活的运用他,并积极探寻更好的解决方案。
注:本文中的所有理解和建议仅仅限制于 react 项目开发,请勿过度解读到其他方面