tabs 行为组件封装 - 新手向

330 阅读6分钟

前言

因为近期换了个团队,所以就整理了一下自己很久之前做的一些需求,这也是这个很简单的需求的由来 这篇文章很简单,所以就不写太多的东西了

总所周知,移动端其实很容易出现各种奇奇怪怪的tabs样式

也就容易导致了我们经常在做需求的时候需要重复去写不同样式的tabs组件

但是总所周知,tabs这个组件的行为本质上是提供了“切换tabs”、“内容展示”等功能

那我们能不能把tabs的相关行为进行一个封装,在每次遇到不同样式的tabs的时候只需要处理UI层面东西就可以了?

例子

所以在这个前提下尝试做了这个操作,所以以下六个例子中看到的tabs的组件实际上都是同一个组件,已经在两个已上线的需求中愉快使用

image.png

image.png

image.png

image.png

设计与实现

思考&需求拆解

1. 一个tabs 可能会有什么需求细节?

对应tabs这个组件相信大家其实都很熟悉,我们想要去抽离tabs的行为,那么第一件要做的事件就是先去梳理tabs这个组件能想到的所有需求细节。然后再将相关的行为进行一个抽离和实现。

那么对于一个tabs来说,有可能会有什么样的需求细节呢?

  1. 标签栏

    • 自由点击切换激活态和非激活态
    • 提供内容超出可滚动 和 内容超出不滚动两种模式
    • 两个tabs之间可能会需要插入一个node,例如分割线或者图标
    • tabs 存在下划线及其样式,例如圆角、颜色、高度
    • tabs 内容区域的padding
    • tabs内的文字样式
    • tabs是否进行拉伸,即内容宽度不够的时候是否拉伸为铺满
    • tabs内容可能为图标或者图片
    • tabs可能需要徽标
    • 需要提供当前活动的tabs的key
    • 需要可以设置默认的活动 tabs 的功能,当不设置时默认为第一个tabs
    • 需要提供tab切换时的onchange方法,需要支持返回的是一个promise来应对某些特殊业务场景
    • 需要提供横向和竖向的布局
    • 如果抽离行为,那样式需要传入非激活态和激活态时的样式
    • tabsList 的 classname需要可以传入
    • 有可能需要限制至少存在一个tabs才显示选项卡栏
    • 需要可以传入 tabsList 的内容数组,至少包括了 key 与 title ,title 因为有可能是图标或者其他什么,所以需要支持ReactNode
    • tabs对应的内容
  2. 内容区域(pane)

    • 需要提供当前活动的pane的index
    • 需要可以传入pane相关的内容
    • 需要可以传入当前tabs的活动key作为映射
    • 需要提供内容区域不可滚动 和 可滚动两种模式
    • 需要提供滚动时触发的方法,以支持某些业务场景

2. 要抽离哪些需求?

因为我们这个组件是tabs的行为组件的抽离,所以我们要遵循一个原则:UI的归UI,行为和逻辑的归行为和逻辑

所以根据这个原则,那么我们最后需要实现的需求就是:

  1. 标签栏

    • 需要可以传入 tabsList 的内容数组,至少包括了 key 与 title,title类型为string / ReactNode
    • 需要提供当前活动的tabs的key
    • 需要可以设置默认的活动 tabs 的功能,当不设置时默认为第一个tabs
    • 需要提供tab切换时的onchange方法,需要支持返回的是一个promise来应对某些特殊业务场景
    • 需要传入非激活态和激活态时的样式
    • 自由点击切换激活态和非激活态
    • 提供内容超出可滚动 和 内容超出不滚动两种模式
    • 两个tabs之间可能会需要插入一个node
    • tabs是否进行拉伸,即内容宽度不够的时候是否拉伸为铺满
    • 需要提供横向和竖向的布局
    • tabsList 的 classname需要可以传入
    • 有可能需要限制至少存在一个tabs才显示选项卡栏
    • tabs对应的内容
  2. 内容区域(pane)

    • 需要可以传入pane相关的内容
    • 需要提供当前活动的pane的index
    • 需要可以传入当前tabs的活动key作为映射
    • 需要提供内容区域不可滚动 和 可滚动两种模式
    • 需要提供滚动时触发的方法,以支持某些业务场景

实现

1. api 设计

(1) tabs 

/** 非激活态和激活态时的节点的props */
export interface ITabsItemProps {
  tabsItem: ITabsListItem;
  index?: number;
  activityKey?: number;
}

export interface ITabsListItem {
  key: string | number;
  title: string | React.ReactNode;
  [key: string]: any
}


export interface ITabsProps {
  /** tabsList 的内容数组,至少包括了 key 与 title,title类型为string / ReactNode */
  tabList: ITabsListItem[];
  /** 当前活动的tabs的key */
  activityKey: number;
  /** tabs切换时的onchange方法,需要支持返回的是一个promise来应对某些特殊业务场景 */
  onChange: (index: number) => void | Promise<void>;
  /** tabs对应的内容 */
  children?: React.ReactNode;
  /** 非激活态的样式 */
  TabsItem: (props: ITabsItemProps) => JSX.Element;
  /** 激活态的样式 */
  TabsItemActive: (props: ITabsItemProps) => JSX.Element;
  /** tabsList 的 classname */
  tabsListClassName?: string;
  /** tabs容器的flex样式 */
  tabsContainerFlex?: IFlex;
  /** tabsList容器的flex样式 */
  tabsListFlex?: IFlex;
  /** 两个tabs之间需要插入的node */
  middleExtraNode?: React.ReactNode;
  /** 是否限制至少存在一个tabs才显示选项卡栏  */
  tabsListShowLimit?: boolean;
  /** tabs是否进行拉伸,即内容宽度不够的时候是否拉伸为铺满 */
  tabListItemAverage?: boolean;
}

(2) pane

interface IPosition {
  left: number;
  top: number;
}

interface IProps {
  /** 当前活动的tabs的key */
  activityKey: number;
  /** pane对应的内容 */
  children?: React.ReactNode;
  /** pane对应的index */
  index: number;
  /** pane滚动事件 */
  onScroll?: (position: IPosition, activityKey: number) => void;
  /** 当前pane是否允许滚动 */  
  isScroll?: boolean;
}

2. tabs 具体的功能实现

export default (props: ITabsProps) => {
  const {
    middleExtraNode,
    tabList,
    activityKey,
    onChange,
    TabsItem,
    TabsItemActive,
    children,
    tabsListClassName,
    tabsListFlex,
    tabsContainerFlex,
    tabsListShowLimit,
    tabListItemAverage
  } = props;

  const tabListRender = () => {
    return <TabsList
      tabList={tabList}
      activityKey={activityKey}
      onChange={onChange}
      TabsItem={TabsItem}
      TabsItemActive={TabsItemActive}
      tabsListClassName={tabsListClassName}
      middleExtraNode={middleExtraNode}
      tabsListFlex={tabsListFlex}
      tabListItemAverage={tabListItemAverage}
    />
  }
  return (
    <div className="tabs-container" style={tabsContainerFlex}>
      {
        tabsListShowLimit ? tabList.length > 1 && tabListRender() : tabListRender()
      }
      <Pane>{children}</Pane>
    </div>
  );
};

const TabsList = React.memo((props: ITabsProps) => {
  const { middleExtraNode, tabList, activityKey, onChange, TabsItem, TabsItemActive, tabsListClassName, tabsListFlex, tabListItemAverage } = props;
  
  // 激活与非激活态样式控制
  const tabsRender = (activityKey: number, item: ITabsListItem, index: number) => {
    if (activityKey === index) return <TabsItemActive tabsItem={item} index={index} />;
    return <TabsItem tabsItem={item} index={index} />;
  };

  // tabs间node插入
  const tabsMiddleExtraNode = (index: number) => {
    if (tabList.length > 2 && index < tabList.length - 1) {
      return middleExtraNode;
    }
    return null;
  };

  const tabsListClass = classNames('tabs-list', tabsListClassName);
  return (
    <div
      className={tabsListClass}
      style={tabsListFlex}>
      {tabList.map((item, index) => {
        return (
          <div className="tabs-list-item-container" key={item.key} onClick={async() => {await onChange(index);}} style={tabListItemAverage ? { flex: 1 } : {}}>
            {tabsRender(activityKey, item, index)}
            {middleExtraNode && tabsMiddleExtraNode(index)}
          </div>
        );
      })}
    </div>
  );
});

const Pane = (props: IPane) => {
  const { children } = props;
  return <div className="tabs-behavior-pane">{children}</div>;
};
/* css */

.tabs-container {
  height: 100%;
  width: 100%;
  display: flex;
  flex-direction: column;
  .tabs-list {
    display: flex;
    align-items: center;
    justify-content: center;
    .tabs-list-item-container {
      display: flex;
      flex-shrink: 0;
      align-items: center;
    }
  }
  .tabs-list:-webkit-scrollbar {
    display: none;
  }
  .tabs-behavior-pane {
    height: 100%;
    flex: 1;
    overflow: hidden;
  }
}

3. pane 具体的功能实现

const Pane = (props: IProps) => {
  const { activityKey, children, index, onScroll, isScroll } = props;

  const isShow = () => {
    if (activityKey === index) {
      return 'block';
    }
    return 'none';
  };

  const refs = useRef(null);
  const position = useScroll(refs, (val) => (onScroll ? true : false));

  useEffect(() => {
    onScroll && onScroll(position, activityKey);
  }, [position, activityKey]);

  return (
    <div className="pane" style={{ display: isShow(), overflowY: isScroll ? 'scroll' : 'hidden' }} ref={refs}>
      {children}
    </div>
  );
};
.pane {
  height: 100%;
  min-height: 300px;
}

使用

在使用的时候我们只需要写UI相关的样式,然后进行一个简单的二次封装就可以愉快的使用啦

下面就是一个简单的例子

image.png