«
组件的拆分和封装

时间:2022-6   


从组件拆分方案(颗粒度)、组件接口设计(约定和配置)、组件解耦(通信和设计模式)等一个或多个角度,都可以给到一个相对不错或有帮助有启发的回答。这个问题甚至还可以上升到 DDD(Domain-Driven Design 领域驱动设计)这种相对宏观,超脱于前端的编程领域 topic 上,让答案变得普适。

但针对提问者重点提到的「不擅长封装和拆分组件」,我尝试从一个组件的优化进阶方案入手,在思想和一个具体案例上,讨论组件的封装和拆分,并针对每一次优化,比对编程成本(组件设计复杂度)和控制粒度(组件灵活度)。

毕竟再谈 slot, render prop, HoC 这些常见的组件设计方案已经太老生常谈啦~

主体回答会花时间讲述一个组件的封装渐进史,tl;dr 直接看最后总结部分即可


原始需求

组件需求很简单,直接看图:

分析需求:一个简单的计数组件,中间现实 Counter 文案和当前计数,左右两边分别是 - 和 + 按钮。这里再加一个限定吧:计数不得超过 10(或者限定的任何一个数字)。

后文代码在 https://github.com/alexis-regnaud/advanced-react-patterns 中,案例也由此提供。

大部分同学给出的代码也比较直接,直接面向业务编程,迅速实现了 Counter 组件。组件使用方式如下:

import React from "react";
import { Counter } from "./Counter";

function Usage() {
  const handleChangeCounter = (count) => {
    console.log("count", count);
  };

  return (
    <Counter
      label="label"
      max={10}
      iconDecrement="minus"
      iconIncrement="plus"
      onChange={handleChangeCounter}
    />
  );
}

export { Usage };

Compound Components Pattern

在复合组件设计模式(Compound Components Pattern)的封装思路下,我们想象一下组件用法:

import React from "react";
import { Counter } from "./Counter";

function Usage() {
  const handleChangeCounter = (count) => {
    console.log("count", count);
  };

  return (
    <Counter onChange={handleChangeCounter}>
      <Counter.Decrement icon="minus" />
      <Counter.Label>Counter</Counter.Label>
      <Counter.Count max={10} />
      <Counter.Increment icon="plus" />
    </Counter>
  );
}

export { Usage };

咦?看上去把 Counter 组件又拆了几个属性或者叫子组件(比如 Counter.Decrement),为什么要这么设计组件呢?

我们来分析一下这种拆分的优势:

当然,你可以抬杠:设计师或者产品并不会反复横跳更改这些 UI 呀?

我们只从用法入手,进行设计思想的启发。如果要抬这个杠,那我就换个例子!

划重点:任何组件的封装和拆分,都是一场 trade off. 我们有收益,自然也有损失。通过下面的例子,我们也会更直观地印证这个平衡。

那么弊端,总结如下:

如果你翻阅的优秀组件开源库,你会发现很多国际出名的轮子都在采用 Compound Components Pattern,比如:

Control Props Pattern

我们分析上例 Compound Components Pattern,最关键的计数状态 count state 是维护在 Counter 组件当中的。

熟悉 React 组件的同学应该对 controlled component 受控组件并不陌生。如果我们改造上述 Counter 组件实现,将计数状态 counter 作为外部唯一数据源(single source of truth)传给 Counter 组件,那么 Counter 组件在状态上将会变得更加灵活。毕竟关键状态数据由消费方控制,你想怎么用,就怎么用好了,如下使用代码:

import React, { useState } from "react";
import { Counter } from "./Counter";

function Usage() {
  const [count, setCount] = useState(0);

  const handleChangeCounter = (newCount) => {
    setCount(newCount);
  };
  return (
    <Counter value={count} onChange={handleChangeCounter}>
      <Counter.Decrement icon={"minus"} />
      <Counter.Label>Counter</Counter.Label>
      <Counter.Count max={10} />
      <Counter.Increment icon={"plus"} />
    </Counter>
  );
}

export { Usage };

这样设计组件的优势很明显,

弊端自然也是复杂度的直线上升。在此之前,所有的逻辑都收归在 Counter 组件内部 JSX 中,而在 Control Props Pattern 思路下,count 状态出现在了至少 3 处局部。如下图:

](http://pengwu.ink/content/uploadfile/202206/29529c6e02aa08b442efd25c0b4a3b86.jpg)。

Custom Hook Pattern

通过前面两个例子,读者应该在冥冥中有了「控制反转 inversion of control」的概念。主要逻辑现在已经转移出了组件内部,而逻辑的转移可以使用 hooks 更优雅地实现。

请思考下面代码:

import React from "react";
import { Counter } from "./Counter";
import { useCounter } from "./useCounter";

function Usage() {
  const { count, handleIncrement, handleDecrement } = useCounter(0);
  const MAX_COUNT = 10;

  const handleClickIncrement = () => {
    //Put your custom logic
    if (count < MAX_COUNT) {
      handleIncrement();
    }
  };

  return (
    <>
      <Counter value={count}>
        <Counter.Decrement
          icon={"minus"}
          onClick={handleDecrement}
          disabled={count === 0}
        />
        <Counter.Label>Counter</Counter.Label>
        <Counter.Count />
        <Counter.Increment
          icon={"plus"}
          onClick={handleClickIncrement}
          disabled={count === MAX_COUNT}
        />
      </Counter>
      <button onClick={handleClickIncrement} disabled={count === MAX_COUNT}>
        Custom increment btn 1
      </button>
    </>
  );
}

export { Usage };

在 hooks 设计模式下,使用方调用的姿势就更多了,控制权进一步放大。优势:

组件设计方对外暴露 handleIncrement 方法,而消费方可以自定义 handleClickIncrement 方法,实现「count >= 6 时,不再进行计数增加」——这一自定义需求。

弊端?那聪明的你一定 get 到了,复杂度又一步提升。如下图:

渲染和逻辑进一步进行了分离,使用者需要对渲染和逻辑部分「感知」的更多。如果想用好现在的 Counter 组件,使用者是需要承担一部分心智负担的。

这种组件设计模式在中后台开源组件库中尤其多见(可以想想问什么)。比如:

Props Getters Pattern

前一步 Custom hook 模式给了使用者更多控制权,这意味着组件本身具有了更灵活、更容易被复用的能力。同时组件复杂度也更高,心智负担陡增。

试想一种 Props Getters Pattern,我们可以屏蔽复杂度,与其通过 props 对外暴露更多的控制力,不如提供一个 props list,让使用方决定是否以及如何进行定制:不需要定制(控制)的部分,复杂度屏蔽在组件内部;需要定制的部分,通过 list select 来进行自定义。

这时候就需要一个 getter function 了,一个 getter function 可以返回 props list,通过语义化的 prop name,建立起使用者和组件渲染逻辑的天然连接。

还是很抽象?看代码:

import React from "react";
import { Counter } from "./Counter";
import { useCounter } from "./useCounter";

const MAX_COUNT = 10;

function Usage() {
  const {
    count,
    getCounterProps,
    getIncrementProps,
    getDecrementProps
  } = useCounter({
    initial: 0,
    max: MAX_COUNT
  });

  const handleBtn1Clicked = () => {
    console.log("btn 1 clicked");
  };

  return (
    <>
      <Counter {...getCounterProps()}>
        <Counter.Decrement icon={"minus"} {...getDecrementProps()} />
        <Counter.Label>Counter</Counter.Label>
        <Counter.Count />
        <Counter.Increment icon={"plus"} {...getIncrementProps()} />
      </Counter>
      <button {...getIncrementProps({ onClick: handleBtn1Clicked })}>
        Custom increment btn 1
      </button>
      <button {...getIncrementProps({ disabled: count > MAX_COUNT - 2 })}>
        Custom increment btn 2
      </button>
    </>
  );
}

export { Usage };

通过提供 getter function,使用者可以自然选择需要 connect UI 部分,并接管相关逻辑;而其它逻辑被屏蔽在了组件外部。如下图:

毫无疑问这样灵活度最高,使用者还可以根据自己的逻辑进行 props 选取和编排:

<button {...getIncrementProps({ disabled: count > MAX_COUNT - 2, onClick: handleBtnClicked })}>
   Custom increment btn 2
</button>

这种模式弊端也很明显:使用者需要感知 getter function 返回的所有的 props 内容。一旦有组件内部逻辑的变动,也需要使用者进行感知。

这种接近完美灵活度的组件模式依然出现在很多知名开源库当中,如:

State reducer pattern

函数式编程给我们带来了纯函数的概念,也让前端刮起了 reducer 之风。最后一种组件设计模式,便和 reducer 相关。试想:我们在设计组件时,将 Custom Hook Pattern 上更进一步,把所有的变动 hooks 集中管理起来,使用 reducer 函数来承载。reducer 函数由使用方提供,我们的组件提供状态数据快照,而消费方提供的 reducer 每次都更新当前的数据状态,进而得到最新的数据状态,再交由组件消费。

如下代码:

import React from "react";
import { Counter } from "./Counter";
import { useCounter } from "./useCounter";

const MAX_COUNT = 10;
function Usage() {
  const reducer = (state, action) => {
    switch (action.type) {
      case "decrement":
        return {
          count: Math.max(0, state.count - 2) //The decrement delta was changed for 2 (Default is 1)
        };
      default:
        return useCounter.reducer(state, action);
    }
  };

  const { count, handleDecrement, handleIncrement } = useCounter(
    { initial: 0, max: 10 },
    reducer
  );

  return (
    <>
      <Counter value={count}>
        <Counter.Decrement icon={"minus"} onClick={handleDecrement} />
        <Counter.Label>Counter</Counter.Label>
        <Counter.Count />
        <Counter.Increment icon={"plus"} onClick={handleIncrement} />
      </Counter>
      <button onClick={handleIncrement} disabled={count === MAX_COUNT}>
        Custom increment btn 1
      </button>
    </>
  );
}

export { Usage }; 

通过 state reducers,我们将控制权全部交给了使用者。组件内部的所有 action,state 都可以被外部感知并控制。

](http://pengwu.ink/content/uploadfile/202206/7abe19de7937ab9278ebd353d6c6cd09.jpg) 这个开源组件库中看到大规模地使用。

总结

上面我们分析了 5 种组件设计模式,并从「控制反转」的角度分析了每一种模式的优缺点。灵活度和复杂度之间的控制反转是一场永恒的博弈,结果永远会是一场 trade off,我们的组件设计究竟该何去何从?

耳畔响起老掉牙的名言「With great power comes great responsibility」。组件设计上,我们转移给使用者多少控制权,就丧失了多少即插即用的组件复用点。

你问我「不擅长封装和拆分组件,请问如何改变这样的状态?」,抱歉我没有秘籍,如果上面的代码你也难有耐心读下去的话,那就让我们多站在使用者的角度去思考,多经历几次失败和重构,不惧怕多番折腾和打磨,也许一切就在悄然转变。

Happy coding!