没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

lxf2023-04-05 12:15:02

用 React 技术栈的小伙伴基本每天都在写 React 组件,但是大多是是业务组件,并不是很复杂。

基本就是传入 props,render 出最终的视图,用 hooks 组织下逻辑,最多再用下 context 跨层传递数据。

那相对复杂的组件是什么样子的呢?

其实 antd 组件库里就有很多。

今天我们就来实现一个 antd 组件库里的组件 -- Space 组件吧。

首先看下它是怎么用的:

这是一个布局组件:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

文档介绍它是设置组件的间距的,还可以设置多个组件怎么对齐。

比如这样 3 个盒子:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

渲染出来是这样的:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

我们用 Space 组件包一下,设置方向为水平,就变成这样了:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

当然,也可以竖直:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

水平和竖直的间距都可以通过 size 来设置:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

可以设置 large、middle、small 或者任意数值。

多个子节点可以设置对齐方式,比如 start、end、center 或者 baseline:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

此外子节点过多可以设置换行:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

space 也可以单独设置行列的:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

最后,它还可以设置 split 分割线部分:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

此外,你也可以不直接设置 size,而是通过 ConfigProvider 修改 context 中的默认值:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

很明显,Space 内部会读取 context 中的 size 值。

这样如果有多个 Space 组件就不用每个都设置了,统一加个 ConfigProvider 就行了:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

这就是 Space 组件的全部用法,简单回顾下这几个参数和用法:

  • direction: 设置子组件方向,水平还是竖直排列
  • size:设置水平、竖直的间距
  • align:子组件的对齐方式
  • wrap:超过一屏是否换行,只在水平时有用
  • split:分割线的组件
  • 多个 Space 组件的 size 可以通过 ConfigProvider 统一设置默认值。

是不是过一遍就会用了?

用起来还是挺简单的,但它的功能挺强大。

那这样的布局组件是怎么实现的呢?

我们先看下它最终的 dom:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

对每个 box 包了一层 div,设置了 ant-space-item 的 class。

对 split 部分包了一层 span,设置了 ant-space-item-split 的 class。

最外层包了一层 div,设置了 ant-space 等 class。

这些还是很容易想到的,毕竟设置布局嘛,不包一层怎么布局?

但虽然看起来挺简单,实现的话还是有不少东西的。

下面我们来写一下:

首先声明组件 props 的类型:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

需要注意的是 style 是 React.CSSProperties 类型,也就是各种 css 都可以写。

split 是 React.ReactNode 类型,也就是可以传入 jsx。

其余的参数的类型就是根据取值来,我们上面都测试过。

Space 组件会对所有子组件包一层 div,所以需要遍历传入的 children,做下修改:

props 传入的 children 要转成数组可以用 React.Children.toArray 方法

有的同学说,children 不是已经是数组了么?为什么还要用 React.Children.toArray 转一下?

因为 toArray 可以对 children 做扁平化:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

更重要的是直接调用 children.sort() 会报错

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

而 toArray 之后就不会了:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

同理,我们会用 React.Children.forEach,React.Children.map 之类的方法操作 children,而不是直接操作。

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

但这里我们有一些特殊的需求,比如空节点不过滤掉,依然保留。

所以用 React.Children.forEach 自己实现一下 toArray:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

这部分比较容易看懂,就是用 React.Children.forEach 遍历 jsx 节点,对每个节点做下判断,如果是数组或 fragment 就递归处理,否则 push 到数组里。

保不保留空节点可以根据 keepEmpty 的 option 来控制。

这样用:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

children 就可以遍历渲染 item 了,这部分是这样的:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

我们单独封装个 Item 组件。

然后 childNodes 遍历渲染这个 Item 就可以了:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

然后把这所有的 Item 组件再放到最外层 div 里:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

就可以分别控制整体的布局和 Item 的布局了。

具体的布局还是通过 className 和样式来的:

className 通过 props 计算而来:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

用到了 classnames 这个包,这个算是 react 生态很常用的包了,根据 props 动态生成 className 基本都用这个。

这个前缀是动态获取的,最终就是 ant-space 的前缀:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

这些 class 的样式也都定义好:

$ant-prefix: 'ant';

$space-prefix-cls: #{$ant-prefix}-space;
$space-item-prefix-cls: #{$ant-prefix}-space-item;
 
.#{$space-prefix-cls} {
  display: inline-flex;

  &-vertical {
    flex-direction: column;
  }

  &-align {
    &-center {
      align-items: center;
    }

    &-start {
      align-items: flex-start;
    }

    &-end {
      align-items: flex-end;
    }

    &-baseline {
      align-items: baseline;
    }
  }
}

.#{$space-prefix-cls} {
  &-rtl {
    direction: rtl;
  }
}

整个容器 inline-flex,然后根据不同的参数设置 align-items 和 flex-direction 的值。

最后一个 direction 的 css 可能大家没用过,是设置文本方向的:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

这样,就通过 props 动态给最外层 div 加上了相应的 className,设置了对应的样式。

但还有一部分样式没设置,也就是间距:

其实这部分可以用 gap 设置:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

当然,用 margin 也可以,只不过那个要单独处理下最后一个元素,比较麻烦。

不过 antd 这种组件自然要做的兼容性好点,所以两种都支持,支持 gap 就用 gap,否则用 margin。

问题来了,antd 是怎么检测浏览器是否支持 gap 样式的呢?

它是这么做的:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

创建一个 div,设置样式,加到 body 下,看看 scrollHeight 是多少,最后把这个元素删掉。

这样就能判断是是否支持 gap、column 等样式,因为不支持的话高度会是 0。

然后它又提供了这样一个 hook:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

第一次会检测并设置 state 的值,之后直接返回这个检测结果。

这样组件里就可以就可以用这个 hook 来判断是否支持 gap,从而设置不同的样式了:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

是不是很巧妙?

最后,这个组件还会从 ConfigProvider 中取值,这个我们见到过:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

所以,再处理下这部分:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

用 useContext 读取 context 中的值,设置为 props 的解构默认值,这样如果传入了 props.size 就用传入的值,否则就用 context 里的值。

这里给 Item 子组件传递数据也是通过 context,因为 Item 组件不一定会在哪一层。

用 createContext 创建 context 对象

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

把计算出的 size:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

还有其他的一些值:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

都通过 Provider 设置到 spaceContext 中:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

这样子组件就能拿到 spaceContext 中的值了。

这里 useMemo 很多同学不会用,其实很容易理解:

props 变了会触发组件重新渲染,但有的时候 props 并不需要变化却每次都变,这样就可以通过 useMemo 来避免它没必要的变化了。

useCallback 也是同样的道理。

计算 size 的时候封装了一个 getNumberSize 方法,对于字符串枚举值设置了一些固定的数值:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

至此,这个组件我们就完成了,当然,Item 组件还没展开讲。

先来欣赏下这个 Space 组件的全部源码:

import classNames from 'classnames';
import * as React from 'react';
import { ConfigContext, SizeType } from './config-provider';
import Item from './Item';
import toArray from './toArray';
import './index.scss'
import useFlexGapSupport from './useFlexGapSupport';

export interface Option {
  keepEmpty?: boolean;
}

export const SpaceContext = React.createContext({
  latestIndex: 0,
  horizontalSize: 0,
  verticalSize: 0,
  supportFlexGap: false,
});

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 spaceSize = {
  small: 8,
  middle: 16,
  large: 24,
};

function getNumberSize(size: SpaceSize) {
  return typeof size === 'string' ? spaceSize[size] : size || 0;
}

const Space: React.FC<SpaceProps> = props => {
  const { getPrefixCls, space, direction: directionConfig } = React.useContext(ConfigContext);

  const {
    size = space?.size || 'small',
    align,
    className,
    children,
    direction = 'horizontal',
    split,
    style,
    wrap = false,
    ...otherProps
  } = props;

  const supportFlexGap = useFlexGapSupport();

  const [horizontalSize, verticalSize] = React.useMemo(
    () =>
      ((Array.isArray(size) ? size : [size, size]) as [SpaceSize, SpaceSize]).map(item =>
        getNumberSize(item),
      ),
    [size],
  );
  const childNodes = toArray(children, {keepEmpty: true});

  const mergedAlign = align === undefined && direction === 'horizontal' ? 'center' : align;
  const prefixCls = getPrefixCls('space');
  const cn = classNames(
    prefixCls,
    `${prefixCls}-${direction}`,
    {
      [`${prefixCls}-rtl`]: directionConfig === 'rtl',
      [`${prefixCls}-align-${mergedAlign}`]: mergedAlign,
    },
    className,
  );

  const itemClassName = `${prefixCls}-item`;

  const marginDirection = directionConfig === 'rtl' ? 'marginLeft' : 'marginRight';

  // Calculate latest one
  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>
    );
  });

  const spaceContext = React.useMemo(
    () => ({ horizontalSize, verticalSize, latestIndex, supportFlexGap }),
    [horizontalSize, verticalSize, latestIndex, supportFlexGap],
  );

  if (childNodes.length === 0) {
    return null;
  }

  const gapStyle: React.CSSProperties = {};

  if (wrap) {
    gapStyle.flexWrap = 'wrap';

    if (!supportFlexGap) {
      gapStyle.marginBottom = -verticalSize;
    }
  }

  if (supportFlexGap) {
    gapStyle.columnGap = horizontalSize;
    gapStyle.rowGap = verticalSize;
  }

  return (
    <div
      className={cn}
      style={{
        ...gapStyle,
        ...style,
      }}
      {...otherProps}
    >
      <SpaceContext.Provider value={spaceContext}>{nodes}</SpaceContext.Provider>
    </div>
  );
};

export default Space;

回顾下要点:

  • 基于 React.Children.forEach 自己封装了 toArray 方法,做了一些特殊处理
  • 对 childNodes 遍历之后,包裹了一层 Item 组件
  • 封装了 useFlexGapSupport 的 hook,里面通过创建 div 检查 scrollHeight 的方式来确定是否支持 gap 样式
  • 通过 useContext 读取 ConfigContext 的值,作为 props 的解构默认值
  • 通过 createContext 创建 spaceContext,并通过 Provider 设置其中的值
  • 通过 useMemo 缓存作为参数的对象,避免不必要的渲染
  • 通过 classnames 包来根据 props 动态生成 className

思路理的差不多了,再来看下 Item 的实现:

这部分比较简单,直接上全部代码了:

import * as React from 'react';
import { SpaceContext } from '.';

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

export default function Item({
  className,
  direction,
  index,
  marginDirection,
  children,
  split,
  wrap,
}: ItemProps) {
  const { horizontalSize, verticalSize, latestIndex, supportFlexGap } =
    React.useContext(SpaceContext);

  let style: React.CSSProperties = {};

  if (!supportFlexGap) {
    if (direction === 'vertical') {
      if (index < latestIndex) {
        style = { marginBottom: horizontalSize / (split ? 2 : 1) };
      }
    } else {
      style = {
        ...(index < latestIndex && { [marginDirection]: horizontalSize / (split ? 2 : 1) }),
        ...(wrap && { paddingBottom: verticalSize }),
      };
    }
  }

  if (children === null || children === undefined) {
    return null;
  }

  return (
    <>
      <div className={className} style={style}>
        {children}
      </div>
      {index < latestIndex && split && (
        <span className={`${className}-split`} style={style}>
          {split}
        </span>
      )}
    </>
  );
}

通过 useContext 从 SpaceContext 中取出 Space 组件里设置的值。

根据是否支持 gap 来分别使用 gap 或者 margin、padding 的样式来设置间距。

每个元素都用 div 包裹下,设置 className。

如果不是最后一个元素并且有 split 部分,就渲染 split 部分,用 span 包裹下。

这块还是比较清晰的。

最后,还有 ConfigProvider 的部分没有看:

这部分就是创建一个 context,并初始化一些值:

import React from "react";

export type DirectionType = 'ltr' | 'rtl' | undefined;

export type SizeType = 'small' | 'middle' | 'large' | undefined;

export interface ConfigConsumerProps {
  getPrefixCls: (suffixCls?: string) => string;
  direction?: DirectionType;
  space?: {
    size?: SizeType | number;
  }
}

export const defaultGetPrefixCls = (suffixCls?: string) => {
  return suffixCls ? `ant-${suffixCls}` : 'ant';
};

export const ConfigContext = React.createContext<ConfigConsumerProps>({
    getPrefixCls: defaultGetPrefixCls
});

有没有感觉 antd 里用 context 简直太多了!

确实。

为什么呢?

因为你不能保证组件和子组件隔着几层。

比如 Form 和 Form.Item:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

比如 ConfigProvider 和各种组件(这里是 Space):

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

还有刚讲过的 Space 和 Item。

它们能用 props 传数据么?

不能,因为不知道隔几层。

所以 antd 里基本都是用 cotnext 传数据的。

你会你在 antd 里会见到大量的用 createCotnext 创建 context,通过 Provider 修改 context 值,通过 Consumer 或者 useContext 读取 context 值的这类逻辑。

最后,我们来测试下自己实现的这个 Space 组件吧:

测试代码如下:

import Space from './space';
import './SpaceTest.css';
import { ConfigContext, defaultGetPrefixCls,  } from './space/config-provider';
import React from 'react';

const SpaceTest = () => (
  <ConfigContext.Provider value={
    {
      getPrefixCls: defaultGetPrefixCls,
      space: { size: 'large'}
    }
  }>
    <Space 
      direction="horizontal"
      align="end" 
      style={{height:'200px'}}
      split={<div className="box" style={{background: 'red'}}></div>} 
      wrap={true}
    >
      <div className="box"></div>
      <div className="box"></div>
      <div className="box"></div>
    </Space>
    <Space 
      direction="horizontal"
      align="end" 
      style={{height:'200px'}}
      split={<div className="box" style={{background: 'red'}}></div>} 
      wrap={true}
    >
      <div className="box"></div>
      <div className="box"></div>
      <div className="box"></div>
    </Space>
  </ConfigContext.Provider>
);

export default SpaceTest;

这部分不咋用解释了。就是 ConfigProvider 包裹了俩 Space 组件,这俩 Space 组件没设置 size 值。

设置了 direction、align、split、wrap 等参数。

渲染结果是对的:

没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧

就这样,我们自己实现了 antd 的 Space 组件!

完整代码在 github:github.com/QuarkGluonP…

总结

一直写业务代码,可能很少写一些复杂的组件,而 antd 里就有很多复杂组件,我们挑 Space 组件来写了下。

这是一个布局组件,可以通过参数设置水平、竖直间距、对齐方式、分割线部分等。

实现这个组件的时候,我们用到了很多东西:

  • 用 React.Children.forEach 的 api 来修改每个 childNode。
  • 用 useContext 读取 ConfigContext、SpaceContext 的值
  • 用 createContext 创建 SpaceContext,并用 Provider 修改其中的值
  • 用 useMemo 来避免没必要的渲染
  • 用 classnames 包来根据 props 动态生成 className
  • 自己封装了一个检测样式是否支持的自定义 hook

很多同学不会封装布局组件,其实就是对整体和每个 item 都包裹一层,分别设置不同的 class,实现不同的间距等的设置。

想一下,这些东西以后写业务组件是不是也可以用上呢?