本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
用 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:
image.png
比如 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 class style={{background: 'red'}}></div>} wrap={true} > <div class style={{height:'200px'}} split={<div class style={{background: 'red'}}></div>} wrap={true} > <div class></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,实现不同的间距等的设置。
想一下,这些东西以后写业务组件是不是也可以用上呢?