Effect:渲染之外的副作用

2024-07-29
305911 分钟
...

React 借鉴了许多函数式编程的理念,使得它在构建复杂用户界面时能够保持代码的简洁、可维护和可测试性。函数式编程在很大程度上依赖于纯函数

纯函数(Pure Function)是指在计算机科学和函数式编程中使用的一类函数。一个函数如果具备以下两个主要特性,我们就可以称之为纯函数:

  1. 引用透明性:纯函数在相同的输入下总是产生相同的输出。也就是说,函数的输出仅依赖于其输入参数,不依赖于任何外部状态或变量。
  2. 无副作用:纯函数不会改变函数外部的任何状态或变量,即在函数执行过程中,不会产生任何影响外部环境的副作用,如更改全局变量、输出日志、修改输入参数等。

纯函数的这两个特性为代码的测试、调试、并行化和重构提供了很大的方便。由于纯函数的输出完全由输入决定,预测函数的行为变得更加简单。另外,由于没有副作用,纯函数之间不会互相影响,这有助于提高代码的可维护性和可读性。

在理想模式下 React 组件应该是纯函数,有些组件需要与外部系统同步。例如根据 React state 控制非 React 组件、设置服务器连接或在组件出现在屏幕上时发送分析日志,在 React 中被称为副作用,它们是“额外”发生的事情,与渲染过程无关

编写 Effect

编写 Effect 需要遵循以下三个步骤:

  1. 声明 Effect,默认情况下 Effect 会在每次提交后都会执行
  2. 指定 Effect 依赖,大多数 Effect 应该按需执行,而不是在每次渲染后都执行
  3. 必要时添加清理函数,组件卸载或者依赖项改变时候,在执行新的 Effect 之前调用
import React, { useEffect } from 'react';

const MyComponent = () => {
  useEffect(() => {
    
    console.log('Component mounted or updated');

    
    return () => {
      console.log('Component will unmount or cleanup before next effect');
    };
  }, []); 
  
  return <div>Hello, World!</div>;
};

合理的使用依赖

减少或者合理使用 useEffect 的依赖项可以减少组件重新渲染次数,显著优化性能

使用 useMemo 或 useCallback

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

const MyComponent = ({ items }) => {
  const [filteredItems, setFilteredItems] = useState([]);

  
  const filteredItemsMemo = useMemo(() => {
    return items.filter(item => item.value > 10);
  }, [items]);

  useEffect(() => {
    
    setFilteredItems(filteredItemsMemo);
  }, [filteredItemsMemo]);

  return (
    <div>
      {filteredItems.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
};

export default MyComponent;

使用函数更新状态

const [count, setCount] = useState(0);

useEffect(() => {
 
 const id = setInterval(() => {
   setCount(prevCount => prevCount + 1);  
 }, 1000);

 return () => clearInterval(id);
}, []);

使用稳定的 dispatch 函数

useReducer 创建的 dispatch 函数是一个闭包,它持有对内部状态和 reducer 函数的引用,这些引用在创建 dispatch 时就固定了。因为这些引用不会变,所以 dispatch 函数也不会变

也就是说 dispatch 函数在组件的整个生命周期内是同一个引用,即使组件重新渲染 dispatch 引用也保持不变,利用这一点可以把原本依赖 state 的 useEffect 改成依赖稳定的 dispatch 函数

const MyComponent = () => {
  const [state, dispatch] = useReducer(...);

  useEffect(() => {
    const fetchData = async () => {
      dispatch({ type: 'FETCH_INIT' });

      try {
        const response = await fetch('https://api.example.com/data');
        const result = await response.json();
        dispatch({ type: 'FETCH_SUCCESS', payload: result });
      } catch (error) {
        dispatch({ type: 'FETCH_FAILURE', payload: error });
      }
    };

    fetchData();
  }, [dispatch]);

  const { data, loading, error } = state;

  if (loading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      {data && data.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
};

清理函数的作用

useEffect 的清理函数有两个主要的执行时机

  1. 组件卸载时:当组件即将从 DOM 中被移除时,清理函数会被调用。这个过程有助于避免内存泄漏和其他资源泄露。例如如果你在 useEffect 中设置了订阅、计时器或者事件监听器,在组件卸载时需要清理这些资源
  2. 依赖项变化时:如果 useEffect 的依赖项数组中有一个或多个依赖项发生变化,旧的副作用会先被清理,然后再运行新的副作用函数

清理函数通过管理资源、避免重复操作、取消异步操作等方式,确保 React 组件在其生命周期内的行为是可控和高效的

防止内存泄漏

当组件卸载时,清理函数会释放任何仍在占用的资源或内存,确保不会发生内存泄漏。例如清除定时器、取消订阅网络请求或 WebSocket 连接等

useEffect(() => {
  const interval = setInterval(() => {
    console.log('Interval running');
  }, 1000);
  
  return () => {
    clearInterval(interval); 
  };
}, []);

避免重复操作

在组件重新渲染时,清理函数可以中断或取消之前的副作用,避免重复执行同样的副作用操作

useEffect(() => {
  const handleResize = () => {
    console.log('Window resized');
  };

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

协调异步操作

清理函数可以在组件卸载时取消未完成的异步操作,比如网络请求,以防止在组件不再需要时更新它的状态

useEffect(() => {
  const fetchData = async () => {
    const controller = new AbortController();
    const signal = controller.signal;
    
    try {
      const response = await fetch('https://api.example.com/data', { signal });
      const data = await response.json();
      console.log(data);
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('Fetch error:', error);
      }
    }
    
    return () => {
      
      controller.abort();
    };
  };
  
  fetchData();
}, []);

useLayoutEffect

React 组件更新到页面有几个过程

  1. React 组件渲染阶段,生成新的虚拟 DOM,计算出浏览器需要做的更新
  2. React 向浏览器提交 DOM 变更阶段
  3. 浏览器 DOM 树构建、更新
  4. 样式计算和布局,浏览器绘制页面

useEffect 在浏览器完成渲染之后异步地调用执行,而很多时候需要对浏览器渲染结果立刻做出改变,这样会造成页面两次渲染。useLayoutEffect 可以保证在所有浏览器 DOM 变更之后、浏览器重新绘制之前执行(也就是上面流程这种 3 和 4 之间)

这意味着 useLayoutEffect 内的代码会在浏览器刷新屏幕之前执行,可以同步读取布局信息,并可通过 DOM API 修改布局来避免用户看到中间状态的闪烁或不一致

import React, { useLayoutEffect, useRef, useState } from 'react';

const Example = () => {
  const [height, setHeight] = useState(0);
  const divRef = useRef(null);

  useLayoutEffect(() => {
    
    if (divRef.current) {
      setHeight(divRef.current.getBoundingClientRect().height);
    }
  });

  return (
    <div>
      <div ref={divRef} style={{ height: '100px' }}>
        This is a div.
      </div>
      <p>Div height: {height}px</p>
    </div>
  );
};

export default Example;

useEvent

有这样的一个 case

function Chat() {
  const [text, setText] = useState('');

  const clickHandler = () => {
    sendMessage(text);
  };

  return <SendButton onClick={clickHandler} />;
}

一个很简单的事件处理程序,点击 button 展示一下当前的 state,但 clickHandler 有一个问题只是透传 state,每次组件渲染都需要创新创建,这不合理,如果使用 useCallback 优化发现其实是个死循环

function Chat() {
  const [text, setText] = useState('');

  const clickHandler = useCallback(() => {
    sendMessage(text);
  }, [text]);

  return <SendButton onClick={clickHandler} />;
}

这时候其实可以使用 useRef 和 useLayoutEffect 解决这个问题

function useEvent(handler) {
  const handlerRef = useRef(null);

  useLayoutEffect(() => {
    handlerRef.current = handler;
  });

  return useCallback((...args) => {
    const fn = handlerRef.current;
    return fn(...args);
  }, []); 
}

无论 state 是否发生变化,返回值总是稳定的,但每次组件重新渲染,会调用最新的 handlerRef.current,而 handlerRef.current 已经在每次渲染时更新为最新的 handler,handler 已经从闭包中获取到了最新的 state

function Chat() {
  const [text, setText] = useState('');

  const clickHandler = useEvent(() => {
    sendMessage(text);
  });

  return <SendButton onClick={clickHandler} />;
}

React 官方已经对 useEvent 有了 RFC github.com/reactjs/rfc…

你也许不需要 Effect

Effect 通常用于暂时跳出 React 代码并与外部系统进行同步,通过 useEffect 可以将包含副作用的逻辑(如数据获取、订阅等)从纯渲染逻辑中分离出来,使组件更加简洁和易维护,但如果组件中没有副作用,大可不必使用 Effect,React 官网教程也总结了无需使用 React 的几种 case,相信读了之后可以更好的使用 useEffect,列举几个印象深刻的

不要把可计算出来的逻辑写到 useEffect

当渲染使用的数据可以通过 props 和 state 计算得出时候,这个计算逻辑可以写在组件外层,不需要通过 useEffect 修改数据实现,典型例子就是 List 筛选

List 组件接收一个 items 列表作为 prop,然后用 state 变量 selection 来保持已选中的项。当 items 接收到一个不同的数组时,需要把 selection 重置为 null

function List({ items }) {
  const [selection, setSelection] = useState(null);

  
  useEffect(() => {
    setSelection(null);
  }, [items]);
  
}

每当 items 变化时,List 及其子组件会先使用旧的 selection 值渲染,然后 React 会更新 DOM 并执行 Effect,最后调用 setSelection(null) 将导致 List 及其子组件重新渲染,重新启动整个流程。这样的写法本质上是把可计算的结果冗余的设置了 state,其实 selection item 完全可以直接依赖计算结果

function List({ items }) {
  
  const [selectedId, setSelectedId] = useState(null);
  
  const selection = items.find(item => item.id === selectedId) ?? null;
  
}

区分事件与 Effect

两者在很多时候会互相配合的对 state 进行修改,但其触发逻辑是不同的

  • 事件只在处理用户交互操作带来的变化时候运行
  • 每当依赖数据发生变化,useEffect 就会运行

当不确定某些代码是应该在 Effect 中还是在事件处理程序中时,可以反问自己为什么这段代码需要运行,Effect 只用来执行显示给用户时组件有必要执行的代码

比如有个 Form 组件需要发送两个请求:

  1. 在页面加载之际会发送一个分析请求
  2. 当用户填写表格并点击提交按钮时,它会向 /api/register 接口发送一个请求
function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  
  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  
}

第一个 useEffect 是合理的,因为表单显示给用户的时候就需要发型分析请求,但第二个明显不合理,这个请求不是渲染表单引起的,而是用户交互操作引起的,应该使用事件处理程序

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    post('/api/register', { firstName, lastName });
  }
  
}

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
react2025-08-03
在 React 中实现倒计时功能会有什么坑
倒计时 倒计时是一个非常常见的业务场景,但是在 React 中实现起来,却不算简单。 首先我们来看这段倒计时代码,它能否正常执行? 来看看实际表现效果 可以看到计时器在不断执行,但是 的值却没有变。 这是一个很经典的问题:React 闭包陷阱 。 我们来详细分析下: 的依赖数组 是空的,这意味着 effect 只会在组件挂载时执行一次,而不会在 状态更新时重新执行。所以 回调函数中捕获的 值始终是初始值 。 这个问题解决起来也很简单,有...
学习面试
react2025-07-30
React性能优化三剑客:memo、useMemo和useCallback详解
前言 在React开发中,性能优化是一个永恒的话题。今天我们就来深入探讨React提供的三个重要性能优化工具: 、 和 ,它们如何帮助我们构建更高效的React应用。 1\. 为什么需要性能优化? 🤔 React的核心机制是当组件的state或props发生变化时,组件会重新渲染。但有时这种重新渲染是不必要的: 父组件更新导致所有子组件重新渲染 ,即使子组件的props没有变化 复杂计算在每次渲染时重复执行 ,消耗大量资源 函数引用在...
学习面试
react2024-12-06
React 19 终于发布,一大波新功能正式升级
这篇文章主要介绍了 React 19 的新功能,包括 Actions 自动处理数据突变相关状态,新增的 useActionState、useFormStatus、useOptimistic 等 hook,新的 use API 读取渲染资源,新的 React DOM 静态 API 生成静态站点,以及 React 服务器组件中的 RSC 和 RSA 等内容。同时提到后续会分享针对旧版功能的优化和改良。 关联问题: React 19 性能如何...
学习面试
react2024-09-18
React | 浏览器刷新,会执行 useEffect 的 卸载函数吗?
React | 浏览器刷新,会执行 useEffect 的 卸载函数吗? 需求描述 后端在跑一个长期的异步上传任务,在任务执行过程中,用户刷新页面(f5),此时产品希望能够发送一个 kill 任务,杀掉这个任务。 拆解 需求合理性 其他情况是否存在类似:比如关闭 tab,关闭浏览器,突然断网等操作,等浏览器层级或者异常的用户行为 前端是否应该响应这种行为:对于更深层次的需求处理 根本问题点,或者说为什么会存在这个需求:上一个任务会加锁,...
学习面试
nuxt2025-10-22
Nuxt SSR 与 Next.js SSR
🧭 引言 在现代前端生态中, Nuxt(Vue) 与 Next.js(React) 是最成熟的两大全栈框架。 它们都具备 SSR(Server Side Rendering) 能力,但实现原理与运行机制差异巨大。 如果你在使用 Nuxt 构建 SSR 项目,却发现“路由跳转后不再 SSR”, 而在 Next.js 中同样操作却会重新发起服务端渲染——这不是 bug,而是 理念差异 。 本文将详细对比这两种框架的 SSR 行为,帮助你彻...
学习面试
vue2025-10-22
shallowRef 与 ref 的区别、场景与坑位
结论先行: 会 深度 地把你放进去的对象转成响应式(递归代理),因此"对象内部的改动"也会触发视图更新。 只在 顶层 做依赖追踪: 更换 才触发更新 ;若你只是"在原对象上改属性",不会触发,除非手动 。 1\. 快速对比 维度 追踪粒度 深度(递归) 仅顶层 对象内部属性变更 会触发更新 不会 触发(除非 或重新赋值) 性能 有递归 & 依赖跟踪开销 更轻量,适合大对象/第三方实例 适用对象 普通标量、普通对象、数组 第三方实例(图表...
学习面试

评论

请登录后发表评论

去登录
加载评论中...

目录