四年了,我为什么还是不喜欢 React Hooks?

2023-05-19 413 次浏览 前端

React 一直试图将 FP 带到 UI 世界。在成功推广了声明式开发的范式后,React 团队似乎对传统模式(类组件+无状态函数组件)并不满意,因为其中的大头——类组件还不够「函数式」。因此,React Hooks 应运而生,它以一种激进的方式将主创团队的哲学思考带给大众,并成功改变了前端业界之后多年的发展走向。

这篇文章从一个未被 React 思维同化的普通全栈开发者的视角出发,引出 React Hooks 的一些问题。作为非专业前端和 React 使用者,笔者的观点难免粗浅和片面,可能引起部分 React 粉丝和 FP 信徒的不适,但我相信这种声音会越来越多。这类批评在头两年可能不够有说服力,因为那时 hooks 依然是业界比较前沿的选择,但如今,越来越多设计优秀的框架和思想让 hooks 逐渐失去了昔日的高光。我们不禁要问:React 还是那个领导者吗?2023 年,我们是否有更好的选择?

怪异的设计

要切入 hooks,首当其冲要聊的就是设计问题。我通常会将一些框架和类库的设计按以下标准划分评级:

  • 【殿堂级】优雅、自然的设计:这类设计通常可以让人凭借语言或通用领域知识,不看文档就可以读懂代码,甚至可以凭直觉猜出一些 API 的用法
  • 【卓越级】存在一定魔法,但符合直觉的设计:这类设计的部分特性第一眼可能让人感觉有点 magic,但由于设计较合理,使用下来易于让人接受,不会觉得这些语法糖和 API 存在违和感,虽然部分可能有一定学习成本
  • 【噩梦级】不符合直觉、怪异的设计:这类设计可能在读文档前完全无法从代码中猜测一些关键 API 的语义和功能,部分更加糟糕的设计在阅读文档后仍无法理解,甚至需要依靠测试和翻阅源码帮助理解

如果要拿一些前端框架举例的话,在我的评价里,Vue 类组件大概半只脚迈进了殿堂级;Angular、Vue 组合式/选项式 API、React 类组件、Svelte 等基本可以称得上是卓越级;而稳稳停留在噩梦级且居于最底端的,毫无疑问是 React Hooks。

只要简单列举一些新手初次上手 hooks 时懵逼的问题,大家就可以理解我说的怪异。注意,请尽量先卸载脑中的 React 思维模组,假设你是一个只用过其他常见前后端框架或只学习过原生 JS 的新手:

  • 为什么 useState 返回值是一个元组?
  • 为什么 useState 不是纯函数,而且它返回什么完全不可预测?
  • 组件函数到底用来返回组件实例的还是返回 UI 的?为什么会反复执行?
  • 为什么组件函数从第二次执行开始行为不一致了?它怎么知道之前的 state 值是什么?
  • 为什么 hooks 不能放在判断、循环里?为什么这玩意会依赖时序?
  • 为什么 useEffect 第一个参数竟然还可以返回一个函数来清除副作用?
  • 为什么 useEffect 第二个参数可以有这么多种填法?
  • 为什么我有时候拿到的 state 不是最新的?
  • ...

某些 React 大佬可能会嗤之以鼻,诚然,他们可能写了好多年的 hooks,对大多数 API 如数家珍,思维方式已经被 React 同化,觉得这是看看文档就能理解的东西。但这样是非常主观的,评价设计好坏不能以自身的习惯来看,而是要基于领域公共知识和大多数受众的认知水平来评价。我以上列举的问题大多是普遍客观存在的,也是 hooks 独有的特殊设计和语法,纵观主流框架和库,都难以找到这些怪异设计的影子。挨个对照来看,这些问题也基本契合我上文对【噩梦级】的定义。

当年,在我把这些 React 强加的定义和规则大概理解后,不禁感叹,之前那么多年吐槽过的各种框架的黑魔法在 React Hooks 面前根本不值一提,伏地魔来了都得认 Dan 作父。因为 React 已经在创造新的语义和世界法则,这远远超出了魔法的范畴。

作为软件行业的技术员,我觉得合格的工程师应该有起码的一点追求,怪异的东西就是怪异的,不可能因为写多了习惯了,就掩盖了它本身是一坨外星杂交大便的事实。

Hooks 到底是什么怪物?

在早期,类组件是个妥协的产物,唯一函数式的部分只有不可变数据和渲染函数。彼时,React 的心智负担还没那么高,如果你可以尽量让自己避免「状态驱动」的思维和减少手动性能优化的强迫症,那么 React 其实还算是个 DX 相对友好的库。

但 React 团队痴迷于无状态函数组件的优雅,想用一种模型调和两者,自然而然会想到在现有函数组件的基础上设计一种有状态函数组件。而当设计完成,他们可能才意识到,自己并没有统一两种函数组件,而是创造了一个新的怪物。尽管两者在代码上看起来十分相近,但实质上,这两种组件代表了截然不同的数学模型。

在试图剖析有状态函数组件前,让我们先铺垫一些简单概念和背景。为了便于描述,下文的 hooks 均指代有状态函数组件。

背景

我们都知道,要按照函数式思想描述 UI,那么一定是每次传给函数状态,函数返回计算后的 UI,它应该是纯函数。前些年的文章里经常出现一个公式 $UI=f(state)$,虽然似乎并不出自 React 官方,但因为大家对它很熟悉,我们不妨借用它来辅助理解。请注意,这个公式只能用来描述渲染函数,和「组件」这个概念无关。事实上,要真正实现 UI 完全函数式地表达,需要的参数远不止狭义的 state。这有点类似于刘慈欣的短篇小说《镜子》中的可以模拟宇宙的超弦计算机,有足够的参数,就可以计算出宇宙在任意时刻的切面。而函数式 UI 这种理想化表达也像这样充满了数学的浪漫,实际上应该是 $UI=f(props, state, context, effects, ...)$,或者说 $UI=f(world)$,这才是真正的函数式 UI。但几乎没有人愿意用这种方式描述 UI 世界。

为了降低描述 UI 的难度,使其更接近大多数人对真实世界的认知,引入「组件」是一个非常有效的方式。

要简化函数式表达所必需的世界参数,最自然的方式就是把与这段 UI 相关的东西拎出来,使内层描述 UI 的函数是参数尽可能少的纯函数,由外层持有状态。同时因为前端的强交互属性,我们不可能渲染完一个 UI 就丢弃它,而是继续持有状态,随事件触发局部更新进而重新渲染。上面的这一层就是组件,这也是我们现在主流的 UI 建模的方式:基于对象。是的,没有人会用真正的函数式,包括 React 在内所有主流前端框架都是这个模式。

那么组件是什么?它是状态、事件处理、生命周期和 UI 描述的捆绑包的模板,对应于我们熟悉的类;基于这个可复用模板实例化产生的就是组件实例,对应我们熟悉的对象。哪怕强如 React 团队,为了让大众易于接受,在传统面向对象和函数式中间取得一个微妙的平衡点,也只能将组件基于对象建模,才是唯一符合大众认知的方式。人们习惯基于实体,以连续的变化描述事物,这也与现在浏览器和移动端 UI 普遍使用的保留模式一脉相承。Hooks 并没能摆脱之前为类组件设定的模型:基于对象的组件+函数式的渲染,反而带来了更多问题。

一切的源头:多实例模型

在这里我并不想用 FP 的一些理论去解释它,一方面是 hooks 对外表现的形式一点也不 FP,如果嫌 JS 拖后腿,请 React 团队发明一个新语言,而非基于 JS 去设计一些古怪的语法,现在的 hooks 明显有些两头不讨好。既然他们暴露如此不 FP 的形式给大众,那就无法阻止人们以正常的、非 FP 的思维去理解(Reacter 不应该用 React 思维和 FP 经验去指责他人);另一方面是,我太菜不会 FP。

现在让我们试图从另一个非主流角度出发,用一种不完全符合实际情况但更通俗易懂的方式转换概括 hooks 的运行过程:当每次触发渲染时,React 根据此时记忆的状态,创建一个空白组件实例,边运行用户的组件函数,边动态补全这个实例上的状态、注册生命周期等,待组件实例构建完毕(即 return 语句前),继续运行实例上的渲染函数,即可获得 UI 结果。这个实例是一次性的,仅服务于一次渲染流程,其会在没有引用后被释放。在此之外,还存在一些特例,暂不讨论。

这里我不得不借用实例的概念来描述,因为无论 React 多么想要屏蔽这个概念,实例都是真实存在的(否则为什么会存在 useRef 呢)。由于 React 将实例限定为服务于渲染的临时闭包,一旦涉及一些异步操作,就可能并存多个幽灵实例,进而导致臭名昭著的闭包陷阱等问题。此即 hooks 最关键的一项性质:多实例。

react hooks simple model

上文我们提到了主流框架都是基于对象的,因为状态需要连续变化,且事件处理逻辑、生命周期和渲染的上下文也基于同一个对象的作用范围。既然实例是临时创建的,那组件状态被存储在什么地方?答案是 React 框架内部,你无法直接触碰。这一点这是最骚的,你能想象你写的组件,状态竟然不是自己声明和持有的,而是交给 React 大人在黑盒中托管,需要的时候再去求吗?这是一件多么可怕的事情。而更可怕的在于,推崇纯函数的 React,它的有状态函数组件形式上是 100% 不纯的,其运行完全依赖于隐式执行上下文。表面上同一个函数,相同参数,但在运行时可能是基于 emptyStatestateV1stateV2 等等无限多种不同的情况。无法预测和判断代码运行在哪个实例中,让我在写 hooks 时十分不安。

至此,我们可以试验性地引出有状态函数组件的本质:以函数闭包形式承载的、基于隐式单实例的函数式动态多实例组件工厂。(抱歉,我已经尽可能去描述这种外星怪物了)

我试图琢磨 React 团队为什么将 hooks 设计为多实例模型,但没有找到什么有说服力的理由。如果要说这么做有哪些好处,我想大概是 React 成功地将函数式从渲染粒度推广到了组件粒度,并且成功地将副作用概念推广开来。是的,看起来是多么美妙的事情。如果把 hooks 写法脑补转换到某些 FP 语言或许是解释得通的。但问题在于,它当前在形式上并不函数式,借 JS 中普通函数的壳和若干面向过程代码来「转写」某些函数式语法,显得不伦不类,在整个 JS 领域知识中是无法自然解释的,这更像是用函数形式笨拙地模拟面向对象。作为使用者,只能为其偏执买单,承受巨大的心智负担。React 的设计总是「过分理想化的」,但现实世界并不是那么纯粹。所以 React 也不得不加入 useRefuseEvent 等补丁让开发者短暂地将世界修正为稳定状态。我甚至在 React 新文档中找到了他们一点微不足道的理由,让人有点哭笑不得:

react hooks multi instances demo in official docs

在如此巨大的负收益面前,我始终无法理解这么设计的原因,或许还是自己太肤浅了罢。欢迎有见解的同学指点。

荒诞的规定:时序

与古怪的多实例模型相伴的,是另一个让初学者上手即迷惑的设定:「时序依赖」。当理解了多实例模型后,虽然我可以理解时序设计的意图,但依赖时序来把状态声明和生命周期补齐的做法并不是很优雅,甚至可以说有点愚蠢。

事实上,有很多种方法来从隐世界中取回状态,哪怕在编译期做掉都比依赖时序要强。依赖代码运行时序保障正确性真的不是好设计,它不仅让代码变得无比脆弱,必须借助强加的规则和代码检查降低风险,同时还逼迫开发者理解框架的运行逻辑。从这一点上说,hooks 的设计并不成功。

混乱的世界:语义混淆

另外一个设计上的败笔是,为了靠函数语义,React 混淆了「组件」和「渲染函数」这两个概念。如果说无状态函数组件是 state 为空的类组件的语法糖,那么有状态函数组件是什么?为什么一个长的明明就是渲染函数的东西里面耦合了构造函数和生命周期?我认为 hooks 的设计初衷是有问题的,在函数式 UI 中,仅仅一个函数是不够表达「组件」这个概念的,因为组件不是产出 UI 的过程,而是一个围绕 UI 片段的,包含了与之相关的领域知识和管理手段的集合,除了产出 UI 的过程(渲染)以外还拥有持久化状态、生命周期、事件处理等能力。一味将二者等价只会带来歧义和混乱。

虽然但是,函数到底能不能描述组件呢?当然可以,但有个前提:放弃 FP。Solid 就是典型的例子,因为函数闭包一定程度上和对象是可以互相转换的,可以想象为一个语法糖,语义上并无太多问题。有趣的是,在一般情况下,反而 Solid 的函数组件在形式上是纯函数。

多元的想法:低约束力

如果你有阅读过 React 文档或经常参与一些争论,就会发现:无论是 React 官方还是 React 民间科学家,都经常就「React Hooks 的正确用法」向大众传道,比如 xx 用法是不对的、不应该 watch 之类。甚至 React 官网上都有长篇文章《You Might Not Need an Effect》让你学习他们的思想。

这里我并没有想说他们不对或者做法欠妥。只是提出一个疑问:当一个框架要费这么多功夫来从思维方式上引导和劝诫使用者的时候,到底是谁出了问题?


小结一下,本章节我们浅层次、试验性地分析了 React Hooks 的隐式单实例对象+符合函数式表达的多实例模型本质,并指出了其设计导致的闭包陷阱、时序、语义混淆等问题,肤浅地探讨了 hooks 设计的缺陷。

真香,吗?

在我接触 hooks 到现在的几年里,我一直无法理解各种社区里时不时冒出的「真香」的评价。为此,我搜索了很多文章和评论,试图理解他们的兴奋点。

逻辑复用:函数组合

从 hooks 开始,React 终于开放了在组件外定义状态和声明回调的能力,这使得状态和逻辑复用变得十分容易。而 React Hooks 巧妙地利用函数闭包的特性,推广了函数组合,这个模式也直接影响了 Vue 3。借助 ES 对象解构,它还可以比较好地规避命名问题,使用起来十分轻量和简洁。这也是我对 hooks 为数不多可以称赞的点。

但函数组合的流行,也让很多新手变得盲从和拒绝思考。在我接触过的前端方向的学生里,没有一个人可以说出除了 mixin 和函数组合以外的状态/逻辑复用的办法(Vue 那边也一样)。当然这也和网上一些吹捧 hooks 的文章有关,这些文章只告诉新手它是个万能的好东西。事实上,只要 UI 框架支持外部状态和生命周期合并,无论是引入函数、对象或是别的东西到组件,都可以正常工作,自然就可以很容易地实现组合,这并不是 hooks 独占。这里我暂且不提 MobX 之类的方案(总有人挑刺),由于 Vue 也可以实现一样的功能且代码易读,并且有社区库真实实现了这些功能,这里以 Vue 类组件(Options API 只能实现部分)的例子说明,React 类组件理论上也可以做到:

vue composition

代码组织:逻辑关注点聚合

这似乎又是 React Hooks 和 Vue Composition API 共同的一大卖点,Vue 的文档上甚至给出了浅显易懂的图示。它宣称可以将一类状态逻辑(包含状态定义、方法、生命周期回调等)在代码组织层面聚合在一处。我必须承认这种模式的优点,对多数情况,按此方式聚合将会让逻辑变得相对清晰,很容易阅读相关的一类逻辑。这种模式的实现,归功于函数化钩子(如 useEffectonMounted 等),它提供了另一种声明生命周期的代码组织方式,无需固定在指定的生命周期方法中,而是可以任意多次声明。

这种代码组织方式可能更像一种风格偏好,而非银弹。当代码量增大后,依然面临维护性问题。有的人可能会说,我可以把它们再抽离到不同的函数里去。但如果你已经养成了抽离的习惯,你会发现每个几十行的小函数即使不使用这种代码组织方式,也依然很容易维护。

但在理性讨论之外,在网上也经常看到一些盲目鼓吹甚至误导的言论。他们宣称只有类 hooks 写法才能解决此类问题,误导了很多初学者,属实是小丑罢了。继续以 Vue 为例:

vue logic aggregation


这个章节并非替面向对象挽回尊严,我反对的只是一些营销文中对某些技术的一味吹捧(除了显露他们的无知外,更多的是误导新手)。在我第一次看到这些特性时,只有很简单的感受:「哦,xxx 现在也能这么写了」,并不会觉得香。只是一些很基础的东西罢了,有什么好大惊小怪的?

社区影响力,天使还是魔鬼?

React Hooks 对前端业界的影响,我认为仅次于声明式 UI 的推广。由于 React 早年的先发积累和社区生态的繁荣(这是个中性词),hooks 可以说是一个里程碑式的节点,它的发布,意味着整个前端业界突然发生了一次急转弯,开始朝着 COP(Closure Oriented Programming,面向闭包编程)一路狂奔。

COP 并不是一个很常见的说法,或许是我自造的词,但我更倾向于使用这个名词。表面上看起来,越来越多前端框架和库选择抛弃 OOP(其中装饰器是重要的一个因素,这里不展开),转向类函数式的范式。但其实如果深究的话,哪怕连最函数式的 React 也离 FP 差很多,更别提其他几兄弟了。

有些同学可能会问,Vue Composition API、Solid 那些不都是函数式写法吗,和 React Hooks 一样啊?这其实是不正确的。我们不谈 React,来分析下其他几兄弟。

首先贴一段 Vue 组件的代码,左边是组合式 API(COP),右边是改写的类组件(OOP),两者基本等价且可以互相转换:

vue transition

再看一段 Solid 的例子:

solid transition

看出来什么规律了吗?因为它们选择的组件语义都是「对象」:初始化一个实例,UI 的初态由实例化时的状态决定,后续状态改变会在当前 UI 上增量变更。举一个现实世界的例子,当某个时刻需要为我的房子添一把椅子的时候,面向对象的做法是创造一个椅子,然后安静地搁在房子里的某处;而函数式的做法是计算得到一个包含椅子的状态参数,重新创造一个有了椅子的新房子,然后将我传送到新房子(当然在 React 中,有时候我可能会因为部分时候还留在旧房子里而思考世界 Online 的真实性,笑)。对应一下这个例子,你会发现 Vue、Solid 等框架都符合面向对象的做法。

很多人在对比 React 和其他框架时说,它们的主要区别是不可变数据 VS 可变数据。当然这说的也没错,只是我认为这并不是本质区别。其实,只有 FP 要求不可变,而 OOP 可变或不可变都可以。它们真正的区别在于所选范式导致的模型和语义差异。

与标准面向对象不同的是,Vue setup、Solid 等都是以函数闭包的形式描述组件,它们在首次运行后产出一个闭包,后续的一切更新都在此闭包上增量进行。闭包和对象在很多场景下可以相互转换。在 JS 中,以穷人的对象来比喻闭包再合适不过了。函数闭包的好处我们上文有提到,这里不再赘述。但代价是什么呢?代价是很难被结构化观测和包装,在面向闭包的范式下,要实现常见的自动依赖注入、装饰、切面、动态替换等特性并不容易,对框架和库的增强变得困难,同时在应对复杂项目时,也难以应用某些设计模式。

在 React Hooks 带起的函数闭包狂潮中,有人说其他的框架「没有 React 的命,却有 React 的病」,虽然有些偏激了,但确有那么一点意思。这些框架愈发朝着「简单化」的方向发展,甚至误打误撞让一些老技术焕发了第二春,它们似乎终于找到了救命稻草,以此逃避发展缓慢的 OO 方向。虽然我完全认同一些框架维护者的决定,他们必须考虑历史包袱和现状,函数闭包是最佳的选择。但我的立场是鲜明的:COP 绝不是一个正确的方向,它没有太多前景。这场「自我退化的大逃难」注定会让前端业界继续曲折前行多年,但未来是属于开拓者们的。

你可能并不需要 React Hooks

从前,我以为前端 React 大佬们各个都是 React 专家、FP 宗师,各种理论、范式和数据流经验信手拈来,而且这类大佬还相当多,哪怕在我自己的工作环境中都有大量这样的人。但通过这些年的观察,使我明白了一个事实:并没有多少人真正研究和使用 FP,仅仅一个 hooks,大家就可以把以前奉为真理的设计模式直接扔掉,转头就去写一堆和 FP 没什么关系的函数来复用进组件里。

没错,大众需要的根本不是 FP,也没有什么 FP 信仰,他们需要的只是一个能帮助他们更高效解决问题的工具。人们喜欢的是函数组合、TS 支持、JSX 等便利,而不是什么不可变数据、多实例、effect 心智负担、闭包陷阱等由 FP 设计带来的负收益。

从这个角度上来说,你可能并不需要 React Hooks。如果你喜欢开箱即用的全家桶生态、追求较低心智负担,Vue 完全可以满足你的需求,它的组合式 API 拥有基本碾压 hooks 的设计,性能不错,能写 JSX,TS 支持也上了一个台阶;如果你追求更自然和更低心智负担的模型,Svelte 是个不错的选择,从一些独立项目和小组件写起会非常舒适;而如果你更喜欢接近 React 的自由,那么也可以试试 Solid,其信号模式比 hooks 更容易理解和使用。至于 Angular、Qwik 等我们就不再提了,都是优秀的项目。在 React 中要自损八百才能享受到的便利,用其他框架可能只需要自损一百。如果有机会,为什么不去试一试呢?

那 React 真正无可匹敌的优势到底是什么呢?是生态。但在 2023 年,生态的优势或许已没有那么大了。同时,一些负面教材(如 React Router、Remix 等)也让我们见识到了,把别人几年前做的东西实现或包装一遍去宣传,也是能取得很高话题度的。但这些闭门造车、圈地自萌的生态,真的有很大的价值么?

结语

本文从 React Hooks 的设计、本质、社区影响等方面讨论了 React Hooks 存在的问题及其背后的意义,试图给社区带来一些不一样的声音。毕竟好话说的够多了,但如果变成宗教就是另一种极端,不是吗?

本文属于「前端娱乐圈杂谈」系列,通过输出一些令人不那么愉快的看法,给前端圈降降火。不求改变浮躁风气,只为随便吐槽调侃。您呐,看个乐子就行。

参考文章


注:本文经过两轮迭代,大幅降低了攻击性。


bLue 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。
本文地址:https://dreamer.blue/blog/post/2023/05/19/why-i-still-do-not-like-react-hooks.dream

还不快抢沙发

添加新评论