• 可视化拖拽组件库一些技术要点原理分析(四)


    本文是可视化拖拽系列的第四篇,比起之前的三篇文章,这篇功能点要稍微少一点,总共有五点:

    1. SVG 组件
    2. 动态属性面板
    3. 数据来源(接口请求)
    4. 组件联动
    5. 组件按需加载

    如果你对我之前的系列文章不是很了解,建议先把这三篇文章看一遍,再来阅读本文(否则没有上下文,不太好理解):

    另附上项目、在线 DEMO 地址:

    SVG 组件

    目前项目里提供的自定义组件都是支持自由放大缩小的,不过他们有一个共同点——都是规则形状。也就是说对它们放大缩小,直接改变宽高就可以实现了,无需做其他处理。但是不规则形状就不一样了,譬如一个五角星,你得考虑放大缩小时,如何成比例的改变尺寸。最终,我采用了 svg 的方案来实现(还考虑过用 iconfont 来实现,不过有缺陷,放弃了),下面让我们来看看具体的实现细节。

    用 SVG 画一个五角星

    假设我们需要画一个 100 * 100 的五角星,它的代码是这样的:

    <svg 
        version="1.1" 
        baseProfile="full" 
        xmlns="http://www.w3.org/2000/svg"
    >
        <polygon 
            points="50 0,62.5 37.5,100 37.5,75 62.5,87.5 100,50 75,12.5 100,25 62.5,0 37.5,37.5 37.5" 
            stroke="#000" 
            fill="rgba(255, 255, 255, 1)" 
            stroke-width="1"
        >polygon>
    svg>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    svg 上的版本、命名空间之类的属性不是很重要,可以先忽略。重点是 polygon 这个元素,它在 svg 中定义了一个由一组首尾相连的直线线段构成的闭合多边形形状,最后一点连接到第一点。也就是说这个多边形由一系列坐标点组成,相连的点之间会自动连上。polygon 的 points 属性用来表示多边形的一系列坐标点,每个坐标点由 x y 坐标组成,每个坐标点之间用 ,逗号分隔。

    在这里插入图片描述

    上图就是一个用 svg 画的五角星,它由十个坐标点组成 50 0,62.5 37.5,100 37.5,75 62.5,87.5 100,50 75,12.5 100,25 62.5,0 37.5,37.5 37.5。由于这是一个 100*100 的五角星,所以我们能够很容易的根据每个坐标点的数值算出它们在五角星(坐标系)中所占的比例。譬如第一个点是 p1(50,0),那么它的 x y 坐标比例是 50%, 0;第二个点 p2(62.5,37.5),对应的比例是 62.5%, 37.5%

    // 五角星十个坐标点的比例集合
    const points = [
        [0.5, 0],
        [0.625, 0.375],
        [1, 0.375],
        [0.75, 0.625],
        [0.875, 1],
        [0.5, 0.75],
        [0.125, 1],
        [0.25, 0.625],
        [0, 0.375],
        [0.375, 0.375],
    ]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    既然知道了五角星的比例,那么要画出其他尺寸的五角星也就易如反掌了。我们只需要在每次对五角星进行放大缩小,改变它的尺寸时,等比例的给出每个坐标点的具体数值即要。

    <div class="svg-star-container">
        <svg
            version="1.1"
            baseProfile="full"
            xmlns="http://www.w3.org/2000/svg"
        >
            <polygon
                ref="star"
                :points="points"
                :stroke="element.style.borderColor"
                :fill="element.style.backgroundColor"
                stroke-width="1"
            />
        svg>
        <v-text :prop-value="element.propValue" :element="element" />
    div>
    
    <script>
    function drawPolygon(width, height) {
        // 五角星十个坐标点的比例集合
        const points = [
            [0.5, 0],
            [0.625, 0.375],
            [1, 0.375],
            [0.75, 0.625],
            [0.875, 1],
            [0.5, 0.75],
            [0.125, 1],
            [0.25, 0.625],
            [0, 0.375],
            [0.375, 0.375],
        ]
    
        const coordinatePoints = points.map(point => width * point[0] + ' ' + height * point[1])
        this.points = coordinatePoints.toString() // 得出五角星的 points 属性数据
    }
    script>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    在这里插入图片描述

    其他 SVG 组件

    同理,要画其他类型的 svg 组件,我们只要知道它们坐标点所占的比例就可以了。如果你不知道一个 svg 怎么画,可以网上搜一下,先找一个能用的 svg 代码(这个五角星的 svg 代码,就是在网上找的)。然后再计算它们每个坐标点所占的比例,转成小数点的形式,最后把这些数据代入上面提供的 drawPolygon() 函数即可。譬如画一个三角形的代码是这样的:

    function drawTriangle(width, height) {
        const points = [
            [0.5, 0.05],
            [1, 0.95],
            [0, 0.95],
        ]
    
        const coordinatePoints = points.map(point => width * point[0] + ' ' + height * point[1])
        this.points = coordinatePoints.toString() // 得出三角形的 points 属性数据
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    在这里插入图片描述

    动态属性面板

    目前所有自定义组件的属性面板都共用同一个 AttrList 组件。因此弊端很明显,需要在这里写很多 if 语句,因为不同的组件有不同的属性。例如矩形组件有 content 属性,但是图片没有,一个不同的属性就得写一个 if 语句。

    <el-form-item v-if="name === 'rectShape'" label="内容">
       <el-input />
    el-form-item>
    
    
    • 1
    • 2
    • 3
    • 4

    幸好,这个问题的解决方案也不难。在本系列的第一篇文章中,有讲解过如何动态渲染自定义组件:

    <component :is="item.component">component> 
    
    • 1

    在每个自定义组件的数据结构中都有一个 component 属性,这是该组件在 Vue 中注册的名称。因此,每个自定义组件的属性面板可以和组件本身一样(利用 component 属性),做成动态的:

    
    <section class="right">
        <el-tabs v-if="curComponent" v-model="activeName">
            <el-tab-pane label="属性" name="attr">
                <component :is="curComponent.component + 'Attr'" /> 
            el-tab-pane>
            <el-tab-pane label="动画" name="animation" style="padding-top: 20px;">
                <AnimationList />
            el-tab-pane>
            <el-tab-pane label="事件" name="events" style="padding-top: 20px;">
                <EventList />
            el-tab-pane>
        el-tabs>
        <CanvasAttr v-else>CanvasAttr>
    section>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    同时,自定义组件的目录结构也需要做下调整,原来的目录结构为:

    - VText.vue
    - Picture.vue
    ...
    
    • 1
    • 2
    • 3

    调整后变为:

    - VText
    	- Attr.vue 
    	- Component.vue 
    - Picture
    	- Attr.vue
    	- Component.vue
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    现在每一个组件都包含了组件本身和它的属性面板。经过改造后,图片属性面板代码也更加精简了:

    <template>
        <div class="attr-list">
            <CommonAttr>CommonAttr> 
            <el-form>
                <el-form-item label="镜像翻转">
                    <div style="clear: both;">
                        <el-checkbox v-model="curComponent.propValue.flip.horizontal" label="horizontal">水平翻转el-checkbox>
                        <el-checkbox v-model="curComponent.propValue.flip.vertical" label="vertical">垂直翻转el-checkbox>
                    div>
                el-form-item>
            el-form>
        div>
    template>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    这样一来,组件和对应的属性面板都变成动态的了。以后需要单独给某个自定义组件添加属性就非常方便了。

    在这里插入图片描述

    数据来源(接口请求)

    有些组件会有动态加载数据的需求,所以特地加了一个 Request 公共属性组件,用于请求数据。当一个自定义组件拥有 request 属性时,就会在属性面板上渲染接口请求的相关内容。至此,属性面板的公共组件已经有两个了:

    -common
    	- Request.vue 
    	- CommonAttr.vue 
    
    • 1
    • 2
    • 3
    // VText 自定义组件的数据结构
    {
        component: 'VText',
        label: '文字',
        propValue: '双击编辑文字',
        icon: 'wenben',
        request: { // 接口请求
            method: 'GET',
            data: [],
            url: '',
            series: false, // 是否定时发送请求
            time: 1000, // 定时更新时间
            paramType: '', // string object array
            requestCount: 0, // 请求次数限制,0 为无限
        },
        style: { // 通用样式
            width: 200,
            height: 28,
            fontSize: '',
            fontWeight: 400,
            lineHeight: '',
            letterSpacing: 0,
            textAlign: '',
            color: '',
        },
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    在这里插入图片描述
    从上面的动图可以看出,api 请求的方法参数等都是可以手动修改的。但是怎么控制返回来的数据赋值给组件的某个属性呢?这可以在发出请求的时候把组件的整个数据对象 obj 以及要修改属性的 key 当成参数一起传进去,当数据返回来时,就可以直接使用 obj[key] = data 来修改数据了。

    // 第二个参数是要修改数据的父对象,第三个参数是修改数据的 key,第四个数据修改数据的类型
    this.cancelRequest = request(this.request, this.element, 'propValue', 'string')
    
    • 1
    • 2

    组件联动

    组件联动:当一个组件触发事件时,另一个组件会收到通知,并且做出相应的操作。

    在这里插入图片描述
    上面这个动图的矩形,它分别监听了下面两个按钮的悬浮事件,第一个按钮触发悬浮并广播事件,矩形执行回调向右旋转移动;第二个按钮则相反,向左旋转移动。

    要实现这个功能,首先要给自定义组件加一个新属性 linkage,用来记录所有要联动的组件:

    {
    	// 组件的其他属性...
    	linkage: {
    	     duration: 0, // 过渡持续时间
    	     data: [ // 组件联动
    	         {
    	             id: '', // 联动的组件 id
    	             label: '', // 联动的组件名称
    	             event: '', // 监听事件
    	             style: [{ key: '', value: '' }], // 监听的事件触发时,需要改变的属性
    	         },
    	     ],
    	 }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    对应的属性面板为:

    在这里插入图片描述
    组件联动本质上就是订阅/发布模式的运用,每个组件在渲染时都会遍历它监听的所有组件。

    事件监听

    <script>
    import eventBus from '@/utils/eventBus'
    
    export default {
        props: {
            linkage: {
                type: Object,
                default: () => {},
            },
            element: {
                type: Object,
                default: () => {},
            },
        },
        created() {
            if (this.linkage?.data?.length) {
                eventBus.$on('v-click', this.onClick)
                eventBus.$on('v-hover', this.onHover)
            }
        },
        mounted() {
            const { data, duration } = this.linkage || {}
            if (data?.length) {
                this.$el.style.transition = `all ${duration}s`
            }
        },
        beforeDestroy() {
            if (this.linkage?.data?.length) {
                eventBus.$off('v-click', this.onClick)
                eventBus.$off('v-hover', this.onHover)
            }
        },
        methods: {
            changeStyle(data = []) {
                data.forEach(item => {
                    item.style.forEach(e => {
                        if (e.key) {
                            this.element.style[e.key] = e.value
                        }
                    })
                })
            },
    
            onClick(componentId) {
                const data = this.linkage.data.filter(item => item.id === componentId && item.event === 'v-click')
                this.changeStyle(data)
            },
    
            onHover(componentId) {
                const data = this.linkage.data.filter(item => item.id === componentId && item.event === 'v-hover')
                this.changeStyle(data)
            },
        },
    }
    script>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    从上述代码可以看出:

    1. 每一个自定义组件初始化时,都会监听 v-click v-hover 两个事件(目前只有点击、悬浮两个事件)
    2. 事件回调函数触发时会收到一个参数——发出事件的组件 id(譬如多个组件都触发了点击事件,需要根据 id 来判断是否是自己监听的组件)
    3. 最后再修改对应的属性

    事件触发

    <template>
        <div @click="onClick" @mouseenter="onMouseEnter">
            <component
                :is="config.component"
                ref="component"
                class="component"
                :style="getStyle(config.style)"
                :prop-value="config.propValue"
                :element="config"
                :request="config.request"
                :linkage="config.linkage"
            />
        div>
    template>
    
    <script>
    import eventBus from '@/utils/eventBus'
    
    export default {
        methods: {
            onClick() {
                const events = this.config.events
                Object.keys(events).forEach(event => {
                    this[event](events[event])
                })
    
                eventBus.$emit('v-click', this.config.id)
            },
    
            onMouseEnter() {
                eventBus.$emit('v-hover', this.config.id)
            },
        },
    }
    script>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    从上述代码可以看出,在渲染组件时,每一个组件的最外层都监听了 click mouseenter 事件,当这些事件触发时,eventBus 就会触发对应的事件( v-click 或 v-hover ),并且把当前的组件 id 作为参数传过去。

    最后再捊一遍整体逻辑:

    1. a 组件监听原生事件 click mouseenter
    2. 用户点击或移动鼠标到组件上触发原生事件 click 或 mouseenter
    3. 事件回调函数再用 eventBus 触发 v-click 或 v-hover 事件
    4. 监听了这两个事件的 b 组件收到通知后再修改 b 组件的相关属性(例如上面矩形的 x 坐标和旋转角度)

    组件按需加载

    目前这个项目本身是没有做按需加载的,但是我把实现方案用文字的形式写出来其实也差不多。

    第一步,抽离

    第一步需要把所有的自定义组件出离出来,单独存放。建议使用 monorepo 的方式来存放,所有的组件放在一个仓库里。每一个 package 就是一个组件,可以单独打包。

    - node_modules
    - packages
    	- v-text # 一个组件就是一个包 
    	- v-button
    	- v-table
    - package.json
    - lerna.json
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    第二步,打包

    建议每个组件都打包成一个 js 文件 ,例如叫 bundle.js。打包好直接调用上传接口放到服务器存起来(发布到 npm 也可以),每个组件都有一个唯一 id。前端每次渲染组件的时,通过这个组件 id 向服务器请求组件资源的 URL。

    第三步,动态加载组件

    动态加载组件有两种方式:

    1. import()