搞轮子:从业务到组件Tabs

前言

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

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

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

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

业务中的 Tab 组件

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

image-20211115163300514

后端返回数据结构

总结构:

image-20211115163114381

详细结构:

image-20211115163050904

当时的写法:

<Tab
  ref={tabRef}
  data={tagGroupProductList}
  sticky={true}
  selected={selectedTab}
  onChange={onHandleChangeTab}
  onClick={onClickToBtn}
/>

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

image-20211115163152899

在 Tab 中,集成了 TabItem 和 TabContext

TabItem:头部图片+文字

TabContext:内容卡片,支持滑动

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

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

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

<Tab
  ref={tabRef}
  data={tagGroupProductList} // 传入一次性参数
  sticky={true}
  selected={selectedTab}
  onChange={onHandleChangeTab}
  onClick={onClickToBtn}
/>

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

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

重构组件

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

<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,所以我后面改造成了

<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 的结构是怎么样的

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 的使用方法

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

所以在代码中

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

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

联想到组件

<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 写完美解决”关注点问题“