React-hook较佳实践

256 阅读32分钟

项目地址


WindDancer/react-hooks-beginner

GitHub - WindDancerUBI/react-hooks-beginner

视频地址


在线文档


Better Partice in React Hook

一、React Introduce

1、react是基本的页面渲染库

基于不同的平台有:

  • react-dom: 浏览器
  • react-native: app环境
  • react-vr: vr平台

2、为什么要使用React-Hook

在React-Hook诞生之前,React通常使用class作为组件。而function只能作为受控组件,它本身是没有自己的状态(state),只能通过接受props来被动渲染。引入React-Hook后,函数组件可以通过useState来拥有自己的状态;useEffect整合了各类生命周期,使得代码逻辑更清晰;自定义hook使得代码更容易复用。以下是官网对于React-Hook的介绍。

Hook 简介 - React

简而言之,hook主要解决了以下的一些问题:

  • 大型组件很难拆分和重构,也很难测试。
  • 业务逻辑分散在组件的各个方法之中,导致重复逻辑或关联逻辑。
  • 组件类引入了复杂的编程模式,比如 render props 和高阶组件。
💡 核心思想:组件的最佳写法应该是函数,而不是类。React Hooks 的意思是,组件尽量写成纯函数,如果需要外部功能和副作用,就用钩子把外部代码"钩"进来。

3、Hook 的发展历程

React 团队从一开始就很注重 React 的代码复用性

他们对代码复用性的解决方案历经:Mixin, HOC, Render Prop,直到现在的 Custom Hook

所以 Custom Hook 并不是一拍脑门横空出世的产物,即使是很多对 Custom Hook 有丰富开发经验的开发者,也不了解 Hook 到底是怎么来的,以及在 React 里扮演什么角色

不理解这段设计思路是无法深刻的理解 Custom Hook 的。

3.1 Mixin

var SetIntervalMixin = {
  componentWillMount: function() {
    this.intervals = [];
  },
  setInterval: function() {
    this.intervals.push(setInterval.apply(null, arguments));
  },
  componentWillUnmount: function() {
    this.intervals.forEach(clearInterval);
  }
};

var createReactClass = require('create-react-class');

var TickTock = createReactClass({
  mixins: [SetIntervalMixin], // 使用 mixin
  getInitialState: function() {
    return {seconds: 0};
  },
  componentDidMount: function() {
    this.setInterval(this.tick, 1000); // 调用 mixin 上的方法
  },
  tick: function() {
    this.setState({seconds: this.state.seconds + 1});
  },
  render: function() {
    return (
      <p>
        React has been running for {this.state.seconds} seconds.
      </p>
    );
  }
});

ReactDOM.render(
  <TickTock />,
  document.getElementById('example')
);

优点:

  1. 确实起到了重用代码的作用

缺点:

  1. 它是隐式依赖,隐式依赖被认为在 React 中是不好的
  2. 名字冲突问题
  3. 只能在 React.createClass里工作,不支持 ES6 的 Class Component
  4. 实践下来发现:难以维护

在 React 官网中已经被标记为 '不推荐使用',官方吐槽点这里

3.2 HOC

2015 年开始,React 团队宣布不推荐使用 Mixin,推荐大家使用 HOC 模式

HOC 采用了 '装饰器模式' 来复用代码

function withWindowWidth(BaseComponent) {
  class DerivedClass extends React.Component {
    state = {
      windowWidth: window.innerWidth,
    }

    onResize = () => {
      this.setState({
        windowWidth: window.innerWidth,
      })
    }

    componentDidMount() {
      window.addEventListener('resize', this.onResize)
    }

    componentWillUnmount() {
      window.removeEventListener('resize', this.onResize);
    }

    render() {
      return <BaseComponent {...this.props} {...this.state}/>
    }
  }
  return DerivedClass;
}

const MyComponent = (props) => {
  return <div>Window width is: {props.windowWidth}</div>
};

经典的 容器组件与展示组件分离 (separation of container presidential) 就是从这里开始的

下面是最最经典的 HOC 容器组件与展示组件分离 案例 - Redux中的connect 的实例代码

export const createInfoScreen = (ChildComponent, fetchData, dataName) => {
  class HOComponent extends Component {
    state = { counter: 0 }
    handleIncrementCounter = () => {
       this.setState({ counter: this.state.counter + 1 });
    }
componentDidMount() {
      this.props.fetchData();
    }

    render() {
      const { data = {}, isFetching, error } = this.props[dataName]; 
      if (isFetching) {
        return (
          <div>Loading</div>
        );
      }

      if (error) {
        return (
          <div>Something is wrong. Please try again!</div>
        );
      }

      if (isEmpty(data)) {
        return (
          <div>No Data!</div>
        );
      }

      return (
        <ChildComponent 
          counter={this.state.counter}
          onIncrementCounterClick={this.handleIncrementCounter}
          {...this.props}
        />
      );
    }
  }

  const dataSelector = state => state[dataName];
  const getData = () => createSelector(dataSelector, data => data);
  const mapStateToProps = state => {
    const data = getData();
    return {
      [dataName]: data(state),
    };
  };

  HOComponent.propTypes = {
    fetchData: PropTypes.func.isRequired,
  };

  HOComponent.displayName = `createInfoScreen(${getDisplayName(HOComponent)})`;

  return connect(
    mapStateToProps,
    { fetchData },
  )(HOComponent);
};

优点:

  1. 可以在任何组件包括 Class Component 中工作
  2. 它所倡导的 容器组件与展示组件分离 原则做到了:关注点分离

缺点:

  1. 不直观,难以阅读
  2. 名字冲突
  3. 组件层层层层层层嵌套

3.3 Render Prop

2017 年开始,Render Prop 流行了起来

Render Prop 采用了 '代理模式' 来复用代码

class WindowWidth extends React.Component {
  propTypes = {
    children: PropTypes.func.isRequired
  }

  state = {
    windowWidth: window.innerWidth,
  }

  onResize = () => {
    this.setState({
      windowWidth: window.innerWidth,
    })
  }

  componentDidMount() {
    window.addEventListener('resize', this.onResize)
  }

  componentWillUnmount() {
    window.removeEventListener('resize', this.onResize);
  }

  render() {
    return this.props.children(this.state.windowWidth);
  }
}

const MyComponent = () => {
  return (
    <WindowWidth>
      {width => <div>Window width is: {width}</div>}
    </WindowWidth>
  )
}

React Router 也采用了这样的API设计:

<Route path = "/about" render= { (props) => <About {...props} />}>

优点:

  1. 灵活

缺点:

  1. 难以阅读,难以理解

3.4 Hook

2018 年,React 团队宣布推出一种全新的重用代码的方式 - React Hook

它的核心改变是:允许函数式组件存储自己的状态,在这之前函数式组件是不能有自己的状态的

这个改变使我们可以像抽象一个普通函数一样抽象React组件中的逻辑

实现的原理:闭包

import { useState, useEffect } from "react";

const useWindowsWidth = () => {
  const [isScreenSmall, setIsScreenSmall] = useState(false);

  let checkScreenSize = () => {
    setIsScreenSmall(window.innerWidth < 600);
  };
  useEffect(() => {
    checkScreenSize();
    window.addEventListener("resize", checkScreenSize);

    return () => window.removeEventListener("resize", checkScreenSize);
  }, []);

  return isScreenSmall;
};

export default useWindowsWidth;

import React from 'react'
import useWindowWidth from './useWindowWidth.js'

const MyComponent = () => {
  const onSmallScreen = useWindowWidth();

  return (
    // Return some elements
  )
}

优点:

  1. 提取逻辑出来非常容易
  2. 非常易于组合
  3. 可读性非常强
  4. 没有名字冲突问题

缺点:

  1. Hook有自身的用法限制: 只能在组件顶层使用,只能在组件中使用
  2. 由于原理为闭包,所以极少数情况下会出现难以理解的问题

二、useState

1、基本用法

const [state, setState] = useState(initValue)

useState使React函数组件拥有了状态。

  • 括号里的initValue是state的初始值。
  • 数组解构的第一个参数是最新的state值,每次state的值的改变将触发页面重新渲染。
  • 数组解构的第二个参数是state的更新函数,通过给setState(newState)传递参数newState来改变状态值(state),并引发页面的重新渲染。
import React, { useState } from 'react';

const Example = () => {
  // 声明一个叫 "count" 的 state 变量
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

2、setState是同步还是异步

setState的批处理、合并策略

setState本身是同步的。但是react内部的批处理和合并逻辑的作用下,其行为像是异步的。请看下面一个例子:

import { useState } from "react";
import "./styles.css";

export default function App() {
  const [num, setNum] = useState(0);
  const addNum = () => {
    setNum(num + 1)
    setNum(num + 2)
    setNum(num + 3)
  };
  return (
    <div className="App">
      <h1>setState是同步还是异步的?</h1>
      <p>数字:{num}</p>
      <button onClick={addNum}>增加</button>
    </div>
  );
}

在该代码中,点击一次增加按钮,连续调用了三次setNum,分别将num +1, +2, +3。最后发现,num相较于点击按钮之前只增加了3,而不是预期的6(1+2+3),这是什么原因呢?

这里涉及到React的批处理、合并策略:

  • 无论调用多少次 setState,,都不会立即执行更。而是将要更新的 state存入_pendingStateQuene,将要更新的组件存入 dirtyComponent;
  • 当根组件 didMount后,批处理机制更新为flse。此时再取出_pendingStateQuene和 dirtyComponent中的state和组件进行合并更新,合并的方式是直接用最新的一次操作覆盖之前的操作。

但是在原生事件和异步代码中,setState的机制又是不同的,请看下面的例子:

export default class ClassComponent extends React.Component {
  state = {
    number: 0,
  }

  handleClick = () => {
    this.setState({
      number: this.state.number + 1
    })
    this.setState({
      number: this.state.number + 2
    })
    this.setState({
      number: this.state.number + 3
    })
    setTimeout(() => {
      this.setState({
        number: this.state.number + 1
      })
      this.setState({
        number: this.state.number + 2
      })
      this.setState({
        number: this.state.number + 3
      })
    }, 10)

  }
  render() {
    return (
      <div>
        <p>数字:{this.state.number}</p>
        <Button onClick={this.handleClick}>增加</Button>
      </div>
    )
  }
}
💡 最后结果显示的是一次点击增加了9!

这是因为在执行setTimeout之前,React对这三次state操作进行了批处理,并没有立即去更新他们。但执行到setTimeout时,React无权去对这个异步操作去执行批处理。这时,他将去处理之前的三个批处理,并将其合并更新,这时候的number值为3。在之后10ms后,执行异步代码setTimeout里的setState,这时候他不进行批处理,而是分别进行三次state更新,因此最后的结果是一次点击增加了9。

总结如下:

  • 原生事件不会触发 react的批处理机制,因而调用 setState会直接更新;
  • 异步代码中调用 setState,,由于js的异步处理机制制,异步代码会暂存,等待同步代码执行完毕再执行,此时 react的批处理机制已经结束,因而直接更新。

现在,将上面的类组件更改为函数组件,重新实现一编上面的操作:

import { Button, Typography } from "antd";
import { useState } from "react";

const { Title, Text, Paragraph } = Typography

const FunctionComponent = () => {
  const [num, setNum] = useState(0);

  const addNumAsync = () => {
    setNum(num + 1)
    setNum(num + 2)
    setNum(num + 3)
    setTimeout(() => {
      setNum(num + 1)
      setNum(num + 2)
      setNum(num + 3)
    });
  }
  return (
    <div>
      <p>数字:{num}</p>
      <Button onClick={addNumAsync}>增加</Button>
    </div>
  );
}

export default FunctionComponent
💡 最后结果显示的是一次点击增加了3!

其实,函数组件和类组件在批处理的实现上是一致的,不同的区别在于:

  • class组件是通过this.state获取到state的,因此更新时state是最新的值。
  • function组件是setTimeout 时读取了当时渲染闭包环境的数据。虽然之后state值进行了更新,但setState时所用到的state仍然是之前的值。

setState有时同步,有时异步

运行以下代码,在一次点击事件中调用三次setNum,结果发现三次打印的num均为0。

const addNum = () => {
  setNum(num + 1)
  console.log('num', num)
  setNum(num + 2)
  console.log('num', num)
  setNum(num + 3)
  console.log('num', num)
};

造成此种现象的原因已在上面解释过了,当执行到setNum(num + 1)时,React执行了该段代码,但是由于批处理机制并没有去更新state,因此执行到下一句代码时,打印的结果当然是0,同理,后面两个的结果也是0。此结果可以将setState的状态更新看成是异步的!

运行以下代码,将一次点击事件改成先连续调用三次setState,然后再异步函数中在调用三次setState,结果发现六次打印结果为0,0,0,4,6,9

handleClick = () => {
  this.setState({
    number: this.state.number + 1
  })
  console.log('1', this.state.number)
  this.setState({
    number: this.state.number + 2
  })
  console.log('2', this.state.number)
  this.setState({
    number: this.state.number + 3
  })
  console.log('3', this.state.number)
  setTimeout(() => {
    this.setState({
      number: this.state.number + 1
    })
    console.log('4', this.state.number)
    this.setState({
      number: this.state.number + 2
    })
    console.log('5', this.state.number)
    this.setState({
      number: this.state.number + 3
    })
    console.log('6', this.state.number)
  }, 10)

}

很显然,前三次的状态更新进行了批处理。因此状态更新表现为异步的。但是在setTimeout中,批处理已经结束了,现在执行setState会立马更新state的值,此时状态更新相当于是同步的。

如果将上述代码的实现改为function component,打印结果会是6个0。这个结果是显然易见的,更新机制仍然是和class组件一样的,但是因为闭包的原因,无法取到最新的state值!

3、函数式更新

通过上一节的分析,我们知道了,函数组件与类组件的区别在于:

  • 类组件是通过this.state来获取state的值,因此在进项状态更新时是可以获取到最新的state的值
  • 函数组件是在当时渲染闭包环境时读取state的值,因此当state值更新时,setState时操作的state的值是过时的、不是最新的。

为了解决这个问题,可以通过在setState时,使用函数是更新法,传入一个state参数。

函数式更新除了可以获取到最新的状态值,还可以阻止React的合并更新。

const Chapter2_3 = () => {
  const [num, setNum] = useState(0);

  const addNumAsync = () => {
    setNum(state => state + 1)
    console.log('1', num)
    setNum(num => num + 2)
    console.log('1', num)
    setNum(num => num + 3)
    console.log('1', num)
    setTimeout(() => {
      setNum(num => num + 1)
      console.log('1', num)
      setNum(num => num + 2)
      console.log('1', num)
      setNum(num => num + 3)
      console.log('1', num)
    });
  };

  return (
    <Typography>
      <p>数字:{num}</p>
      <Button onClick={addNumAsync}>增加</Button>
    </Typography>
  );
}

以上代码一次点击最终增加了12,满足了我们一开始想要的结果。

setState()可以接收一个对象外,还可以接收一个函数:

  • 传递对象 批处理,对相同变量进行的多次处理会合并为一个,并以最后一次的处理结果为准
  • 传递函数 链式调用,React 会把我们更新 state 的函数加入到一个队列里面,然后,按照函数的顺序依次调用。同时,为每个函数传入 state 的前一个状态,这样,就能更合理的来更新我们的 state 了,该函数有两个参数

4、惰性初始化

useState可以存字符串、数值等基本类型,也可以存数组、字符串等引用类型。那么可不可以存函数呢?

const Chapter2_4 = () => {
  const [callback, setCallback] = useState(() => { alert("init"); });

  return (
    <Typography>
      <Button className="bnt-margin" onClick={() => { setCallback(() => { alert("change"); }); }} > 
				更改函数 
			</Button>
      <Button onClick={callback}>执行函数</Button>
    </Typography>
  );

通过callback存入一个函数,在点击更改函数按钮时,变更存储的函数;在点击执行函数按钮时,调用存入的函数。然而结果并不是像我们期望的那样。在初始化state和点击更改函数按钮时,都自动执行了存入的函数!(如果使用了typescript,执行函数点击事件onClick={callback},会直接报类型错误—不能将void赋值给function)

💡 这是因为React的useState有着惰性初始化的特性。

传入函数给useState,React并不会认为你要存的是一个函数。相反他会认为这是一个非常消耗性能的计算state的操作,他会立即去执行还函数,并将该函数的返回值作为新的state。

💡 那么useState如何存一个函数呢?

其实很简单,可以传入一个函数,该函数返回一个我们需要存储的函数,这样就达到了存储函数的目的,修改后的代码如下:

const FunctionState = () => {
  const [callback, setCallback] = useState(() => () => {alert('init')})
  return (
    <div className="App">
      <h1>useState惰性初始化</h1>
      <button onClick={() => {setCallback(() => () => {alert('change')})}}>
				更改函数
			</button>
      <button onClick={callback}>执行函数</button>
    </div>
  );
}

三、useEffect

1、基本用法

useEffect(() => {
  // do some effect function
}, [dependence]);

在DOM更新完毕之后执行副作用函数,可以取代class的生命周期函数。

  • 当没有依赖项,会在组件每次更新后执行
  • 依赖项为空数组:会在组件挂载和卸载时执行
  • 依赖项为变量时,会在这些变量改变后才执行

以下代码是一个简单的useEffect使用,当点击按钮后状态值num改变,引起页面重新渲染。由于依赖项是num,页面渲染完毕后,num发生了改变执行useEffect,将状态值doubleNum更新为2*num。此时页面因为状态值doubleNum的改变会再次重新渲染,渲染完毕后,由于num的值未改变,因此并不会再次执行useEffect了。

const Chapter3_1 = () => {
  const [num, setNum] = useState<number>(0);
  const [doubleNum, setDoubleNum] = useState<number>(0);

  useEffect(() => {
		console.log('页面渲染完毕')
    setDoubleNum(num * 2);
  }, [num]);

  return (
    <div>
      <p>num: {num}</p>
      <p>doubleNum: {doubleNum}</p>
      <button onClick={() => setNum(num + 1)} className="bnt-margin">
        增加num
      </button>
    </div>
  );
};

export default Chapter3_1;

如果依赖性为[],则useEffect只会在页面第一次渲染的时候执行,因此doubleNum一直为0;

如果依赖项为[num,doubleNum]

2、清除effect

React官方对effect的清除有详细的说明

官方文档:需要清除的Effect

简而言之,有些useEffect中的副作用是定时器、订阅这些操作。如果不及时清除这些副作用,在页面重新渲染,执行useEffect时,就会再次创建一个定时器或订阅操作。这样随着页面的不断重新渲染,创建的定时器或订阅操作就会越来越多,最终导致内存泄漏

看下面一个例子:

function Chapter3_2() {
  const [num, setNum] = useState<number>(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setNum(num + 1);
    }, 2000);
    console.log("effect部分");
    return () => {
      clearInterval(timer);
      console.log("return部分");
    };
  }, [num]);
  return (
    <div>
      <Title level={2}>useEffect如何清除effect</Title>
      <p>数字:{num}</p>
    </div>
  );
}

export default Chapter3_2;

设置一个定时器,在两秒后更新状态值num。num的改变导致页面重新渲染,此时再次执行useEffect,它会先去执行return的函数,然后再去执行副作用,这样就达到了先清理定时器。

  • React会在第一次渲染时执行useEffect中的函数,是不会执行return。
  • effect 在之后的每次渲染的时候都会执行。此时先执行return函数,再执行effect中的副作用。
  • React 会在组件卸载的时候执行清除操作(即执行return)

3、useEffect依赖项的意义

探寻依赖项的意义

接着使用上节的代码,但是这次依赖项设置为空,并定时在控制台打印num的值;并设置一个增加按钮,他可以用来改变num的值。结果发现,无论如何操作,控制台打印的值始终为0。

function Chapter3_3() {
  const [num, setNum] = useState<number>(0);

  const addNum = () => {
    setNum(num + 1)
  }

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('num:', num)
    }, 1000);
    return () => {
      clearInterval(timer);
    };
  }, []);
  return (
    <div>
      <p>数字:{num}</p>
      <Button onClick={addNum}>增加</Button>
    </div>
  );
}

导致上述结果的原因是:React-Hook严重依赖闭包

💡 闭包

以下是一个关于闭包的例子:

const testClosure = () => {
  let num = 0;

  const effect = () => {
    num += 1;
    const message = `num value in message:${num}`;

    return function unmount() {
      console.log(message);
    };
  };

  return effect;
};
// 执行testClosure,返回effect函数,每次调用effect时执行的环境是不同的
const add = testClosure();
// 执行effect函数,返回引用了message1的unmount函数
const unmount = add();
// 再一次执行effect函数,返回引用了message2的unmount函数
add();
// message3
add();
// message4
add();
// message5
add();
unmount(); // 在这里会打印什么呢?按照直觉似乎应该打印5,实际上打印了1

这是因为在闭包返回effect函数时,num=0的值被effect函数保留了下来,之后effect每次执行时num的值会加1。effcet函数本身也是一个闭包,unmount是在num=1时创建并返回的函数,它将num=1的值保留到打印信息中,因此后面num值如何变化,都无法影响到unmount函数中的num。想要使unmount函数返回最新的num,其实很简单,只需要重新创建并返回一次unmount函数。记在上述代码底部添加如下代码即可:

const otherUnmount = add()
otherUnmount() // 打印了最新值6

当了解了闭包的特性后,就可以得出依赖项的意义:

在依赖项改变时,重新创建一个新的hook,并在创建的过程中拿到最新的值,防止在useEffect中使用到的状态值过期。

如果使用了eslint的话,vscode会在依赖项里提示你将在useEffect中用到的变量添加进依赖项中。

4、依赖项的注意事项

一般选择什么作为useEffect的依赖项?选择依赖项时需要注意些什么呢?请看下面一个例子:

function Chapter3_4() {
  const [refresh, setRefresh] = useState<boolean>(false)
  const [dataSource, setDataSource] = useState<any>([]);
  const [otherObject, setOtherObject] = useState<{[key: string]: any}>({})

  const constValue = 1
  const object = {}

  useEffect(() => {
    console.log("页面重新渲染了")
    setDataSource([
      {key: 1, name: '小明', age: Math.ceil(Math.random()* 30), gender: 'male'},
      {key: 2, name: '小花', age: Math.ceil(Math.random()* 30), gender: 'female'},
      {key: 3, name: '小张', age: Math.ceil(Math.random()* 30), gender: 'male'},
      {key: 4, name: '小李', age: Math.ceil(Math.random()* 30), gender: 'female'},
    ])
  }, [refresh])

  return (
    <Typography>
      <Title level={2}>useEffectdet依赖项的选择</Title>
      <Title level={3}>实例</Title>
      <Button onClick={() => setRefresh(!refresh)}>刷新数据</Button>
      <Table dataSource={dataSource}>
        <Table.Column dataIndex="name" title="姓名"/>
        <Table.Column dataIndex="age" title="年龄"/>
        <Table.Column dataIndex="gender" title="性别"/>
      </Table>
    </Typography>
  );
}

export default Chapter3_4;

分别使用以下五个常量、变量作为useEffect依赖项,得到的结果如下:

  • 使用普通常量(constValue)作为依赖项,页面只会渲染一次;
  • 使用普通对象(object)作为依赖项,页面无限渲染;
  • 使用普通状态值(refresh),在状态值改变时页面重新渲染;
  • 使用对象状态值(otherObject),页面只会渲染一次;
  • 使用对象状态值(dataSource)并且在useEffect中改变了该对象状态值,页面无限渲染。

根据以上结果可以得到以下的一些建议:

  • 根据useEffect的闭包特性,为了避免获取到过期的值,因此需要将useEffect中使用到的变量作为依赖项;
  • 如果依赖项中有定义的普通对象,useEffect中有使用到了该对象时。请将该对象使用useState定义成状态值。
    • 因为普通对象会在页面渲染时重新定义,其引用地址改变了,会引发useEffect的执行,该行为不可控。
    • 而状态对象在页面重新渲染时,其引用定制不会改变。只会在setState是改变,这个行为是可控的。
  • 如果依赖项中有useState定义的状态对象,useEffect中又有改变该状态值时,请检查自己的逻辑。这种操作逻辑肯定是有问题的。

四、useLayoutEffect

1、useLayoutEffect与useEffect之间的区别

两者之间的区别主要是:两者的执行时机不同。请看下面一个例子:

给useEffect和useLayoutEffect分别添加依赖,为状态值num。点击增加按钮改变状态值num,这将导致DOM改变、页面重新渲染。从而触发useEffect和useLayoutEffect的执行。

function Chapter4_1() {
  const [num, setNum] = useState<number>(0)

  useEffect(() => {
    console.log('执行第一个effect')
  }, [num])
  
  useEffect(() => {
    console.log('执行第二个effect')
  }, [num])

  useLayoutEffect(() => {
    console.log('执行layout-effect')
  }, [num])

  const clickHandle = () => {
    setNum(num + 1)
  }

  return (
    <div>
      <p>数字:{num}</p>
      <Button onClick={clickHandle}>增加</Button>
    </div>
  );
}

export default Chapter4_1;

每次点击按钮的打印结果如下所示:

执行layout-effect
执行第一个effect
执行第二个effect

很显然,useLayout执行的优先级比useEffect执行的优先级更高。通过查阅React官放文档可以得到如下结论:

当React函数组件的状态更新时,此时页面重新渲染、该函数组件将再次执行。按照代码顺序一次来到useEffectuseEffectuseLayoutEffect处。由于useEffect的执行时机是在页面重新绘制后,因此它将被React托管,在稍后再执行useEffect中的副作用。而useLayoutEffect是在DOM改变时,触发重渲染,因此在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

  • useLayoutEffect的执行时机是:页面DOM改变时,同步执行,然后重新绘制页面;
  • useEffect的执行时机是:页面DOM改变后,页面重新绘制后,执行副作用;

2、何时使用useLayoutEffect?

首先,useLayoutEffectuseEffect的作用基本是完全一样的,只是两者的执行时机不同。一般情况下应当使用useEffect,因为useLayoutEffect的执行时机是DOM更新时、页面绘制之前,这将阻塞页面的渲染。

除非要修改DOM并且不让用户看到修改DOM的过程,才考虑使用useLayoutEffect。请看下面的一个例子:

function Chapter4_2() {

  const [num, setNum] = useState(0);
  useEffect(() => {
    console.log(`useEffect - count=${num}`)
    // 耗时的操作
    const pre = Date.now();
    while(Date.now() - pre < 1000) {}
    
    // num为0时重新生成个随机数
    if (num === 0) {    
        setNum(10 + Math.random() * 200);
    }
  }, [num]);
  
  const [count, setCount] = useState(0);
  useLayoutEffect(() => {
    console.log(`useLayoutEffect - count=${count}`)
    // 耗时的操作
    const pre = Date.now();
    while(Date.now() - pre < 1000) {}

    if (count === 0) {    
        setCount(10 + Math.random() * 200);
    }
  }, [count]);
  

  return (
    <Typography>
      <Title level={3}>实例1:使用useEffect</Title>
      <p>数字num:{num}</p>
      <Button onClick={() => setNum(0)}>重置</Button>
      <Title level={3}>实例2:使用useLayoutEffect</Title>
      <p>数字count:{count}</p>
      <Button onClick={() => setCount(0)}>重置</Button>
    </Typography>
  );
}

export default Chapter4_2;

该组件要实现这么一个功能:点击按钮重置状态值num为0,num为0时,在1000ms后状态值num随机变为一个非0的值。

  • 使用useEffect处理该副作用时,发现有一个明显的num从0 到 随机值的过程。
  • 使用useLayoutEffect处理该副作用,发现点击重置后在卡顿了1s后页面显示了新的随机值,变0的这个过程没有在页面上显示出来。

五、useReducer

1、使用useReducer代替useState管理状态

useReducer作为useState 的替代方案。它接收一个形如 (state, action) => newState的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。

相比较于useStateuseReducer 具有如下优点:

  • state中的状态值之间相互关联;
  • 下一个 state的更新依赖于之前的 state。

下面通过一个例子来说明如何使用useReducer

功能描述:

可以对一个数字进行不断地赋值,同时记录下历史值;可以通过undo对当前值进行撤销操作,一步步地回到最初值。在进行撤销操作的同时,记录下undo掉的值;通过redo可以回到undo之前的值,不断地redo最终可以回到执行所有撤销操作之前的值。

代码实现:

  • 首先创建reducer。其作用是针对不同的操作类型,改变当前状态值,并将对应的历史记录存储到past、future中。
export type State<T> = {
  past: T[]; // 存放历史值
  present: T; // 当前值
  future: T[]; // 存放undo值,用于取消撤销
}

export type Action<T> = {
  newPresent?: T;
  type: 'UNDO' | 'REDO' | 'SET' | 'RESET';
}

export const undoReducer = <T>(state: State<T>, action: Action<T>) => {
  const { past, present, future } = state;
  const {newPresent} = action
  switch (action.type) {
    case 'UNDO':
      if (past.length === 0) return state;
      const previous = past[past.length - 1];
      const newPast = past.slice(0, past.length - 1);
      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      };
    case 'REDO':
      if (future.length === 0) return state;
      const next = future[0];
      const newFuture = future.slice(1);
      return {
        past: [...past, present],
        present: next,
        future: newFuture,
      };
    case 'SET':
      if (newPresent === present) return state;
      return {
        past: [...past, present],
        present: newPresent,
        future: [],
      };
    case 'RESET':
      return {
        past: [],
        present: newPresent,
        future: [],
      };
    default:
      return state
  }
}
  • 在组件中使用reducer,定义各种点击时间应该dispatch的类型和值。
function Chapter5_1() {
  const [state, dispatch] = useReducer(undoReducer, {
    past: [],
    present: 0,
    future: [],
  } as State<number>)

  const present = state.present as number;

  const canUndo = state.past.length !== 0;
  const canRedo = state.future.length !== 0;

  // 撤销
  const undo = useCallback(() => {dispatch({type: 'UNDO'})}, []);

  // 取消撤销
  const redo = useCallback(() => {dispatch({type: 'REDO'})}, []);

  // 指定为特定值
  const set = useCallback((newPresent: number) => {dispatch({type: 'SET', newPresent: newPresent})}, []);

  // 重置为初始值
  const reset = useCallback(() => {dispatch({type: 'RESET', newPresent: 0})}, []);

  return (
    <div>
      <div>当前值:{present}</div>
      <div style={{ marginTop: 30 }}>
        <Button onClick={undo} disabled={!canUndo} style={{ marginRight: 15 }}>
          撤销
        </Button>
        <Button onClick={redo} disabled={!canRedo} style={{ marginRight: 15 }}>
          恢复撤销
        </Button>
        <Button
          onClick={() => {
            set(present + 1);
          }}
          style={{ marginRight: 15 }}
        >
          增加
        </Button>
        <Button
          onClick={() => {
            set(present - 1);
          }}
          style={{ marginRight: 15 }}
        >
          减少
        </Button>
        <Button onClick={reset}>重置</Button>
      </div>
    </div>
  );
}

export default Chapter5_1;

可以看到针对复杂的状态并且状态之间相互关联时时,使用useReducer比useState具有很大的优势

2、useReducer如何diapstch函数

useReducer的设计理念与redux是相一致的,即数据改变必须可控。因此reducer中返回的必须是plian object。

但是在开发中,我们要用到的状态大多数都是从后台获取的。当然你可以在接口返回数据后,再dispatch接口数据,但是这样的话,代码耦合度就很高了,这样组件就偏离了专注于渲染!

在redux中dispatch异步函数的解决方案有redux-thunk,其代码非常的短小精悍。其原理就是拦截action,如果是函数,就执行该函数,再将该函数其返回值action传入dispatch。

// redux-thunk源码
function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;
  • 仿照redux-thunk,我们可以给useReducer进行一次封装。
import { useReducer } from "react";
import { Action, State } from "./reducer";

const thunkDispatch = (dispatch: React.Dispatch<Action>, getState: () => 
  {
    loading: boolean;
    dataSource: any;
  }
) => {
  return (action: any) => {
    if (typeof action === 'function') {
      action(thunkDispatch(dispatch, getState), getState);
    } else {
      dispatch(action);
    }
  };
};

export const useThunkReducer = (reducer: (state: State, action: Action) => 
  {
    loading: boolean;
    dataSource: any;
  }, 
  defaultState: State
) => {
  const [state, dispatch] = useReducer(reducer, defaultState);
  const getState = () => state;
  const newDispatch = thunkDispatch(dispatch, getState);
  return [state, newDispatch] as const
};

export const getDataSource = () => {
  return (dispatch: React.Dispatch<Action>) => {
    setTimeout(() => {
      dispatch({
        type: 'SUCCESS',
        data: [
          {key: 1, name: '小明', age: Math.ceil(Math.random()* 30), gender: 'male'},
          {key: 2, name: '小花', age: Math.ceil(Math.random()* 30), gender: 'female'},
          {key: 3, name: '小张', age: Math.ceil(Math.random()* 30), gender: 'male'},
          {key: 4, name: '小李', age: Math.ceil(Math.random()* 30), gender: 'female'},
        ]
      })
    }, 1000);
  }
}
  • 定义reducer和获取接口数据的请求函数
export type State = {
  loading: boolean; // 加载状态
  dataSource: any[]
}

export type Action = {
  data?: any[];
  type: 'START' | 'SUCCESS';
}

export const fetchDataReducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'START':
      return {
        ...state,
        loading: true,
      }
    case 'SUCCESS':
      return {
        loading: false,
        dataSource: action.data
      };
    default:
      return state
  }
}

export const getDataSource = () => {
  return (dispatch: React.Dispatch<Action>) => {
    setTimeout(() => {
      dispatch({
        type: 'SUCCESS',
        data: [
          {key: 1, name: '小明', age: Math.ceil(Math.random()* 30), gender: 'male'},
          {key: 2, name: '小花', age: Math.ceil(Math.random()* 30), gender: 'female'},
          {key: 3, name: '小张', age: Math.ceil(Math.random()* 30), gender: 'male'},
          {key: 4, name: '小李', age: Math.ceil(Math.random()* 30), gender: 'female'},
        ]
      })
    }, 1000);
  }
}
  • 在组件中使用useThunkReducer来dispatch异步函数
function Chapter5_2() {
  const [state, dispatch] = useThunkReducer(fetchDataReducer, {
    dataSource: [],
    loading: false
  })

  const { loading, dataSource } = state as State

  const clickHandle = () => {
    // 请求开始前,先将加载状态置为true
    dispatch({
      type: 'START',
      dataSource: []
    })
    // 直接将请求接口的函数传给dispatch
    dispatch(getDataSource())
  }

  return (
    <div>
      <Button onClick={clickHandle}>刷新</Button>
      <Table dataSource={dataSource} loading={loading}>
        <Table.Column dataIndex="name" title="姓名"/>
        <Table.Column dataIndex="age" title="年龄"/>
        <Table.Column dataIndex="gender" title="性别"/>
      </Table>
    </div>
  );
}

export default Chapter5_2;

六、useContext

1、useContext的使用

useContext的作用是将该组件的状态值在所有其子组件之间实现共享。通过一个例子来说明如何使用useContext。

  • 首先通过createContext创建一个句柄,它是状态在子组件之间共享的核心
import React, { createContext } from "react";
export const Context = createContext<{
  num: number;
  setNum: React.Dispatch<React.SetStateAction<number>>;
}>({ num: 0, setNum: () => {} })
  • 在父组件中使用Context.Provider将所有子组件包裹,并通过value提供共享的状态值;
function Chapter5_1() {
  const [num, setNum] = useState(0);

  return (
     <Context.Provider value={{num, setNum}} >
       <div style={{ padding: 10, border: '1px solid #000' }}>
         <h1>父组件</h1>
         <p>数字num:{num}</p>
         <ChildOne />
         <ChildTwo />
       </div>
     </Context.Provider>
  );
}

export default Chapter5_1;
  • 在子组件中通过useContext接受Context传递下来的value值
function ChildOne() {
  const context = useContext(Context)

  return (
    <div style={{ padding: 10, border: '1px solid #000' }}>
      <h2>子组件1</h2>
      <p>该子组件用来显示num值</p>
      <p>数字num:{context.num}</p>
    </div>
  );
}

export default ChildOne;
function ChildTwo() {
  const context = useContext(Context)

  const clickHandle = () => {
    context.setNum(context.num + 1)
  }

  return (
    <div style={{ padding: 10, border: '1px solid #000' }}>
      <h2>子组件2</h2>
      <p>该子组件用来修改num值</p>
      <Button onClick={clickHandle}>增加</Button>
    </div>
  );
}

export default ChildTwo;

父组件通过Context.Provider包裹所有子组件来达到将numsetNum共享给所有的子组件。子组件通过useContext接受Context来获取到numsetNum,这样就可以达到状态在所有子组件中共享并且可以修改该状态的目的。

  • Context的用法非常类似与redux,都是在最上层提供一个Provider。这样下层的组件都可以获取到value(value),从而达到数据共享;
  • 事实上,Context确实是作为状态管理的一种手段。在面对某个组件下的子组件之间的数据管理时,是可以用Context来代替redux的。
  • 但是做全局状态统一管理还是推荐使用redux,因为redux管理全局状态时性能稍好一些。

2、配合useReducer实现redux功能

  • 首先创建一个类似于store的存储状态的地方,并且定义reducer函数。
export type State = {
  num: number
}

export type Action = {
  data?: any[];
  type: 'ADD' | 'REDUCE';
}

export const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'ADD':
      return {
        num: state.num + 1
      }
    case 'REDUCE':
      return {
        num: state.num - 1
      };
    default:
      return state
  }
}
  • 在顶层组件中使用createContext创建Context,包裹子组件;并定义useReducer,通过Context.Provider将state和dispatch分发该下层的所有子组件
export const Context = createContext<{
  state: { num: number };
  dispatch: React.Dispatch<React.SetStateAction<any>>;
}>({ state: {num: 0}, dispatch: () => {} })

function Chapter6_2() {
  const [state, dispatch] = useReducer(reducer, { num: 0 });

  return (
		<Context.Provider value={{state, dispatch}} >
       <div style={{ padding: 10, border: '1px solid #000' }}>
         <h1>父组件</h1>
         <p>数字num:{state.num}</p>
         <ChildOne />
         <ChildTwo />
       </div>
     </Context.Provider>  
  );
}

export default Chapter6_2;
  • 在子组件中使用状态state,并可以通过dispatch改变状态值
function ChildOne() {
  const context = useContext(Context)

  const addNum = () => {
    context.dispatch({
      type: 'ADD',
    })
  }

  return (
    <div style={{ padding: 10, border: '1px solid #000' }}>
      <h2>子组件1</h2>
      <p>数字num:{context.state.num}</p>
      <Button onClick={addNum}>增加</Button>
    </div>
  );
}

export default ChildOne;

七、useRef

1、useRef的作用

useRef 返回一个可变的 ref 对象,其ref .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。

这是官方的说法,对于萌新来说可能比较抽象。以下通过比较两种方法实现同一件需求来阐述useRef的意义。

需求说明

在页面第一次加载完毕后,设置一个定时器,该定时器的作用是定期更改num的值,并将该定时器的id通过一个变量保存起来。当num的值大于10的时候,通过取出变量中保存的定时器id值,clearInterval达到清除定时器的作用。最终数字num应当停止在11。

方案一:通过普通变量来存储定时器

代码实现

function Chapter7_1() {
  const [num_1, setNum_1] = useState<number>(0);
  let timer_1: any
  useEffect(() => {
    timer_1 = setInterval(() => {
      setNum_1(num => num + 1)
    }, 500)
  }, [])
  useEffect(() => {
    if (num_1 > 10) {
      console.log(timer_1)
      clearInterval(timer_1)
    }
  }, [num_1])

  return (
    <div>
      <p>数字1:{num_1}</p>
    </div>
  );
}

export default Chapter7_1;

结果分析

最终并没有清除定时器,因此最终数字也没有停留在11。通过观察控制台打印的timer_1,发现打印的结果是undefined。很显然一开始timer_1保存的定时器id在多次的页面渲染后丢失了。

导致这个原因的分析过程如下:首先定义了一个timer值,在页面加载完毕后执行第一个useEffect,在副作用函数中定义了一个定时器,其id是正确赋值给了timer_1的。但是1秒后,定时器触发了回调修改了状态值num_1,而状态值的修改将触发页面的重新渲染。因此React又开始重新执行Chapter7_1函数,当它执行到let timer_1: any这一行时,timer_1的值被重新定义为了undefined。其后的多次重新渲染又重新定义了timer_1为undefined。因此之前保存的定时器id在页面的重新渲染下丢失了。

方案二:通过useRef来存储定时器

改进方案

通过上述代码的分析,我们明白了实现该需求的问题所在。就是如何保存定时器的id,使其在整个生命周期都保持不变。而解决这个问题的方案也不少,其一,可以定义一个state状态来存储定时器id。其二,就是现在所使用的的useRef。它们的值都是实整个生命周期中,不随页面渲染发生改变,只有你手动去更新它。

代码实现

function Chapter7_1() {
  const [num_2, setNum_2] = useState<number>(0);
  const ref = useRef<any>()
  useEffect(() => {
    ref.current = setInterval(() => {
      setNum_2(num => num + 1)
    }, 500)
  }, [])
  useEffect(() => {
    if (num_2 > 10) {
      console.log(ref.current)
      clearInterval(ref.current)
    }
  }, [num_2])

  return (
    <div>
      <p>数字1:{num_2}</p>
    </div>
  );
}

export default Chapter7_1;

2、useRef的特性

useRefref 定义的对象在组件的整个生命周期内持续存在,因此可以用ref.current来存储值。但这里有个疑问,useState也可以存储值,并且在整个生命周期内值也不会改变。那么useRef存在的意义是什么呢?

useRef与useState最大的区别是,useRef是通过ref.current的赋值操作来更改存储的值。并且ref.current的赋值操作是不会触发页面重新渲染的。

在下面这个代码中,使用ref.current来存储要显示的数字,但是每次点击增加按钮(使ref.current+1)时,页面上显示的数字并没有改变(仍为0)。但是查看控制台打印的ref,发现其current值是正确的数字。很显然这验证了ref.current的赋值操作不会触发页面的渲染。点击刷新按钮,更改了页面的状态值refresh,页面重新渲染,此刻页面重新显示了正确的数字。

function Chapter7_2() {
  const ref = useRef<number>(0)
  const add = () => {
    ref.current = ref.current + 1
    console.log('ref', ref)
  }
  
  const [refresh, setRefresh] = useState<boolean>(false)

  const refreshPage = () => {
    setRefresh(!refresh)
  }

  return (
    <div>
      <Title level={2}>useRef的特性</Title>
      <p>数字:{ref.current}</p>
      <Button onClick={add} style={{marginRight: 15}}>增加</Button>
      <Button onClick={refreshPage}>刷新</Button>
    </Typography>
  );
}

export default Chapter7_2;

当然,在上面这个案例中应当使用useState来存储数值,这样才能达到我们的预期。但是实际开发中,我们存储的有些值并不用显示在页面上,只是单纯的存储,在某个时间中拿到存储的值即可。在这种情况下,使用useRef更有优势,因为使用useState在改变存储的值时,会导致一些不必要的重新渲染

八、useImperativeHandle

1、React.forwardRef

useImperativeHandle 应当与 forwardRef 一起使用。因此首先我们来研究一下React.forwardRef的作用。

React.forwardRef 会创建一个React组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中。这种技术并不常见,但在以下两种场景中特别有用:

  • 转发 refs 到 DOM 组件
  • 在高阶组件中转发 refs

需求分析

通过以下的例子来说明,定义一个子组件Child,它包裹有一个输入框Input。在父组件中定义一个按钮点击事件,在该事件中去触发子组件Child中的Input组件的focus(聚焦)事件。

解决方案是要在父组件拿到子组件中的Input组件的ref,这样就可以获取到Input的DOM,该DOM上有Input的focus(聚焦)事件。这样在父组件中的按钮点击事件中调用ref.currenr.focus即可达到目的。

因为该Input组件被包裹在子组件Child中,因此需要React.forwardRef作为中间的转发层。将父组件中定义的ref透传到子组件Child中的Input组件,这样就可以在父组件中拿到Input组件的引用。

代码实现

  • 父组件
function Chapter8_1() {
  const ref = React.createRef<any>()

  const focus = () => {
    console.log(ref)
    ref.current.focus()
  }

  return (
    <div>
      <Button onClick={focus}>聚焦子组件Input</Button>
      <Child ref={ref} />
    </div>
  );
}

export default Chapter8_1;
  • 子组件
const Child = React.forwardRef((props, ref: any) => {

  return (
    <div style={{ padding: 10, border: '1px solid #000' }}>
      <h2>子组件</h2>
      <Input ref={ref} />
    </div>
  );
})

export default Child;

2、useImperativeHandle的作用

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。

在上一个仅仅使用React.forwardRef,父组件只是通过拿到了子组件Child中的Input组件的DOM。当然我们的欲望不止于此,很多时候我们还想在父组件中通过ref获得子组件中的状态和定义的方法。这个时候useImperativeHandle 就派上用场了

  • 子组件

该子组件中定义了addTagsetTag两个方法。这两个方法需要在父组件中去调用,传参并改变要显示的标签。因此在子组件中通过useImperativeHandle 将这两个方法暴露出去。

import React, { forwardRef, useImperativeHandle, useState } from "react"; 
import { Tag } from "antd";

type Value = { id: any; label: string }[];
interface MultipleTagProps {
  initialValue?: Value; // 初始值
  onChange?: (value: Value) => void;
}

const MultipleTag = forwardRef((props: MultipleTagProps, ref) => {
  const { initialValue, onChange } = props;
  const [array, setArray] = useState<Value>(initialValue || []);

  // 删除标签
  const deleteTag = (id: any) => {
    const newArray = array.filter((item) => item.id !== id);
    setArray(newArray);
    onChange?.(newArray);
  };

  // 添加标签
  const addTag = (params: Value) => {
    const tmp = [...array];
    params.forEach((item) => {
      const isExit = tmp.find((thing) => thing.id === item.id);
      if (!isExit) {
        tmp.push(item);
      }
    });
    setArray(tmp);
    onChange?.(tmp);
  };

  // 设置标签
  const setTag = (params: Value) => {
    setArray(params);
    onChange?.(params);
  };

  // 使用 ref 时自定义暴露给父组件的实例值。
  useImperativeHandle(ref, () => ({
    value: array,
    addTag: (params: Value) => {
      addTag(params);
    },
    setTag: (params: Value) => {
      setTag(params);
    }
  }));

  return (
    <div style={{ width: 300, border: "1px solid #000", display: "flex", justifyContent: "center", alignItems: "center", minHeight: 40 }} >
        {array.map((item) => (
          <Tag
            closable
            key={item.id}
            onClose={() => deleteTag(item.id)}
          >
            {item.label}
          </Tag>
        ))}
    </div>
  );
});

export default MultipleTag;
  • 父组件

子组件通过ref中暴露的addTag、setTag方法,在父组件中直接通过ref.current.addTag()ref.current.setTag(),可达到调用子组件方法的目的。

import { Button, Divider, Select} from "antd";
import React, { useRef }  from "react";
import MultipleTag from "./MultipleTag";

const dataSource = [
  { label: "小花", value: 1 },
  { label: "小明", value: 2 },
  { label: "小张", value: 3 },
  { label: "老王", value: 4 }
]

function Chapter8_2() {
  const ref = useRef<any>()

  // 添加标签
  const addTag = (value: number, options: any) => {
    const val = { label: options.label, id: options.value }
    ref.current.addTag([val])
  }

  // 重置标签
  const setTag = () => {
    ref.current.setTag([{ label: "小花", id: 1 }])
  }

  return (
    <div>
      <Select options={dataSource} onChange={addTag} placeholder="请选择要添加的标签" style={{width: 200}}/>
      <Button onClick={setTag} style={{marginLeft: 15}}>重置</Button>
      <Divider />
      <MultipleTag initialValue={[{ label: "小花", id: 1 }]} ref={ref}/>
    </div>
  );
}

export default Chapter8_2;

九、useMemo

1、useMemo的作用

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

这句话是官方对于useMemo的解释。简而言之,就是某个值的计算与某些状态有关时,可以使用useMemo只在该依赖状态改变时重新计算,从而减少不必要的计算开销。看下面的一个例子。

该组件定义一个状态数字num将它显示在页面上,再定义另一个数字multipleNum为num的随机倍数也将它显示在页面上;如果使用普通函数计算multipleNum(再该函数中写一个for循环模拟高开销计算。

function Chapter9_1() {
  const [num, setNum] = useState<number>(0);
  const [refresh, setRefresh] = useState<boolean>(false)

  const multipleNum = () => {
		// 模拟耗性能的开销
		for (let i = 0; i < 999999999; i++) {
      const element = i
    }
    return num * Math.floor(Math.random() * 10 + 1);
  }

  return (
    <div>
      <p>数字num: {num}</p>
      <p>多倍数字:{multipleNum()}</p>
      <Button onClick={() => setNum(num => num + 1)} style={{ marginRight: 15 }}>增加</Button>
      <Button onClick={() => setRefresh(!refresh)}>刷新</Button>
    </diiv>
  );
}

export default Chapter9_1;

发现在每次页面渲染时都会计算multipleNum。由于该函数中有个模拟高开销的计算,因此每次点击刷新按钮都会导致页面卡顿一下。显然无关状态的更新(refresh)不应该去计算multipleNum。

使用useMemo可以解决这个问题。其思想与Vue中的compute比较类似。

function Chapter9_1() {
  const [num, setNum] = useState<number>(0);
  const [refresh, setRefresh] = useState<boolean>(false)

  const multipleNum = useMemo(() => {
    // 模拟耗性能的开销
    for (let i = 0; i < 999999999; i++) {
      const element = i
    }
    return num * Math.floor(Math.random() * 10 + 1);
  }, [num])

  return (
    <div>
      <p>多倍数字:{multipleNum}</p>
      <Button onClick={() => setNum(num => num + 1)} style={{ marginRight: 15 }}>增加</Button>
      <Button onClick={() => setRefresh(!refresh)}>刷新</Button>
    </div>
  );
}

export default Chapter9_1;

使用了useMemo后,它只会依赖项num更新时才去重新执行useMemo中的代码,它返回一个memoized 值。并将该值作为multipleNum存储。

这样执行高开销的计算时,只会在依赖项更新时才会卡顿一下,而不是每次页面渲染都卡顿。这样就达到了优化的目的。

2、useMemo的依赖项问题

useMemo的依赖项与useEffect完全一致,如果该值只需在页面加载完毕时执行一次,这时可以传入空数组。如果每次页面渲染都需要重新计算该值,就不写依赖项,但这样也失去了使用useMemo的意义。

十、useCallback

1、useCallback的作用

useCallback的与useMemo大致相同。useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。返回一个 memoized 回调函数。

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

请看下面一个例子:

  • 父组件
function Chapter10_1() {
  const [num, setNum] = useState<number>(0);
  const [refresh, setRefresh] = useState<boolean>(false)

  // const addNum = () => {
  //   setNum(num + 1)
  // }

  const addNum = useCallback(() => {
    setNum(num + 1)
  }, [num])
  
  return (
    <div>
      <div style={{ padding: 10, border: '1px solid #000' }}>
        <h2>父组件</h2>
        <p>数字num: {num}</p>
        <Button onClick={() => setRefresh(!refresh)}>刷新</Button>
        <Child addNum={addNum}/>
      </div>
    </div>
  );
}

export default Chapter10_1;
  • 子组件
function Child(props: {addNum: () => void}) {
  const { addNum } = props

  useEffect(() => {
    console.log('函数重新定义了')
  }, [addNum])

  return (
    <div style={{ padding: 10, border: '1px solid #000' }}>
      <h2>子组件</h2>
      <Button onClick={addNum}>增加</Button>
    </div>
  );
}

export default Child;

在父组件中定义一个方法通过props传给子组件,在子组件中监视该函数。现在分别使用普通函数和useCallback优化的函数两种方式传值。点击刷新按钮,结果普通函数在每次页面重新渲染时都会重新定义;而useCallback优化的函数只在依赖项更新时重新定义。

💡 注意,上述代码中的依赖项num不可轻易省去
const addNum = useCallback(() => {
	setNum(num + 1)
}, [])

上述代码是存在问题的,每次点击增加按钮,页面都会显示1。导致此问题的原因是useCallback一样使用了闭包机制。在依赖项改变时,重新定义函数,获取到的num值是函数定义时的num(0)。改代码只在页面加载完毕的时候定义一次addNum函数,并将当时的num值保存下来。之后每次执行addNum函数时,setNum(num + 1)中的num永远是0,而不是最新的状态值1。

但是添加num的依赖后,每次num更新时都会重新定义一次addNum,很显然这是不必要的,因为这个只需要在页面加载时仅定义一次是最合理的。解决这个问题可以采用setState的函数式更新,代码如下:

const addNum = useCallback(() => {
	setNum(num => num + 1)
}, [])

2. React.memo

useCallback的作用只是避免函数不必要的重新定义,并不能控制函数的执行。那么useCallback有什么应用场景呢?

官网对它的解释是:当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

这里所说的引用相等性去避免非必要渲染除了它说的shouldComponentUpdate外,还有React.memo。而useCallback可以结合React.memo一起使用来达到性能优化。先来介绍一下React.memo的作用。

总所周知,React每次父组件重新渲染时,都会导致其子组件一起重新渲染,而React.memo就是为了解决这个问题。在父组件重新渲染时,React.memo优化的子组件会比较父组件传给它的props值是否发生了改变,如果没改变则不进行子组件的渲染。

看下面一个例子

  • 父组件
function Father() {
  const [num, setNum] = useState<number>(0);
  const [refresh, setRefresh] = useState<boolean>(false)
  
  return (
    <div>
      <div style={{ padding: 10, border: '1px solid #000' }}>
        <h2>父组件</h2>
        <p>数字num: {num}</p>
        <Button onClick={() => setRefresh(!refresh)}>刷新</Button>
        <Child num={num}/>
      </div>
    </div>
  );
}

export default Father;
  • 子组件
const Child = React.memo((props: {num: number}) {
  const { num } = props

  console.log('子组件重新渲染')
  return (
    <div style={{ padding: 10, border: '1px solid #000' }}>
      <h2>子组件</h2>
      <p>数字:{num}</p>
    </div>
  );
})

export default Child;

父组件将状态值num传给经过React.memo优化后的子组件。该子组件只会在num改变时重新渲染(在控制台输出日志),而点击刷新按钮改变refresh值并不会导致子组件重新渲染。

3、useCallback的优化

但是传递给子组件的props里有函数的话,这情形不一样了。

  • 父组件
function Chapter10_1() {
  const [num, setNum] = useState<number>(0);
  const [refresh, setRefresh] = useState<boolean>(false)

  const addNum = () => {
	  setNum(num + 1)
  }
  
  return (
    <div>
      <div style={{ padding: 10, border: '1px solid #000' }}>
        <h2>父组件</h2>
        <p>数字num: {num}</p>
        <Button onClick={() => setRefresh(!refresh)}>刷新</Button>
        <Child addNum={addNum}/>
      </div>
    </div>
  );
}

export default Chapter10_1;
  • 子组件
const Child = React.memo((props: {addNum: () => void}) {
  const { addNum } = props

 console.log('子组件重新渲染')

  return (
    <div style={{ padding: 10, border: '1px solid #000' }}>
      <h2>子组件</h2>
      <Button onClick={addNum}>增加</Button>
    </div>
  );
})

export default Child;

点击刷新按钮,子组件也将重新渲染。因为React.memo对于对象只进行引用地址的比较。而父组件得重新渲染必定导致普通函数的重新定义,引用地址当然也就改变了。React.memo就失去了意义。

解决上面问题的方法当然是使用useCallback。只在必要的时候重新定义函数。

const addNum = useCallback(() => {
  setNum(num => num + 1)
}, [])

十一、Custom Hook

1、Hook 使用规则

  • 只能在函数最外层调用 Hook。不要在循环、条件判断或者子函数中调用。
  • 只能在 React 的函数组件中调用 Hook。不要在其他 JavaScript 函数中调用。(还有一个地方可以调用 Hook —— 就是自定义的 Hook 中,我们稍后会学习到。)
💡 为什么只能在函数最外层调用hook?

Hook 规则 - React

2、实例1:自定义一个useArray hook(如何写一个自定义hook)

遵循React的规则,使用React提供的基础hook,自定义一个useArray hook。该hook封装了对数组的操作,在其他地方可以方便的调用这个hook来完成对数组的操作。

功能说明

页面用来展示一组列表数据,页面上有添加、移除、清除三个按钮。这些按钮的作用分别是往列表数据里添加一条新的数据;移除现有列表数据中某个位置上的数据;清除现有列表中的所有数据。

实现原理是定义一个数组状态(使用useState创建)用来存储列表数据。并定义一些方法对列表数据进行添加、移除、清除等操作。将数组状态和这些方法用custom hook封装,通过return返回。其他组件通过调用该hook获得要展示的列表数据和操作这些数据的方法。

实现代码

  • useArray hook的实现
import { useCallback, useState } from 'react';

const useArray = <T>(val: T[]) => {
  const [value, setValue] = useState(val);

  // 清空列表
  const clear = useCallback(() => {
    setValue([]);
  }, [])

  // 移除列表特定位置元素
  const removeIndex = useCallback((index: number) => {
    setValue(state => {
      const temp = [...state]
      temp.splice(index, 1);
      return temp
    });
  }, [])

  // 添加元素
  const add = useCallback((thing: T) => {
    setValue(state => {
      const temp = [...state]
      temp.push(thing)
      return temp
    });
  }, [])
  return {
    value,
    clear,
    removeIndex,
    add,
  };
};

export default useArray;
  • 在页面中使用custom hook
import { Button } from 'antd';
import React from 'react';
import useArray from './useArray';

function Chapter11_1() {
  const persons: { name: string; age: number }[] = [
    { name: "小花", age: 25 },
    { name: "小张", age: 22 }
  ];
  const { value, clear, removeIndex, add } = useArray(persons);

  return (
    <div style={{ fontFamily: 'sans-serif', textAlign: 'center'}}>
      <Button
        style={{ marginRight: "50px" }}
        onClick={() => add({ name: "小明", age: 1 + Math.round(Math.random() * (100 - 1)) })}
      >
        添加
      </Button>
      <Button style={{ marginRight: "50px" }} onClick={() => removeIndex(0)}>
        移除首位
      </Button>
      <Button style={{ marginBottom: "50px" }} onClick={() => clear()}>
        清空
      </Button>
      {value.map((person, index) => (
        <div style={{ marginBottom: "30px" }}>
          <span style={{ color: "red", marginRight: "20px" }}>{index}</span>
          <span>{person.name}</span>
          <span>{person.age}</span>
        </div>
      ))}
    </div>
  );
}

export default Chapter11_1;

注意事项

  • 自定义hook一定要使用use开头的命名规范,否则会直接报错。因为自定义hook通常也会使用其他hook,如果使用了其他hook的函数,则它本身也是一个hook。因此必须符合hook的命名规范
  • 不要在回调函数中调用hook,应该把需要用到的回调方法通过hook返回,然后在需要用到的函数组件最外层获取该方法,这样就可以在回调函数中调用该方法。
  • 自定义hook中定义的方法建议使用useCallback包裹再返回。使用useCallback包裹的方法不会无缘无故的重复定义
💡 为什么推荐使用useCallback定义hook中的方法?

useCallback定义的方法不会在页面渲染时重新定义。它的好处有以下两点:

  1. 比如在调用cutom hook的页面中useEffect里使用了cutom hook定义的方法,则eslint会提示你将该方法添加到依赖项中(对于该cutom hook的使用者而非编写者很可能就会将它添加到依赖项)。如果useEffect中的副作用导致了页面重新渲染了的话,则该方法会被重新定义再一次调用useEffect,最终导致页面无限渲染。如果使用useCallback的话则会避免这个问题。
  2. 比如在调用cutom hook的页面中使用了该hook返回的方法,并将该方法以props的形式传给了它的子组件。如果该子组件使用了React.memo进行了优化,则该页面重新渲染时会导致cutom hook的该方法重新定义。这个时候React.memo比较该方法发现改变了,因此子组件也会重新渲染,这样就失去了React.memo使用的初衷(不随父页面的渲染而跟着一起渲染)。同样的,使用useCallback的话则会避免这个问题。

3、实例2:自定义useQuery请求数据(自定义hook使用场景)

在项目开发中,经常会碰到在多个组件中使用到了相同的代码逻辑。这个相同的代码逻辑是指相同的状态,以及与这个状态相关的一系列方法,这个时候就可以使用custom hook来封装这部分逻辑。

功能说明

在多个页面中都进行了相同的接口请求,现在要将这个接口返回的数据展示在不同的页面上,并且每个页面单独对这些数据进行清除操作。此时自定义一个useQuery来完成。

代码实现

  • useQuery hook的实现
import { useCallback, useState } from "react";

const useQuery = () => {
  const [dataList, setDataList] = useState<Array<any>>([]);
  const [loading, setLoading] = useState<boolean>(false)

  // 请求数据
  const mutate = useCallback(() => {
    // 模拟数据请求
    setLoading(true)
    setTimeout(() => {
      setDataList([
        {key: 1, name: '小明', age: Math.ceil(Math.random()* 30), gender: 'male'},
        {key: 2, name: '小花', age: Math.ceil(Math.random()* 30), gender: 'female'},
        {key: 3, name: '小张', age: Math.ceil(Math.random()* 30), gender: 'male'},
        {key: 4, name: '小李', age: Math.ceil(Math.random()* 30), gender: 'female'},
      ])
      setLoading(false)
    }, 1000);
  }, [])

  // 清除数据
  const clear = useCallback(() => {
    setDataList([])
  }, [])

  return {
    dataList,
    loading,
    mutate,
    clear
  }
};

export default useQuery;
  • 在不同的页面中使用数据,并进行不同的操作
import { Button, Table } from 'antd';
import React from 'react';
import useQuery from './useQuery';

function PageTwo() {
  const {dataList, loading, mutate, clear} = useQuery()
  return (
    <div>
      <Button onClick={mutate}>获取数据</Button>
      <Button onClick={clear}>清除数据</Button>
      <Table dataSource={dataList} loading={loading}>
        <Table.Column dataIndex="name" title="姓名"/>
        <Table.Column dataIndex="age" title="年龄"/>
        <Table.Column dataIndex="gender" title="性别"/>
      </Table>
    </div>
  );
}

export default PageTwo;
import { Button, Table } from 'antd';
import React from 'react';
import useQuery from './useQuery';

function PageOne() {
  const {dataList, loading, mutate, clear} = useQuery()
  return (
    <div>
      <Button onClick={mutate}>获取数据</Button>
      <Button onClick={clear}>清除数据</Button>
      <Table dataSource={dataList} loading={loading}>
        <Table.Column dataIndex="name" title="姓名"/>
        <Table.Column dataIndex="age" title="年龄"/>
        <Table.Column dataIndex="gender" title="性别"/>
      </Table>
    </div>
  );
}

export default PageOne;

这里只是一个简单地实例。在实际开发中,需要从请求的数据,比如Select组件下拉框数据列表可能会在多处使用。而且针对请求过来的这个数据还有一些后续操作,比如将下拉框中的第一个数据进行默认值填入到Select中,这个时候就可以像上面那样写一个custom hook。

💡 与状态管理工具Redux的区别

通过这个例子发现,使用custom hook管理状态似乎与redux相似。但他们有着本质的区别,redux这类状态管理工具是全局状态;而使用hook的状态在不同的组件中使用,相互之间是互不影响。实际开发中根据需求进行选择。

4、应用:用useSelectedMenu

在文档的收官部分,再来看一个在本项目使用到自定义hook的例子。

捕获.PNG.png

如果你之前有细心的话,就会发现。这个项目的Title(上图中标黄的部分)会随着页面的跳转,文字会自动更新到与左边的选中的菜单中的文字相同。那么这个是怎么实现的呢?是在每个页面上写上该文字吗?这种做法当然可行,但是如果今后想修改文字的话,则需要在两处都修改。显然,这种做法并不优雅!

如果想做到只修改一处代码,使得菜单和Title部分的文字都同步更新,则可以试一试custom hook!

  • 首先需要定义一个配置项,该配置项包含路由、菜单文字等相关信息。代码见**/src/consts/menu.ts**
export const menu = [
  {
    label: "1. React基本简介",
    key: "1",
    children: [
      {
        label: "1.1 React的特点",
        key: "1-1",
        path: "/abstract/feature",
        component: Chapter1_1,
      },
      {
        label: "1.2 React-Hook的历史",
        key: "1-2",
        path: "/abstract/history",
        component: Chapter1_2,
      }
    ]
  },
  {
    label: "2. useState",
    key: "2",
    children: [
      {
        label: "2.1 useState的基本用法",
        key: "2-1",
        path: "/useState/base",
        component: Chapter2_1
      },
      {
        label: "2.2 setState是同步还是异步",
        key: "2-2",
        path: "/useState/sync",
        component: Chapter2_2
      },
      {
        label: "2.3 useState的函数式更新",
        key: "2-3",
        path: "/useState/func-update",
        component: Chapter2_3
      },
      {
        label: "2.4 useState的惰性初始化",
        key: "2-4",
        path: "/useState/lazy",
        component: Chapter2_4
      }
    ]
  },
 /** other codes */
]
  • 自定义一个hook,该hook根据当前页面的路由路径,去配置项里找到对应的文字,并将其以变量title返回。代码见**/src/utils/useSelectedMenu.ts**
import { useEffect } from "react"
import { useLocation } from "react-router"
import { useImmer } from "use-immer"
import { menu } from "../consts/menu"

interface StateProps {
  selectedKeys: string[];
  openKeys: string[];
  title: string
}

export const useSelectedMenu = () => {
  const [state, setState] = useImmer<StateProps>({
    selectedKeys: [],
    openKeys: [],
    title: ''
  })
  const {pathname} = useLocation()

  useEffect(() => {
    menu.forEach(item => {
      item.children.forEach(child => {
        if (child.path === pathname) {
          setState(state => {
            state.openKeys = [item.key]
            state.selectedKeys = [child.key]
            state.title = child.label
          })
        }
      })
    })
  }, [pathname])

  return {
    selectedKeys: state.selectedKeys,
    openKeys: state.openKeys,
    title: state.title
  }
}
  • 在PageHeader组件中直接使用custom hook获取到title值即可,代码见**/src/components/PageHeader.ts**
import { useSelectedMenu } from "../utils/useSelectedMenu";

const PageHeader = () => {
  const { title } = useSelectedMenu();
  return (
    <div style={{ marginLeft: 25 }}>
      <h1 style={{ color: "#fff" }}>{title}</h1>
    </div>
  );
};

export default PageHeader;