前言
本系列基于 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 组件基本就大功告成了
结尾
这篇就是记录我自己封装时的一个思路。
后面些时日我会贴出源码地址和线上地址。感兴趣的话可以自己去看看