• 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.小册总结

上节我们理清了低代码编辑器的实现原理,实现了核心数据结构 components 和 add、update、delete 方法。

并且把拖拽操作对应到了这些增删改方法上。

这节我们来实现下拖拽操作。

首先,我们把 json 渲染到中间的画布区:

现在的 json 里只有组件名,没有具体的组件:

我们写两个组件:

editor/materials/Container/index.tsx

import { PropsWithChildren } from "react";

const Container = ({ children }: PropsWithChildren) => {
    return (
        <div className="border-[1px] border-[#000] min-h-[100px] p-[20px]">
            {children}
        </div>
    );
};

export default Container;

因为布局放在 components 目录下,那物料组件就放 materials 目录下吧:

加了一个黑色的 border,设置了最小高度为 100px,padding 为 20px。

然后再加一个 Button 组件:

editor/materials/Button/index.tsx

import { Button as AntdButton } from "antd";
import { ButtonType } from "antd/es/button";

export interface ButtonProps {
    type: ButtonType;
    text: string;
}

const Button = ({ type, text }: ButtonProps) => {
    return <AntdButton type={type}>{text}</AntdButton>;
};

export default Button;

安装用到的 antd:

npm install --save-dev antd

然后还要加一个 compnent 名字和 Component 实例的映射。

在 stores 下创建一个新的 Store

stores/component-config.tsx

import { create } from "zustand";
import Container from "../materials/Container";
import Button from "../materials/Button";

export interface ComponentConfig {
    name: string;
    defaultProps: Record<string, any>;
    component: any;
}

interface State {
    componentConfig: { [key: string]: ComponentConfig };
}

interface Action {
    registerComponent: (name: string, componentConfig: ComponentConfig) => void;
}

export const useComponentConfigStore = create<State & Action>((set) => ({
    componentConfig: {
        Container: {
            name: "Container",
            defaultProps: {},
            component: Container,
        },
        Button: {
            name: "Button",
            defaultProps: {
                type: "primary",
                text: "按钮",
            },
            component: Button,
        },
    },
    registerComponent: (name, componentConfig) =>
        set((state) => {
            return {
                ...state,
                componentConfig: {
                    ...state.componentConfig,
                    [name]: componentConfig,
                },
            };
        }),
}));

声明 state 和 action 的类型。

state 就是 componentConfig 的映射。

key 是组件名,value 是组件配置(包括 component 组件实例、defaultProps 组件默认参数)。

action 就是往 componentConfig 里加配置。

componentConfig 现在有 Container、Button 两个组件。

有了组件的配置,接下来就可以渲染了:

在 EditArea/index.tsx 递归渲染 components

import React, { useEffect } from "react";
import { useComponentConfigStore } from "../../stores/component-config";
import { Component, useComponetsStore } from "../../stores/components";

export function EditArea() {
    const { components, addComponent } = useComponetsStore();
    const { componentConfig } = useComponentConfigStore();

    useEffect(() => {
        addComponent(
            {
                id: 222,
                name: "Container",
                props: {},
                children: [],
            },
            1
        );

        addComponent(
            {
                id: 333,
                name: "Button",
                props: {
                    text: "无敌",
                },
                children: [],
            },
            222
        );
    }, []);

    function renderComponents(components: Component[]): React.ReactNode {
        return components.map((component: Component) => {
            const config = componentConfig?.[component.name];

            if (!config?.component) {
                return null;
            }

            return React.createElement(
                config.component,
                {
                    key: component.id,
                    ...config.defaultProps,
                    ...component.props,
                },
                renderComponents(component.children || [])
            );
        });
    }

    return (
        <div className="h-[100%]">
            <pre>{JSON.stringify(components, null, 2)}</pre>
            {renderComponents(components)}
        </div>
    );
}

components 是一个树形结构,我们 render 的时候也要递归渲染:

从组件配置中拿到 name 对应的组件实例,然后用 React.cloneElement 来创建组件。

props 是配置里的 defaultProps 用 component.props 覆盖后的结果。

React.cloneElement 的第三个参数是 children,递归调用 renderComponents 渲染就行。

这样,就把 components 组件树渲染了出来。

看下效果:

json 下面并没有渲染出组件来。

因为 Page 组件还没写。

写一下:

materials/Page/index.tsx

import { PropsWithChildren } from "react";

function Page({ children }: PropsWithChildren) {
    return <div className="p-[20px] h-[100%] box-border">{children}</div>;
}

export default Page;

在 componentConfig 里配置下:

Page: {
    name: 'Page',
    defaultProps: {},
    component: Page
}

把 json 注释掉:

看下渲染效果:

components 里的 Page、Container、Button 组件都渲染出来了。

用 react devtools 看下:

没啥问题。

这样,我们就把 components 的 json 渲染成了组件树。

把 addComponent 去掉,我们用拖拽的方式来添加组件:

拖拽用 react-dnd 来做。

安装 react-dnd 的包:

npm install react-dnd react-dnd-html5-backend

在 main.tsx 里引入 DndProvider:

这个是 react-dnd 用来跨组件传递数据的

import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { HTML5Backend } from "react-dnd-html5-backend";
import { DndProvider } from "react-dnd";

ReactDOM.createRoot(document.getElementById("root")!).render(
    <DndProvider backend={HTML5Backend}>
        <App />
    </DndProvider>
);

然后在要拖拽的组件上添加 useDrag,在拖拽到的组件上添加 useDrop 就可以实现拖拽。

我们先写一下物料区:

components/Material/index.tsx

import { useMemo } from "react";
import { useComponentConfigStore } from "../../stores/component-config";

export function Material() {
    const { componentConfig } = useComponentConfigStore();

    const components = useMemo(() => {
        return Object.values(componentConfig);
    }, [componentConfig]);

    return (
        <div>
            {components.map((item) => {
                return (
                    <div
                        className="
                    border-dashed
                    border-[1px]
                    border-[#000]
                    py-[8px] px-[10px]
                    m-[10px]
                    cursor-move
                    inline-block
                    bg-white
                    hover:bg-[#ccc]
                ">
                        {item.name}
                    </div>
                );
            })}
        </div>
    );
}

读取 componentConfig 里注册的所有组件类型,渲染出来。

设置下 border、margin、padding。

看下效果:

我们要给每个 item 添加 useDrag 实现拖拽。

封装个组件:

components/MaterialItem/index.tsx

export interface MaterialItemProps {
    name: string;
}

export function MaterialItem(props: MaterialItemProps) {
    const { name } = props;

    return (
        <div
            className="
            border-dashed
            border-[1px]
            border-[#000]
            py-[8px] px-[10px]
            m-[10px]
            cursor-move
            inline-block
            bg-white
            hover:bg-[#ccc]
        ">
            {name}
        </div>
    );
}

这样组件渲染的时候就可以用

components.map((item, index) => {
    return <MaterialItem name={item.name} key={item.name + index} />;
});

不影响页面渲染:

然后加一下 useDrag:

import { useEffect, useRef } from "react";
import { useDrag } from "react-dnd";

export interface MaterialItemProps {
    name: string;
}

export function MaterialItem(props: MaterialItemProps) {
    const { name } = props;

    const [_, drag] = useDrag({
        type: name,
        item: {
            type: name,
        },
    });

    return (
        <div
            ref={drag}
            className="
            border-dashed
            border-[1px]
            border-[#000]
            py-[8px] px-[10px]
            m-[10px]
            cursor-move
            inline-block
            bg-white
            hover:bg-[#ccc]
        ">
            {name}
        </div>
    );
}

type 是当前 drag 的元素的标识,drop 的时候根据这个来决定是否 accept。

item 是传递的数据。

测试下:

现在就可以拖拽了。

只是还没处理 drop 的逻辑。

我们在 Page 组件加一下 useDrop 的处理逻辑:

import { message } from "antd";
import { PropsWithChildren } from "react";
import { useDrop } from "react-dnd";

function Page({ children }: PropsWithChildren) {
    const [{ canDrop }, drop] = useDrop(() => ({
        accept: ["Button", "Container"],
        drop: (item: { type: string }) => {
            message.success(item.type);
        },
        collect: (monitor) => ({
            canDrop: monitor.canDrop(),
        }),
    }));

    return (
        <div
            ref={drop}
            className="p-[20px] h-[100%] box-border"
            style={{ border: canDrop ? "2px solid blue" : "none" }}>
            {children}
        </div>
    );
}

export default Page;

accept 指定接收的 type,这里接收 Button 和 Container 组件

drop 的时候显示下传过来的 item 数据。

canDrop 的话加一个 border 的高亮。

试一下:

可以看到,Container 和 Button 拖拽到 Page 组件的时候,会触发 drop 事件。

接下来我们只要调用 addComponent 来添加 component 就行了。

这需要把 id 传进来:

我们在 renderComponents 的时候传一下 component 的 id、name。

每个组件的参数都是这样,我们在 interface.ts 里定义下参数类型:

editor/interface.ts

import { PropsWithChildren } from "react";

export interface CommonComponentProps extends PropsWithChildren {
    id: number;
    name: string;
    [key: string]: any;
}

然后调用下 addComponent:

import { useDrop } from "react-dnd";
import { CommonComponentProps } from "../../interface";
import { useComponetsStore } from "../../stores/components";
import { useComponentConfigStore } from "../../stores/component-config";

function Page({ id, name, children }: CommonComponentProps) {
    const { addComponent } = useComponetsStore();
    const { componentConfig } = useComponentConfigStore();

    const [{ canDrop }, drop] = useDrop(() => ({
        accept: ["Button", "Container"],
        drop: (item: { type: string }) => {
            const props = componentConfig[item.type].defaultProps;

            addComponent(
                {
                    id: new Date().getTime(),
                    name: item.type,
                    props,
                },
                id
            );
        },
        collect: (monitor) => ({
            canDrop: monitor.canDrop(),
        }),
    }));

    return (
        <div
            ref={drop}
            className="p-[20px] h-[100%] box-border"
            style={{ border: canDrop ? "2px solid blue" : "none" }}>
            {children}
        </div>
    );
}

export default Page;

测试下:

完美!

这样,拖拽编辑的第一步就完成了。

然后 Container 组件也是可以 drop 的。

我们加一下:

import { useComponetsStore } from "../../stores/components";
import { useComponentConfigStore } from "../../stores/component-config";
import { useDrop } from "react-dnd";
import { CommonComponentProps } from "../../interface";

const Container = ({ id, children }: CommonComponentProps) => {
    const { addComponent } = useComponetsStore();
    const { componentConfig } = useComponentConfigStore();

    const [{ canDrop }, drop] = useDrop(() => ({
        accept: ["Button", "Container"],
        drop: (item: { type: string }) => {
            const props = componentConfig[item.type].defaultProps;

            addComponent(
                {
                    id: new Date().getTime(),
                    name: item.type,
                    props,
                },
                id
            );
        },
        collect: (monitor) => ({
            canDrop: monitor.canDrop(),
        }),
    }));

    return (
        <div
            ref={drop}
            className={`min-h-[100px] p-[20px] ${canDrop ? "border-[2px] border-[blue]" : "border-[1px] border-[#000]"}`}>
            {children}
        </div>
    );
};

export default Container;

测试下:

可以拖拽组件到 Container 了,但是 Page 的 drop 也被触发了。

我们要加一下判断,处理过 drop 就不再处理。

const didDrop = monitor.didDrop();
if (didDrop) {
    return;
}

这样就好了:

没啥问题。

useDrop 代码重复了两次,我们封装一个自定义 hooks:

editor/hooks/useMaterialDrop.ts

import { useDrop } from "react-dnd";
import { useComponentConfigStore } from "../stores/component-config";
import { useComponetsStore } from "../stores/components";

export function useMaterailDrop(accept: string[], id: number) {
    const { addComponent } = useComponetsStore();
    const { componentConfig } = useComponentConfigStore();

    const [{ canDrop }, drop] = useDrop(() => ({
        accept,
        drop: (item: { type: string }, monitor) => {
            const didDrop = monitor.didDrop();
            if (didDrop) {
                return;
            }

            const props = componentConfig[item.type].defaultProps;

            addComponent(
                {
                    id: new Date().getTime(),
                    name: item.type,
                    props,
                },
                id
            );
        },
        collect: (monitor) => ({
            canDrop: monitor.canDrop(),
        }),
    }));

    return { canDrop, drop };
}

传入 accept 和 id 参数,返回 canDrop 和 drop。

在 Page 和 Container 组件用一下:

import { CommonComponentProps } from "../../interface";
import { useMaterailDrop } from "../../hooks/useMaterailDrop";

function Page({ id, name, children }: CommonComponentProps) {
    const { canDrop, drop } = useMaterailDrop(["Button", "Container"], id);

    return (
        <div
            ref={drop}
            className="p-[20px] h-[100%] box-border"
            style={{ border: canDrop ? "2px solid blue" : "none" }}>
            {children}
        </div>
    );
}

export default Page;
import { useMaterailDrop } from "../../hooks/useMaterailDrop";
import { CommonComponentProps } from "../../interface";

const Container = ({ id, children }: CommonComponentProps) => {
    const { canDrop, drop } = useMaterailDrop(["Button", "Container"], id);

    return (
        <div
            ref={drop}
            className={`min-h-[100px] p-[20px] ${canDrop ? "border-[2px] border-[blue]" : "border-[1px] border-[#000]"}`}>
            {children}
        </div>
    );
};

export default Container;

这样代码好看多了。

然后我们先在 Setting 组件里展示下 json:

import { useComponetsStore } from "../../stores/components";

export function Setting() {
    const { components } = useComponetsStore();

    return (
        <div>
            <pre>{JSON.stringify(components, null, 2)}</pre>
        </div>
    );
}

测试下:

可以看到,拖拽编辑的时候,json 和画布的内容会同步修改。

完美!

案例代码上传了小册仓库,可以切换到这个 commit 查看:

git reset --hard 6f55fcbcc93bfec667975d808ac2d4c3f97fac05

总结

这节我们实现了拖拽组件到画布,也就是拖拽编辑 json。

首先我们加了 Button 和 Container 组件,并创建了 componentConfig 的全局 store,用来保存组件配置。

然后实现了 renderComponents,它就是递归渲染 component,用到的组件配置从 componentConfig 取。

之后引入 react-dnd 实现了拖拽编辑,左侧的物料添加 useDrag,画布里的组件添加 useDrop,然后当 drop 的时候,在对应 id 下添加一个对应的类型的组件。

组件类型在 useDrag 的时候通过 item 传递,添加到的组件 id 在 drop 的那个组件里就有。

然后还要处理下 didDrop,保证只 drop 一次。

这样,我们就实现了拖拽编辑 json 的功能。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
67.低代码编辑器:核心数据结构、全局store
Next
69.低代码编辑器:画布区hover展示高亮框