Next.js 14: `useEffect` 重复触发排查与解决
2024-12-26 17:33:02
Next.js 14 中 useEffect
重复触发的排查与解决
在 Next.js 14 应用开发过程中, useEffect
钩子在页面加载时意外执行两次是一个常见问题。特别是在处理 API 调用、状态更新或者依赖外部副作用时,这种行为可能导致一些意想不到的结果,例如不必要的 API 请求或者数据异常。下面我们将分析造成这个问题的可能原因并提供相应的解决方案。
页面加载时 useEffect
触发两次的原因
主要的原因可以归结为几个方面。首先,Next.js 的开发环境 (dev mode) 中为了提高热模块替换(HMR) 和组件更新效率,会默认启用双重渲染。这意味着组件会被渲染两次,触发相应的 effect 钩子。其次,一些额外的 StrictMode
可能未启用,或其他的库/插件中包含这种行为。
虽然双重渲染机制对开发阶段有好处,能帮助开发者尽早发现一些隐藏的问题,但有时也需要我们避免副作用产生两次。需要注意的是,本文场景下开发者提到并未开启 StrictMode
。 因此,我们着重分析与解决 useEffect 和 IntersectionObserver 可能造成的多次调用问题。
问题复现和案例分析
以下面这段 Next.js 组件的代码为例:
'use client';
import { useState, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
// ...其他代码导入
let offset = 0;
export function ComboboxDemo({ services }: { services: string[] }) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
// ... 省略了部分 UI 和状态逻辑
const { ref, inView } = useInView();
const [data, setData] = useState<string[]>([]);
useEffect(() => {
if (inView) {
const fetchService = async () => {
const response = await fetch(
`/dashboard/inventory/api/services?offset=${offset}`,
);
const result = await response.json();
return result;
};
fetchService().then((res) => {
setData([...data, ...res]);
});
}
console.log('i fire once');
}, [inView, data]);
// ...省略UI代码
}
这段代码的关键部分是使用 useInView
检测组件是否进入视口,并在进入视口后调用 fetchService
API,更新数据。useEffect
的依赖项为 [inView, data]
,这可能会造成意外行为:
- 首次渲染和视口交叉: 初始渲染,
inView
为false,useEffect
不会触发。当组件进入视口后,inView
变为true
,useEffect
会被触发,调用api并更新data
。 - 状态更新导致的重新渲染: 更新
data
状态之后会再次触发组件渲染,由于inView
依赖没变仍然为true
, 这导致useEffect
第二次触发, 并再次调用api.
结合问题可以发现,每次data
更新都会导致 useEffect
重复执行,每次 useEffect
执行都会调用API,这显然并非期望的行为。
解决方案和最佳实践
为避免 API 被多次调用,有多种方法可以调整,关键点是要准确控制 useEffect
的执行时机:
解决方案 1: 使用 inView
状态控制首次触发
可以引入一个额外的 hasFetched
状态, 配合 inView
状态控制,仅在组件首次进入视口时执行 fetchService
:
'use client';
import { useState, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
// ... 其他导入
let offset = 0;
export function ComboboxDemo({ services }: { services: string[] }) {
// ...其他状态
const { ref, inView } = useInView();
const [data, setData] = useState<string[]>([]);
const [hasFetched, setHasFetched] = useState(false);
useEffect(() => {
if (inView && !hasFetched) {
setHasFetched(true)
const fetchService = async () => {
const response = await fetch(
`/dashboard/inventory/api/services?offset=${offset}`,
);
const result = await response.json();
return result;
};
fetchService().then((res) => {
setData([...data, ...res]);
});
}
console.log('i fire once');
}, [inView, hasFetched]);
//...其他代码
}
操作步骤:
- 引入新的
hasFetched
状态, 初始值设为false
. - 在
useEffect
内判断inView
并且hasFetched
为false
时执行API调用,然后立刻把hasFetched
设置为true
. - 调整
useEffect
依赖数组。
这种方案避免了数据更新引发的多次 useEffect
触发, 但依旧会在组件首次渲染后触发。
解决方案 2: 依赖项调整和条件控制
修改 useEffect
依赖和逻辑,只在inView
状态变化时触发。由于只有inView
依赖发生了变化才触发effect。 这能够有效地减少effect重复触发次数。同时我们只需要第一次进入view的时候触发,那么用一个if
判断就能防止后面更新 data
的时候再次触发。
'use client';
import { useState, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
// ... 其他导入
let offset = 0;
export function ComboboxDemo({ services }: { services: string[] }) {
// ...其他状态
const { ref, inView } = useInView();
const [data, setData] = useState<string[]>([]);
useEffect(() => {
if(inView) {
const fetchService = async () => {
const response = await fetch(
`/dashboard/inventory/api/services?offset=${offset}`,
);
const result = await response.json();
return result;
};
fetchService().then((res) => {
setData([...data, ...res]);
});
}
console.log('i fire once');
}, [inView]);
// ...省略
}
操作步骤:
- 移除
data
状态作为useEffect
的依赖。 - 在
useEffect
里增加inView
条件,确保只有inView
是true的时候执行代码。
这个方案更加简洁直接,能精准控制 useEffect
在组件进入视口时触发一次 API 调用。 推荐使用这个方案解决问题。
安全建议
- 在开发过程中可以结合 console.log 信息帮助调试问题. 在必要时还可以加入更多的调试辅助信息。
- 务必认真理解依赖数组的工作方式,根据实际情况设置恰当的依赖。
总结
以上两种解决方案能够有效的避免在页面加载时 useEffect
执行两次的现象。根据项目的需求选择最合适的解决方案可以显著的提升开发体验。正确理解 useEffect
的工作原理,并采用正确的逻辑,能帮助我们编写出更健壮、更易于维护的应用代码。
此外,理解 Next.js 中服务器组件、客户端组件以及数据获取方式有助于解决数据相关的问题。