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,如果觉的本文对你有帮助,麻烦点个赞,谢谢!!

相关推荐
醉の虾16 分钟前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧24 分钟前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm34 分钟前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7011 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王1 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
疯狂的沙粒1 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript
gqkmiss2 小时前
Chrome 浏览器 131 版本开发者工具(DevTools)更新内容
前端·chrome·浏览器·chrome devtools
Summer不秃2 小时前
Flutter之使用mqtt进行连接和信息传输的使用案例
前端·flutter
旭日猎鹰2 小时前
Flutter踩坑记录(二)-- GestureDetector+Expanded点击无效果
前端·javascript·flutter