第275集:Vue 3低代码平台可视化编辑器实现
一、可视化编辑器概述
可视化编辑器是低代码平台的核心交互界面,它允许用户通过拖拽、配置等可视化操作来构建应用界面,无需编写大量代码。一个功能完整的可视化编辑器通常包括组件面板、画布区域、属性配置面板和工具栏等核心部分。
1.1 核心功能
- 组件拖拽:从组件面板拖拽组件到画布
- 画布操作:缩放、平移、选择组件
- 组件配置:通过属性面板配置组件属性
- 组件嵌套:支持组件的层级嵌套
- 撤销/重做:支持操作历史记录
- 保存/导出:保存当前设计或导出为代码
- 预览功能:实时预览设计效果
- 响应式设计:支持不同屏幕尺寸的设计
1.2 架构设计
可视化编辑器通常采用分层架构设计:
- 表现层:负责用户界面渲染和交互
- 核心引擎层:处理拖拽逻辑、组件渲染、属性更新等核心功能
- 数据层:管理组件树数据、操作历史、配置信息等
- 导出层:负责将设计导出为代码或其他格式
二、核心实现
2.1 项目初始化
使用Vite创建一个Vue 3项目:
npm create vite@latest visual-editor -- --template vue-ts
cd visual-editor
npm install
# 安装必要的依赖
npm install vue-draggable-next @vueuse/core2.2 数据模型设计
首先,我们需要设计可视化编辑器的数据模型,包括组件树、组件配置、操作历史等:
// 组件配置接口
export interface ComponentConfig {
id: string;
type: string;
label: string;
props: Record<string, any>;
children?: ComponentConfig[];
parentId?: string;
position?: {
x: number;
y: number;
};
size?: {
width: number;
height: number;
};
}
// 操作历史项接口
export interface HistoryItem {
id: string;
type: 'add' | 'delete' | 'update' | 'move';
data: any;
timestamp: number;
}
// 编辑器状态接口
export interface EditorState {
// 组件树
componentTree: ComponentConfig;
// 选中的组件ID
selectedComponentId: string | null;
// 操作历史
history: HistoryItem[];
// 历史记录指针
historyIndex: number;
// 画布缩放比例
zoom: number;
// 画布偏移量
canvasOffset: { x: number; y: number };
// 是否处于拖拽状态
isDragging: boolean;
}
// 组件库接口
export interface ComponentLibrary {
id: string;
name: string;
category: string;
icon?: string;
description?: string;
defaultProps?: Record<string, any>;
// 组件渲染函数或组件引用
component: any;
// 可配置的属性列表
configurableProps: {
name: string;
label: string;
type: 'text' | 'number' | 'boolean' | 'select' | 'color' | 'object';
options?: { label: string; value: any }[];
defaultValue?: any;
required?: boolean;
}[];
}2.3 编辑器主组件
实现可视化编辑器的主组件,整合组件面板、画布和属性面板:
<template>
<div class="visual-editor">
<!-- 顶部工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-left">
<h1>Vue 3 可视化编辑器</h1>
</div>
<div class="toolbar-right">
<button @click="handleUndo" :disabled="!canUndo">撤销</button>
<button @click="handleRedo" :disabled="!canRedo">重做</button>
<button @click="handleSave">保存</button>
<button @click="handleExport">导出</button>
<button @click="togglePreview">
{{ isPreviewMode ? '退出预览' : '预览' }}
</button>
</div>
</div>
<!-- 编辑器主体 -->
<div class="editor-main" :class="{ 'preview-mode': isPreviewMode }">
<!-- 左侧组件面板 -->
<div class="component-panel" v-if="!isPreviewMode">
<div class="panel-header">
<h3>组件库</h3>
</div>
<div class="panel-content">
<div
v-for="category in componentCategories"
:key="category"
class="component-category"
>
<h4>{{ category }}</h4>
<div
v-for="component in getComponentsByCategory(category)"
:key="component.id"
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, component)"
>
<div class="component-icon">{{ component.icon || '📦' }}</div>
<div class="component-info">
<div class="component-name">{{ component.name }}</div>
<div class="component-description">{{ component.description }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 中间画布区域 -->
<div class="canvas-area">
<div
class="canvas-container"
ref="canvasContainer"
@dragover.prevent
@drop="handleDrop"
@mousedown="handleCanvasMouseDown"
>
<div
class="canvas"
:style="{
transform: `scale(${editorState.zoom}) translate(${editorState.canvasOffset.x}px, ${editorState.canvasOffset.y}px)`,
transformOrigin: '0 0'
}"
>
<!-- 画布背景网格 -->
<div class="canvas-grid"></div>
<!-- 渲染组件树 -->
<component-node
v-if="editorState.componentTree"
:component="editorState.componentTree"
:selected-id="editorState.selectedComponentId"
:is-preview="isPreviewMode"
@select="handleComponentSelect"
@update="handleComponentUpdate"
@delete="handleComponentDelete"
@move="handleComponentMove"
/>
</div>
</div>
</div>
<!-- 右侧属性面板 -->
<div class="property-panel" v-if="!isPreviewMode && selectedComponent">
<div class="panel-header">
<h3>属性配置</h3>
<button class="delete-btn" @click="handleComponentDelete(selectedComponent.id)">删除</button>
</div>
<div class="panel-content">
<div class="property-group">
<h4>基本信息</h4>
<div class="property-item">
<label>组件类型</label>
<input type="text" v-model="selectedComponent.type" disabled />
</div>
<div class="property-item">
<label>组件ID</label>
<input type="text" v-model="selectedComponent.id" disabled />
</div>
</div>
<!-- 动态渲染可配置属性 -->
<div class="property-group">
<h4>组件属性</h4>
<div
v-for="propConfig in getComponentConfig(selectedComponent.type)?.configurableProps"
:key="propConfig.name"
class="property-item"
>
<label>{{ propConfig.label }}</label>
<!-- 文本输入 -->
<input
v-if="propConfig.type === 'text'"
type="text"
v-model="selectedComponent.props[propConfig.name]"
@input="handlePropertyChange"
/>
<!-- 数字输入 -->
<input
v-else-if="propConfig.type === 'number'"
type="number"
v-model.number="selectedComponent.props[propConfig.name]"
@input="handlePropertyChange"
/>
<!-- 布尔值开关 -->
<input
v-else-if="propConfig.type === 'boolean'"
type="checkbox"
v-model="selectedComponent.props[propConfig.name]"
@change="handlePropertyChange"
/>
<!-- 下拉选择 -->
<select
v-else-if="propConfig.type === 'select'"
v-model="selectedComponent.props[propConfig.name]"
@change="handlePropertyChange"
>
<option
v-for="option in propConfig.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<!-- 颜色选择 -->
<input
v-else-if="propConfig.type === 'color'"
type="color"
v-model="selectedComponent.props[propConfig.name]"
@input="handlePropertyChange"
/>
<!-- 对象输入(JSON格式) -->
<textarea
v-else-if="propConfig.type === 'object'"
v-model="objectProps[propConfig.name]"
@input="handleObjectPropertyChange(propConfig.name)"
rows="4"
></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue';
import type { ComponentConfig, EditorState, ComponentLibrary, HistoryItem } from './types';
import ComponentNode from './components/ComponentNode.vue';
// 组件库定义
const componentLibrary: ComponentLibrary[] = [
{
id: 'button',
name: '按钮',
category: '基础组件',
icon: '🔘',
description: '基础按钮组件',
defaultProps: {
type: 'primary',
text: '按钮',
size: 'medium'
},
component: 'Button',
configurableProps: [
{
name: 'type',
label: '按钮类型',
type: 'select',
options: [
{ label: '主要', value: 'primary' },
{ label: '次要', value: 'secondary' },
{ label: '成功', value: 'success' },
{ label: '危险', value: 'danger' }
],
defaultValue: 'primary'
},
{
name: 'text',
label: '按钮文本',
type: 'text',
defaultValue: '按钮',
required: true
},
{
name: 'size',
label: '按钮尺寸',
type: 'select',
options: [
{ label: '小', value: 'small' },
{ label: '中', value: 'medium' },
{ label: '大', value: 'large' }
],
defaultValue: 'medium'
}
]
},
{
id: 'input',
name: '输入框',
category: '基础组件',
icon: '📝',
description: '文本输入框',
defaultProps: {
placeholder: '请输入内容',
value: '',
disabled: false
},
component: 'Input',
configurableProps: [
{
name: 'placeholder',
label: '占位文本',
type: 'text',
defaultValue: '请输入内容'
},
{
name: 'value',
label: '默认值',
type: 'text',
defaultValue: ''
},
{
name: 'disabled',
label: '是否禁用',
type: 'boolean',
defaultValue: false
}
]
},
{
id: 'container',
name: '容器',
category: '布局组件',
icon: '📦',
description: '容器组件,用于布局',
defaultProps: {
class: 'container',
style: { padding: '20px', backgroundColor: '#f5f5f5' }
},
component: 'div',
configurableProps: [
{
name: 'class',
label: 'CSS类名',
type: 'text',
defaultValue: 'container'
},
{
name: 'style',
label: '内联样式',
type: 'object',
defaultValue: { padding: '20px', backgroundColor: '#f5f5f5' }
}
]
}
];
// 获取组件分类
const componentCategories = computed(() => {
const categories = new Set<string>();
componentLibrary.forEach(comp => categories.add(comp.category));
return Array.from(categories);
});
// 根据分类获取组件
const getComponentsByCategory = (category: string) => {
return componentLibrary.filter(comp => comp.category === category);
};
// 根据组件类型获取组件配置
const getComponentConfig = (type: string) => {
return componentLibrary.find(comp => comp.id === type);
};
// 初始编辑器状态
const initialState: EditorState = {
componentTree: {
id: 'root',
type: 'container',
label: '根容器',
props: {
class: 'root-container',
style: { width: '100%', height: '100%', minHeight: '600px' }
},
children: []
},
selectedComponentId: null,
history: [],
historyIndex: -1,
zoom: 1,
canvasOffset: { x: 0, y: 0 },
isDragging: false
};
// 编辑器状态
const editorState = reactive<EditorState>({ ...initialState });
// 预览模式
const isPreviewMode = ref(false);
// 画布容器引用
const canvasContainer = ref<HTMLElement>();
// 当前拖拽的组件
const draggingComponent = ref<ComponentLibrary | null>(null);
// 对象类型属性的JSON表示
const objectProps = reactive<Record<string, string>>({});
// 选中的组件
const selectedComponent = computed(() => {
if (!editorState.selectedComponentId) return null;
return findComponentById(editorState.componentTree, editorState.selectedComponentId);
});
// 监听选中组件变化,更新对象属性的JSON表示
watch(selectedComponent, (newComponent) => {
if (newComponent) {
// 清空之前的对象属性
Object.keys(objectProps).forEach(key => delete objectProps[key]);
// 更新对象属性的JSON表示
const compConfig = getComponentConfig(newComponent.type);
if (compConfig) {
compConfig.configurableProps.forEach(prop => {
if (prop.type === 'object' && newComponent.props[prop.name]) {
objectProps[prop.name] = JSON.stringify(newComponent.props[prop.name], null, 2);
}
});
}
}
}, { immediate: true, deep: true });
// 能否撤销
const canUndo = computed(() => editorState.historyIndex > 0);
// 能否重做
const canRedo = computed(() => editorState.historyIndex < editorState.history.length - 1);
// 根据ID查找组件
function findComponentById(component: ComponentConfig, id: string): ComponentConfig | null {
if (component.id === id) return component;
if (component.children) {
for (const child of component.children) {
const found = findComponentById(child, id);
if (found) return found;
}
}
return null;
}
// 生成唯一ID
function generateId(): string {
return `comp_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
}
// 处理拖拽开始
function handleDragStart(event: DragEvent, component: ComponentLibrary) {
draggingComponent.value = component;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy';
}
}
// 处理放置组件
function handleDrop(event: DragEvent) {
if (!draggingComponent.value) return;
event.preventDefault();
// 获取放置位置
const rect = canvasContainer.value?.getBoundingClientRect();
if (!rect) return;
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// 创建新组件
const newComponent: ComponentConfig = {
id: generateId(),
type: draggingComponent.value.id,
label: draggingComponent.value.name,
props: { ...draggingComponent.value.defaultProps },
position: { x, y },
parentId: editorState.componentTree.id
};
// 添加到根容器
if (!editorState.componentTree.children) {
editorState.componentTree.children = [];
}
// 记录历史
addHistory({
id: generateId(),
type: 'add',
data: { component: newComponent, parentId: editorState.componentTree.id },
timestamp: Date.now()
});
// 添加组件
editorState.componentTree.children.push(newComponent);
// 选中新添加的组件
editorState.selectedComponentId = newComponent.id;
draggingComponent.value = null;
}
// 处理组件选择
function handleComponentSelect(componentId: string) {
editorState.selectedComponentId = componentId;
}
// 处理组件更新
function handleComponentUpdate(component: ComponentConfig) {
// 找到并更新组件
const updateComponent = (tree: ComponentConfig) => {
if (tree.id === component.id) {
Object.assign(tree, component);
return true;
}
if (tree.children) {
for (let i = 0; i < tree.children.length; i++) {
if (updateComponent(tree.children[i])) {
return true;
}
}
}
return false;
};
updateComponent(editorState.componentTree);
}
// 处理组件删除
function handleComponentDelete(componentId: string) {
if (componentId === 'root') return; // 不能删除根组件
// 找到要删除的组件及其父组件
let parent: ComponentConfig | null = null;
let componentToDelete: ComponentConfig | null = null;
let index = -1;
const findComponentAndParent = (tree: ComponentConfig, parentComp: ComponentConfig | null = null) => {
if (tree.id === componentId) {
componentToDelete = tree;
parent = parentComp;
return true;
}
if (tree.children) {
for (let i = 0; i < tree.children.length; i++) {
if (findComponentAndParent(tree.children[i], tree)) {
index = i;
return true;
}
}
}
return false;
};
findComponentAndParent(editorState.componentTree);
if (parent && componentToDelete && index >= 0 && parent.children) {
// 记录历史
addHistory({
id: generateId(),
type: 'delete',
data: { component: componentToDelete, parentId: parent.id, index },
timestamp: Date.now()
});
// 删除组件
parent.children.splice(index, 1);
// 取消选择
if (editorState.selectedComponentId === componentId) {
editorState.selectedComponentId = null;
}
}
}
// 处理组件移动
function handleComponentMove(componentId: string, newPosition: { x: number; y: number }) {
const component = findComponentById(editorState.componentTree, componentId);
if (component) {
// 记录历史
addHistory({
id: generateId(),
type: 'move',
data: {
componentId,
oldPosition: { ...component.position },
newPosition
},
timestamp: Date.now()
});
// 更新位置
component.position = newPosition;
}
}
// 处理属性变化
function handlePropertyChange() {
if (selectedComponent.value) {
// 记录历史
addHistory({
id: generateId(),
type: 'update',
data: {
componentId: selectedComponent.value.id,
oldProps: { ...selectedComponent.value.props },
newProps: { ...selectedComponent.value.props }
},
timestamp: Date.now()
});
}
}
// 处理对象属性变化
function handleObjectPropertyChange(propName: string) {
if (selectedComponent.value) {
try {
const parsedValue = JSON.parse(objectProps[propName]);
selectedComponent.value.props[propName] = parsedValue;
handlePropertyChange();
} catch (e) {
console.error('Invalid JSON:', e);
}
}
}
// 处理画布鼠标按下
function handleCanvasMouseDown(event: MouseEvent) {
// 点击画布空白处,取消选择
if ((event.target as HTMLElement).classList.contains('canvas') ||
(event.target as HTMLElement).classList.contains('canvas-grid')) {
editorState.selectedComponentId = null;
}
}
// 添加历史记录
function addHistory(item: HistoryItem) {
// 如果当前不在历史记录末尾,删除后面的历史
if (editorState.historyIndex < editorState.history.length - 1) {
editorState.history = editorState.history.slice(0, editorState.historyIndex + 1);
}
// 添加新历史
editorState.history.push(item);
editorState.historyIndex = editorState.history.length - 1;
// 限制历史记录数量
if (editorState.history.length > 50) {
editorState.history.shift();
editorState.historyIndex--;
}
}
// 撤销操作
function handleUndo() {
if (!canUndo.value) return;
const historyItem = editorState.history[editorState.historyIndex];
if (!historyItem) return;
// 根据操作类型执行撤销
switch (historyItem.type) {
case 'add':
// 撤销添加:删除组件
handleComponentDelete(historyItem.data.component.id);
break;
case 'delete':
// 撤销删除:重新添加组件
const { component, parentId, index } = historyItem.data;
const parent = findComponentById(editorState.componentTree, parentId);
if (parent && parent.children) {
parent.children.splice(index, 0, component);
}
break;
case 'update':
// 撤销更新:恢复旧属性
const { componentId, oldProps } = historyItem.data;
const comp = findComponentById(editorState.componentTree, componentId);
if (comp) {
comp.props = oldProps;
}
break;
case 'move':
// 撤销移动:恢复旧位置
const { componentId: moveCompId, oldPosition } = historyItem.data;
const moveComp = findComponentById(editorState.componentTree, moveCompId);
if (moveComp) {
moveComp.position = oldPosition;
}
break;
}
editorState.historyIndex--;
}
// 重做操作
function handleRedo() {
if (!canRedo.value) return;
editorState.historyIndex++;
const historyItem = editorState.history[editorState.historyIndex];
if (!historyItem) return;
// 根据操作类型执行重做
switch (historyItem.type) {
case 'add':
// 重做添加:重新添加组件
const { component: addComp, parentId: addParentId } = historyItem.data;
const addParent = findComponentById(editorState.componentTree, addParentId);
if (addParent && addParent.children) {
addParent.children.push(addComp);
}
break;
case 'delete':
// 重做删除:删除组件
handleComponentDelete(historyItem.data.component.id);
break;
case 'update':
// 重做更新:应用新属性
const { componentId: redoCompId, newProps } = historyItem.data;
const redoComp = findComponentById(editorState.componentTree, redoCompId);
if (redoComp) {
redoComp.props = newProps;
}
break;
case 'move':
// 重做移动:应用新位置
const { componentId: redoMoveId, newPosition: redoNewPos } = historyItem.data;
const redoMoveComp = findComponentById(editorState.componentTree, redoMoveId);
if (redoMoveComp) {
redoMoveComp.position = redoNewPos;
}
break;
}
}
// 保存设计
function handleSave() {
const designData = {
componentTree: editorState.componentTree,
version: '1.0.0',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// 保存到本地存储(实际项目中可能保存到服务器)
localStorage.setItem('visual-editor-design', JSON.stringify(designData));
alert('保存成功!');
}
// 导出设计
function handleExport() {
const designData = {
componentTree: editorState.componentTree,
version: '1.0.0',
createdAt: new Date().toISOString()
};
// 创建下载链接
const dataStr = JSON.stringify(designData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `design-${Date.now()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// 切换预览模式
function togglePreview() {
isPreviewMode.value = !isPreviewMode.value;
}
onMounted(() => {
// 从本地存储加载设计(如果存在)
const savedDesign = localStorage.getItem('visual-editor-design');
if (savedDesign) {
try {
const designData = JSON.parse(savedDesign);
editorState.componentTree = designData.componentTree;
} catch (e) {
console.error('Failed to load saved design:', e);
}
}
});
</script>
<style scoped>
.visual-editor {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
font-family: Arial, sans-serif;
}
/* 工具栏样式 */
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
background-color: #1976d2;
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.toolbar-left h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.toolbar-right {
display: flex;
gap: 10px;
}
.toolbar-right button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.toolbar-right button:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.3);
}
.toolbar-right button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 编辑器主体样式 */
.editor-main {
display: flex;
flex: 1;
overflow: hidden;
background-color: #f5f5f5;
}
/* 预览模式 */
.editor-main.preview-mode {
background-color: white;
}
/* 组件面板样式 */
.component-panel {
width: 280px;
background-color: white;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
background-color: #fafafa;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.component-category {
margin-bottom: 20px;
}
.component-category h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.component-item {
display: flex;
align-items: center;
padding: 12px;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 8px;
cursor: grab;
transition: all 0.3s ease;
}
.component-item:hover {
border-color: #1976d2;
box-shadow: 0 2px 8px rgba(25, 118, 210, 0.15);
transform: translateY(-1px);
}
.component-icon {
font-size: 24px;
margin-right: 12px;
}
.component-info {
flex: 1;
}
.component-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.component-description {
font-size: 12px;
color: #666;
line-height: 1.3;
}
/* 画布区域样式 */
.canvas-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
padding: 20px;
background-color: #f0f2f5;
}
.canvas-container {
position: relative;
background-color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
cursor: grab;
}
.canvas-container:active {
cursor: grabbing;
}
.canvas {
position: relative;
width: 1200px;
height: 800px;
background-color: white;
transition: transform 0.2s ease;
}
.canvas-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
}
/* 属性面板样式 */
.property-panel {
width: 320px;
background-color: white;
border-left: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.property-panel .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.delete-btn {
padding: 6px 12px;
background-color: #f44336;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.delete-btn:hover {
background-color: #d32f2f;
}
.property-group {
margin-bottom: 20px;
}
.property-group h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #666;
}
.property-item {
margin-bottom: 12px;
}
.property-item label {
display: block;
font-size: 12px;
font-weight: 500;
color: #666;
margin-bottom: 4px;
}
.property-item input[type="text"],
.property-item input[type="number"],
.property-item select,
.property-item textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
transition: all 0.3s ease;
}
.property-item input[type="text"]:focus,
.property-item input[type="number"]:focus,
.property-item select:focus,
.property-item textarea:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
}
.property-item input[type="checkbox"] {
margin-top: 4px;
cursor: pointer;
}
.property-item input[type="color"] {
width: 100%;
height: 36px;
padding: 2px;
border: 1px solid #e0e0e0;
border-radius: 4px;
cursor: pointer;
}
</style>2.4 组件节点组件
实现组件节点组件,用于渲染和管理单个组件:
<template>
<div
class="component-node"
:class="{
'selected': isSelected,
'draggable': !isPreview
}"
:style="nodeStyle"
@click.stop="handleSelect"
@mousedown.stop="handleMouseDown"
draggable="!isPreview"
@dragstart="handleDragStart"
>
<!-- 组件选中状态指示器 -->
<div v-if="!isPreview && isSelected" class="selection-indicator">
<div class="resize-handle top-left"></div>
<div class="resize-handle top-right"></div>
<div class="resize-handle bottom-left"></div>
<div class="resize-handle bottom-right"></div>
</div>
<!-- 组件渲染 -->
<component
:is="componentType"
v-bind="component.props"
>
<!-- 渲染子组件 -->
<component-node
v-for="child in component.children"
:key="child.id"
:component="child"
:selected-id="selectedId"
:is-preview="isPreview"
@select="$emit('select', $event)"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
</component>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import type { ComponentConfig } from './types';
const props = defineProps<{
component: ComponentConfig;
selectedId: string | null;
isPreview: boolean;
}>();
const emit = defineEmits<{
(e: 'select', componentId: string): void;
(e: 'update', component: ComponentConfig): void;
(e: 'delete', componentId: string): void;
(e: 'move', componentId: string, newPosition: { x: number; y: number }): void;
}>();
// 是否选中
const isSelected = computed(() => props.component.id === props.selectedId);
// 组件类型
const componentType = computed(() => {
// 对于HTML原生元素,直接返回标签名
if (['div', 'span', 'h1', 'h2', 'h3', 'p', 'input', 'button'].includes(props.component.type)) {
return props.component.type;
}
// 否则返回组件名称(需要在父组件中注册)
return props.component.type;
});
// 节点样式
const nodeStyle = computed(() => {
const style: Record<string, any> = {
position: 'absolute',
minWidth: '100px',
minHeight: '40px',
boxSizing: 'border-box'
};
// 如果有位置信息,应用定位
if (props.component.position) {
style.left = `${props.component.position.x}px`;
style.top = `${props.component.position.y}px`;
}
// 如果有尺寸信息,应用尺寸
if (props.component.size) {
style.width = `${props.component.size.width}px`;
style.height = `${props.component.size.height}px`;
}
// 应用组件自身的样式
if (props.component.props.style) {
Object.assign(style, props.component.props.style);
}
return style;
});
// 拖拽状态
const isDragging = ref(false);
const dragStartPos = ref({ x: 0, y: 0 });
const componentStartPos = ref({ x: 0, y: 0 });
// 处理选择
function handleSelect() {
emit('select', props.component.id);
}
// 处理鼠标按下
function handleMouseDown(event: MouseEvent) {
if (props.isPreview) return;
// 只处理左键
if (event.button !== 0) return;
// 如果不是选中状态,先选中
if (!isSelected.value) {
emit('select', props.component.id);
}
// 记录初始位置
isDragging.value = true;
dragStartPos.value = { x: event.clientX, y: event.clientY };
componentStartPos.value = {
x: props.component.position?.x || 0,
y: props.component.position?.y || 0
};
// 添加全局事件监听
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
// 处理鼠标移动
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value) return;
// 计算移动距离
const deltaX = event.clientX - dragStartPos.value.x;
const deltaY = event.clientY - dragStartPos.value.y;
// 计算新位置
const newPosition = {
x: componentStartPos.value.x + deltaX,
y: componentStartPos.value.y + deltaY
};
// 触发移动事件
emit('move', props.component.id, newPosition);
}
// 处理鼠标释放
function handleMouseUp() {
isDragging.value = false;
// 移除全局事件监听
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
// 处理拖拽开始
function handleDragStart(event: DragEvent) {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', props.component.id);
}
}
onUnmounted(() => {
// 确保移除事件监听
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
});
</script>
<style scoped>
.component-node {
position: absolute;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.component-node.selected {
outline: 2px solid #1976d2;
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2);
z-index: 10;
}
.component-node.draggable:hover {
outline: 1px dashed #1976d2;
}
/* 选择指示器 */
.selection-indicator {
position: absolute;
top: -8px;
left: -8px;
right: -8px;
bottom: -8px;
pointer-events: none;
z-index: 20;
}
/* 调整大小手柄 */
.resize-handle {
position: absolute;
width: 16px;
height: 16px;
background-color: #1976d2;
border: 2px solid white;
border-radius: 50%;
pointer-events: all;
cursor: nwse-resize;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.resize-handle.top-left {
top: 0;
left: 0;
}
.resize-handle.top-right {
top: 0;
right: 0;
cursor: nesw-resize;
}
.resize-handle.bottom-left {
bottom: 0;
left: 0;
cursor: nesw-resize;
}
.resize-handle.bottom-right {
bottom: 0;
right: 0;
}
</style>三、使用示例
3.1 基本使用
启动项目:
npm run dev使用编辑器:
- 从左侧组件面板拖拽组件到中间画布
- 点击选中组件,在右侧属性面板中配置组件属性
- 使用顶部工具栏的撤销/重做、保存、导出、预览等功能
保存和导出:
- 点击"保存"按钮,设计会保存到浏览器本地存储
- 点击"导出"按钮,可以将设计导出为JSON文件
3.2 扩展组件库
要扩展组件库,只需在componentLibrary数组中添加新的组件配置:
// 添加新组件
const componentLibrary: ComponentLibrary[] = [
// 现有组件...
{
id: 'card',
name: '卡片',
category: '布局组件',
icon: '🃏',
description: '卡片组件,用于展示内容',
defaultProps: {
title: '卡片标题',
content: '卡片内容',
footer: '卡片底部'
},
component: 'Card',
configurableProps: [
{
name: 'title',
label: '卡片标题',
type: 'text',
defaultValue: '卡片标题'
},
{
name: 'content',
label: '卡片内容',
type: 'text',
defaultValue: '卡片内容'
},
{
name: 'footer',
label: '卡片底部',
type: 'text',
defaultValue: '卡片底部'
}
]
}
];四、性能优化
4.1 虚拟渲染
对于大型组件树,可以实现虚拟渲染,只渲染可见区域的组件:
// 虚拟渲染逻辑
const visibleComponents = computed(() => {
// 根据画布可见区域计算可见的组件
// 只返回可见区域内的组件
return props.components.filter(component => {
// 计算组件是否在可见区域内
// ...
return isVisible;
});
});4.2 防抖更新
对于频繁的属性更新,可以使用防抖机制减少渲染次数:
import { debounce } from 'lodash-es';
// 防抖处理属性更新
const debouncedPropertyChange = debounce(() => {
// 处理属性更新
}, 300);
function handlePropertyChange() {
debouncedPropertyChange();
}4.3 组件缓存
对于不常变化的组件,可以进行缓存,避免重复渲染:
<template>
<keep-alive>
<component
:is="componentType"
v-bind="component.props"
>
<!-- 子组件 -->
</component>
</keep-alive>
</template>五、最佳实践
5.1 组件设计原则
- 原子化设计:将组件拆分为原子组件、分子组件、组织组件和模板
- 高内聚低耦合:组件内部功能内聚,组件之间耦合度低
- 可配置性:组件应提供丰富的可配置属性
- 响应式设计:组件应支持不同屏幕尺寸
5.2 编辑器设计原则
- 直观易用:界面设计简洁直观,操作流程符合用户习惯
- 实时反馈:用户操作应得到实时反馈
- 可扩展性:支持自定义组件和插件
- 性能优化:确保编辑器在大型项目中仍能流畅运行
- 错误处理:提供友好的错误提示和容错机制
5.3 数据管理
- 单一数据源:所有组件状态来自单一数据源
- 不可变数据:使用不可变数据模式,便于撤销/重做
- 状态管理:对于复杂应用,考虑使用Pinia或Vuex进行状态管理
六、总结
可视化编辑器是低代码平台的核心组件,它允许用户通过拖拽、配置等可视化操作来构建应用界面。在本集中,我们学习了:
- 可视化编辑器的核心功能和架构设计
- 使用Vue 3实现可视化编辑器的主界面
- 实现组件拖拽、选择、配置等核心功能
- 实现操作历史记录(撤销/重做)
- 实现保存、导出、预览等功能
- 性能优化策略和最佳实践
通过合理设计和实现可视化编辑器,我们可以构建出高效、易用的低代码平台,极大提高应用开发的效率和质量。
在下一集中,我们将学习如何实现低代码平台的数据源管理功能,进一步完善低代码开发环境。