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

譚光志發表於2020-12-24

本文主要對以下技術要點進行分析:

  1. 編輯器
  2. 自定義元件
  3. 拖拽
  4. 刪除元件、調整圖層層級
  5. 放大縮小
  6. 撤消、重做
  7. 元件屬性設定
  8. 吸附
  9. 預覽、儲存程式碼
  10. 繫結事件
  11. 繫結動畫
  12. 匯入 PSD
  13. 手機模式

為了讓本文更加容易理解,我將以上技術要點結合在一起寫了一個視覺化拖拽元件庫 DEMO:

建議結合原始碼一起閱讀,效果更好(這個 DEMO 使用的是 Vue 技術棧)。

1. 編輯器

先來看一下頁面的整體結構。

這一節要講的編輯器其實就是中間的畫布。它的作用是:當從左邊元件列表拖拽出一個元件放到畫布中時,畫布要把這個元件渲染出來。

這個編輯器的實現思路是:

  1. 用一個陣列 componentData 維護編輯器中的資料。
  2. 把元件拖拽到畫布中時,使用 push() 方法將新的元件資料新增到 componentData
  3. 編輯器使用 v-for 指令遍歷 componentData,將每個元件逐個渲染到畫布(也可以使用 JSX 語法結合 render() 方法代替)。

編輯器渲染的核心程式碼如下所示:

<component 
  v-for="item in componentData"
  :key="item.id"
  :is="item.component"
  :style="item.style"
  :propValue="item.propValue"
/>

每個元件資料大概是這樣:

{
    component: 'v-text', // 元件名稱,需要提前註冊到 Vue
    label: '文字', // 左側元件列表中顯示的名字
    propValue: '文字', // 元件所使用的值
    icon: 'el-icon-edit', // 左側元件列表中顯示的名字
    animations: [], // 動畫列表
    events: {}, // 事件列表
    style: { // 元件樣式
        width: 200,
        height: 33,
        fontSize: 14,
        fontWeight: 500,
        lineHeight: '',
        letterSpacing: 0,
        textAlign: '',
        color: '',
    },
}

在遍歷 componentData 元件資料時,主要靠 is 屬性來識別出真正要渲染的是哪個元件。

例如要渲染的元件資料是 { component: 'v-text' },則 <component :is="item.component" /> 會被轉換為 <v-text />。當然,你這個元件也要提前註冊到 Vue 中。

如果你想了解更多 is 屬性的資料,請檢視官方文件

2. 自定義元件

原則上使用第三方元件也是可以的,但建議你最好封裝一下。不管是第三方元件還是自定義元件,每個元件所需的屬性可能都不一樣,所以每個元件資料可以暴露出一個屬性 propValue 用於傳遞值。

例如 a 元件只需要一個屬性,你的 propValue 可以這樣寫:propValue: 'aaa'。如果需要多個屬性,propValue 則可以是一個物件:

propValue: {
  a: 1,
  b: 'text'
}

在這個 DEMO 元件庫中我定義了三個元件。

圖片元件 Picture

<template>
    <div style="overflow: hidden">
        <img :src="propValue">
    </div>
</template>

<script>
export default {
    props: {
        propValue: {
            type: String,
            require: true,
        },
    },
}
</script>

按鈕元件 VButton:

<template>
    <button class="v-button">{{ propValue }}</button>
</template>

<script>
export default {
    props: {
        propValue: {
            type: String,
            default: '',
        },
    },
}
</script>

文字元件 VText:

<template>
    <textarea 
        v-if="editMode == 'edit'"
        :value="propValue"
        class="text textarea"
        @input="handleInput"
        ref="v-text"
    ></textarea>
    <div v-else class="text disabled">
        <div v-for="(text, index) in propValue.split('\n')" :key="index">{{ text }}</div>
    </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
    },
    computed: mapState([
        'editMode',
    ]),
    methods: {
        handleInput(e) {
            this.$emit('input', this.element, e.target.value)
        },
    },
}
</script>

3. 拖拽

從元件列表到畫布

一個元素如果要設為可拖拽,必須給它新增一個 draggable 屬性。另外,在將元件列表中的元件拖拽到畫布中,還有兩個事件是起到關鍵作用的:

  1. dragstart 事件,在拖拽剛開始時觸發。它主要用於將拖拽的元件資訊傳遞給畫布。
  2. drop 事件,在拖拽結束時觸發。主要用於接收拖拽的元件資訊。

先來看一下左側元件列表的程式碼:

<div @dragstart="handleDragStart" class="component-list">
    <div v-for="(item, index) in componentList" :key="index" class="list" draggable :data-index="index">
        <i :class="item.icon"></i>
        <span>{{ item.label }}</span>
    </div>
</div>
handleDragStart(e) {
    e.dataTransfer.setData('index', e.target.dataset.index)
}

可以看到給列表中的每一個元件都設定了 draggable 屬性。另外,在觸發 dragstart 事件時,使用 dataTransfer.setData() 傳輸資料。再來看一下接收資料的程式碼:

<div class="content" @drop="handleDrop" @dragover="handleDragOver" @click="deselectCurComponent">
    <Editor />
</div>
handleDrop(e) {
    e.preventDefault()
    e.stopPropagation()
    const component = deepCopy(componentList[e.dataTransfer.getData('index')])
    this.$store.commit('addComponent', component)
}

觸發 drop 事件時,使用 dataTransfer.getData() 接收傳輸過來的索引資料,然後根據索引找到對應的元件資料,再新增到畫布,從而渲染元件。

元件在畫布中移動

首先需要將畫布設為相對定位 position: relative,然後將每個元件設為絕對定位 position: absolute。除了這一點外,還要通過監聽三個事件來進行移動:

  1. mousedown 事件,在元件上按下滑鼠時,記錄元件當前的位置,即 xy 座標(為了方便講解,這裡使用的座標軸,實際上 xy 對應的是 css 中的 lefttop
  2. mousemove 事件,每次滑鼠移動時,都用當前最新的 xy 座標減去最開始的 xy 座標,從而計算出移動距離,再改變元件位置。
  3. mouseup 事件,滑鼠抬起時結束移動。
handleMouseDown(e) {
    e.stopPropagation()
    this.$store.commit('setCurComponent', { component: this.element, zIndex: this.zIndex })

    const pos = { ...this.defaultStyle }
    const startY = e.clientY
    const startX = e.clientX
    // 如果直接修改屬性,值的型別會變為字串,所以要轉為數值型
    const startTop = Number(pos.top)
    const startLeft = Number(pos.left)

    const move = (moveEvent) => {
        const currX = moveEvent.clientX
        const currY = moveEvent.clientY
        pos.top = currY - startY + startTop
        pos.left = currX - startX + startLeft
        // 修改當前元件樣式
        this.$store.commit('setShapeStyle', pos)
    }

    const up = () => {
        document.removeEventListener('mousemove', move)
        document.removeEventListener('mouseup', up)
    }

    document.addEventListener('mousemove', move)
    document.addEventListener('mouseup', up)
}

4. 刪除元件、調整圖層層級

改變圖層層級

由於拖拽元件到畫布中是有先後順序的,所以可以按照資料順序來分配圖層層級。

例如畫布新增了五個元件 abcde,那它們在畫布資料中的順序為 [a, b, c, d, e],圖層層級和索引一一對應,即它們的 z-index 屬性值是 01234(後來居上)。用程式碼表示如下:

<div v-for="(item, index) in componentData" :zIndex="index"></div>

如果不瞭解 z-index 屬性的,請看一下 MDN 文件

理解了這一點之後,改變圖層層級就很容易做到了。改變圖層層級,即是改變元件資料在 componentData 陣列中的順序。例如有 [a, b, c] 三個元件,它們的圖層層級從低到高順序為 abc(索引越大,層級越高)。

如果要將 b 元件上移,只需將它和 c 調換順序即可:

const temp = componentData[1]
componentData[1] = componentData[2]
componentData[2] = temp

同理,置頂置底也是一樣,例如我要將 a 元件置頂,只需將 a 和最後一個元件調換順序即可:

const temp = componentData[0]
componentData[0] = componentData[componentData.lenght - 1]
componentData[componentData.lenght - 1] = temp

刪除元件

刪除元件非常簡單,一行程式碼搞定:componentData.splice(index, 1)

5. 放大縮小

細心的網友可能會發現,點選畫布上的元件時,元件上會出現 8 個小圓點。這 8 個小圓點就是用來放大縮小用的。實現原理如下:

1. 在每個元件外面包一層 Shape 元件,Shape 元件裡包含 8 個小圓點和一個 <slot> 插槽,用於放置元件。

<!--頁面元件列表展示-->
<Shape v-for="(item, index) in componentData"
    :defaultStyle="item.style"
    :style="getShapeStyle(item.style, index)"
    :key="item.id"
    :active="item === curComponent"
    :element="item"
    :zIndex="index"
>
    <component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    />
</Shape>

Shape 元件內部結構:

<template>
    <div class="shape" :class="{ active: this.active }" @click="selectCurComponent" @mousedown="handleMouseDown"
    @contextmenu="handleContextMenu">
        <div
            class="shape-point"
            v-for="(item, index) in (active? pointList : [])"
            @mousedown="handleMouseDownOnPoint(item)"
            :key="index"
            :style="getPointStyle(item)">
        </div>
        <slot></slot>
    </div>
</template>

2. 點選元件時,將 8 個小圓點顯示出來。

起作用的是這行程式碼 :active="item === curComponent"

3. 計算每個小圓點的位置。

先來看一下計算小圓點位置的程式碼:

const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb']

getPointStyle(point) {
    const { width, height } = this.defaultStyle
    const hasT = /t/.test(point)
    const hasB = /b/.test(point)
    const hasL = /l/.test(point)
    const hasR = /r/.test(point)
    let newLeft = 0
    let newTop = 0

    // 四個角的點
    if (point.length === 2) {
        newLeft = hasL? 0 : width
        newTop = hasT? 0 : height
    } else {
        // 上下兩點的點,寬度居中
        if (hasT || hasB) {
            newLeft = width / 2
            newTop = hasT? 0 : height
        }

        // 左右兩邊的點,高度居中
        if (hasL || hasR) {
            newLeft = hasL? 0 : width
            newTop = Math.floor(height / 2)
        }
    }

    const style = {
        marginLeft: hasR? '-4px' : '-3px',
        marginTop: '-3px',
        left: `${newLeft}px`,
        top: `${newTop}px`,
        cursor: point.split('').reverse().map(m => this.directionKey[m]).join('') + '-resize',
    }

    return style
}

計算小圓點的位置需要獲取一些資訊:

  • 元件的高度 height、寬度 width

注意,小圓點也是絕對定位的,相對於 Shape 元件。所以有四個小圓點的位置很好確定:

  1. 左上角的小圓點,座標 left: 0, top: 0
  2. 右上角的小圓點,座標 left: width, top: 0
  3. 左下角的小圓點,座標 left: 0, top: height
  4. 右下角的小圓點,座標 left: width, top: height

另外的四個小圓點需要通過計算間接算出來。例如左邊中間的小圓點,計算公式為 left: 0, top: height / 2,其他小圓點同理。

4. 點選小圓點時,可以進行放大縮小操作。

handleMouseDownOnPoint(point) {
    const downEvent = window.event
    downEvent.stopPropagation()
    downEvent.preventDefault()

    const pos = { ...this.defaultStyle }
    const height = Number(pos.height)
    const width = Number(pos.width)
    const top = Number(pos.top)
    const left = Number(pos.left)
    const startX = downEvent.clientX
    const startY = downEvent.clientY

    // 是否需要儲存快照
    let needSave = false
    const move = (moveEvent) => {
        needSave = true
        const currX = moveEvent.clientX
        const currY = moveEvent.clientY
        const disY = currY - startY
        const disX = currX - startX
        const hasT = /t/.test(point)
        const hasB = /b/.test(point)
        const hasL = /l/.test(point)
        const hasR = /r/.test(point)
        const newHeight = height + (hasT? -disY : hasB? disY : 0)
        const newWidth = width + (hasL? -disX : hasR? disX : 0)
        pos.height = newHeight > 0? newHeight : 0
        pos.width = newWidth > 0? newWidth : 0
        pos.left = left + (hasL? disX : 0)
        pos.top = top + (hasT? disY : 0)
        this.$store.commit('setShapeStyle', pos)
    }

    const up = () => {
        document.removeEventListener('mousemove', move)
        document.removeEventListener('mouseup', up)
        needSave && this.$store.commit('recordSnapshot')
    }

    document.addEventListener('mousemove', move)
    document.addEventListener('mouseup', up)
}

它的原理是這樣的:

  1. 點選小圓點時,記錄點選的座標 xy。
  2. 假設我們現在向下拖動,那麼 y 座標就會增大。
  3. 用新的 y 座標減去原來的 y 座標,就可以知道在縱軸方向的移動距離是多少。
  4. 最後再將移動距離加上原來元件的高度,就可以得出新的元件高度。
  5. 如果是正數,說明是往下拉,元件的高度在增加。如果是負數,說明是往上拉,元件的高度在減少。

6. 撤消、重做

撤銷重做的實現原理其實挺簡單的,先看一下程式碼:

snapshotData: [], // 編輯器快照資料
snapshotIndex: -1, // 快照索引
        
undo(state) {
    if (state.snapshotIndex >= 0) {
        state.snapshotIndex--
        store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
    }
},

redo(state) {
    if (state.snapshotIndex < state.snapshotData.length - 1) {
        state.snapshotIndex++
        store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
    }
},

setComponentData(state, componentData = []) {
    Vue.set(state, 'componentData', componentData)
},

recordSnapshot(state) {
    // 新增新的快照
    state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData)
    // 在 undo 過程中,新增新的快照時,要將它後面的快照清理掉
    if (state.snapshotIndex < state.snapshotData.length - 1) {
        state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
    }
},

用一個陣列來儲存編輯器的快照資料。儲存快照就是不停地執行 push() 操作,將當前的編輯器資料推入 snapshotData 陣列,並增加快照索引 snapshotIndex。目前以下幾個動作會觸發儲存快照操作:

  • 新增元件
  • 刪除元件
  • 改變圖層層級
  • 拖動元件結束時

...

撤銷

假設現在 snapshotData 儲存了 4 個快照。即 [a, b, c, d],對應的快照索引為 3。如果這時進行了撤銷操作,我們需要將快照索引減 1,然後將對應的快照資料賦值給畫布。

例如當前畫布資料是 d,進行撤銷後,索引 -1,現在畫布的資料是 c。

重做

明白了撤銷,那重做就很好理解了,就是將快照索引加 1,然後將對應的快照資料賦值給畫布。

不過還有一點要注意,就是在撤銷操作中進行了新的操作,要怎麼辦呢?有兩種解決方案:

  1. 新操作替換當前快照索引後面所有的資料。還是用剛才的資料 [a, b, c, d] 舉例,假設現在進行了兩次撤銷操作,快照索引變為 1,對應的快照資料為 b,如果這時進行了新的操作,對應的快照資料為 e。那 e 會把 cd 頂掉,現在的快照資料為 [a, b, e]
  2. 不頂掉資料,在原來的快照中新增一條記錄。用剛才的例子舉例,e 不會把 cd 頂掉,而是在 cd 之前插入,即快照資料變為 [a, b, e, c, d]

我採用的是第一種方案。

7. 吸附

什麼是吸附?就是在拖拽元件時,如果它和另一個元件的距離比較接近,就會自動吸附在一起。

吸附的程式碼大概在 300 行左右,建議自己開啟原始碼檔案看(檔案路徑:src\\components\\Editor\\MarkLine.vue)。這裡不貼程式碼了,主要說說原理是怎麼實現的。

標線

在頁面上建立 6 條線,分別是三橫三豎。這 6 條線的作用是對齊,它們什麼時候會出現呢?

  1. 上下方向的兩個元件左邊、中間、右邊對齊時會出現豎線
  2. 左右方向的兩個元件上邊、中間、下邊對齊時會出現橫線

具體的計算公式主要是根據每個元件的 xy 座標和寬度高度進行計算的。例如要判斷 ab 兩個元件的左邊是否對齊,則要知道它們每個元件的 x 座標;如果要知道它們右邊是否對齊,除了要知道 x 座標,還要知道它們各自的寬度。

// 左對齊的條件
a.x == b.x

// 右對齊的條件
a.x + a.width == b.x + b.width

在對齊的時候,顯示標線。

另外還要判斷 ab 兩個元件是否“足夠”近。如果足夠近,就吸附在一起。是否足夠近要靠一個變數來判斷:

diff: 3, // 相距 dff 畫素將自動吸附

小於等於 diff 畫素則自動吸附。

吸附

吸附效果是怎麼實現的呢?

假設現在有 ab 元件,a 元件座標 xy 都是 0,寬高都是 100。現在假設 a 元件不動,我們正在拖拽 b 元件。當把 b 元件拖到座標為 x: 0, y: 103 時,由於 103 - 100 <= 3(diff),所以可以判定它們已經接近得足夠近。這時需要手動將 b 元件的 y 座標值設為 100,這樣就將 ab 元件吸附在一起了。

優化

在拖拽時如果 6 條標線都顯示出來會不太美觀。所以我們可以做一下優化,在縱橫方向上最多隻同時顯示一條線。實現原理如下:

  1. a 元件在左邊不動,我們拖著 b 元件往 a 元件靠近。
  2. 這時它們最先對齊的是 a 的右邊和 b 的左邊,所以只需要一條線就夠了。
  3. 如果 ab 元件已經靠近,並且 b 元件繼續往左邊移動,這時就要判斷它們倆的中間是否對齊。
  4. b 元件繼續拖動,這時需要判斷 a 元件的左邊和 b 元件的右邊是否對齊,也是隻需要一條線。

可以發現,關鍵的地方是我們要知道兩個元件的方向。即 ab 兩個元件靠近,我們要知道到底 b 是在 a 的左邊還是右邊。

這一點可以通過滑鼠移動事件來判斷,之前在講解拖拽的時候說過,mousedown 事件觸發時會記錄起點座標。所以每次觸發 mousemove 事件時,用當前座標減去原來的座標,就可以判斷元件方向。例如 x 方向上,如果 b.x - a.x 的差值為正,說明是 b 在 a 右邊,否則為左邊。

// 觸發元素移動事件,用於顯示標線、吸附功能
// 後面兩個引數代表滑鼠移動方向
// currY - startY > 0 true 表示向下移動 false 表示向上移動
// currX - startX > 0 true 表示向右移動 false 表示向左移動
eventBus.$emit('move', this.$el, currY - startY > 0, currX - startX > 0)

8. 元件屬性設定

每個元件都有一些通用屬性和獨有的屬性,我們需要提供一個能顯示和修改屬性的地方。

// 每個元件資料大概是這樣
{
    component: 'v-text', // 元件名稱,需要提前註冊到 Vue
    label: '文字', // 左側元件列表中顯示的名字
    propValue: '文字', // 元件所使用的值
    icon: 'el-icon-edit', // 左側元件列表中顯示的名字
    animations: [], // 動畫列表
    events: {}, // 事件列表
    style: { // 元件樣式
        width: 200,
        height: 33,
        fontSize: 14,
        fontWeight: 500,
        lineHeight: '',
        letterSpacing: 0,
        textAlign: '',
        color: '',
    },
}

我定義了一個 AttrList 元件,用於顯示每個元件的屬性。

<template>
    <div class="attr-list">
        <el-form>
            <el-form-item v-for="(key, index) in styleKeys" :key="index" :label="map[key]">
                <el-color-picker v-if="key == 'borderColor'" v-model="curComponent.style[key]"></el-color-picker>
                <el-color-picker v-else-if="key == 'color'" v-model="curComponent.style[key]"></el-color-picker>
                <el-color-picker v-else-if="key == 'backgroundColor'" v-model="curComponent.style[key]"></el-color-picker>
                <el-select v-else-if="key == 'textAlign'" v-model="curComponent.style[key]">
                    <el-option
                        v-for="item in options"
                        :key="item.value"
                        :label="item.label"
                        :value="item.value"
                    ></el-option>
                </el-select>
                <el-input type="number" v-else v-model="curComponent.style[key]" />
            </el-form-item>
            <el-form-item label="內容" v-if="curComponent && curComponent.propValue && !excludes.includes(curComponent.component)">
                <el-input type="textarea" v-model="curComponent.propValue" />
            </el-form-item>
        </el-form>
    </div>
</template>

程式碼邏輯很簡單,就是遍歷元件的 style 物件,將每一個屬性遍歷出來。並且需要根據具體的屬性用不同的元件顯示出來,例如顏色屬性,需要用顏色選擇器顯示;數值類的屬性需要用 type=number 的 input 元件顯示等等。

為了方便使用者修改屬性值,我使用 v-model 將元件和值繫結在一起。

9. 預覽、儲存程式碼

預覽和編輯的渲染原理是一樣的,區別是不需要編輯功能。所以只需要將原先渲染元件的程式碼稍微改一下就可以了。

<!--頁面元件列表展示-->
<Shape v-for="(item, index) in componentData"
    :defaultStyle="item.style"
    :style="getShapeStyle(item.style, index)"
    :key="item.id"
    :active="item === curComponent"
    :element="item"
    :zIndex="index"
>
    <component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    />
</Shape>

經過剛才的介紹,我們知道 Shape 元件具備了拖拽、放大縮小的功能。現在只需要將 Shape 元件去掉,外面改成套一個普通的 DIV 就可以了(其實不用這個 DIV 也行,但為了繫結事件這個功能,所以需要加上)。

<!--頁面元件列表展示-->
<div v-for="(item, index) in componentData" :key="item.id">
    <component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    />
</div>

儲存程式碼的功能也特別簡單,只需要儲存畫布上的資料 componentData 即可。儲存有兩種選擇:

  1. 儲存到伺服器
  2. 本地儲存

在 DEMO 上我使用的 localStorage 儲存在本地。

10. 繫結事件

每個元件有一個 events 物件,用於儲存繫結的事件。目前我只定義了兩個事件:

  • alert 事件
  • redirect 事件
// 編輯器自定義事件
const events = {
    redirect(url) {
        if (url) {
            window.location.href = url
        }
    },

    alert(msg) {
        if (msg) {
            alert(msg)
        }
    },
}

const mixins = {
    methods: events,
}

const eventList = [
    {
        key: 'redirect',
        label: '跳轉事件',
        event: events.redirect,
        param: '',
    },
    {
        key: 'alert',
        label: 'alert 事件',
        event: events.alert,
        param: '',
    },
]

export {
    mixins,
    events,
    eventList,
}

不過不能在編輯的時候觸發,可以在預覽的時候觸發。

新增事件

通過 v-for 指令將事件列表渲染出來:

<el-tabs v-model="eventActiveName">
    <el-tab-pane v-for="item in eventList" :key="item.key" :label="item.label" :name="item.key" style="padding: 0 20px">
        <el-input v-if="item.key == 'redirect'" v-model="item.param" type="textarea" placeholder="請輸入完整的 URL" />
        <el-input v-if="item.key == 'alert'" v-model="item.param" type="textarea" placeholder="請輸入要 alert 的內容" />
        <el-button style="margin-top: 20px;" @click="addEvent(item.key, item.param)">確定</el-button>
    </el-tab-pane>
</el-tabs>

選中事件時將事件新增到元件的 events 物件。

觸發事件

預覽或真正渲染頁面時,也需要在每個元件外面套一層 DIV,這樣就可以在 DIV 上繫結一個點選事件,點選時觸發我們剛才新增的事件。

<template>
    <div @click="handleClick">
        <component
            class="conponent"
            :is="config.component"
            :style="getStyle(config.style)"
            :propValue="config.propValue"
        />
    </div>
</template>
handleClick() {
    const events = this.config.events
    // 迴圈觸發繫結的事件
    Object.keys(events).forEach(event => {
        this[event](events[event])
    })
}

11. 繫結動畫

動畫和事件的原理是一樣的,先將所有的動畫通過 v-for 指令渲染出來,然後點選動畫將對應的動畫新增到元件的 animations 陣列裡。同事件一樣,執行的時候也是遍歷元件所有的動畫並執行。

為了方便,我們使用了 animate.css 動畫庫。

// main.js
import '@/styles/animate.css'

現在我們提前定義好所有的動畫資料:

export default [
    {
        label: '進入',
        children: [
            { label: '漸顯', value: 'fadeIn' },
            { label: '向右進入', value: 'fadeInLeft' },
            { label: '向左進入', value: 'fadeInRight' },
            { label: '向上進入', value: 'fadeInUp' },
            { label: '向下進入', value: 'fadeInDown' },
            { label: '向右長距進入', value: 'fadeInLeftBig' },
            { label: '向左長距進入', value: 'fadeInRightBig' },
            { label: '向上長距進入', value: 'fadeInUpBig' },
            { label: '向下長距進入', value: 'fadeInDownBig' },
            { label: '旋轉進入', value: 'rotateIn' },
            { label: '左順時針旋轉', value: 'rotateInDownLeft' },
            { label: '右逆時針旋轉', value: 'rotateInDownRight' },
            { label: '左逆時針旋轉', value: 'rotateInUpLeft' },
            { label: '右逆時針旋轉', value: 'rotateInUpRight' },
            { label: '彈入', value: 'bounceIn' },
            { label: '向右彈入', value: 'bounceInLeft' },
            { label: '向左彈入', value: 'bounceInRight' },
            { label: '向上彈入', value: 'bounceInUp' },
            { label: '向下彈入', value: 'bounceInDown' },
            { label: '光速從右進入', value: 'lightSpeedInRight' },
            { label: '光速從左進入', value: 'lightSpeedInLeft' },
            { label: '光速從右退出', value: 'lightSpeedOutRight' },
            { label: '光速從左退出', value: 'lightSpeedOutLeft' },
            { label: 'Y軸旋轉', value: 'flip' },
            { label: '中心X軸旋轉', value: 'flipInX' },
            { label: '中心Y軸旋轉', value: 'flipInY' },
            { label: '左長半徑旋轉', value: 'rollIn' },
            { label: '由小變大進入', value: 'zoomIn' },
            { label: '左變大進入', value: 'zoomInLeft' },
            { label: '右變大進入', value: 'zoomInRight' },
            { label: '向上變大進入', value: 'zoomInUp' },
            { label: '向下變大進入', value: 'zoomInDown' },
            { label: '向右滑動展開', value: 'slideInLeft' },
            { label: '向左滑動展開', value: 'slideInRight' },
            { label: '向上滑動展開', value: 'slideInUp' },
            { label: '向下滑動展開', value: 'slideInDown' },
        ],
    },
    {
        label: '強調',
        children: [
            { label: '彈跳', value: 'bounce' },
            { label: '閃爍', value: 'flash' },
            { label: '放大縮小', value: 'pulse' },
            { label: '放大縮小彈簧', value: 'rubberBand' },
            { label: '左右晃動', value: 'headShake' },
            { label: '左右扇形搖擺', value: 'swing' },
            { label: '放大晃動縮小', value: 'tada' },
            { label: '扇形搖擺', value: 'wobble' },
            { label: '左右上下晃動', value: 'jello' },
            { label: 'Y軸旋轉', value: 'flip' },
        ],
    },
    {
        label: '退出',
        children: [
            { label: '漸隱', value: 'fadeOut' },
            { label: '向左退出', value: 'fadeOutLeft' },
            { label: '向右退出', value: 'fadeOutRight' },
            { label: '向上退出', value: 'fadeOutUp' },
            { label: '向下退出', value: 'fadeOutDown' },
            { label: '向左長距退出', value: 'fadeOutLeftBig' },
            { label: '向右長距退出', value: 'fadeOutRightBig' },
            { label: '向上長距退出', value: 'fadeOutUpBig' },
            { label: '向下長距退出', value: 'fadeOutDownBig' },
            { label: '旋轉退出', value: 'rotateOut' },
            { label: '左順時針旋轉', value: 'rotateOutDownLeft' },
            { label: '右逆時針旋轉', value: 'rotateOutDownRight' },
            { label: '左逆時針旋轉', value: 'rotateOutUpLeft' },
            { label: '右逆時針旋轉', value: 'rotateOutUpRight' },
            { label: '彈出', value: 'bounceOut' },
            { label: '向左彈出', value: 'bounceOutLeft' },
            { label: '向右彈出', value: 'bounceOutRight' },
            { label: '向上彈出', value: 'bounceOutUp' },
            { label: '向下彈出', value: 'bounceOutDown' },
            { label: '中心X軸旋轉', value: 'flipOutX' },
            { label: '中心Y軸旋轉', value: 'flipOutY' },
            { label: '左長半徑旋轉', value: 'rollOut' },
            { label: '由小變大退出', value: 'zoomOut' },
            { label: '左變大退出', value: 'zoomOutLeft' },
            { label: '右變大退出', value: 'zoomOutRight' },
            { label: '向上變大退出', value: 'zoomOutUp' },
            { label: '向下變大退出', value: 'zoomOutDown' },
            { label: '向左滑動收起', value: 'slideOutLeft' },
            { label: '向右滑動收起', value: 'slideOutRight' },
            { label: '向上滑動收起', value: 'slideOutUp' },
            { label: '向下滑動收起', value: 'slideOutDown' },
        ],
    },
]

然後用 v-for 指令渲染出來動畫列表。

新增動畫

<el-tabs v-model="animationActiveName">
    <el-tab-pane v-for="item in animationClassData" :key="item.label" :label="item.label" :name="item.label">
        <el-scrollbar class="animate-container">
            <div
                class="animate"
                v-for="(animate, index) in item.children"
                :key="index"
                @mouseover="hoverPreviewAnimate = animate.value"
                @click="addAnimation(animate)"
            >
                <div :class="[hoverPreviewAnimate === animate.value && animate.value + ' animated']">
                    {{ animate.label }}
                </div>
            </div>
        </el-scrollbar>
    </el-tab-pane>
</el-tabs>

點選動畫將呼叫 addAnimation(animate) 將動畫新增到元件的 animations 陣列。

觸發動畫

執行動畫的程式碼:

export default async function runAnimation($el, animations = []) {
    const play = (animation) => new Promise(resolve => {
        $el.classList.add(animation.value, 'animated')
        const removeAnimation = () => {
            $el.removeEventListener('animationend', removeAnimation)
            $el.removeEventListener('animationcancel', removeAnimation)
            $el.classList.remove(animation.value, 'animated')
            resolve()
        }
            
        $el.addEventListener('animationend', removeAnimation)
        $el.addEventListener('animationcancel', removeAnimation)
    })

    for (let i = 0, len = animations.length; i < len; i++) {
        await play(animations[i])
    }
}

執行動畫需要兩個引數:元件對應的 DOM 元素(在元件使用 this.$el 獲取)和它的動畫資料 animations。並且需要監聽 animationend 事件和 animationcancel 事件:一個是動畫結束時觸發,一個是動畫意外終止時觸發。

利用這一點再配合 Promise 一起使用,就可以逐個執行元件的每個動畫了。

12. 匯入 PSD

由於時間關係,這個功能我還沒做。現在簡單的描述一下怎麼做這個功能。那就是使用 psd.js 庫,它可以解析 PSD 檔案。

使用 psd 庫解析 PSD 檔案得出的資料如下:

{ children: 
   [ { type: 'group',
       visible: false,
       opacity: 1,
       blendingMode: 'normal',
       name: 'Version D',
       left: 0,
       right: 900,
       top: 0,
       bottom: 600,
       height: 600,
       width: 900,
       children: 
        [ { type: 'layer',
            visible: true,
            opacity: 1,
            blendingMode: 'normal',
            name: 'Make a change and save.',
            left: 275,
            right: 636,
            top: 435,
            bottom: 466,
            height: 31,
            width: 361,
            mask: {},
            text: 
             { value: 'Make a change and save.',
               font: 
                { name: 'HelveticaNeue-Light',
                  sizes: [ 33 ],
                  colors: [ [ 85, 96, 110, 255 ] ],
                  alignment: [ 'center' ] },
               left: 0,
               top: 0,
               right: 0,
               bottom: 0,
               transform: { xx: 1, xy: 0, yx: 0, yy: 1, tx: 456, ty: 459 } },
            image: {} } ] } ],
    document: 
       { width: 900,
         height: 600,
         resources: 
          { layerComps: 
             [ { id: 692243163, name: 'Version A', capturedInfo: 1 },
               { id: 725235304, name: 'Version B', capturedInfo: 1 },
               { id: 730932877, name: 'Version C', capturedInfo: 1 } ],
            guides: [],
            slices: [] } } }

從以上程式碼可以發現,這些資料和 css 非常像。根據這一點,只需要寫一個轉換函式,將這些資料轉換成我們元件所需的資料,就能實現 PSD 檔案轉成渲染元件的功能。目前 quark-h5luban-h5 都是這樣實現的 PSD 轉換功能。

13. 手機模式

由於畫布是可以調整大小的,我們可以使用 iphone6 的解析度來開發手機頁面。

這樣開發出來的頁面也可以在手機下正常瀏覽,但可能會有樣式偏差。因為我自定義的三個元件是沒有做適配的,如果你需要開發手機頁面,那自定義元件必須使用移動端的 UI 元件庫。或者自己開發移動端專用的自定義元件。

總結

由於 DEMO 的程式碼比較多,所以在講解每一個功能點時,我只把關鍵程式碼貼上來。所以大家會發現 DEMO 的原始碼和我貼上來的程式碼會有些區別,請不必在意。

另外,DEMO 的樣式也比較簡陋,主要是最近事情比較多,沒太多時間寫好看點,請見諒。

參考資料

相關文章