React 组件库 Happy-ui(一):Space 组件

前言

本系列基于 React + Ts 搭建属于自己的组件库,目的是学习如何高效的封装组件,以及一些封装组件的技巧

正片

Space 的 DOM 结构

首先,我们来看看Ant Design Space组件是怎么样去做布局的

tsx 复制代码
import React from 'react';
import { UploadOutlined } from '@ant-design/icons'; 
import { Button, Popconfirm, Space, Upload } from 'antd'; 
const App: React.FC = () => ( 
    <Space> 
        Space 
        <Button type="primary">Button</Button>
        <Upload>
        <Button icon={<UploadOutlined />}>Click to Upload</Button>
        </Upload>
        <Popconfirm title="Are you sure delete this task?" okText="Yes" cancelText="No">
        <Button>Confirm</Button>
        </Popconfirm> 
   </Space>
); 
export default App;

渲染的 DOM 如下:

我们发现,Space 组件会创建一个容器 div.ant-space(是 flex 布局,用于direction的布局), 然后给每一个子组件外层套一个 div.ant-sapce-item(设置样式后,可以实现内部子元素的对齐方式)

那清楚了它的 DOM 结构,对应的布局功能思路如下:

  • 通过 const childNodes = React.Children.toArray(props.children) 将包裹的子元素数组扁平化,方便遍历生成 DOM 结构,二来这样转了之后,调用 children.sort()、children.forEach() 不会报错
  • 然后遍历 childNodes,把每个子元素渲染在 Item 子组件中
  • 最后将处理好的子元素包裹在外层容器中
  • 当然,我们再通过 classnames 这个包去生成 className
tsx 复制代码
export type SpaceSize = SizeType | number;

export interface SpaceProps extends React.HTMLAttributes<HTMLDivElement> {
  className?: string;
  style?: React.CSSProperties;
  size?: SpaceSize | [SpaceSize, SpaceSize];
  direction?: 'horizontal' | 'vertical';
  align?: 'start' | 'end' | 'center' | 'baseline';
  split?: React.ReactNode;
  wrap?: boolean;
}

const getPrefixCls = (suffixCls?: string) => {
  return suffixCls ? `happy-${suffixCls}` : 'happy';
}

const Space: React.FC<SpaceProps> = (props) => {
  const {
    size = space?.size || 'small',
    align,
    className,
    children,
    direction = 'horizontal',
    split,
    style,
    wrap = false,
    ...otherProps
  } = props;
  
  // 生成 class 前缀, 即 happy-space
  const prefixCls = getPrefixCls('space');
  // classNames 第三方包生成 Space 组件的 className 类名
  const cn = classNames(
    prefixCls,
    `${prefixCls}-${direction}`,
    {
      [`${prefixCls}-rtl`]: directionConfig === 'rtl',
      [`${prefixCls}-align-${mergedAlign}`]: mergedAlign,
    },
    className,
  );
  
  // 扁平化
  const childNodes = React.Children.toArray(children);
  
  // 用 Item 组件包裹每一个 children
  // 并记录有多少个 children . 通过记录下标的方式来
  let latestIndex = 0;
  const nodes = childNodes.map((child: any, i) => {
    if (child !== null && child !== undefined) {
      latestIndex = i;
    }

    const key = (child && child.key) || `${itemClassName}-${i}`;

    return (
      <Item
        className={itemClassName}
        key={key}
        direction={direction}
        index={i}
        marginDirection={marginDirection}
        split={split}
        wrap={wrap}
      >
        {child}
      </Item>
    );
  });
  
  return (
      <div
        className={cn}
        style={{
          ...gapStyle,
          ...style,
        }}
        {...otherProps}
      >
        {/* 将处理好的子元素包裹在外层容器中 */}
        {nodes}
      </div>
    );
}

然后对应的 Item 组件里面的代码如下:

tsx 复制代码
import * as React from 'react';

export interface ItemProps {
  className: string;
  children: React.ReactNode;
  index: number;
  direction?: 'horizontal' | 'vertical';
  marginDirection: 'marginLeft' | 'marginRight';
  split?: string | React.ReactNode;
  wrap?: boolean;
}

const Item: React.FC<ItemProps> = ({
  className,
  direction,
  index,
  marginDirection,
  children,
  split,
  wrap,
}) => {
  if (children === null || children === undefined) {
    return null;
  }

  return (
    <>
      <div className={className} style={style}>
        {children}
      </div>
      {/* 如果传入了自定义的分隔符 split, 就加载每一项的后面即可 */}
      {index < latestIndex && split && (
        <span className={`${className}-split`} style={style}>
          {split}
        </span>
      )}
    </>
  );
};

export default Item;

然后样式如下:

less 复制代码
.happy-space {
  display: inline-flex;
}
.happy-space-vertical {
  flex-direction: column;
}
.happy-space-align-center {
  align-items: center;
}
.happy-space-align-start {
  align-items: flex-start;
}
.happy-space-align-end {
  align-items: flex-end;
}
.happy-space-align-baseline {
  align-items: baseline;
}
.happy-space-rtl {
  direction: rtl;
}

至此,我们完成了布局的操作

gap 间隔布局

Ant Design Space的间隔布局是通过 gap 熟悉来实现的,那如何判断是否支持 gap 属性呢?

它是这么做的

ts 复制代码
let flexGapSupported: boolean | undefined;
export const detectFlexGapSupported = () => {
  if (flexGapSupported !== undefined) {
    return flexGapSupported;
  }

  const flex = document.createElement('div');
  flex.style.display = 'flex';
  flex.style.flexDirection = 'column';
  flex.style.rowGap = '1px';

  flex.appendChild(document.createElement('div'));
  flex.appendChild(document.createElement('div'));

  document.body.appendChild(flex);
  flexGapSupported = flex.scrollHeight === 1;
  document.body.removeChild(flex);

  return flexGapSupported;
};

它的逻辑是:

  • 创建一个 div(我们叫他 flexDiv 吧), 然后设置这个 div 的样式为 display: 'flex', flexDirection: 'column', rowGap: '1px'然后再创建两个 div, 通过 appendChild 添加到 flexDiv 中, 然后再把 flexDiv 加到 body 下, 判断 flexDiv.scrollHeight 是否等于 1, 等于就支持否则就不支持 gap. 然后再删除 flexDiv.

当支持 gap 时,我们用 gap 来间隔,不支持时再去设置 marignLeft 或者 marginRight 来间隔

然后再封装一个 hook,来判断是否支持 gap

tsx 复制代码
import * as React from 'react';
import { detectFlexGapSupported } from './styleChecker';

export default () => {
  const [flexible, setFlexible] = React.useState(false);
  React.useEffect(() => {
    setFlexible(detectFlexGapSupported());
  }, []);

  return flexible;
};

然后再 Space 组件里面

tsx 复制代码
import useFlexGapSupport from './useFlexGapSupport';
const Space: React.FC<SpaceProps> = (props) => {
    //...其余代码
    
    // 判断是否支持 gap 属性
    const supportFlexGap = useFlexGapSupport();
    
    if (supportFlexGap) {
      // 设置 gap
    }
    
    // ... 其余代码
}

context 传值

Space 组件还可以通过 ConfigProvider 的方式传递某些 context

那我们可以在 Space 里面创建一个 SpaceContext

tsx 复制代码
export const SpaceContext = React.createContext({
  latestIndex: 0, //几个子元素
  horizontalSize: 0, //水平布局时的间隔大小
  verticalSize: 0, // 垂直布局时的间隔大小
  supportFlexGap: false, // 是否支持 gap 属性
});

然后,因为传入的 size 可以是 large | middle | small, 也可以是 300 这样的数字,所以需要计算一下

tsx 复制代码
  function getNumberSize(size: SpaceSize) {
    return typeof size === 'string' ? spaceSize[size] : size || 0;
  }
  // size 可以是 SizeType 类型, 也可以是传入具体的数字
  // 这里处理成 [size, size] 用于计算水平以及垂直的间距 gap
  const [horizontalSize, verticalSize] = React.useMemo(
    () =>
      (
        (Array.isArray(size) ? size : [size, size]) as [SpaceSize, SpaceSize]
      ).map((item) => getNumberSize(item)),
    [size],
  );

然后,我们将计算出来的最新值,放到 spaceContext 这个变量中,

tsx 复制代码
 const spaceContext = React.useMemo(
    () => ({ horizontalSize, verticalSize, latestIndex, supportFlexGap }),
    [horizontalSize, verticalSize, latestIndex, supportFlexGap],
 );

最后,再通过 context 传递给子组件 Item

tsx 复制代码
const Space = () => {
   //...其余代码
   
   return (
    <div
      className={cn}
      style={{
        ...gapStyle,
        ...style,
      }}
      {...otherProps}
    >
      {/* 这里给 Item 子组件传递数据也是通过 context,因为 Item 组件不一定会在哪一层。 */}
      <SpaceContext.Provider value={spaceContext}>
        {nodes}
      </SpaceContext.Provider>
    </div>
  );
}

然后再 Item 子组件里面获取,就可以去做 gap 布局或者 margin 布局

tsx 复制代码
const Item = () => {
      const { 
        horizontalSize, 
        verticalSize, 
        latestIndex, 
        supportFlexGap 
      } = React.useContext(SpaceContext);

      // 不支持 gap 时才去手动设置 style 变量
      if (!supportFlexGap) {
        if (direction === 'vertical') {
          // 如果不支持 gap 并且是垂直布局,且还不是最后一个,那就设置 marginBottom
          if (index < latestIndex) {
            style = { marginBottom: horizontalSize / (split ? 2 : 1) };
          }
        } else {
          // 如果不支持 gap 并且是横向布局, 且还不是最后一个, 那就设置 marginLeft | marginRight
          style = {
            ...(index < latestIndex && {
              [marginDirection]: horizontalSize / (split ? 2 : 1),
            }),
            // 如果自动换行就设置 paddingBottom, 且 wrap 仅在 水平布局时生效
            ...(wrap && { paddingBottom: verticalSize }),
          };
        }
      }
      
      //...其余代码
}

这样,Space 组件基本就大功告成了

结尾

这篇就是记录我自己封装时的一个思路。

后面些时日我会贴出源码地址和线上地址。感兴趣的话可以自己去看看

相关推荐
September_ning1 小时前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人1 小时前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱0011 小时前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js
Rattenking3 小时前
React 源码学习01 ---- React.Children.map 的实现与应用
javascript·学习·react.js
熊的猫4 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
小牛itbull9 小时前
ReactPress:重塑内容管理的未来
react.js·github·reactpress
FinGet20 小时前
那总结下来,react就是落后了
前端·react.js
王解1 天前
Jest项目实战(2): 项目开发与测试
前端·javascript·react.js·arcgis·typescript·单元测试
AIoT科技物语2 天前
免费,基于React + ECharts 国产开源 IoT 物联网 Web 可视化数据大屏
前端·物联网·react.js·开源·echarts
初遇你时动了情2 天前
react 18 react-router-dom V6 路由传参的几种方式
react.js·typescript·react-router