前言

因为之前为 UI 库由我来做且维护,但缺点是集成在项目中,不便于其他项目使用,且组件被同事改了之后会导致之前沿用的组件乱掉。所以 UI 由独立库维护是最好的。正逢项目不急,就将其排上了日期,其中写了

皮肤概念解决的是 UI 库目前适用于两个主色调、次色调不同的项目

css 定位解决的是 Tag 组件的 border 边框让尺寸变长问题

现在要提取 Tab 组件,发现原来的组件不太合理

业务中的 Tab 组件

业务效果如图所示,滑动它,能连带着切换下划线和内容

image-20211115163300514

后端返回数据结构

总结构:

image-20211115163114381

详细结构:

image-20211115163050904

当时的写法:

1
2
3
4
5
6
7
8
<Tab
ref={tabRef}
data={tagGroupProductList}
sticky={true}
selected={selectedTab}
onChange={onHandleChangeTab}
onClick={onClickToBtn}
/>

组件 Tab 分为三部分,主 Tab、TabItem、TabContext

image-20211115163152899

在 Tab 中,集成了 TabItem 和 TabContext

TabItem:头部图片+文字

TabContext:内容卡片,支持滑动

1
2
3
4
5
// 伪代码,此为 Tab
<div className="jing-tab" style={style} ref={ref}>
// 遍历<TabItem></TabItem>
// 塞入当前下标页的数据,展示 TabContext <TabContext />
</div>

又提供滑动后的回调 changeSwiper。当滑动后,意味着组件头部的位置改变,也意味着 context 数据的改变。虽然写的有点饶,但我想表达的是原来的组件的业务和逻辑绑定密切

这种写法是封装好了样式,只要传 props 即可,相当于木偶组件,使用者传入数据即可。

1
2
3
4
5
6
7
8
<Tab
ref={tabRef}
data={tagGroupProductList} // 传入一次性参数
sticky={true}
selected={selectedTab}
onChange={onHandleChangeTab}
onClick={onClickToBtn}
/>

缺点是子组件不能传参数,你不能控制子组件,当遇到类似的 Tab(标签页)效果,但内容结构不一致就会导致不可用。说白了,这个 Tab 只适用于当前页面的效果,没有普世性

为了组件的可拓展性,业务和项目尽量分开

重构组件

我一开始的设想是希望做成这样:

1
2
3
4
5
<Tabs swiper={true} active="0">
<Tab title="标签1">内容1</Tab>
<Tab title="标签2">内容2</Tab>
<Tab title="标签3">内容3</Tab>
</Tabs>

Tabs 控制标签页是否能滑动,能否点击,滑动到那个标签页等等,Tab 控制当前标签的标题和内容

但这样,Tabs 和 Tab 就分离成了两个组件,有必要分离吗?用到标签页(Tabs)的时候必然会用 Tab,所以我后面改造成了

1
2
3
4
5
<Tabs value="{value}">
<Tabs.Panel title="标签1">内容 1</Tabs.Panel>
<Tabs.Panel title="标签2">内容 2</Tabs.Panel>
<Tabs.Panel title="标签3">内容 3</Tabs.Panel>
</Tabs>

Tab 嫁接在 Tabs 的子组件中

思路与做法

Tabs 的作用是为了外界的 props

Tab 的作用是展示数据,如 title 和 content

思路有了,怎么做?

Tabs 的属性包括但不限于:

  • value:绑定当前选中标签的标识符

  • swipeable:是否开启手势滑动切换

  • sticky:是否使用粘性定位布局

  • disabled:是否禁用标签

  • swipeThreshold:滚动阈值

拆开了 Tabs 和 Tab 的结构是怎么样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const Panel = (props) => {
const classes = classnames(prefixCls, props.className, {
[`${prefixCls}--active`]: !!props.selected,
[`${prefixCls}--disabled`]: !!props.disabled,
});
return (
<div className="jing-tabs__panel" style={props.style} role="tabpanel">
{props.children}
</div>
)
}

const Tabs = (props) => {
return (
<div className="jing-tabs">
<div className="jing-tabs__wrap">
React.Children.map(children, (item: any, index: number) => {
// 简化
return (
<Title ...item.props/>
);
})
</div>
<div className="jing-tabs__content">
// 遍历 React.Children,将Children中的数据塞入 TabPanel 中
React.Children.map(children, (item,index) => (
<TabPanel {...item.props}/>
))
</div>
</div>
)
}

Tabs.Panel = Panel

这里有个知识点:React.Children

什么是 React.Children?

React.Children 提供了用于处理 this.props.children 不透明数据结构的实用方法。

React.Children.map 的使用方法

1
React.Children.map(children, function[(thisArg)])

所以在代码中

1
React.Children.map(children, (item, index) => <TabPanel {...item.props} />)

(item, index) => <TabPanel /> 表示的是 children 中的每个 Tabs.Panel

联想到组件

1
2
3
4
5
<Tabs value={value}>
<Tabs.Panel title="标签1">内容 1</Tabs.Panel>
<Tabs.Panel title="标签2">内容 2</Tabs.Panel>
<Tabs.Panel title="标签3">内容 3</Tabs.Panel>
</Tabs>

在这个案例中,React.Children 表示 children,即三个 Tabs.Panel,并且将其遍历

(item, index) => <TabPanel /> ,就一一对应每个 TabPanel

所以 children 中的内容必须是 Tans.Panel 组件,这样才不会出差错

同理,我们可以把标题抽离出来,成一”木偶组件”,React.Children.map 时传入所需要的数据

这里需要注意的是,Tabs.Panel 组件承担了 Tabs.Title 传值的作用,其 title、img 等都是传给 Tabs.Title 的参数。

做组件需要注意什么

  1. 它的结构

    • 我对 标签页组件是拆分为 TabsTabs.PanelTabs.Title。其中 Tabs.Title 没有暴露出来,因为不需要
  2. 它有什么属性

    • swipeable:支持滑动
      • 如果需要滑动,那么就需要用到 Swiper 滑块组件
      • 当它滑动时,标题中的 line 也要跟着滚动到相应的位置
    • sticky:粘性布局
      • 同样,粘性布局在很多场景下都有用到,是否需要抽离成组件
    • swipeThreshold:滚动阈值
      • 当 children 中的数量(React.Children.Count)大于阈值时支持横向滚动
  3. 它的下标跟随怎么做

    1. 首先要先获取每个 Tabs.Title 的 ref,即获取它的 dom

      1. Tabs.Title 时转发(forwardRef) 此组件
      2. 使用 <Title ref={(el) => (tabsTitleRef.current[index] = el)} ... /> 获取每个 Tabs.Title 的 dom
    2. 补间动画

      1. 拿到点击后的值的 dom 的 坐标,transform: translateX(XX)
    3. 当达到滚动阈值后,tabsNavRefTabs.Title 的父容器) 移动,移动是有公式的

      • function scrollLeftTo() {
          cancelRaf(rafId)
          let count = 0
          const from = scroller.scrollLeft
          const frames = duration === 0 ? 1 : Math.round((duration * 1000) / 16)
        
          function animate() {
            scroller.scrollLeft += (to - from) / frames
        
            if (++count < frames) {
              rafId = raf(animate)
            }
          }
        
          animate()
        }
        

其余的没什么难度了

总结

Tabs 标签页组件的核心在于什么?标签的标题和内容,点击标签时,内容切换到该页面以及当它可以滑动时,变化的是当前的下标。我们需要以它为切入点,当它变化时,下划线滚动。而点击 Title 或者滑动 Content 都会触发 setCurrentIndex(index),用 hooks 写完美解决”关注点问题“