返回

Next.js 14: `useEffect` 重复触发排查与解决

javascript

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],这可能会造成意外行为:

  1. 首次渲染和视口交叉: 初始渲染,inView为false,useEffect不会触发。当组件进入视口后,inView变为 true, useEffect会被触发,调用api并更新data
  2. 状态更新导致的重新渲染: 更新 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]);
//...其他代码

}

操作步骤:

  1. 引入新的 hasFetched 状态, 初始值设为 false.
  2. useEffect 内判断 inView 并且 hasFetchedfalse时执行API调用,然后立刻把 hasFetched 设置为 true.
  3. 调整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]);

// ...省略
}

操作步骤:

  1. 移除 data 状态作为 useEffect 的依赖。
  2. useEffect 里增加inView条件,确保只有inView 是true的时候执行代码。

这个方案更加简洁直接,能精准控制 useEffect 在组件进入视口时触发一次 API 调用。 推荐使用这个方案解决问题。

安全建议

  • 在开发过程中可以结合 console.log 信息帮助调试问题. 在必要时还可以加入更多的调试辅助信息。
  • 务必认真理解依赖数组的工作方式,根据实际情况设置恰当的依赖。

总结

以上两种解决方案能够有效的避免在页面加载时 useEffect 执行两次的现象。根据项目的需求选择最合适的解决方案可以显著的提升开发体验。正确理解 useEffect 的工作原理,并采用正确的逻辑,能帮助我们编写出更健壮、更易于维护的应用代码。

此外,理解 Next.js 中服务器组件、客户端组件以及数据获取方式有助于解决数据相关的问题。