没写过复杂 React 组件?来实现下 AntD 的 Space 组件吧
用 React 技术栈的小伙伴基本每天都在写 React 组件,但是大多是是业务组件,并不是很复杂。
基本就是传入 props,render 出最终的视图,用 hooks 组织下逻辑,最多再用下 context 跨层传递数据。
那相对复杂的组件是什么样子的呢?
其实 antd 组件库里就有很多。
今天我们就来实现一个 antd 组件库里的组件 -- Space 组件吧。
首先看下它是怎么用的:
这是一个布局组件:
文档里介绍它是设置组件的间距的,还可以设置多个组件怎么对齐。
比如这样 3 个盒子:
渲染出来是这样的:
我们用 Space 组件包一下,设置方向为水平,就变成这样了:
当然,也可以竖直:
水平和竖直的间距都可以通过 size 来设置:
可以设置 large、middle、small 或者任意数值。
多个子节点可以设置对齐方式,比如 start、end、center 或者 baseline:
此外子节点过多可以设置换行:
space 也可以单独设置行列的:
最后,它还可以设置 split 分割线部分:
此外,你也可以不直接设置 size,而是通过 ConfigProvider 修改 context 中的默认值:
很明显,Space 内部会读取 context 中的 size 值。
这样如果有多个 Space 组件就不用每个都设置了,统一加个 ConfigProvider 就行了:
这就是 Space 组件的全部用法,简单回顾下这几个参数和用法:
- direction: 设置子组件方向,水平还是竖直排列
- size:设置水平、竖直的间距
- align:子组件的对齐方式
- wrap:超过一屏是否换行,只在水平时有用
- split:分割线的组件
- 多个 Space 组件的 size 可以通过 ConfigProvider 统一设置默认值。
是不是过一遍就会用了?
用起来还是挺简单的,但它的功能挺强大。
那这样的布局组件是怎么实现的呢?
我们先看下它最终的 dom:
对每个 box 包了一层 div,设置了 ant-space-item 的 class。
对 split 部分包了一层 span,设置了 ant-space-item-split 的 class。
最外层包了一层 div,设置了 ant-space 等 class。
这些还是很容易想到的,毕竟设置布局嘛,不包一层怎么布局?
但虽然看起来挺简单,实现的话还是有不少东西的。
下面我们来写一下:
首先声明组件 props 的类型:
需要注意的是 style 是 React.CSSProperties 类型,也就是各种 css 都可以写。
split 是 React.ReactNode 类型,也就是可以传入 jsx。
其余的参数的类型就是根据取值来,我们上面都测试过。
Space 组件会对所有子组件包一层 div,所以需要遍历传入的 children,做下修改:
props 传入的 children 要转成数组可以用 React.Children.toArray 方法。
有的同学说,children 不是已经是数组了么?为什么还要用 React.Children.toArray 转一下?
因为 toArray 可以对 children 做扁平化:
更重要的是直接调用 children.sort() 会报错:
而 toArray 之后就不会了:
同理,我们会用 React.Children.forEach,React.Children.map 之类的方法操作 children,而不是直接操作。
但这里我们有一些特殊的需求,比如空节点不过滤掉,依然保留。
所以用 React.Children.forEach 自己实现一下 toArray:
这部分比较容易看懂,就是用 React.Children.forEach 遍历 jsx 节点,对每个节点做下判断,如果是数组或 fragment 就递归处理,否则 push 到数组里。
保不保留空节点可以根据 keepEmpty 的 option 来控制。
这样用:
children 就可以遍历渲染 item 了,这部分是这样的:
我们单独封装个 Item 组件。
然后 childNodes 遍历渲染这个 Item 就可以了:
然后把这所有的 Item 组件再放到最外层 div 里:
就可以分别控制整体的布局和 Item 的布局了。
具体的布局还是通过 className 和样式来的:
className 通过 props 计算而来:
用到了 classnames 这个包,这个算是 react 生态很常用的包了,根据 props 动态生成 className 基本都用这个。
这个前缀是动态获取的,最终就是 ant-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 可能大家没用过,是设置文本方向的:
这样,就通过 props 动态给最外层 div 加上了相应的 className,设置了对应的样式。
但还有一部分样式没设置,也就是间距:
其实这部分可以用 gap 设置:
当然,用 margin 也可以,只不过那个要单独处理下最后一个元素,比较麻烦。
不过 antd 这种组件自然要做的兼容性好点,所以两种都支持,支持 gap 就用 gap,否则用 margin。
问题来了,antd 是怎么检测浏览器是否支持 gap 样式的呢?
它是这么做的:
创建一个 div,设置样式,加到 body 下,看看 scrollHeight 是多少,最后把这个元素删掉。
这样就能判断是是否支持 gap、column 等样式,因为不支持的话高度会是 0。
然后它又提供了这样一个 hook:
第一次会检测并设置 state 的值,之后直接返回这个检测结果。
这样组件里就可以就可以用这个 hook 来判断是否支持 gap,从而设置不同的样式了:
是不是很巧妙?
最后,这个组件还会从 ConfigProvider 中取值,这个我们见到过:
所以,再处理下这部分:
用 useContext 读取 context 中的值,设置为 props 的解构默认值,这样如果传入了 props.size 就用传入的值,否则就用 context 里的值。
这里给 Item 子组件传递数据也是通过 context,因为 Item 组件不一定会在哪一层。
用 createContext 创建 context 对象:
把计算出的 size:
还有其他的一些值:
都通过 Provider 设置到 spaceContext 中:
这样子组件就能拿到 spaceContext 中的值了。
这里 useMemo 很多同学不会用,其实很容易理解:
props 变了会触发组件重新渲染,但有的时候 props 并不需要变化却每次都变,这样就可以通过 useMemo 来避免它没必要的变化了。
useCallback 也是同样的道理。
计算 size 的时候封装了一个 getNumberSize 方法,对于字符串枚举值设置了一些固定的数值:
至此,这个组件我们就完成了,当然,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:
比如 ConfigProvider 和各种组件(这里是 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 等参数。
渲染结果是对的:
就这样,我们自己实现了 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,实现不同的间距等的设置。
想一下,这些东西以后写业务组件是不是也可以用上呢?
转载自:https://juejin.cn/post/7205171975647608892