likes
comments
collection
share

学会使用 react hook,从转变心智模型开始

作者站长头像
站长
· 阅读数 60

在 react hook 出现之后,我们经常会听到这种言论:“你觉得 react hook 用的不爽是因为你心智模型不对”,“经常遇到闭包问题是因为心智模型没有调整过来”。

但是很少有人会讲这个所谓心智模型到底是什么,应该怎么调整才能适应 react hook 的开发流程。本文就来盘一盘这个心智模型究竟是什么东西,让你对 react hook 的理解更加深刻透彻。

什么是心智模型?

不搞那些复杂的心理学概念,通俗讲心智模型就是你遇到一个问题时“如何进行思考”,说白了就是思维定势。当你长时间使用一个工具(例如 react 类组件)解决问题之后,你的脑子里就会渐渐的积累起这个工具的使用经验。通过代入这些经验,你就可以更加快速的解决问题。

通常情况下这是很好的,但是当遇到一个新工具时这种经验就有可能会造成一些阻碍,特别是当新工具和旧工具比较像的时候。就会下意识的把新工具带入到老的经验里,从而导致一些问题。

而在 react hook 这里就尤为突出,核心原因还是这两种写法实在是太像了:

学会使用 react hook,从转变心智模型开始

你可以说有很多细节存在不同,但是整个组件的开发流程和结构布局是相同的,先是声明一堆数据,然后写一堆方法,最后返回一个 jsx。

这种类似导致很多人下意识的认为两者是类似的(但实际上两者的本质区别很大),然后使用老的经验去使用 Hook(甚至自己都没有意识到),从而产生了各种问题,比如下面这个。

函数式组件经典的闭包问题

hook 出来之后争论最多的一点就是 react 函数式组件的闭包问题,各种闭包陷阱之类的说法铺天盖地。这其实就是心智模型没转变过来导致的,我们先来看一个简单的闭包例子:

const func = () => {
  let val = 1;

  const showVal = () => {
    val++;
    console.log(val);
  }

  return { showVal };
}

现在我们调用这个 func 两次:

const obj1 = func()
const obj2 = func()

现在问你,调用 obj2.showVal() 可以访问到 obj1 里的 val 变量么?很明显肯定不能,它们两个是不同的闭包,怎么可能相互访问。

那么问题来了,react 函数式组件的运行机制和上面这个例子一模一样,为什么我们会奢望在后续的闭包(后续渲染)中可以访问到前面闭包(曾经的渲染)的数据呢?

原因在于:在思考上面的例子时,我们是以 函数和闭包为本 的概念去思考的,在这种思考方式下,产生闭包并且无法相互访问才是正确的。

而在 react 开发中,我们是以 类组件为本 的模式去思考的。而类组件是有一个明确的“实例化”概念的。类组件里方法和数据声明出来之后就会一直存放在那里,无论什么时候,你 this.data.myVal 都可以访问到这个值的最新状态。所以在函数式组件里,我们下意识的就会觉得 useState 里出来的值是唯一存在的,与渲染了多少次无关。

但是函数式组件每次渲染都会重新创建一份新的,这个分歧让很多人觉得很难受,总觉得损耗了很多性能。特别是使用 useCallback 去“缓存”方法,然后一不小心访问到老数据的时候。这种不爽会达到顶点:“什么鬼,不仅要手动优化,还有可能会获取到旧的值,每次渲染都会闭包出一个新的切片,这么恶心的做法是谁想出来的”

不同的心智模型会带来不同的思考路线

通过上面这个例子,我们可以发现,不同的心智模型真的会产生影响,一个模型里理所应当的事情,在另一个模型里却会带来很多的困扰。

不知道你曾经有没有过这种经历:一个问题苦思冥想半天始终无法解决,哪怕勉强解决了也是弯弯绕绕好多,十分难受。但是放下这个问题过一段时间之后再来看,就会突然想到应该怎么解决,并且解决方案简单直接,十分顺畅。

这个其实就是因为一开始我们被错误的心智模型困住了,俗称钻牛角尖了。

那正确的 hook 心智模型应该是什么样的呢?

相信你之前应该看过这样的说法:react hook 的书写方法更加的“声明式”,你只需要关心什么时候会变,以及变化会导致什么后果就可以了。这句话其实就说明了 hook 心智模型应该是什么样的。

怎么理解呢?比如一个简单的流程:修改搜索条件 > 点击搜索 > 表格更新。

在点击搜索按钮时,我们会更新(setState)“筛选条件”这个状态。注意!搜索按钮的回调里只干这一件事就行了!不需要直接调用接口请求数据。

不直接调用接口的原因在于:点击搜索按钮和调用接口请求数据之间并不是强关联的。点击搜索按钮的作用只是更新搜索条件。而筛选条件更新,则恰好会导致需要重新调用接口请求数据。前者说明了“什么时候会变”,后者说明了“变化会导致什么后果”。

为什么要进行这个拆分,而不是直接在按钮回调里调用接口呢?原因在于 重新调用接口请求时并不关心是谁导致了重新调用。比如搜索时会更新筛选条件,切换页码的时候会更新筛选条件,切换 tab 的时候也会更新搜索条件。那岂不是每个事件回调里我都需要手动调一次接口请求么?

学会使用 react hook,从转变心智模型开始

而如果我们把 更新状态和消费状态分开 的话,整个组件的逻辑就会清晰很多:

学会使用 react hook,从转变心智模型开始

其实想一下就能发现,方案2更符合单一职责原则,所以实际的开发时逻辑结构会更清晰。那这就引出了两个问题:为什么类组件时期没有使用方案2进行开发? 以及 在函数组件里使用方案1会导致什么问题?


先回答第一个问题。

实际上,类组件时期已经有使用方案2进行开发的范式。redux 和 redux-react 最火的那段时间,状态和异步接口请求都是由 redux 的中间件例如 redux-thunkredux-saga 负责的,然后会通过 redux-react 的 connect 注入到组件的 props 里。最后在类的 componentDidUpdate 里对比新旧 state,如果发生变化就调用 props 上注入进来的异步接口请求方法。

可以看到这个方案就已经实现了更新状态和消费状态分开。

但是说实话,这个逻辑很绕,一方面需要手动比对新旧 state,另一方面所有的比对、重新请求的操作都被集中到了 componentDidUpdate,导致这个方法异常臃肿。

并且这个逻辑链条不够直观,步骤之间没有很强的关联性,导致新人很难理解这个组件里发生了什么。进而导致这种开发方案比较难以推广开来。毕竟第一种方案则明显更符合直觉。

而 hook 的 useEffect 就完美的解决了这个问题,一方面不再需要手动对比,另一方面 useEffect 可以有很多个,从而拆分了巨大的 componentDidUpdate


然后是第二个问题,在函数组件里使用方案1会导致什么问题?

答案是会导致开发变得恶心。设想一下,如果搜索表单、分页、tab 或者有很多地方都会触发接口查询的时候。我们一般都会在组件里封装成一个函数,然后函数的入参是新的搜索参数,这样就可以在多个回调里直接调用了。

但是坏就坏在函数式组件每次渲染都会产生一个闭包,这在请求比较简单时是没问题的,但是如果你的接口请求需要从六七个状态里组织请求参数时,那一不小心就有可能会获取某个旧状态。为了解决这个问题,你可能会尝试使用 useRef,确保能获取到最新的值,然后就又要注意在状态更新时同步更新 useRef,最终整个组件里都充斥着各种状态同步的代码,但是测试依旧能从各种犄角旮旯边界条件里挑出来 bug 说为什么接口的参数跟我点的不一样。

我见过非常多新手写出过类似的代码,一个简单的列表查询里都能用到好几个 useRef,而仅仅是为了解决“我老是拿不到这个状态的最新值”。这其实就是在用类组件的心智模型去用函数式组件。

忘掉生命周期

另一个很典型的例子就是,很多人会问 如何在函数式组件里模拟各种生命周期函数?这其实也是一个比较大的误区。因为按照 react hook 的思想来看,开发者不再需要关心生命周期了。

比如说,很多 hook 新手遇到的第一个难题就是,我进入表单详情页时要调用接口通过 formId 获取数据,那就应该写在组件挂载的回调里,那函数式组件怎么访问组件挂载的生命周期?

然后百度之后发现,哦,应该这么写:

useEffect(() => {
  // ...
}, [])

然后写着写着就被 lint 教育了:

学会使用 react hook,从转变心智模型开始

这个问题真的非常常见,几乎每个学习 react hook 的人都会遇到。但是,我们静下心来想想:

真的应该在组件挂载时调用接口么?

答案是否定的,这只是类组件的心智模型(即命令式、而非声明式的解决问题)导致的。你真正要实现的需求是:formId 变化时发起接口查询。只不过 formId 的变化恰好和组件的挂载时刻重叠了而已。

并且可以思考一下,如果我从表单A直接跳转到表单B,这时候 formId 变化了,但是组件并没有卸载再挂载,而是直接复用了。如果我们是在组件挂载时调用的接口,那表单B页面上看到的不还是表单A的数据么?

这个问题在 Vue 里也会出现,所以也应该监听 this.$router 的变化而不是直接在组件挂载时请求数据。

让我们回到上文中提到的核心思路:关心什么时候会变,以及变化会导致什么后果

按照这个思路来分析上面的例子:

  • formId 什么时候会变,答:路由更新时会变,这不是我们组件需要关心的。
  • 那 formId 变化会导致什么?答:会导致需要重新调接口发起查询。

ok,答案就出来了:

useEffect(() => {
  // ...
  fetchFormData();
}, [formId])

我们不需要关心什么时候 formId 会变,不需要去关心什么生命周期,更不需要关心 hook 里怎么模拟各种生命周期。只要知道 formId 变化会导致接口调用,然后知道 useEffect 怎么用,那代码应该怎么写就自然而然的出来的。

总结

其实这篇文章并没有涉及太多代码相关的内容,大部分都是思维链条的介绍(即如何用 react hook 的方式去推理),核心也非常简单:关心什么时候会变,以及变化会导致什么后果

不过说起来简单,想要深刻理解这句话还是要经历大量的代码实践才行,我就是在不断的“用类组件的方式思考 > 组件逐渐变大 > 越来越难以维护”的试错过程中意识到自己思维模式的问题,然后才开始真正的理解上面这句话并开始把这句话实践在开发中,并惊讶的发现开发时要思考的内容一下子少了很多,但应用依旧保持很好的稳定状态。哪怕是应用状态十分的复杂也是如此,出现问题时只需要检查谁变了,什么时候变了,变了导致了什么,就可以很快的定位到问题。

下篇文章我会介绍一下试错过程中遇到的一些常见问题,比如 useEffect 被频繁触发、第二个参数太长了,useState 太多了管理起来很麻烦 应该怎么解决,然后分享一下我是如何设计一个函数式组件的。感兴趣的同学可以点个关注哦。

参考

转载自:https://juejin.cn/post/7241813423461515323
评论
请登录