深入浅出 React -- Hooks 的动机和概览

515 阅读10分钟

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

  • 完全可选的。 你无需重写任何已有代码就可以在一些组件中尝试 Hook。但是如果你不想,你不必现在就去学习或使用 Hook。
  • 100% 向后兼容的。 Hook 不包含任何破坏性改动。
  • 现在可用。 Hooks 已发布于 v16.8.0。

初识动机

React Hooks 是 React 团队在开发实践中,逐渐认识到的改进点。它背后的思考涉及到类组件函数组件的对比。所以,我们首先需要知道,什么是类组件、函数组件以及两者的比较。

类组件(Class Component)

基于 ES6 Class ,通过继承 React.Component 定义组件。

示例:

// 代码源自 “深入浅出搞定 React -- 修言”

class DemoClass extends React.Component {
  // 初始化类组件的 state
  state = {
    text: ''
  }

  // 编写生命周期方法 didMount
  componentDidMount() {
    // 省略业务逻辑
  }

  // 编写自定义的实例方法
  changeText = (newText) => {
    // 更新 state
    this.setState({
      text: newText
    })
  }

  // 编写生命周期方法 render
  render() {
    return (
      <div className="demoClass">
        <p>{this.state.text}</p>
        <button onClick={this.changeText}>点我修改</button>
      </div>
    )
  }
}

函数组件/无状态组件(Function Component/Stateless Component)

以函数形式定义的组件。在早期没有 React-Hooks 的时候,函数组件内部无法定义和维护 state,所以它也叫无状态组件。

示例:

// 代码源自 “深入浅出搞定 React -- 修言”

function DemoFunction(props) {
  const { text } = props

  return (
    <div className="demoFunction">
      <p>{`function 组件所接收到的来自外界的文本内容是:[${text}]`}</p>
    </div>
  )
}

类组件与函数组件的对比

通过上面两个示例,除了在写法上的区分。还有一些用法上的区别包括但不限于:

  • 类组件需要继承;函数组件不需要
  • 类组件可以访问生命周期方法;函数组件不可以
  • 类组件可以获取实例化后的 this,并可以基于 this 做各种操作;函数组件不可以
  • 类组件可以定义并维护 state ;函数组件不可以
  • ...

我们很容易看出,类组件的能力明显强于函数组件(在 Hook 出现之前),但这并不说明“类组件强于函数组件”。同理,也不应鼓吹函数组件的轻量优雅,会在将来干掉类组件。这两种形式并没有优劣之别,我们更应该关注两者的不同,进而在不同的场景使用相应的组件,这样才能获得一个全面、辩证的认知。

深入理解类组件

类组件是典型的面向对象编程。对于面向对象这个概念,我们总会想到 “封装” 和 “继承”

再次思考下类组件示例:

// 代码源自 “深入浅出搞定 React -- 修言”

class DemoClass extends React.Component {
  // 初始化类组件的 state
  state = {
    text: ''
  }

  // 编写生命周期方法 didMount
  componentDidMount() {
    // 省略业务逻辑
  }

  // 编写自定义的实例方法
  changeText = (newText) => {
    // 更新 state
    this.setState({
      text: newText
    })
  }

  // 编写生命周期方法 render
  render() {
    return (
      <div className="demoClass">
        <p>{this.state.text}</p>
        <button onClick={this.changeText}>点我修改</button>
      </div>
    )
  }
}

从中可以看出,React 类组件内置了很多属性/方法;你只需继承 React.Component 就可以获得它们。

类组件的强大毋庸置疑,但“多”就是“好”吗?其实未必。

React 类组件提供了多少东西,你就需要学习多少东西。假如对属性/方法的使用理解不够透彻甚至错误,会出现很大的问题。大而全的背后,是巨大的学习成本

类组件还有一个问题,它太重了,对于解决很多简单的问题,编写一个类组件就显得过于复杂。复杂度高就带来了高昂的理解成本。

更为致命的是,类组件的内部逻辑难以复用。如果一定要这么做需要学习更复杂的实现(比如高阶组件、Render Props 等),这是一个更高的学习成本

深入理解函数组件

我们再来看下函数组件示例:

// 代码源自 “深入浅出搞定 React -- 修言”

function DemoFunction(props) {
  const { text } = props

  return (
    <div className="demoFunction">
      <p>{`function 组件所接收到的来自外界的文本内容是:[${text}]`}</p>
    </div>
  )
}

函数组件不止可以做渲染,也可以实现复杂的交互逻辑:

// 代码源自 “深入浅出搞定 React -- 修言”

function DemoFunction(props) {
  const { text } = props 

  const showAlert = ()=> {
    alert(`我接收到的文本是${text}`)
  } 

  return (
    <div className="demoFunction">
      <p>{`function 组件所接收到的来自外界的文本内容是:[${text}]`}</p>
      <button onClick={showAlert}>点击弹窗</button>
    </div>
  )
}

相较于类组件,函数组件轻量、灵活、易于组织和维护、较低的学习成本。

更重要的是,React 作者 Dan Abramov 写过一篇关于 函数式组件与类组件有何不同 的文章。整篇文章论证了一件事:

函数式组件捕获了渲染所用的值。(Function components capture the rendered values.)

React的函数式组件和类组件之间是否有任何根本上的区别?当然有 —— 在心智模型上。是面向对象和函数式编程的两套不同的设计思想之间的差异。

函数组件更加契合 React 框架的设计理念

UI = f(data)

React 组件本身的定位就是函数,能够把数据变成视图的函数。

作为开发者,我们所编写的是声明式的代码,而 React 框架的主要工作就是把声明式的代码转换为命令式的 DOM 操作,把数据映射到用户可见的视图。所以,React 的数据应该和渲染绑在一起,而类组件做不到这一点。

为什么类组件做不到?我们来看一下上面文章的示例代码:

  • 类组件:
import React from 'react';

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

export default ProfilePage;
  • 函数组件:
import React from 'react';

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

export default ProfilePage;

尝试按照以下顺序来分别使用这两个按钮:

  1. 点击 其中某一个 Follow 按钮。
  2. 在3秒内 切换 选中的账号。
  3. 查看 弹出的文本。

你将看到一个奇特的区别:

  • 当使用 函数式组件 实现的 ProfilePage, 当前账号是 Dan 时点击 Follow 按钮,然后立马切换当前账号到 Sophie,弹出的文本将依旧是 'Followed Dan'

  • 当使用 类组件 实现的 ProfilePage, 弹出的文本将是 'Followed Sophie'

  • 类组件的结果:

类组件的结果让人感到困惑:user 的内容是通过 props 传递的,而 props 是不可变的,为什么弹出的文字变了(从 Dan 变成 Sophie)?

this.props.user 读取数据。在 React 中 props 是不可变的(immutable),但是 this 是永远可变的(mutable)。事实上,这就是类组件 this 存在的意义。React 本身会随着时间的推移而改变,以便可以在 render 方法及生命周期方法中获得最新的实例。

如果我们说UI在概念上是当前应用状态的一个函数,那么事件处理程序则是渲染结果的一部分 —— 就像视觉输出一样。我们的事件处理程序“属于”一个拥有特定 props 和 state 的特定渲染。

然而,调用一个回调函数读取 this.props 的 timeout 会打断这种关联。我们的 showMessage 回调并没有与任何一个特定的渲染“绑定”在一起,所以它“失去”了正确的 props。从 this 中读取数据的这种行为,切断了这种联系。

  • 函数组件的结果:

props 会在 ProfilePage 函数执行的时候就被捕获,而 props 本身是不可变的,所以我们可以充分保证从函数执行开始,到之后的任何时机读取的 props 都是最初捕获的 props

当父组件传入新的 props 重新渲染,本质上是基于新的 props 发起一次全新的函数调用,并不会影响上一次调用的 props

也就是说:函数式组件捕获了渲染所用的值。

经过编程实践,React 团队认识到了,函数组件更加符合设计理念、也更有利于逻辑拆分和复用。接下来就是“用脚投票”,用实际行动支持开发者编写函数组件。于是,React-Hooks 便应运而生。

Hooks 的本质

一套能够使得函数组件更强大、更灵活的“钩子”。让函数组件拥有类组件的能力,并且保留轻量、优雅的特性。

Hooks 是使用你已经知道的 React 特性的一种更直接的方式 —— 比如 state,生命周期,context,以及 refs。它们并没有从根本上改变 React 的工作方式,你对组件,props, 以及自顶向下的数据流的知识并没有改变。

Hooks 核心 API

useState()

为函数组件引入状态(state

有了 useState 之后,我们就可以直接在函数组件中引入 state

示例对比:

import React, { Component } from "react";

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

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={this.changeCount}>
          Click me
        </button>
      </div>
    );
  }
}
import React, { useState } from 'react';

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

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

同样逻辑的函数组件相比类组件而言,复杂度要低得多得多

useState 做了什么

useState 是一种新方法,它与 class 里面的 this.state 提供的功能完全相同。一般来说,在函数退出后变量就会”消失”,而 state 中的变量会被 React 保留。

useState 参数

useState() 方法里面唯一的参数就是初始 state。不同于 class 的是,我们可以按照需要使用数字或字符串对其进行赋值,而不一定是对象。

useState 返回值

返回值为:当前 state 以及更新 state 的函数。

this.state = {
  text: 'abc',
  num: 3,
  arr: [1, 2, 3]
}

使用 useState

const [text, setText] = useState('abc')

const [num, setNum] = useState(3)

const [arr, setArr] = useState([1, 2, 3])

你还可以定义布尔值、对象等,它就像类组件中 state 对象的某一个属性一样,对应着一个单独的状态,允许存储任意类型的值。

useEffect()

Effect Hook 可以让你在函数组件中执行副作用操作

useEffect 在一定程度上弥补了函数组件的生命周期的缺失。我们可以将在 componentDidMountcomponentDidUpdatecomponentWillUnmount 三个生命周期里来做的事情,放到 useEffect 里来做。比如操作 DOM、绑定/解绑事件、网络请求 等。

示例对比:

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>
    );
  }
}
import React, { useState, useEffect } from 'react';

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

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

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

useEffect 和生命周期的关系

useEffect 和生命周期方法并不完全相等。它们在心智模型上是不同的,useEffect 更接近于实现状态同步,而不是响应生命周期事件。

当我不再透过熟悉的class生命周期方法去窥视useEffect 这个Hook的时候,我才得以融会贯通。

effect 分类

副作用分为两类:需要清除和不需要清除

useEffect 参数

两个参数,回调函数和依赖数组

useEffect(callback, [])

第一个参数,要么返回一个能清除副作用的函数,要么无返回值

useEffect(() => {
  // 副作用逻辑

  return () => {
    // 清除副作用
  }
}, [])

清除副作用会在卸载阶段执行,且与依赖数组无关。

第二个参数:依赖数组

  • 如果不传入依赖数组,每一次渲染后都执行副作用
useEffect(callback)
  • 依赖数组为空数组,仅在挂载阶段执行一次副作用
useEffect(callback, [])
  • 依赖数组为非空数组,React 会在新的一次渲染后去对比前后两次的渲染,查看依赖数组内的变量是否发生更新,只要有一个发生更新就会执行副作用
useEffect(callback, [a, b, c])

扩展阅读

由 React 作者的文章: useEffect 完整指南

Why Hooks

  • 类(Class)组件难以理解
  • 类组件业务逻辑难以拆分
  • 类组件的状态逻辑难以复用
  • 函数组件从设计思想上更契合 React

难以理解的类组件

类组件难以理解的背后是 this 和生命周期的理解

  • this 问题

🌰:

class Example extends React.Component {
  state = {
    count: 0
  }
  
  changeCount() {
    this.setState({
      count: this.state.count + 1
    })
  }

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

当点击 button 按钮时,我们希望 count + 1 ,但实际是程序会报错。原因很简单,changeCount 方法里面拿不到组件实例 this。为了解决这个问题,我们使用 bind 或箭头函数。但不管哪种方法,本质上都是在实践层面的约束来解决设计层面的问题

  • 生命周期的问题
    • 学习成本
    • 不合理的逻辑规划

Hooks 实现更好的逻辑拆分

类组件中业务逻辑与生命周期耦合在一起

componentDidMount() {
  // 1. 这里发起异步调用
  // 2. 这里从 props 里获取某个数据,根据这个数据更新 DOM
  // 3. 这里设置一个订阅
  // 4. 这里随便干点别的什么 
  // ...
}

componentWillUnMount() {
  // 在这里卸载订阅
}

componentDidUpdate() {
  // 1. 在这里根据 DidMount 获取到的异步数据更新 DOM
  // 2. 这里从 props 里获取某个数据,根据这个数据更新 DOM(和 DidMount 的第2步一样)
}

将没有关联的逻辑塞进同一个生命周期里,将强关联的逻辑分散到不同的生命周期里;导致生命周期复杂,逻辑混乱。

Hooks 可以实现关注点分离,将组件中相互关联的部分拆分成更小的函数。帮助我们实现业务逻辑的聚合,避免复杂的组件和冗余的代码。

状态复用

类组件的逻辑复用需要使用高阶组件(HOC) 和 Render Props 组件设计模式。在实现逻辑复用的同时,也破坏了组件的结构;其中最常见的就是 “嵌套地狱” 现象

Hooks 可以通过自定义 Hook 实现逻辑复用,这么做既不破坏组件结构,也能实现逻辑复用的目的。

Hooks 不是万能的

  • Hooks 还没有完全为函数组件补齐类组件的能力:比如 getSnapshotBeforeUpdatecomponentDidCatch 这些生命周期
  • 函数组件给了我们一定程度的自由,但也对开发者的水平提出更高的要求
  • Hooks 在使用上有着严格的规则约束