React 16.8 之 React Hook

1,021 阅读11分钟

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

这是react官网对react hook 的一句话简介。简而言之,在我看来就是让函数式组件(无状态组件)可以拥有自己的状态(state)以及改变状态的方法(setState)

React Hook 为何而存在

  1. 在组件之间复用状态逻辑很难

虽然我们可以用render props 和高阶组件来解决这一问题,但是!但是! 这不仅会容易形成“嵌套地狱”,还会面临一个很尴尬的问题(相对于我这种新手菜鸟来说) 那就是复杂逻辑难写!简单逻辑我宁可重写一遍都觉得比用高阶组件要快要方便(毕竟可以复制粘贴嘛)。

  1. 复杂组件难以理解

各种生命周期函数内充斥着各种状态逻辑处理和副作用,且难以复用、零散,比如一个调用列表数据的接口方法getList分别要写到componentDidMount 和componentDidUpdate中等等。

  1. 难以理解的class

this的指向问题(经常在某一处忘记bind(this)然后bug找半天)、组件预编译技术(组件折叠)会在class中遇到优化失效的case(还并不了解,先写上~)、class不能很好的压缩、class在热重载时会出现不稳定的情况。

所以!有了 react hook 就不用写class了,组件都可以用function来写,不用再写生命周期钩子,最关键的,不用再面对this了!

State Hook

首先我们先看下官方给出的简单例子

这是一个简单的累加计数器,我们先看class声明的组件

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  render() {
    return (
      <div>
        <p>你点击了{this.state.count}次</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          点我
        </button>
      </div>
    );
  }
}

这是一个非常非常简单的组件了,相信了解过react的人都能够看懂

那么我们再看一下使用hook的版本

import React, { useState } from 'react';

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

  return (
    <div>
      <p>你点击了{count}次</p>
      <button onClick={() => setCount(count + 1)}>
        点我
      </button>
    </div>
  );
}

是不是hook要简单了一点?可以看到,这是一个函数,与以往不同的是它拥有了自己的状态(count),就像class中的this.state.count;同时它还可以通过setCount()来更新自己的状态,就像class中的this.setState()。

为什么会这样呢? 仔细看第一行我们引入了useState这个hook,就是这个hook让我们的无状态组件成为了一个有状态的组件。

状态值的声明、读取、更新

那么我们分解一下看看到底这个hook都为我们做了什么

const [count, setCount] = useState(0);

首先useState的作用就是来声明状态变量,这个函数接收的参数是我们要为变量赋予的初始值,它返回了一个数组,索引[0]是当前的状态值,[1]是用来更新这个状态值的方法

所以这一句其实就是声明了一个count变量,就好比

this.state = {
      count: 0
    };

并且通过useState(0)传入参数0来为count赋了一个初始值0,同时提供了一个像this.setState一样可以更新它的方法setCount

然后我们引用读取这个变量的时候直接 {count}就好了,不用再this.state.count 这么长了!

<p>你点击了{count}次</p>

当我们想更新这个值的时候,直接调用setCount(新的值)就可以了。

怎么样,是不是很简单很容易理解?

但是,但是! 诶? 不对啊,这个函数是怎么记住之前的状态的? 通常来说我们在函数中声明一个变量,函数运行完也就跟着销毁了,重复调用的时候会重新声明,那这个Example函数是怎么做到记住之前声明的状态变量的?

State Hook 解决存储持久化的方案

我所知道的可以通过js存储持久化状态的方法有: class类、全局变量、DOM、闭包

通过学习react hook源码解析了解到是用闭包实现的,深入的我也看不懂!简单来说首先useState就是这样实现的

function useState(initialState){
    let state = initialState;
    function dispatch = (newState, action)=>{
        state = newState;
    }
    return [state, dispatch]
}


像不像redux? 给定一个初始state,然后通过dispatch一个action,经由reducer改变state,再返回新的state,触发组件的重新渲染。

但是仅仅这样还满足不了要求,我们需要一个新的数据结构来保存上一次的state和这一次的state,以便可以在初始化流程调用useState和更新流程调用useState时可以取到对应的正确值。假定这个数据结构叫Hook:

type Hook = {
  memoizedState: any,   // 上一次完整更新之后的最终状态值
  queue: UpdateQueue<any, any> | null, // 更新队列
};

考虑到第一次组件mounting和后续的updating逻辑差异,定义两个不同的useState函数来实现,分别叫做mountState和updateState

function useState(initialState){
    if(isMounting){
        return mountState(initialState);
    }
    
    if(isUpdateing){
        return updateState(initialState);
    }
}

// 第一次调用组件的 useState 时实际调用的方法
function mountState(initialState){
    let hook = createNewHook();
    hook.memoizedState = initalState;
    return [hook.memoizedState, dispatchAction]
}

function dispatchAction(action){
    // 使用数据结构存储所有的更新行为,以便在 rerender 流程中计算最新的状态值
    storeUpdateActions(action);
    // 执行 fiber 的渲染
    scheduleWork();
}

// 第一次之后每一次执行 useState 时实际调用的方法
function updateState(initialState){
    // 根据 dispatchAction 中存储的更新行为计算出新的状态值,并返回给组件
    doReducerWork();
    
    return [hook.memoizedState, dispatchAction];
}   

function createNewHook(){
    return {
        memoizedState: null,
        baseUpdate: null
    }
}

以上就是基本的实现思路,内容参考自源码解析React Hook构建过程

想深入了解源码原理的请自行跳转~

声明多个state变量

useState是可以多次调用的

function ExampleWithManyStates() {
  // 声明多个 state 变量
  const [age, setAge] = useState(42);
  const [fruit, setFruit] = useState('banana');
  const [todos, setTodos] = useState([{ text: '学习 Hook' }]);
  // ...
}

useState也支持接收对象或者数组作为参数。与this.setState不同的是,this.setState是合并状态后返回一个新的状态,而useState只是直接替换老状态后返回新状态。

Hook 规则

hook本质也是javaScript函数,但是他在使用的时候需要遵循两条规则,并且react要求强制执行这两条规则,不然就会出现奇怪的bug。

1.只能在最顶层使用hook

不要在循环,条件或者嵌套函数中调用hook,确保总是在react函数的最顶层调用,目的是为了确保hook在每一次渲染中都按照同样的顺序被调用,这让react能够在多次的useState和useEffect(另一种hook,下面会说)调用之间保持状态的正确,来保证多个state的相互独立和一一对应的关系。

2.只在react函数中调用hook

不要在普通js函数中调用hook

这里针对第一条举个栗子:

function Form() {
  const [name1, setName1] = useState('zhangsan');
  const [name2, setName2] = useState('lisi');
  const [name3, setName3] = useState('wangwu');
  // ...
}

这里连续使用了三次useState来声明了name1,name2,name3三个状态并且都赋予了初始值,那么在渲染时是这样的

//首次渲染(赋初始值)
useState('zhangsan')       // 1. 使用 'zhangsan' 初始化变量名为 name1 的 state
useState('lisi')           // 2. 使用 'lisi' 初始化变量名为 name2 的 state
useState('wangwu')         // 3. 使用 'wangwu' 初始化变量名为 name3 的 state

//第二次渲染
useState('zhangsan')       // 1. 读取name1的值
useState('lisi')           // 2. 读取name2的值
useState('wangwu')         // 3. 读取name3的值

如果我们这么写!

let tag = true;
function Form() {
  const [name1, setName1] = useState('zhangsan');
  if (tag) {
    const [name2, setName2] = useState('lisi');
    tag = false;
}
  const [name3, setName3] = useState('wangwu');
}

这个过程就会变为

//首次渲染(赋初始值,和上面一样)
useState('zhangsan')       // 1. 使用 'zhangsan' 初始化变量名为 name1 的 state
useState('lisi')           // 2. 使用 'lisi' 初始化变量名为 name2 的 state
useState('wangwu')         // 3. 使用 'wangwu' 初始化变量名为 name3 的 state

//第二次渲染
useState('zhangsan')       // 1. 读取name1的值
//useState('lisi')         // 2. 通过条件判断并没有走读取name2这一步
useState('wangwu')         // 3. 读取name3的值时通过顺序判断读取到的却是name2,导致报错

所以hook规则其实就是确保hook的执行顺序,因为它是通过顺序来完成一一对应关系以及互相独立的!

Effect Hook

官方文档中一句话说明 Effect Hook 可以让你在函数组件中执行副作用操作,可能一句话并不能让人很好的理解它是干嘛用的,我们可以接着看上面那个最简单的计数器的栗子,在它的基础上加一个小功能

function Example() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    //设置浏览器标题内容
    document.title = `你点击了${count} 次`;
  });

  return (
    <div>
      <p>你点击了{count}次</p>
      <button onClick={() => setCount(count + 1)}>
        点我
      </button>
    </div>
  );
}

这段代码增加了一个将document的title设置为包含了点击次数的消息,如果通过class组件,应该怎么写呢?我们对比一下:

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    document.title = `你点击了 ${this.state.count} 次`;
  }

  componentDidUpdate() {
    document.title = `你点击了 ${this.state.count} 次`;
  }

  render() {
    return (
      <div>
        <p>你点击了{this.state.count}次</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          点我
        </button>
      </div>
    );
  }
}

通过对比,可以看出好像effect hook就相当于生命周期的componentDidMount,componentDidUpdate; 其实,我们写的有状态组件,通常都会产生副作用,比如ajax请求,浏览器事件的绑定和解绑,手动修改dom,记录日志等等;副作用还分为需要清除的和不需要清除的,所以effect hook 还相当于一个componentWillUnmount(比如我们有个需求是需要论询向服务器请求最新数据,那么我们就需要在组件卸载的时候来清理掉这个轮询操作)

清除副作用

componentDidMount(){
    //轮询获取数据
    this.getNewData()
}
componentWillUnmount(){
    //组件卸载前清除轮询操作
    this.unGetNewData()
}

我们完全可以在函数式组件中使用effect hook 来清除这个副作用,用法是在effect函数中return一个函数(清除操作)

useEffect(()=>{
    getNewData()
    return function cleanup() {
        unGetNewData()
    }
})

effect中返回一个函数,这是effect可选的清除机制。每个effect都可以返回一个清除函数,看你的需要。

react会在组件卸载的时候执行清除操作。effect在每次渲染的时候都会执行,并且是每次渲染之前都会去执行cleanup来清除上一个effect副作用。

需要注意的是!!!这种解绑模式同componentWillUnmount不一样。componentWillUnmount只会在组件被销毁前执行一次,而effect hook里的函数,每次组件渲染都会执行一遍,包括副作用函数和它return的清理操作

effect的性能优化

大家一看到“每一次”都会执行,首先就会想到跟性能有关的问题,那么其实effect是可以跳过的,同样通过对比class组件来理解effect hook,class组件中使用componentDidUpdate来进行前后逻辑的比较

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    //判断状态值count改变了,再触发副作用操作
    document.title = `你点击了${this.state.count}次`;
  }
}

同样的在effect中,有第二个参数,起同样的作用

useEffect(() => {
  document.title = `你点击了${count}次`;
}, [count]); // 仅在 count 更改时更新

第二个参数为一个数组,如果数组中有多个元素,即使只有一个元素发生了改变,react也会执行effect(即只有数组中所有元素都未变化,react才会跳过这次effect);若为空数组[],则表示该effect只会执行一次(包括副作用和return的清除副作用操作)

自定义Hook

也就是我们最初的目的:逻辑复用! 试想一下,加入我们现在要实现这样一个功能组件,点击button随机切换背景颜色,如图

用我们所熟悉的class组件是这样实现的

import React, { Component } from "react";

export default class ChangeColor extends Component {
  constructor() {
    super();
    this.state = {
      color: "red"
    };
    this.colors = ["red", "bule", "green", "yellow", "black"];
  }

  changeColor() {
    const index = Math.floor(Math.random() * this.colors.length);
    this.setState({ color: this.colors[index] });
  }

  render() {
    return (
      <div>
        <div
          style={{
            width: 400,
            height: 100,
            border: "1px solid #ccc",
            background: this.state.color
          }}
        ></div>
        <button onClick={() => this.changeColor()}>随机切换</button>
      </div>
    );
  }
}

那么用hook是怎么实现的呢

import React, { useState } from "react";

export default function ChangeColor() {
  const colors = ["red", "bule", "green", "yellow", "black"];
  const [color, setColor] = useState("red");
  function changeColor() {
    const index = Math.floor(Math.random() * colors.length);
    setColor(colors[index]);
  }
  return (
    <div>
      <div
        style={{
          width: 400,
          height: 100,
          border: "1px solid #ccc",
          background: color
        }}
      ></div>
      <button onClick={changeColor}>随机切换</button>
    </div>
  );
}

这样看起来两种方法并没有什么大的差别,只是写法上的不同而已。那么问题来了,现在我们同时又有了一个需求,和它很像,但是不是点击切换了,而是定时器自动切换(滑动切换,反正各种切换方法~)。这下class组件直接就傻掉了,没办法复用啊! 里面有点击事件的逻辑和dom啊,我们能够复用的只是切换颜色这个逻辑而已!没办法那就复制粘贴再写一个新的吧,反正也很短。那那那要是很复杂的逻辑呢?怎么办?这时候我们看一哈自定义hook,它可以帮我们解决这个问题,抽离出我们需要复用的逻辑,实现优雅的复用。

首先我们考虑,能够复用的只是切换颜色的逻辑而已,所以我们抽离出的也一定是纯逻辑,也就是说这个自定义hook中不应包含dom

import { useState } from "react";

export default function useRandomColor() {
  const colors = ["red", "bule", "green", "yellow", "black"];
  const [color, setColor] = useState("red");
  function changeColor() {
    const index = Math.floor(Math.random() * colors.length);
    setColor(colors[index]);
  }

  return [color, changeColor];
}

这里我们自定义了一个叫做useRandomColor的hook,返回值为[颜色,改变颜色的方法] 是不是很像useState?这就是一个简单的由我们自定义的一个hook。我们在父组件中调用的时候就像这样

import React from "react";
import useRandomColor from "./changeColor";

export default function ChangeColor() {
  const [color, setColor] = useRandomColor();

  return (
    <div>
      <div
        style={{
          width: 400,
          height: 100,
          border: "1px solid #ccc",
          background: color
        }}
      ></div>
      <button onClick={setColor}>随机切换</button>
    </div>
  );
}

简直可以说是和useState的使用方法一模一样是不是!

需要注意的一点是,自定义hook是一个函数,其名称以 "use" 开头,它的内部可以调用其他的hook(无论是api提供的还是我们自定义的)

自定义 Hook 必须以 “use” 开头吗?必须如此。这个约定非常重要。不遵循的话,由于无法判断某个函数是否包含对其内部 Hook 的调用,React 将无法自动检查你的 Hook 是否违反了 Hook 的规则。

在两个组件中使用相同的 Hook 会共享 state 吗?不会。自定义 Hook 是一种重用状态逻辑的机制(例如设置为订阅并存储当前值),所以每次使用自定义 Hook 时,其中的所有 state 和副作用都是完全隔离的。

还有哪些react提供的hook

这里我只讲述了useState 和 useEffect两个最重要也是最最常用的hook(对于目前阶段的我来说) 其实react还提供了很多hook:

  • useContext
  • useReducer
  • useCallback
  • useMemo
  • useRef
  • useImperativeMethods
  • useMutationEffect
  • useLayoutEffect

深入了解使用方法可跳转:react-1251415695.cos-website.ap-chengdu.myqcloud.com/docs/hooks-… 鉴于我个人的能力有限和不足,这些hook会在日后陆续学习和研究使用方法和场景等等。

参考