視覺化拖拽元件庫一些技術要點原理分析(四)
本文是視覺化拖拽系列的第四篇,比起之前的三篇文章,這篇功能點要稍微少一點,總共有五點:
- SVG 元件
- 動態屬性面板
- 資料來源(介面請求)
- 元件聯動
- 元件按需載入
如果你對我之前的系列文章不是很瞭解,建議先把這三篇文章看一遍,再來閱讀本文(否則沒有上下文,不太好理解):
另附上專案、線上 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>
從上述程式碼可以看出:
- 每一個自定義元件初始化時,都會監聽
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() <script>
第一種方式實現起來比較方便:
const name = 'v-text' // 元件名稱 const component = await import('http://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('http://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 = 'http://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日)已經有一年多了。沒想到這個專案這麼受歡迎,在短短一年的時間裡獲得了很多網友的認可。所以希望本系列的第四篇文章還是能像之前一樣,對大家有幫助,再次感謝!
最後,毛遂自薦一下自己,本人五年+前端,有基礎架構和帶團隊的經驗。有沒有大佬有北京、天津的前端崗位推薦。如果有,請在評論區留言,或者私信幫忙內推一下,感謝!
本人的社交主頁:
- 社群精選 | 不容錯過的9個冷門css屬性
- 2022最新版 Redis大廠面試題總結(附答案)
- 手寫一個mini版本的React狀態管理工具
- 【vue3原始碼】十三、認識Block
- 天翼雲全場景業務無縫替換至國產原生作業系統CTyunOS!
- JavaScript 設計模式 —— 代理模式
- MobTech簡訊驗證ApiCloud端SDK
- 以羊了個羊為例,淺談小程式抓包與響應報文修改
- 這幾種常見的 JVM 調優場景,你知道嗎?
- 聊聊如何利用管道模式來進行業務編排(下篇)
- 通用ORM的設計與實現
- 如此狂妄,自稱高效能佇列的Disruptor有啥來頭?
- 為什麼要學習GoF設計模式?
- 827. 最大人工島 : 簡單「並查集 列舉」運用題
- 介紹 Preact Signals
- 手把手教你如何使用 Timestream 實現物聯網時序資料儲存和分析
- 850. 矩形面積 II : 掃描線模板題
- Java 併發程式設計解析 | 基於JDK原始碼解析Java領域中的併發鎖,我們可以從中學習到什麼內容?
- 令人困惑的 Go time.AddDate
- 壓測平臺在全鏈路大促壓測中的實踐