React hook使用详解

因为篇幅原因,React hook的由来和影响这里不做介绍。本文主要介绍的是hook的基本API,还有从class编程迁移到hook编程过程中的细节记录和心得体会

目录

  • useState
  • useEffect
  • useCallback
  • useMemo
  • useRef

Ⅰ.useState

1.useState的基本用法

useState是hook提供的一个最基本的API,通过调用useState方法,能返回一个数组:

  • 数组的第一项是返回的state数值
  • 数组的第二项是修改这个state数值的函数
  • 传入useState的参数就是这个state的初始值

例如有以下代码,设计一个点击时显示数字递增的按钮

import React, { useState } from "react";

export default function Example() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

image

上面的代码在Class组件中相当于

export default class Example extends React.Component {
  state = {
    count: 0
  };
  setCount = count => {
    this.setState({
      count: count + 1
    });
  };
  render() {
    const { count } = this.state;
    return <button onClick={() => this.setCount(count)}>{count}</button>;
  }
}

2.以回调的方式修改state

set方法除了直接接收新值修改state外,还可以通过回调的方式修改state,例如下面:setCount接收的回调里,回调的参数就是当前的state.count,而回调的返回值是更新后的state.count

import React, { useState } from "react";
export default function Example() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count => count + 1)}>{count}</button>;
}

3.useState的回调参数:节约state初始化带来的性能损耗

useState初始化state时一般比较简单,对于它带来的性能损耗可以忽略不计,但如果遇到state创建时有较大计算量的情况的话,重复渲染的过程中就可能带来比较昂贵的性能损耗,这时我们可以把一个回调传给useState,计算后返回值会作为state的初始值。 这个回调只在函数组件入栈的时候调用一次,就可以节约重复计算的性能损耗。

import React, { useState } from "react";
export default function Example() {
  const [count, setCount] = useState(() => {
    // ... 其他计算
    const v = 1 + 1 * 1 - 2;
    return v;
  });
  return <button onClick={() => setCount(count => count + 1)}>{count}</button>;
}

4.模拟setState更新完成后的异步回调

在class组件的编程当中,我们有时会遇到这样的需求:在setState完成后执行某项异步回调,但函数组件的set方法是没有第二个参数的,那我们应该怎么处理呢?

实际上可以结合useEffect和useRef来实现(下文会详细介绍这两个API)

  • useEffect: 可监听某个依赖state的变化并异步执行响应函数
  • useRef:因为useEffect除了依赖参数变化会调用外,组件入栈时也会调用,useRef主要是通过标记排除组件入栈的情况
import React, { useState, useEffect, useRef } from "react";
export default function Example() {
  const [count, setCount] = useState(0);
  const isMountedRef = useRef(true);
  useEffect(() => {
    if (isMountedRef.current) {
      isMountedRef.current = false;
      return;
    }
    // 下面是count这一state改变后的回调
    console.log("count被改变了,当前值为" + count);
  }, [count]);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

运行结果

[图片上传失败...(image-b96fdc-1610348773478)]

Ⅱ. useEffect

使用 useEffect 完成副作用操作。赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。你可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。

1.useEffect不同写法的执行差别

(1)不写第二个参数

会在函数组件初次渲染和每次重渲染的时候调用,包括props改变和state改变导致的更新。

效果相当于componentDidMount + componentDidUpdate + componentWillUnmount

useEffect(() => {
    // ...
});

(2)第二个参数为空数组

只在入栈的时候运行一次

效果相当于componentDidMount

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

(3)useEffect使用返回值

返回值是一个函数,将会在组件销毁时候调用

如果useEffect按照上面1中编写方式不写第二个参数,也就是只在入栈时运行一次的话,那么此时返回函数效果相当于componentWillUnmount

useEffect(() => {
   let id = setInterval(() => { ... }, 1000);
   return () => clearInterval(id);
},[]);

(4)useEffect在第二个参数中写入数据属性

这种写法的效果是:除了初次入栈被以外,将只在数据属性改变的时候才运行useEffect内的函数,如下面代码中useEffect内匿名函数将会伴随count的变化而调用(初次入栈时count也会被识别为是"变化"的)

const [count, setCount] = useState(0);
useEffect(() => {
  // ...
}, [count]);

某种程度上说,它相当于class组件中的以下写法

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    // ...
  }
}

2.useEffect不写第二个参数进行调用时和函数直接调用的区别

两者在执行次数上是一样的,区别在于useEffect是异步的,而函数内调用是同步的

例如有以下代码

export default function Example() {
  useEffect(() => {
    console.log("useEffect调用");
  });
  console.log("函数调用");
  return <div />;
}

image

但你不能因此就把副作用直接写在组件函数内部

在函数组件主体内(这里指在 React 渲染阶段)改变 DOM、添加订阅、设置定时器、记录⽇志以及执 ⾏其他包含副作⽤用的操作都是不不被允许的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性。

3.函数组件内多个useEffect的执行次序

函数组件内部是可以写入多个useEffect的,如果这几个useEffect内的函数都是同步代码且执行条件相同的话(useEffect第二个参数相同),理论上多个useEffect内部函数是会按照编写时从上到下的次序执行的。

从源码上看, 组件加载时会依次执行各个useEffect,然后根据先后次序建立链表,而在effect执行时遍历链表,依次判断条件并执行effect函数

参考资料: https://www.cnblogs.com/vvjiang/p/12160791.html

(当然最好还是不要在逻辑上依赖于这个顺序,毕竟官方文档并没有特别陈述这一点)

export default function Example() {
  useEffect(() => {
    console.log(1);
  }, []);
  useEffect(() => {
    console.log(2);
  }, []);
  useEffect(() => {
    console.log(3);
  }, []);
  return <div />;
}

输出

image

4.useEffect访问外部依赖的限制

下面用一个例子加以说明,我们来实现这样一个功能:在页面中显示一个从0开始每隔1秒增加1的变化数字。

1.我们可能想要在1中的回调里面更新state,然而这却可能遇到问题,例如以下代码中,我们在useEffect中先访问了外部的count变量,然后在原来的count值的基础上去setCount

export default function Example() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <div>{count}</div>;
}

问题来了: React Hook的eslint检查器会提醒你以下警告,说必须要声明依赖

React Hook useEffect has a missing dependency: 'count'.

What?! ! 这里就形成一个矛盾了:

  • 我之所以不声明count依赖,就是为了只在入栈的时候执行一次模拟componentDidMount的效果
  • 而如果声明了count依赖,上面的方法就会在每次setCount的时候重新执行一遍Effect回调, 那就不是我要的componentDidMount了呀

解决办法:通过setState接收回调参数的方式更新state,这样就不用访问useEffect外部的state了

setCount(count => count + 1);

具体代码如下

export default function Example() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    let id = setInterval(() => {
      setCount(count => count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <div>{count}</div>;
}

总结: 使用useEffect访问外部数据的时候要小心,如果可以的话尽量使逻辑闭合在useEffect内部

Ⅲ. useMemo

useMemo这个API的作用是用来优化渲染性能的,它接收两个参数,

  • 第一个参数是一个计算某个值的函数
  • 第二个参数是一个依赖数组。组件重渲染的时候会根据依赖数组是否变化决定是否重新计算

根据接收参数的不同useMemo的执行情况如下:

  • 不传第二个参数时:每次组件渲染useMemo接收函数都会调用并返回计算值
  • 第二个参数为空数组时:只有组件首次加载时候useMemo接收函数才会调用返回计算值,后续重渲染都返回第一次计算的缓存值
  • 第二个参数为依赖数组时:当依赖发生改变时useMemo调用接收函数并返回值,如果依赖相比前一次渲染没有改变就返回缓存值

例如下面这个例子,

  1. 我们每隔一秒就通过setCount使组件重渲染,
  2. 但全过程中只调用setText了1次,也即只改变了text一次
  3. 然后以text为依赖调用useMemo计算函数。
export default function Example() {
  let [text, setText] = useState("默认文本");
  let [count, setCount] = useState(0);
  useEffect(() => {
    // 更新count,使组件每隔1秒就刷新一次
    const id = setInterval(() => setCount(count => count + 1), 1000);
    // 在入栈1s后修改text
    setTimeout(() => setText("修改后文本"), 1000);
    return () => clearInterval(id);
  }, []);
  // 只在text变化的时候才重新运行memo内部函数
  let t = useMemo(() => {
    console.log("memo调用");
    return "当前文本:" + text;
  }, [text]);
  // 在组件函数中打印
  console.log("组件渲染");
  return (
    <div>
      <p>{t}</p>
      <p>统计数:{count}</p>
    </div>
  );
}

通过控制台观察到useMemo的函数只在text变化时候才会进行实际的调用

[图片上传失败...(image-87dc0-1610348773477)]

React.memo

React.memo这个顶层API可以实现类似于PureComponent的功能

const NewComponent = React.memo(function MyComponent(props) {
 // ...   
})
// 效果类似于
class  NewComponent extends React.PureComponent {  
  // ...
}

React.memo还可以接收一个比较函数作为第二个参数,当返回true时会阻止组件重渲染,返回false则不阻止

function isEqual(preProps, nextProps) {
  return preProps.index === nextProps.index;
}
function Item({ index }) {
  return <div>{index}</div>;
}
export default React.memo(Item, isEqual);

Ⅳ. useCallback

useCallback的作用规律也是和useEffect, useMemo相似的

  • 不传第二个依赖参数时:每次渲染都把传入的函数原样返回,每次返回的都是新的函数引用
  • 第二个参数为空数组时:每次渲染都返回缓存的第一次传入的函数引用
  • 第二个参数为一个依赖数组时,只有依赖改变时才返回接收到的新函数引用,如果依赖没有改变就返回之前缓存的函数引用

1.useCallback和useMemo的异同

useMemo和useCallback也具有缓存作用,并可以用于优化渲染性能。但两者也有区别:

(1)执行逻辑不同

  • useMemo缓存的是计算结果,而useCallback缓存的是函数引用
  • useMemo是会对传入函数做计算的,而useCallback不会运行传入的函数,它只会选择性地返回函数引用

(2)使用目的不同

  • useMemo的性能优化是针对当前组件
  • useCallback的性能优化不是针对当前组件的,而是针对当前组件的子组件的

(这句话将在下文将着重解释,详见下文[useCallback的语义陷阱]一节)

“针对子组件”是什么意思? 让我们先从一段既有性能代码的问题开始讲起吧。

有以下代码:在Example组件中写入一个子组件Item, 子组件Item被设计为一个PureComponent,也就是只有在props发生变化时才会重新渲染。我们定义一个onClick方法传递给pure子组件Item。

import React, { useEffect, useState } from "react";

let Item = React.memo(function({ onClick }) {
  console.log(`item组件渲染`);
  return <div onClick={onClick}>item</div>;
});

export default function Example() {
  let [count, setCount] = useState(0);
  useEffect(() => {
    // 更新count,使组件每隔1秒就刷新一次
    const id = setInterval(() => setCount(count => count + 1), 1000);
    return () => clearInterval(id);
  }, []);
  // 定义一个传入子组件的函数
  let f = () => {};
  return (
    <div>
      <div>重渲染次数:{count}</div>
      <Item onClick={f} />
    </div>
  );
}

一切看起来都很正常,但其实这段代码是有性能问题的,请看下控制台:控制台显示作为pureComponet的item每一次都被重渲染了!

image

在这里,我们遇到了一个Class组件编程中不会遇到的问题:因为事件函数onClick的赋值在组件渲染函数的内部,所以每次重渲染的时都会重新创建并赋值,从而使传入Item子组件的props是一个新的函数引用,最后导致Item重复进行不必要的重渲染,React.memo的优化失效。

显然,我们希望onClick只要开始的时候创建一次就好,最好缓存起来,后面直接获取之前缓存的onClick就好了,这正是useCallback给我们起到的作用。

我们只要稍微改一改就能解决上面的问题

let Item = React.memo(function({ onClick }) {
  console.log(`item组件渲染`);
  return <div onClick={onClick}>item</div>;
});

export default function Example() {
  let [count, setCount] = useState(0);
  useEffect(() => {
    // 更新count,使组件每隔1秒就刷新一次
    const id = setInterval(() => setCount(count => count + 1), 1000);
    return () => clearInterval(id);
  }, []);
  // 改为使用useCallback创建事件函数 
  let f = useCallback(() => {}, []);
  return (
    <div>
      <div>重渲染次数:{count}</div>
      <Item onClick={f} />
    </div>
  );
}

输出如下,可以看到多次渲染的时候,子组件只渲染了一次

image

2.useCallback的语义陷阱

前文讲过一句话:

useCallback的性能优化不是针对当前组件的,而是针对当前组件的子组件的”

也就是说如果当前组件没有需要优化的子组件的话,useCallback其实是派不上用场的。并不能起到优化性能的作用,反而还会增加性能损耗。

因为useMemo这个方法的影响,我们可能会误以为下面这段代码里useCallback也能够优化性能

export default function Example() {
  let f = useCallback(() => { ... }, []);
  return <div onClick={f}></div>;
}

但实际上是不能的,因为它等效于于:

export default function Example() {
  // 新创建函数  
  let fn = () => {};
  // 调用useCallback
  let f = useCallback(fn, []);
  return <div onClick={f}></div>;
}

这样看就很清晰了:这里使用useCallback不但不能节约性能,反而还会因为useCallback的比较逻辑增加性能损耗。

Ⅴ. useRef

React hook中的useRef有两个作用

  • 作为存值对象使用,起到类似class组件中this的作用
  • 读取到当前最新值而非旧的“快照”
  • 获取上一轮次渲染的state或props

作为存值对象使用

在函数式组件中你是不能使用this的,当你想用this又找不到用法的时候,也许useRef就是你想要的东西。

useRef就是在函数式组件中能够“替代”class中this的一个api(也许这里用等效一词更合适一些)。

useRef返回的ref对象,自创建后会在函数组件的整个生命周期中一直留存。也就是说,当次渲染时写入ref的数据能在下次渲染时读取出来。

useRef调用后会返回一个含有current属性的对象, 这个对象的.current 属性被初始化为传入的参数,并且可以在后续进行修改。

例如有以下代码:我们通过对ref.current的读写实现根据是否为首次渲染返回不同文本

import React, { useEffect, useState, useRef } from "react";
export default function Example() {
  const [count, setCount] = useState(0);
  // useEffect的目的是使组件重渲染
  useEffect(
    () =>
      setTimeout(() => {
        setCount(1);
      }, 1000),
    []
  );
  // 调用useRef方法
  let isMountedRef = useRef(true);
  if (isMountedRef.current) {
    isMountedRef.current = false;
    return <div>首次渲染</div>;
  }
  return <div>非首次渲染</div>;
}

UI变化

首次渲染   // 0秒
非首次渲染 // 1秒后

读取到当前最新值而非旧的“快照”

useRef的另外一个作用是通过引用取值的方式,读取到当前的props的最新值而非旧的“快照”

函数式组件带来的一个可能的问题是获取数据的滞后性,当前的state在使用的时候可能是旧的而不是最新的。

以下面官方文档提供的demo为例:页面上有一个按钮和一个弹框,点击按钮后在页面上同步[点击次数],同时点击弹框的时候能把当前[点击次数]以弹框的形式弹出。

import React, { useCallback, useEffect, useState, useRef } from "react";
function Example() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('你点击了: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        按钮
      </button>
      <button onClick={handleAlertClick}>
        弹框
      </button>
    </div>
  );
}

运行上面的代码

如果你是先点按钮再点弹框,那么页面显示是同步的,你点击了按钮多少次,弹框就会提示你已经点击的次数

image

但如果你是先点弹框再点按钮,结果就可能是滞后的,例如下面那样,如果先点弹框,我们明明接下来连续点了按钮8次了,但是弹框还是显示0次

image

为什么会出现这两种截然不同的结果?我们来分析一下。

1.先点按钮再点弹框

这会触发setCount并使页面重渲染,handleAlertClick会被重新声明,重新声明时它获取到的count是最新的,这时候当然页面显示是同步的

2.先点弹框再点按钮

点击弹框的瞬间就发起了一个异步调用,这个时候读取的count是一个基本类型的数值而不是一个引用,所以它的值就被“固定”下来了,这就是导致弹框内的弹出次数滞后于按钮点击次数的原因

  const [count, setCount] = useState(0);
  function handleAlertClick() {
    setTimeout(() => {
      alert('你点击了: ' + count);
    }, 3000);
  }

使用useRef解决这个问题

useRef创建的是一个在函数组件生命周期内一直存续的对象引用,能够帮助解决这种“旧值”问题

如下所示,我们通过useRef进行改造

import React, { useCallback, useEffect, useState, useRef } from "react";

export default function Example() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);
  function handleAlertClick() {
    // 通过ref获取最新值
    setTimeout(() => {
      alert("你点击了: " + countRef.current);
    }, 3000);
  }

  return (
    <div>
      <p>你点击了按钮 {count} 次</p>
      <button
        onClick={() => {
          setCount(count + 1);
          // 修改ref内存储的数据
          countRef.current = count + 1;
        }}
      >
        按钮
      </button>
      <button onClick={handleAlertClick}>弹框</button>
    </div>
  );
}

运行结果如下,现在先点弹框再点按钮也可以显示正常了

[图片上传失败...(image-37a943-1610348773477)]

获取上一次渲染的state或props

这一点,官方文档的例子说的很明白了

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);  
  return <h1>当前: {count}, 上一次: {prevCount}</h1>;
}

function usePrevious(value) {  
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容