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

这节开始,我们做一个实战项目:低代码编辑器

这种编辑器都差不多,比如百度开源的 amis:

左边是物料区,中间是画布区,右边是属性编辑区。

可以从物料区拖拽组件到中间的画布区,来可视化搭建页面:

画布区的组件可以选中之后,在属性编辑区修改属性:

左边可以看到组件的大纲视图,用树形展示组件嵌套结构:

也可以直接看生成的 json 结构:

可以看到,json 的嵌套结构和页面里组件的结构一致,并且 json 对象的属性也是在属性编辑区编辑后的。

所以说,整个低代码编辑器就是围绕这个 json 来的。

从物料区拖拽组件到画布区,其实就是在 json 的某一层级加了一个组件对象。

选中组件在右侧编辑属性,其实就是修改 json 里某个组件对象的属性。

大纲就是把这个 json 用树形展示。

你从 json 的角度来回想一下低代码编辑器的拖拽组件到画布、编辑属性、查看大纲这些功能,是不是原理就很容易想通了?

没错,这就是低代码编辑器的核心,就是一个 json。

拖拽也是低代码编辑器的一个难点,用 react-dnd 做就行。

但交互方式是次要的,比如移动端页面的低代码编辑器,可能不需要拖拽,点击就会添加到画布:

这种不需要拖拽的是低代码编辑器么?

明显也是。所以说,拖拽不是低代码编辑器必须的。

理解低代码编辑器的核心就是 json 数据结构,不同交互只是修改这个 json 不同部分就行。

下面我们自己来写一个:

npx create-vite lowcode-editor

安装依赖,把项目跑起来:

npm install
npm run dev

改下 main.tsx:

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

ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

新建 src/editor/index.tsx

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

在 App.tsx 引入下:

import LowcodeEditor from "./editor";

function App() {
    return <LowcodeEditor />;
}

export default App;

按照 tailwind 文档里的步骤安装 tailwind:

npm install -D tailwindcss postcss autoprefixer

npx tailwindcss init -p

会生成 tailwind 和 postcss 配置文件:

修改下 content 配置,也就是从哪里提取 className:

/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    theme: {
        extend: {},
    },
    plugins: [],
};

tailwind 会提取 className 之后按需生成最终的 css。

改下 index.css 引入 tailwind 基础样式:

@tailwind base;
@tailwind components;
@tailwind utilities;

在 main.tsx 里引入:

如果你没安装 tailwind 插件,需要安装一下:

这样在写代码的时候就会提示 className 和对应的样式值:

不知道 className 叫啥的样式,还可以在 tailwind 文档里搜:

接下来写布局:

我们用 allotment 实现可拖动改变大小的 pane:

安装这个包:

npm install --save allotment

改下 LowcodeEditor:

import { Allotment } from "allotment";
import "allotment/dist/style.css";

export default function ReactPlayground() {
    return (
        <div className="h-[100vh] flex flex-col">
            <div className="">Header</div>
            <Allotment>
                <Allotment.Pane preferredSize={240} maxSize={300} minSize={200}>
                    Materail
                </Allotment.Pane>
                <Allotment.Pane>EditArea</Allotment.Pane>
                <Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
                    Setting
                </Allotment.Pane>
            </Allotment>
        </div>
    );
}

引入 Allotment 组件和样式。

设置左右两个 pane 的初始 size,最大最小 size。

h-[任意数值] 是 tailwind 支持的样式写法,就是 height: 任意数值 的意思。

h-[100vh] 就是 height: 100vh

然后设置 flex、flex-col

看下样式:

没问题。

左右两边是可以拖拽改变大小的:

初始 size、最大、最小 size 都和我们设置的一样。

然后写下 header 的样式。

高度 60px、用 flex 布局,竖直居中,有一个底部 border

h-[60px] flex items-center border-b-[1px] border-[#000]

没啥问题。

然后换成具体的组件:

import { Allotment } from "allotment";
import "allotment/dist/style.css";
import { Header } from "./components/Header";
import { EditArea } from "./components/EditArea";
import { Setting } from "./components/Setting";
import { Material } from "./components/Material";

export default function ReactPlayground() {
    return (
        <div className="h-[100vh] flex flex-col">
            <div className="h-[60px] flex items-center border-b-[1px] border-[#000]">
                <Header />
            </div>
            <Allotment>
                <Allotment.Pane preferredSize={240} maxSize={300} minSize={200}>
                    <Material />
                </Allotment.Pane>
                <Allotment.Pane>
                    <EditArea />
                </Allotment.Pane>
                <Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
                    <Setting />
                </Allotment.Pane>
            </Allotment>
        </div>
    );
}

分别写下这几个组件:

editor/components/Header.tsx

export function Header() {
    return <div>Header</div>;
}

editor/components/Material.tsx

export function Material() {
    return <div>Material</div>;
}

editor/components/EditArea.tsx

export function EditArea() {
    return <div>EditArea</div>;
}

editor/components/Setting.tsx

export function Setting() {
    return <div>Setting</div>;
}

布局写完了,接下来可以正式来写逻辑了。

这节先来写下低代码编辑器核心的数据结构。

我们不用 Context 保存全局数据了,用 zustand 来做。

npm install --save zustand

前面做 todolist 案例用过 zustand:

声明 State、Action 的类型,然后在 create 方法里声明 state、action 就行。

创建 editor/stores/components.tsx,在这里保存全局的那个组件 json:

import { create } from "zustand";

export interface Component {
    id: number;
    name: string;
    props: any;
    children?: Component[];
    parentId?: number;
}

interface State {
    components: Component[];
}

interface Action {
    addComponent: (component: Component, parentId?: number) => void;
    deleteComponent: (componentId: number) => void;
    updateComponentProps: (componentId: number, props: any) => void;
}

export const useComponetsStore = create<State & Action>((set, get) => ({
    components: [
        {
            id: 1,
            name: "Page",
            props: {},
            desc: "页面",
        },
    ],
    addComponent: (component, parentId) =>
        set((state) => {
            if (parentId) {
                const parentComponent = getComponentById(
                    parentId,
                    state.components
                );

                if (parentComponent) {
                    if (parentComponent.children) {
                        parentComponent.children.push(component);
                    } else {
                        parentComponent.children = [component];
                    }
                }

                component.parentId = parentId;
                return { components: [...state.components] };
            }
            return { components: [...state.components, component] };
        }),
    deleteComponent: (componentId) => {
        if (!componentId) return;

        const component = getComponentById(componentId, get().components);
        if (component?.parentId) {
            const parentComponent = getComponentById(
                component.parentId,
                get().components
            );

            if (parentComponent) {
                parentComponent.children = parentComponent?.children?.filter(
                    (item) => item.id !== +componentId
                );

                set({ components: [...get().components] });
            }
        }
    },
    updateComponentProps: (componentId, props) =>
        set((state) => {
            const component = getComponentById(componentId, state.components);
            if (component) {
                component.props = { ...component.props, ...props };

                return { components: [...state.components] };
            }

            return { components: [...state.components] };
        }),
}));

export function getComponentById(
    id: number | null,
    components: Component[]
): Component | null {
    if (!id) return null;

    for (const component of components) {
        if (component.id == id) return component;
        if (component.children && component.children.length > 0) {
            const result = getComponentById(id, component.children);
            if (result !== null) return result;
        }
    }
    return null;
}

我们从上到下来看下:

store 里保存着 components 组件树,它是一个用 children 属性连接起来的树形结构。

我们定义了每个 Component 节点的类型,有 id、name、props 属性,然后通过 chiildren、parentId 关联父子节点。

此外,定义了 add、delete、update 的增删改方法,用来修改 components 组件树。

这是一个树形结构,想要增删改都要先找到 parent 节点,我们实现了查找方法:

树形结构中查找节点,自然是通过递归。

如果节点 id 是查找的目标 id 就返回当前组件,否则遍历 children 递归查找。

之后就可以实现增删改方法了:

新增会传入 parentId,在哪个节点下新增:

查找到 parent 之后,在 children 里添加一个 component,并把 parentId 指向这个 parent。

没查到就直接放在 components 下。

删除则是找到这个节点的 parent,在 parent.children 里删除当前节点:

修改 props 也是找到目标 component,修改属性:

这样,components 和它的增删改查方法就都定义好了。

这就是我们前面分析的核心数据结构。

有了这个就能实现低代码编辑器的大多数功能了。

不信?

我们试一下:

比如我们拖拽一个容器组件进来:

是不是就是在 components 下新加了一个组件。

模拟实现下:

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

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

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

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

在 EditArea 组件里,调用 store 里的 addComponent 添加一个组件。

然后把 components 组件树渲染出来:

可以看到,Page 下多了一个 Container 组件。

然后在 Container 下拖拽一个 Video 组件过去:

对应的底层操作就是这样的:

addComponent(
    {
        id: 333,
        name: "Video",
        props: {},
        children: [],
    },
    222
);

在编辑器中把这个组件删除:

对应的操作就是 deleteComponent:

setTimeout(() => {
    deleteComponent(333);
}, 3000);

在右边属性编辑区修改组件的信息:

对应的就是 updateComponentProps:

(amis 用的 body 属性关联子组件,我们用的 children)

至于大纲和 json:

就是对这个 json 的展示:

所以说,从物料区拖组件到画布,删除组件、在属性编辑区修改组件属性,都是对这个 json 的修改。

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

git reset --hard 32bd1b33e74adb3832c839161aef415a0d4f3b20

总结

我们分析了下低代码编辑器 amis,发现核心就是一个 json 的数据结构。

这个 json 就是一个通过 children 属性串联的组件对象树。

从物料区拖拽组件到画布区,就是在 json 的某一层级加了一个组件对象。

选中组件在右侧编辑属性,就是修改 json 里某个组件对象的属性。

大纲就是把这个 json 用树形展示。

然后我们写了下代码,用 allomet 实现了 split pane 布局,用 tailwind 来写样式,引入 zustand 来做全局 store。

在 store 中定义了 components 和对应的 add、update、delete 方法。

然后对应低代码编辑器里的操作,用这些方法实现了一下。

这个数据结构并不复杂,却是低代码编辑器的核心。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
66.Ref的实现原理
Next
68.低代码编辑器:拖拽组件到画布、拖拽编辑json