菜鸟日记 | 一点React组件设计的思考

126 阅读7分钟

写在前面

笔者是一个非科班转行的菜鸟前端,从零开始,边学边写,技术栈主要是React + TypeScript + Nest.js,写这个系列的文章就是想记录一下技术方面的学习总结和一些零散的思考。

什么是组件

什么是组件呢?网上有很多不同的答案,以我的理解,组件就是构成页面的最小元素,由于平时工作大多使用函数组件,React官方也不再推崇Class组件,可以将组件狭义的定义为一个接受任意入参,返回JSX的函数。

组件还可以根据是否有私有数据和生命周期细分为有状态和无状态,实际开发的主要难点在于有状态组件。做个粗略的类比,服务端接口设计大概是 entity=>dataentity => data,在看到需求后,要先思考用什么样的实体(Mysql表)存储并维护,再思考用什么样的数据(接口协议)表达。那么组件设计大概就是state=>UIstate => UI,思考要用什么状态存储并维护,再思考用什么样的JSX(HTML\CSS)表达。

组件设计思路

当初学习React很匆忙,看看基本概念和示例代码就直接写entry task,直到有一天再重读文档,才发现React哲学这一节写满了精华。以下是我根据React官方文档,结合个人的工作体会总结的设计思路。

  1. 分析UI的JSX结构
  2. 分析UI所需数据
  3. 分析状态
  4. 分析页面的数据流

分析UI的JSX结构

个人习惯先梳理成UI的骨架,一个页面大概分为几个部分,每个部分各自有什么HTML元素构成,是否有重复或类似的结构可以封装,这一步做好了就可以直接写出静态页面了。

分析UI所需数据

数据一般可以粗略分为静态和动态两种,静态数据在浏览器加载页面资源后就是已知的且不可更改,一般可以抽成配置。而动态数据往往依赖于业务请求或页面交互,需要用状态去存储,也是这一步的重点目标。 那什么样的数据可以用状态存储呢?React官方文档有段话形容的很好:

stateprops之间的区别是什么? propsstate都是普通的 JavaScript 对象。它们都是用来保存信息的,这些信息可以控制组件的渲染输出,而它们的一个重要的不同点就是:props 是传递给组件的(类似于函数的形参),而 state 是在组件内被组件自己管理的(类似于在一个函数内声明的变量)。

因此,凡是影响页面渲染,无法从组件参数获取,也无法从其他状态计算得到的动态数据,都可以认为是状态。

分析状态

这一步主要是确定状态的维护层级,数据结构和依赖项。笔者的日常工作基本是useState + useEffect + useRef一把梭,可以粗暴的归纳为useState存什么东西,useState在哪个组件存,useEffect写什么逻辑。

以admin系统常见的列表页为例,页面主体由Form和Table构成,Form用户输入数据的筛选条件和对数据进行编辑操作,Table用于展示数据。那么父组件很明显就是页面的Content,两个子组件分别是Form和Table,列表数据、查询条件、表单的交互UI这些都会影响页面渲染,需要用状态去存储。而维护层级显然是父组件更合适,因为父组件渲染本身就需要这些状态(比如页面的全局loading,列表的分页器等),如果维护在子组件,父组件获取或修改状态会很不方便,而父组件维护并以回调的方式传给子组件可以轻松解决。另外,页面的loading状态和列表数据也是有关联的,页面一打开就要查询初始的列表数据,显然loading此时应该为true,查询完毕再置为false,这个关联性需要一个useEffect表达。useRef一般用来存储DOM的引用,常配合useImperativeHandle使用,但不局限于此,可以存储任何在组件生命周期内不希望被渲染修改的变量。

这个例子比较简单,应该没有什么疑问,但是复杂场景下这一步很容易出问题。

最近遇到的一个例子就是在弹窗组件中展示多个item,每个item都允许用户编辑并保存,同一时间只允许一个item被编辑,弹窗的初始数据影响表单UI,表单内的交互也影响到整个弹窗下方的confirm按钮。开发者先写内层的表单组件,内部维护输入框交互需要的状态和表单值,接受回调修改父组件的状态,同时父组件依赖于子组件状态,通useEffect绑定依赖。这种状态维护方案存在冗余,并且会带来一个问题:子组件执行父组件传入的回调时,会引发全局重新渲染,子组件的状态被重置,甚至引发无限渲染。

究其原因还是没想清楚就写代码,写着写着发现父子组件数据之间有关联,就开始扩展参数堆状态,最后耦合过重,代码混乱,BUG太多甚至还要返工。解决方案之一是将状态全部上移至父组件维护,子组件只负责渲染UI。也许有其他更好的方案,但迫于需求时间压力,暂时没有尝试。

分析页面数据流

React是单向数据流,理清楚页面的数据流向是为了能更好确定组件协议,上一步处理的是组件内的状态管理,这一步是处理组件之间的通信问题。

首先要划分好各组件的职责,尽量清晰且单一,不要把过多事情糅杂在一起交给一个组件去做。 然后确定组件协议,这个过程类似确定接口协议,根据组件的职责就能知道组件需要什么参数,返回什么样的JSX,暴露给外部什么方法。 上一节的反面案例就可以看出,子组件的职责是展示item的数据,支持用户对某一item的修改。如何获取用户输入值,表单校验逻辑,用户输入值会引发什么UI变化(比如confirm是否可以点击)等等,都应该由父组件处理,再通过onChangeonClick等回调传给子组件,子组件不需要关心数据的变化和每个表单事件对应的逻辑,只需要在父组件影响下重渲染和执行回调即可。

有了协议,最后再整理下组件间的通信,一般分为四种:

  1. 父组件——子组件
  2. 子组件——父组件
  3. 跨级组件
  4. 兄弟组件

其中#1和#2比较简单,通过props和callback就可以解决。

跨级组件是指多于2层的组件通信,比如爷——父——子这种三层结构,一种糟糕的方案是从最顶层组件开始逐层往下透传,这种方式很容易漏传,需求变动时又要每层都做修改,DEBUG也很麻烦。目前笔者的常用方案两种:

  1. 使用上下文useContext
  2. 使用全局状态

笔者更倾向使用全局状态,一般用Redux或基于Redux的全局状态管理工具(比如Dva)实现。这个方案参考观察者模式,全局状态Model是被观察者,各组件是观察者,当状态更新时Model需要通知各组件,组件接受回调Dispatch,修改状态需要通过Dispatch传入指定结构的Action触发,由Model的Reducer修改,具体原理不做赘述。

兄弟组件之间的通信,一种糟糕的方案是子组件可以修改彼此的状态,并将方法暴露给父组件,再以回调的方式传入。比较推荐的方案是将共享的状态提升至父组件统一维护。