当你编写一个 Effect 时,linter 会验证你是否已经把这个 Effect 读取的每个响应式值(比如 props 和 state)都包含在 Effect 的依赖列表中。这可以确保你的 Effect 始终与组件的最新 props 和 state 保持同步。不必要的依赖可能会导致你的 Effect 运行过于频繁,甚至创建无限循环。请按照本指南来检查并移除 Effect 中不必要的依赖。
You will learn
- 如何修复无限的 Effect 依赖循环
- 当你想移除一个依赖时该怎么做
- 如何在不“响应”某个值的情况下从 Effect 中读取它
- 如何以及为什么要避免对象和函数依赖
- 为什么屏蔽 dependency linter 很危险,以及应该如何替代
依赖应该与代码匹配
当你编写一个 Effect 时,你首先要指定如何 开始和停止 你希望 Effect 执行的内容:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
// ...
}然后,如果你把 Effect 的依赖留空([]),linter 会建议正确的依赖:
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> 选择聊天房间:{' '} <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} /> </> ); }
按照 linter 的提示把它们补全:
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 已声明所有依赖
// ...
}Effects 会对响应式值“做出反应”。 由于 roomId 是一个响应式值(它可能因为重新渲染而改变),linter 会验证你是否将它指定为依赖项。如果 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> 选择聊天房间:{' '} <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 是一个响应式值,你不能把它从依赖列表中移除。linter 不会允许这样做:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // 🔴 React Hook useEffect 缺少依赖项:'roomId'
// ...
}而且 linter 是对的!由于 roomId 可能会随着时间变化,这会在你的代码中引入 bug。
要移除一个依赖,就要向 linter “证明”它不需要成为依赖。 例如,你可以把 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 的代码或响应式值的声明方式。
- 然后,你遵循 linter 并调整依赖,使其与已修改的代码匹配。
- 如果你对依赖列表不满意,你就回到第一步(再次修改代码)。
最后这一点很重要。如果你想改变依赖,先改变周围的代码。 你可以把依赖列表看作是 Effect 代码使用的所有响应式值的列表。 你并不是在选择要把什么放进这个列表里。这个列表是在描述你的代码。要改变依赖列表,就改变代码。
这可能会让人觉得像在解方程。你可能会先有一个目标(例如移除一个依赖),然后你需要“找到”符合这个目标的代码。并不是每个人都觉得解方程有趣,而写 Effects 也可能如此!幸运的是,下面有一系列常见的处理方法供你尝试。
Deep Dive
屏蔽 linter 会导致非常不直观、难以发现和修复的 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“只在挂载时运行”。你读到过 空的([])依赖 可以做到这一点,于是你决定忽略 linter,并强行将 [] 指定为依赖。
这个计数器本应每秒按两个按钮可配置的数值递增。然而,由于你对 React “撒谎”说这个 Effect 不依赖任何东西,React 会永远继续使用初始渲染时的 onTick 函数。在那次渲染期间, count 是 0,increment 是 1。这就是为什么那次渲染中的 onTick 每秒都会一直调用 setCount(0 + 1),而你总是只看到 1。这类 bug 如果分散在多个组件中,会更难修复。
永远都存在比忽略 linter 更好的解决方案!要修复这段代码,你需要把 onTick 加入依赖列表。(为了确保 interval 只设置一次,把 onTick 变成一个 Effect Event。)
我们建议把 dependency lint 错误视为编译错误。如果你不屏蔽它,你就永远不会看到这类 bug。 本页其余部分会记录针对这一情况以及其他情况的替代方案。
移除不必要的依赖
每次你调整 Effect 的依赖项以反映代码时,都要看看依赖列表。Effect 在这些依赖项中的任何一个变化时重新运行,这样做合理吗?有时,答案是“不”:
- 你可能希望在不同条件下重新执行 Effect 的不同部分。
- 你可能只想读取某个依赖的最新值,而不是“响应”它的变化。
- 某个依赖可能由于它是对象或函数而无意中变化得太频繁。
要找到正确的解决方案,你需要先回答一些关于 Effect 的问题。我们来一起看看。
这段代码应该移到事件处理函数中吗?
你首先应该思考的是,这段代码是否应该是一个 Effect。
想象一个表单。在提交时,你把 submitted 状态变量设为 true。你需要发送一个 POST 请求并显示一条通知。你把这段逻辑放进了一个会在 submitted 为 true 时“响应”的 Effect 中:
function Form() {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
// 🔴 避免:Effect 中包含特定于事件的逻辑
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) {
// 🔴 避免:Effect 中包含特定于事件的逻辑
post('/api/register');
showNotification('Successfully registered!', theme);
}
}, [submitted, theme]); // ✅ 已声明所有依赖项
function handleSubmit() {
setSubmitted(true);
}
// ...
}这样做之后,你引入了一个 bug。假设你先提交表单,然后在深色和浅色主题之间切换。theme 会改变,Effect 会重新运行,于是同样的通知又显示了一次!
这里的问题在于,这段代码一开始就不应该是一个 Effect。 你想要在提交表单这个特定交互发生时发送这个 POST 请求并显示通知。要响应某个特定交互执行一些代码,就把这段逻辑直接放到对应的事件处理函数里:
function Form() {
const theme = useContext(ThemeContext);
function handleSubmit() {
// ✅ 好:特定于事件的逻辑由事件处理函数调用
post('/api/register');
showNotification('Successfully registered!', theme);
}
// ...
}现在代码已经在事件处理函数中了,它不再具有响应性——因此它只会在用户提交表单时运行。你可以阅读更多关于在事件处理函数和 Effect 之间做选择以及如何删除不必要的 Effect的内容。
你的 Effect 是否在做几个互不相关的事情?
你接下来应该问自己的问题是,Effect 是否在做几个互不相关的事情。
假设你正在创建一个配送表单,用户需要选择所在城市和区域。你根据所选的 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 属性将 cities 状态与网络同步。你不能把它放到事件处理函数里,因为你需要在 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);
}
});
// 🔴 避免:单个 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 状态变量,你不得不把 city 加入依赖列表。这样一来,又引入了一个问题:当用户选择不同的城市时,Effect 会重新运行并调用 fetchCities(country)。结果就是,你会不必要地多次重新获取城市列表。
这段代码的问题在于,你在同步两个不同且互不相关的东西:
- 你想根据
country属性把cities状态同步到网络。 - 你想根据
city状态把areas状态同步到网络。
把逻辑拆分成两个 Effect,每个 Effect 只响应它需要同步的那个属性:
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 中来改进。
你是否在读取某些状态来计算下一个状态?
这个 Effect 会在每次收到新消息时,使用新创建的数组更新 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 都会重新同步,并重新连接到聊天。这不是理想的用户体验!(在这个例子中,即使禁用 lint 规则也没用——如果你那样做,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]); // ✅ 已声明所有依赖项
// ...Effect Event 不是响应式的,所以你不需要把它们指定为依赖项。这样,即使父组件每次重新渲染时都传入一个不同的函数,聊天也不会重新连接。
分离响应式和非响应式代码
在这个例子中,你希望每次 roomId 改变时都记录一次访问。你希望每条日志都包含当前的 notificationCount,但你不希望 notificationCount 的变化触发一次日志事件。
解决方案同样是把非响应式代码拆分到一个 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 Events 从 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 重新连接到聊天。然而,上面这段代码也有一个问题。要看到这个问题,试着在下面的沙盒中输入一些内容,观察控制台里发生了什么:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); // 临时禁用 lint 规则以演示问题 // 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} /> </> ); }
在上面的沙盒中,输入框只会更新 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();
}, []); // ✅ 已声明所有依赖项
// ...这样,你就在向 lint 规则证明它不是响应式的。它不会因为重新渲染而改变,因此不需要成为依赖项。现在重新渲染 ChatRoom 不会导致你的 Effect 重新同步。
这对函数同样有效:
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId: 'music'
};
}
function ChatRoom() {
const [message, setMessage] = useState('');
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ 已声明所有依赖项
// ...由于 createOptions 是在组件外部声明的,所以它不是响应式值。这就是为什么它不需要出现在 Effect 的依赖项中,也不会导致 Effect 重新同步。
将动态对象和函数移到 Effect 内部
如果你的对象依赖于某个可能因重新渲染而改变的响应式值,比如 roomId 属性,那么你不能把它移到组件外部。不过,你可以把它的创建过程移到 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 的依赖项了。此时,Effect 使用的唯一响应式值是 roomId。由于 roomId 不是对象或函数,你可以确定它不会无意中变得不同。在 JavaScript 中,数字和字符串是按照内容比较的:
// 在第一次渲染期间
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 内部用相同的值创建一个对象)。但这样能非常明确地表达你的 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。
Recap
- 依赖项应始终与代码保持一致。
- 当你对依赖项不满意时,需要修改的是代码。
- 抑制 lint 规则会导致非常令人困惑的 bug,你应该始终避免这样做。
- 要删除某个依赖项,你需要向 lint 规则“证明”它不是必需的。
- 如果某些代码应该响应特定交互而运行,就把它移到事件处理函数中。
- 如果 Effect 的不同部分应该因为不同原因重新运行,就把它拆成多个 Effect。
- 如果你想基于上一个状态来更新某个状态,请传入一个更新函数。
- 如果你想在不“响应”变化的情况下读取最新值,请从 Effect 中提取一个 Effect Event。
- 在 JavaScript 中,如果对象和函数是在不同时间创建的,它们会被视为不同的值。
- 尽量避免对象和函数作为依赖项。把它们移到组件外部或 Effect 内部。
Challenge 1 of 4: 修复一个会重置的定时器
这个 Effect 设置了一个每秒触发一次的定时器。你注意到有些奇怪的事情:它似乎会在每次触发时被销毁并重新创建。修复代码,使定时器不会被不断重新创建。
import { useState, useEffect } from 'react'; export default function Timer() { const [count, setCount] = useState(0); useEffect(() => { console.log('✅ 正在创建一个定时器'); const id = setInterval(() => { console.log('⏰ 定时器触发'); setCount(count + 1); }, 1000); return () => { console.log('❌ 正在清除一个定时器'); clearInterval(id); }; }, [count]); return <h1>Counter: {count}</h1> }