https://zh-hans.react.dev/reference/react/useCallback
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
Hook 规则
- 只在最顶层使用 Hook
- 只在 React 函数中调用 Hook。(或:在自定义 Hook 中调用其他 Hook)
useEffect
useEffect接收一个方法作为第一个参数,该方法会在每次渲染完成之后被调用。
它还会接收一个数组作为第二个参数,这个数组里的每一项内容都会被用来进行渲染前后的对比,如果没有变化,则不会调用该副作用。
useEffect 的依赖如果是个空数组,只会在 DOM 渲染后触发一次,以后都不会触发,相当于 componentDidMount。可以看做是 componentDidMount、componentDidUpdate、componentWillUnmount 三个钩子的组合。
useEffect可以返回一个函数,用于清除副作用的回调。每当组件卸载,或者组件重新render,都会触发这个函数。而且是先执行 return 函数,再执行 useEffect 内部逻辑。
注意事项
对于传入的对象类型,React只会判断引用是否改变,不会判断对象的属性是否改变,所以建议依赖数组中传入的变量都采用基本类型。
useEffect的清除函数在每次重新渲染时都会执行,而不是只在卸载组件的时候执行。
useLayoutEffect
在使用方式上,和 useEffect 一样。大部分情况只使用 useEffect 即可,当 useEffect 处理 DOM 相关逻辑时,出现问题了,再使用 useLayoutEffect。
至于出现什么问题,我们先来看一下它俩的执行时机。
组件更新过程
浏览器中 JS 线程和渲染线程是互斥的,渲染线程必须等待 JS 线程执行完毕,才开始渲染组件。
而我们的组件从 state 变化到渲染,大概可以分为如下几步:
改变 state,触发更新 state 变量的方法
React 根据组件返回的 vDOM 进行 diff 对比,得到新的 Virtual DOM
将新的 VDom 交给渲染线程处理,绘制到浏览器上
用户看到新的内容
而 useEffect 是在第 3 步之后执行的,也就是在浏览器绘制之后才调用。而且 useEffect 还是异步执行的,所谓异步就是被 requestIdleCallback 封装,只在浏览器空闲时候才会执行,保证了不会阻塞浏览器的渲染过程。
useLayoutEffect 就不一样,它会在第二步之后(diff 出新的 vDOM 之后),第三步之前执行,也就是渲染之前同步执行的,所以会等它执行完再渲染页面到浏览器上。
如果我们要操作 DOM,或者不想出现 内容闪烁 的问题,我们就是用 useLayoutEffect
明显的闪烁问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| useEffect(() => { async function fn() { if (num === 1) { let count = 0; console.time(); for (let i = 0; i < 99999999; i++) { count++; } console.timeEnd(); setNum(Math.random()); } } fn(); return () => { console.log("useEffect tail function"); }; }, [num]);
|
没有闪烁问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| useLayoutEffect(() => { async function fn() { if (num === 1) { let count = 0; console.time(); for (let i = 0; i < 99999999; i++) { count++; } console.timeEnd(); setNum(Math.random()); } } fn(); return () => { console.log("useLayoutEffect tail function"); }; }, [num]);
|
总结
- 优先使用 useEffect,因为它是异步执行的,不会阻塞渲染
- 会影响到渲染的操作尽量放到 useLayoutEffect中去,避免出现闪烁问题
- useLayoutEffect和componentDidMount是等价的,会同步调用,阻塞渲染
- 在服务端渲染的时候 useLayoutEffect 无效,使用 useEffect
性能优化—— useCallback、useMemo、memo
尽可能的保证组件不去发生变化,发生变化的因素有:state、props、context。
那么 React 是如何比较这三者的呢? 答案是 内存地址。
比如说,对比一个 function,对比的就是这个函数在内存中的地址,通过地址的判断,从而判断 props 是否发生了改变。
React.memo
https://react.docschina.org/docs/hooks-faq.html#how-do-i-implement-shouldcomponentupdate
React.memo 包裹一个组件,来对它的 props 进行浅比较。等效于 PureComponent,但它只比较 props。(也可以通过第二个参数指定一个自定义的比较函数来比较新旧 props。如果函数返回 true,就会跳过更新。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const Child = () => { console.log('Child') return ( <>Child component</> ) } const Demo = () => { const [count, setCount] = useState(0) return ( <> <button onClick={() => setCount(count => count + 1)}>+</button> <Child /> </> ) }
const Child = memo(() => { console.log('Child') return ( <>Child component</> ) })
|
当 memo 感知 props 没有发生改变时,不会重新 render 组件。如果传入 count 进来,Child组件就会重新 render。
总结:
- 如果我们将 setCount 当做 prop 传入进来,Child 不会重新render(
因为 setCount 在内存中的地址没有发生改变)
- 如果传入我们自己定义的方法 (fn)进来,Child会重新 render,因为 Demo 组件每次更新 count 后,重新生成了 fn 函数。
- 只是传了个 fn ,不想让 Child 组件更新怎么办?那就要用到
useCallback 钩子了
useMemo
把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算。
1 2 3 4
| const computedCount = useMemo(() => { return count * 2 }, [count])
|
useMemo 也允许你跳过一次子节点的昂贵的重新渲染,比如组件初始化时,需要一次大量的计算,后续就不会再改变了:
1 2 3 4 5 6 7 8 9 10 11 12
| function Parent({ a, b }) { const child1 = useMemo(() => <Child1 a={a} />, [a]); const child2 = useMemo(() => <Child2 b={b} />, [b]); return ( <> {child1} {child2} </> ) }
|
useCallback
把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。
下面这个例子,即使我们用 memo 包裹了组件,因为 setCount 每次会引起 Demo 组件重新 render,生成了新的 fn 函数(内存地址发生了变化),导致 Child 也会重新 render。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| interface IChild { fn: React.Dispatch<React.SetStateAction<number>> } const Child = memo((props: IChild) => { console.log('Child') return ( <>Child component</> ) })
const Demo = () => { const [count, setCount] = useState(0) const fn = () => console.log('is fn')
return ( <> <button onClick={() => setCount(count => count + 1)}>+</button> <Child fn={fn} /> </> ) }
|
我们不想让 fn 函数的 内存地址 发生变化,怎么办呢?使用 useCallback 钩子将其包裹起来即可。
注意:useMemo 也可以这样用,缓存 fn,从而使得 Child 组件不会重复 render。
1 2 3 4 5 6 7
|
const fn = useCallback(() => { console.log('is fn') }, [])
|
这样 fn 函数就是一个缓存函数了,即使 count 不停的发生变化,也不会造成 Child 组件重复 render。
总结:
- 当 Demo 组件内部 state 发生了改变引起 Demo 和 Child 组件重新 render
- 并且 Child 组件接受了一个来自 Demo 组件自定义的方法(fn)
- 如果不希望 Child 组件重新 render,那么就需要用 useCallback 钩子将自定义方法
fn 包裹起来
- 因为 Child 组件 props 里面的 fn 和 useCallback 返回的 fn 指向的是内存中的同一个地址,那么 Child 组件就不会更新
- useCallback 返回新函数的条件是:依赖项(第二个参数)发生了改变。
- 如果说我们的 Child 组件,本身就是需要根据 count 变化而变化,那么就不需要加这个缓存 API了,反而增加其计算负担。
设计组件
不要为了使用钩子,过渡的使用钩子,好的页面设计,也许用不上这些钩子。
把不变的组件和变化的组件抽离出来!
比如可以把 count 相关部分抽离成一个 Count 组件,使其和 Child 组件同层级排列,Count 组件和 Child 组件分开了,也不会引起 Child 组件做多余的 render。
1 2
| <Count /> <Child prop={fn} />
|
或者是通过 props.children 渲染 Child,也不会造成 Child 重新 render。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const Count = (props: any) => { const [count, setCount] = useState(0)
return ( <> <button onClick={() => setCount(count => count + 1)}>+</button> {/* children 不会重新 render */} {props.children} </> ) } const Demo = () => { const fn = () => {} return ( <> <Count> <Child fn={fn} /> </Count> </> ) }
|
useRef / createRef
https://zh-hans.react.dev/reference/react/useRef
获取 DOM 元素。
当 React 创建 DOM 节点并将其渲染到屏幕时,React 将会把 DOM 节点设置为你的 ref 对象的 current 属性。
当节点从屏幕上移除时,React 将把 current 属性设回 null。
1 2 3 4 5 6
| const inputRef = useRef(null)
inputRef.current.focus()
return <input ref={inputRef} />;
|
通过使用 ref,你可以确保:
- 可以在重新渲染之间 存储信息(不像是普通对象,每次渲染都会重置)。
- 改变它 不会触发重新渲染(不像是 state 变量,会触发重新渲染)。
- 对于你的组件的每个副本来说,这些信息都是本地的(不像是外面的变量,是共享的)。
注意:
- 不要在渲染期间写入 或者读取 ref.current。
1 2 3 4 5 6 7 8
| function MyComponent() { myRef.current = 123; return <h1>{myOtherRef.current}</h1>; }
|
- 可以在 事件处理程序或者 effects 中读取和写入 ref。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function MyComponent() { useEffect(() => { myRef.current = 123; }); function handleClick() { doSomething(myOtherRef.current); } }
|
编写一个获取 DOM信息的 hook
假如我们想要获取一个 dom 的 getBoundingClientRect 信息,我可能这样做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const getHeight = useMemo(() => { return (node: HTMLObjectElement) => { if (node) { setHeight(node.getBoundingClientRect().height) } } }, [])
const getHeight = useCallback((node: HTMLObjectElement) => { if (node) { setHeight(node.getBoundingClientRect().height) } }, [])
|
但是,获取 DOM 信息的逻辑其实很通用,所以考虑下,将 ref 逻辑抽离成一个 Hook。
1 2 3 4 5 6 7 8 9 10 11
| const useClientRect = () => { const [rect, setRect] = useState(null) const ref = useCallback(node => { if (node) { setRect(node.getBoundingClientRect()) } }, [])
return [rect, ref] }
|
使用
1 2 3 4 5 6
| const [rect, ref] = useClientRect()
<h1 ref={ref}>是 H1 标签 {count}</h1> { rect && <span>{rect.height}</span> }
|
React.forwardRef
https://zh-hans.react.dev/reference/react/forwardRef
React.forwardRef 会创建一个React组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中。
1 2 3 4 5 6 7 8 9
| const FancyInput = forwardRef((props, ref) => ( <input ref={inputRef} {...props} /> ))
const inputEle = React.createRef() <FancyInput ref={inputEle} />
|
useImperativeHandle(ref, createHandle, [deps])
useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。
对上述代码中所涉参数说明如下。
- ref:定义current对象的ref属性。
- createHandle:这是一个函数,返回值是一个对象,即这个ref的current对象。
- [deps]:依赖列表。当监听的依赖发生变化时,useImperativeHandle才会重新将子组件的实例属性输出到父组件ref的current属性上;如果为空数组,则不会重新输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| const FancyInput = forwardRef((props, ref) => { const inputRef = useRef();
useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); }, scrollIntoView() { inputRef.current.scrollIntoView(); }, })); return <input ref={inputRef} {...props} />; })
const ref = React.createRef() <FancyInput ref={ref} />
ref.current.focus() ref.current.scrollIntoView()
|
useReducer & useContext(组件级的状态管理)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
import { createContext } from "react";
export const AppContext = createContext(null);
export const appReducer = (state, action) => { switch (action.type) { case "UPDATE_AGE": return { ...state, user: { ...state.user, age: action.payload } }; case "UPDATE_NAME": return { ...state, user: { ...state.user, name: action.payload } }; default: return state; } };
|
使用上下文,可以使用 AppContext.consumer,但是有了 useContext 了就没必要了。
根组件使用 AppContext.Provider 提供状态 initState
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import { appReducer, AppContext } from "./reducers/app-reducer.js";
function App() { const initState = { type: "person", user: { age: 18, name: "alex.cheng" } }; const [state, dispatch] = useReducer(appReducer, initState);
return ( <AppContext.Provider value={state}> <Child1 /> </AppContext.Provider> ); }
|
子孙子组件通过 useContext(AppContext) 获取上下文提供的状态。
1 2 3 4 5 6 7 8
| const Child = () => { const context = useContext(AppContext); return ( <> <p>{JSON.stringify(context)}</p> </> ); };
|
参考资料