React 组件中的两种逻辑类型:
- 渲染逻辑代码 位于组件的顶层,接收 props 和 state,进行转换,返回屏幕上看到的 JSX,只计算不做其他任何事情;
- 事件处理程序 嵌套在组件内部的函数,由特定的用户操作(如按钮点击)引起的”副作用“(改变了程序的状态)。
实际开发过程中,还会遇到当进入页面时触发一些动作(如播放视频、日志发送、连接到聊天服务器等)。其①不能在渲染过程中发生,②也没有一个特定的事件(比如点击)触发。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // 渲染期间不能调用 `play()`。
} else {
ref.current.pause(); // 同样,调用 `pause()` 也不行。
}
return <video ref={ref} src={src} loop playsInline />;
}
当第一次调用 VideoPlayer
时,对应的 DOM 节点甚至还不存在!
⭐Effect 允许指定由渲染本身,而不是特定事件引起的副作用。
把调用 DOM 方法的操作封装在 Effect 中,可以让 React 先更新屏幕,确定相关 DOM 创建好了以后然后再运行 Effect。
export default () => {
useEffect(() => {
console.log(Date.now()) // DOM渲染后执行,1710483434421
}, []);
const now = () => Date.now(); // 先执行,1710483434420
return (<p>Now: {now()}</p>)
}
所以,上述 VideoPlayer
可以将对应的判断包裹到 useEffect
中。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
// useEffect 会把这段代码放到屏幕更新渲染之后执行
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
Effect 依赖项
作用 | 示例(依赖项) |
---|---|
每次 渲染后执行 | useEffect(() => {}); |
组件挂载后执行 | useEffect(() => {}, []); |
每次 渲染后,且 a 或 b 的值与上次渲染不一致时执行 | useEffect(() => {}, [a, b]); |
⭐ 响应式值必须包含在依赖项中,在组件内部声明的 props、state 和其他值都是 响应式 的,因为它们是在渲染过程中计算的,并参与了 React 的数据流。
React 会验证是否将每个响应式值都指定为了依赖项 1
当指定的所有依赖项在上一次渲染期间的值与当前值完全相同时,React 会跳过重新运行该 Effect。React 使用 Object.is
比较依赖项的值。
Object.is()
不等价于===
运算符-0 === +0; // true Object.is(-0, +0); // false NaN === NaN; // false Object.is(NaN, NaN); // true
⚠️ 注意:Effect 会在 每次 渲染后执行,而以下代码会陷入死循环中:
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
每次渲染结束都会执行 Effect;而更新 state 会触发重新渲染。但是新一轮渲染时又会再次执行 Effect,然后 Effect 再次更新 state……如此周而复始,从而陷入死循环。
Effect 的生命周期
✅ 每个 React 组件都经历相同的生命周期:
- 当组件被添加到屏幕上时,它会进行组件的 挂载。
- 当组件接收到新的 props 或 state 时,通常是作为对交互的响应,它会进行组件的 更新。
- 当组件从屏幕上移除时,它会进行组件的 卸载。
但并不适用于 Effect,➡️ Effect 只能做两件事:开始同步某些东西,然后停止同步它。
useEffect(() => {
// 每次渲染后都会执行此处的代码
return () => {
// 清理函数,销毁时执行此处的代码
}
});
代码中的每个 Effect 应该代表一个独立的同步过程。
🐾 好思路:使用清理函数,防止数据异常:
当 userId 发生改变时,会触发异步请求,可能会出现后一个请求比前一个请求返回更快的情况(导致渲染结果有误)
useEffect(() => {
let ignore = false;
// 异步请求
async function startFetching() {
const json = await fetchTodos(userId);
// 忽略结果
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
无法撤消已经发生的网络请求,但是清理函数应当确保获取数据的过程以及获取到的结果不会继续影响程序运行。
不滥用Effect ⛔
1️⃣ 根据 props 或 state 来更新 state => 使用字面量
如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 避免:多余的 state 和不必要的 Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ✅ 如果 getFilteredTodos() 的耗时不长,这样写就可以了。(渲染时就会重新计算)
const visibleTodos = getFilteredTodos(todos, filter);
// ✅ 除非 todos 或 filter 发生变化,否则不会重新执行 getFilteredTodos()
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
}
当 newTodo
这样不相关的 state 变量变化时,你并不想重新执行 getFilteredTodos()
。你可以使用 useMemo
Hook 缓存(或者说 记忆(memoize))一个昂贵的计算。
2️⃣ 当 props 变化时重置所有 state => 使用key
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 避免:当 prop 变化时,在 Effect 中重置 state
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
但这是低效的,因为 ProfilePage
和它的子组件首先会用旧值渲染,然后再用新值重新渲染。
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ 当 key 变化时,该组件内的 comment 或其他 state 会自动被重置
const [comment, setComment] = useState('');
// ...
}
总是检查是否可以通过添加 key 来重置所有 state,或者 在渲染期间计算所需内容。
☀️ 总结
- 如果可以在渲染期间计算某些内容,则不需要使用 Effect;
- 想要重置整个组件树的 state,请传入不同的
key
; - 组件 显示 时就需要执行的代码应该放在 Effect 中,否则应该放在事件处理函数中;
- 你可以使用 Effect 获取数据,但你需要实现清除逻辑以避免竞态条件。
延伸
多数组件不需要使用下述两个 hooks,组件返回 JSX,然后浏览器计算他们的 布局(位置和大小)& 样式 并重新绘制屏幕。
useLayoutEffect
2
在浏览器重新绘制屏幕之前触发。
👀 典型的案例:Tooltip。如果有足够的空间,tooltip 应该出现在元素的上方,但是如果不合适,它应该出现在下面。为了让 tooltip 渲染在最终正确的位置,需要知道它的高度(即它是否适合放在顶部)。
- 将 tooltip 渲染到任何地方(即使位置不对)。
- 测量它的高度并决定放置 tooltip 的位置。
- 把 tooltip 渲染放在正确的位置。
所有这些都需要在浏览器重新绘制屏幕之前完成。
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0); // 还不知道真正的高度
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height); // 现在重新渲染,你知道了真实的高度
}, []);
// ... 在下方的渲染逻辑中使用 tooltipHeight ...
}
即使 Tooltip
组件需要两次渲染(首先,使用初始值为 0 的 tooltipHeight
渲染,然后使用实际测量的高度渲染),你也只能看到最终结果。如果使用 useEffect
tooltip 会“闪烁”(更正位置之前短暂地看到初始位置)。
useInsertionEffect
3
在布局副作用触发之前将元素插入到 DOM 中。
useInsertionEffect
是为 CSS-in-JS 库的作者特意打造的。除非你正在使用 CSS-in-JS 库并且需要注入样式,否则你应该使用useEffect
或者useLayoutEffect
。
https://react.docschina.org/learn/lifecycle-of-reactive-effects#react-verifies-that-you-specified-every-reactive-value-as-a-dependency React 会验证是否将每个响应式值都指定为了依赖项 ↩︎
https://react.docschina.org/reference/react/useLayoutEffect useLayoutEffect ↩︎
https://react.docschina.org/reference/react/useInsertionEffect useInsertionEffect ↩︎