一.实现思想
一看到低代码编辑器,我们一定想到的是拖拽,以为拖拽才是他的核心,其实不然,低代码编辑器和react playGround的核心都是数据。他们的本质就是数据驱动视图的思想。这和react、vue的核心思想如出一辙。
二.组成部分
今天我们就关于react的低代码编辑器解读一遍。
首先从界面上看,低代码编辑器有2部分组成:1.是应用管理平台,2.低代码编辑器。
我们可以在应用管理平台里面新建应用,然后点击编辑进入低代码编辑器。同时,低代码编辑器里面的代码,可以通过保存,进入平台管理。
进入低代码管理器以后,我们看到界面主要由四部分组成:头部,左边物料区,右边设置区,中间编辑区。
左边的物料区又分为三部分:物料、大纲、源码。
右边设置区分为三部分:属性、样式、事件。
而事件里面又包含访问链接,消息提示,组件方法,自定义js。
值的注意的是:不同的元素,对应的事件是不一样的,比如对按钮来说最主要的事件就是onClick,但是对于input来说最主要的事件就是onChang
代码配置完成以后,以后可以用下载代码的按钮,把你低代码编辑的页面导出成一个zip包。
解压,然后执行
npm i
npm run dev
然后你就可以看到一个在预览里面看到的页面了。
三.核心数据
整个低代码编辑器的核心就是下面这个东西:
是不是很熟悉,这不就是个react的虚拟dom么,是的,我们在编辑器里面加入的所有组件,都是以虚拟dom的形式存在的。为了方便操作数据,我们选择zustand作为状态管理器。
zustand 的使用方法参考Zustand 状态库汇总它用最简单的方法将state和修改state的方式全部放在一个对象里面集中管理,然后又提供了set和get内置方法来操控state。
我们之所以不用context是因为它会多层嵌套,加大低代码编辑器的理解难度。
首先对于components.tsx的定义如下:
一个组件是由: id,name,props,styles,children,parentId, desc组成,元素的样式变量会放在styles里面,元素的事件会根据元素的不同,配置不同事件,这些具体的代码都会被放在props里面。
import { CSSProperties } from 'react';
import { create } from 'zustand';
export interface Component {
id: number;
name: string;
props: any;
styles?: CSSProperties;
children?: Component[];
parentId?: number;
desc?: string;
}
interface State {
components: Component[];
curComponentId?: number | null;
curComponent: Component | null;
mode: 'edit' | 'preview';
}
interface Action {
addComponent: (component: Component, parentId?: number) => void;
deleteComponent: (componenetId: number) => void;
updateComponentProps: (componenetId: number, props: any, replace?: boolean) => void;
setCurComponentId: (componenetId: number | null) => void;
updateComponentStyles: (componenetId: number, styles: CSSProperties, replace: boolean) => void;
setMode: (mode: State['mode']) => void;
}
//用递归寻找父元素
export function getComponentById(
id: number | null,
components: Component[]): Component | null | undefined {
if (!id) {
return null;
}
for (const item of components) {
if (item.id === id) {
return item;
}
if (item.children && item.children.length > 0) {
const result = getComponentById(id, item.children);
if (result !== null) {
return result;
}
}
}
}
//定义仓库和操作仓库值的方法
export const useComponentsStore = create<State & Action>((set, get) => ({
components: [{
id: 1,
name: 'Page',
props: {},
desc: '页面',
}],
curComponent: null,
curComponentId: null,
mode: 'edit',
setMode: (mode) => set({ mode }),
setCurComponentId(componenetId) {
return set((state) => ({
curComponentId: componenetId,
curComponent: getComponentById(componenetId, state.components)
}));
},
addComponent: (component, parentId) => {//添加元素,拿着组件和父组件id去添加元素,如果
set((state) => {
if (!parentId) {
return { components: [...state.components, component] };
}
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] };
});
},
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] };
}),
updateComponentStyles: (componenetId, styles, replace) => {
return set((state) => {
const componenet = getComponentById(componenetId, state.components);
if (componenet) {
componenet.styles = replace ? { ...styles } : { ...componenet.styles, ...styles };
return { components: [...state.components] };
}
return { compoents: [...state.components] };
});
}
}));
属性配置:主要为了配置左边物料组件里面的相关属性,比如按钮,他有主按钮、次按钮,文本类型,都会配置在这里。
import { create } from 'zustand';
import ContainerDev from '../components/material/container/dev';
import ContainerProd from '../components/material/container/prod';
import ButtonDev from '../components/material/button/dev';
import ButtonProd from '../components/material/button/prod';
import PageDev from '../components/material/page/dev';
import PageProd from '../components/material/page/prod';
import ModalDev from '../components/material/modal/dev';
import ModalProd from '../components/material/modal/prod';
export interface ComponentSetter {
name: string;
label: string;
type: string;
[key: string]: any;
}
export interface ComponentEvent {
name: string;
label: string;
}
export interface ComponentMethod {
name: string;
label: string;
}
export interface ComponentConfig {
name: string;
defaultProps: Record<string, any>,
desc: string;
setter?: ComponentSetter[];
styleSetter?: ComponentSetter[];
dev: any;
prod: any;
events?: ComponentEvent[];
methods?: ComponentMethod[];
}
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: {},
desc: "容器",
dev: ContainerDev,
prod: ContainerProd
},
Button: {
name: 'Button',
defaultProps: {
type: 'primary',
text: '按钮'
},
setter: [{
name: "type",
label: '按钮类型',
type: 'select',
options: [
{ label: "主按钮", value: 'primary' },
{ label: "次按钮", value: 'default' },
]
}, {
name: 'text',
label: '文本',
type: 'input'
}],
styleSetter: [{
name: "width",
label: '宽度',
type: 'inputNumber'
}, {
name: "height",
label: '高度',
type: 'input'
}],
events: [
{
name: 'onClick',
label: '点击事件',
},
{
name: 'onDoubleClick',
label: '双击事件'
},
],
methods: [
{
name: "open",
label: '打开弹框'
},
{
name: "close",
label: '关闭弹框'
}
],
desc: "按钮",
dev: ButtonDev,
prod: ButtonProd
},
Page: {
name: 'Page',
defaultProps: {
},
desc: "页面",
dev: PageDev,
prod: PageProd
},
Modal: {
name: 'Modal',
defaultProps: {
title: '弹窗'
},
setter: [
{
name: 'title',
label: '标题',
type: 'input'
}
],
stylesSetter: [],
events: [
{
name: 'onOk',
label: '确认事件',
},
{
name: 'onCancel',
label: '取消事件'
},
],
desc: '弹窗',
dev: ModalDev,
prod: ModalProd,
},
},
registerComponent: (name, componentConfig) => set((state) => {
return {
...state,
componentConfig: {
...state.componentConfig,
[name]: componentConfig
}
};
})
}));
当配置项发生变化的时候,源码也会发生相应的变化。
所以说一个低代码编辑器有2个数据中心,一个主数据中心,存储的是源码,及其操作源码内容的方法。另一个数据中心,存储的是设置区的组件配置项。当配置项里面发生变化都会反应在源码里面。
四.编辑区和预览的区别
其实编辑区和预览都是想要将源码里面定义好的组件,展示出来。对于下面的源码,是不是很熟悉?像不像我们react的jsx编译后react.createElement()里面的内容?
是的,我们的编辑区就是直接用递归的方式,执行 React.createElement 编译源码,将组件显示在编辑区,在预览区们我们要给组件添加上具体的事件,这样添加的事件才能发挥作用。
第二个区别就是在编辑区的组件如果被hover进入就要有边框提示,如果组件被单击,就会出现删除提示。
这个功能我们用import { createPortal } from 'react-dom';
实现的,就是在编辑区我们建立一个空的div
<div className="protal-wrapper"></div>
然后做一个蒙版,把蒙版的内容渲染到这个div里面。在计算组件宽高的时候,我们使用的是getBoundingClientRect()
最终实现给hover的元素添加高亮和删除按钮。
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { getComponentById, useComponentsStore } from '../../store/components';
interface HoverMaskProps {
containerClassName: string;
componentId: number;
portalWrapperClassName: string;
}
function HoverMask({ containerClassName, portalWrapperClassName, componentId }: HoverMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
labelTop: 0,
labelLeft: 0,
});
const { components } = useComponentsStore();
useEffect(() => {
updatePosition();
}, [componentId]);
useEffect(() => {
updatePosition();
}, [components]);
function updatePosition() {
if (!componentId) return;
const container = document.querySelector(`.${containerClassName}`);
if (!container) return;
const node = document.querySelector(`[data-component-id="${componentId}"]`);
if (!node) return;
const { top, left, width, height } = node.getBoundingClientRect();
const { top: containerTop, left: containerLeft } = container.getBoundingClientRect();
let labelTop = top - containerTop + container.scrollTop;
let labelLeft = left - containerLeft + width;
if (labelTop <= 0) {
labelTop -= -20;
}
setPosition({
top: top - containerTop + container.scrollTop,
left: left - containerLeft + container.scrollTop,
width,
height,
labelTop,
labelLeft,
});
}
const el = useMemo(() => {
return document.querySelector(`.${portalWrapperClassName}`)!;
}, []);
const curComponent = useMemo(() => {
return getComponentById(componentId, components);
}, [componentId]);
return createPortal((
<>
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(0, 0, 255, 0.05)",
border: "1px dashed blue",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
<div
style={{
position: "absolute",
left: position.labelLeft,
top: position.labelTop,
fontSize: "14px",
zIndex: 13,
display: (!position.width || position.width < 10) ? "none" : "inline",
transform: 'translate(-100%, -100%)',
}}
>
<div
style={{
padding: '0 8px',
backgroundColor: 'blue',
borderRadius: 4,
color: '#fff',
cursor: "pointer",
whiteSpace: 'nowrap',
}}
>
{curComponent?.desc}
</div>
</div>
</>
), el);
}
export default HoverMask;
五.下载代码
之前说了,页面上所有渲染的东西都是这个源码,现在如何将源码也就是虚拟组件转化成真正的组件,然后放到jsx文件里面,下载也就成功了一半了。
社区的优秀方案是: 社区里面也有其他比较优秀的方案,这里做一些简单的方案罗列,本质上的使用大差不差:
- doT: olado.github.io/doT/index.h…
- art-template: aui.github.io/art-templat…
- mustache.js: mustache.github.io/
- nunjucks: mozilla.github.io/nunjucks/
我们选择的是nunjucks,没有为啥,就是看得顺眼而已,你们可以选择自己的工具。
当选择完成对应的模板引擎库后,以nunjucks为例子声明以下的模板块,
- name: 组件的名称,在之前components中导出的resolver模块。
- props: 组件的属性,props中是通过属性面板和物料默认的一些信息内容
- children: 子组件的内容,有的话需要显示,没有的话就不需要展示了。
- nodes: 子节点的nodeId,如果需要渲染子节点那么就需要进行一次递归
这几个字段的值是React构成元素方法createElement的参数,因此可以将其显示抽象为React TSX的内容文本。
安装
pnpm add nunjucks
pnpm add @types/nunjucks -D
使用
// 引入nunjucks
import NJ from "nunjucks";
import { has, isString } from 'lodash-es';
// nunjucks配置
NJ.configure({ autoescape: false });
// 创建模板代码
const tpl = `
<{{ name }}
{% for key, value in props -%}
{% if key != 'children' %}
{{ key }}={{ transformValue(key, value) }}
{% endif %}
{% endfor -%}
{% if props.children %}
>
{{props.children}}
</{{name}}>
{% else %}
/>
{% endif %}
`;
// 使用nunjucks的renderString方法
const str = NJ.renderString(tpl, {
// 这是一个示例的数据结构,nodeData的具体显示
"name": "Button",
"displayName": "按钮",
"props": {
"children": "测试文案",
"loading": {
"$$jsx": "true"
},
"block": {
"$$jsx": "false"
},
"danger": {
"$$jsx": "true"
}
},
"custom": {
"useResize": false
},
"parent": "ROOT",
"isCanvas": false,
"hidden": false,
"nodes": [],
"linkedNodes": {},
transformValue: (_, v) => {
// 表达式
if (has(v, "$$jsx")) {
return `{${v.$$jsx}}`;
}
if (isString(v)) {
return JSON.stringify(v);
}
return `{${JSON.stringify(v, null, 2)}}`;
},
});
// 输出
console.log(str, '当前组件的代码');
执行node server.js,测试如下:
是不是发现格式不对?
那就请prettier上场处理
// 引入nunjucks
import NJ from "nunjucks";
import { has, isString } from 'lodash-es';
import prettier from "prettier/standalone";
import babel from "prettier/plugins/babel";
import estree from "prettier/plugins/estree";
// nunjucks配置
NJ.configure({ autoescape: false });
// 创建模板代码
const tpl = `
<{{ name }}
{% for key, value in props -%}
{% if key != 'children' %}
{{ key }}={{ transformValue(key, value) }}
{% endif %}
{% endfor -%}
{% if props.children %}
>
{{props.children}}
</{{name}}>
{% else %}
/>
{% endif %}
`;
// 使用nunjucks的renderString方法
const str = NJ.renderString(tpl, {
// 这是一个示例的数据结构,nodeData的具体显示
"name": "Button",
"displayName": "按钮",
"props": {
"children": "测试文案",
"loading": {
"$$jsx": "true"
},
"block": {
"$$jsx": "false"
},
"danger": {
"$$jsx": "true"
}
},
"custom": {
"useResize": false
},
"parent": "ROOT",
"isCanvas": false,
"hidden": false,
"nodes": [],
"linkedNodes": {},
transformValue: (_, v) => {
// 表达式
if (has(v, "$$jsx")) {
return `{${v.$$jsx}}`;
}
if (isString(v)) {
return JSON.stringify(v);
}
return `{${JSON.stringify(v, null, 2)}}`;
},
});
// 输出
console.log('处理前的代码', str);
const formatCode = await prettier
.format(str, {
parser: "babel",
plugins: [babel, estree],
printWidth: 50
});
console.log('处理后的代码', formatCode);
执行后
是不是乖乖变成了你想要的模样?
组件代码就这样被我们组装好了。最后你可以把你的组件组装成一个完整的项目,然后利用jszip
和'file-saver
实现代码的前端打包下载功能。