跳到主要内容

低代码编辑器:核心数据结构、全局store

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

这种编辑器都差不多,比如百度开源的 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 方法。

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

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