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 的机制没有了,改为了直接替换。如果你的组件里有 a
、b
、c
、d
四个状态,其中 a
、b
总是一起更改,c
、d
总是一起更改,那就把 { a, b }
和 { c, d }
分别作为两个 state,这样不用每次都去合并对象,也使得相关联的状态能写在一起。
useEffect
是用来处理副作用的。它的执行时机是 DOM 更新完毕之后,相当于以前的 cDM
和 cDU
。它返回了一个清理句柄,是用来在组件卸载前或者更新前调用的。useEffect
将相关的操作聚合在一起,整合了 cDM
、cDU
和 cWUm
的角色。
在组件重新渲染的时候,如果 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,除了提过的 useState
、useEffect
,还有很实用的 useContext
、useRef
、useReducer
等,详见 https://reactjs.org/docs/hooks-reference.html 。相信在不久的将来,社区会贡献出无数奇妙的自定义 hooks。
回顾与展望
开头提到了 React 当下的三个痛点,我们看看 hooks 是怎么解决这些它们的:
- 相似的组件间复用逻辑难 —— 自定义 hooks,作为纯函数,没有 mixin 带来的混乱,没有 HOC 带来的层级深渊。
- 不合理的生命周期方法导致组件臃肿 ——
useEffect
天然整合了cDM
、cDU
和cWUm
的能力,并使得相关联的代码写在一起。 - 对人,对机器均不友好的
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 其实一直都在。