1.效果演示
先插句题外话,本来是想插入一个视频的,去西瓜视频发布的时候,强制升级到抖音创作者中心平台,在抖音创作者中心发布录制视频后,无法像西瓜视频一样获取发布视频地址,好不容易在抖音app上找到发布视频地址,插入的时候却提示格式不对。只好曲线救国,把视频录制成gif动图,插入到文章中。但与视频相比,有些卡顿,想看视频的点击这里。
现在我们进入正题,整个页面分为三部分:
- 左侧侧边栏展示组件库,目前只有一个Button组件;
- 中间是拖拽区域,分为编辑和预览两个面板,编辑面板组件的位置可以重新拖拽调整,可以删除组件,但是不响应属性面板配置的点击事件,预览面板则相反,会响应给组件绑定的事件,但不能拖拽移动,不能删除组件,不能配置组件的属性;
- 右侧是选中组件属性的配置区域,可以配置选中组件的属性,比如说选中的是按钮组件,那么可以配置按钮组件的文案,背景色,点击事件,点击了应用按钮之后才会生效到画布中
看完视频,是不是感觉有点低代码平台雏形的样子,接下来我们看看如何实现演示的功能。
2.实现步骤
项目采用的技术栈是Vue3+Vite+Pinia+Less, 项目目录如下所示:
- components文件夹下,放置了四个组件,分别是:
组件 | 说明 |
---|---|
ComponentLibrary.vue | 组件库列表 |
ConfigPanel.vue | 组件属性配置面板 |
DraggableCanvas.vue | 拖拽工作区 |
LowCodeButton.vue | 自定义的Button组件 |
- engines 组件注册与管理
- stores 存储公共状态数据
- App.vue 页面主文件
- main.js 项目入口文件
2.1 先绘制页面布局
- 左侧放置的是组件库
<ComponentLibrary />
, 展示组合页面的积木组件 - 主内容区可以在编辑和预览面板之间进行切换,在编辑模式下,主内容区的拖拽工作区
<DraggableCanvas />
的内容可以编辑,在预览模式下是只读的; - 右侧是组件属性配置面板,展示选择组件的配置信息
html
<!-- App.vue -->
<template>
<div class="app">
<div class="sidebar">
<ComponentLibrary />
</div>
<div class="main-content">
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.key"
:class="{ active: workspaceStore.mode === tab.key }"
@click="() => handleModeChange(tab.key)"
>
{{ tab.label }}
</button>
</div>
<DraggableCanvas ref="dragCanvasRef" :mode="workspaceStore.mode" />
</div>
<div class="config-panel">
<ConfigPanel
:selectedConfig="workspaceStore.configData"
@update:config="updateComponentConfig"
/>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import ComponentLibrary from "@/components/ComponentLibrary.vue";
import DraggableCanvas from "@/components/DraggableCanvas.vue";
import ConfigPanel from "@/components/ConfigPanel.vue";
import { useWorkspaceStore } from "@/stores/workspace";
const workspaceStore = useWorkspaceStore();
// 方法:更新组件配置
const dragCanvasRef = ref(null);
const updateComponentConfig = (newConfig) => {
dragCanvasRef.value.updateComponentConfig(newConfig);
};
// Tabs 配置
const tabs = [
{ key: "edit", label: "编辑" },
{ key: "preview", label: "预览" },
];
const handleModeChange = (mode) => {
workspaceStore.setMode(mode);
};
</script>
主工作区的公共数据流逻辑是:当切换编辑和预览模式时,调用workspaceStore.setMode(mode)
变更模式状态,拖拽工作区和右侧组件属性配置面板接收到新的模式后,需要对自身绑定的事件响应行为进行禁止或启用。
主工作区接收到<ConfigPanel />
组件上传的选中组件最新配置数据后,要调用<DraggableCanvas />
组件中的updateComponentConfig(newConfig)
方法,更新拖拽工作区组件的属性。
2.2 组件库的实现
- step1: 先开发一个按钮组件,创建
src\components\LowCodeButton.vue
文件,内容如下:
html
<template>
<button
:style="{ backgroundColor: config.color }"
@click.stop="handleClick"
class="low-code-button"
>
{{ config.label }}
</button>
</template>
<script setup>
// import { watchEffect } from "vue";
import { useWorkspaceStore } from "@/stores/workspace";
// 定义 Props
const props = defineProps({
config: {
type: Object,
default: () => ({
label: "默认按钮",
color: "#007bff",
onClick: "",
}),
},
ignoreClick: {
type: Boolean,
default: false,
},
});
// watchEffect(() => {
// console.log("props.config", props.config.label);
// });
// 处理点击事件
const workspaceStore = useWorkspaceStore();
const handleClick = () => {
console.log("点击按钮");
// 组件列表中的事件一律不触发
if (props.ignoreClick || workspaceStore.mode === "edit") return;
let onClickFn = props.config.onClick;
try {
if (props.config.onClick) {
onClickFn = new Function(`return ${onClickFn}`)();
}
} catch (e) {
console.error("解析点击事件代码失败:", e);
}
onClickFn && onClickFn();
};
</script>
组件接收两个参数config
和ignoreClick
, config
是父组件传入进来的组件属性数据,ignoreClick
用于控制是否执行按钮点击事件。在左侧组件库列表栏,是不允许执行按钮点击事件的。 还有工作区处于编辑状态时,也不响应点击事件。 由于点击事件回调函数是通过属性面板的input输入框配置的,所以是字符串格式,无法直接执行,需要创建一个立即表达式 ,用new Function('代码内容')()
包裹一下,就像下面这样,才能正常运行。
js
onClickFn = new Function(`return ${onClickFn}`)();
- step2 注册组件,创建
D:\low-code-platform\src\engines\index.js
文件,内容如下:registerComponent
用于注册组件,getRegisteredComponents
用于获取组件列表, 定义完这两个函数后, 注册一个组件LowCodeButton
。
js
// 示例组件
import LowCodeButton from "@/components/LowCodeButton.vue";
export const registeredComponents = {};
export function registerComponent(name, component, config) {
registeredComponents[name] = { component, config };
}
export function getRegisteredComponents() {
return registeredComponents;
}
// 注册默认组件
registerComponent("Button", LowCodeButton, {
label: "默认按钮",
color: "#007bff",
onClick: () => {
alert("点击按钮");
},
});
- step3 在主页展示注册的组件列表, 新建
src\components\ComponentLibrary.vue
文件,内容如下: 获取注册的组件列表,并进行遍历展示,为了禁止组件列表中的点击事件,给每个组件添加ignoreClick
属性, 给组件列表容器添加draggable="true"
属性和dragstart
事件,使其里面的组件可以拖拽。在dragstart
事件回调中,将被拖拽组件的组件名和宽高传入目标容器。传递宽高是用于组件被放置到目标区域后,进行拖拽移动后,用于禁止拖动的可移动区域的判断。
html
<template>
<div class="component-library">
<h3>组件库</h3>
<div
v-for="(item, name) in components"
:key="name"
class="component-item"
draggable="true"
@dragstart="onDragStart($event, name)"
>
<component :is="item.component" ignoreClick />
</div>
</div>
</template>
<script setup>
import { getRegisteredComponents } from "@/engines/index";
const components = getRegisteredComponents();
const onDragStart = (event, componentName) => {
console.log("onDragStart", event);
// 获取元素的宽度和高度
const rect = event.target.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// 将尺寸信息作为自定义数据传递
event.dataTransfer.setData(
"componentInfo",
JSON.stringify({ componentName, width, height })
);
};
</script>
2.3 主工作区的实现
编辑和预览两个tab的切换功能比较简单,就不展开说了。重点说一下拖拽区域的功能实现。创建画布文件src\components\DraggableCanvas.vue
,内容如下:
html
<template>
<div class="play-ground" @dragover.prevent @drop.stop="onDrop">
<div
v-for="(item, index) in compList"
:key="index"
:style="{
top: item.position.top + 'px',
left: item.position.left + 'px',
}"
:class="[
'draggable-item',
{ selected: selectedComponentIndex === index },
]"
@mousedown="onMouseDown(index, $event)"
@click.capture="selectComponent(index)"
>
<!-- 组件渲染 -->
<component :is="item.component" :config="item.config" />
<!-- 删除按钮 -->
<button
v-if="selectedComponentIndex === index && props.mode === 'edit'"
class="delete-btn"
@click.stop="deleteComponent(index)"
>
x
</button>
</div>
</div>
</template>
<script setup>
import { reactive, ref, watchEffect } from "vue";
import { registeredComponents } from "@/engines/index";
import { useWorkspaceStore } from "@/stores/workspace";
import { cloneDeep } from "lodash-es";
const props = defineProps({
mode: Boolean,
});
// 工作区画布组件列表
const compList = reactive([]);
// 选中组件索引号
const selectedComponentIndex = ref(-1);
// watchEffect(() => {
// console.log(compList);
// });
// 选中组件时, 设置组件属性面板配置数据
const workspaceStore = useWorkspaceStore();
// 组件的宽高
let selectComponentRect = {
width: 0,
height: 0,
};
// 处理拖放事件
const onDrop = (event) => {
console.log("onDrop");
if (workspaceStore.mode === "preview") return;
const componentInfo = event.dataTransfer.getData("componentInfo");
const { componentName, width, height } = JSON.parse(componentInfo);
selectComponentRect = { width, height };
if (!componentName) return;
const { component, config } = registeredComponents[componentName];
const newComponent = {
component,
config, // 配置对象
position: {
top: event.offsetY,
left: event.offsetX,
},
};
compList.push(newComponent);
selectedComponentIndex.value = compList.length - 1;
selectComponent(selectedComponentIndex.value);
};
// 鼠标拖动逻辑
const onMouseDown = (index, event) => {
console.log("onMouseDown");
if (workspaceStore.mode === "preview") return;
const selectedComponent = compList[index];
let initX = event.clientX;
let initY = event.clientY;
const onMouseMove = (moveEvent) => {
// 就算x,y坐标偏移量
const dx = moveEvent.clientX - initX;
const dy = moveEvent.clientY - initY;
// 计算新的初始位置
initX = moveEvent.clientX;
initY = moveEvent.clientY;
// 计算新的位置
let newTop = selectedComponent.position.top + dy;
let newLeft = selectedComponent.position.left + dx;
// 限制在画布范围内
const playGround = document.querySelector(".play-ground");
const { width, height } = playGround.getBoundingClientRect();
newTop = Math.max(0, Math.min(newTop, height - selectComponentRect.height));
newLeft = Math.max(0, Math.min(newLeft, width - selectComponentRect.width));
selectedComponent.position.top = newTop;
selectedComponent.position.left = newLeft;
};
const onMouseUp = (event) => {
console.log("onMouseUp");
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
const selectComponent = (index) => {
if (workspaceStore.mode === "preview") return;
selectedComponentIndex.value = index;
workspaceStore.setData(cloneDeep(compList[index].config));
};
// 暴露给父组件的方法-更新组件属性
const updateComponentConfig = (config) => {
compList[selectedComponentIndex.value].config = cloneDeep(config);
};
defineExpose({
updateComponentConfig,
});
// 删除组件
const deleteComponent = (index) => {
compList.splice(index, 1);
selectedComponentIndex.value = null;
workspaceStore.setData({});
};
</script>
功能主要有三点:
- 组件拖拽之后的放置逻辑
- 组件的移动逻辑
- 组件的选中和删除逻辑
在讲这四个功能之前,先看看用到的公共状态数据。左侧组件列表,主内容区,右侧属性配置面板公用的数据是选中组件的属性configData
和工作区的模式mode
,当模式为预览时,需要清空右侧的属性配置面板数据。
js
import { defineStore } from "pinia";
export const useWorkspaceStore = defineStore("workspace", {
state: () => ({
configData: {},
mode: "edit",
}),
getters: {},
actions: {
setData(data) {
this.configData = data;
},
setMode(mode) {
this.mode = mode;
if (mode === "preview") {
this.configData = {};
}
},
},
});
2.3.1 组件拖拽之后的放置逻辑
拖拽目标元素只有设置了@dragover.prevent
属性,drop
事件才能被监听到
html
<div class="play-ground" @dragover.prevent @drop.stop="onDrop">
当组件库列表的组件被放置到工作区后,会触发在onDrop函数的执行,在onDrop
函数里,先判断是不是预览模式,如果不是,获取拖拽源事件传递过来的组件参数(组件名,组件的宽高),然后从注册组件列表中取出对应的组件,并追加从drop事件中获取组件移动位置,添加到工作区的渲染列表compList
中,就实现的拖拽组件的功能。此外,还要标记一下当前添加元素为选中元素,在右侧属性配置面板展示添加元素的配置信息。
2.3.2 组件的移动逻辑
当在画布中移动组件时,会触发onMouseDown
函数执行,在onMouseDown
函数中,先将初始移动位置保存出来,然后全局注册mousemove
和mouseup
事件,分别用于监听移动事件和移动结束事件。在移动事件回调中,计算位置移动增量,判断被移动的组件,是否触达拖拽工作区的边界。横向和纵向的最大能移动距离分别是width - selectComponentRect.width
和height - selectComponentRect.height
js
// 计算新的移动位置
let newTop = selectedComponent.position.top + dy;
let newLeft = selectedComponent.position.left + dx;
// 限制在画布范围内
const playGround = document.querySelector(".play-ground");
const { width, height } = playGround.getBoundingClientRect();
newTop = Math.max(0, Math.min(newTop, height - selectComponentRect.height));
newLeft = Math.max(0, Math.min(newLeft, width - selectComponentRect.width));
selectedComponent.position.top = newTop;
selectedComponent.position.left = newLeft;
当组件移动结束后,会触发mouseup
事件,在mouseup
事件回调中,移除对全局mousemove
和mouseup
事件的监听。
2.3.3 组件的选中和删除逻辑
画布中的组件被选中后,要将选中组件的配置属性数据存储到全局store
中,右侧属性配置面板会接收到更新选中组件的配置数据。组件被删除时,会清空属性面板数据。
2.4 配置面板的实现
配置面板用于选中组件的属性编辑,创建src\components\ConfigPanel.vue
文件,内容如下: 配置面板接收到主界面传递的选中组件属性数据后,会按key-value的方式进行遍历渲染,key采用label标签展示,value采用input标签展示。点击了应用按钮之后,才向主页面传递最新的组件属性值, 触发拖拽工作区的选中组件属性更新。
html
<template>
<div class="config-panel">
<h3>属性面板</h3>
<div v-if="Object.keys(selectedConfig).length">
<div v-for="(value, key) in selectedConfig" :key="key" class="form-group">
<label>{{ key }}</label>
<input v-model="selectedConfig[key]" />
</div>
<button @click="applyChanges">应用配置</button>
</div>
<div v-else>
<p>未选中任何组件</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
selectedConfig: Object, // 当前选中组件的配置信息
});
// 定义组件属性修改事件
const emit = defineEmits(["update:config"]);
// 组件属性修改事件通知
const applyChanges = () => {
emit("update:config", props.selectedConfig);
};
</script>
最后
至此,这个简易版的低代码demo功能就实现了。这个低代码平台demo纯属自娱自乐。与市面上的商用低代码平台没有可比性。唯一的意义就在于化繁为简,理解商用低代码平台最基础的功能的实现方式。好了,今天的分享就到这里,各位掘友,下期见。