React Hooks

Hooks为什么会出现

跨组件复用stateful logic(包含状态的逻辑)十分困难
在hooks出现之前,我们想要复用包含状态的逻辑,可能会用到render props 或者高阶组件,但是这些模式都要求你重新构造你的组件,这可能会非常麻烦。你可以在React DevTool里看到我们的组件被层层叠叠的providers, consumers, 高阶组件, render props, 和其他抽象层包裹。使用Hooks,你可以在将含有state的逻辑从组件中抽象出来,这将可以让这些逻辑容易被测试。同时,Hooks可以帮助你在不重写组件结构的情况下复用这些逻辑。

处理生命周期函数造成的问题
当我们的组件变得越来越大时,会发现各种逻辑在组件中散落的到处都是,很复杂很乱。每个生命周期钩子中都包含了一堆互不相关的逻辑。比如我们常常在componentDidMount 和 componentDidUpdate 中拉取数据,同时compnentDidMount 方法可能又包含一些不相干的逻辑,比如设置事件监听(之后需要在 componentWillUnmount 中清除)。最终的结果是强相关的代码被分离,反而是不相关的代码被组合在了一起。这显然会导致大量错误。为了解决这个问题,Hooks允许您根据相关部分(例如设置订阅或获取数据)将一个组件分割成更小的函数,而不是强制基于生命周期方法进行分割。

解决掉class中this难以被理解的问题
class中this的指向通常难以明确,即使是计算机本身。而hooks是基于函数的,连class都不存在,那就更不谈this了。

Hooks是如何解决问题的

什么是State Hook

state hook解决了状态逻辑复用的问题。

import { useState } from 'react'; 

function Example() {
  // 声明一个名为“count”的新状态变量
  const [count, setCount] = useState(0);

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

在这里, useState是一个钩子(Hook)。我们在一个函数式组件中调用它,为这个组件增加一些内部的状态。React将会在下一次渲染前保存此状态。 useState返回一对值:当前的状态(state value)和一个可以更新状态的函数。你可以在事件处理程序(event handler)中或其他地方调用这个函数。 它与类组件中的this.setState类似,但不能将新旧状态进行合并。(我们在使用状态钩子中展示了一个将useState和this.state进行对比的例子。)

useState唯一的参数就是初始状态(initial state)。在上面的例子中,因为我们的计数器从零开始所以它是0。这里的状态与this.state不同,它不必是一个对象— 如果你想这么做,当然也可以。初始状态参数只在第一次渲染中被使用。

声明多个状态

function ExampleWithManyStates() {
// 声明多个状态变量!
const [age, setAge] = useState(42);
const [fruit, setFruit] = useState('banana');
const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
// ...
}

通过调用useState我们声明了一些状态变量,我们可以使用数组解构语法赋予这些状态变量不同的名字。这些名字不是useState API的一部分。 相反,当你多次调用useState时,React假定你在每一次渲染中以相同的顺序调用它们。我们会在之后再来解释为什么这样可以运行以及在什么时候起作用。

state hook 的注意事项

useState返回的是一个数组,通过数组解构,它起的作用和如下代码是一样的

let _useState = useState(0);
let count = _useState[0];
let setCount = _useState[1];

react是怎么保证多个useState的相互独立的?

在声明多个变量里我们调用了三次useState,每次我们传的参数只是一个值(如42,‘banana’),我们根本没有告诉react这些值对应的key是哪个,那react是怎么保证这三个useState找到它对应的state呢?答案是,react是根据useState出现的顺序来定的。我们具体来看一下:

//第一次渲染
useState(42);  //将age初始化为42
useState('banana');  //将fruit初始化为banana
useState([{ text: 'Learn Hooks' }]); //...

//第二次渲染
useState(42);  //读取状态变量age的值(这时候传的参数42直接被忽略)
useState('banana');  //读取状态变量fruit的值(这时候传的参数banana直接被忽略)
useState([{ text: 'Learn Hooks' }]); //...

假如我们改一下代码:

let showFruit = true;
function ExampleWithManyStates() {
const [age, setAge] = useState(42);

if(showFruit) {
    const [fruit, setFruit] = useState('banana');
    showFruit = false;
}

const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);

这样一来,

//第一次渲染
useState(42);  //将age初始化为42
useState('banana');  //将fruit初始化为banana
useState([{ text: 'Learn Hooks' }]); //...

//第二次渲染
useState(42);  //读取状态变量age的值(这时候传的参数42直接被忽略)
// useState('banana');  
useState([{ text: 'Learn Hooks' }]); //读取到的却是状态变量fruit的值,导致报错

鉴于此,react规定我们必须把hooks写在函数的最外层,不能写在ifelse等条件语句当中,来确保hooks的执行顺序一致。

什么是Effect Hooks?

我们在上一节的例子中增加一个新功能:

import { useState, useEffect } from 'react';

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

// 类似于componentDidMount 和 componentDidUpdate:
useEffect(() => {
    // 更新文档的标题
    document.title = `You clicked ${count} times`;
});

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

我们对比着看一下,如果没有hooks,我们会怎么写?

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

componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
}

componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
}

render() {
    return (
    <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
        Click me
        </button>
    </div>
    );
}
}

我们写的有状态组件,通常会产生很多的副作用(side effect),比如发起ajax请求获取数据,添加一些监听的注册和取消注册,手动修改dom等等。我们之前都把这些副作用的函数写在生命周期函数钩子里,比如componentDidMount,componentDidUpdate和componentWillUnmount。而现在的useEffect就相当与这些声明周期函数钩子的集合体。它以一抵三。
同时,由于前文所说hooks可以反复多次使用,相互独立。所以我们合理的做法是,给每一个副作用一个单独的useEffect钩子。这样一来,这些副作用不再一股脑堆在生命周期钩子里,代码变得更加清晰。

useEffect做了什么?

我们再梳理一遍下面代码的逻辑:

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

useEffect(() => {
    document.title = `You clicked ${count} times`;
});

首先,我们声明了一个状态变量count,将它的初始值设为0。然后我们告诉react,我们的这个组件有一个副作用。我们给useEffecthook传了一个匿名函数,这个匿名函数就是我们的副作用。在这个例子里,我们的副作用是调用browser API来修改文档标题。当react要渲染我们的组件时,它会先记住我们用到的副作用。等react更新了DOM之后,它再依次执行我们定义的副作用函数。
这里要注意几点:
第一,react首次渲染和之后的每次渲染都会调用一遍传给useEffect的函数。而之前我们要用两个声明周期函数来分别表示首次渲染(componentDidMount),和之后的更新导致的重新渲染(componentDidUpdate)。
第二,useEffect中定义的副作用函数的执行不会阻碍浏览器更新视图,也就是说这些函数是异步执行的,而之前的componentDidMount或componentDidUpdate中的代码则是同步执行的。这种安排对大多数副作用说都是合理的,但有的情况除外,比如我们有时候需要先根据DOM计算出某个元素的尺寸再重新渲染,这时候我们希望这次重新渲染是同步发生的,也就是说它会在浏览器真的去绘制这个页面前发生。

useEffect怎么解绑一些副作用

这种场景很常见,当我们在componentDidMount里添加了一个注册,我们得马上在componentWillUnmount中,也就是组件被注销之前清除掉我们添加的注册,否则内存泄漏的问题就出现了。
怎么清除呢?让我们传给useEffect的副作用函数返回一个新的函数即可。这个新的函数将会在组件下一次重新渲染之后执行。这种模式在一些pubsub模式的实现中很常见。看下面的例子:

import { useState, useEffect } from 'react';

function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null);

function handleStatusChange(status) {
    setIsOnline(status.isOnline);
}

useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // 一定注意下这个顺序:告诉react在下次重新渲染组件之后,同时是下次调用ChatAPI.subscribeToFriendStatus之前执行cleanup
    return function cleanup() {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
});

if (isOnline === null) {
    return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}

这里有一个点需要重视!这种解绑的模式跟componentWillUnmount不一样。componentWillUnmount只会在组件被销毁前执行一次而已,而useEffect里的函数,每次组件渲染后都会执行一遍,包括副作用函数返回的这个清理函数也会重新执行一遍。所以我们一起来看一下下面这个问题。

为什么要让副作用函数每次组件更新都执行一遍?

我们先看以前的模式:

componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
    );
}

componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
    );
}

我们在componentDidMount注册,再在componentWillUnmount清除注册。但假如这时候props.friend.id变了怎么办?我们不得不再添加一个componentDidUpdate来处理这种情况:

...
componentDidUpdate(prevProps) {
    // 先把上一个friend.id解绑
    ChatAPI.unsubscribeFromFriendStatus(
    prevProps.friend.id,
    this.handleStatusChange
    );
    // 再重新注册新但friend.id
    ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
    );
}
...

看到了吗?很繁琐,而我们但useEffect则没这个问题,因为它在每次组件更新后都会重新执行一遍。所以代码的执行顺序是这样的:

1.页面首次渲染
2.替friend.id=1的朋友注册

3.突然friend.id变成了2
4.页面重新渲染
5.清除friend.id=1的绑定
6.替friend.id=2的朋友注册
...

怎么跳过一些不必要的副作用函数

按照上一节的思路,每次重新渲染都要执行一遍这些副作用函数,显然是不经济的。怎么跳过一些不必要的计算呢?我们只需要给useEffect传第二个参数即可。用第二个参数来告诉react只有当这个参数的值发生改变时,才执行我们传的副作用函数(第一个参数)。

useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 只有当count的值发生变化时,才会重新执行`document.title`这一句

当我们第二个参数传一个空数组[]时,其实就相当于只在首次渲染的时候执行。也就是componentDidMount加componentWillUnmount的模式。不过这种用法可能带来bug,少用。

怎么写自定义的Effect Hooks?

为什么要自己去写一个Effect Hooks? 这样我们才能把可以复用的逻辑抽离出来,变成一个个可以随意插拔的“插销”,哪个组件要用来,我就插进哪个组件里,so easy!看一个完整的例子,你就明白了。
比如我们可以把上面写的FriendStatus组件中判断朋友是否在线的功能抽出来,新建一个useFriendStatus的hook专门用来判断某个id是否在线。

import { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);

function handleStatusChange(status) {
    setIsOnline(status.isOnline);
}

useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
    ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
});

return isOnline;
}

这时候FriendStatus组件就可以简写为:

function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id);

if (isOnline === null) {
    return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}

假如这个时候我们又有一个朋友列表也需要显示是否在线的信息:

function FriendListItem(props) {
const isOnline = useFriendStatus(props.friend.id);

return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
    {props.friend.name}
    </li>
);
}