前言
随着信息化时代的到来,软件已经成为企业和个人不可或缺的工具。然而,许多人在开发软件时遇到了各种问题,比如开发周期长、技术门槛高、成本高昂等等。为了解决这些问题,低代码平台应运而生。低代码平台是一种快速开发工具,它可以帮助开发者快速构建应用程序,从而提高开发效率和降低成本。
近年来,国产软件市场蓬勃发展,越来越多的企业开始关注自主创新和信息化发展。低代码平台作为信息化创新的重要工具,也逐渐受到了广泛关注。同时,随着Vue.js等前端框架的普及,拖拉拽布局系统也成为了低代码平台的核心技术之一。灵活好用的拖拉拽布局系统,能够帮助开发者快速搭建界面,提高开发效率。
在这篇博客中,我们将深入探究基于Vue.js的拖拽布局的实现方法,以及如何使用它来快速构建应用程序的界面。
阅读本文结合请结合上一篇文章理解:
低代码信创开发核心技术(一):基于Vue.js的描述依赖渲染DDR实现模型驱动的组件
效果预览
通过动图我们可以了解,这个布局功能已经可以实现在元素前添加、元素后添加、元素内添加,并能支持复杂的父子关系嵌套的添加。
甚至结合Ant Design Vue的选项卡,也可以轻松使用。
知识储备
1、您需要先掌握Vue.js 3.0版本的相关知识。
2、您需要先了解浏览器内置的window对象、事件驱动机制和拖拽相关的API。
3、您需要先了解HTML5和CSS3的相关知识。
整体界面布局
这里我们设想左边是一个工具栏,通过拖拽到中间工作空间可以把控件拖拽上去,与此同时未来在选中控件之后,在右侧还可以设置控件的相关属性。因为写这篇博客的时候没有找美工,所以尽管丑了点,功能还是设想的比较全面的。
这里我们用FrontendBlocks设计一下,一键生成出来界面布局代码:
文件名:Designer.vue
<template>
<div class="root">
<div class="DesignerControls">
<div class="BtnControl">Label</div>
</div>
<div class="DesignerWorkSpace">
</div>
<div class="DesignerAttrs">
<div class="attrItem">
<div class="attrName">属性名</div>
<input class="attrValue" />
</div>
</div>
</div>
</template>
<script>
export default {
props: ['id', 'text', 'context', 'initData'],
data() {
return {}
},
mounted() {
this.context.initControl(this)
}
}
</script>
<style>
html,
body,
.root {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
}
.root {
box-sizing: border-box;
font-weight: normal;
font-style: normal;
text-align: left;
display: flex;
position: relative;
width: 100%;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
}
.DesignerControls {
box-sizing: border-box;
background-color: rgba(243, 243, 243, 1);
font-weight: normal;
font-style: normal;
text-align: left;
flex-shrink: 0;
display: flex;
position: relative;
width: 180px;
height: 100%;
padding: 8px;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
overflow: hidden scroll;
}
.BtnControl {
box-sizing: border-box;
border: 1px solid rgba(0, 0, 0, 1);
border-top: 1px solid rgba(0, 0, 0, 1);
border-bottom: 1px solid rgba(0, 0, 0, 1);
border-left: 1px solid rgba(0, 0, 0, 1);
border-right: 1px solid rgba(0, 0, 0, 1);
font-size: 12px;
font-weight: normal;
font-style: normal;
text-align: left;
display: flex;
position: relative;
width: 100%;
padding: 8px;
margin: 0px 0px 8px 0px;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.DesignerWorkSpace {
box-sizing: border-box;
font-weight: normal;
font-style: normal;
text-align: left;
position: relative;
width: 100%;
height: 100%;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.DesignerAttrs {
box-sizing: border-box;
background-color: rgba(243, 243, 243, 1);
font-weight: normal;
font-style: normal;
text-align: left;
flex-shrink: 0;
display: flex;
position: relative;
width: 240px;
height: 100%;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
overflow: hidden scroll;
}
.attrItem {
box-sizing: border-box;
border: 1px solid rgba(167, 167, 167, 1);
border-top: none;
border-bottom: 1px solid rgba(167, 167, 167, 1);
border-left: none;
border-right: none;
font-weight: normal;
font-style: normal;
text-align: left;
display: flex;
position: relative;
width: 100%;
padding: 4px;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.attrName {
box-sizing: border-box;
font-size: 12px;
font-weight: normal;
font-style: normal;
text-align: left;
flex-shrink: 0;
display: flex;
position: relative;
width: 30%;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.attrValue {
box-sizing: border-box;
border: 1px solid rgba(0, 0, 0, 1);
border-top: 1px solid rgba(0, 0, 0, 1);
border-bottom: 1px solid rgba(0, 0, 0, 1);
border-left: 1px solid rgba(0, 0, 0, 1);
border-right: 1px solid rgba(0, 0, 0, 1);
font-weight: normal;
font-style: normal;
text-align: left;
display: flex;
position: relative;
width: 100%;
padding: 5px;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
</style>
JavaScript拖拽事件简述
整体拖拽流程如下:
给需要拖拽的元素绑定 dragstart 事件,该事件在开始拖拽时触发。在该事件中,我们可以设置拖拽数据和拖拽效果。
当鼠标移动到其他元素上时,会触发 dragenter 事件。在该事件中,我们可以设置拖拽目标元素的样式,以反馈给用户当前的拖拽位置。
接着,dragover 事件会持续触发,直到拖拽元素离开了拖拽目标元素。在该事件中,我们可以阻止默认行为,以便能够将元素放置到拖拽目标元素上。
当拖拽元素被放置到拖拽目标元素上时,会触发 drop 事件。在该事件中,我们可以获取拖拽数据,并进行相应的处理操作。
最后,当拖拽完成时,会触发 dragend 事件。在该事件中,我们可以进行一些清理工作,比如重置拖拽元素的样式。
需要注意的是:
拖拽过程中,需要设置拖拽元素和拖拽目标元素的 draggable 属性为 true。
在 dragover 事件中,需要阻止默认行为,以便能够将元素放置到拖拽目标元素上。
在 drop 事件中,需要阻止默认行为,并且需要确保拖拽元素和拖拽目标元素都支持相应的拖拽类型。
在 dragstart 事件中,可以设置拖拽数据和拖拽效果。在 drop 事件中,可以获取拖拽数据,并进行相应的处理操作。
在 dragend 事件中,需要重置拖拽元素的样式,并进行一些清理工作。
是不是很简单呢?
控件栏发起拖拽事件
接下来我们稍作改造,先把Label那个可以拖拽的按钮改成由JSON维护的:
<div v-for="(item) in controlTools" class="BtnControl"
draggable="true"
@dragstart="dragStart($event,item.value)"
@dragend="dragLeaveWorkSpace($event)">
{{item.name}}
</div>
data() {
return {
controlTools:[{
name:"Label",
value:'文本说明'
},{
name:"TextBox",
value:'<input type="text" />'
},{
name:"Button",
value:'<button type="button">搜索</button>'
}]
}
}
这里的我们定义了两个事件处理,一个是dragStart,另一个是dragLeaveWorkSpace。先在methods里把这两个函数实现了,不过我写的时候也不知道dragLeaveWorkSpace应该处理什么,反正先留着空:
dragStart(e,data) {
e.dataTransfer.setData("content", data);
},
dragLeaveWorkSpace(e) {
e.preventDefault();
}
然后为了方便测试,我们把工作区改造一下:
<DropTarget class="DesignerWorkSpace">
<DropTarget class="DemoBox">
<div class="DemoBox">
<DropTarget class="DemoBox">
<div class="DemoBox">
<DropTarget class="DemoBox">
</DropTarget>
</div>
</DropTarget>
</div>
</DropTarget>
<div class="DemoBox" style="display: flex;flex-direction: row;margin-top: 10px;">
<DropTarget class="DemoBox" style="width: 100%;padding: 20px;"></DropTarget>
<DropTarget class="DemoBox" style="width: 100%;padding: 20px;"></DropTarget>
<DropTarget class="DemoBox" style="width: 100%;padding: 20px;"></DropTarget>
<DropTarget class="DemoBox" style="width: 100%;padding: 20px;"></DropTarget>
<DropTarget class="DemoBox" style="width: 100%;padding: 20px;"></DropTarget>
</div>
</DropTarget>
把DemoBox的样式写上
.DemoBox {
border: 1px solid #a3a3a3;
padding: 50px;
box-sizing: border-box;
position: relative;
}
实现DropTarget 可拖放区域
我们写个新组件DropTarget.vue,因为上面工作区我们要往里放子元素,所以自然就要用到slot。
<div class="DropTarget"
@dragenter="dragEnter($event)"
@dragover="dragOver($event)"
@drop="drop($event)">
<slot></slot>
</div>
然后给DropTarget上个背景色,这个不是必须的
.DropTarget {
background-color: #96969632;
}
接下来写dragEnter方法,考虑到有可能有子元素父元素之间的关系处理,那么就先写个递归函数放methods里,向上不断找父级,只要碰到class里有DropTarget的,那一定是目标组件。
calcRealTarget(element) {
let target = element;
if (target.classList.contains("DropTarget")) {
return target;
}
if (!element.parentElement) return null;
return this.calcRealTarget(element.parentElement)
}
然后我们正式开始写dragEnter方法:
dragEnter(e) {
let target = this.calcRealTarget(e.target)
if (target == null) return false;
e.preventDefault();
e.stopPropagation();
// 处理添加
target.originBgColor = target.style.backgroundColor
target.style.outline = "1px dashed #74c3ff"
target.style.outlineOffset = "-1px"
target.classList.add('draging')
if (window.currentDropTarget && window.currentDropTarget != target) {
let oldDropTarget = window.currentDropTarget
oldDropTarget.style.backgroundColor = oldDropTarget.originBgColor
oldDropTarget.style.outline = null
oldDropTarget.classList.remove('draging')
}
window.currentDropTarget = target
}
这里有个技巧,使用outline样式,相比于border来说,它不会因为占文档流的像素而使界面位置发生位移,特别是复杂界面处理的过程中,向外差一两个像素都是灾难性的。这里outlineOffset设置-1px,可以有效防止和边框border重叠,观感更好。
代码中,我们每次DragEnter事件中都会往window对象写一个属性currentDropTarget,把当前对象传过去。这里判断一遍,主要是为了防止鼠标从父容器移动到子容器的时候会再次触发DragEnter事件,为了不出现卡顿,展现出丝般顺滑的感觉,这里需要判断一下,一旦发生了同级别之间的目标转移,则立即把前一个组件的样式清掉。
为什么我这里不用dragLeave事件呢?那是因为鼠标拖拽划过父子容器的时候势必会触发一次dragLeave,从而导致样式被莫名清空。
为了配合鼠标动作,有一个直观的展现,那么这里我们用伪类做一个不会吃掉鼠标事件的半透明遮罩,测试一下,瞬间这感觉就上来了。不过要注意的是,可拖放区域一定要显式声明position样式,否则这个遮罩会超出边界。
.draging::before {
content: ' ';
width: 100%;
height: 100%;
left: 0px;
top: 0px;
opacity: 0.3;
position: absolute;
background-color: #74c3ff;
z-index: 99999;
pointer-events: none;
}
接下来我们编写dragOver事件的函数:
dragOver(e) {
let target = this.calcRealTarget(e.target)
if (target == null) return false;
// 判断什么时候可以显示排序:1、当前事件对象不是容器 2、当前事件对象虽然是容器,但父组件也是容器
let showOrderLine = false;
if (target != e.target) {
showOrderLine = true;
}
if (target.parentElement.classList.contains("DropTarget")) {
showOrderLine = true
}
// 清除原有提示状态
if (window.currentDropBefore && window.currentDropBefore != e.target) {
let oldDropBefore = window.currentDropBefore
oldDropBefore.style.borderInlineStart = null
window.currentDropBefore = null;
}
if (window.currentDropAfter && window.currentDropAfter != e.target) {
let oldDropAfter = window.currentDropAfter
oldDropAfter.style.borderInlineEnd = null
window.currentDropAfter = null
}
// 需要显示则显示
if (showOrderLine) {
if (e.offsetX < (e.target.offsetWidth * 0.25)) {
e.target.style.borderInlineStart = "2px solid #ff6600"
window.currentDropBefore = e.target
if (window.currentDropAfter) {
window.currentDropAfter.style.borderInlineEnd = null
}
} else if (e.offsetX > (e.target.offsetWidth * 0.75)) {
e.target.style.borderInlineEnd = "2px solid #ff6600"
window.currentDropAfter = e.target
if (window.currentDropBefore) {
window.currentDropBefore.style.borderInlineStart = null
}
} else {
e.target.style.borderInlineStart = null
e.target.style.borderInlineEnd = null
window.currentDropBefore = null
window.currentDropAfter = null
}
}
e.preventDefault();
},
因为我们在实现拖放的同时还要实现排序,所以这里我们就约定一个可拖放区域的左侧25%的区域是同级别向前插入一个元素,右侧25%区域是向同级别后面追加一个元素,只有中间的50%区域是向其中填充子元素,如果前后无需排序,则整片区域都是当做添加子元素,鼠标拖拽到哪个区域,便会有对应区域的样式展示,这里使用borderInlineStart和borderInlineEnd可以很方便的展现样式,为什么用到Inline呢?这是因为outline不支持分别设置外框线。
然后我们实现drop方法:
drop(e) {
let target = this.calcRealTarget(e.target)
if (target == null) return false;
e.preventDefault();
e.stopPropagation();
let data = e.dataTransfer.getData('content');
// 清除因拖拽产生的样式
if (window.currentDropTarget) {
window.currentDropTarget.style.backgroundColor = window.currentDropTarget.originBgColor
window.currentDropTarget.style.outline = null
window.currentDropTarget.classList.remove('draging')
}
if (window.currentDropBefore) window.currentDropBefore.style.borderInlineStart = null
if (window.currentDropAfter) window.currentDropAfter.style.borderInlineEnd = null
// 完成拖拽操作并添加DOM元素
// TODO:这里需要修改一下,我们可以通过DOM元素的.__vnode.ctx.proxy属性获取到VUE的vnode对象
// 或者是通过VUE当前组件的.$el和实际拿到的DOM元素进行匹配。两种方式都可以找到VUE的代理对象
// 通过代理对象,可以将这里面的slot换成通过数组来维护,通过DDR方式,递归将JSON渲染成组件
// 如果不了解数组和控件系统的思想,可以看上一篇文章
let newNode=document.createElement("div")
newNode.innerHTML=data
if (window.currentDropBefore) {
window.currentDropBefore.parentElement.insertBefore(newNode, window.currentDropBefore)
} else if (window.currentDropAfter) {
window.currentDropAfter.after(newNode)
} else {
window.currentDropTarget.appendChild(newNode)
}
}
这里把传递过来的信息接收到变量data里,然后清除掉所有临时加的样式,接下来就是创建元素、把元素放置在合适的位置上即可。
如果结合上篇文章,这里其实最好是用系统预设的组件,比如uiTextBox.vue,全程JSON控制,控制起来非常方便。
dragEnd事件处理
最后就是我们回到Designer.vue里,照着刚才的drag最后清理的逻辑,当拖拽结束时还原样式。这里后续要做二次拖拽(工具栏拖拽到工作区,从工作区一个组件拖拽到另一个组件里),我们可以在这个事件处理中销毁原组件。
dragLeaveWorkSpace(e) {
e.preventDefault();
if (window.currentDropTarget) {
let oldDropTarget = window.currentDropTarget
oldDropTarget.style.backgroundColor = oldDropTarget.originBgColor
oldDropTarget.style.outline = null
oldDropTarget.classList.remove('draging')
window.currentDropTarget = null
}
}
总结
本文主要介绍了基于Vue.js的拖拽布局的实现方法和如何使用它来快速构建应用程序的界面。深入探究了基于Vue.js的拖拽布局的实现方法,并展示了其效果预览和整体界面布局。最后,通过包括Vue.js 3.0版本、浏览器内置的window对象、事件驱动机制、拖拽相关API非常简单的实现了拖拽布局机制。
当我们能够创造出快速完成布局的系统之后,我们就可以结合上文所说的控件系统完成控件属性的设置、生成JSON代码保存到后台,然后再从后台读出JSON来渲染界面。
当我们能够快速批量的制造页面之后,就可以开始考虑结合后台整体实现模型驱动架构(MDA:Model Driven Architecture,它是一种软件设计方法论,通过将系统的业务逻辑和技术实现分离,将系统的关注点从技术层面转移到业务层面,提高了软件的可维护性和可重用性。在MDA架构中,模型是软件开发的核心,程序员通过定义模型来描述系统的业务逻辑和功能需求,然后使用模型转换工具将模型转换成最终的代码。),从而向使用部门或客户提供能够支撑其完成信息化创新的基础工具。