【新手向 🥷】手把手开发一个易于扩展的 Tabs 组件

3,750 阅读6分钟

在日常的业务开发过程中,笔者发现有很多优秀的组件设计模式,善用这些模式可以有效提升代码的可维护性,但往往很多“新手”开发者还不熟练,导致写出来的代码难以扩展。这篇文章将从零开始分享如何开发一个易于扩展的 Tabs 组件。

下面的内容会尽可能详细地说明用到的知识点,不过这篇文章的重点并不是 Tabs 组件的实现噢😯。

废话少说,先看效果:

Kapture 2022-06-19 at 21.11.26.gif

在线 codesandbox:codesandbox.io/s/jolly-fro…

效果很简单,每次点击一个 Tab 元素,将内容区域切换成展示相应的内容。类似的场景有很多,有时只是皮肤不一样,但实现逻辑和效果都是相同的。例如 Antd 中的 Tabs 组件:

image.png

不考虑过场动画、兼容滚动、动态增减的场景,一个 Tabs 组件其实非常简单,相信大部分人很快就能做出来。

陷阱 🤔

首先,从 UI 的角度思考组件拆分,可以看到很明显每个 Tab 项是重复的,它们都有一个图标、一个名字、两种展示状态,响应点击事件等等。

image.png

按照这个思路设计 Tab 组件,代码大概是这样:

interface TabProps extends HTMLAttributes<HTMLElement> {
  active: boolean;
  name: string;
  iconType: Parameters<typeof Icon>["0"]["type"];
}

function Tab(props: TabProps) {
  const { active, name, iconType, className, ...rest } = props;
  return (
    <li
      className={clsx("tab", className, {
        active: active
      })}
      {...rest}
    >
      <Icon type={iconType} className="icon" />
      {name}
    </li>
  );
}

我们让 TabProps 继承自 HTMLAttributes<HTMLElement>,那么通用的 props 例如 style/className/onClick 就可以省略掉了。在 render 时也直接通过展开运算符(...rest)直接透传就行。

Props active 属性用于区分 Tab 组件的状态;
name 是展示的名字;
iconType 用于区分 Tab 组件使用的 Icon 类型,它会作为 type 传给 Icon 组件。

这里的 Icon 是个通用组件,在业务开发中可能对应某个组件库中的图标组件,例子中我是使用 svgr 来引入 svg 文件,通过传 type 类型来区分使用的图标。

const icons = {
  homeOutline: Home,
  musicOutline: Music,
  personOutline: Person,
  searchOutline: Search
} as const;

export function Icon(props: Props) {
  const { type, ...rest } = props;
  let Component = icons[type];
  return <Component {...rest} />;
}

Parameters<typeof Icon>["0"]["type"]; 的作用是取 Icon 方法类型的第一个参数里的 type 字段

接着,将多个 Tab 组件包装成 Tabs 组件,告诉它有哪些 Tab,每个 Tab 组件的名字以及图标就可以了。


interface TabsProps {
  tab: string;
  tabs: Omit<TabProps, "active">[];
  onChange: (val: string) => void;
}

function Tabs(props: TabsProps) {
  const { tab, tabs, onChange } = props;
  return (
    <ul
      className="tabs"
      style={
        {
          "--tab-total": tabs.length,
          "--tab-active-index": tabs.map((item) => item.name).indexOf(tab)
        } as React.CSSProperties
      }
    >
      {tabs.map(({ name, iconType }) => {
        return (
          <Tab
            name={name}
            active={tab === name}
            iconType={iconType}
            onClick={() => onChange(name)}
          />
        );
      })}
    </ul>
  );
}

.tabs 元素上注入 CSS Variables,结合 CSS 联动来控制指示器的位置。

"--tab-total": 4,
"--tab-active-index": ["Home", "Music", "Search", "Person"].indexOf(tab)

这样做到的前提是每个 Tab 高亮时,指示器的宽度都是固定的,如果需要动态修改指示器的尺寸,那么还需要动态获取当前 Tab 的宽度。

.tabs {
  &::before {
    left: calc((var(--tab-active-index) + 0.5) / (var(--tab-total)) * 100%);
  }
}

这样我们就封装好了一个 Tabs 组件,实际使用时再将它和 state 关联起来,根据 tab 的值来判断要展示那个内容区域,代码大概长下面这样:

funciont Basic () {
    const [tab, setTab] = useState("Home");
    const tabs = [
        { name: "Home", iconType: "homeOutline" },
        { name: "Music", iconType: "musicOutline" },
        { name: "Search", iconType: "searchOutline" },
        { name: "Person", iconType: "personOutline" }
      ]
    return (
        <div>
            {tab === "Home" ? <div className="tab-pane">Home</div> : null}
            {tab === "Music" ? <div className="tab-pane">Music</div> : null}
            {tab === "Search" ? <div className="tab-pane">Search</div> : null}
            {tab === "Person" ? <div className="tab-pane">Person</div> : null}
            <Tabs
              tab={tab}
              onChange={setTab}
              tabs={tabs}
            />
        </div>
    )
}

完整的代码再这里:codesandbox.io/s/jolly-fro…

由于每个内容区大概率都长得不一样,可以不用封装成组件。

这样的代码结构不知道你有没有觉得很熟悉呢?看似不错的封装,实则可扩展性非常差,下面来看看这个组件存在的问题。

问题 😈

这样的代码作为仅一次性的开发没有问题,若后续还会持续迭代则需谨慎,它可能会导致代码失控。见过太多类似的场景,由于组件可扩展性差,又担心改出线上问题不敢动,于是有些开发者会选择复制一份出来支持新的特性。

举粒子来说明,在项目中的其他位置需要支持以下几种场景:

仅展示图标 image.png

仅展示名称 image.png

在个别 Tab 元素上增加小圆点 image.png

在某个文案上增加一些活动角标 image.png

前两种还好,仅需修改 TabPropsiconTypename 属性改成非必传,不传的话就不展示,为了空间上的平衡,可能还需要做一些 padding/margin 的小调整。

第三第四种情况就不得不新增字段了...

然而,随着特性需求越来越多,各种字段也随着不断增加,最后会发现这个 Tabs 组件无比庞大,到了难以维护的地步。某些历史字段不知道还有没有在用,不敢删;字段 A 和 字段 B 存在样式冲突,不能同时设置;字段 C 和字段 D 强绑定,传了 C 就必须传 D 等等让人头疼的问题。显然 Tabs 组件被过渡封装了 ,能力相当有限。

破局 👻

依据开放封闭原则,我们可以对 Tabs 的通用能力进行提取封装,而各类差异性的展示保留开放能力。Tabs 组件的能力是哪些?其实就是高亮状态、点击事件、以及切换展示内容,这部分是通用的,无论 UI 长什么样都具备这样的能力。而每个 Tab 的内容是变化的,不同场景下有不同的展示需求。

基于这个思路,可以将 UI 渲染的部分完全移交给具体的场景,Tabs 组件保留扩展能力,在 React 中,可以使用 children 或者通过 props 传递 ReactElement 的方式来支持”插槽“。

修改代码,Tab 组件不再关心图标和名字,直接渲染 children 得了。

interface TabProps extends HTMLAttributes<HTMLElement> {
    active?: boolean;
    pane?: ReactElement
}

function Tab(props: TabProps) {
  const { active, className, children, ...rest } = props;
  return (
    <li
      className={clsx("tab", className, {
        active: active
      })}
      {...rest}
    >
      {children}
    </li>
  );
}

新增了 pane 属性(可选),仅作为 props 类型定义用,真正的消费者是 Tabs

function Tabs(
  props: PropsWithChildren<{
    value: string;
    onChange: (value: string) => void;
  }>
) {
  const { value, children, onChange } = props;
  const tabValues =
    Children.map(children, (item: any) => item?.key || "") || [];
  return (
    <>
      {Children.map(children, (item: any) =>
        item.key === value ? item.props.pane : null
      )}
      <ul
        className="tabs"
        style={
          {
            "--tab-total": tabValues.length,
            "--tab-active-index": tabValues.indexOf(value)
          } as React.CSSProperties
        }
      >
        {Children.map(children, (item: any) =>
          React.cloneElement(item, {
            active: item.key === value,
            pane: null,
            onClick: () => onChange(item.key)
          })
        )}
      </ul>
    </>
  );
}

Tabs 组件也支持 children 插槽,自动从 children 中提取所有 Tab Value组件即配置,不再需要 tabs 属性。

在 React 中 key 是特殊的 prop,它不会出现在组件的 props 内,而是被提取出来赋值给 ReactElement。

并且将切换内容区域的功能也收编进来(就是传给 Tabpane 属性),只有和当前活跃中的 tab 内容才会展示。

{Children.map(children, (item: any) =>
   item.key === value ? item.props.pane : null
)}

事件绑定和 active 状态也可以内聚,通过 React 的顶级方法 cloneElement 将 element 赋值一遍,再第二个参数中传入额外的 props,完成 active 状态的判断和点击事件绑定。

{Children.map(children, (item: any) =>
  React.cloneElement(item, {
    active: item.key === value,
    pane: null,
    onClick: () => onChange(item.key)
  })
)}

最终使用时,代码长下面这样:

export function Advance() {
  const [tab, setTab] = useState("Home");

  return (
    <div className="page">
      <Tabs value={tab} onChange={setTab}>
        <Tab
          key="Home"
          pane={<div className="tab-pane">Home</div>}
        >
          <Icon type="homeOutline" className="icon" />
          Home
        </Tab>
        <Tab
          key="Music"
          pane={<div className="tab-pane">Music</div>}
        >
          <Icon type="musicOutline" className="icon" />
          Music
        </Tab>
        {...}
      </Tabs>
    </div>
  );
}

可以看到,经过调整之后,Tab 组件变得更加纯粹了,不再需要作为工具人传递 iconType,也不再限制具体的 UI 展现形式,支持渲染任意的内容,现在可以支持更多的使用场景了。

总结 🚷

太困了,不想总结...886