如何从无到有搭建一套完整的低代码平台(五)核心拖拽区域的实现(1)-拖拽

1,588 阅读11分钟

一. 介绍

好久没有更新,低代码方面的文章了。。不对是好久没有更新文章了。别问为什么,问就是懒!看着我低代码专栏里面空荡荡的四篇文章,我心生愧疚。不说了,感谢各位还能关注我,我将按时更新质量更高的文章。

17c6105f5007d7f9315331dc95634a35.jpg

在之前的文章中,已经完成了基本的配置,以及简单页面的搭建,现在对页面进行简单的优化。

注意一点,因为页面代码跟之前相比,有一些调整。最终代码会放到文章后面。

image.png

简单一张图。中间是渲染页面,可以把左侧的元素拖拽进去。点击渲染页面上已经存在的元素,在右侧配置区,可以进行当前元素的属性和事件的渲染。最后要生成一个配置的json,支持渲染页面的导入和导出。

总结一下三个技术点:

  1. 拖拽实现
  2. 元素配置
  3. 生成配置json

二. 物料区的实现

2.1 物料区的配置json

物料区就是可以拖拽到渲染页面的组件,通过这些组件完成页面的配置。

image.png

实现的想法是读取设置的settings.json文件,完成渲染。

如下代码,一个基本元素的配置:

    {
     "button": {
        "name": "button",
        "label": "按钮",
        "description": "a component button",
        "component": "ElButton",
        "settings": [
            {
                "label": "按钮标题",
                "key": "title",
                "type": "input"
            },
            {
                "label": "按钮名称",
                "key": "name",
                "type": "input"
            },
            {
                "label": "按钮基本样式",
                "key": "style",
                "type": "select",
                "options": [
                    "primary",
                    "success",
                    "info",
                    "warning",
                    "danger"                
                ]
            },
            {
                "label": "尺寸",
                "key": "size",
                "type": "select",
                "options": [
                    "small",
                    "default",
                    "larget"
                ]
            },
            {
                "label": "是否朴素按钮",
                "key": "plain",
                "type": "boolean",
                "defaultValue": false
            },
            {
                "label": "是否圆角",
                "key": "round",
                "type": "boolean",
                "defaultValue": false
            },
            {
                "label": "是否禁用",
                "key": "disabled",
                "type": "boolean",
                "defaultValue": false
            }
        ],
        "style": {
            "width": "60px",
            "height": "40px"
        }
    },
    }

解释一下几个字段:

  • label: 组件名称
  • component: 当前组件对应的原子组件
  • settings: 组件对应的配置项(在元素配置中会用到)
  • style: 当前组件的基本样式,用于在渲染页面中初始样式的设置。

2.2 基本页面配置

我们新建DrawContainer.vue、ComponentTools.vue、ComponentSettingsDraw.vue、CanvasFace.vue组件。

DrawContainer.vue作为入口文件,在DrawContainer组件中把其他组件引入进去。

 <template>
    <div id="draw" class="draw-contain">
        <ComponentTools></ComponentTools>
        <CanvasFace></CanvasFace>
        <ComponentSettingsDraw></ComponentSettingsDraw>
    </div>
</template>

<script lang="ts" setup>
// 物料区
import ComponentTools from '../ComponentTools/ComponentTools.vue';
// 配置区
import ComponentSettingsDraw from '../ComponentSettingsDraw/ComponentSettingsDraw.vue';
// 渲染区
import CanvasFace from '../CanvasFace/index.vue'

</script>


<style lang="less" scoped>
.draw-contain {
    width: 100vw;
    height: 100vh;
    display: flex;
    background-color: rgb(242, 242, 242);
}
</style>

2.3 ComponentTools组件实现

实现的想法也很简单

  • 读取settings.json文件。
  • 根据settings.json文件里面的component属性和style属性融合起来,输出一个vue可以渲染的组件列表。
  • 最后在ComponentTools遍历这个组件列表完成渲染。

第一步和第三步比较简单,核心的内容就是第二步的实现。

2.3.1 settings.json的读取

在hooks/panel/下,新建文件useComponentHooks.ts

import { ref, onMounted } from "vue";
// 所有的类型都会统一放到代码库中,大家自取哈
import { type componentlistType } from '@six-membered/types';

export type componentListHooksType = {
    listCount: Ref<number>,
    list: Ref<componentlistType[]>,
    getList: () => void
}

export const function useComponentListHooks(settings: any): componentListHooksType {
    const _settings = settings
    let listCount = ref<number>(0)
    let list = ref<componentlistType[]>([])
    
    const getList = (): void => {
    
    }
    
     onMounted(() => {
        getList();
    })
    
    return {
        list,
        listCount,
        getList
    }
}

在新建文件useComponentHooks中,执行当前hooks,返回一个 list(物料列表)listCount(物料列表的数量),以及一个 获取物料列表的方法。接收一个settings.json,读取对应的内容,然后进行组件融合。核心就是这个getList方法,那我们来实现一下。

2.3.2 getList实现

getList的核心就是把原子组件和一些配置项结合其他生成一个新的组件。首先要把需要的原子组件引入进去。

    import { ElButton, ElLink, ElText } from "@six-membered/ui";

然后设置一个映射表,通过每个配置项里面的label来找到对应的原子组件,如下代码。

    const nameToComponent = {
        button: ElButton,
        link: ElLink,
        text: ElText
    } as any

这里有个优化点:可以通过 import() 的方式,懒加载对应的组件,这里没有实现,因为import()里面接收变量有点问题,还没有解决。

通过设置nameToComponent找到对应的组件,如何把组件和配置项结合起来。这里需要用到h函数defineComponent

什么是h函数?

h 函数本质就是 createElement() 的简写,作用是根据配置创建对应的虚拟节点,这里面这个虚拟节点很重要,我们要的就是这个虚拟节点,然后把虚拟节点转为一个可以渲染的组件。 在配置项中,我们会设置一些默认的style,通过h函数可以动态编程的方式,把style融合进去。

ok,那么实现一下这个效果。

     /**
     * 
     * @param component 组件
     * @param name 显示内容
     * @returns render()函数
     */
    const setComponent = (component: any, name: string, style: any) => {
        // 拿到style对象
        const propStyle = getPropsStyle(style)
        // 我们需要的就是这个return值
        return defineComponent({
            // 这里用一个render函数,类似于tsx的写法
            render() {
                return h(component, propStyle ?? {}, name)
            }
        })
    }
    
    const getPropsStyle = (style: {width: string, height: string}) => {
        const obj = {} as any
        for (let s in style) {
            obj[s] = style[s]
        }

        return {
            style: obj
        }
    }

最后完善一下getList方法。

    const getList = (): void => {
        for (let key in _settings) {
            listCount.value++;
            list.value.push({
                key: listCount.value,
                title: key,
                name: _settings[key].label,
                type:  _settings[key].name,
                label:  _settings[key].label,
                description: _settings[key].description,
                component: setComponent(nameToComponent[key], _settings[key].label, _settings[key].style),
                renderComponent: nameToComponent[key],
                style: _settings[key].style
            })
        }
    }

我们在页面初始化的时候调用一下这个getList方法,获取一个list数组,这个就是左侧物料区, 拖拽的组件。

最后我们在页面上渲染一下:

回到componentTools.vue中:(这里只放了部分代码)

    <template>
         <div id="face-container">
             <div v-for="item in list" class="component-list">
                 <component :id="'componentRef' + item.key" :is="item.component" draggable="true" @dragstart="dragStartFunc(item, `componentRef${item.key}`, $event)" />
             </div>
        </div>
    </template>
    
    <srcipt>
    import { useComponentListHooks } from '../../hooks';
    const { list } = toRefs(useComponentListHooks(settings));
    </script>

三. 拖拽实现的方案选择

终于到核心地方了。其实拖拽组件实现渲染的操作,算不上复杂,问题在于如何在渲染页面上展示这些组件(先不考虑每个组件的属性还有事件等问题)。市面上也有比较成熟的库,也可以去选择。但是我们毕竟是在实现这个功能,不能盲目的选择成熟的库,可以多对比一下,这里提供以下这些方法。

3.1 定位方式

这个其实用的比较多,也比较常见。

把外层容器设置为position: relative, 里面的每个组件相对于最外层容器绝对定位。

但是这个方法有以下几点原因:

  • 定位在移动中频繁改变组件位置,每次都需要修改定位的位置。性能上不够优秀。
  • absloute定位,是脱离文档流的,这样会造成形成的页面的子元素全都是脱离文档的。这个不科学,更不合理。
  • 定位的话很难处理更深层级元素的位置(不可能所有元素都是相对于最外层元素定位),单独元素的选择也不太支持。

但是我们可以简单的实现一下。

不考虑文档的 纵深问题 以及 边界问题,只考虑第一层的正常拖拽。

回到componentTools.vue文件中:在上面的代码中,我们通过is的方法引入所有的拖拽组件,给每个组件增加了一个draggable属性dragstart方法

3.1.1 draggable实现

image.png

这里有个很重要的概念!!!被 拖拽的元素,和 拖拽之后放置的元素

用一张图来解释一下:

image.png

  1. 拖拽元素,设置一个dragstart属性,用来设置开始拖拽的一些属性。
  2. 当我拖拽元素进入放置元素中的时候,就会触发,放置元素的一些事件。比较常用的就是dragenter(进入的时候触发),dragover(移动的时候触发),dragleave(离开的时候触发),drop(松开鼠标放置的时候触发)事件。

OK,我们实现一些代码:

回到CanvasFace/index.vue文件中:

 <div class="face-content" id="face"
    @dragenter="dragEnter"
    @dragover="dragOver"
    @dragleave="dragLeave"
    @drop="handleDragEnd"
>

<srcipt>
import { usePanelDraggaleEventHooks } from '../../hooks/index'
const { handleDragEnter, handleDragOver, handleDragLeave, dropEnd, drawRender } = usePanelDraggaleEventHooks()

const dragEnter = (e: DragEvent) => {
    handleDragEnter(e)
}

const dragOver = (e: DragEvent) => {
    handleDragOver(e)
}

const dragLeave = (e: DragEvent) => {
    handleDragLeave(e)
}
</script>

核心是这个usePanelDraggaleEventHooks文件:

在usePanelDraggaleEventHooks文件中:我们实现一下

   /**
     * 
     * @param e drag事件
     * @description 目标元素进入到画板的过程
     * @result 改变光标手势
     */
    function handleDragEnter(e: DragEvent) {
        e.dataTransfer!.dropEffect = 'move'
    }

    /**
     * 
     * @param e drag事件
     * @description 目标元素在画板的移动过程中
     * @result 阻止默认事件
     */
    function handleDragOver(e: DragEvent) {
        e.preventDefault()
    }

    /**
     * 
     * @param e drag事件
     * @description 目标元素在离开画板的过程
     * @result 改变光标手势
     */
    function handleDragLeave(e: DragEvent) {
        e.dataTransfer!.dropEffect = 'none'
    }

dragEnter和dragLeave是为了改变光标的样式。‌dragover事件中阻止默认事件是为了允许拖拽元素在被拖放的目标元素上停留。 ‌ 默认情况下,浏览器会阻止拖拽元素在被拖放目标上停留,只有通过阻止默认事件,才能实现拖拽元素在目标元素上的停留和放下‌。

OK,准备工作完成之后我们开始实现一下拖拽了。

  1. 当我开始拖拽的时候,生成一个新的元素,注意这个新的元素里面要有一个唯一的id。我们回到componentTools.vue组件中看这段代码:
 <component :id="'componentRef' + item.key" :is="item.component" draggable="true" @dragstart="dragStartFunc(item, `componentRef${item.key}`, $event)" />

在dragStartFunc方法中,我们生成一个新的组件


const dragStartFunc = (item: any, id: string, e: MouseEvent) => {
    // 生成一个新的组件
    const clonedNode = createOriginRenderComponent(item, id)
    // 存储在pinia里面的当前组件配置
    componentStore.setClonedNodeComponent(clonedNode)
    // 存储在pinia里面的面板组件配置
    componentStore.setCurrentComponentOptions({...item, clonedNode})
}

核心是这个createOriginRenderComponent

function createOriginRenderComponent (options: any, id: string) {
    // 生成一个唯一的uuid
    const componentId = uuidv4() as string
    const canvasFace = document.getElementById('face') as HTMLBaseElement
    const originalNode = document.getElementById(id) as HTMLBaseElement
    const clonedNode = originalNode.cloneNode(true) as HTMLBaseElement
    clonedNode.style.display = 'none'
    clonedNode.id = componentId
    // 在面板中加入这个元素
    canvasFace.appendChild(clonedNode)
    return clonedNode
}

这里有个问题,会出现用户拖拽并没有进去目标元素的情况,可以在enter中加入一个判断就好。

完成这步,就会出现一个display为none的元素在面板中。

3.1.2 位置更新

这个就比较简单了,就是在目标元素上移动的时候同时在pinia中获取最新的位置,当触发放置事件的时候,完成drop事件,位置更新,生成的json更新。

要注意几点,不能只能用窗口的视图,要获取当前画布在页面上的位置。试试getBoundingClientRect。

在CanvasFace/index.vue文件中执行一下初始位置获取

 /**
 * getBoundingClientRect
 */

function getDrawFacePosition(docu: HTMLElement) {
    const position = docu.getBoundingClientRect() as DOMRect
    return {
        canvasFaceOffestX: position.x.toString(),
        canvasFaceOffestY: position.y.toString()
    }
}

然后每次移动获取新位置,不断更新,在最后的drop的时候执行:

function targetElementTransform(id: string, trans: { x: string, y: string}) {
    nextTick(() => {
        const element = document.getElementById(id) as HTMLBaseElement
        // 位置更新
        element.style.position = 'absolute',
        element.style.top = `${trans.y}px`
        element.style.left = `${trans.x}px`


        if (element?.style.display === 'none') {
            element.style.display = 'block'
        }
    })
}

OK,简单的定位方式实现拖拽就算完成了。当然还有很多细节还要处理,这里就不多说了。

3.2 transform方式

这个方法其实原理上跟定位是一样的,通过transform移动的方式完成页面的布局,好处在于,可以通过transform开启硬件加速,性能上应该会好一点(我没测,可能是瞎说的)。

也是通过不断更新位置来实现。修改一下位置更新代码即可。

  element.style.transform = `translate(0px, 0px)`
  element.style.transform = `translate(${trans.x}px, ${trans.y}px)`

但是要注意偏移量的计算

3.3 vue-draggable-plus

市面上的一些库我都大概的试过了,要么就是版本太久不更新,要么就是用起来很奇怪。比较推荐这个库,vue-draggable-plus。大家可以去了解一下,用法也比较简单。

   
pnpm i -S vue-draggable-plus

可以提供三种方式,组件方式,hooks方式,还有指令形式。这里简单使用一下组件方式:

在ComponentTools.vue中:

<VueDraggable v-model="list">
    <div v-for="item in list" class="component-list">
        <component :id="'componentRef' + item.key" :is="item.component"  @dragstart="dragStartFunc(item, `componentRef${item.key}`, $event)" />
    </div>
</VueDraggable>
import { VueDraggable } from 'vue-draggable-plus'

ok! 以上介绍了三种,大家可以自己去玩一下,总体实现下来还有点问题,我需要在选择一下,目前来看的话position实现起来更简单方便。

四. 总结

主要介绍了三种拖拽实现的方式。从方法实用还有性能上简单分析了一下,但是具体这些拖拽放入画布中的组件该如何布局,我们放到下一期吧。

往期文章:

# 如何从无到有搭建一套完整的低代码平台(一)项目搭建

# 如何从无到有搭建一套完整的低代码平台(二)前端选型

# 如何从无到有搭建一套完整的低代码平台(三)通用组件库的配置

# 如何从无到有搭建一套完整的低代码平台(四)页面design设计

项目的地址: gitee.com/sqmmaybe/lo…