React Hooks:在自省中蜕变

React Conf 2018 上,几位团队成员介绍了一个激动人心的新概念:Hooks。在看完演讲和官方文档之后,我作了一些总结如下。

8102 年了,React 还存在着哪些痛点?

1、相似的组件间复用逻辑难。

在 React 刚出世的时候,采用的是一种叫 mixin 的技术,它允许在不同的类之间共享实例方法。后来开发团队坚决地舍弃了这一特性,因为它带来的问题比其解决的还要多。详见:https://reactjs.org/blog/2016/07/13/mixins-considered-harmful.html

该博文的核心论点是:组合优于继承,所以取代 mixin 的是高阶组件(HOC)和 render props。HOC 和 render props 的优点在博文中已经阐述地很清楚了,这里不再赘述;但是其缺点也是明显的:

层级深渊

图中大部分都是伪层级(false hierarchy),即仅仅为了实现某些逻辑、但是不对应真实 DOM 节点的组件。而且膨胀的组件层级不仅使得 debug 变得困难,而且也会使得应用变慢。最后,如果使用了静态类型语言,你会发现大部分 HOC 的类型很难精确地标识出来。

2、不合理的生命周期方法导致组件臃肿。

这里指的是 React 的生命周期本身的不合理性,而不是说哪个使用者的问题。

考虑一个查看实时股票报价的组件,它接受股票代码作为一个属性,展示实时的报价。报价通过订阅后端接口获得。

import { Component } from 'react';

class StockQuote extends Component {
  constructor(props) {
    super(props);
    this.state = { quote: 0 };
    this.handleQuoteChange = this.handleQuoteChange.bind(this);
  }

  handleQuoteChange(quote) {
    this.setState({ quote });
  }

  componentDidMount() {
    // 假想的方法,订阅报价并使用回调函数来处理
    subscribe(this.props.code, this.handleQuoteChange);
  }

  componentDidUpdate(prevProps) {
    // 如果股票代码发生变更,重新订阅报价
    if (this.props.code !== prevProps.code) {
      unsubscribe(prevProps.code);
      subscribe(this.props.code, this.handleQuoteChange);
    }
  }

  componentWillUnmount() {
    unsubscribe(this.props.code);
  }

  render() {
    return <div>{this.state.quote}</div>;
  }
}

可以看到,订阅/取消订阅这一件事情,被分在了三个不同的生命周期里去实现。如果这个组件还有在 window 上注册事件的需求的话,我们还要在 cDM 里绑定事件,然后在 cWUm 里解绑;这样的结果是每个生命周期内部的操作是不相关的;反过来,要完全看懂一个操作,需要结合三个生命周期一起看。这已经违背了关注点分离原则。

初学者比较常见的错误是,忘记在 cWUm 或者 cDU 里做必要的清理工作,这将直接导致内存泄漏。

3、对人,对机器均不友好的 class

JS 的世界中本来没有 class,直到 ES6 定义了 class 关键字。但这个 class 和大部分其他语言都不同,实际上只是建立在函数原型上的语法糖。其中 this 指针的概念就非常让人迷惑,相信每个写过前端的人都在 this 上踩过坑。在上个例子中,我需要在 constructor 中绑定 handleQuoteChange 方法,否则会抛出运行时错误。如果没有使用非正式的语法 handleQuoteChange = () => {}constructor 方法会变得越来越臃肿。

class 对编译器也是不友好的。假如定义了一个类方法、但是没有在其它方法里调用,编译器根本无法检查出这一点:因为这个方法挂载在原型上,有可能在外部文件中用到了。另外,类方法名、以及 state 上的状态名称,是无法压缩以减少构建文件体积的。最后,class 的复杂性还使得 hot-reloading 很难正确实现。

遗憾的是,尽管 React 提供了纯函数式组件,但是它却只能是无状态的。常见的情况是:你先写了一个函数式组件,过一会儿发现它需要一个内置状态,于是你不得不花功夫把它改成了 class 组件。久而久之,你已经养成了只写 class 组件的习惯。

Hooks 速览

为了解决这些痛点,React 提供了一类新的顶级 API:Hooks。

use 开头的函数,我们称之为一个 hook。看看上边的例子用 hooks 重写后的样子:

import { useState, useEffect } from 'react';

function StockQuote(props) {
  const [quote, setQuote] = useState(0);

  useEffect(() => {
    subscribe(props.code, setQuote);
    return () => {
      unsubscribe(props.code);
    };
  });

  return <div>{quote}</div>;
}

useState 接受一个初始值,然后返回一个 [状态, 状态修改器] 的二元组。每次 <StockQuote /> 重新渲染时,整个函数会重新执行,但是 useState 会记住上次的值。下文会介绍这是如何做到的。

如果组件有多个状态的话,多次使用 useState 就可以了。现在每个 state 是独立存储的 —— 所以以往合并 state 的机制没有了,改为了直接替换。如果你的组件里有 abcd 四个状态,其中 ab 总是一起更改,cd 总是一起更改,那就把 { a, b }{ c, d } 分别作为两个 state,这样不用每次都去合并对象,也使得相关联的状态能写在一起。

useEffect 是用来处理副作用的。它的执行时机是 DOM 更新完毕之后,相当于以前的 cDMcDU。它返回了一个清理句柄,是用来在组件卸载前或者更新前调用的。useEffect 将相关的操作聚合在一起,整合了 cDMcDUcWUm 的角色。

在组件重新渲染的时候,如果 props.code 没有改变,那我们是不希望 useEffect 里的订阅/取消订阅操作再执行一次的。要实现这个判断,只需要在 useEffect 里传入第二个参数,它是一个数组:如果两次调用间,数组中的每个值都没有变化的话,则不会执行 effect 函数。

useEffect(() => {
  subscribe(props.code, setQuote);
  return () => {
    unsubscribe(props.code);
  };
}, [props.code]);

未来编辑器可能会实现自动分析用到的变量,并进行优化的功能。

直观对比

那么 useState 在被调用时,怎么知道是哪个组件的哪个状态?答案是,得益于 JS 的单线程模型,React 能够获取当前正在渲染的组件。而状态是通过组件中被调用的顺序来区分的。

所以 Hooks 也有一些限制,它只能在函数组件的顶级代码块中(或者自定义 hook 中,下文提及)被调用,而不能放在条件分支或者循环语句里;因为这样会导致两次渲染间,Hooks 的调用顺序不完全一致。React 会提供一个 linter 插件来帮助开发者遵守此限制。

下面我们来看看如何在组件间复用逻辑。假设有另外一个组件也需要通过股票代码获取报价的功能,那么我们可以把这部分逻辑抽成一个自定义 hook:

import { useState, useEffect } from 'react';

function useQuote(code) {
  const [quote, setQuote] = useState(0);

  useEffect(() => {
    subscribe(code, setQuote);
    return () => {
      unsubscribe(code);
    };
  }, [code]);

  return quote;
}

function StockQuote(props) {
  const quote = useQuote(props.code);
  return <div>{quote}</div>;
}

function StrongStockQuote(props) {
  const quote = useQuote(props.code);
  return <strong>{quote}</strong>;
}

仔细看上边这段代码,然后你会发现它和上一个例子是完全等价的!这其实仅仅是把重复代码抽成函数了而已。至于这个自定义函数的输入、输出,完全由开发者决定。不过它最好以 use 开头,这是为了告诉 React 的 linter 这是一个自定义 hook,使得其中调用其他 hook 不会违反 lint 规则。

React 目前提供了十种内置 hook,除了提过的 useStateuseEffect,还有很实用的 useContextuseRefuseReducer 等,详见 https://reactjs.org/docs/hooks-reference.html 。相信在不久的将来,社区会贡献出无数奇妙的自定义 hooks

回顾与展望

开头提到了 React 当下的三个痛点,我们看看 hooks 是怎么解决这些它们的:

  • 相似的组件间复用逻辑难 —— 自定义 hooks,作为纯函数,没有 mixin 带来的混乱,没有 HOC 带来的层级深渊。
  • 不合理的生命周期方法导致组件臃肿 —— useEffect 天然整合了 cDMcDUcWUm 的能力,并使得相关联的代码写在一起。
  • 对人,对机器均不友好的 class —— 现在函数式组件有了状态,我们不再需要 class 了。没有 this、没有写死的方法名和 state 名称。而且要标识一个函数的静态类型,比标识一个 class 乃至 HOC 要方便而且准确。

放眼整个前端,现在大部分前端框架对组件的定义,都是一个 class(或者对象)。React 在文档中说道,hooks 的目标之一是完全取代 class 的使用场景(尽管在可预期的范围内,没有移除 class 的计划,也就是说不学 hooks 也没多大问题)。这是一次思想上的进化,它将革掉自己的命,抛弃 OOP,进入函数式编程的时代。

有兴趣的同事,可以阅读官方的文档 https://reactjs.org/docs/hooks-intro.html 来获取进一步的了解。

最后,引用 Dan Abramov 在前几天 React Conf 2018 上的演讲词结束本文:

……我曾经疑惑,React 的 Logo 为什么是一个原子?后来我想到了这个解释。我们知道物质由原子组成,是原子的特性决定了物质的外观和行为。就像 React,你可以把用户视图拆成独立的组件,再像原子一样自由组合起来,是组件的特性决定了用户视图的行为。科学家们曾一度认为原子是不可分割的最小单位,直到发现了电子,一种原子内部更小的粒子。事实上,是电子的特征影响了原子的性质。我认为 hooks 就好比电子,与其说它是一个新特性,不如说是已知的 React 特性(state,context,生命周期)的更直接的展现形式,而这四年来我们却一直对它视而不见。

如果盯着 React 的 logo 看的话,你会发现 hooks 其实一直都在。

扩展阅读