跟 React 学设计模式:掌握编程“套路”,打造高质量应用

这一讲将针对 React 中的设计模式进行探讨。
与性能优化的视角相似,当谈论 React 设计模式时,实际上是在谈论“React 组件的设计模式”。在 React 设计模式中,最重要、也是最为大家所津津乐道的几个模式分别是:

  • 高阶组件(HOC)
  • Render Props
  • 剥离有状态组件与无状态组件

除此之外,每个团队或许都会有自己的一套在实践中摸索出的设计模式方法论。这些形态各异的 React 设计模式虽然实现思路有所不同,但本质上基本都是为了实现这样一个终极目标——以尽可能优雅的姿态,实现组件逻辑的复用
而本讲接下来所要探讨的这 3 种模式,也无出其右。
在本讲,首先要做的第一件事情,就是把每一种设计模式到底是什么、怎么用给弄清楚。在这个过程中,我们会对“单一职责”和“开放封闭”这两个非常重要的设计原则有所认知。
在此基础上,我们还需要去思考这样一个问题:设计模式是否是万能的?如果不是,那么对框架来说,还有没有更加深刻、彻底的解法呢?
其实,最终的话题还是回到 Hooks 中来。接下来就先迈出第一步,来认识/重温一下高阶组件这个概念。

1、高阶组件(HOC):最经典的组件逻辑复用方式

1)什么是高阶组件

  • 高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。——React 官方

高阶组件(Higher Order Components)在概念上沿袭了高阶函数(Higher-Order Function)。在第 21 讲 ,已经和高阶函数打过交道,这里再复习一下高阶函数的概念:接收函数作为输入,或者输出另一个函数的一类函数,就是高阶函数
相应的,高阶组件指的就是参数为组件,返回值为新组件的函数。没错,高阶组件本质上是一个函数。下面是一个简单的高阶组件示例:

  1. const withProps = (WrappedComponent) => {
  2. const targetComponent = (props) => (
  3. <div className="wrapper-container">
  4. <WrappedComponent {...props} />
  5. </div>
  6. );
  7. return targetComponent;
  8. };

在这段代码中,withProps 就是一个高阶组件。

2)高阶组件是如何实现逻辑复用的?

现在考虑这样一种情况:有一个名为 checkUserAccess 的方法,这个方法专门用来校验用户的身份是否合法,若不合法,那么一部分组件就要根据这个不合法的身份调整自身的展示逻辑(比如查看个人信息界面需要提示“请校验身份”等)。
假如说页面中的 A、B、C、D、E 五个组件都需要甄别用户身份是否合法,那么这五个组件在理论上都需要先请求一遍 checkUserAccess 这个接口。但一个一个对组件进行修改未免太麻烦了,我们期望的是对“获取 checkUserAccess 接口信息,并通知到对应组件”这层逻辑进行复用,这时候就可以请出高阶组件来帮忙了。
可以像下面代码这样在高阶组件中定义这层通用的逻辑:

  1. // 假设 checkUserAccess 已经在 utils 文件中被封装为了一段独立的逻辑
  2. import checkUserAccess from './utils
  3. // 用高阶组件包裹目标组件
  4. const withCheckAccess = (WrappedComponent) => {
  5. // 这部分是通用的逻辑:判断用户身份是否合法
  6. const isAccessible = checkUserAccess()
  7. // 将 isAccessible(是否合法) 这个信息传递给目标组件
  8. const targetComponent = (props) => (
  9. <div className="wrapper-container">
  10. <WrappedComponent {...props} isAccessible={isAccessible} />
  11. </div>
  12. );
  13. return targetComponent;
  14. };

这样当需要为某个组件复用这层请求逻辑的时候,只需要直接用 withCheckAccess 包裹这个组件就可以了。以 A 组件为例,假设 A 组件的原始版本为 AComponent,那么包裹它的形式就是下面代码这样:

  1. const EnhancedAComponent = withCheckAccess(Acomponent);

通过简单地对高阶组件 withCheckAccess 进行引入,EnhancedAComponent 轻松具备了校验用户合法性的能力。这样一来,即便再多出 5 个组件想要引入 checkUserAccess也没问题——毕竟包裹五个组件和重写五段逻辑的工作量是没法相提并论的。
高阶组件不仅能够简化逻辑的引入过程,还可以有效规避掉逻辑变更带来的烦琐的修改步骤:假如这段 checkUserAccess 的逻辑是散落在 A、B、C、D、E 这五个组件之中的,那么一旦 checkUserAccess 的判定规则需要修改,就得需要去修改五段代码;但现在,checkUserAccess 被抽离进了一个独立的高阶组件里,在高阶组件中的一次修改,将在所有被它处理过的组件中生效0。
由此可以看出,高阶组件可以从根本上减少重复的编写和修改工作,这不仅是高阶组件这一种模式的利好,更是“逻辑复用”这件事情的意义所在

2、Render Props:逻辑复用的另一种思路

  • 术语“render prop”是指一种在 React 组件之间使用一个值为函数的 prop 共享代码的简单技术。——React 官方

    1)什么是 render props?

    render props 是 React 中复用组件逻辑的另一种思路,它在实现上和高阶组件有异曲同工之妙——两者都是把通用的逻辑提取到某一处。区别主要在于使用层面,高阶组件的使用姿势是用“函数”包裹“组件”,而 render props 恰恰相反,它强调的是用“组件”包裹“函数”
    一个简单的 render props 可以是这样的,见下面代码:

    1. import React from 'react'
    2. const RenderChildren = (props) => {
    3. return(
    4. <React.Fragment>
    5. {props.children(props)}
    6. </React.Fragment>
    7. );
    8. };

    RenderChildren 将渲染它所有的子组件。从这段代码里,需要把握住两个要点:

  • render props 的载体应该是一个React 组件,这一点是与高阶组件不同的(高阶组件本质是函数);

  • render props 组件正常工作的前提是它的子组件需要以函数形式存在

第 1 点相对浅显易懂,如果对第 2 点感到迷惑也没关系,接下来就直接来看 RenderChildren 的使用方式,请看下面代码:

  1. <RenderChildren>
  2. {() => <p>我是 RenderChildren 的子组件</p>}
  3. </RenderChildren>

RenderChildren 本身是一个 React 组件,它可以包裹其他的 React 组件。一般来说,我们习惯于看到的包裹形式是“标签包裹着标签”,也就是下面代码演示的这种效果:

  1. <RenderChildren>
  2. <p>我是 RenderChildren 的子组件</p>
  3. </RenderChildren>

但在 render props 这种模式下,它要求被 render props 组件标签包裹的一定是个函数,也就是所谓的“函数作为子组件传入”。这样一来,render props 组件就可以通过调用这个函数,传递 props,从而实现和目标组件的通信了。

2)render props 是如何实现逻辑复用的?

这里仍然以 checkUserAccess 这个场景举例。使用 render props 复用 checkUserAccess 这段逻辑可以这样做,请看下面代码:

  1. // 假设 checkUserAccess 已经在 utils 文件中被封装为了一段独立的逻辑
  2. import checkUserAccess from './utils
  3. // 定义 render props 组件
  4. const CheckAccess = (props) => {
  5. // 这部分是通用的逻辑:判断用户身份是否合法
  6. const isAccessible = checkUserAccess()
  7. // 将 isAccessible(是否合法) 这个信息传递给目标组件
  8. return <React.Fragment>
  9. {props.children({ ...props, isAccessible })}
  10. </React.Fragment>
  11. };

接下来 CheckAccess 子组件就可以这样获取 isAccessible 的值,见下面代码:

  1. <CheckAccess>
  2. {
  3. (props) => {
  4. const { isAccessible } = props;
  5. return <ChildComponent {...props} isAccessible={isAccessible} />
  6. }
  7. }
  8. </CheckAccess>

到这里,我们已经了解了“函数作为子组件传入”这种情况的来龙去脉。但其实,对于 render props 这种模式来说,函数并不一定要作为子组件传入,它也可以以任意属性名传入,只要 render props 组件可以感知到它就行
举个例子,可以允许函数通过一个名为 checkTaget 的属性传入 render props 组件,那么 CheckAccess 组件只需要改写一下它接收函数的形式即可,见下面代码:

  1. // 假设 checkUserAccess 已经在 utils 文件中被封装为了一段独立的逻辑
  2. import checkUserAccess from './utils
  3. // 定义 render props 组件
  4. const CheckAccess = (props) => {
  5. // 这部分是通用的逻辑:判断用户身份是否合法
  6. const isAccessible = checkUserAccess()
  7. // 将 isAccessible(是否合法) 这个信息传递给目标组件
  8. return <React.Fragment>
  9. {props.checkTaget({ ...props, isAccessible })}
  10. </React.Fragment>
  11. };

在使用 CheckAccess 组件的时候,将函数放在 checkTaget 中传入组件即可,见下面代码:

  1. <CheckAccess
  2. checkTaget={(props) => {
  3. const { isAccessible } = props;
  4. return <ChildComponent {...props} isAccessible={isAccessible} />
  5. }}
  6. />

像这样使用 render props,也是完全可以的。

3)理解 render props 的灵活之处

到这我们难免会产生这样的困惑:高阶组件和 render props 都能复用逻辑,那到底用哪个好呢?
这里先给出【结论】:render props 将是你更好的选择,因为它更灵活。 这“更灵活”从何说起呢?
render props 和高阶组件一个非常重要的区别,在于对数据的处理上:在高阶组件中,目标组件对于数据的获取没有主动权,数据的分发逻辑全部收敛在高阶组件的内部;而在 render props 中,除了父组件可以对数据进行分发处理之外,子组件也可以选择性地对数据进行接收
这样说可能会有点抽象,这里举个例子:假如说现在多出一个 F 组件,它同样需要 checkUserAccess 这段逻辑。但是这个 F 组件是一个老组件,它识别不了 props.isAccessible,只认识 props.isValidated。带着这个需求,先来看看高阶组件怎么解决问题。原有的高阶组件逻辑是下面这样的:

  1. // 假设 checkUserAccess 已经在 utils 文件中被封装为了一段独立的逻辑
  2. import checkUserAccess from './utils
  3. // 用高阶组件包裹目标组件
  4. const withCheckAccess = (WrappedComponent) => {
  5. // 这部分是通用的逻辑:判断用户身份是否合法
  6. const isAccessible = checkUserAccess()
  7. // 将 isAccessible(是否合法) 这个信息传递给目标组件
  8. const targetComponent = (props) => (
  9. <div className="wrapper-container">
  10. <WrappedComponent {...props} isAccessible={isAccessible} />
  11. </div>
  12. );
  13. return targetComponent;
  14. };

它会不由分说地给所有组件安装上 isAccessible 这个变量。要想让它适配 F 组件的逻辑,最直接的一个思路就是在 withCheckAccess 中增加一个组件类型的判断,一旦判断出当前入参是 F 组件,就专门将 isAccessible 改名为 isValidated。
这样做虽然能够暂时解决问题,但这并不是一个灵活的解法:假如需要改属性名的组件越来越多,那么 withCheckAccess 内部将不可避免变得越来越臃肿,长此以往将难以维护。
事实上,在软件设计模式中,有一个非常重要的原则,叫“开放封闭原则”。一个好的模式,应该尽可能做到对拓展开放,对修改封闭
当发现 withCheckAccess 的内部逻辑需要频繁地跟随需求的变化而变化时,此时就应该提高警惕了,因为这已经违反了“对修改封闭”这一原则。
处理同样的需求,render props 就能够在保全“开放封闭”原则的基础上,帮我们达到目的
前面说过,在 render props 中,除了父组件可以对数据进行分发处理之外,子组件也可以选择性地对数据进行接收。这就意味着可以在新增的 F 组件相关的逻辑中实现数据适配(如下面代码所示),而且不会影响老的 CheckAccess 组件中的逻辑。

  1. <CheckAccess>
  2. {
  3. (props) => {
  4. const { isAccessible } = props;
  5. return <ChildComponent {...props} isValidated={isAccessible} />
  6. }
  7. }
  8. </CheckAccess>

这样一来,不管新增多少个组件,需要变更多少个属性名,影响面都会被牢牢地控制在“新增逻辑”这个范畴里。契合了“开放封闭”原则的 render props 模式显然比高阶组件灵活多了

3、有状态组件与无状态组件:“单一职责”原则在组件设计模式中的实践

1)什么是“单一职责”原则?

单一职责原则又叫“单一功能原则”,它指的是一个类或者模块应该有且只有一个改变的原因。通俗来讲,就是说组件功能要尽可能地聚合,不要试图让一个组件做太多的事情

2)什么是有状态组件?什么是无状态组件?

无状态组件这个概念我们在[第 06 讲] 中已经介绍过了,这里简单复习一下:
函数组件顾名思义,就是以函数的形态存在的 React 组件。早期并没有 React-Hooks 的加持,函数组件内部无法定义和维护 state,因此它还有一个别名叫“无状态组件”
如下面代码所示,就是一个典型的无状态组件:

  1. function DemoFunction(props) {
  2. const { text } = props
  3. return (
  4. <div className="demoFunction">
  5. <p>{`function 组件所接收到的来自外界的文本内容是:[${text}]`}</p>
  6. </div>
  7. );
  8. }

无状态组件不一定是函数组件,不维护内部状态的类组件也可以被认为是无状态组件。 相比之下,能够在组件内部维护状态、管理数据的组件,就是“有状态组件”

3)为何需要剥离有状态组件和无状态组件?

有状态组件和无状态组件有很多别名,有的书籍里也会管它们叫“容器组件”和“展示组件”,甚至“聪明组件”和“傻瓜组件”。不管叫啥,核心目的就一个——把数据处理和界面渲染这两个工作剥离开来
为什么要这样做?别忘了,React 的核心特征是“数据驱动视图”,我们经常用下图的公式来表达它的工作模式:
23.React 设计模式 - 图1
因此对一个 React 组件来说,它做的事情说到底无外乎是这两件:

  • 处理数据(包括数据的获取、格式化、分发等)
  • 渲染界面

当然也可以在一个组件里面做完这两件事情,但这样不够优雅。
按照“单一职责”的原则,应该将数据处理的逻辑和界面渲染的逻辑剥离到不同的组件中去,这样功能模块的组合将会更加灵活,也会更加有利于逻辑的复用。此外,单一职责还能够尽可能地控制变更范围,降低代码的维护成本:当数据相关的逻辑发生变化时,只需要去修改有状态组件就可以了,无状态组件将完全不受影响。

4、Why Hooks:设计模式解决不了所有的问题

设计模式虽好,但它并非万能。
就 React 来说,无论是高阶组件,还是 render props,两者的出现都是为了弥补类组件在“逻辑复用”这个层面的不灵活性。它们各自都有着自己的不足,这些不足包括但不限于以下几点:

  • 嵌套地狱问题,当嵌套层级过多后,数据源的追溯会变得十分困难
  • 较高的学习成本
  • props 属性命名冲突问题

……
总体来看,“HOC/render props+类组件”这种研发模式,还是不够到位。当设计模式解决不了问题时,我们本能地需要从编程模式上寻找答案。于是便有了如今大家在 React 中所看到的 “函数式编程”对“面向对象”的补充(并且大有替代之势),有了今天我们所看到的“一切皆可 Hooks”的大趋势。
现在,当我们想要去复用一段逻辑时,第一反应肯定不是“高阶函数”或者“render props”,而应该是“自定义 Hook”。Hooks 能够很好地规避掉旧时类组件中各种设计模式带来的弊端,比如说它不存在嵌套地狱,允许属性重命名、允许我们在任何需要它的地方引入并访问目标状态等。由此可以看出,一个好的编程模式可以帮节约大量“打补丁”式地学习各种组件设计模式的时间。框架设计越合理,开发者的工作就越轻松。

5、总结

本讲是围绕“React 组件设计模式”这一专题进行学习。在认识高阶组件、render props 两种经典设计模式的同时,也对“单一职责”“开放封闭”这两个重要的软件设计原则形成了初步的认识
软件领域没有银弹,就算有,也不可能是设计模式。通过本讲的学习,我们在认识设计模式的利好之余,也认识到了它的局限性。在此基础上,我们也对 React-Hooks 及其背后的“函数式编程”思想建立了更加强烈的正面认识。
至此,整个 React 专栏的知识讲解部分就结束了。学习完本专栏之后,我们会多或少都会对 React 的运行机制,甚至前端框架这个领域产生了一定的的兴趣。在下一讲,将会分享对框架的理解,并借机来聊一聊刚刚推出不久的 React 17。