《保姆级》低代码知识点详解(一)

demo体验地址:dbfu.github.io/lowcode-dem...

背景

在公司里负责低代码平台研发也有几年了,有一些低代码开发心得,这里和大家分享一下。

这一篇文章内容比较基础,主要是让大家先了解一些低代码的知识点,为后面一点点带着大家开发一套企业级低代码平台做准备。

初始化项目

使用vite初始化项目

sh 复制代码
npm create vite

安装tailwindcss

安装依赖

sh 复制代码
pnpm i -D tailwindcss postcss autoprefixer
pnpx tailwindcss init -p

配置文件

把下面内容复制到tailwind.config.js文件中

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

把下面复制到src/index.css

css 复制代码
@tailwind base;
@tailwind components;
@tailwind utilities;

修改代码

启动服务测试

sh 复制代码
npm run dev

文件结构

按照下面结构创建文件夹,这个文件夹结构是我比较喜欢的结构,大家可以根据自己喜好更改。

tree 复制代码
├── editor\
│   ├── common                // 存放公共组件
│   ├── components            // 存放物料组件
│   ├── contexts              // 存放react Context
│   ├── layouts               // 布局
│   │   ├── header            // 头
│   │   ├── material          // 物料区
│   │   ├── setting           // 配置区
│   │   └── renderer          // 中间渲染区
│   └── utils                 // 存放工具方法

布局

介绍

现在市面上的低代码平台大多是像下面截图一样的布局方式。

主要结构就这4个

  • 头-工具栏
  • 左侧-组件库
  • 中间-画布
  • 右侧-组件属性配置

实现

简单实现

tsx 复制代码
// src/editor/layouts/index.tsx
import React from 'react';
import Header from './header';
import Material from './material';
import Setting from './setting';
import Stage from './stage';

const Layout: React.FC = () => {
  return (
    <div className='h-[100vh] flex flex-col'>
      <div className='h-[50px] flex items-center bg-red-300'>
        <Header />
      </div>
      <div className='flex-1 flex'>
        <div className='w-[200px] bg-green-400'>
          <Material />
        </div>
        <div className='flex-1 bg-blue-400'>
          <Stage />
        </div>
        <div className='w-[200px] bg-orange-400'>
          <Setting />
        </div>
      </div>
    </div>
  )
}

export default Layout;

进阶实现

让物料区和设置区可以拖拽调整大小,实现这个功能可以使用allotment库,用react-split-pane这个库也行,不过我觉得allotment更好用更简单。

sh 复制代码
pnpm i allotment
tsx 复制代码
import { Allotment } from "allotment";
import "allotment/dist/style.css";
import React from 'react';

import Header from './header';
import Material from './material';
import Setting from './setting';
import Stage from './stage';

const Layout: React.FC = () => {
  return (
    <div className='h-[100vh] flex flex-col'>
      <div className='h-[50px] flex items-center bg-red-300'>
        <Header />
      </div>
      <Allotment>
        <Allotment.Pane preferredSize={200} maxSize={400} minSize={200}>
          <Material />
        </Allotment.Pane>
        <Allotment.Pane>
          <Stage />
        </Allotment.Pane>
        <Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
          <Setting />
        </Allotment.Pane>
      </Allotment>
    </div>
  )
}

export default Layout;

效果展示

数据结构

渲染画布的数据结构

ts 复制代码
interface Component {
  /**
   * 组件唯一标识
   */
  id: number;
  /**
   * 组件名称
   */
  name: string;
  /**
   * 组件属性
   */
  props: any;
  /**
   * 子组件
   */
  children?: Component[];
}

动态渲染组件

这个是低代码核心功能,低代码所有的一切都是基于这个构建出来的,不过这个很简单,就是按条件渲染组件。

mock数据

tsx 复制代码
const components: Component[] = [
    {
      id: 1,
      name: 'Button',
      props: {
        type: 'primary',
        children: '按钮',
      },
    },
    {
      id: 2,
      name: 'Space',
      props: {
        size: 'large',
      },
      children: [{
        id: 3,
        name: 'Button',
        props: {
          type: 'primary',
          children: '按钮1',
        },
      }, {
        id: 4,
        name: 'Button',
        props: {
          type: 'primary',
          children: '按钮2',
        },
      }]
    },
];

渲染组件

动态渲染组件最简单的方式是根据组件名称判断渲染某个组件

tsx 复制代码
import { Button, Space } from 'antd';

interface Component {
  /**
   * 组件唯一标识
   */
  id: number;
  /**
   * 组件名称
   */
  name: string;
  /**
   * 组件属性
   */
  props: any;
  /**
   * 子组件
   */
  children?: Component[];
}

const components: Component[] = [
  {
    id: 1,
    name: 'Button',
    props: {
      type: 'primary',
      children: '按钮',
    },
  },
  {
    id: 2,
    name: 'Space',
    props: {
      size: 'large',
    },
    children: [{
      id: 3,
      name: 'Button',
      props: {
        type: 'primary',
        children: '按钮1',
      },
    }, {
      id: 4,
      name: 'Button',
      props: {
        type: 'primary',
        children: '按钮2',
      },
    }]
  },
];


const Stage: React.FC = () => {

  function renderComponents(components: Component[]) {
    return components.map((component) => {
      if (component.name === 'Button') {
        return (
          <Button {...component.props}>{component.props.children}</Button>
        )
      } else if (component.name === 'Space') {
        return (
          <Space {...component.props}>{renderComponents(component.children || [])}</Space>
        )
      }
    })
  }

  return (
    <div className='p-[24px]'>
      {renderComponents(components)}
    </div>
  )
}

export default Stage;

使用if/else判断,组件多了,代码会越来越多。我们使用策略模式改造一下,使用React.createElement动态创建组件。

tsx 复制代码
  function renderComponents(components: Component[]): React.ReactNode {
    return components.map((component: Component) => {

      if (!ComponentMap[component.name]) {
        return null;
      }

      if (ComponentMap[component.name]) {
        return React.createElement(ComponentMap[component.name], component.props, component.props.children || renderComponents(component.children || []))
      }

    })
  }

渲染效果

拖拽

前言

前面数据是写死的,现在我们可以从物料区拖拽组件到画布区动态渲染。

这里拖拽库使用大名鼎鼎的react-dnd,功能很强大,可以满足我们所有要求。

对react-dnd不了解的,可以先看下官网示例

安装react-dnd依赖

sh 复制代码
pnpm i react-dnd-html5-backend react-dnd

实战

改造main.tsx文件,使用DndProvider包裹Layout组件。

tsx 复制代码
// src/main.tsx
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import ReactDOM from 'react-dom/client'

import Layout from './editor/layouts'


import './index.css'

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <DndProvider backend={HTML5Backend}>
    <Layout />
  </DndProvider>
)

定义组件类型映射

为了统一组件名称,我们定义一个组件名称映射对象,后面组件名称都从这里取。

ts 复制代码
// src/editor/item-type.ts
export const ItemType = {
  Button: 'Button',
  Space: 'Space',
};

改造画布文件,可以放置从物料区拖拽过来的组件

这里可以使用react-dnd里的useDrop

tsx 复制代码
// src/editor/layouts/stage/index.tsx
import { Button } from 'antd';
import React from 'react';
import { useDrop } from 'react-dnd';
import Space from '../../components/space';
import { ItemType } from '../../item-type';
import { Component } from '../../stores/components';

const ComponentMap: { [key: string]: any } = {
  Button: Button,
  Space: Space,
}

const Stage: React.FC = () => {

  const components: Component[] = [];

  function renderComponents(components: Component[]): React.ReactNode {
    return components.map((component: Component) => {

      if (!ComponentMap[component.name]) {
        return null;
      }

      if (ComponentMap[component.name]) {
        return React.createElement(
          ComponentMap[component.name],
          { key: component.id, id: component.id, ...component.props },
          component.props.children || renderComponents(component.children || [])
        )
      }

      return null;
    })
  }

  // 如果拖拽的组件是可以放置的,canDrop则为true,通过这个可以给组件添加边框
  const [{ canDrop }, drop] = useDrop(() => ({
    // 可以接受的元素类型
    accept: [
      ItemType.Space,
      ItemType.Button,
    ],
    drop: (_, monitor) => {
      const didDrop = monitor.didDrop()
      if (didDrop) {
        return;
      }

      return {
        id: 0,
      }
    },
    collect: (monitor) => ({
      canDrop: monitor.canDrop(),
    }),
  }));

  return (
    <div ref={drop} style={{ border: canDrop ? '1px solid #ccc' : 'none' }} className='p-[24px] h-[100%]'>
      {renderComponents(components)}
    </div>
  )
}

export default Stage;

改造物料区,添加可拖拽组件

首先封装一个公共的可拖拽组件,使用react-dnd里面的useDrag

tsx 复制代码
// src/editor/common/component-item.tsx
import { useDrag } from 'react-dnd';
import { ItemType } from '../item-type';

interface ComponentItemProps {
  // 组件名称
  name: string,
  // 组件描述
  description: string,
  // 拖拽结束回调
  onDragEnd: any,
}

const ComponentItem: React.FC<ComponentItemProps> = ({ name, description, onDragEnd }) => {

  const [{ isDragging }, drag] = useDrag(() => ({
    type: name,
    end: (_, monitor) => {
      const dropResult = monitor.getDropResult();
      console.log(dropResult, 'dropResult');

      if (!dropResult) return;

      onDragEnd && onDragEnd({
        name,
        props: name === ItemType.Button ? { children: '按钮' } : {},
        ...dropResult,
      });
    },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
      handlerId: monitor.getHandlerId(),
    }),
  }));

  const opacity = isDragging ? 0.4 : 1;

  return (
    <div
      ref={drag}
      className='border-dashed border-[1px] border-[gray] bg-white cursor-move py-[8px] px-[20px] rounded-lg'
      style={{
        opacity,
      }}
    >
      {description}
    </div>
  )
}

export default ComponentItem;

在物料组件中使用上面功能组件

tsx 复制代码
import ComponentItem from '../../common/component-item';
import { ItemType } from '../../item-type';

const Material: React.FC = () => {

  const onDragEnd = (dropResult: any) => {
    console.log(dropResult);
  }

  return (
    <div className='flex p-[10px] gap-4 flex-wrap'>
      <ComponentItem onDragEnd={onDragEnd} description='按钮' name={ItemType.Button} />
      <ComponentItem onDragEnd={onDragEnd} description='间距' name={ItemType.Space} />
    </div>
  )
}

export default Material;

效果展示

动态添加组件

监听拖拽完成事件,往组件树里添加当前拖拽的组件。因为经常跨层级操作组件树,所以这里使用zustand来存储组件树。

安装zustand依赖

sh 复制代码
pnpm i zustand

创建store

tsx 复制代码
// src/editor/stores/components.ts
import {create} from 'zustand';

export interface Component {
  /**
   * 组件唯一标识
   */
  id: number;
  /**
   * 组件名称
   */
  name: string;
  /**
   * 组件属性
   */
  props: any;
  /**
   * 子组件
   */
  children?: Component[];
}

interface State {
  components: Component[];
}

interface Action {
  /**
   * 添加组件
   * @param component 组件属性
   * @returns
   */
  addComponent: (component: Component) => void;
}

export const useComponets = create<State & Action>((set) => ({
  components: [],
  addComponent: (component) =>
    set((state) => {
      return {components: [...state.components, component]};
    }),
}));

改造画布渲染组件,从store中获取组件树。

改造物料拖拽结束事件

tsx 复制代码
import ComponentItem from '../../common/component-item';
import { ItemType } from '../../item-type';
import { useComponets } from '../../stores/components';

const Material: React.FC = () => {

  const { addComponent } = useComponets();

  /**
   * 拖拽结束,添加组件到画布
   * @param dropResult 
   */
  const onDragEnd = (dropResult: { name: string, props: any }) => {
    addComponent({
      id: new Date().getTime(),
      name: dropResult.name,
      props: dropResult.props,
    });
  }


  return (
    <div className='flex p-[10px] gap-4 flex-wrap'>
      <ComponentItem onDragEnd={onDragEnd} description='按钮' name={ItemType.Button} />
      <ComponentItem onDragEnd={onDragEnd} description='间距' name={ItemType.Space} />
    </div>
  )
}

export default Material;

效果展示

支持嵌套组件

假设现在有一个间距组件(Space),需要往间距组件中放置按钮组件,只需要使用useDrop把间距组件(Space)改造成可放置组件就行了。

在components文件夹自定义间距组件(Space)

tsx 复制代码
// src/editor/components/space/index.tsx
import { Space as AntdSpace } from 'antd';
import React from "react";
import { useDrop } from 'react-dnd';
import { ItemType } from '../../item-type';

interface Props {
  // 当前组件的子节点
  children: any;
  // 当前组件的id
  id: number;
}

const Space: React.FC<Props> = ({ children, id }) => {

  const [{ canDrop }, drop] = useDrop(() => ({
    accept: [ItemType.Space, ItemType.Button],
    drop: (_, monitor) => {
      const didDrop = monitor.didDrop()
      if (didDrop) {
        return;
      }

      // 这里把当前组件的id返回出去,在拖拽结束事件里可以拿到这个id。
      return {
        id,
      }
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop(),
    }),
  }));

  if (!children?.length) {
    return (
      <AntdSpace ref={drop} className='p-[16px]' style={{ border: canDrop ? '1px solid #ccc' : 'none' }}>
        暂无内容
      </AntdSpace>
    )
  }

  return (
    <AntdSpace ref={drop} className='p-[16px]' style={{ border: canDrop ? '1px solid #ccc' : 'none' }}>
      {children}
    </AntdSpace>
  )
}

export default Space;

改造store中addComponent方法

递归查找父组件的方法实现

改造拖拽后事件,把drop传过来的id传给addComponent方法

效果展示

选中组件高亮显示

前言

想修改组件配置,首先要选中组件,选中组件有两种常用实现方式

  1. 一种是给每个组件加点击事件,点击后把当前组件id设置到全局,然后在组件内部加选中蒙版。
  2. 还有一种方案是给每个组件添加一个data-component-id,然后监听渲染点击事件,点击后判断点击的元素是否有data-component-id属性,如果有,获取data-component-id值设置到全局,然后根据当前元素坐标和大小动态渲染一个遮罩盖在上面。

这里我推荐使用第二种,简单而优雅。

实战

渲染时给每个组件添加data-component-id属性

store中添加当前选中组件id属性和设置当前选中组件id方法

给stage添加点击事件

封装选中遮罩组件

这里用到了react-dom里的createPortal方法,把一个组件渲染到别的元素里,antd的弹框就是用这个api渲染到body上。

tsx 复制代码
// src/editor/common/selected-mask.tsx

import {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useState,
} from 'react';
import { createPortal } from 'react-dom';

interface Props {
  // 组件id
  componentId: number,
  // 容器class
  containerClassName: string,
  // 相对容器class
  offsetContainerClassName: string
}

function SelectedMask({ componentId, containerClassName, offsetContainerClassName }: Props, ref: any) {

  const [position, setPosition] = useState({
    left: 0,
    top: 0,
    width: 0,
    height: 0,
  });

  // 对外暴露更新位置方法
  useImperativeHandle(ref, () => ({
    updatePosition,
  }));

  useEffect(() => {
    updatePosition();
  }, [componentId]);

  function updatePosition() {
    if (!componentId) return;

    const container = document.querySelector(`.${offsetContainerClassName}`);
    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();

    console.log(top - containerTop + container.scrollTop, left - containerLeft);

    // 计算位置
    setPosition({
      top: top - containerTop + container.scrollTop,
      left: left - containerLeft,
      width,
      height,
    });
  }

  return createPortal((
    <div
      style={{
        position: "absolute",
        left: position.left,
        top: position.top,
        backgroundColor: "rgba(66, 133, 244, 0.2)",
        border: "1px solid rgb(66, 133, 244)",
        pointerEvents: "none",
        width: position.width,
        height: position.height,
        zIndex: 1003,
        borderRadius: 4,
        boxSizing: 'border-box',
      }}
    />
  ), document.querySelector(`.${containerClassName}`)!)
}

export default forwardRef(SelectedMask);

改造stage组件,引入遮罩组件

效果展示

属性

前言

现在组件属性都是写死的,我们希望选中组件后,能够更改当前组件属性。

store中添加更改组件属性方法

更改setting.tsx文件

根据当前选中的组件类型动态渲染配置表单,监听表单元素值改变,修改组件属性。

tsx 复制代码
import { Form, Input, Select } from 'antd';
import { useEffect } from 'react';
import { ItemType } from '../../item-type';
import { useComponets } from '../../stores/components';

const componentSettingMap = {
  [ItemType.Button]: [{
    name: 'type',
    label: '按钮类型',
    type: 'select',
    options: [{ label: '主按钮', value: 'primary' }, { label: '次按钮', value: 'default' }],
  }, {
    name: 'children',
    label: '文本',
    type: 'input',
  }],
  [ItemType.Space]: [
    {
      name: 'size',
      label: '间距大小',
      type: 'select',
      options: [
        { label: '大', value: 'large' },
        { label: '中', value: 'middle' },
        { label: '小', value: 'small' },
      ],
    },
  ],
}

const Setting: React.FC = () => {

  const { curComponentId, updateComponentProps, curComponent } = useComponets();

  const [form] = Form.useForm();

  useEffect(() => {
    // 初始化表单
    form.setFieldsValue(curComponent?.props);
  }, [curComponent])

  /**
   * 动态渲染表单元素
   * @param setting 元素配置
   * @returns 
   */
  function renderFormElememt(setting: any) {
    const { type, options } = setting;

    if (type === 'select') {
      return (
        <Select options={options} />
      )
    } else if (type === 'input') {
      return (
        <Input />
      )
    }
  }

  // 监听表单值变化,更新组件属性
  function valueChange(changeValues: any) {
    if (curComponentId) {
      updateComponentProps(curComponentId, changeValues);
    }
  }


  if (!curComponentId || !curComponent) return null;


  // 根据组件类型渲染表单
  return (
    <div className='pt-[20px]'>
      <Form
        form={form}
        onValuesChange={valueChange}
        labelCol={{ span: 8 }}
        wrapperCol={{ span: 14 }}
      >
        {(componentSettingMap[curComponent.name] || []).map(setting => {
          return (
            <Form.Item name={setting.name} label={setting.label}>
              {renderFormElememt(setting)}
            </Form.Item>
          )
        })}
      </Form>
    </div>
  )
}

export default Setting;

效果展示

预览

前言

页面编辑完,一般需要先预览一下,我们实现一下预览功能。

实战

给store加一个mode属性,表示当前是编辑模式还是预览模式

改造header.tsx组件,添加预览和退出预览按钮

tsx 复制代码
// src/editor/layouts/header/index.tsx
import { Button, Space } from 'antd';
import { useComponets } from '../../stores/components';

const Header: React.FC = () => {

  const { mode, setMode, setCurComponentId } = useComponets();

  return (
    <div className='flex justify-end w-[100%] px-[24px]'>
      <Space>
        {mode === 'edit' && (
          <Button
            onClick={() => {
              setMode('preview');
              setCurComponentId(null);
            }}
            type='primary'
          >
            预览
          </Button>
        )}
        {mode === 'preview' && (
          <Button
            onClick={() => { setMode('edit') }}
            type='primary'
          >
            退出预览
          </Button>
        )}
      </Space>
    </div>
  )
}

export default Header;

添加预览模式画布组件

和编辑模式下的画布组件差不多,把物料区和属性配置区隐藏掉。

tsx 复制代码
// src/editor/layouts/stage/prod.tsx
import { Button, Space } from 'antd';
import React from 'react';
import { Component, useComponets } from '../../stores/components';

const ComponentMap: { [key: string]: any } = {
  Button: Button,
  Space: Space,
}

const ProdStage: React.FC = () => {

  const { components } = useComponets();

  function renderComponents(components: Component[]): React.ReactNode {
    return components.map((component: Component) => {

      if (!ComponentMap[component.name]) {
        return null;
      }

      if (ComponentMap[component.name]) {
        return React.createElement(
          ComponentMap[component.name],
          {
            key: component.id,
            id: component.id,
            ...component.props,
          },
          component.props.children || renderComponents(component.children || [])
        )
      }

      return null;
    })
  }

  return (
    <div>
      {renderComponents(components)}
    </div>
  );
}

export default ProdStage;

修改布局组件,根据模式渲染不同的画布

tsx 复制代码
// src/editor/layouts/index.tsx
import { Allotment } from "allotment";
import "allotment/dist/style.css";
import React from 'react';

import { useComponets } from '../stores/components';
import Header from './header';
import Material from './material';
import Setting from './setting';
import EditStage from './stage/edit';
import ProdStage from './stage/prod';

const Layout: React.FC = () => {

  const { mode } = useComponets();

  return (
    <div className='h-[100vh] flex flex-col'>
      <div className='h-[50px] flex items-centen border-solid border-[1px] border-b-[#ccc]'>
        <Header />
      </div>
      {mode === 'edit' ? (
        <Allotment>
          <Allotment.Pane preferredSize={200} maxSize={400} minSize={200}>
            <Material />
          </Allotment.Pane>
          <Allotment.Pane>
            <EditStage />
          </Allotment.Pane>
          <Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
            <Setting />
          </Allotment.Pane>
        </Allotment>
      ) : (
        <ProdStage />
      )}
    </div>
  )
}

export default Layout;

效果展示

最后

由于篇幅有限,这篇就到这里了。后面还有组件属性动态绑定变量组件联动远程加载组件等知识点讲解,敬请关注。

demo体验地址:dbfu.github.io/lowcode-dem...

demo仓库地址:github.com/dbfu/lowcod...

相关推荐
undefined&&懒洋洋4 分钟前
Web和UE5像素流送、通信教程
前端·ue5
大前端爱好者2 小时前
React 19 新特性详解
前端
随云6322 小时前
WebGL编程指南之着色器语言GLSL ES(入门GLSL ES这篇就够了)
前端·webgl
随云6322 小时前
WebGL编程指南之进入三维世界
前端·webgl
无知的小菜鸡2 小时前
路由:ReactRouter
react.js
寻找09之夏3 小时前
【Vue3实战】:用导航守卫拦截未保存的编辑,提升用户体验
前端·vue.js
多多米10054 小时前
初学Vue(2)
前端·javascript·vue.js
柏箱4 小时前
PHP基本语法总结
开发语言·前端·html·php
新缸中之脑4 小时前
Llama 3.2 安卓手机安装教程
前端·人工智能·算法
hmz8564 小时前
最新网课搜题答案查询小程序源码/题库多接口微信小程序源码+自带流量主
前端·微信小程序·小程序