Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
woai3c opened this issue Aug 6, 2022 · 16 comments
Open

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

woai3c opened this issue Aug 6, 2022 · 16 comments

Comments

@woai3c
Copy link
Owner

woai3c commented Aug 6, 2022

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

  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>

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],
]

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

<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>

在这里插入图片描述

其他 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 属性数据
}

在这里插入图片描述

动态属性面板

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

<el-form-item v-if="name === 'rectShape'" label="内容">
   <el-input />
</el-form-item>
<!-- 其他属性... -->

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

<component :is="item.component"></component> <!-- 动态渲染组件 -->

在每个自定义组件的数据结构中都有一个 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>

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

- VText.vue
- Picture.vue
...

调整后变为:

- VText
	- Attr.vue <!-- 组件的属性面板 -->
	- Component.vue <!-- 组件本身 -->
- Picture
	- Attr.vue
	- Component.vue

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

<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>

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

在这里插入图片描述

数据来源(接口请求)

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

-common
	- Request.vue <!-- 接口请求 -->
	- CommonAttr.vue <!-- 通用样式 -->
// 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: '',
    },
}

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

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

组件联动

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

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

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

{
	// 组件的其他属性...
	linkage: {
	     duration: 0, // 过渡持续时间
	     data: [ // 组件联动
	         {
	             id: '', // 联动的组件 id
	             label: '', // 联动的组件名称
	             event: '', // 监听事件
	             style: [{ key: '', value: '' }], // 监听的事件触发时,需要改变的属性
	         },
	     ],
	 }
}

对应的属性面板为:

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

事件监听

<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. 每一个自定义组件初始化时,都会监听 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>

从上述代码可以看出,在渲染组件时,每一个组件的最外层都监听了 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

第二步,打包

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

第三步,动态加载组件

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

  1. import()
  2. <script> 标签

第一种方式实现起来比较方便:

const name = 'v-text' // 组件名称
const component = await import('https://xxx.xxx/bundile.js')
Vue.component(name, component)

但是兼容性上有点小问题,如果要支持一些旧的浏览器(例如 IE),可以使用 <script> 标签的形式来加载:

function loadjs(url) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script')
        script.src = url
        script.onload = resolve
        script.onerror = reject
    })
}

const name = 'v-text' // 组件名称
await loadjs('https://xxx.xxx/bundile.js')
// 这种方式加载组件,会直接将组件挂载在全局变量 window 下,所以 window[name] 取值后就是组件
Vue.component(name, window[name])

为了同时支持这两种加载方式,在加载组件时需要判断一下浏览器是否支持 ES6。如果支持就用第一种方式,如果不支持就用第二种方式:

function isSupportES6() {
    try {
        new Function('const fn = () => {};')
    } catch (error) {
        return false
    }

    return true
}

最后一点,打包也要同时兼容这两种加载方式:

import VText from './VText.vue'

if (typeof window !== 'undefined') {
    window['VText'] = VText
}

export default VText

同时导出组件和把组件挂在 window 下。

其他小优化

图片镜像翻转

在这里插入图片描述
图片镜像翻转需要使用 canvas 来实现,主要使用的是 canvas 的 translate() scale() 两个方法。假设我们要对一个 100*100 的图片进行水平镜像翻转,它的代码是这样的:

<canvas width="100" height="100"></canvas>

<script>
    const canvas = document.querySelector('canvas')
    const ctx = canvas.getContext('2d')
    const img = document.createElement('img')
    const width = 100
    const height = 100
    img.src = 'https://avatars.githubusercontent.com/u/22117876?v=4'
    img.onload = () => ctx.drawImage(img, 0, 0, width, height)

    // 水平翻转
    setTimeout(() => {
        // 清除图片
        ctx.clearRect(0, 0, width, height)
        // 平移图片
        ctx.translate(width, 0)
        // 对称镜像
        ctx.scale(-1, 1)
        ctx.drawImage(img, 0, 0, width, height)
        // 还原坐标点
        ctx.setTransform(1, 0, 0, 1, 0, 0)
    }, 2000)
</script>

ctx.translate(width, 0) 这行代码的意思是把图片的 x 坐标往前移动 width 个像素,所以平移后,图片就刚好在画布外面。然后这时使用 ctx.scale(-1, 1) 对图片进行水平翻转,就能得到一个水平翻转后的图片了。

在这里插入图片描述

垂直翻转也是一样的原理,只不过参数不一样:

// 原来水平翻转是 ctx.translate(width, 0)
ctx.translate(0, height) 
// 原来水平翻转是 ctx.scale(-1, 1)
ctx.scale(1, -1)

实时组件列表

画布中的每一个组件都是有层级的,但是每个组件的具体层级并不会实时显现出来。因此,就有了这个实时组件列表的功能。

这个功能实现起来并不难,它的原理和画布渲染组件是一样的,只不过这个列表只需要渲染图标和名称。

<div class="real-time-component-list">
    <div
        v-for="(item, index) in componentData"
        :key="index"
        class="list"
        :class="{ actived: index === curComponentIndex }"
        @click="onClick(index)"
    >
        <span class="iconfont" :class="'icon-' + getComponent(index).icon"></span>
        <span>{{ getComponent(index).label }}</span>
    </div>
</div>

但是有一点要注意,在组件数据的数组里,越靠后的组件层级越高。所以不对数组的数据索引做处理的话,用户看到的场景是这样的(假设添加组件的顺序为文本、按钮、图片):

在这里插入图片描述
从用户的角度来看,层级最高的图片,在实时列表里排在最后。这跟我们平时的认知不太一样。所以,我们需要对组件数据做个 reverse() 翻转一下。譬如文字组件的索引为 0,层级最低,它应该显示在底部。那么每次在实时列表展示时,我们可以通过下面的代码转换一下,得到翻转后的索引,然后再渲染,这样的排序看起来就比较舒服了。

<div class="real-time-component-list">
    <div
        v-for="(item, index) in componentData"
        :key="index"
        class="list"
        :class="{ actived: transformIndex(index) === curComponentIndex }"
        @click="onClick(transformIndex(index))"
    >
        <span class="iconfont" :class="'icon-' + getComponent(index).icon"></span>
        <span>{{ getComponent(index).label }}</span>
    </div>
</div>

<script>
function getComponent(index) {
    return componentData[componentData.length - 1 - index]
}

function transformIndex(index) {
    return componentData.length - 1 - index
}
</script>

在这里插入图片描述
经过转换后,层级最高的图片在实时列表里排在最上面,完美!

总结

至此,可视化拖拽系列的第四篇文章已经结束了,距离上一篇系列文章的发布时间(2021年02月15日)已经有一年多了。没想到这个项目这么受欢迎,在短短一年的时间里获得了很多网友的认可。所以希望本系列的第四篇文章还是能像之前一样,对大家有帮助,再次感谢!

@liujianyu0126
Copy link

文字组件有一点小问题哦~ 文字组件编辑点击其他组件的话不能正确触发blur事件

@woai3c
Copy link
Owner Author

woai3c commented Aug 30, 2022

文字组件有一点小问题哦~ 文字组件编辑点击其他组件的话不能正确触发blur事件

我试了一下没问题,可以录个动图和复现步骤吗?另外,请在可视化的仓库下提 issue,这个仓库是博客。

@liujianyu0126
Copy link

有没有比较好的生成html文件的方案

@liujianyu0126
Copy link

主要是做落地页,然后打包放到不同域名里面去,又害怕改组件的时候会影响到之前的页面,分版本的话可能会迭代多个版本。非常没有必要

1 similar comment
@liujianyu0126
Copy link

主要是做落地页,然后打包放到不同域名里面去,又害怕改组件的时候会影响到之前的页面,分版本的话可能会迭代多个版本。非常没有必要

@woai3c
Copy link
Owner Author

woai3c commented Sep 5, 2022

主要是做落地页,然后打包放到不同域名里面去,又害怕改组件的时候会影响到之前的页面,分版本的话可能会迭代多个版本。非常没有必要

可以在 HTML 文件上直接 new Vue() 建个实例,引入相关的组件代码。不过需要你把自定义组件抽出来,方便单独引入。

@Dkrillex
Copy link

想问下 我能不能基于这个为后台 生产一个前台页面呢 想了解一下步骤是怎么实现的

@woai3c
Copy link
Owner Author

woai3c commented Jan 16, 2023

想问下 我能不能基于这个为后台 生产一个前台页面呢 想了解一下步骤是怎么实现的

没太明白你的意思,可以再详细描述下。

@a951273629
Copy link

这个eslint报错难定啊

@a951273629
Copy link

我是真的搞不明白了都配置eslint了为何不配个prettier的配置。一按保存满屏都飘红还得自己配语法检查

@woai3c
Copy link
Owner Author

woai3c commented Feb 5, 2023

我是真的搞不明白了都配置eslint了为何不配个prettier的配置。一按保存满屏都飘红还得自己配语法检查

prettier 和 airbnb 语法格式差太多了。

@shiheme
Copy link

shiheme commented Mar 2, 2023

想请教一下保存到数据库的问题。

我把这个低代码平台通过iframe植入到wordpress的后台(php)页面中,希望达到后台用户登录后通过它生成json存入数据表的字段中,现在用户登录了,我应该如何获取用户登录态身份认证并POST到后台中?有没有相关的技术文档,谢谢

@woai3c
Copy link
Owner Author

woai3c commented Mar 3, 2023

想请教一下保存到数据库的问题。

我把这个低代码平台通过iframe植入到wordpress的后台(php)页面中,希望达到后台用户登录后通过它生成json存入数据表的字段中,现在用户登录了,我应该如何获取用户登录态身份认证并POST到后台中?有没有相关的技术文档,谢谢

iframe 之间可以通过 postmessage 通信传递数据,然后用 localStorage 存起来。

@a951273629
Copy link

孩子的毕业论文就靠您的文章了啊。大佬yyds

@wbdaydayup
Copy link

大佬,我想问一下,关于适配的疑问比如画布大小是1200740,但实际屏幕大小是19201080,是不是后续生成页面,要自己手写一个单位转化,比如组件的宽高等

@woai3c
Copy link
Owner Author

woai3c commented Jun 15, 2023

大佬,我想问一下,关于适配的疑问比如画布大小是1200_740,但实际屏幕大小是1920_1080,是不是后续生成页面,要自己手写一个单位转化,比如组件的宽高等

是的 目前没有考虑自适应

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants