• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

我们用 flex、margin、padding 等来布局,写每个组件都要用。

但其实很多布局是通用的。

我们能不能把布局抽离出来,作为一个组件来复用呢?

可以的,这类组件叫做布局组件。

布局就是确定元素的位置,比如间距、对齐、换行等都是确定元素位置的。

在 antd 文档里有专门一个分类:

今天我们来写下其中的 Space 组件。

首先用一下:

npx create-react-app --template=typescript space-component

安装 antd:

npm install --save antd

改下 App.tsx:

import "./App.css";

export default function App() {
    return (
        <div>
            <div className="box"></div>
            <div className="box"></div>
            <div className="box"></div>
        </div>
    );
}

App.css 里写下样式:

.box {
    width: 100px;
    height: 100px;
    background: pink;
    border: 1px solid #000;
}

把开发服务跑起来:

npm run start

渲染出来是这样的:

然后我们用 antd 的 Space 组件包一下:

import { Space } from "antd";
import "./App.css";

export default function App() {
    return (
        <div>
            <Space direction="horizontal">
                <div className="box"></div>
                <div className="box"></div>
                <div className="box"></div>
            </Space>
        </div>
    );
}

方向变为水平了,并且有个默认间距。

改为竖直试一下:

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

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

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

import { Space } from "antd";
import "./App.css";

export default function App() {
    return (
        <div>
            <Space
                direction="horizontal"
                style={{ height: 200, background: "green" }}
                align="center">
                <div className="box"></div>
                <div className="box"></div>
                <div className="box"></div>
            </Space>
        </div>
    );
}

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

也可以用数组分别设置行、列的间距:

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

import { Space } from "antd";
import "./App.css";

export default function App() {
    return (
        <div>
            <Space
                direction="horizontal"
                split={
                    <div className="box" style={{ background: "yellow" }}></div>
                }>
                <div className="box"></div>
                <div className="box"></div>
                <div className="box"></div>
            </Space>
        </div>
    );
}

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

import { ConfigProvider, Space } from "antd";
import "./App.css";

export default function App() {
    return (
        <div>
            <ConfigProvider space={{ size: 100 }}>
                <Space direction="horizontal">
                    <div className="box"></div>
                    <div className="box"></div>
                    <div className="box"></div>
                </Space>
            </ConfigProvider>
        </div>
    );
}

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

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

import { ConfigProvider, Space } from "antd";
import "./App.css";

export default function App() {
    return (
        <div>
            <ConfigProvider space={{ size: 100 }}>
                <Space direction="horizontal">
                    <div className="box"></div>
                    <div className="box"></div>
                    <div className="box"></div>
                </Space>
                <Space direction="vertical">
                    <div className="box"></div>
                    <div className="box"></div>
                    <div className="box"></div>
                </Space>
            </ConfigProvider>
        </div>
    );
}

可以看到,两个 Space 的间距设置都生效了。

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

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

我们自己一般不会封装这种组件,这些布局直接用 flex 写在组件里不就好了,封装啥布局组件?

但其实这样把布局抽出来,就不用在组件里写布局代码了,直接用 Space 组件调整下参数就好。

当布局比较固定的时候,把这种布局抽出来封装的意义就很大,可以各处复用。

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

打开 devtools 看下它的 dom:

就是对每个 child 包一层 div,然后加上不同的 className 就好了。

下面我们来写一下:

创建 Space/index.tsx:

export interface SpaceProps extends React.HTMLAttributes<HTMLDivElement> {
    className?: string;
    style?: React.CSSProperties;
}

const Space: React.FC<SpaceProps> = (props) => {
    const { className, style, ...otherProps } = props;

    return <div className={className} style={style} {...otherProps}></div>;
};

export default Space;

className 和 style 的参数就不用解释了。

这里继承了 HTMLAttributes<HTMLDivElement> 类型,那就可以传入各种 div 的属性。

在 App.tsx 里用用看:

import Space from "./Space";
import "./App.css";

export default function App() {
    return (
        <div>
            <Space></Space>
        </div>
    );
}

这样,组件用起来就和 div 一模一样。

我们只要把其他参数透传给 Space 组件里的 div 即可:

然后把其他 props 也声明了:

export type SizeType = "small" | "middle" | "large" | number | undefined;

export interface SpaceProps extends React.HTMLAttributes<HTMLDivElement> {
    className?: string;
    style?: React.CSSProperties;
    size?: SizeType | [SizeType, SizeType];
    direction?: "horizontal" | "vertical";
    align?: "start" | "end" | "center" | "baseline";
    split?: React.ReactNode;
    wrap?: boolean;
}

style 是 CSSProperties 类型,可以传入各种 css。

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

size 可以传单个值代表横竖间距,或者传一个数组,分别设置横竖间距。

这些我们都测试过。

然后是内容部分:

我们传入的是这样的 children:

但是渲染出来的包了一层 div:

这是怎么做到的呢?

用 React.Children 的 api。

文档里可以看到这些 api:

很明显,就是用于 children 的遍历、修改、计数等操作的。

有的同学可能对 React.Children.toArray 有疑问:

children 不是已经是数组了么?为什么还要用 React.Children.toArray 转一下?

试下这段代码就知道了:

import React from "react";

interface TestProps {
    children: React.ReactNode[];
}

function Test(props: TestProps) {
    const children2 = React.Children.toArray(props.children);

    console.log(props.children);
    console.log(children2);
    return <div></div>;
}

export default function App() {
    return (
        <Test>
            {[[<div>111</div>, <div>222</div>], [<div>333</div>]]}
            <span>hello world</span>
        </Test>
    );
}

分别打印 props.children 和 Children.toArray 处理之后的 children:

可以看到,React.Children.toArray 对 children 做扁平化。

而且 props.children 调用 sort 方法会报错:

import React from "react";

interface TestProps {
    children: React.ReactNode[];
}

function Test(props: TestProps) {
    console.log(props.children.sort());
    return <div></div>;
}

export default function App() {
    return (
        <Test>
            {33}
            <span>hello world</span>
            {22}
            {11}
        </Test>
    );
}

toArray 之后就不会了:

import React from "react";

interface TestProps {
    children: React.ReactNode[];
}

function Test(props: TestProps) {
    const children2 = React.Children.toArray(props.children);

    console.log(children2.sort());
    return <div></div>;
}

export default function App() {
    return (
        <Test>
            {33}
            <span>hello world</span>
            {22}
            {11}
        </Test>
    );
}

可以看到,可以排序了。

这里的打印如果执行两遍,是 React.StrictMode 那个组件导致的,可以改下 index.tsx:

import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(
    document.getElementById("root") as HTMLElement
);
root.render(<App />);

我们遍历下 Children:

import React from "react";

export type SizeType = "small" | "middle" | "large" | number | undefined;

export interface SpaceProps extends React.HTMLAttributes<HTMLDivElement> {
    className?: string;
    style?: React.CSSProperties;
    size?: SizeType | [SizeType, SizeType];
    direction?: "horizontal" | "vertical";
    align?: "start" | "end" | "center" | "baseline";
    split?: React.ReactNode;
    wrap?: boolean;
}

const Space: React.FC<SpaceProps> = (props) => {
    const { className, style, ...otherProps } = props;

    const childNodes = React.Children.toArray(props.children);

    const nodes = childNodes.map((child: any, i) => {
        const key = (child && child.key) || `space-item-${i}`;

        return (
            <div className="space-item" key={key}>
                {child}
            </div>
        );
    });

    return (
        <div className={className} style={style} {...otherProps}>
            {nodes}
        </div>
    );
};

export default Space;

在 App.tsx 里测试下:

import "./App.css";
import Space from "./Space";

export default function App() {
    return (
        <Space>
            <div>111</div>
            <div>222</div>
            <div>333</div>
        </Space>
    );
}

可以看到,children 修改成功了:

然后我们引入 classnames 包处理下其它 className:

npm install --save classnames

根据 direction、align 的 props 来生成 className:

import React from "react";
import classNames from "classnames";

export type SizeType = "small" | "middle" | "large" | number | undefined;

export interface SpaceProps extends React.HTMLAttributes<HTMLDivElement> {
    className?: string;
    style?: React.CSSProperties;
    size?: SizeType | [SizeType, SizeType];
    direction?: "horizontal" | "vertical";
    align?: "start" | "end" | "center" | "baseline";
    split?: React.ReactNode;
    wrap?: boolean;
}

const Space: React.FC<SpaceProps> = (props) => {
    const {
        className,
        style,
        children,
        size = "small",
        direction = "horizontal",
        align,
        split,
        wrap = false,
        ...otherProps
    } = props;

    const childNodes = React.Children.toArray(children);

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

    const nodes = childNodes.map((child: any, i) => {
        const key = (child && child.key) || `space-item-${i}`;

        return (
            <div className="space-item" key={key}>
                {child}
            </div>
        );
    });

    return (
        <div className={cn} style={style} {...otherProps}>
            {nodes}
        </div>
    );
};

export default Space;

测试下:

import "./App.css";
import Space from "./Space";

export default function App() {
    return (
        <Space direction="horizontal" align="end">
            <div>111</div>
            <div>222</div>
            <div>333</div>
        </Space>
    );
}

也生效了:

那接下来的事情不就很简单了么,只要实现这些 className 的样式就好了。

我们安装 sass:

npm install --save-dev sass

然后写下样式:

Space/index.scss:

.space {
    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;
        }
    }
}

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

在 Space 组件引入:

测试下:

import "./App.css";
import Space from "./Space";

export default function App() {
    return (
        <Space direction="vertical" align="end">
            <div>111</div>
            <div>222</div>
            <div>333</div>
        </Space>
    );
}

没啥问题。

接下来是根据传入的 size 来计算间距。

image.png

如果 size 不是数组,就要扩展成数组,然后再判断是不是 small、middle、large 这些,是的话就变成具体的值。

最终根据 size 设置 column-gap 和 row-gap 的样式,如果有 wrap 参数,还要设置 flex-wrap。

import React from "react";
import classNames from "classnames";
import "./index.scss";

export type SizeType = "small" | "middle" | "large" | number | undefined;

export interface SpaceProps extends React.HTMLAttributes<HTMLDivElement> {
    className?: string;
    style?: React.CSSProperties;
    size?: SizeType | [SizeType, SizeType];
    direction?: "horizontal" | "vertical";
    align?: "start" | "end" | "center" | "baseline";
    split?: React.ReactNode;
    wrap?: boolean;
}

const spaceSize = {
    small: 8,
    middle: 16,
    large: 24,
};

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

const Space: React.FC<SpaceProps> = (props) => {
    const {
        className,
        style,
        children,
        size = "small",
        direction = "horizontal",
        align,
        split,
        wrap = false,
        ...otherProps
    } = props;

    const childNodes = React.Children.toArray(children);

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

    const nodes = childNodes.map((child: any, i) => {
        const key = (child && child.key) || `space-item-${i}`;

        return (
            <div className="space-item" key={key}>
                {child}
            </div>
        );
    });

    const otherStyles: React.CSSProperties = {};

    const [horizontalSize, verticalSize] = React.useMemo(
        () =>
            (
                (Array.isArray(size) ? size : [size, size]) as [
                    SizeType,
                    SizeType,
                ]
            ).map((item) => getNumberSize(item)),
        [size]
    );

    otherStyles.columnGap = horizontalSize;
    otherStyles.rowGap = verticalSize;

    if (wrap) {
        otherStyles.flexWrap = "wrap";
    }

    return (
        <div
            className={cn}
            style={{
                ...otherStyles,
                ...style,
            }}
            {...otherProps}>
            {nodes}
        </div>
    );
};

export default Space;

测试下:

import "./App.css";
import Space from "./Space";

export default function App() {
    return (
        <Space
            className="container"
            direction="horizontal"
            align="end"
            wrap={true}
            size={["large", "small"]}>
            <div className="box"></div>
            <div className="box"></div>
            <div className="box"></div>
        </Space>
    );
}
.box {
    width: 100px;
    height: 100px;
    background: pink;
    border: 1px solid #000;
}

.container {
    width: 300px;
    height: 300px;
    background: green;
}

可以看到,gap、flex-wrap 的设置都是对的。

接下来,处理下 split 参数:

const nodes = childNodes.map((child: any, i) => {
    const key = (child && child.key) || `space-item-${i}`;

    return (
        <>
            <div className="space-item" key={key}>
                {child}
            </div>
            {i < childNodes.length && split && (
                <span className={`${className}-split`} style={style}>
                    {split}
                </span>
            )}
        </>
    );
});

此外,这个组件还会从 ConfigProvider 中取值:

前面测试过,当有 ConfigProvider 包裹的时候,就不用单独设置 size 了,会直接用那里的配置。

这个很明显是用 context 实现的。

创建 Space/ConfigProvider.tsx

import React, { PropsWithChildren } from "react";
import { SizeType } from ".";

export interface ConfigContextType {
    space?: {
        size?: SizeType;
    };
}
export const ConfigContext = React.createContext<ConfigContextType>({});

在 Space 组件里用 useContext 读取它:

这样,size 默认值会优先用 context 里的值。

const { space } = React.useContext(ConfigContext);

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

至此,这个组件我们就完成了。

测试下:

import "./App.css";
import Space from "./Space";
import { ConfigContext } from "./Space/ConfigProvider";

export default function App() {
    return (
        <div>
            <ConfigContext.Provider value={{ space: { size: 20 } }}>
                <Space direction="horizontal">
                    <div className="box"></div>
                    <div className="box"></div>
                    <div className="box"></div>
                </Space>
                <Space direction="vertical">
                    <div className="box"></div>
                    <div className="box"></div>
                    <div className="box"></div>
                </Space>
            </ConfigContext.Provider>
        </div>
    );
}

没啥问题。

不过这个 ConfigProvider 和 antd 的还是不大一样。

antd 的是这样的:

我们的是这样的:

很明显需要再包一层:

interface ConfigProviderProps extends PropsWithChildren<ConfigContextType> {}

export function ConfigProvider(props: ConfigProviderProps) {
    const { space, children } = props;

    return (
        <ConfigContext.Provider value={{ space }}>
            {children}
        </ConfigContext.Provider>
    );
}

这样就一样了:

import "./App.css";
import Space from "./Space";
import { ConfigProvider } from "./Space/ConfigProvider";

export default function App() {
    return (
        <div>
            <ConfigProvider space={{ size: 20 }}>
                <Space direction="horizontal">
                    <div className="box"></div>
                    <div className="box"></div>
                    <div className="box"></div>
                </Space>
                <Space direction="vertical">
                    <div className="box"></div>
                    <div className="box"></div>
                    <div className="box"></div>
                </Space>
            </ConfigProvider>
        </div>
    );
}

案例代码上传了小册仓库。

总结

我们自己实现了 antd 的 Space 组件。

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

我们用到了 React.children 的 api 来修改 children,然后根据 props 来确定 className,然后还有 context 的读取。

这个组件并不复杂,但这种把布局抽离成组件来复用的方式还是很值得学习的。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
13.组件实战:Icon图标组件
Next
15.React.Children和它的两种替代方案