低代码编辑器总结

1,981 阅读6分钟

一.实现思想

一看到低代码编辑器,我们一定想到的是拖拽,以为拖拽才是他的核心,其实不然,低代码编辑器和react playGround的核心都是数据。他们的本质就是数据驱动视图的思想。这和react、vue的核心思想如出一辙。

二.组成部分

今天我们就关于react的低代码编辑器解读一遍。

首先从界面上看,低代码编辑器有2部分组成:1.是应用管理平台,2.低代码编辑器。

我们可以在应用管理平台里面新建应用,然后点击编辑进入低代码编辑器。同时,低代码编辑器里面的代码,可以通过保存,进入平台管理。

进入低代码管理器以后,我们看到界面主要由四部分组成:头部,左边物料区,右边设置区,中间编辑区。

左边的物料区又分为三部分:物料、大纲、源码。

右边设置区分为三部分:属性、样式、事件。

而事件里面又包含访问链接,消息提示,组件方法,自定义js。

值的注意的是:不同的元素,对应的事件是不一样的,比如对按钮来说最主要的事件就是onClick,但是对于input来说最主要的事件就是onChang

image.png

代码配置完成以后,以后可以用下载代码的按钮,把你低代码编辑的页面导出成一个zip包。

image.png

解压,然后执行

npm i

npm run dev

然后你就可以看到一个在预览里面看到的页面了。

三.核心数据

整个低代码编辑器的核心就是下面这个东西:

image.png

是不是很熟悉,这不就是个react的虚拟dom么,是的,我们在编辑器里面加入的所有组件,都是以虚拟dom的形式存在的。为了方便操作数据,我们选择zustand作为状态管理器。

zustand 的使用方法参考Zustand 状态库汇总它用最简单的方法将state和修改state的方式全部放在一个对象里面集中管理,然后又提供了set和get内置方法来操控state。

我们之所以不用context是因为它会多层嵌套,加大低代码编辑器的理解难度。

首先对于components.tsx的定义如下:

image.png

一个组件是由: id,name,props,styles,children,parentId, desc组成,元素的样式变量会放在styles里面,元素的事件会根据元素的不同,配置不同事件,这些具体的代码都会被放在props里面。

image.png

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] };
    });
  }
}));


属性配置:主要为了配置左边物料组件里面的相关属性,比如按钮,他有主按钮、次按钮,文本类型,都会配置在这里。

image.png

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
      }
    };
  })
}));

当配置项发生变化的时候,源码也会发生相应的变化。

image.png

所以说一个低代码编辑器有2个数据中心,一个主数据中心,存储的是源码,及其操作源码内容的方法。另一个数据中心,存储的是设置区的组件配置项。当配置项里面发生变化都会反应在源码里面。

四.编辑区和预览的区别

其实编辑区和预览都是想要将源码里面定义好的组件,展示出来。对于下面的源码,是不是很熟悉?像不像我们react的jsx编译后react.createElement()里面的内容?

是的,我们的编辑区就是直接用递归的方式,执行 React.createElement 编译源码,将组件显示在编辑区,在预览区们我们要给组件添加上具体的事件,这样添加的事件才能发挥作用。

image.png

第二个区别就是在编辑区的组件如果被hover进入就要有边框提示,如果组件被单击,就会出现删除提示。

image.png

这个功能我们用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文件里面,下载也就成功了一半了。

社区的优秀方案是: 社区里面也有其他比较优秀的方案,这里做一些简单的方案罗列,本质上的使用大差不差:

我们选择的是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,测试如下:

image.png 是不是发现格式不对?

那就请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);

执行后

image.png

是不是乖乖变成了你想要的模样?

组件代码就这样被我们组装好了。最后你可以把你的组件组装成一个完整的项目,然后利用jszip 和'file-saver实现代码的前端打包下载功能。