前言
这一阵,React 的官方文档有了更新,并且还被尤大大怒怼了一番,便想着重新仔细的去全面阅读一下官方文档。
仔细阅读文档的时候,找到了一个之前别人问我的一个问题,useEffect 为什么会执行两次?
问题
在平时开发的时候,当 useEffect 第二个参数为空数组时,明明是在组件挂载之后执行一次,但是你会发现在里面打印时,会有两次输出。
js
import { useEffect } from 'react';
const App = () => {
useEffect(() => {
console.log('useEffect 执行!');
}, [])
}
会造成一些的困惑,为什么会执行两次,明明给出的类似 componentDidMount 生命周期,在挂载后执行一次。
官方文档给出的解释
对此问题,官方文档给出了相应的解释。
这种情况只在开发环境中才如此,是 react 方便使用者在开发环境调试。
是为了代码的健壮性,但本人感觉多少会有点心智负担,尤其是没有仔细阅读过官方文档的人。
如何处理在开发环境中 Effect 执行两次?
在开发环境中,React 有意重复挂载你的组件,以查找像上面示例中的错误。正确的态度是 "如何修复 Effect 以便它在重复挂载后能正常工作" ,而不是 "如何只运行一次 Effect"。
通常的解决办法是实现清理函数。清理函数应该停止或撤销 Effect 正在执行的任何操作。简单来说,用户不应该感受到 Effect 只执行一次(如在生产环境中)和执行"挂载 → 清理 → 挂载"过程(如在开发环境中)之间的差异。
下面提供一些常用的 Effect 应用模式。
控制非 React 组件
有时需要添加不是使用 React 编写的 UI 小部件。例如,假设你要向页面添加地图组件,并且它有一个 setZoomLevel() 方法,你希望调整缩放级别(zoom level)并与 React 代码中的 zoomLevel state 变量保持同步。Effect 看起来应该与下面类似:
js
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
请注意,在这种情况下不需要清理。在开发环境中,React 会调用 Effect 两次,但这两次挂载时依赖项 zoomLevel 都是相同的,所以会跳过执行第二次挂载时的 Effect。开发环境中它可能会稍微慢一些,但这问题不大,因为它在生产中不会进行不必要的重复挂载。
某些 API 可能不允许连续调用两次。例如,内置的 dialog 元素的 showModal 方法在连续调用两次时会抛出异常,此时实现清理函数并使其关闭对话框:
js
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
在开发环境中,Effect 将调用 showModal(),然后立即调用 close(),然后再次调用 showModal()。这与调用只一次 showModal() 的效果相同。也正如在生产环境中看到的那样。
订阅事件
如果 Effect 订阅了某些事件,清理函数应该退订这些事件:
js
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
在开发环境中,Effect 会调用 addEventListener(),然后立即调用 removeEventListener(),然后再调用相同的 addEventListener(),这与只订阅一次事件的 Effect 等效;这也与用户在生产环境中只调用一次 addEventListener() 具有相同的感知效果。
触发动画
如果 Effect 对某些内容加入了动画,清理函数应将动画重置:
js
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // 触发动画
return () => {
node.style.opacity = 0; // 重置为初始值
};
}, []);
在开发环境中,透明度由 1 变为 0,再变为 1。这与在生产环境中,直接将其设置为 1 具有相同的感知效果,如果你使用支持过渡的第三方动画库,你的清理函数应将时间轴重置为其初始状态。
获取数据
如果 Effect 将会获取数据,清理函数应该要么 中止该数据获取操作,要么忽略其结果:
js
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
我们无法撤消已经发生的网络请求,但是清理函数应当确保获取数据的过程以及获取到的结果不会继续影响程序运行。如果 userId 从 'Alice' 变为 'Bob',那么请确保 'Alice' 响应数据被忽略,即使它在 'Bob' 之后到达。
在开发环境中,浏览器调试工具的"网络"选项卡中会出现两个 fetch 请求。这是正常的。使用上述方法,第一个 Effect 将立即被清理,而 ignore 将被设置为 true。因此,即使有额外的请求,由于有 if (!ignore) 判断检查,也不会影响程序状态。
在生产环境中,只会显示发送了一条获取请求。如果开发环境中,第二次请求给你造成了困扰,最好的方法是使用一种可以删除重复请求、并缓存请求响应的解决方案:
js
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
这不仅可以提高开发体验,还可以让你的应用程序速度更快。例如,用户按下按钮时,如果数据已经被缓存了,那么就不必再次等待加载。你可以自己构建这样的缓存,也可以使用很多在 Effect 中手动加载数据的替代方法。
如何正确使用 Effect 依赖
现在知道了 Effect 为什么会执行两次的原因,对于 effect 的依赖项,如何正确的使用是关键,如果监听没有意义的响应值,会造成没有必要的重复渲染和 bug 的产生!
react 自带的 lint 检查,建议不要关闭,可以明确的检测出是否需要响应值。一般情况下,在 Effect 内部使用到的变量,都要去定义,就会造成一个问题,这些值是否是真的可以当做依赖项。
官方给出了如下的几个场景:
响应值和非响应值
首先需要辨认什么事响应值,什么是非响应值。 响应值:文档中有介绍,在组件内部声明的 props、state 和其他值都是 响应式 的,因为它们是在渲染过程中计算的,并参与了 React 的数据流。
给出一个官方文档中的例子,更加直接
js
function ChatRoom({ roomId, selectedServerUrl }) { // roomId 是响应式的
const settings = useContext(SettingsContext); // settings 是响应式的
const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl 是响应式的
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // Effect 读取了 roomId 和 serverUrl
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // 因此,当它们中的任何一个发生变化时,它需要重新同步!
// ...
}
非响应值:除了上述之外的值都是非响应的
js
const serverUrl = 'http://xxxx'; // 非响应式的
function ChatRoom({ roomId, selectedServerUrl }) { // roomId 是响应式的
const settings = useContext(SettingsContext); // settings 是响应式的
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // Effect 读取了 roomId 和 serverUrl
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]); // 因此,当它们中的任何一个发生变化时,它需要重新同步!
// ...
}
移除没有必要的依赖
当你在打开 lint 检查时,有些在 Effect 中使用到的响应式值不想作为依赖,但是 lint 检查会报错,此时就要权衡考虑该响应式值如何从 Effect 中移除,变成非响应式,或者你可以使用暴力,关闭 lint 检查,但是这种"欺骗 React"的方法很不可取。
正确使用 Effect
在有些情况下,可能根本就没有必要使用 Effect,例如点击发送接口这种事件行为,没有必要写在 Effect 中通过依赖进行实现。
Effect 是否在做几个不相关的事情
js
useEffect(() => {
if (isCity) {
// 执行相关逻辑
}
if (isCountry) {
// 执行相关逻辑
}
}, [isCity, isCountry])
上述代码,同时依赖了 isCity 和 isCountry,在 Effect 内部判断当他俩各自为 true 时去执行相关逻辑。
这种做法是不可取的,应当将两者分开,不然会造成多余的重复渲染。并且违背了 React 创建 hook 的宗旨,就是为了分离不相关的代码,不像 类组件一样,在同一个生命周期内执行多个不想干的逻辑。
js
useEffect(() => {
if (isCity) {
// 执行相关逻辑
}
}, [isCity])
useEffect(() => {
if (isCountry) {
// 执行相关逻辑
}
}, [isCountry])
是否读取一些状态来计算下一个状态
js
const [message, setMessage] = useState([]);
useEffect(() => {
if (isCity) {
// 相关逻辑
setMessage([...message, ...newMessage]);
}
}, [isCity])
因为在 Effect 中使用到了 message,lint 肯定会检查出来的,当依赖中没有的话,会报错!但是并不想在 message 变化时,重新执行 Effect 中的代码,并重新渲染。
js
const [message, setMessage] = useState([]);
useEffect(() => {
if (isCity) {
// 相关逻辑
setMessage(msg => [...msg, ...newMessage]);
}
}, [isCity])
进行上述解决,注意 Effect 现在根本不读取 messages 变量。
读取一个值而不对其变化做出"反应"
js
const [message, setMessage] = useState([]);
useEffect(() => {
if (isCity) {
// 相关逻辑
connect('message', (newMessage) => {
setMessage(msg => [...msg, ...newMessage]);
if (isTrue) {
// 相关逻辑
}
})
}
}, [isCity])
此时,lint 会要求 isTrue 添加到依赖项中。 但是并不想在改变 isTrue 时,再一次执行 Effect 中的逻辑。
js
import { useEffectEvent } from 'react';
const App = () => {
const [message, setMessage] = useState([]);
const onMessage = useEffectEvent(newMessage => {
setMessages(msgs => [...msgs, newMessage]);
if (!isMuted) {
// 相关逻辑
}
});
useEffect(() => {
if (isCity) {
// 相关逻辑
connect('message', (newMessage) => {
onMessage(newMessage);
})
}
}, [isCity])
}
使用 useEffectEvent 将逻辑独立出来,但是此 hook 还在建设中,没有发布到稳定。
包装来自 props 的事件处理程序
js
// 父组件
<Parent>
<Son onReceive={(newMessage) => {}} />
</Parent>
// 子组件
const Son = ({ onReceive }) => {
useEffect(() => {
if (isCity) {
// 相关逻辑
connect('message', (newMessage) => {
onReceive(newMessage);
})
}
}, [isCity])
}
如果直接使用,lint 将会提示,将 onReceive 作为依赖项,这就会造成,每次父组件更新的时候,都会造成 Effect 内部的逻辑重新执行。最要用 useEffectEvent 进行包裹。
js
// 父组件
<Parent>
<Son onReceive={(newMessage) => {}} />
</Parent>
// 子组件
const Son = ({ onReceive }) => {
const onMessage = (newMessage) => {
onReceive(newMessage);
}
useEffect(() => {
if (isCity) {
// 相关逻辑
connect('message', (newMessage) => {
onMessage(newMessage);
})
}
}, [isCity])
}
分离响应式和非响应式代码
父组件接收过来的值,只想其中一个值的改变使子组件 Effect 中的逻辑重新执行,但另外一个值还在逻辑中使用。
js
// 父组件
<Parent>
<Son value1 = {value1} value2={value2} />
</Parent>
// 子组件
const Son = ({ value1, value2 }) => {
const func = useEffectEvent(() => {
func1(value1, value2);
})
useEffect(() => {
connect('message', (newMessage) => {
func(value1);
})
}, [value1])
}
一些值无意中触发改变
使用对象或者函数作为依赖值
js
const App = () => {
const options = {
url: 'http://xxxx',
name: '音乐'
}
useEffect(() => {
connect(options);
}, [options])
}
在 Effect 内使用组件内部声明的对象时,每次组件渲染都会重新生成 options 对象,依赖项一直在发生变化。
js
const options = {
url: 'http://xxxx',
name: '音乐'
}
const App = () => {
useEffect(() => {
connect(options);
}, [])
}
将对象移出组件,放在组件外部,函数也是如此。
将动态对象和函数移动到 Effect 中
如果函数和对象,依赖外部参数时,将他们放在 Effect 中
js
const App = () => {
useEffect(() => {
const options = {
url: 'http://xxxx',
name: name
}
connect(options);
}, [name])
}
从对象中读取原始值
js
<Parent>
<Son obj={{
value,
name
}} />
</Parent>
const Son = ({ obj }) => {
useEffect(() => {
connect(obj);
}, [obj])
}
因为 obj 是从父组件传递过来的,造成每次传入都是一个新的对象,会造成 Effect 内部的逻辑会重复执行。可以将对象解构出来,对某些值进行依赖。
js
<Parent>
<Son obj={{
value,
name
}} />
</Parent>
const Son = ({ obj }) => {
const { value, name } = obj;
useEffect(() => {
connect({
value,
name
});
}, [value, name])
}
从函数中计算原始值
上述方法也适用于函数
js
<Parent>
<Son func={() => ({
value,
name
}}) />
</Parent>
const Son = ({func}) => {
const { value, name } = func();
useEffect(() => {
connect({
value,
name
});
}, [value, name])
}
总结
- Effect 当依赖项为空数组时,只在开发环境中执行两次,为了提高代码额健壮性
- 解决办法要根据实际业务场景去解决,有清除函数时要加上
- Effect 的依赖项很重要,有些没有必要监听的依赖,要从 Effect 中独立出来,变为非响应式的值