MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

useEffect Hook的使用场景与最佳实践

2023-03-022.6k 阅读

一、useEffect Hook 基础介绍

在 React 应用开发中,useEffect Hook 是一个极为强大的工具,它为函数式组件引入了副作用操作的能力。在类组件时代,我们通过 componentDidMountcomponentDidUpdatecomponentWillUnmount 这些生命周期方法来处理诸如数据获取、订阅或者手动修改 DOM 等副作用操作。而 useEffect Hook 以一种更简洁且统一的方式,让我们可以在函数式组件中处理这些类似的任务。

useEffect 的基本语法如下:

import React, { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    // 副作用操作代码
    return () => {
      // 清理函数,可选
    };
  }, []); // 第二个参数是依赖数组

  return <div>My Component</div>;
}

这里的 useEffect 接收两个参数,第一个参数是一个回调函数,这个回调函数中编写的就是副作用操作代码。第二个参数是一个可选的依赖数组,它决定了 useEffect 回调函数何时执行。

二、useEffect Hook 的使用场景

  1. 组件挂载时执行副作用操作
    • 数据获取:当组件挂载到 DOM 树上时,我们常常需要从服务器获取数据。例如,我们有一个展示博客文章列表的组件,在组件挂载后需要从后端 API 获取文章数据。
import React, { useState, useEffect } from'react';

function BlogPostList() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    async function fetchPosts() {
      const response = await fetch('/api/posts');
      const data = await response.json();
      setPosts(data);
    }
    fetchPosts();
  }, []);

  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default BlogPostList;

在这个例子中,useEffect 的依赖数组为空 [],这意味着它只会在组件挂载时执行一次。fetchPosts 函数异步获取文章数据,并通过 setPosts 更新组件的状态。

  • 订阅事件:有时候我们需要在组件挂载时订阅某些事件。比如,我们想监听窗口大小的变化,在组件挂载后添加一个 resize 事件监听器。
import React, { useEffect } from'react';

function WindowSizeListener() {
  useEffect(() => {
    const handleResize = () => {
      console.log(`Window size: ${window.innerWidth} x ${window.innerHeight}`);
    };
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return <div>Listening to window resize...</div>;
}

export default WindowSizeListener;

这里 useEffect 同样在组件挂载时执行,添加了 resize 事件监听器。同时返回了一个清理函数,在组件卸载时移除事件监听器,以避免内存泄漏。

  1. 组件更新时执行副作用操作
    • 根据 prop 的变化更新数据:当组件接收到新的 prop 时,我们可能需要根据 prop 的变化重新获取数据。例如,有一个展示特定用户信息的组件,userId 作为 prop 传入,当 userId 变化时,需要重新获取该用户的信息。
import React, { useState, useEffect } from'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
    }
    fetchUser();
  }, [userId]);

  return (
    <div>
      <h1>User Profile</h1>
      {user && (
        <div>
          <p>Name: {user.name}</p>
          <p>Email: {user.email}</p>
        </div>
      )}
    </div>
  );
}

export default UserProfile;

在这个例子中,useEffect 的依赖数组包含 userId。当 userId 发生变化时,useEffect 的回调函数会再次执行,重新获取用户数据。

  • DOM 操作:在某些情况下,我们需要在组件更新时对 DOM 进行操作。比如,我们有一个可折叠的面板组件,当组件状态变化导致面板展开或折叠时,需要更新面板的高度。
import React, { useState, useEffect } from'react';

function CollapsiblePanel({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const panelRef = React.createRef();

  useEffect(() => {
    if (isOpen) {
      panelRef.current.style.height = panelRef.current.scrollHeight + 'px';
    } else {
      panelRef.current.style.height = '0px';
    }
  }, [isOpen]);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen? 'Close' : 'Open'}
      </button>
      <div ref={panelRef} className="panel">
        {isOpen && children}
      </div>
    </div>
  );
}

export default CollapsiblePanel;

这里 useEffect 依赖于 isOpen 状态,当 isOpen 变化时,根据其值设置面板的高度。

  1. 组件卸载时执行副作用操作 正如前面在订阅事件的例子中所展示的,在组件卸载时,我们需要清理一些资源,比如移除事件监听器。另一个常见的场景是取消未完成的异步操作。例如,在数据获取过程中,如果组件在请求还未完成时就卸载了,我们需要取消这个请求以避免潜在的错误。
import React, { useState, useEffect } from'react';

function CancelableFetch() {
  const [data, setData] = useState(null);
  const controller = new AbortController();

  useEffect(() => {
    async function fetchData() {
      try {
        const response = await fetch('/api/data', { signal: controller.signal });
        const result = await response.json();
        setData(result);
      } catch (error) {
        if (error.name!== 'AbortError') {
          console.error('Error fetching data:', error);
        }
      }
    }
    fetchData();
    return () => {
      controller.abort();
    };
  }, []);

  return (
    <div>
      <h1>Cancelable Fetch</h1>
      {data && <p>{JSON.stringify(data)}</p>}
    </div>
  );
}

export default CancelableFetch;

在这个例子中,useEffect 返回的清理函数中调用 controller.abort() 取消未完成的 fetch 请求。

三、useEffect Hook 的最佳实践

  1. 正确设置依赖数组
    • 依赖数组为空的情况:当依赖数组为空 [] 时,useEffect 回调函数只会在组件挂载和卸载时执行。这适用于那些只需要在组件生命周期的开始和结束时执行一次的操作,如前面提到的组件挂载时的数据获取和订阅事件。但是,要注意不要在回调函数中使用任何会在组件更新时变化的变量,否则可能会导致逻辑错误。例如:
import React, { useState, useEffect } from'react';

function IncorrectEmptyDependency() {
  const [count, setCount] = useState(0);
  const message = 'The count is'+ count;

  useEffect(() => {
    console.log(message);
  }, []);

  return (
    <div>
      <p>{message}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default IncorrectEmptyDependency;

在这个例子中,message 依赖于 count,但 count 不在依赖数组中。当 count 变化时,message 也会变化,但 useEffect 不会重新执行,导致 console.log(message) 输出的 message 不是最新的值。

  • 包含所有依赖的情况:如果希望 useEffect 在某些值变化时执行,就需要将这些值都包含在依赖数组中。例如:
import React, { useState, useEffect } from'react';

function CorrectDependency() {
  const [count, setCount] = useState(0);
  const message = 'The count is'+ count;

  useEffect(() => {
    console.log(message);
  }, [message]);

  return (
    <div>
      <p>{message}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default CorrectDependency;

这里将 message 包含在依赖数组中,当 messagecount 的变化而变化时,useEffect 会重新执行。

  • 使用 useCallbackuseMemo 优化依赖:有时候,依赖数组中的值可能是函数或者复杂对象,这些值在每次渲染时都会重新创建,导致 useEffect 不必要的执行。我们可以使用 useCallbackuseMemo 来优化。例如:
import React, { useState, useEffect, useCallback } from'react';

function OptimizedDependency() {
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  useEffect(() => {
    console.log('handleClick has changed');
  }, [handleClick]);

  return (
    <div>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

export default OptimizedDependency;

这里使用 useCallback 包裹 handleClick 函数,只有当 count 变化时,handleClick 才会重新创建,从而避免了 useEffecthandleClick 每次渲染都重新创建而不必要的执行。

  1. 拆分 useEffect 如果一个组件中有多个不同逻辑的副作用操作,最好将它们拆分成多个 useEffect。这样可以使代码更清晰,每个 useEffect 的职责更明确。例如,一个组件既有数据获取,又有事件监听:
import React, { useState, useEffect } from'react';

function MultipleUseEffects() {
  const [posts, setPosts] = useState([]);
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);

  useEffect(() => {
    async function fetchPosts() {
      const response = await fetch('/api/posts');
      const data = await response.json();
      setPosts(data);
    }
    fetchPosts();
  }, []);

  useEffect(() => {
    const handleResize = () => {
      setWindowWidth(window.innerWidth);
    };
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return (
    <div>
      <h1>Multiple Use Effects</h1>
      <p>Window width: {windowWidth}</p>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default MultipleUseEffects;

通过拆分 useEffect,数据获取和窗口大小监听的逻辑各自独立,更易于维护和理解。

  1. 避免在 useEffect 中进行不必要的渲染useEffect 回调函数中,如果调用 setState 等会触发重新渲染的操作,要确保这些操作是必要的。例如,在数据获取的场景中,如果获取的数据与当前状态相同,就不需要更新状态。
import React, { useState, useEffect } from'react';

function AvoidUnnecessaryRender() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    async function fetchPosts() {
      const response = await fetch('/api/posts');
      const data = await response.json();
      if (JSON.stringify(data)!== JSON.stringify(posts)) {
        setPosts(data);
      }
    }
    fetchPosts();
  }, []);

  return (
    <div>
      <h1>Avoid Unnecessary Render</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default AvoidUnnecessaryRender;

这里通过比较新获取的数据和当前状态的数据,只有当数据不同时才更新状态,避免了不必要的重新渲染。

  1. 处理异步操作中的错误useEffect 中进行异步操作时,要妥善处理错误。例如,在数据获取失败时,我们可以设置一个错误状态并在组件中展示错误信息。
import React, { useState, useEffect } from'react';

function ErrorHandlingInEffect() {
  const [posts, setPosts] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchPosts() {
      try {
        const response = await fetch('/api/posts');
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data = await response.json();
        setPosts(data);
      } catch (error) {
        setError(error.message);
      }
    }
    fetchPosts();
  }, []);

  return (
    <div>
      <h1>Error Handling in Effect</h1>
      {error && <p>{error}</p>}
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default ErrorHandlingInEffect;

在这个例子中,fetchPosts 函数中使用 try - catch 块捕获错误,并通过 setError 设置错误状态,在组件中展示错误信息。

  1. 使用自定义 Hook 封装 useEffect 逻辑 如果在多个组件中有相似的 useEffect 逻辑,可以将其封装成自定义 Hook。例如,我们有多个组件需要进行数据获取,可以封装一个 useFetch 自定义 Hook。
import { useState, useEffect } from'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchData() {
      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const result = await response.json();
        setData(result);
      } catch (error) {
        setError(error.message);
      } finally {
        setLoading(false);
      }
    }
    fetchData();
  }, [url]);

  return { data, error, loading };
}

function ComponentUsingUseFetch() {
  const { data, error, loading } = useFetch('/api/posts');

  return (
    <div>
      <h1>Component Using useFetch</h1>
      {loading && <p>Loading...</p>}
      {error && <p>{error}</p>}
      {data && (
        <ul>
          {data.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default ComponentUsingUseFetch;

通过自定义 Hook useFetch,我们将数据获取的逻辑封装起来,其他组件可以方便地复用这个逻辑,使代码更简洁和可维护。

四、useEffect Hook 与类组件生命周期的对比

  1. 挂载阶段 在类组件中,我们使用 componentDidMount 来处理组件挂载后的副作用操作。例如:
import React, { Component } from'react';

class BlogPostListClass extends Component {
  state = {
    posts: []
  };

  componentDidMount() {
    async function fetchPosts() {
      const response = await fetch('/api/posts');
      const data = await response.json();
      this.setState({ posts: data });
    }
    fetchPosts.bind(this)();
  }

  render() {
    return (
      <div>
        <h1>Blog Posts</h1>
        <ul>
          {this.state.posts.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      </div>
    );
  }
}

export default BlogPostListClass;

而在函数式组件中,使用 useEffect 实现相同的功能,如前面所示:

import React, { useState, useEffect } from'react';

function BlogPostList() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    async function fetchPosts() {
      const response = await fetch('/api/posts');
      const data = await response.json();
      setPosts(data);
    }
    fetchPosts();
  }, []);

  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default BlogPostList;

useEffect 通过依赖数组为空 [] 来模拟 componentDidMount 的行为,并且写法上更加简洁,不需要像类组件那样使用 bind 或者箭头函数来确保 this 的正确指向。

  1. 更新阶段 类组件中,componentDidUpdate 用于处理组件更新时的副作用操作。例如:
import React, { Component } from'react';

class UserProfileClass extends Component {
  state = {
    user: null
  };

  componentDidUpdate(prevProps) {
    if (this.props.userId!== prevProps.userId) {
      async function fetchUser() {
        const response = await fetch(`/api/users/${this.props.userId}`);
        const data = await response.json();
        this.setState({ user: data });
      }
      fetchUser.bind(this)();
    }
  }

  render() {
    return (
      <div>
        <h1>User Profile</h1>
        {this.state.user && (
          <div>
            <p>Name: {this.state.user.name}</p>
            <p>Email: {this.state.user.email}</p>
          </div>
        )}
      </div>
    );
  }
}

export default UserProfileClass;

在函数式组件中,通过 useEffect 并在依赖数组中包含需要监听变化的 prop 来实现类似功能:

import React, { useState, useEffect } from'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
    }
    fetchUser();
  }, [userId]);

  return (
    <div>
      <h1>User Profile</h1>
      {user && (
        <div>
          <p>Name: {user.name}</p>
          <p>Email: {user.email}</p>
        </div>
      )}
    </div>
  );
}

export default UserProfile;

useEffect 依赖数组中的 userId 变化时,回调函数会重新执行,相比类组件中 componentDidUpdate 需要手动比较 prevPropsnextPropsuseEffect 这种方式更加直观和简洁。

  1. 卸载阶段 类组件使用 componentWillUnmount 来处理组件卸载时的副作用操作,比如移除事件监听器。例如:
import React, { Component } from'react';

class WindowSizeListenerClass extends Component {
  componentDidMount() {
    window.addEventListener('resize', this.handleResize);
  }

  componentWillUnmount() {
    window.removeEventListener('resize', this.handleResize);
  }

  handleResize = () => {
    console.log(`Window size: ${window.innerWidth} x ${window.innerHeight}`);
  };

  render() {
    return <div>Listening to window resize...</div>;
  }
}

export default WindowSizeListenerClass;

在函数式组件中,useEffect 通过返回一个清理函数来实现相同的功能:

import React, { useEffect } from'react';

function WindowSizeListener() {
  useEffect(() => {
    const handleResize = () => {
      console.log(`Window size: ${window.innerWidth} x ${window.innerHeight}`);
    };
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return <div>Listening to window resize...</div>;
}

export default WindowSizeListener;

useEffect 的清理函数在组件卸载时执行,这种方式将挂载和卸载的逻辑统一在一个 useEffect 中,使代码结构更加清晰。

五、useEffect Hook 的性能优化

  1. 减少不必要的依赖 正如前面提到的,正确设置依赖数组可以避免 useEffect 不必要的执行。如果一个变量在 useEffect 回调函数中未被使用,就不应该将其放入依赖数组。例如:
import React, { useState, useEffect } from'react';

function UnnecessaryDependency() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  useEffect(() => {
    console.log('Count has changed:', count);
  }, [count, name]); // name 是不必要的依赖

  return (
    <div>
      <p>Count: {count}</p>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default UnnecessaryDependency;

在这个例子中,name 未在 useEffect 回调函数中使用,将其放入依赖数组会导致 useEffectname 变化时不必要地执行。正确的做法是只将 count 放入依赖数组。

  1. 使用 shouldComponentUpdate 类似的逻辑 虽然函数式组件没有 shouldComponentUpdate,但我们可以通过一些技巧实现类似的功能。例如,在 useEffect 中进行数据比较,只有当数据真正变化时才执行副作用操作。
import React, { useState, useEffect } from'react';

function ConditionalEffect() {
  const [posts, setPosts] = useState([]);
  const [newPosts, setNewPosts] = useState([]);

  useEffect(() => {
    if (JSON.stringify(newPosts)!== JSON.stringify(posts)) {
      // 只有当 newPosts 与 posts 不同时执行副作用操作
      setPosts(newPosts);
    }
  }, [newPosts]);

  return (
    <div>
      <h1>Conditional Effect</h1>
      <button onClick={() => setNewPosts([...newPosts, { id: newPosts.length + 1, title: 'New Post' }])}>
        Add Post
      </button>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default ConditionalEffect;

这里通过比较 newPostsposts,只有当它们不同时才更新 posts,避免了不必要的副作用执行。

  1. 使用 React.memouseEffect 配合 React.memo 可以用于 memoize 函数式组件,它会浅比较组件的 props,如果 props 没有变化,组件不会重新渲染。当与 useEffect 配合使用时,可以进一步优化性能。例如:
import React, { useState, useEffect } from'react';

const MemoizedChild = React.memo(({ data }) => {
  useEffect(() => {
    console.log('Child component effect');
  }, [data]);

  return <div>{data}</div>;
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <MemoizedChild data={text} />
    </div>
  );
}

export default ParentComponent;

在这个例子中,MemoizedChild 组件使用 React.memo 进行 memoize,只有当 data prop 变化时才会重新渲染并触发 useEffect。如果 count 变化而 text 不变,MemoizedChild 不会重新渲染,useEffect 也不会执行,从而提高了性能。

  1. 节流与防抖 在处理频繁触发的事件时,如 scrollresize,可以使用节流(throttle)和防抖(debounce)技术来优化 useEffect 的性能。例如,使用防抖来处理窗口滚动事件:
import React, { useEffect } from'react';

function debounce(func, delay) {
  let timer;
  return function() {
    const context = this;
    const args = arguments;
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(context, args);
    }, delay);
  };
}

function ThrottleAndDebounce() {
  const handleScroll = debounce(() => {
    console.log('Window scrolled');
  }, 300);

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  return <div>Scroll the window...</div>;
}

export default ThrottleAndDebounce;

这里通过 debounce 函数,使得 handleScroll 函数在窗口滚动停止 300 毫秒后才会执行,避免了频繁触发导致的性能问题。节流则是在一定时间间隔内只执行一次函数,也可以通过类似的方式实现,通过这种方式可以优化 useEffect 在处理频繁事件时的性能。

通过以上对 useEffect Hook 的使用场景和最佳实践的介绍,希望能帮助开发者更好地在 React 项目中利用这个强大的工具,编写出更高效、可维护的前端代码。无论是处理数据获取、事件监听,还是组件的挂载、更新和卸载等逻辑,正确使用 useEffect 都能让我们的开发工作更加顺畅和优雅。同时,结合性能优化技巧,可以进一步提升 React 应用的性能和用户体验。在实际开发中,需要根据具体的业务需求和场景,灵活运用 useEffect 及其相关技巧,以达到最佳的开发效果。