浅谈 React 中的组件设计
组件是构建 React 应用的基石,良好的组件设计,可以最大程度的发挥 React 的性能优势,也方便日后代码的维护与迭代。很多新手刚刚接触 React 时,不懂得合理的拆分设计组件,总是习惯性地写一个很庞大的 Page 组件,然后将所有的元素,所有的业务、交互逻辑写在一起。我相信很多人看过这样的代码,可能也曾经写过这样的代码,那么,这样写代码有什么问题呢?我们为什么要细粒度的拆分组件?如何去合理的设计组件?带着这些问题,我们一起去谈一谈 React 中的组件设计。
为什么要拆分组件
基于 React 框架的特性,以及编码原则,我们从以下三点去分析组件拆分的必要性:
- 复用性:将功能和 UI 展示相似的模块抽离成单独的组件,可以方便后续使用,提高开发效率。
- 代码可扩展性与可维护性:想象一个有着 2000 行代码的巨石组件,里面充斥着大量的
state
,useEffect
,handleFuncs
,看到这样的代码,谁不迷糊,即使产品只提了一个很小的功能改动,你都很可能要阅读整个文件代码,梳理逻辑,小心谨慎以免翻车。反之,如果这样的巨石组件被合理的按照业务逻辑、功能模块拆分成多个细小的单一职责的组件,组件树结构清晰,代码阅读也会通畅,任何一个功能的改动只需要定位到相关的组件即可,降低了心智负担,提高了工作效率。 - 性能:在 React 中,
state
的变化会触发组件的重新渲染,如果整个页面只有一个组件,那么这个组件内部就包含了大量的state
,任何一个state
的变化,都会这个巨石组件的重新渲染。反之,如果这个巨石组件被合理的拆分成多个子组件,那么状态也被合理的分散到各个子组件内部,子组件内部的state
变化只会导致当前这个子组件的渲染,不会影响其他子组件。
巨石组件的拆分
在 React 中合理的拆分巨石组件(也叫做大型、巨型组件),可以帮助我们构建更具有可维护性和可扩展性的应用程序,巨石组件的拆分,应该遵循以下几点:
- 按照功能模块拆分:不同功能模块之间的联系不大,因此,将他们拆分成不同的组件,使得组件树结构清晰,符合直觉。
- 单一职责原则:单个组件应该尽量简单和原子化,如果一个组件开始负责多个职责了,那么就该考虑拆分它了。
- 区分容器组件和 UI 组件:容器组件用来组合 UI 组件、承载业务逻辑,UI 组件只负责界面的展示和部分交互逻辑。这种分层设计有利于降低组件间的耦合度。 4.复用:检查代码中是否有类似的 UI 代码块,抽离成 UI 组件以实现复用。如果想要实现逻辑的复用,Class Component 使用高阶组件(HOC)、Function Component 使用自定义 hook。
如何设计组件
React 官方文档中Thinking in React一文介绍了如何构建 React 组件:
- 将设计稿转化为组件树:当我们看到设计稿后,我们通常需要跟设计师去讨论一下如何去拆分组件,设计师在设计过程中,会有一些初步的组件划分考虑,优秀的设计师甚至已经为每个组件提供了合理的命名。然后,以单一职责原则为基础,可以得到开发需要的组件树。
- 创建组件的静态版本(无 state):静态版本是指先构建组件树的 UI 部分,而忽略交互部分。这个过程需要大量的编码,几乎不需要任何思考,添加交互的时候是相反的,需要思考很多(考虑交互逻辑与业务逻辑),编码量却较少。在这一步,有两种方式来构建这个组件树,自上而下或者是自下而上,自上而下是符合直觉的,比较适合构建简单的应用。自下而上是先写底层的组件,然后通过组合的方式构建上层组件,这种方式能够适应组件功能的不断变化和迭代。这种设计的组件架构具有较高的可扩展性和可维护性。
- 确定组件的 state:根据以下原则确定表示组件状态的最小 state
- 它是否是一直不变的,如果不变,那就不是 state。
- 它是否可以从 Props 中获得,如果可以,那就不是 state。
- 它是否可以从其他 state 中计算而得,如果可以,那就不是 state。
- 确定 state 应该放在什么地方:
- 状态提升:如果两个不同的组件需要用到相同的 state,那么就要把这个 state 提升到他们的最近公共父组件中。
- 状态就近:state 应该尽量靠近使用到它的地方。如果一个 state 只在一个组件内使用到,那它就应该放在这个组件内部。不必要的状态提升会导致 re-render 及相关性能问题。
- 添加反向数据流:React 中通过 Props 在组件树中传递单向数据流,但是如果我们需要改动 Props 中的数据,我们不能直接在 Props 中直接更改数据,因为 React 中假定 Props 是不可变的只读数据。我们可以通过添加 callback props 来反向传递数据,从而改变上层组件的内部状态。
自上而下 vs 自下而上
上面我们提到了构造组件的两种方式,分别是自上而下和自下而上,这两种方式代表了构建组件的两种不同的心智模型,随着组件的扩展,两种设计方式将会产生不同的结果。
自上而下
自上而下的构建方式,是大多数开发者在构建组件时最常用的心智模型,因为它是符合直觉的、直观的。这种方式也可以相对容易的快速构建组件。
我们在设计稿上粗略的分割一些区域,然后这些就是我们要构建的组件,这种拆分是粗粒度的,是凭直觉的。我们一开始不会过多的思考组件的内部实现和子组件划分,但是会考虑 Props 的设计。所以,这是我们为什么说自上而下是一开始相对容易的,符合直觉的,不用过多思考的构建方式。
以一个后台 admin 页面为例,这个页面是左右布局的,左边是侧边导航栏,右边是对应的内容区域,我们很自然的会把页面拆分为两个大组件,分别是<SideNavigation />
和<Content />
,然后以SideNavigation
为例,考虑到侧边导航栏需要一些列表项,可以得到下面的代码
const navItems = [
{ label: 'Home', to: '/home' },
{ label: 'Dashboards', to: '/dashboards' },
{ label: 'Settings', to: '/settings' },
]
...
<SideNavigation items={navItems} />
总结一下自上而下方式的一些特点:
- 从设计稿中粗粒度的划分组件。
- 组件是一个单体抽象,即它内部处理所有的逻辑与 UI。
- Props 的设计也是自上而下的,组件的 Props 会接收所有需要的数据,并在内部处理它们。
对于小型项目来说,这个方式没有问题,但是对于多人开发的快速交付的大型项目,在项目迭代的过程中,问题会越来越明显。
产品需求和设计稿发生了变化,我们需要给nav item
加上图标、分割线、需要设置它的类型是link
还是button
,需要知道当前是否是选中的状态等等,所以一些新的属性{ id, to, label, icon, size, type, separator, isSelected }
需要加到navItem
中,然后,在<SideNavigation />
组件内部去新增 Props 对应的逻辑。往往这些逻辑通常需要一些 if 分支来渲染不同的 UI,渐渐地,这个组件给人的感觉已经不是那么的简单纯粹了。
随着需求的一次次变更,组件的 Props 变的越来越复杂,内部的实现逻辑也越来越复杂,当其他人想要复用这个组件时,却发现它有着很复杂的配置,而其中的很多功能可能自己并不需要用到,这个时候会发现这个组件已然变成了一个有着复杂 Props 的巨石组件,而且复用性很差。这样的巨石组件通过 Props 接收了太多的状态和数据,管理了太多的state
,返回了太多的 UI。
巨石组件的一些问题:
- 由于过早的抽象而产生。大多是由于 DRY(dont repeat yourself)原则导致的。在项目的初期,我们可能会看到一些功能相似的组件,他们有着几乎相似的 UI 和逻辑,于是 DRY 原则促使我们将这些组件做一个合适的抽象,通过合理设计 Props ,我们得到了一个功能完善,可复用的组件。但是随着需求一次次的迭代,这种原本合理的抽象很容易被击溃,Props 变的越来越复杂,组件逻辑也越来越复杂,巨石组件渐渐诞生。过早的抽象往往意味着错误的抽象,不是说 DRY 原则有问题,而是我们应该做权衡,毕竟重构多个没有抽象的组件要比重构一个错误抽象的组件要容易的多。
- 降低了组件的复用性。在团队多人合作开发模式下,你可能会发现一些别的同事已经实现的组件,它与你需要开发的组件功能高度重合,但是又有一点不一样的地方,导致组件没法直接复用。又或者,你只需要复用别的组件的少部分功能,而不得不引入一个功能复杂的组件。所以,还不如选择复制关键代码,自己重新实现一个适合自己场景的组件。
- 性能问题。就像上面提到的“为什么要拆分组件”中提到的性能问题一样,巨石组件会导致 React re-render 问题出现。
自下而上
相比于自上而下,自下而上不那么直观,最初可能比较慢。因为这种方式会产生很多小组件,而实际中不是每个小组件在最初都需要可复用。所以前期需要花更多时间和努力,让复杂性被封装在每个小组件里。好处是长远看会更快,因为适应性更强。同时避免了单体巨石组件和前面介绍的他会带来的大量问题。
回到上面侧边导航栏的例子,自下而上的构建方式会产生下面的代码结构:
<SideNavigation>
<NavItem to="/home">Home</NavItem>
<NavItem to="/settings">Settings</NavItem>
</SideNavigation>
或者稍微再复杂一下的用法:
<SideNavigation>
<Section>
<NavItem to="/home">Home</NavItem>
<NavItem to="/projects">Projects</NavItem>
<Separator />
<NavItem to="/settings">Settings</NavItem>
<LinkItem to="/foo">Foo</NavItem>
</Section>
<NestedGroup>
<NestedSection title="My projects">
<NavItem to="/project-1">Project 1</NavItem>
<NavItem to="/project-2">Project 2</NavItem>
<NavItem to="/project-3">Project 3</NavItem>
<LinkItem to="/foo.com">See documentation</LinkItem>
</NestedSection>
</NestedGroup>
</SideNavigation>
可以看到,自下而上的构建方式的最终产物还是符合直觉的,它将巨石组件的 Props 和复杂内部逻辑拆分到各个相对独立、单一职责的组件内部,最后通过组合
的方式,构成一个大的组件。这样的方式有以下优点:
- 其他的开发者想要复用组件,只需要按需引入需要的组件。
- 支持 async import 和 code split。
- 通过
组合
避免 re-render。 - 每个子组件功能独立,方便后续单独迭代,不影响其他地方。