React無門檻實現拖拽佈局、表單設計器

愚坤發表於2019-10-20

現在有很多優秀的拖拽佈局工具,表單設計器layui拖拽佈局, Vue-Layout

我們最近也實現了類似的功能,廢話不多說,先把預覽貼出來(不知道為什麼掘金現在圖片不支援gif了,還要自己上傳到圖床)。

React無門檻實現拖拽佈局、表單設計器
React無門檻實現拖拽佈局、表單設計器
React無門檻實現拖拽佈局、表單設計器

在實現這個的功能的過程中,也走了一點彎路,我們內部1.0版本的時候,使用的是sortablejs,由於程式碼寫的比較混亂,拖拽功能經常出現卡死的現象,以為是sortablejs的問題,然後又換成大名鼎鼎的React Dnd,和Redux是同一個作者,但是Dnd並不是太符合我們的需求,拖拽的API確實很強大,但是排序、跨級拖拽等好多功能都要自己手動實現,在實現完跨級拖拽以後,老大讓我換成了sortablejs

拖拽工具:sortablejsReact Dnd

我們還是先說下思路,還有我們在1.0裡給自己挖的坑,你們也要小心哈?。

如果有過樹元件開發經驗的小夥伴,應該對遞迴很熟悉了,左側的頁面結構要用到,右側的渲染也要用到,整體來說,左側的元件樹和右側的畫布區,就是兩個遞迴函式。

頁面即陣列,元件即物件

我們要生成頁面,肯定不是隻看看頁面長什麼樣子,好不好看,而是要把資料儲存起來,生成我們想要的格式,首先就要面臨的問題是,這個資料應該長什麼樣子,都有哪些欄位,分別幹什麼用。

頁面即陣列很好理解,一個頁面上可以有多個元件,而且有順序,用陣列就比較方便了,如果有子元素怎麼辦,不管vue還是react,UI框裡都會有Tree元件,資料格式都是使用children向下巢狀資料組,這點好理解吧?

舉個例子,iview的tree元件文件

React無門檻實現拖拽佈局、表單設計器

子元素和我們有什麼關係呢,我們看表單設計器layui拖拽佈局裡,都有容器元件,什麼意思呢,就是可以在這個元件下繼續拖拽放入元件,如果資料無限的向下延伸,那就需要children幫忙向下無限巢狀。

React無門檻實現拖拽佈局、表單設計器

React無門檻實現拖拽佈局、表單設計器

巢狀的問題解決了,元件怎麼渲染呢,我們先不考慮拖拽的問題,單單一個資料物件渲染成元件,怎麼實現呢? 先大致想一下,我們如果使用Ant Design的元件,我們得知道元件的名稱吧?得有自己props吧? 暫定兩個欄位nameattr,包括上面提到的children欄位,我們先簡單的做個demo,用ant的模板。

import React, { Component } from 'react';
import { Rate,Input,DatePicker } from 'antd';
const { MonthPicker, RangePicker, WeekPicker } = DatePicker;

const GlobalComponent = {
    Rate,
    Input,
    MonthPicker,
    RangePicker,
    WeekPicker,
}


class EditPage extends Component {

    render() {
        
        // 測試資料
        const Data = [
            {
                name: 'Input',
                attr: {
                    size:'large',
                    value:'第一個'
                }
            },
            {
                name: 'Input',
                attr: {
                    size:'default',
                    value:'第二個'
                }
            },
            {
                name: 'Input',
                attr: {
                    size:'small',
                    value:'第三個'
                }
            },
            {
                name: 'Containers',
                attr: {
                    style:{
                        border:'1px solid red'
                    }
                },
                children:[
                    {
                        name: 'Input',
                        attr: {
                            size:'small',
                            value:'巢狀的input'
                        }
                    },
                    {
                        name: 'Rate',
                        attr: {
                            size:'small',
                            value:'巢狀的input'
                        }
                    },
                    {
                        name: 'MonthPicker',
                        attr: {}
                    },
                    {
                        name: 'RangePicker',
                        attr: {}
                    },
                    {
                        name: 'WeekPicker',
                        attr: {}
                    },
                ]

            },
        ];
        
        // 遞迴函式
        const loop = (arr) => (
            arr.map(item => {
                if(item.children){
                    return <div {...item.attr} >{loop(item.children)}</div>
                }
                const ComponentInfo = GlobalComponent[item.name]
                return <ComponentInfo {...item.attr} />
            })
        );

        return (
            <>
                {loop(Data)}
            </>
        );
    }
}

export default EditPage;

複製程式碼

頁面已經渲染出來了,怎麼樣 很簡單吧? 接下來 我們一起來實現拖拽吧

React無門檻實現拖拽佈局、表單設計器

拖拽的實現

資料格式我們有了,渲染也實現了,剩下的就是拖拽了,我們先了解一下了sortablejs這個外掛,官方有提供react版的元件react-sortablejs

安裝依賴,在頁面中引入元件,不多說,看react-sortablejs文件

接下來先說一下sortablejs提供給我們什麼功能

  1. 如果從容器A拖拽到容器B,兩個容器group引數的name保持一致才能實現相互拖拽。
  2. 容器是否可移入和移出,是在group中配置pullput屬性。
  3. 容器有兩個監聽事件,一個是移入的onAdd方法,一個是更新的onUpdate方法
  4. onAddonUpdate只能監聽到拖拽元素的data-id屬性

我們怎麼藉助這些功能實現?

  1. 元件列表,就是左側供我們拖拽的源元件列表,不能移出,並且data-id需要為元件名稱,才能告知右側的容器拖拽進入的是什麼元件。
  2. 右側容器需要巢狀,遞迴展示,有子元素時要展示容器而不是元件。
  3. 右側的容器要可移入移出,方便跨容器拖拽。
  4. 為了把新增的元件資料放進右側對應的位置,右側容器的data-id修改為下標的路徑2-3-2這樣的形式,對應根陣列的第2個元素的第3個子元素的第2個子元素。

好了,現在先把供我們拖拽的源元件列表寫出來

然後把右側的容器改造一下,如果有子元素則展示一個新的容器,並且加上add的監聽方法。

React無門檻實現拖拽佈局、表單設計器
需要注意的是,跨級拖拽的時候觸發onAdd,需要判斷一下進入的data-id到底是下標還是元件,如果為元件直接新增,如果為下標,則對比一下新增和刪除的路徑,先操作靠下方的路徑,再操作靠上的路徑

import React, { Component } from 'react';
import { Rate,Input,DatePicker,Tag } from 'antd';
import Sortable from 'react-sortablejs';
import uniqueId from 'lodash/uniqueId';
const { MonthPicker, RangePicker, WeekPicker } = DatePicker;
import { indexToArray, getItem, setInfo, isPath, getCloneItem, itemRemove, itemAdd } from './utils';
import find from 'find-process';
const GlobalComponent = {
    Rate,
    Input,
    MonthPicker,
    RangePicker,
    WeekPicker,
}


const soundData = [
    {
        name: 'MonthPicker',
        attr: {}
    },
    {
        name: 'RangePicker',
        attr: {}
    },
    {
        name: 'WeekPicker',
        attr: {}
    },
    {
        name: 'Input',
        attr: {
            size:'large',
            value:'第一個'
        }
    },
    {
        name: 'Containers',
        attr: {
            style:{
                border:'1px solid red'
            }
        },
    }
]

class EditPage extends Component {

    constructor(props) {
        super(props);
        this.state = {
            Data:[{
                name: 'Input',
                attr: {
                    size:'large',
                    value:'第一個'
                } 
            }],
        };
    }

     // 拖拽的新增方法
     sortableAdd = evt => {
        // 元件名或路徑
        const nameOrIndex = evt.clone.getAttribute('data-id');
        // 父節點路徑
        const parentPath = evt.path[1].getAttribute('data-id');
        // 拖拽元素的目標路徑
        const { newIndex } = evt;
        // 新路徑 為根節點時直接使用index
        const newPath = parentPath ? `${parentPath}-${newIndex}` : newIndex;
        // 判斷是否為路徑 路徑執行移動,非路徑為新增
        if (isPath(nameOrIndex)) {
            // 舊的路徑index
            const oldIndex = nameOrIndex;
            // 克隆要移動的元素
            const dragItem = getCloneItem(oldIndex, this.state.Data)
            // 比較路徑的上下位置 先執行靠下的資料 再執行考上資料
            if (indexToArray(oldIndex) > indexToArray(newPath)) {
                // 刪除元素 獲得新資料
                let newTreeData = itemRemove(oldIndex, this.state.Data);
                // 新增拖拽元素
                newTreeData = itemAdd(newPath, newTreeData, dragItem)
                // 更新檢視
                this.setState({Data:newTreeData})
                return
            }
            // 新增拖拽元素
            let newData = itemAdd(newPath, this.state.Data, dragItem)
            // 刪除元素 獲得新資料
            newData = itemRemove(oldIndex, newData);

            this.setState({Data:newData})
            return
        }

        // 新增流程 建立元素 => 插入元素 => 更新檢視
        const id = nameOrIndex
        
        const newItem = _.cloneDeep(soundData.find(item => (item.name === id)))
        
        // 為容器或者彈框時增加子元素
        if ( newItem.name === 'Containers') {
            const ComponentsInfo = _.cloneDeep(GlobalComponent[newItem.name])
            // 判斷是否包含預設資料
            newItem.children = []
        }
        
        let Data = itemAdd(newPath, this.state.Data, newItem)
        
        this.setState({Data})
    }

    render() {
        
        // 遞迴函式
        const loop = (arr,index) => (
            arr.map((item,i) => {
                const indexs = index === '' ? String(i) : `${index}-${i}`;
                if(item.children){
                    return <div {...item.attr} 
                        data-id={indexs}
                    >
                        <Sortable
                            key={uniqueId()}
                            style={{
                                minHeight:100,
                                margin:10,
                            }}
                            ref={c => c && (this.sortable = c.sortable)}
                            options={{
                                ...sortableOption,
                                // onUpdate: evt => (this.sortableUpdate(evt)),
                                onAdd: evt => (this.sortableAdd(evt)),
                            }}
                        >
                            {loop(item.children,indexs)}
                        </Sortable>
                    </div>
                }
                const ComponentInfo = GlobalComponent[item.name]
                return <div data-id={indexs}><ComponentInfo {...item.attr} /></div>
            })
        )

        const sortableOption = {
            animation: 150,
            fallbackOnBody: true,
            swapThreshold: 0.65,
            group: {
                name: 'formItem',
                pull: true,
                put: true,
            },
        }

        return (
            <>  
                <h2>元件列表</h2>
                <Sortable
                    options = {{
                            group:{
                                name: 'formItem',
                                pull: 'clone',
                                put: false,
                            },
                            sort: false,
                        }}
                >
                    {
                        soundData.map(item => {
                            return <div data-id={item.name}><Tag>{item.name}</Tag></div>
                        })
                    }
                </Sortable>
                <h2>容器</h2>
                <Sortable
                    ref={c => c && (this.sortable = c.sortable)}
                    options={{
                        ...sortableOption,
                        // onUpdate: evt => (this.sortableUpdate(evt)),
                        onAdd: evt => (this.sortableAdd(evt)),
                    }}
                    key={uniqueId()}
                >
                    {loop(this.state.Data,'')}
                </Sortable>
            </>
        );
    }
}

export default EditPage;
複製程式碼

現在跨級操作和新增已經完成了,接下來我們補充一下同級交換位置的功能,我們用了immutability-helper這個工具函式,具體的自己看文件吧,只是用到了陣列換位。

import update from 'immutability-helper'

// 拖拽的排序方法
sortableUpdate = evt => {
    // 交換陣列
    const { newIndex, oldIndex } = evt;

    // 父節點路徑
    const parentPath = evt.path[1].getAttribute('data-id');

    // 父元素 根節點時直接呼叫data
    let parent = parentPath ? getItem(parentPath, this.state.Data) : this.state.Data;
    // 當前拖拽元素
    const dragItem = parent[oldIndex];
    // 更新後的父節點
    parent = update(parent, {
        $splice: [[oldIndex, 1], [newIndex, 0, dragItem]],
    });

    // 最新的資料 根節點時直接呼叫data
    const Data = parentPath ? setInfo(parentPath, this.state.Data, parent) : parent
    // 呼叫父元件更新方法
    this.setState({Data})
}
複製程式碼

現在跨級和同級的排序的功能都已經完成了,我們看看預覽圖吧。

React無門檻實現拖拽佈局、表單設計器

onUpdateonAdd的函式中,自己封裝了一些根據下標運算元組的方法,也是按照函式式的方式,每個函式返回新的結果,寫的不是特別好,多多見諒哈,剩下的刪除、選中啦,根據自己的需求增加功能就可以了,我把原始碼放在了github上,有需要的拿去吧,碼字碼到手痠,吃飯去了?。

演示地址

原始碼:https://github.com/nihaojob/DragLayout

相關文章