React 中的组合模式(composition)

什么是组合(composition) 模式

composition 是一种自下而上的组件设计模式,由一些细小的、单一职责的、原始组件通过自由搭配组合在一起构成一个功能完善的大组件,这样的一种组件设计模式就是组合模式。有很多组件库都是基于这种模式设计的,比如 Radix, Chakra UI, Reakit 等等。

组合模式的好处:组件复用性、可读性、有能力处理未来可能发生的变化

通过构建一些基础的原始组件,这些原始组件的使用场景可能覆盖整个应用,这样就不用拷贝、粘贴代码,提高组件的复用性。

通过组合模式,我们可以将组件的逻辑拆分成多个小组件,这样一来,每个小组件的逻辑就会变得简单,可读性也会提高。同时,这些小组件的逻辑也会变得更加稳定,因为它们的职责更加单一,更加专注。此外,组合模式可以解决属性打洞(props drilling)的问题,使得组件的逻辑更加清晰。

当我们的组件逻辑发生变化时,我们只需要改动部分涉及的小组件,改动范围更小,更容易维护,也共容易迭代。

在 React 中使用组合模式

通常,我们使用 children 属性来实现组合模式:

jsx 复制代码
const Parent = ({ children }) => {
  return <div>{children}</div>;
};

当然,我们命名一些更加语义化的属性名,并放在组件内部的特定位置,比如 headerbody,footer

jsx 复制代码
const Parent = ({ header, body, footer }) => {
  return (
    <div>
      <div>{header}</div>
      <div>{body}</div>
      <div>{footer}</div>
    </div>
  );
};

但是这种方式,会使得组件声明不够美观:

jsx 复制代码
<Parent
  header={<Header />}
  body={<Body />}
  footer={<Footer />}
/>
//不如下面的直观
<Parent>
  <Header />
  <Body />
  <Footer />
</Parent>

因此组件库一般都是采用 children 属性来实现组合模式的。内部使用 React.Children.map 来遍历 children,然后对每个 child 进行处理,比如添加样式、添加事件等等。

jsx 复制代码
const Parent = ({ children }) => {
  return (
    <div>
      {React.Children.map(children, (child) => {
        return React.cloneElement(child, {
          style: { color: "red" },
        });
      })}
    </div>
  );
};

这里如果涉及父子组件的通信,对每个 child 的处理一般是通过 React.cloneElement 或者 包裹一层 Context.Provider 来实现的。具体参见React 中父子组件通信的几种方式

组合模式与单体模式的对比

这里所说的 单体模式 是指按照自上而下的设计方式,将所有的组件逻辑放到一个大组件内部的这种组件设计模式。

任何一个复杂的组件都可以通过单体模式或者组合模式实现,我们常用的组件库 antd 就是用单体模式实现的大部分组件,而 Radix 组件库就是基于组合模式实现组件的,所以我们通过这两个库里一个常见的组件 Tabs 来看看不同的实现模式的对比。

声明的区别

antd 的 Tabs 组件的声明方式是这样的:

jsx 复制代码
const items = [
  {
    key: "1",
    label: "Tab 1",
    children: "Content of Tab Pane 1",
  },
  {
    key: "2",
    label: "Tab 2",
    children: "Content of Tab Pane 2",
  },
  {
    key: "3",
    label: "Tab 3",
    children: "Content of Tab Pane 3",
  },
];
const App = () => (
  <Tabs defaultActiveKey="1" items={items} onChange={onChange} />
);

我们可以看到,我们只需要声明一个 Tabs 组件,然后通过 items 属性来传递数据,这样的声明方式非常简洁,而且也很容易理解。

而 Radix 的 Tabs 组件的声明方式是这样的:

jsx 复制代码
import * as Tabs from "@radix-ui/react-tabs";

export default () => (
  <Tabs.Root defaultValue="tab1" orientation="vertical">
    <Tabs.List aria-label="tabs example">
      <Tabs.Trigger value="tab1">One</Tabs.Trigger>
      <Tabs.Trigger value="tab2">Two</Tabs.Trigger>
      <Tabs.Trigger value="tab3">Three</Tabs.Trigger>
    </Tabs.List>
    <Tabs.Content value="tab1">Tab one content</Tabs.Content>
    <Tabs.Content value="tab2">Tab two content</Tabs.Content>
    <Tabs.Content value="tab3">Tab three content</Tabs.Content>
  </Tabs.Root>
);

我们可以看到,Radix 的 Tabs 组件的声明方式更加复杂,需要声明多个组件,而且还需要为每个组件添加不同的属性,这样的声明方式不够直观,也不够简洁。

实现的区别

这里可以查看 antd Tabs 组件的实现,其内部主要是调用了 rc-tabs 这个库来实现的。

可以看到 Tabs 透传了大量属性给 rc-tabs ,rc-tabs 本身接收了大量 props, 所以,其内部实现必然相当复杂。

接下来,我们看看 Radix Tabs 组件的实现。在 这里查看源码

通过这个文件的 export,我们可以发现 Radix Tabs 组件被拆分成了 4 个小组件:Root、List、Trigger、Content。

Root 组件充当一个容器,用来整合各个子组件,通过提供一个 Provider 来共享组件的全局状态。

List 组件是 Tabs 标题的容器。负责标题的布局方向、是否循环等等

Trigger 组件是 Tabs 标题。负责标题的样式、选中态、禁用态、处理点击事件等等

Content 组件是 Tabs 内容。负责内容的样式、判断隐藏或展示、动画等等

我们可以看到,通过对组件的拆分,组件的交互逻辑也被分散到各个小组件中,每个小组件只负责自己相关的功能,彼此相对独立。

对于组件逻辑的拆分、分散,一定程度上,可以提高组件的可读性、可维护性。后续如果有需求变更,我们只需要改动相关的组件,而不需要改动整个组件。明确的改动范围,可以减少我们的心里负担。

样式的覆盖

单体组件模式下,一般可以通过 classNamestyle 属性来修改最外层组件的默认样式,对于组件内部比较深层的组件,我们一般很难去改写它们的样式,除非作者将属性打洞暴露出来。

但是,组合模式下,样式的重写就相对容易了,只要每个子组件都支持 className属性,我们可以随便更改深层组件的样式。其实原理就是:组合模式可以解决属性打洞问题

总结

单体模式,对于内部逻辑复杂的组件,其实是一种不太好的设计模式,它会导致组件的可读性、可维护性变差,后续的需求变更也会变得困难。但是他对于使用者来说,比较友好,使用者只需要关心一个组件,而不需要关心组件内部的实现细节,不需要想着如何去拼凑。

组合模式,代表着一种自下而上的组件设计模式,其实对于组件的设计者有着很高的编码能力要求。对于使用者来说,则需要多写一些代码来声明、搭配子组件来实现想要的功能。但是,这样的设计方式使得组件有着更灵活的使用场景,更好的可读性、可维护性,也更容易迭代。

对于一个喜欢Tailwind CSS 的人来说,组合模式的组件无疑是更好的选择,因为它可以更好的支持 Tailwind CSS 的样式覆盖。所以很多 headless 组件库都是基于组合模式+context+hook来实现的。

我是viewer,如果觉的本文对你有帮助,麻烦点个赞,谢谢!!

相关推荐
Asort16 分钟前
JavaScript 从零开始(六):控制流语句详解——让代码拥有决策与重复能力
前端·javascript
无双_Joney35 分钟前
[更新迭代 - 1] Nestjs 在24年底更新了啥?(功能篇)
前端·后端·nestjs
在云端易逍遥37 分钟前
前端必学的 CSS Grid 布局体系
前端·css
ccnocare38 分钟前
选择文件夹路径
前端
艾小码38 分钟前
还在被超长列表卡到崩溃?3招搞定虚拟滚动,性能直接起飞!
前端·javascript·react.js
闰五月39 分钟前
JavaScript作用域与作用域链详解
前端·面试
泉城老铁43 分钟前
idea 优化卡顿
前端·后端·敏捷开发
前端康师傅43 分钟前
JavaScript 作用域常见问题及解决方案
前端·javascript
司宸44 分钟前
Prompt结构化输出:从入门到精通的系统指南
前端