
2018 年的 React Conf 上 Dan Abramov 正式对外介绍了React Hook,这是一种让函数组件支持状态和其他 React 特性的全新方式,并被官方解读为这是下一个 5 年 React 与时俱进的开端。从中细品,可以窥见React Hook的重要性。今年 2 月 6 号,React Hook 新特性随 React v16.8.0 版本正式发布,整个上半年 React 社区都在积极努力地拥抱它,学习并解读它。虽然官方声明,React Hook还在快速的发展和更新迭代过程中,很多Class Component支持的特性,React Hook还并未支持,但这丝毫不影响社区的学习热情。
React Hook上手非常简单,使用起来也很容易,但相比我们已经熟悉了 5 年的类组件写法,React Hook还是有一些理念和思想上的转变。React 团队也给出了使用 Hook 的一些规则和eslint插件来辅助降低违背规则的概率,但规则并不是仅仅让我们去记忆的,更重要的是要去真正理解设计这些规则的原因和背景。
本文是我个人在学习 React Hook 的过程中,通过学习官方文档、阅读源码、浏览其他优秀同行撰写的经验文章,再结合自己的思考,通过逆向思维从 React Hook 希望解决的问题出发,复盘了 React Hook 的核心架构设计和创造的过程。非常适合希望对 React Hook 有更深了解,但又不愿意去读晦涩的源码的同学。
文章中的代码很多只是伪代码,重点在解读设计思路,因此并非完整的实现。很多链表的构建和更新逻辑也一并省略了,但并不影响大家了解整个 React Hook 的设计。事实上React Hook的大部分代码都在适配React Fiber架构的理念,这也是源码晦涩难懂的主要原因。不过没关系,我们完全可以先屏蔽掉React Fiber的存在,去一点点构建纯粹的 React Hook 架构。
设计的背景和初衷
React Hook 的产生主要是为了解决什么问题呢?官方的文档里写的非常清楚,这里只做简单的提炼,不做过多陈述,没读过文档的同学可以先移步阅读React Hook简介。
总结一下要解决的痛点问题就是:
在组件之间复用状态逻辑很难
之前的解决方案是:render props 和高阶组件。
缺点是难理解、存在过多的嵌套形成“嵌套地狱”。
复杂组件变的难以理解
生命周期函数中充斥着各种状态逻辑和副作用。
这些副作用难以复用,且很零散。
难以理解的 Class
this 指针问题。
组件预编译技术(组件折叠)会在 class 中遇到优化失效的 case。
class 不能很好的压缩。
class 在热重载时会出现不稳定的情况。
设计方案
React 官网有下面这样一段话:
为了解决这些问题,Hook 使你在==非 class 的情况下可以使用更多的 React 特性==。 从概念上讲,React 组件一直更像是函数。而 Hook 则拥抱了函数,同时也没有牺牲 React 的精神原则。Hook 提供了问题的解决方案,无需学习复杂的函数式或响应式编程技术
设计目标和原则
对应第一节所抛出的问题,React Hook 的设计目标便是要解决这些问题,总结起来就以下四点:
无 Class 的复杂性
无生命周期的困扰
优雅地复用
对齐 React Class 组件已经具备的能力
设计方案
无 Class 的复杂性(去 Class)
React 16.8 发布之前,按照是否拥有状态的维护来划分的话,组件的类型主要有两种:
类组件 Class Component:主要用于需要内部状态,以及包含副作用的复杂的组件
函数组件 Function Component:主要用于纯组件,不包含状态,相当于一个模板函数
如果设计目标是==去 Class==的话,似乎选择只能落在改造Function Component,让函数组件拥有Class Component一样的能力上了。
我们不妨畅想一下最终的支持状态的函数组件代码:
上述代码使用函数组件定义了一个计数器组件Counter,其中提供了状态state,以及改变状态的setState函数。这些 API 对于Class component来说无疑是非常熟悉的,但在Function component中却面临着不同的挑战:
class 实例可以永久存储实例的状态,而函数不能,上述代码中 Counter 每次执行,state 都会被重新赋值为 0;
每一个
Class component的实例都拥有一个成员函数this.setState用以改变自身的状态,而Function component只是一个函数,并不能拥有this.setState这种用法,只能通过全局的 setState 方法,或者其他方法来实现对应。
以上两个问题便是选择改造Function component所需要解决的问题。
解决方案
在 JS 中,可以存储持久化状态的无非几种方法:
类实例属性
全局变量
DOM
闭包
其他全局存储:indexDB、LocalStorage 等等
Function component对状态的诉求只是能存取,因此似乎以上所有方案都是可行的。但作为一个优秀的设计,还需要考虑到以下几点:
使用简单
性能高效
可靠无副作用
方案 2 和 5 显然不符合第三点;方案 3 无论从哪一方面都不会考虑;因此闭包就成为了唯一的选择了。
闭包的实现方案
既然是闭包,那么在使用上就得有所变化,假设我们预期提供一个名叫useState的函数,该函数可以使用闭包来存取组件的 state,还可以提供一个 dispatch 函数来更新 state,并通过初始调用时赋予一个初始值。
如果用过 redux 的话,这一幕一定非常眼熟。没错,这不就是一个微缩版的 redux 单向数据流吗?
给定一个初始 state,然后通过 dispatch 一个 action,再经由 reducer 改变 state,再返回新的 state,触发组件重新渲染。
知晓这些,useState的实现就一目了然了:
上面的代码简单明了,但显然仍旧不满足要求。Function Component在初始化、或者状态发生变更后都需要重新执行useState函数,并且还要保障每一次useState被执行时state的状态是最新的。
很显然,我们需要一个新的数据结构来保存上一次的state和这一次的state,以便可以在初始化流程调用useState和更新流程调用useState可以取到对应的正确值。这个数据结构可以做如下设计,我们假定这个数据结构叫 Hook:
考虑到第一次组件mounting和后续的updating逻辑的差异,我们定义两个不同的useState函数的实现,分别叫做mountState和updateState。
上面的代码基本上反映出我们的设计思路,但还存在两个核心的问题需要解决:
调用
storeUpdateActions后将以什么方式把这次更新行为共享给doReducerWork进行最终状态的计算。同一个 state,在不同时间调用
mountState和updateState时,如何实现hook对象的共享。
更新逻辑的共享
更新逻辑是一个抽象的描述,我们首先需要根据实际的使用方式考虑清楚一次更新需要包含哪些必要的信息。实际上,在一次事件 handler 函数中,我们完全可以多次调用dispatchAction:
在执行对setCount的 3 次调用中,我们并不希望 Count 组件会因此被渲染 3 次,而是会按照调用顺序实现最后调用的状态生效。因此如果考虑上述使用场景的话,我们需要同步执行完clickHandler中所有的dispatchAction后,并将其更新逻辑顺序存储,然后再触发 Fiber 的 re-render 合并渲染。那么多次对同一个dispatchAction的调用,我们如何来存储这个逻辑呢?
比较简单的方法就是使用一个队列Queue来存储每一次更新逻辑Update的基本信息:
这里使用了单向链表结构来存储更新队列,为什么要用单向链表而不用数组呢?这个问题应该是一道经典的数据结构的面试题,留给大家自己去思考。
有了这个数据结构之后,我们再来改动一下代码:
到这一步,更新逻辑的共享,我们就已经解决了。
Hook 对象的共享
Hook 对象是相对于组件存在的,所以要实现对象在组件内多次渲染时的共享,只需要找到一个和组件全局唯一对应的全局存储,用来存放所有的 Hook 对象即可。对于一个 React 组件而言,唯一对应的全局存储自然就是 ReactNode,在React 16x 之后,这个对象应该是FiberNode。这里为了简单起见,我们暂时不研究 Fiber,我们只需要知道一个组件在内存里有一个唯一表示的对象即可,我们姑且把他叫做fiberNode:
现在,摆在我们面前的问题是,我们对Function component的期望是什么?我们希望的是用Function component的useState来完全模拟Class component的this.setState吗?如果是,那我们的设计原则会是:
一个函数组件全局只能调用一次 useState,并将所有的状态存放在一个大 Object 里
如果仅仅如此,那么函数组件已经解决了去Class的痛点,但我们并没有考虑优雅地复用状态逻辑的诉求。
试想一个状态复用的场景:我们有多个组件需要监听浏览器窗口的resize事件,以便可以实时地获取clientWidth。在Class component里,我们要么在全局管理这个副作用,并借助 ContextAPI 来向子组件下发更新;要么就得在用到该功能的组件中重复书写这个逻辑。
ContextAPI 的方法无疑是不推荐的,这会给维护带来很大的麻烦;ctrl+c ctrl+v就更是无奈之举了。
如果Function component可以为我们带来一种全新的状态逻辑复用的能力,那无疑会为前端开发在复用性和可维护性上带来更大的想象空间。
因此理想的用法是:
综上所述,设计上理应要考虑一个组件对应多个 Hook 的用法。带来的挑战是:
我们需要在
fiberNode上存储所有 Hook 的状态,并确保它们在每一次re-render时都可以获取到最新的正确的状态
要实现上述存储目标,直接想到的方案就是用一个 hashMap 来搞定:
如果用这种方法来存储,会需要为每一次 hook 的调用生成唯一的 key 标识,这个 key 标识需要在 mount 和 update 时从参数中传入以保证能路由到准确的 hook 对象。
除此方案之外,还可以使用 hook.update 采用的单向链表结构来存储,给 hook 结构增加一个 next 属性即可实现:
这种方案存在一个问题需要注意:
整个链表是在
mount时构造的,所以在update时必须要保证执行顺序才可以路由到正确的 hook。
我们来粗略对比一下这两种方案的优缺点:
很显然,hashMap 的缺点是无法忍受的,使用体验和成本都太高了。而链表方案缺点中的规范是可以通过 eslint 等工具来保障的。从这点考虑,链表方案无疑是胜出了,事实上这也正是React团队的选择。
到这里,我们可以了解到为什么 React Hook 的规范里要求:
只能在函数组件的顶部使用,不能再条件语句和循环里使用
至此,我们已经基本实现了 React Hooks 去Class的设计目标,现在用函数组件,我们也可以通过useState这个 hook 实现状态管理,并且支持在函数组件中调用多次 hook。
无生命周期的困扰
上一节我们借助闭包、两个单向链表(单次 hook 的 update 链表、组件的 hook 调用链表)、透传 dispatch 函数实现了 React Hook 架构的核心逻辑:如何在函数组件中使用状态。到目前为止,我们还没有讨论任何关于生命周期的事情,这一部分也是我们的设计要解决的重点问题。我们经常会需要在组件渲染之前或者之后去做一些事情,譬如:
在
Class component的componentDidMount中发送ajax请求向服务器端拉取数据。在
Class component的componentDidMount和componentDidUnmount中注册和销毁浏览器的事件监听器。
这些场景,我们同样需要在 React Hook 中予以解决。React 为Class component设计了一大堆生命周期函数:
在实际的项目开发中用的比较频繁的,譬如渲染后期的:
componentDidMount、componentDidUpdate、componentWillUnmount;很少被使用的渲染前期钩子
componentWillMount、componentWillUpdate;一直以来被滥用且有争议的
componentWillReceiveProps和最新的getDerivedStateFromProps;用于性能优化的
shouldComponentUpdate;
React 16.3 版本已经明确了将在 17 版本中废弃componentWillMount、componentWillUpdate和componentWillReceiveProps这三个生命周期函数。设计用来取代componentWillReceiveProps的getDerivedStateFromProps也并不被推荐使用。
真正被重度使用的就是渲染后和用于性能优化的几个,在 React hook 之前,我们习惯于以 render 这种技术名词来划分组件的生命周期阶段,根据名字componentDidMount我们就可以判断现在组件的 DOM 已经在浏览器中渲染好了,可以执行副作用了。这显然是技术思维,那么在 React Hook 里,我们能否抛弃这种思维方式,让开发者无需去关注渲染这件事儿,只需要知道哪些是副作用,哪些是状态,哪些需要缓存即可呢?
根据这个思路我们来设计 React Hook 的生命周期解决方案,或许应该是场景化的样子:
这样设计的好处是开发者不再需要去理清每一个生命周期函数的触发时机,以及在里面处理逻辑会有哪些影响。而是更关注去思考哪些是状态,哪些是副作用,哪些是需要缓存的复杂计算和不必要的渲染。
useEffect
effect的全称应该是Side Effect,中文名叫副作用,我们在前端开发中常见的副作用有:
dom 操作
浏览器事件绑定和取消绑定
发送 HTTP 请求
打印日志
访问系统状态
执行 IO 变更操作
在 React Hook 之前,我们经常会把这些副作用代码写在componentDidMount、componentDidUpdate和componentWillUnmount里,比如:
这种写法存在一些体验的问题:
同一个副作用的创建和清理逻辑分散在多个不同的地方,这无论是对于新编写代码还是要阅读维护代码来说都不是一个上佳的体验。
有些副作用可能要再多个地方写多份。
第一个问题,我们可以通过 thunk 来解决:将清理操作和新建操作放在一个函数中,清理操作作为一个 thunk 函数被返回,这样我们只要在实现上保障每次 effect 函数执行之前都会先执行这个 thunk 函数即可:
第二个问题,对于函数组件而言,则再简单不过了,我们完全可以把部分通用的副作用抽离出来形成一个新的函数,这个函数可以被更多的组件复用。
useEffect 的执行时机
既然是设计用来解决副作用的问题,那么最合适的时机就是组件已经被渲染到真实的 DOM 节点之后。因为只有这样,才能保证所有副作用操作中所需要的资源(dom 资源、系统资源等)是 ready 的。
上面的例子中描述了一个在 mount 和 update 阶段都需要执行相同副作用操作的场景,这样的场景是普遍的,我们不能假定只有在 mount 时执行一次副作用操作就能满足所有的业务逻辑诉求。所以在 update 阶段,useEffect 仍然要重新执行才能保证满足要求。
这就是 useEffect 的真实机制:
Function Component函数(useState、useEffect、…)每一次调用,其内部的所有 hook 函数都会再次被调用。
这种机制带来了一个显著的问题,就是:
父组件的任何更新都会导致子组件内 Effect 逻辑重新执行,如果 effect 内部存在性能开销较大的逻辑时,可能会对性能和体验造成显著的影响。
React 在PureComponent和底层实现上都有过类似的优化,只要依赖的 state 或者 props 没有发生变化(浅比较),就不执行渲染,以此来达到性能优化的目的。useEffect同样可以借鉴这个思想:
上面的例子中,只要传入的firstName在前后两次更新中没有发生变化,effectCreator函数就不会执行。也就是说,即便调用多次setCount(*),组件会重复渲染多次,但只要 firstName 没有发生变化,effectCreator函数就不会重复执行。
useEffect 的实现
useEffect 的实现和 useState 基本相似,在mount时创建一个 hook 对象,新建一个 effectQueue,以单向链表的方式存储每一个 effect,将 effectQueue 绑定在 fiberNode 上,并在完成渲染之后依次执行该队列中存储的 effect 函数。核心的数据结构设计如下:
deps 参数的优化逻辑就很简单了:
useEffect 小结
执行时机相当于
componentDidMount和componentDidUpdate,有 return 就相当于加了componentWillUnmount。主要用来解决代码中的副作用,提供了更优雅的写法。
多个 effect 通过一个单向循环链表来存储,执行顺序是按照书写顺序依次执行。
deps 参数是通过循环浅比较的方式来判断和上一次依赖值是否完全相同,如果有一个不同,就重新执行一遍 Effect,如果相同,就跳过本次 Effect 的执行。
每一次组件渲染,都会完整地执行一遍清除、创建 effect。如果有 return 一个清除函数的话。
清除函数会在创建函数之前执行。
useMemo
在useEffect中我们使用了一个deps参数来声明 effect 函数对变量的依赖,然后通过areHookInputsEqual函数来比对前后两次的组件渲染时deps的差异,如果浅比较的结果是相同,那么就跳过 effect 函数的执行。
仔细想想,这不就是生命周期函数shouldComponentUpdate要做的事情吗?何不将该逻辑抽取出来,作为一个通用的 hook 呢,这就是useMemo这个 hook 的原理。
但 useMemo 和shouldComponentUpdate的区别在于 useMemo 只是一个通用的无副作用的缓存 Hook,并不会影响组件的渲染与否。所以从这点上讲,useMemo 并不能替代shouldComponentUpdate,但这丝毫不影响 useMemo 的价值。useMemo 为我们提供了一种通用的性能优化方法,对于一些耗性能的计算,我们可以用 useMemo 来缓存计算结果,只要依赖的参数没有发生变化,就达到了性能优化的目的。
那么要完整实现shouldComponentUpdate的效果应该怎么办呢?答案是借助React.memo:
这相当于使用了 PureComponent。
到目前为止,除了getDerivedStateFromProps,其他常用的生命周期方法在 React Hook 中都已经有对应的解决方案了,componentDidCatch官方已经声明正在实现中。这一节的最后,我们再来看看getDerivedStateFromProps的替代方案。
这个生命周期的作用是根据父组件传入的 props,按需更新到组件的 state 中。虽然很少会用到,但在 React Hook 组件中,仍然可以通过在渲染时调用一次"setState"来实现:
如果在渲染过程中调用了"setState",组件会取消本次渲染,直接进入下一次渲染。所以这里一定要注意"setState"一定要放在条件语句中执行,否则会造成死循环。
优雅地复用
React 组件化开发方式,本质上就是组件的复用,开发一个应用就像搭积木一样把各种组件有机地堆叠在一起。但这是整个组件层面的复用,是一种粗粒度的复用。在不同的组件内部,我们仍然会经常做一些重复劳动,这些重复劳动可能包含以下几种:
状态及其逻辑的重复。比如 loading 状态,计数器等。
副作用的逻辑重复。比如有同一个 ajax 请求、多个组件内对同一个浏览器事件的监听、同一类 dom 操作或者宿主 API 的调用等。
React Hook 的设计目标中很重要的一点就是:
如何让状态及其逻辑和副作用逻辑具备真正的复用性而不需要使用
reder-props和HOC?
React 中的代码复用
使用过早期版本 React 的同学可能知道Mixins API,这是官方提供的一种比组件更细粒度的逻辑复用能力。在 React 推出基于 ES6 的Class Component的写法后,就被逐渐’抛弃’了。Mixins虽然可以非常方便灵活地解决AOP类的问题,譬如组件的性能日志监控逻辑的复用:
但这种模式本身会带来很多的危害,具体可以参考官方的一片博文:《Mixins Considered Harmful》。
React 官方在 2016 年建议拥抱HOC,也就是使用高阶组件的方式来替代mixins的写法。minxins API 仅可以在create-react-class手动创建组件时才能使用。这基本上宣告了 mixins 这种逻辑复用的方式的终结。
HOC非常强大,React 生态中大量的组件和库都使用了HOC,比如react-redux的connect API:
用HOC实现上面的性能日志打印,代码如下:
HOC虽然强大,但因其本身就是一个组件,仅仅是通过封装了目标组件提供一些上层能力,因此难以避免的会带来嵌套地狱的问题。并且因为HOC是一种将可复用逻辑封装在一个 React 组件内部的高阶思维模式,所以和普通的React组件相比,它就像是一个魔法盒子一样,势必会更难以阅读和理解。
可以肯定的是HOC模式是一种被广泛认可的逻辑复用模式,并且在未来很长的一段时间内,这种模式仍将被广泛使用。但随着React Hook架构的推出,HOC模式是否仍然适合用在Function Component中?还是要寻找一种新的组件复用模式来替代HOC呢?
React 官方团队给出的答案是后者,原因是在React Hook的设计方案中,借助函数式状态管理以及其他 Hook 能力,逻辑复用的粒度可以实现的更细、更轻量、更自然和直观。毕竟在 Hook 的世界里一切都是函数,而非组件。
来看一个例子:
上面的代码中展示了一个带有 loading 状态,可以避免在加载结束之前反复点击的按钮。这种组件可以有效地给予用户反馈,并且避免用户由于得不到有效反馈带来的不断尝试造成的性能和逻辑问题。
很显然,loadingButton 的逻辑是非常通用且与业务逻辑无关的,因此完全可以将其抽离出来成为一个独立的LoadingButton组件:
上面这种将某一个通用的 UI 组件单独封装并提取到一个独立的组件中的做法在实际业务开发中非常普遍,这种抽象方式同时将状态逻辑和 UI 组件打包成一个可复用的整体。
很显然,这仍旧是组件复用思维,并不是逻辑复用思维。试想一下另一种场景,在点击了 loadingButton 之后,希望文章的正文也同样展示一个 loading 状态该怎么处理呢?
如果不对 loadingButton 进行抽象的话,自然可以非常方便地复用 isLoading 状态,代码会是这样:
但针对抽象出 LoadingButton 的版本会是什么样的状况呢?
问题并没有因为抽象而变的更简单,父组件 Article 仍然要自定一个 isLoading 状态才可以实现上述需求,这显然不够优雅。那么问题的关键是什么呢?
答案是耦合。上述的抽象方案将isLoading状态和button标签耦合在一个组件里了,这种复用的粒度只能整体复用这个组件,而不能单独复用一个状态。解决方案是:
如此便实现了更细粒度的状态逻辑复用,在此基础上,还可以根据实际情况,决定是否要进一步封装 UI 组件。譬如,仍然可以封装一个 LoadingButton:
状态逻辑层面的复用为组件复用带来了一种全新的能力,这完全有赖于React Hook基于Function的组件设计,一切皆为函数调用。并且,Function Component也并不排斥HOC,你仍然可以使用熟悉的方法来提供更高阶的能力,只是现在,你的手中拥有了另外一种武器。
自定义 Hook 的实现原理
上述例子中的 useIsLoading 函数被称之为自定义Hook,它所做的仅仅是将部分hook代码提取到一个独立的函数中,就像我们把可复用的逻辑提取到一个独立的函数中一样。
从上文中我们了解到,Hook 队列需要存储在组件对应的 FiberNode 上才可以,那么自定义 hook 也会对应一个 FiberNode 吗?自定义 Hook 对入参和结果有什么要求呢?
我们对自定义 Hook 的定义是逻辑的复用,而不是组件的复用,因此它不应该像Function Component一样直接返回组件树,自然也就没有一个独立的 FiberNode 来对应了。如果没有独立存储,那自定义 hook 函数内部调用的 useState、useEffect 等 hook 函数的数据结构应该如何存储呢?
答案是绑定在调用这个自定义 hook 的Function Component对应的FiberNode上,被抽离出来的自定义 Hook 逻辑,在实际执行的过程中,就好像 copy 了一份自定义 Hook 代码,替换了原来的调用代码,这就是自定义 Hook 的本质。
因此自定义 Hook 在使用时也需要遵循 Hook 规范,需要在函数顶部调用 hook,不能写在条件语句和循环里。除此之外,由于规范允许在自定义 Hook 中调用 hook 函数,但不允许在普通的 function 中调用,因此需要一种规范或者机制来保障开发者不会犯错。
React 团队给出的方案是命名规范和 eslint 校验:
自定义 Hook 必须以
use开头,以便可以通过命名规范来区分。比如:‘useIsLoading’使用 ESLINT 插件来确保当开发者犯错时可以进行提示
对齐 React Class 组件已经具备的能力
在本文撰写的时间点上,仍然有一些Class Component具备的功能是React Hook没有具备的,譬如:生命周期函数componentDidCatch,getSnapshotBeforeUpdate。还有一些第三方库可能还无法兼容 hook,官方给出的说法是:
我们会尽快补齐
未来可期,我们只需静静地等待。
小结
武侠小说中有”无招胜有招“的境界,在设计领域也有”没有设计就是最好的设计“的论断。React Hook抛弃Class,拥抱函数式编程,使用 JS 语言独特的闭包来存储状态,这种设计就像是日本设计师深泽直人倡导的无意识设计一样,对于 Javascript 程序员而言,使用的时候不需要多余的思考,一切皆函数,一切都那么自然、优雅和顺理成章。因本人能力的局限性,文中难免有解读不正确之处,盼望大家可以交流指正(笔者 github 博客地址:https://github.com/shanggqm/blog)







评论 1 条评论