由来
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 提供 componentDidMount
、componentWillUnmount
和 shouldComponentUpdate
等生命周期钩子,开发人员可以直接使用
但是 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
的参数是 [ ]
时,在组件卸载的时候,useEffect
的 cleanup
方法会执行。
我们可以简单的封装组件挂载和卸载生命周期钩子,方便在项目中使用
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
在项目中这样使用 useMount
和 useUnMount
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),先执行 useEffect
的 cleanup
逻辑,再执行本次的 useEffect
逻辑, 由于函数式组件每一次渲染都是一个快照,本该在 cleanup
阶段取消的事件绑定实际上无法取消绑定,多次点击 button
按钮,会导致绑定很多事件都没有取消,都滞留在内存中,最后造成内存泄露。推荐使用 ahooks 的 useMemoizedFn
解决这个问题
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 对比
useEffect
和 useLayoutEffect
是两个非常相似的 React Hook。它们在功能上非常相似,都可以让你在函数组件中执行副作用操作。但是它们在执行的时机上有所不同
useEffect
在 React Update DOM and refs 之后执行,适用于数据获取、订阅或者手动修改 DOM 等操作
useLayoutEffect
在 React Update DOM and refs 之前执行,适用于需要同步读取 DOM 布局或者避免闪烁等场景
总结:如果你的副作用需要同步执行或者涉及到 DOM 布局,那么应该使用 useLayoutEffect。否则使用 useEffect。
总结
在浏览器环境中,使用 React 框架需要注意副作用,如何正确使用 useEffect
方法,尽量避免未知问题。只需要记住一点,当业务逻辑带有副作用时,就需要写在 useEffect
中