当你写 Effect 的时候,代码检查工具会检查依赖项列表是否已经包含了 Effect 读取的每个响应式值(例如 props 和 state)。这可以保证 Effect 和组件最新的 props 以及 state 保持同步。不必要的依赖可能会导致 Effect 频繁运行,甚至造成无限循环。请跟随这篇指南来检查和移除你的 Effect 中不必要的依赖项。
你将会学习到
- 怎样修复 Effect 依赖的无限循环
- 移除一个依赖项的时候要做些什么
- 怎样从 Effect 中读取一个值而不需要对他“做出响应”
- 怎样以及为什么要避免对象和函数依赖项
- 为什么抑制依赖项检查是危险的,以及应该怎么做
依赖项应该和代码匹配
当你写 Effect 时,无论想要 Effect 做什么,首先要做的就是指明如何 开始和结束:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
// ...
}
如果你将 Effect 依赖项置为空([]
),代码检查工具就会建议正确的依赖项:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, []); // <-- 修复这里的错误! return <h1>Welcome to the {roomId} room!</h1>; } export default function App() { const [roomId, setRoomId] = useState('general'); 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> <hr /> <ChatRoom roomId={roomId} /> </> ); }
根据代码检查工具的建议填写依赖项:
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 所有依赖项被声明
// ...
}
Effect 会对响应式值“做出响应”。由于 roomId 是响应式值(它会因为重新渲染而变化),代码检查工具会验证你是否已经将它指定为依赖项。每当 roomId 接收到一个不同的值,React 就会重新同步对应的 Effect。这可以保证聊天会一直和选中的房间保持连接,并对下拉框的变化“做出响应”:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h1>Welcome to the {roomId} room!</h1>; } export default function App() { const [roomId, setRoomId] = useState('general'); 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> <hr /> <ChatRoom roomId={roomId} /> </> ); }
移除一个依赖,需要先证明它不是依赖项
注意你不能“选择”Effect 的依赖项。Effect 代码中用到的每一个 响应式值 都必须在依赖项列表中声明。依赖项列表是由周围的代码决定的:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) { // 这是响应式值
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 这个 Effect 读取了响应式值
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 所以你必须将这个响应式值指定为 Effect 的依赖项
// ...
}
响应式值 包括 props 和直接在组件内部声明的所有变量和函数。因为 roomId 是响应式值,所以不能将它从依赖项列表中移除。这在代码检查工具中是不会通过的:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // 🔴 React Hook useEffect 缺少依赖项: 'roomId'
// ...
}
代码检查工具是对的!因为 roomId
可能随着时间的过去而变化,这将在代码中引入 bug。
移除依赖项需要向代码检查工具证明这个值不需要成为依赖项。例如,你可以从组件中移除 roomId
来证明它不是响应式值且在重新渲染时不会变化:
const serverUrl = 'https://localhost:1234';
const roomId = 'music'; // 不再是响应式值
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ 声明的所有依赖
// ...
}
既然 roomId
不是响应式值(并且在重渲染时不会变化),它就不需要作为依赖项:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; const serverUrl = 'https://localhost:1234'; const roomId = 'music'; export default function ChatRoom() { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, []); return <h1>Welcome to the {roomId} room!</h1>; }
这就是为什么你现在可以指定一个 空 ([]
) 依赖项列表。Effect 实际上不再依赖任何响应式值,所以当组件的任何 props 和 state 变化时,它并不需要重新运行。
修改依赖项之前先修改代码
你可能已经注意到工作流中的一个模式:
- 首先你要 修改代码,包括 Effect 或者声明响应式值的方式。
- 然后遵循代码检查工具的建议并且调整依赖项使其 匹配刚刚修改的代码。
- 如果你不满意依赖项列表,则 返回第一步(再次修改代码)。
最后一部分很重要。如果你想修改依赖项,就要先修改周围的代码。你可以把依赖项列表当成 Effect 代码中 用到的所有响应式值的列表。不是你 选择 放什么到列表,而是列表 描述 了你的代码。想要修改依赖项列表,就要先修改代码。
这可能感觉像是解决一个方程式。你也许会从目标着手(例如移除依赖项),需要“找到”匹配目标的代码。不是每个人都对解决方程式感兴趣,写 Effect 也是这样!幸运的是下面有一些你可以尝试的常用方法列表。
深入探讨
抑制代码检查会导致非常不直观的 bug,它们很难被找到并修复。这里是一个案例:
import { useState, useEffect } from 'react'; export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); function onTick() { setCount(count + increment); } useEffect(() => { const id = setInterval(onTick, 1000); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <h1> Counter: {count} <button onClick={() => setCount(0)}>Reset</button> </h1> <hr /> <p> Every second, increment by: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}>–</button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }
假设你想要Effect“只在组件挂载”的时候运行。你知道 空 ([]
)依赖项 可以达到这个目的,所以你已经决定忽略代码检查工具的建议并且强制指定 []
为依赖。
这个计数器本应该每秒增加一个数,这个数由两个按钮配置。但是因为你对 React“谎称”这个 Effect 不依赖任何值,所以 React 一直使用初始渲染时的 onTick
函数。在这次渲染期间,count
是 0
,increment
为 1
。这就是为什么此次渲染的 onTick
总是每秒调用一次 setCount(0 + 1)
,且你看到的总是 1
。像这样的 bug,当它们跨越多个组件的时候更难修复。
比起忽略代码检查,有一个更好的解决方法!你需要向依赖项列表中加入 onTick
来修复这段代码。(为了确保 interval 只设置一次,需要 让 onTick
成为 Effect Event。)
我们推荐你像对待编译错误一样对待依赖项检查错误。如果你不抑制它,你就永远不会看到像这样的 bug。这篇文档的剩余部分记录了这种这种场景下的一些替代方案。
移除非必要的依赖项
每次调整 Effect 的依赖项以反映代码时,看看依赖项列表。当任意依赖项变化时,Effect 重新运行有意义吗?有时候答案是“no”:
- 你可能想要在不同条件下重新执行 Effect 的 不同部分。
- 你可能想要只读取一些依赖项的 最新值 而不是对它的变化“做出响应”。
- 因为它是一个对象或者函数,所以一个依赖可能 无意中 会频繁变化。
为了找到合适的解决方案,你需要回答一些关于你的 Effect 的一些问题。让我们开始吧。
这段代码应该移动事件处理函数中吗?
你首先应该思考的是这段代码是否应该是一个 Effect。
假设有一个表单。在提交的时候,设置 submitted
state 变量为 true
。你需要发送一个 POST 请求并且展示一个通知。你已经把逻辑放在了 Effect 里面,会对 submitted
变为 true
“做出响应”:
function Form() {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
// 🔴 Avoid: Effect 内部的 Event-specific 逻辑
post('/api/register');
showNotification('Successfully registered!');
}
}, [submitted]);
function handleSubmit() {
setSubmitted(true);
}
// ...
}
之后你需要根据当前的主题给通知信息设置样式,所以你需要读取当前的主题。由于 theme
在组件内声明,所以它是一个响应式值,所以你需要将它添加到依赖项:
function Form() {
const [submitted, setSubmitted] = useState(false);
const theme = useContext(ThemeContext);
useEffect(() => {
if (submitted) {
// 🔴 Avoid: Effect 内部的 Event-specific 逻辑
post('/api/register');
showNotification('Successfully registered!', theme);
}
}, [submitted, theme]); // ✅ 声明的所有依赖
function handleSubmit() {
setSubmitted(true);
}
// ...
}
这样做会引入一个 bug。假设你先提交了表单,然后在 Dark 和 Light 主题间切换。theme
会变化,Effect 就会重新运行,所以它又会展示同样的通知消息!
这里的问题首先是这不应该是一个 Effect 。你想要发送这个 POST 请求并且作为对“提交表单”这个特殊交互的响应展示通知。为了响应特殊交互而运行的一些代码,直接把这段逻辑放在相应的事件处理函数中:
function Form() {
const theme = useContext(ThemeContext);
function handleSubmit() {
// ✅ Good: Event-specific 逻辑是从事件处理函数调用的
post('/api/register');
showNotification('Successfully registered!', theme);
}
// ...
}
既然代码是在一个事件处理函数里,所以它不是响应式的 — 所以它只会在用户提交表单的时候运行。了解更多关于 如何选择事件处理函数和 Effect 以及 如何删除不必要的Effect。
你的 Effect 正在做若干不相关的事情吗?
你应该扪心自问的下一个问题是你的 Effect 是否正在做若干不相关的事情。
假设你正在创建一个 shipping 表单,用户在里面需要选择他们的城市和地区。你根据选中的 country
从服务器获取 cities
列表并且在下拉菜单中展示:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
const [city, setCity] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]); // ✅ 声明的所有依赖
// ...
这是一个 在Effect中获取数据 的优秀示例。你正在根据 country
prop 借助网络同步 cities
state。你无法在一个事件函数中去做这件事情,因为你需要 ShippingForm
只要展示就去获取数据,并在 country
变化时立即重新获取(无论是什么交互导致的)。
假设你现在正在因为添加城市区域二级选择框,这个选择框获取当前选中的 city
的 areas
。你可能会从在同一个 Effect 内部添加第二个 fetch
调用获取区域列表开始:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
// 🔴 Avoid: 同一个 Effect 同步两个独立的进程
if (city) {
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
}
return () => {
ignore = true;
};
}, [country, city]); // ✅ 声明的所有依赖项
// ...
但是由于 Effect 现在使用 city
state 变量,你必须将 city
添加到依赖项列表中。相对地,这会引起一个问题:每当用户选择不同的城市,Effect 就会重新运行和调用 fetchCities(country)
。结果就是,你需要多次不必要地重新获取城市列表。
这段代码的问题在于你同时同步两个不相关的事物:
- 你想要基于
country
prop 将cities
state 同步到网络。 - 你想要基于
city
state 将areas
state 同步到网络。
将这段逻辑拆分成两个 Effect,每个 Effect 只对它需要同步的 prop 做出响应:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]); // ✅ 声明的所有依赖
const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]); // ✅ 声明的所有依赖
// ...
现在第一个 Effect 只在 country
变化时重新运行,而第二个 Effect 只在 city
变化时重新运行。你已经根据目的将他们进行了拆分:两个不同的事物由两个单独的 Effect 进行同步。两个独立的 Effect 有各自的依赖项列表,所以不会无意中相互触发。
最终代码比原本的代码更长,但是分割这些 Effect 的做法仍然是非常正确的。每个 Effect 应该表示一个独立的同步进程。在这个例子中,删除一个 Effect 不会破坏其他 Effect 的逻辑。这意味着他们 同步不同的事物,并且拆分它们是有好处的。如果你担心重复,可以通过 提取重复逻辑到自定义 Hook 来改进这段代码。
你正在读取一些 state 来计算下一个 state 吗?
这个 Effect 会在每次新消息到达时通过新建数组来更新 state 变量 messages
:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages([...messages, receivedMessage]);
});
// ...
它使用 messages
变量来 创建一个以所有已经存在的消息开头的新数组,并且在末尾添加新消息。但是因为 messages
是在 Effect 中读取的响应式值,所以它必须被设置为依赖项:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages([...messages, receivedMessage]);
});
return () => connection.disconnect();
}, [roomId, messages]); // ✅ 声明的所有依赖
// ...
让 messages
成为依赖项会引发一个问题。
每当收到一个消息,setMessages()
会因为新 messages
数组包含接收到的消息而导致组件重新渲染。但是由于这个 Effect 现在依赖于 messages
,这 也 会重新同步这个 Effect。所以每条新的消息都会让聊天室重新连接。用户并不希望这样!
为了修复这个问题,请不要在 Effect 内部读取 messages
值。而是传递一个 更新函数 来 setMessages
:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages(msgs => [...msgs, receivedMessage]);
});
return () => connection.disconnect();
}, [roomId]); // ✅ 声明的所有依赖
// ...
注意你的 Effect 现在完全不会读取 messages
变量。你只需要传递一个像 msgs => [...msgs, receivedMessage]
这样的更新函数。React 把你的更新函数放置在一个队列中 且在下一次渲染中提供 msgs
参数执行。这就是为什么 Effect 本身不再需要依赖 messages
的原因。这个修复的结果就是收到聊天消息将不会在使得聊天重新连接。
你想要只读取值而不对它的变化“做出响应”吗?
假设你想要 isMuted
不是 true
的时候在用户收到一个新消息的时候播放声音:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages(msgs => [...msgs, receivedMessage]);
if (!isMuted) {
playSound();
}
});
// ...
由于 Effect 现在在代码里使用了 isMuted
,所以必须把它加到依赖项中:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages(msgs => [...msgs, receivedMessage]);
if (!isMuted) {
playSound();
}
});
return () => connection.disconnect();
}, [roomId, isMuted]); // ✅ 声明的所有依赖
// ...
问题是每次 isMuted
变化时(例如用户按下“静音”按钮),Effect 会重新同步,并且聊天会重新连接。这不是预期的用户体验!(在这个示例中,即使禁用了代码检查也不会生效—如果你这么做,isMuted
会卡在旧值)。
为了解决这个问题,你需要从 Effect 中提取出不应该是响应式的逻辑。你不希望这个 Effect 对 isMuted
的变化“做出响应”。将这段非响应式代码移入一个Effect Event 中:
import { useState, useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [isMuted, setIsMuted] = useState(false);
const onMessage = useEffectEvent(receivedMessage => {
setMessages(msgs => [...msgs, receivedMessage]);
if (!isMuted) {
playSound();
}
});
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
onMessage(receivedMessage);
});
return () => connection.disconnect();
}, [roomId]); // ✅ 声明的所有依赖
// ...
Effect Event 让你将一个 Effect 拆分成响应式部分(这部分应对像 roomId
这样的响应式值值以及他们的变化“做出响应”)和非响应式部分(这部分只读取它们的最新值,比如 onMessage
读取 isMuted
)。既然你在 Effect Event 内部读取了 isMuted
,就不需要将它作为 Effect 的依赖项之一了。最终结果是当你切换“静音”状态的开关时,聊天不会重新连接,解决了初始的问题!
封装一个来自 props 的事件处理函数
当组件收到一个作为 prop 的事件处理函数时,你可能会遇到一个类似的问题:
function ChatRoom({ roomId, onReceiveMessage }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
onReceiveMessage(receivedMessage);
});
return () => connection.disconnect();
}, [roomId, onReceiveMessage]); // ✅ 声明的所有依赖
// ...
假设父组件在每次渲染时都传递了一个 不同的 onReceiveMessage
函数:
<ChatRoom
roomId={roomId}
onReceiveMessage={receivedMessage => {
// ...
}}
/>
由于 onReceiveMessage
是一个依赖项,所以它会在每次父组件重新渲染后引发 Effect 重新同步。这会让聊天要重新连接。为了解决这个问题,需要将其调用封装在一个 Effect Event 中:
function ChatRoom({ roomId, onReceiveMessage }) {
const [messages, setMessages] = useState([]);
const onMessage = useEffectEvent(receivedMessage => {
onReceiveMessage(receivedMessage);
});
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
onMessage(receivedMessage);
});
return () => connection.disconnect();
}, [roomId]); // ✅ 声明的所有依赖
// ...
拆分响应式代码和非响应式代码
在这个示例中,你需要在每次 roomId
变化时记录一次访问。且需要在每个记录中包含当前的 notificationCount
,但是你 不 希望 notificationCount
的变化触发 log 事件。
解决方案就是再将非响应式代码分割到一个 Effect Event 中:
function Chat({ roomId, notificationCount }) {
const onVisit = useEffectEvent(visitedRoomId => {
logVisit(visitedRoomId, notificationCount);
});
useEffect(() => {
onVisit(roomId);
}, [roomId]); // ✅ 声明的所有依赖
// ...
}
你希望关于 roomId
的逻辑是响应式的,所以你在 Effect 内部读取 roomId
值。但是你不想因为 notificationCount
的变化而记录一次额外的访问,所以你在 Effect Event 内部读取 notificationCount
。了解更多如何通过 Effect Event 从 Effect 中读取最新的 props 和 state 值。
会有一些响应式值无意中变化吗?
有时候你 确实 希望 Effect 对某个值“做出响应”,但是那个值比预期的变化频率要高—并且从用户角度来说并没有实际变化。举个例子,假设你在组件内创建一个 options
对象,然后从 Effect 内部读取这个对象:
function ChatRoom({ roomId }) {
// ...
const options = {
serverUrl: serverUrl,
roomId: roomId
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
// ...
这个对象是在组件内部声明的,所以它是一个响应式值。当你在 Effect 内部读取像这样的响应式值时,需要将它声明为依赖项之一。这保证了 Effect 一定会对它的变化“做出响应”:
// ...
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // ✅ 声明的所有依赖
// ...
将它声明为依赖非常重要!例如,这保证了如果 roomId
变化,Effect 会使用新的 options
重新连接聊天。但是上面的代码也存在一个问题。为了找到它,尝试在下面的输入框输入并且查看 console 处发生了什么:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); // 临时禁用代码检查演示问题 // eslint-disable-next-line react-hooks/exhaustive-deps const options = { serverUrl: serverUrl, roomId: roomId }; useEffect(() => { const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [options]); return ( <> <h1>Welcome to the {roomId} room!</h1> <input value={message} onChange={e => setMessage(e.target.value)} /> </> ); } export default function App() { const [roomId, setRoomId] = useState('general'); 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> <hr /> <ChatRoom roomId={roomId} /> </> ); }
上面的沙盒输入框只更新了 state 变量 message
。从用户角度来看,这不应该影响到聊天连接。但是每当你更新 message
,组件就会重新渲染。而当组件重新渲染时,内部的代码又会重新开始。
每次重新渲染 ChatRoom
组件都会创建一个新的 options
对象。React 认为本次渲染期间创建的 options
和上一次渲染期间创建的 options
是 不一样的。这就是为什么你的 Effect(依赖于 options
)重新渲染,并且当你输入的时候聊天会重新连接。
这个问题只影响对象和函数。在 JavaScript 中,每一个新创建的对象和函数都被认为是和其他的对象和函数不一样。内部的内容是否相同并不会影响这一结果!
// 第一次渲染期间
const options1 = { serverUrl: 'https://localhost:1234', roomId: 'music' };
// 第二次渲染期间
const options2 = { serverUrl: 'https://localhost:1234', roomId: 'music' };
// 这是两个不同的对象!
console.log(Object.is(options1, options2)); // false
对象和函数依赖让 Effect 的重新同步频率高于你的需求。
这就是为什么你应该尽可能避免将对象和函数作为 Effect 的依赖项。而是应该尝试将它们移动到组件外部,移入 Effect 内部或者从中提取初始值。
从组件中移出静态对象和函数
如果这个对象不依赖于任何 props 和 state,你就可以将它们从组件中移出去:
const options = {
serverUrl: 'https://localhost:1234',
roomId: 'music'
};
function ChatRoom() {
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ 声明的所有依赖
// ...
这样,你就可以向代码检查工具“证明”它不是响应式的。它不会因为重新渲染而变化,所以它不需要成为依赖项之一。现在重新渲染 ChatRoom
组件将不会让 Effect 重新同步。
这对函数也有用:
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId: 'music'
};
}
function ChatRoom() {
const [message, setMessage] = useState('');
useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, []); // ✅ 声明的所有依赖
// ...
因为 createOptions
是在组件外部声明的,所以它不是响应式值。这就是为什么它不需要被指定为 Effect 的依赖项,并且不会引起 Effect 重新同步。
将动态对象和函数移入 Effect
如果你的对象依赖于某些像 roomId
prop 这样会因为重新渲染而变化的响应式值,你就不能将它移动到组件 外部。但是你可以将它的创建移动到 Effect 代码的内部:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 声明的所有依赖
// ...
既然 options
是在 Effect 内部声明的,它就不再是依赖项了。而 Effect 唯一使用的响应式值是 roomId
。因为 roomId
不是对象或者函数,你可以确保它不会有 不符合预期的 不同。在 JavaScript 中,number 和 string 是通过内容进行比较的:
// 第一次渲染期间
const roomId1 = 'music';
// 第二次渲染期间
const roomId2 = 'music';
// 这两个字符串是一样的!
console.log(Object.is(roomId1, roomId2)); // true
由于这个修复,如果你再修改输入值,聊天不会再重新连接了:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); useEffect(() => { const options = { serverUrl: serverUrl, roomId: roomId }; const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [roomId]); return ( <> <h1>Welcome to the {roomId} room!</h1> <input value={message} onChange={e => setMessage(e.target.value)} /> </> ); } export default function App() { const [roomId, setRoomId] = useState('general'); 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> <hr /> <ChatRoom roomId={roomId} /> </> ); }
但是当你修改 roomId
时,还是 会和预期的一样重新连接。
这对函数也有效:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
function createOptions() {
return {
serverUrl: serverUrl,
roomId: roomId
};
}
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 声明的所有依赖
// ...
你可以写自己的函数来对 Effect 内部的逻辑进行分组。只要你还在 Effect 内部 声明了它们,就不是响应式值,所以也不需要成为 Effect 的依赖项。
从对象中读取基本值
有时候你可能会从 props 中接收到一个对象:
function ChatRoom({ options }) {
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // ✅ 声明的所有依赖
// ...
这里的风险在于父组件会在渲染期间创建这个对象:
<ChatRoom
roomId={roomId}
options={{
serverUrl: serverUrl,
roomId: roomId
}}
/>
这会导致 Effect 会在每次父组件重新渲染时重新连接。为了修复这个问题,从 Effect 外部 的对象读取信息,并且避免拥有对象和函数依赖:
function ChatRoom({ options }) {
const [message, setMessage] = useState('');
const { roomId, serverUrl } = options;
useEffect(() => {
const connection = createConnection({
roomId: roomId,
serverUrl: serverUrl
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ 声明的所有依赖
// ...
这段逻辑有一点重复了(你从 Effect 外部读取了某些值,然后在内部又创建了一个有同样值的对象)。但是它会明确 Effect 实际 依赖的是什么。如果父组件意外地重新创建了一个对象,聊天也不会重新连接。但是如果 options.roomId
或者 options.serverUrl
真的变化了,聊天就会重新连接。
通过函数计算基本值
同样的方法对函数也有效。例如假设父组件传递了一个函数:
<ChatRoom
roomId={roomId}
getOptions={() => {
return {
serverUrl: serverUrl,
roomId: roomId
};
}}
/>
为了避免让它成为依赖项(会导致聊天在重新渲染中重新连接),而是在 Effect 外部调用。这会给你一个非对象的 roomId
和 serverUrl
值,并且你可以从 Effect 内部读取这个值:
function ChatRoom({ getOptions }) {
const [message, setMessage] = useState('');
const { roomId, serverUrl } = getOptions();
useEffect(() => {
const connection = createConnection({
roomId: roomId,
serverUrl: serverUrl
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ 声明的所有依赖
// ...
这只对 纯 函数有效,因为他们在渲染期间调用是安全的。如果你的函数是一个事件处理函数,但是你不想它的变化重新同步 Effect,那就把它封装进一个 Effect Event。
摘要
- 依赖应该永远和代码匹配。
- 当你不满意依赖项时,你需要做的就是修改代码。
- 抑制代码检查工具会引起令人疑惑的 bug,你应该永远避免这种行为。
- 为了移除依赖项,你需要向代码检查工具“证明”它不是必要的。
- 如果一些代码应该只在特定交互的时候运行,那就将这段代码移动到事件处理函数。
- 如果你的 Effect 中部分代码需要因为不同的原因重新运行,那你需要将它分割成若干个 Effect。
- 如果你想要更新一些基于之前 state 值的state,那就传递一个更新函数。
- 如果你想要读取最新的值而不用对它“做出响应”,那就从你的 Effect 中提取出一个 Effect Event 。
- 在 JavaScript 中,对象和函数如果是在不同时间创建的就会被认为是不一样的。
- 尝试避免对象和函数依赖。把它们移动到组件外部或者 Effect 内部。
第 1 个挑战 共 4 个挑战: 修复重置时间间隔
这个 Effect 设置了一个每秒 tick 一次的时间间隔。你已经注意到发生了一些奇怪的现象:每次 tick 的时候看上去像 interval 被销毁又被重新创建。修复这段代码,这样不会一直重新创建 interval。
import { useState, useEffect } from 'react'; export default function Timer() { const [count, setCount] = useState(0); useEffect(() => { console.log('✅ Creating an interval'); const id = setInterval(() => { console.log('⏰ Interval tick'); setCount(count + 1); }, 1000); return () => { console.log('❌ Clearing an interval'); clearInterval(id); }; }, [count]); return <h1>Counter: {count}</h1> }