数据来源(接口请求)
有些组件会有动态加载数据的需求,所以特地加了一个 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>
从上述代码可以看出:
- 每一个自定义组件初始化时,都会监听
v-click
v-hover
两个事件(目前只有点击、悬浮两个事件) - 事件回调函数触发时会收到一个参数——发出事件的组件 id(譬如多个组件都触发了点击事件,需要根据 id 来判断是否是自己监听的组件)
- 最后再修改对应的属性
事件触发
<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 作为参数传过去。
最后再捊一遍整体逻辑:
- a 组件监听原生事件 click mouseenter
- 用户点击或移动鼠标到组件上触发原生事件 click 或 mouseenter
- 事件回调函数再用 eventBus 触发 v-click 或 v-hover 事件
- 监听了这两个事件的 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。
第三步,动态加载组件
动态加载组件有两种方式:
import()
</code> 标签</li></ol><div>第一种方式实现起来比较方便:</div><div data-card-type="block" data-ready-card="codeblock" data-card-value="data:%7B%22mode%22%3A%22plain%22%2C%22code%22%3A%22const%20name%20%3D%20'v-text'%20%2F%2F%20%E7%BB%84%E4%BB%B6%E5%90%8D%E7%A7%B0%5Cnconst%20component%20%3D%20await%20import('https%3A%2F%2Fxxx.xxx%2Fbundile.js')%5CnVue.component(name%2C%20component)%22%2C%22heightLimit%22%3Atrue%2C%22margin%22%3Atrue%2C%22id%22%3A%22Ju4rV%22%7D"></div><div>但是兼容性上有点小问题,如果要支持一些旧的浏览器(例如 IE),可以使用 <code><script></code> 标签的形式来加载:</div><div data-card-type="block" data-ready-card="codeblock" data-card-value="data:%7B%22mode%22%3A%22plain%22%2C%22code%22%3A%22function%20loadjs(url)%20%7B%5Cn%20%20%20%20return%20new%20Promise((resolve%2C%20reject)%20%3D%3E%20%7B%5Cn%20%20%20%20%20%20%20%20const%20script%20%3D%20document.createElement('script')%5Cn%20%20%20%20%20%20%20%20script.src%20%3D%20url%5Cn%20%20%20%20%20%20%20%20script.onload%20%3D%20resolve%5Cn%20%20%20%20%20%20%20%20script.onerror%20%3D%20reject%5Cn%20%20%20%20%7D)%5Cn%7D%5Cnconst%20name%20%3D%20'v-text'%20%2F%2F%20%E7%BB%84%E4%BB%B6%E5%90%8D%E7%A7%B0%5Cnawait%20loadjs('https%3A%2F%2Fxxx.xxx%2Fbundile.js')%5Cn%2F%2F%20%E8%BF%99%E7%A7%8D%E6%96%B9%E5%BC%8F%E5%8A%A0%E8%BD%BD%E7%BB%84%E4%BB%B6%EF%BC%8C%E4%BC%9A%E7%9B%B4%E6%8E%A5%E5%B0%86%E7%BB%84%E4%BB%B6%E6%8C%82%E8%BD%BD%E5%9C%A8%E5%85%A8%E5%B1%80%E5%8F%98%E9%87%8F%20window%20%E4%B8%8B%EF%BC%8C%E6%89%80%E4%BB%A5%20window%5Bname%5D%20%E5%8F%96%E5%80%BC%E5%90%8E%E5%B0%B1%E6%98%AF%E7%BB%84%E4%BB%B6%5CnVue.component(name%2C%20window%5Bname%5D)%22%2C%22heightLimit%22%3Atrue%2C%22margin%22%3Atrue%2C%22id%22%3A%22F3XJ1%22%7D"></div><div>为了同时支持这两种加载方式,在加载组件时需要判断一下浏览器是否支持 ES6。如果支持就用第一种方式,如果不支持就用第二种方式:</div><div data-card-type="block" data-ready-card="codeblock" data-card-value="data:%7B%22mode%22%3A%22plain%22%2C%22code%22%3A%22function%20isSupportES6()%20%7B%5Cn%20%20%20%20try%20%7B%5Cn%20%20%20%20%20%20%20%20new%20Function('const%20fn%20%3D%20()%20%3D%3E%20%7B%7D%3B')%5Cn%20%20%20%20%7D%20catch%20(error)%20%7B%5Cn%20%20%20%20%20%20%20%20return%20false%5Cn%20%20%20%20%7D%5Cn%20%20%20%20return%20true%5Cn%7D%22%2C%22heightLimit%22%3Atrue%2C%22margin%22%3Atrue%2C%22id%22%3A%22oHMHg%22%7D"></div><div>最后一点,打包也要同时兼容这两种加载方式:</div><div data-card-type="block" data-ready-card="codeblock" data-card-value="data:%7B%22mode%22%3A%22plain%22%2C%22code%22%3A%22import%20VText%20from%20'.%2FVText.vue'%5Cnif%20(typeof%20window%20!%3D%3D%20'undefined')%20%7B%5Cn%20%20%20%20window%5B'VText'%5D%20%3D%20VText%5Cn%7D%5Cnexport%20default%20VText%22%2C%22heightLimit%22%3Atrue%2C%22margin%22%3Atrue%2C%22id%22%3A%22ZgCSv%22%7D"></div><div>同时导出组件和把组件挂在 window 下。</div><h2 id="item-6">其他小优化</h2><h3 id="item-6-5">图片镜像翻转</h3><div><span data-card-type="inline" data-ready-card="image" data-card-value="data:%7B%22src%22%3A%22https%3A%2F%2Fucc.alicdn.com%2Fpic%2Fdeveloper-ecology%2Fdxrdxmxsuvtcc_31b53d71565c4a109b607bc958085c14.gif%22%2C%22originWidth%22%3A577%2C%22originHeight%22%3A330%2C%22size%22%3A0%2C%22display%22%3A%22inline%22%2C%22align%22%3A%22left%22%2C%22linkTarget%22%3A%22_blank%22%2C%22status%22%3A%22done%22%2C%22style%22%3A%22none%22%2C%22search%22%3A%22%22%2C%22margin%22%3A%7B%22top%22%3Afalse%2C%22bottom%22%3Afalse%7D%2C%22width%22%3A577%2C%22height%22%3A330%7D"></span></div><div>图片镜像翻转需要使用 canvas 来实现,主要使用的是 canvas 的 <code>translate()</code> <code>scale()</code> 两个方法。假设我们要对一个 100*100 的图片进行水平镜像翻转,它的代码是这样的:</div><div data-card-type="block" data-ready-card="codeblock" data-card-value="data:%7B%22mode%22%3A%22plain%22%2C%22code%22%3A%22%3Ccanvas%20width%3D%5C%22100%5C%22%20height%3D%5C%22100%5C%22%3E%3C%2Fcanvas%3E%5Cn%3Cscript%3E%5Cn%20%20%20%20const%20canvas%20%3D%20document.querySelector('canvas')%5Cn%20%20%20%20const%20ctx%20%3D%20canvas.getContext('2d')%5Cn%20%20%20%20const%20img%20%3D%20document.createElement('img')%5Cn%20%20%20%20const%20width%20%3D%20100%5Cn%20%20%20%20const%20height%20%3D%20100%5Cn%20%20%20%20img.src%20%3D%20'https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F22117876%3Fv%3D4'%5Cn%20%20%20%20img.onload%20%3D%20()%20%3D%3E%20ctx.drawImage(img%2C%200%2C%200%2C%20width%2C%20height)%5Cn%20%20%20%20%2F%2F%20%E6%B0%B4%E5%B9%B3%E7%BF%BB%E8%BD%AC%5Cn%20%20%20%20setTimeout(()%20%3D%3E%20%7B%5Cn%20%20%20%20%20%20%20%20%2F%2F%20%E6%B8%85%E9%99%A4%E5%9B%BE%E7%89%87%5Cn%20%20%20%20%20%20%20%20ctx.clearRect(0%2C%200%2C%20width%2C%20height)%5Cn%20%20%20%20%20%20%20%20%2F%2F%20%E5%B9%B3%E7%A7%BB%E5%9B%BE%E7%89%87%5Cn%20%20%20%20%20%20%20%20ctx.translate(width%2C%200)%5Cn%20%20%20%20%20%20%20%20%2F%2F%20%E5%AF%B9%E7%A7%B0%E9%95%9C%E5%83%8F%5Cn%20%20%20%20%20%20%20%20ctx.scale(-1%2C%201)%5Cn%20%20%20%20%20%20%20%20ctx.drawImage(img%2C%200%2C%200%2C%20width%2C%20height)%5Cn%20%20%20%20%20%20%20%20%2F%2F%20%E8%BF%98%E5%8E%9F%E5%9D%90%E6%A0%87%E7%82%B9%5Cn%20%20%20%20%20%20%20%20ctx.setTransform(1%2C%200%2C%200%2C%201%2C%200%2C%200)%5Cn%20%20%20%20%7D%2C%202000)%5Cn%3C%2Fscript%3E%22%2C%22heightLimit%22%3Atrue%2C%22margin%22%3Atrue%2C%22id%22%3A%22HPfeZ%22%7D"></div><div><code>ctx.translate(width, 0)</code> 这行代码的意思是把图片的 x 坐标往前移动 width 个像素,所以平移后,图片就刚好在画布外面。然后这时使用 <code>ctx.scale(-1, 1)</code> 对图片进行水平翻转,就能得到一个水平翻转后的图片了。</div><div><span data-card-type="inline" data-ready-card="image" data-card-value="data:%7B%22src%22%3A%22https%3A%2F%2Fucc.alicdn.com%2Fpic%2Fdeveloper-ecology%2Fdxrdxmxsuvtcc_4cc8fef9e6a143579568cf1ae5534c7a.gif%22%2C%22originWidth%22%3A164%2C%22originHeight%22%3A108%2C%22size%22%3A0%2C%22display%22%3A%22inline%22%2C%22align%22%3A%22left%22%2C%22linkTarget%22%3A%22_blank%22%2C%22status%22%3A%22done%22%2C%22style%22%3A%22none%22%2C%22search%22%3A%22%22%2C%22margin%22%3A%7B%22top%22%3Afalse%2C%22bottom%22%3Afalse%7D%2C%22width%22%3A164%2C%22height%22%3A108%7D"></span></div><div>垂直翻转也是一样的原理,只不过参数不一样:</div><div data-card-type="block" data-ready-card="codeblock" data-card-value="data:%7B%22mode%22%3A%22plain%22%2C%22code%22%3A%22%2F%2F%20%E5%8E%9F%E6%9D%A5%E6%B0%B4%E5%B9%B3%E7%BF%BB%E8%BD%AC%E6%98%AF%20ctx.translate(width%2C%200)%5Cnctx.translate(0%2C%20height)%20%5Cn%2F%2F%20%E5%8E%9F%E6%9D%A5%E6%B0%B4%E5%B9%B3%E7%BF%BB%E8%BD%AC%E6%98%AF%20ctx.scale(-1%2C%201)%5Cnctx.scale(1%2C%20-1)%22%2C%22heightLimit%22%3Atrue%2C%22margin%22%3Atrue%2C%22id%22%3A%22WV1xZ%22%7D"></div><h3 id="item-6-6">实时组件列表</h3><div>画布中的每一个组件都是有层级的,但是每个组件的具体层级并不会实时显现出来。因此,就有了这个实时组件列表的功能。</div><div>这个功能实现起来并不难,它的原理和画布渲染组件是一样的,只不过这个列表只需要渲染图标和名称。</div><div data-card-type="block" data-ready-card="codeblock" data-card-value="data:%7B%22mode%22%3A%22plain%22%2C%22code%22%3A%22%3Cdiv%20class%3D%5C%22real-time-component-list%5C%22%3E%5Cn%20%20%20%20%3Cdiv%5Cn%20%20%20%20%20%20%20%20v-for%3D%5C%22(item%2C%20index)%20in%20componentData%5C%22%5Cn%20%20%20%20%20%20%20%20%3Akey%3D%5C%22index%5C%22%5Cn%20%20%20%20%20%20%20%20class%3D%5C%22list%5C%22%5Cn%20%20%20%20%20%20%20%20%3Aclass%3D%5C%22%7B%20actived%3A%20index%20%3D%3D%3D%20curComponentIndex%20%7D%5C%22%5Cn%20%20%20%20%20%20%20%20%40click%3D%5C%22onClick(index)%5C%22%5Cn%20%20%20%20%3E%5Cn%20%20%20%20%20%20%20%20%3Cspan%20class%3D%5C%22iconfont%5C%22%20%3Aclass%3D%5C%22'icon-'%20%2B%20getComponent(index).icon%5C%22%3E%3C%2Fspan%3E%5Cn%20%20%20%20%20%20%20%20%3Cspan%3E%7B%7B%20getComponent(index).label%20%7D%7D%3C%2Fspan%3E%5Cn%20%20%20%20%3C%2Fdiv%3E%5Cn%3C%2Fdiv%3E%22%2C%22heightLimit%22%3Atrue%2C%22margin%22%3Atrue%2C%22id%22%3A%22y1xI8%22%7D"></div><div>但是有一点要注意,在组件数据的数组里,越靠后的组件层级越高。所以不对数组的数据索引做处理的话,用户看到的场景是这样的(<strong>假设添加组件的顺序为文本、按钮、图片</strong>):</div><div><span data-card-type="inline" data-ready-card="image" data-card-value="data:%7B%22src%22%3A%22https%3A%2F%2Fucc.alicdn.com%2Fpic%2Fdeveloper-ecology%2Fdxrdxmxsuvtcc_467efda9ab09447c9b7f150f7ea09766.png%22%2C%22originWidth%22%3A583%2C%22originHeight%22%3A674%2C%22size%22%3A0%2C%22display%22%3A%22inline%22%2C%22align%22%3A%22left%22%2C%22linkTarget%22%3A%22_blank%22%2C%22status%22%3A%22done%22%2C%22style%22%3A%22none%22%2C%22search%22%3A%22%22%2C%22margin%22%3A%7B%22top%22%3Afalse%2C%22bottom%22%3Afalse%7D%2C%22width%22%3A583%2C%22height%22%3A674%7D"></span></div><div>从用户的角度来看,层级最高的图片,在实时列表里排在最后。这跟我们平时的认知不太一样。所以,我们需要对组件数据做个 <code>reverse()</code> 翻转一下。譬如文字组件的索引为 0,层级最低,它应该显示在底部。那么每次在实时列表展示时,我们可以通过下面的代码转换一下,得到翻转后的索引,然后再渲染,这样的排序看起来就比较舒服了。</div><div data-card-type="block" data-ready-card="codeblock" data-card-value="data:%7B%22mode%22%3A%22plain%22%2C%22code%22%3A%22%3Cdiv%20class%3D%5C%22real-time-component-list%5C%22%3E%5Cn%20%20%20%20%3Cdiv%5Cn%20%20%20%20%20%20%20%20v-for%3D%5C%22(item%2C%20index)%20in%20componentData%5C%22%5Cn%20%20%20%20%20%20%20%20%3Akey%3D%5C%22index%5C%22%5Cn%20%20%20%20%20%20%20%20class%3D%5C%22list%5C%22%5Cn%20%20%20%20%20%20%20%20%3Aclass%3D%5C%22%7B%20actived%3A%20transformIndex(index)%20%3D%3D%3D%20curComponentIndex%20%7D%5C%22%5Cn%20%20%20%20%20%20%20%20%40click%3D%5C%22onClick(transformIndex(index))%5C%22%5Cn%20%20%20%20%3E%5Cn%20%20%20%20%20%20%20%20%3Cspan%20class%3D%5C%22iconfont%5C%22%20%3Aclass%3D%5C%22'icon-'%20%2B%20getComponent(index).icon%5C%22%3E%3C%2Fspan%3E%5Cn%20%20%20%20%20%20%20%20%3Cspan%3E%7B%7B%20getComponent(index).label%20%7D%7D%3C%2Fspan%3E%5Cn%20%20%20%20%3C%2Fdiv%3E%5Cn%3C%2Fdiv%3E%5Cn%3Cscript%3E%5Cnfunction%20getComponent(index)%20%7B%5Cn%20%20%20%20return%20componentData%5BcomponentData.length%20-%201%20-%20index%5D%5Cn%7D%5Cnfunction%20transformIndex(index)%20%7B%5Cn%20%20%20%20return%20componentData.length%20-%201%20-%20index%5Cn%7D%5Cn%3C%2Fscript%3E%22%2C%22heightLimit%22%3Atrue%2C%22margin%22%3Atrue%2C%22id%22%3A%22AeIrI%22%7D"></div><div><span data-card-type="inline" data-ready-card="image" data-card-value="data:%7B%22src%22%3A%22https%3A%2F%2Fucc.alicdn.com%2Fpic%2Fdeveloper-ecology%2Fdxrdxmxsuvtcc_8b7b88613cd046c98539e4e3f04211b1.png%22%2C%22originWidth%22%3A606%2C%22originHeight%22%3A664%2C%22size%22%3A0%2C%22display%22%3A%22inline%22%2C%22align%22%3A%22left%22%2C%22linkTarget%22%3A%22_blank%22%2C%22status%22%3A%22done%22%2C%22style%22%3A%22none%22%2C%22search%22%3A%22%22%2C%22margin%22%3A%7B%22top%22%3Afalse%2C%22bottom%22%3Afalse%7D%2C%22width%22%3A606%2C%22height%22%3A664%7D"></span></div><div>经过转换后,层级最高的图片在实时列表里排在最上面,完美!</div><h2 id="item-7">总结</h2><div>至此,可视化拖拽系列的第四篇文章已经结束了,距离上一篇系列文章的发布时间(2021年02月15日)已经有一年多了。没想到这个项目这么受欢迎,在短短一年的时间里获得了很多网友的认可。所以希望本系列的第四篇文章还是能像之前一样,对大家有帮助,再次感谢!</div><div><strong>最后</strong>,毛遂自荐一下自己,本人五年+前端,有基础架构和带团队的经验。有没有大佬有北京、天津的前端岗位推荐。如果有,请在评论区留言,或者私信帮忙内推一下,感谢!</div>