用 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:
還有剛講過的 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:https://github.com/QuarkGluonPlasma/my-antd-test
總結
一直寫業務代碼,可能很少寫一些複雜的元件,而 antd 裡就有很多複雜元件,我們挑 Space 元件來寫了下。
這是一個布局元件,可以通過參數設定水準、豎直間距、對齊方式、分割線部分等。
實作這個元件的時候,我們用到了很多東西:
- 用 React.Children.forEach 的 api 來修改每個 childNode。
- 用 useContext 讀取 ConfigContext、SpaceContext 的值
- 用 createContext 建立 SpaceContext,并用 Provider 修改其中的值
- 用 useMemo 來避免沒必要的渲染
- 用 classnames 包來根據 props 動态生成 className
- 自己封裝了一個檢測樣式是否支援的自定義 hook
很多同學不會封裝布局元件,其實就是對整體和每個 item 都包裹一層,分别設定不同的 class,實作不同的間距等的設定。
想一下,這些東西以後寫業務元件是不是也可以用上呢?