一文掌握 react hook 使用和渲染优化(上篇)

3,780 阅读14分钟

前言

官方文档:Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性

react hook其实就是使react函数组件可以使用state的一种方案,当然了不止可以简单的使用state,还提供了一些其他的hook, 这些方法就是 react hook。

本篇文章会涉及以下hook

1、useState
2、useCallback、useMemo/memo
3、useEffect、useLayoutEffect

为什么要是使用react hook?

ps:文章会比较详细,所以阅读需要有花一点时间

准备

后面所有代码都将使用两个组件讲解,请先准备好哟~
假如你的页面在demo文件夹下,需要在demo文件夹中建如下文件:

demo

  • Index.js 页面代码
  • Button.js 页面中的一个按钮组件

除了准备上面代码文件外,还需要安装chrome插件 React Developer Tools (react开发者工具,这里提供的连接需要翻墙,如果不能翻墙的童鞋可以自行百度下载)。

demo页面都将开启React开发者工具的 Highlight updates when components render.(高亮显示被渲染组件,会有个高亮的边框)

开启方式如下:
首先打开控制台,然后如下图操作即可。

本文章中所所涉及的ts代码都会进行详细解释代码的意思,所有不会ts的童鞋也么得关系。

useState

这个hook是最常使用的,当然也非常的容易理解。就是在函数组件中使用 state ,state改变后函数组件会重新渲染,和class组件一样。

useState函数的TypeScript类型定义:

//ts函数重载
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];

type SetStateAction<S> = S | ((prevState: S) => S);
type Dispatch<A> = (value: A) => void;

定义解释:

上面两个useState函数类型是使用了typeScript的函数重载进行定义的,也就是说调用useState时存在两种情况

第一种情况:
useState 函数接受一个参数 initialState(初始值)( S 会保存传入参数类型,如传入的 initialState 为string,那 S就为string)
useState 执行后返回一个数组,数组存在两个元素:[ initialState, Dispatch ]。第一个元素(值)initialState就是传入的初始值,第二个元素(改变值的方法)为dispatch 方法。ps:正确调用 dispatch方法 时将会改变state,state改变后组件将会重新渲染

初始值直接在页面引用使用就行。dispatch方法调用方法需要看上面ts代码中的类型定义,可以看出 dispatch 接受一个参数,参数可以是S类型(初始值类型)。参数也可以为一个函数,函数形参为 prevState(未改变前的state),函数需返回一个S类型。

有的看官可能会感到疑惑。dispatch 方法传入函数拿到的prevState形参不就是当前的state值吗?直接使用的state和使用函数提供的形参state有啥区别呢?有啥用呢?(🌲下面都会进行演示哟~)

第二种情况:
如果你理解了第一种情况,那第二种情况你应该都不需要看了。
第二种情况就是在调用 useState 函数时不传入参数。这时候 useState 第一个参数和返回的数组的一个元素(值)都将默认为undefined。 其余的定义和方法都和第一种情况一样...

🌰 show code

//Index.js

import React, { useState } from 'react';

//引入按钮组件
import Button  from './Button';

const  Index = () => { 

  //使用es6解构赋值
  //@count 为state  初始值设置为了0
  //@setCount 为dispatch方法 调用时传入数据即可改变count值
  const [count, setCount] = useState(0);
  
  //ts调用方式
  //const [count, setCount] = useState<number>(0);
    
  //第一种方式 直接传入参数改变count
  const onClick = ()=> { 
    setCount(count + 1)
  }
  
  //第一种方式 传入函数,函数返回值将为新的count
  const onClickTwo = ()=> {
  	//prevState就是还没改变之前的值,也就是当前的值
  	const actionFn = (prevState) => prevState + 1;
  	setCount(actionFn)
  }
  
  //组件渲染时候在这里log记录
  console.log('Index组件渲染')
  
  return <>
      <p>count:{count}</p>

     <Button onClick={onClick}>第一种方式 加1</Button>
     <Button onClick={onClickTwo}>第二种方式 加1</Button>
  </>
}
export default Index;
 
//Button.js 组件
//这个组件没什么特别的只是一个按钮
//为了方便后面实践性能优化等问题所以需要建立它

import React from 'react'; 

const Button = (props) => {   

    //组件渲染时候在这里log记录
    console.log('Botton组件渲染')
    
    return <button onClick={props.onClick}>
        {props.children}
    </button>
}

export default Button

✨执行效果如下

从gif图中可以看到只要调用 setCount方法页面(包括页面中的Button组件)就会重新渲染一遍,state也会更新。因为Button组件引用了两遍,所有Button组件在state改变后总是会渲染两次。

这里只是想让页面显示的 count:[n] 后面的数字重新渲染,也就是说只想重新渲染Index页面,可不想重新渲染按钮,而且更不想重新渲染两遍按钮😰。

🤔 怎么优化?

✨ so easy, 使用 useCallback + useMemo / React.memo 即可。

↓↓↓ 请继续阅读下面 ↓↓↓

useCallback、useMemo、memo

如果想避免子组件重复渲染那 useCallback 必须和 useMemo 一起使用,如果是想避免 useEffect 重复执行的话可以就直接使用useCallback包裹函数即可(useEffect一节细谈)。memo就比较厉害了,可独立使用也可以配合useCallback来避免子组件重复渲染。

先简单看下这三个hook/api的 说明和定义以及调用方法

useCallback 说明

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新

以上为官方文档解释,看不懂不要紧。也就是说 useCallback 接受两个参数,第一参数是个函数,第二个参数是个数组,数组的某个元素如果被改变了的话第一个参数也就是传入的函数就会被更新,否则就不更新传入的函数嘛。

useCallback函数的TypeScript类型定义

type DependencyList = ReadonlyArray<any>;

function useCallback<T extends (...args: any[]) => any>(callback: T, deps: DependencyList): T;

以上定义即描述了 useCallback 接受的第一个参数为一个函数,第二个参数为一个只读的数组。
当第二个参数(依赖列表)给空数组时,那useCallback包裹的函数将永远不会被更新。

useCallback函数的调用示例

const fooFn = ()=>{}
const callBackFooFn = useCallback(fooFn, [])

useMemo 说明

useMemo 调用方式和 useCallback 差不多一样。只是useMemo包裹的是一个函数,函数需要返回一个组件

useMemo函数的TypeScript类型定义

type DependencyList = ReadonlyArray<any>;
function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T;

以上定义描述和 useCallback 是一样的。不过明确定义了依赖列表可不传。( useCallback依赖列表也可以不传)

useCallback函数的调用示例

import Button from "./Button"
//...
return <>
	{ useMemo(() => <Button/>, []) }
</>

memo 说明

如果你的组件在相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。

以上为官方解释,白话来说就是如果组件的props没有改变的话,那可以使用 memo 函数让子组件不进行重复的渲染。

memo是一个高阶函数,同样接收两个参数,第一个参数为组件,第二个参数为一个函数,函数返回true的话组件将不更新,返回false组件将会更新。

memo函数的TypeScript类型定义

//ComponentClass FunctionComponent 分别是类组件和函数组件的类型
type ComponentType<P = {}> = ComponentClass<P> | FunctionComponent<P>;
type MemoExoticComponent<T extends ComponentType<any>> = NamedExoticComponent<ComponentPropsWithRef<T>> & {
    readonly type: T;
};
function memo<T extends ComponentType<any>>(
    Component: T,
    propsAreEqual?: (prevProps: Readonly<ComponentProps<T>>, nextProps: Readonly<ComponentProps<T>>) => boolean
): MemoExoticComponent<T>;

以上定义描述过于麻烦和复杂,就不在这里赘述了。从memo也能看出来该方法接受两个参数,第一个参数是组件,第二个可选参数为一个函数,函数需要返回一个布尔值。第二个函数有两个只读形参,前者是当前组件的props,后者为接下来的组件props(也就是props变化后的数据)

😁 这一节代码将会在 useState一节 的代码基础上更改(为了减少代码,省略不涉及的代码片段)。

🌰 show code

为了使 Button 组件不进行重复渲染,我们必须这么做

  • 必须使用 useCallback hook来包裹 onClick 函数。
  • 并且使用 useMemo hook 包裹Button 组件。
//Index.js

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

//引入按钮组件
import Button  from './Button';

const  Index = () => { 

   const [count, setCount] = useState(0); 
     
  //使用useCallback 方法包裹 第二个参数我们给空数组 让这个函数永远不会更新
  //注意这个函数永远不会更新的话 setCount 就必须写成funtion 不然拿不到想要的count状态
  const onClick = useCallback(()=> { 
    setCount(prevState => prevState + 1)
  }, [])  
  
  console.log('Index组件渲染')
  
  return <>
      <p>count:{count}</p>
	 
     {/* 使Button组件也永远不要更新 */}
     { useMemo(()=><Button onClick={onClick}>加1</Button> , []}) }
  </>
}
export default Index;
 

✨执行效果如下

✌可以看出来Button组件已经不再进行重复渲染了。
以上就是 useCallback + useMemo的使用。

除了上面使用 useMemo 包裹 Button 组件外还可以使用memo方法包裹Button组件。

//Index.js 
const  Index = () => {  
  //...
  return <>   
  	//...
     <Button onClick={onClick}>加1</Button>
  </>
}
export default Index;
//Button.js 组件  
const Button = (props) => {   
  	//... 
    return <button onClick={props.onClick}>
        {props.children}
    </button>
}

//这里使用memo包裹
export default memo(Button)

上面代码执行效果可执行尝试,和使用 useMemo 包裹Button组件效果是完全一样的。
不同点在于:useMemo 是在引用时候使用者包裹,memo 是组件内部自行包裹。两者自行选用其一即可。

memo 的另一个奇淫技巧。(一般不推荐使用,因为有时候会很诡异,但是既然有这个方法那就一定会有用得到的地方)

//Index.js 
//引入按钮组件
import Button  from './Button'; 
const  Index = () => {  
  const [count, setCount] = useState(0);  
  const onClick = ()=> { 
    //这里必须使用function方式 否则onClick方法永远不会更新,所以拿不到想要的数值
    setCount(prevState => prevState + 1)
  }
  
  console.log('Index组件渲染 --memo包裹')
  
  return <>
      <p>count:{count}</p> 
     <Button onClick={onClick}>加1</Button>
  </>
}
export default Index;
 
//Button.js 组件  
import React, { memo } from 'react';
const Button = (props) => {   

   console.log('Botton组件渲染 --memo包裹')
   
   return <button onClick={props.onClick}>
       {props.children}
   </button>
}

export default memo(Button, (prevProps, nextProps)=>{	
   //可以使用prevProps, nextProps进行一些逻辑判断
   return true
})

✨执行效果如下

🤔原来单独使用 memo 也可以搞定 useCallback + useMemo组合。
但是需要注意:当 memo 第二个参数返回 true 时候组件将不会被更新,也就是上一次的 props 的更新将不会用到组件中,可能会造成一些奇怪的问题。所以如果你控制不住的话,最好不要使用🤣。

useEffect、useLayoutEffect

先简单看下这两个hook的 说明和定义以及调用方法

useEffect 说明

使用 useEffect 完成副作用操作。赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。你可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。默认情况下,effect 将在每轮渲染结束后执行,但你可以选择让它 在只有某些值改变的时候 才执行。(effect就是传入到useEffect中的函数)

ps:在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用

以上文档解释的很清楚了,意思就是 useEffect 接收一个函数,函数在浏览器布局和绘制完毕后就会被调用。也可以传入一个依赖项,在依赖项变化后react渲染完毕就会调用(传入空数组就只会首次渲染执行)。

useEffect 函数的 TypeScript 类型定义

type DependencyList = ReadonlyArray<any>;
type EffectCallback = () => (void | (() => void | undefined));
function useEffect(effect: EffectCallback, deps?: DependencyList): void;

以上定义即描述了 useEffect 接受的第一个参数为函数,第二个参数为只读的数组。
当第一个参数执行后应该无返回值或者返回一个函数,返回的函数执是无返回值的。
当第二个参数(依赖列表)给空数组时,那 useEffect 就只会执行一次

useEffect 函数的调用示例

//Index.js 
import React, { useEffect } from 'react';
//引入按钮组件
import Button  from './Button'; 
const  Index = () => {  
  const [count, setCount] = useState(0);  
  const onClick = ()=> {  
  	setCount(prevState => prevState + 1)
  }
  
  useEffect(()=>{ 
 	console.log('Index 渲染完成')
  }) 

  return <>
      <p>count:{count}</p> 
      <Button onClick={onClick}>加1</Button>
  </>
}
export default Index;
 

✨执行效果如下

  • 1、会在完全渲染完毕后延时执行 不阻塞dom渲染
  • 2、注意这里不会等待React.Suspense中的组件

我们在 useEffectdebugger 一下可能会更清楚的看明白。
此处需要配合react开发者工具高亮渲染

useEffect(()=>{ 
  console.log('Index 渲染完成') 
  debugger;  
}) 

✨执行效果如下 从图中可以看出来渲染是在 useEffect 函数执行之前就进行的。因为从第一次可以明显看到了渲染高亮在出现后执行了断点,随后的几次都没看到高亮(可能是断点时候渲染已经渲染完了)。

将 子集变得复杂 加上 操作dom 来更直观的感受

//Index.js 
import React, { useEffect } from 'react';
//引入按钮组件
import Button  from './Button'; 
const  Index = () => {   
  const onClick = () => {
      setCount(prevState => prevState + 1)
  }
  useEffect(()=>{ 
 	console.log('Index 渲染完成')
    //让dom向下平移300px
    document.querySelector('.button').style.transform = "translateY(300px)" 
  }) 

  return <> 
      <div className="button">
      	<Button onClick={onClick}>加1</Button>
      </div>
  </>
}
export default Index;
 
import React from 'react'; 
//返回一万个button
const Button = (props) =>   
    return <>
    {
        new Array(10000).fill(1).map((item, index)=>{
            return <button onClick={props.onClick} key={index}>
            {props.children} -- {index}
        </button>
        })
    }
    </>
} 
export default Button

✨执行效果如下

可以从图中清晰看出来,dom先是在原位置出现的,然后才向下平移了300px。

了解了 useEffect 的执行时机后我们就可以清楚的明白在什么时候可以使用它了。

其实 useEffect 和类组件的 componentDidMount 是差不多的,唯一的不同是,useEffect 会在渲染完毕后延时执行。

接下来我们来瞅瞅 useLayoutEffect

useLayoutEffect 说明

和 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

意思就是 useLayoutEffectuseEffect 是一样的,只是两个 hook 执行的时机不同。
上面说了 useLayoutEffect 是在 DOM 变更之后同步调用 effect(就是传入到useLayoutEffect中的函数),所以 useLayoutEffect 会比 useEffect 执行得早一些。

useLayoutEffect 函数的 TypeScript 类型定义

type DependencyList = ReadonlyArray<any>;
type EffectCallback = () => (void | (() => void | undefined));
function useLayoutEffect(effect: EffectCallback, deps?: DependencyList): void;

这段描述和 useEffect 的描述是完全一样滴~

useLayoutEffect 函数的调用示例

//Index.js 
import React, { useLayoutEffect } from 'react';
//引入按钮组件
import Button  from './Button'; 
const  Index = () => {  
  const [count, setCount] = useState(0);  
  const onClick = ()=> {  
  	setCount(prevState => prevState + 1)
  }
  
  useLayoutEffect(()=>{ 
 	console.log('Index 渲染完成')
  }) 

  return <>
      <p>count:{count}</p> 
      <Button onClick={onClick}>加1</Button>
  </>
}
export default Index;
 

✨执行效果如下

  • 1、会在react渲染完之前执行 所以会比useEffect钩子执行会晚(此时dom也已经被渲染出来了)
  • 2、会阻塞渲染,除非要操作dom 否则不推荐使用这个钩子

其实这么看着效果和 useEffect hook是完全一样的。

那和 useEffectuseLayoutEffect 到底在哪?🤔
别急,下面几个案例你就可以直观感受到了。

我们在 useLayoutEffectdebugger 一下,看看和useEffect有什么区别🌲。

useLayoutEffect(()=>{ 
  console.log('Index 渲染完成') 
  debugger;  
}) 

✨执行效果如下 如图可以看出来,是在断点后 react 开发者工具才会进行提示高亮渲染。说明这块是在 DOM 变更之后同步调用 effect,而不是和 useEffect 一样等react完全渲染完毕后才延时执行。

我们再来看下 将 子集变得复杂 加上 操作dom 来和 useEffect的区别

这里button.js中的代码完全和 useEffect 案例中的一样,所以省略

//Index.js 
import React, { useLayoutEffect } from 'react';
//引入按钮组件
import Button  from './Button'; 
const  Index = () => {    
  //...
  
  useLayoutEffect(()=>{ 
    console.log('Index 渲染完成')
    //让dom向下平移300px
    document.querySelector('.button').style.transform = "translateY(300px)" 
  }) 

  return <> 
     <div className="button">
      	<Button onClick={onClick}>加1</Button>
      </div>
  </>
}
export default Index;
 

✨执行效果如下

可以看出来和 useEffect 区别很大。使用 useLayoutEffect 不会有在原来的位置停留一下。而是直接渲染了下移了300px后的dom。所以也证明了 DOM 变更之后同步调用 effect

本来想把所有的 hook 说明都写到这篇中,但是文章有点长了,其余的下一篇再论😂。

喜欢的话点个赞喔🤦‍♂️

下篇:一文掌握 react hook 使用和渲染优化(下篇)