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 组件基本就大功告成了

结尾

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

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

相关推荐
Eamonno9 小时前
深入理解React性能优化:掌握useCallback与useMemo的黄金法则
react.js·性能优化
goldenocean11 小时前
React之旅-02 创建项目
前端·react.js·前端框架
一路向前的月光11 小时前
React(8)
前端·react.js·前端框架
林啾啾11 小时前
常用的 React Hooks 的介绍和示例
前端·javascript·react.js
goldenocean13 小时前
React之旅-01 初识
前端·javascript·react.js
power-辰南14 小时前
AI Agent架构深度解析:从ReAct到AutoGPT,自主智能体的技术演进与工程实践
人工智能·react.js·架构·ai agent
开发者每周简报18 小时前
React:UI开发的革新者
javascript·react native·react.js
engchina1 天前
使用 Vite + React 19 集成 Tailwind CSS 与 shadcn/ui 组件库完整指南
css·react.js·ui·vite·tailwind·react 19·shadcn
祈澈菇凉2 天前
React 的 context 是什么?
前端·javascript·react.js
Au_ust2 天前
千峰React:脚手架准备+JSX基础
前端·javascript·react.js