React useEffect 正确使用姿势

由来

React 问世的前几年,我们这样写 React 组件

tsx 复制代码
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  componentDidMount() {}

  componentWillUnmount() {}

  shouldComponentUpdate(
    nextProps: Readonly<{}>,
    nextState: Readonly<{}>,
    nextContext: any,
  ): boolean {}

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {}

  render() {
    return <div></div>;
  }
}

使用 class 来声明 React 组件,React 提供 componentDidMountcomponentWillUnmountshouldComponentUpdate等生命周期钩子,开发人员可以直接使用

但是 Meta 在 React16.8 中推出函数式组件(React Hooks),React 组件变成下面的形式

tsx 复制代码
const App = () => {
  const [state, setState] = useState(0);

  useMemo(() => {});

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

  return <div></div>;
};

React Hooks 对比 Class 组件,没有了生命周期钩子,声明组件内部状态的用法也发生了变化。在 React Hooks 提出强大的 useEffect 方法

纯函数

在讲述 useEffect 之前,我们先来了解一下纯函数,因为 useEffect 与纯函数有关系

那么什么是纯函数呢?纯函数需要满足下列要求

  • 函数在相同输入值时,需产生相同输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关
  • 函数不能有语义上可观察的函数副作用,诸如 "触发事件",使输出设备输出,或更改输出值以外物件的内容等

纯函数的输出可以不用和所有的输入值有关,甚至可以和所有的输入值都无关。但纯函数的输出不能和输入值以外的任何状态有关。纯函数可以传回多个输出值,但上述的原则需针对所有输出值都要成立。若引数是传引用调用,若有对参数物件的更改,就会影响函数以外物件的内容,因此就不是纯函数

在纯函数的定义中,可以发现提到一个非常重要的概念------函数副作用。那么什么是函数副作用?

副作用是在计算结果的过程中,系统状态的一种变化或者与外部世界进行的可观察的交互。大白话讲,只要涉及系统外状态的改变都是副作用。系统的副作用包括不限于如下:

  • 网络请求
  • 定时器
  • console.log
  • 在 DOM 元素上绑定事件
  • 更改全局变量或应用状态

一个成熟的系统,既要有外部状态的输入,又要有内部状态的输出。纯函数不能胜任所有的业务场景,前端有很多业务场景需要处理副作用。React 推荐使用 useEffect 处理与系统外部状态的副作用

useEffect

jsx 复制代码
useEffect(effect, deps);

useEffect 接收两个参数。第一个参数是一个名为 effect 的方法,第二个参数是 deps 存储依赖关系的数组

上面是 React Hooks 的执行顺序图,当 React Update DOM and refs 后,先执行 useLayoutEffect(下文会讲这个方法),再执行 useEffect

  • scene1 - 如果不传第二个参数,组件每次重渲染时,useEffect 里的 effect 都会执行。因此不推荐不传递第二个参数
  • scene2 - 传递第二个参数,只要第二个参数的引用发生变化,useEffect 里的 effect 才会执行

特别说明 useEffect 的 effect 和 cleanup 执行逻辑,当组件 render 时,先执行 effect,等下一次 render 时,先执行上一次的 cleanup,再执行这一次的 effect

模拟生命周期钩子

在 React Hooks 中,官方没有提供生命周期钩子,但是在实际的业务场景里,需要在对应的生命周期钩子里执行特定的业务逻辑,而 useEffect 可以用来模拟生命周期钩子

  • useEffect 模拟 componentDidMount()

当组件挂载的时候,需要执行一些业务逻辑

jsx 复制代码
useEffect(() => {
  // do something
}, []);

deps 的参数是 [ ] 时,useEffect 里的业务逻辑只在挂载的时候执行。如果 deps 的参数不是 [ ] ,那么该 useEffect 就不是模拟组件挂载的生命周期钩子,这一点非常重要

  • useEffect 模拟 componentDidUnMount()

componentDidUnMount() 是组件卸载的生命周期钩子,在组件卸载的时候执行

jsx 复制代码
useEffect(() => {
  return () => {
    // do something
  };
}, []);

deps 的参数是 [ ] 时,在组件卸载的时候,useEffectcleanup 方法会执行。

我们可以简单的封装组件挂载和卸载生命周期钩子,方便在项目中使用

useUnmount 的实现如下

tsx 复制代码
// useUnMount.ts

import { useEffect, useRef } from 'react'

export default function useUnmount(fn: () => void): void {
  if (!isFunction(fn)) {
   console.error(`useUnmount: parameter \`fn\` expected to be a function, but got "${typeof fn}".`)
  }

  const ref = useRef(fn)

  useEffect(
    (): (() => void) => (): void => {
      ref.current?.()
    },
    []
  )
}

function isFunction(fn: unknown): fn is Function {
  return typeof fn === 'function'
}

useMount 的实现如下

tsx 复制代码
// useMount.ts

import { useEffect } from 'react'

const useMount = (fn: () => void) => {
  if (!isFunction(fn)) {
    console.error(`useMount: parameter \`fn\` expected to be a function, but got "${typeof fn}".`)
  }

  useEffect(() => {
    fn?.()
  }, [])
}

export default useMount

在项目中这样使用 useMountuseUnMount

tsx 复制代码
const App = () => {
  useMount(() => {
    // ...
  })

  useUnMount(() => {
    // ...
  })

  return <></>
}

网络请求

网络请求是非常重要的副作用。假如我们不在项目中使用 MobX / zustand 等第三方状态管理工具,只在组件单独处理网络请求,该怎么做呢?请看如下代码

tsx 复制代码
import { useEffect, useState } from "react";
import type { ReactElement } from "react";
import { createRoot } from "react-dom/client";

const controller = new AbortController();
const signal = controller.signal;

function App(): ReactElement {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    function fetchUsers(): void {
      // 伪代码
      fetch(url, {
        signal: controller.signal,
      })
        .then((res) => res.json())
        .then((users) => {
          setUsers(users);
        })
        .catch((err) => console.error("failed to fetch users", err));
    }

    void fetchUsers();

    return (): void => {
      controller.abort();
    };
  }, []);
  // ...
}

const root = document.getElementById("root");
createRoot(root).render(<App/>);

定时器

当组件里需要使用定时器时,我们需要在 useEffect 里处理定时器相关的业务逻辑,当定时器结束时在 cleanup 中清除定时器,防止内存泄露

tsx 复制代码
import { useEffect, useState } from "react";
import type { ReactElement } from "react";
import { createRoot } from "react-dom/client";

function App(): ReactElement {
  const [count, setCount] = useState<number>(0);

  useEffect(() => {
    const timer = setInterval((): void => {
      setCount((count) => count + 1);
    }, 1000);

    return (): void => {
      clearInterval(timer);
    };
  }, []);
  // ...
}

const root = document.getElementById("root");
createRoot(root).render(<App/>);

ahooks 封装了定时器方法 useInterval,我们来看一下 ahooks 怎么实现的

tsx 复制代码
const useInterval = (fn: () => void, delay?: number, options: { immediate?: boolean } = {}) => {
  const timerCallback = useMemoizedFn(fn);
  const timerRef = useRef<NodeJS.Timer | null>(null);

  const clear = useCallback(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
    }
  }, []);

  useEffect(() => {
    if (!isNumber(delay) || delay < 0) {
      return;
    }
    if (options.immediate) {
      timerCallback();
    }
    timerRef.current = setInterval(timerCallback, delay);
    return clear;
  }, [delay, options.immediate]);

  return clear;
};

查看 useInterval 的源码可以发现,定时器的重要逻辑依旧放在 useEffect

在 DOM 元素上绑定事件

React 利用 Virtual DOM 的优势,开发人员不需要直接操作 DOM。在大部分的场景下,开发人员只需要使用 React 提供的合成事件。但是在某些业务场景下,开发人员依旧需要操作 DOM。在 React Hooks 中,推荐使用 useRef 获取元素的真实 DOM

tsx 复制代码
const App = () => {
  const divRef = useRef(null);

  return <div ref={divRef}></div>;
};

divRef.current 中存着 div 的 DOM 对象。获取 DOM 元素后,可以绑定/取消原生事件,但是这些逻辑都要放在 useEffect 里。请看下面的例子

tsx 复制代码
function App(): ReactElement {
  const btnRef = useRef<HTMLButtonElement | null>(null);

  useEffect(() => {
    function listener() {
      console.log("it clicked!");
    }

    btnRef.current.addEventListener("click", listener);

    return () => {
      btnRef.current.removeEventListener("click", listener);
    };
  }, []);

  return <button ref={btnRef}>click</button>;
}

不使用 React 提供的合成事件 onClick, 而使用 useRef 获取 button 的元素 DOM,在真实的 DOM 上绑定/取消事件,这段逻辑都写在 useEffect 中。

将上面的例子稍微修改一下,这里有一个非常重要的 React 陷阱,大家一定要注意。

tsx 复制代码
function App(): ReactElement {
  const btnRef = useRef<HTMLButtonElement | null>(null);

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

  const listener = useCallback(() => {
    setCount((c) => c + 1);
    console.log(count);
  }, [count]);

  useEffect(() => {
    btnRef.current?.addEventListener("click", listener);

    return () => {
      // App 组件每次重渲染没有取消事件监听
      btnRef.current?.removeEventListener("click", listener);
    };
  }, [listener]);

  return <button ref={btnRef}>click</button>;
}

点击 Button 按钮,App 组件会发生一次重渲染(re-render),先执行 useEffectcleanup 逻辑,再执行本次的 useEffect 逻辑, 由于函数式组件每一次渲染都是一个快照,本该在 cleanup 阶段取消的事件绑定实际上无法取消绑定,多次点击 button 按钮,会导致绑定很多事件都没有取消,都滞留在内存中,最后造成内存泄露。推荐使用 ahooksuseMemoizedFn 解决这个问题

tsx 复制代码
 const listener = useMemoizedFn(() => {
    setCount((c) => c + 1);
    console.log(count);
  });

useEffect(() => {
  btnRef.current?.addEventListener("click", listener);

  return () => {
    btnRef.current?.removeEventListener("click", listener);
  };
}, [listener]);

更改全局变量或应用状态

在前端的业务里,更改全局变量的场景太多了。比如更改页面的标题

tsx 复制代码
function App(): ReactElement {
  useEffect(() => {
    document.title = "title 被改变了";
  }, []);
}

console.log

平常在写代码的过程中,调试代码需要在控制台查看变量,那么推荐在将该逻辑写在 useEffect 里,如下图所示

tsx 复制代码
import { useEffect, useRef } from "react";
import type { ReactElement } from "react";
import { createRoot } from "react-dom/client";

function Example(): ReactElement {
  const [count, setCount] = useState<number>(0);

  useEffect(() => {
    console.log("count =", count);
  }, [count]);
  // ...
}

const root = document.getElementById("root");
createRoot(root).render(<Example />);

与 useLayoutEffect 对比

useEffectuseLayoutEffect 是两个非常相似的 React Hook。它们在功能上非常相似,都可以让你在函数组件中执行副作用操作。但是它们在执行的时机上有所不同

useEffectReact Update DOM and refs 之后执行,适用于数据获取、订阅或者手动修改 DOM 等操作

useLayoutEffectReact Update DOM and refs 之前执行,适用于需要同步读取 DOM 布局或者避免闪烁等场景

总结:如果你的副作用需要同步执行或者涉及到 DOM 布局,那么应该使用 useLayoutEffect。否则使用 useEffect。

总结

在浏览器环境中,使用 React 框架需要注意副作用,如何正确使用 useEffect 方法,尽量避免未知问题。只需要记住一点,当业务逻辑带有副作用时,就需要写在 useEffect

参考链接

相关推荐
凹凸曼打不赢小怪兽1 小时前
react 受控组件和非受控组件
前端·javascript·react.js
鑫宝Code1 小时前
【React】状态管理之Redux
前端·react.js·前端框架
2401_857610036 小时前
深入探索React合成事件(SyntheticEvent):跨浏览器的事件处理利器
前端·javascript·react.js
fighting ~7 小时前
react17安装html-react-parser运行报错记录
javascript·react.js·html
老码沉思录7 小时前
React Native 全栈开发实战班 - 列表与滚动视图
javascript·react native·react.js
老码沉思录8 小时前
React Native 全栈开发实战班 - 状态管理入门(Context API)
javascript·react native·react.js
老码沉思录11 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
老码沉思录11 小时前
React Native 全栈开发实战班 - 第四部分:用户界面进阶之动画效果实现
react native·react.js·ui
奔跑草-17 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
林太白1 天前
❤React-React 组件通讯
前端·javascript·react.js