从组件拆分方案(颗粒度)、组件接口设计(约定和配置)、组件解耦(通信和设计模式)等一个或多个角度,都可以给到一个相对不错或有帮助有启发的回答。这个问题甚至还可以上升到 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),为什么要这么设计组件呢?
我们来分析一下这种拆分的优势:
-
按照 Compound Components Pattern 设计组件,无疑减少了组件 API 的复杂度。相比把 Counter 组件所有 props 塞到一个大而全的 Counter 组件配置项中,我们按照组件功能又进行了细致的复合拆分,每一个相关的 props 被分配个子组件 SubComponent。即每个单独的「组件子领域只关心自己的配置项」
-
这样一来,我们获得了更大的结构灵活度,UI 更加可控。比如,使用者可以改变子组件 SubComponent 的显示顺序,如下图:
当然,你可以抬杠:设计师或者产品并不会反复横跳更改这些 UI 呀?
我们只从用法入手,进行设计思想的启发。如果要抬这个杠,那我就换个例子!
- 真正做到了关注点分离。通过组件的合理拆分和组合,我们结合 context,将数据和变更统一维护,并分配给相关 SubComponent。潜在收益是数据和状态的流转和复用度提升,如下图:
划重点:任何组件的封装和拆分,都是一场 trade off. 我们有收益,自然也有损失。通过下面的例子,我们也会更直观地印证这个平衡。
那么弊端,总结如下:
- 过于灵活的 UI,使用者拥有更多控制权,反过来说,组件自身的控制力被削弱。如下图,
- 相对来说更多的 JSX 代码。这一点很好理解,如下图:
如果你翻阅的优秀组件开源库,你会发现很多国际出名的轮子都在采用 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 设计模式下,使用方调用的姿势就更多了,控制权进一步放大。优势:
- 更大的控制权。使用者可以在 JSX UI 表达和 hook 函数中插入自己的自定义逻辑,如下代码:
组件设计方对外暴露 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!