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
方法
效果展示
选中组件高亮显示
前言
想修改组件配置,首先要选中组件,选中组件有两种常用实现方式
- 一种是给每个组件加点击事件,点击后把当前组件id设置到全局,然后在组件内部加选中蒙版。
- 还有一种方案是给每个组件添加一个
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...