視覺化拖拽元件庫一些技術要點原理分析(四)

語言: CN / TW / HK

本文是視覺化拖拽系列的第四篇,比起之前的三篇文章,這篇功能點要稍微少一點,總共有五點:

  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。

第三步,動態載入元件

動態載入元件有兩種方式:

import()
<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日)已經有一年多了。沒想到這個專案這麼受歡迎,在短短一年的時間裡獲得了很多網友的認可。所以希望本系列的第四篇文章還是能像之前一樣,對大家有幫助,再次感謝!

最後,毛遂自薦一下自己,本人五年+前端,有基礎架構和帶團隊的經驗。有沒有大佬有北京、天津的前端崗位推薦。如果有,請在評論區留言,或者私信幫忙內推一下,感謝!

本人的社交主頁: