在日常的业务开发过程中,笔者发现有很多优秀的组件设计模式,善用这些模式可以有效提升代码的可维护性,但往往很多“新手”开发者还不熟练,导致写出来的代码难以扩展。这篇文章将从零开始分享如何开发一个易于扩展的 Tabs 组件。
下面的内容会尽可能详细地说明用到的知识点,不过这篇文章的重点并不是 Tabs 组件的实现噢😯。
废话少说,先看效果:
在线 codesandbox:codesandbox.io/s/jolly-fro…
效果很简单,每次点击一个 Tab 元素,将内容区域切换成展示相应的内容。类似的场景有很多,有时只是皮肤不一样,但实现逻辑和效果都是相同的。例如 Antd 中的 Tabs 组件:
不考虑过场动画、兼容滚动、动态增减的场景,一个
Tabs组件其实非常简单,相信大部分人很快就能做出来。
陷阱 🤔
首先,从 UI 的角度思考组件拆分,可以看到很明显每个 Tab 项是重复的,它们都有一个图标、一个名字、两种展示状态,响应点击事件等等。
按照这个思路设计 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…
由于每个内容区大概率都长得不一样,可以不用封装成组件。
这样的代码结构不知道你有没有觉得很熟悉呢?看似不错的封装,实则可扩展性非常差,下面来看看这个组件存在的问题。
问题 😈
这样的代码作为仅一次性的开发没有问题,若后续还会持续迭代则需谨慎,它可能会导致代码失控。见过太多类似的场景,由于组件可扩展性差,又担心改出线上问题不敢动,于是有些开发者会选择复制一份出来支持新的特性。
举粒子来说明,在项目中的其他位置需要支持以下几种场景:
仅展示图标
仅展示名称
在个别 Tab 元素上增加小圆点
在某个文案上增加一些活动角标
前两种还好,仅需修改 TabProps 将 iconType 和 name 属性改成非必传,不传的话就不展示,为了空间上的平衡,可能还需要做一些 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。
并且将切换内容区域的功能也收编进来(就是传给 Tab 的 pane 属性),只有和当前活跃中的 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