前端拖拽 - 低代碼、可視化平臺(tái)的基石

HTML5 Drag and Drop 接口
html5中提供了一系列Drag and Drop 接口,主要包括四部分:DragEvent,DataTansfer,DataTransferItem 和DataTransferItemList。

DragEvent
源元素和目標(biāo)元素

源元素: 即被拖拽的元素。

目標(biāo)元素: 即合法的可釋放元素。

每個(gè)事件的事件主體都是兩者之一。

拖拽事件
事件    事件處理程序    事件主體    觸發(fā)時(shí)機(jī)
dragstart    ondragstart    源元素    當(dāng)源元素開始被拖拽。
drag    ondrag    源元素    當(dāng)源元素被拖拽(持續(xù)觸發(fā))。
dragend    ondragend    源元素    當(dāng)源元素拖拽結(jié)束(鼠標(biāo)釋放或按下esc鍵)
dragenter    ondragenter    目標(biāo)元素    當(dāng)被拖拽元素進(jìn)入該元素。
dragover    ondragover    目標(biāo)元素    當(dāng)被拖拽元素停留在該元素(持續(xù)觸發(fā))。
dragleave    ondragleave    目標(biāo)元素    當(dāng)被拖拽元素離開該元素。
drop    ondrop    目標(biāo)元素    當(dāng)拖拽事件在合法的目標(biāo)元素上釋放。
觸發(fā)順序及次數(shù)
我們綁定相關(guān)的事件,拖放一次來查看相關(guān)事件的觸發(fā)情況。



我們讓相應(yīng)事件處理程序打印事件名稱及事件觸發(fā)的主體是誰,下面截取部分展示。


我們可以看到對于被拖拽元素,事件觸發(fā)順序是   dragstart->drag->dragend;對于目標(biāo)元素,事件觸發(fā)的順序是  dragenter->dragover->drop/dropleave。

其中drag和dragover會(huì)分別在源元素和目標(biāo)元素反復(fù)觸發(fā)。整個(gè)流程一定是dragstart第一個(gè)觸發(fā),dragend最后一個(gè)觸發(fā)。

這里還有一個(gè)注意的點(diǎn),如果某個(gè)元素同時(shí)設(shè)置了dragover和drop的監(jiān)聽,那么必須阻止dragover的默認(rèn)行為,否則drop將不會(huì)被觸發(fā)。


DataTansfer
我們先用一張圖來直觀的感受一下:


我們可以看到,DataTransfer如同它的名字,作用就是在拖放過程中對數(shù)據(jù)進(jìn)行傳輸,其中setData用來存放數(shù)據(jù),getData用來獲取數(shù)據(jù),出于安全的考量,數(shù)據(jù)只能在drop時(shí)獲取,而effectAllowed和dropEffect則影響鼠標(biāo)展示的樣式,下面我們用一個(gè)例子來進(jìn)行展示:

sourceElem.addEventListener('dragstart', (event) => {
    event.dataTransfer.effectAllowed = 'move';
    event.dataTransfer.setData('text/plain', '放進(jìn)來了');
});
targetElem.addEventListener('dragover', (event) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
});
targetElem.addEventListener('drop', (event) => {
    event.target.innerHTML = event.dataTransfer.getData('text/plain');
});

可以看到在藍(lán)色方塊設(shè)置的數(shù)據(jù)被成功取得了。

DataTransferItemList
屬性
length: 列表中拖動(dòng)項(xiàng)的數(shù)量。

方法
add(): 向拖動(dòng)項(xiàng)列表中添加新項(xiàng) (File對象或String),該方法返回一個(gè) DataTransferItem) 對象。

remove(): 根據(jù)索引刪除拖動(dòng)項(xiàng)列表中的對象。

clear(): 清空拖動(dòng)項(xiàng)列表。

DataTransferItem(): 取值方法:返回給定下標(biāo)的DataTransferItem對象.

DataTransferItem
屬性
kind: 拖拽項(xiàng)的種類,string 或是 file。

type: 拖拽項(xiàng)的類型,一般是一個(gè)MIME 類型。






方法
getAsString: 使用拖拽項(xiàng)的字符串作為參數(shù)執(zhí)行指定回調(diào)函數(shù)。

getAsFile: 返回一個(gè)關(guān)聯(lián)拖拽項(xiàng)的 File 對象 (當(dāng)拖拽項(xiàng)不是一個(gè)文件時(shí)返回 null)。

實(shí)踐
學(xué)習(xí)了上面的基礎(chǔ)知識(shí),我們從幾個(gè)常見的應(yīng)用場景入手,來實(shí)踐上面的知識(shí)

可放置組件
知道上面幾個(gè)事件后,我們來完成一個(gè)簡單可放置組件,為了方便大家理解,這里不使用任何框架,以免增加不會(huì)框架同學(xué)的學(xué)習(xí)成本。

想讓組件可拖行,那么就要可以改變它的位置,有兩種思路:

pos:abs通過top/left等直接改變元素的位置
使用css的transform屬性中的translate對元素的位置進(jìn)行改變
我推薦第二種,首先translate是基于本身的移動(dòng),因此自身的坐標(biāo)就作為原點(diǎn)(0,0),但是第一種,元素本身的top/left等可能并不為0,計(jì)算起來比較復(fù)雜。

其次,第一種是通過cpu去計(jì)算,而第二種是通過gpu去計(jì)算,并且會(huì)提升到一個(gè)新的層,這樣做非常有利于頁面的性能。原因是 Chrome 這樣將 DOM 轉(zhuǎn)變成一個(gè)屏幕圖像:

獲取 DOM 并將其分割為多個(gè)層
將層作為紋理上傳至 GPU
復(fù)合多個(gè)層來生成最終的屏幕圖像。
但更新的幀可以走捷徑,不必經(jīng)歷所有過程:

如果某些特定 CSS 屬性變化,并不需要發(fā)生重繪。Chrome 可以使用早已作為紋理而存在于 GPU 中的層來重新復(fù)合,但會(huì)使用不同的復(fù)合屬性(例如,出現(xiàn)在不同的位置,擁有不同的透明度等等)。

如果圖層中某個(gè)元素需要重繪,那么整個(gè)圖層都需要重繪 。所以提升為一個(gè)新的層,可以減少重繪的次數(shù)。因?yàn)橹桓淖兾恢茫钥梢詮?fù)用紋理,提高性能。

有了思路那么我們就開始吧!

首先我們要知道這次拖拽的向量是怎樣的,因?yàn)镈ragEvent繼承自MouseEvent ,所以我們可以通過MouseEvent接口的offsetX屬性和offsetY屬性獲取鼠標(biāo)現(xiàn)在相對于該物體的位置差。

而transform設(shè)置多個(gè)屬性值,效果就可以疊加,所以我們要獲得之前的移動(dòng)效果,再加上現(xiàn)在的移動(dòng)效果即可,之前的移動(dòng)效果可以通過window.getComputedStyle(e.target).transform獲得。

sourceElem.addEventListener('dragend', (e) => {
    const startPosition = window.getComputedStyle(e.target).transform;
    e.target.style.transform = `${startPosition} translate(${e.offsetX}px, ${e.offsetY}px)`;
}, true);
我們給要拖拽的元素加上這段處理程序似乎就大功告成了。



但是實(shí)際使用時(shí),這個(gè)元素并沒有停在預(yù)覽的位置,而是左上角移動(dòng)到鼠標(biāo)的位置,顯然不符合預(yù)期,相信大家都能猜到,我們少考慮了鼠標(biāo)在元素的位置,而鼠標(biāo)初始的位置同樣可以通過MouseEvent接口的offsetX屬性和offsetY屬性獲?。╠ragstart)。改善如下:

function enableDrag(element) {
    let mouseDiff = null;
    element.addEventListener('dragstart', (e) => {
        //初始時(shí)鼠標(biāo)與元素的位置差
        mouseDiff = `translate(${-e.offsetX}px, ${-e.offsetY}px)`
    }, true);
    element.addEventListener('dragend', (e) => {
        //開始時(shí)元素的位置
        const startPosition = window.getComputedStyle(e.target).transform;
        //鼠標(biāo)移動(dòng)的位置
        const mouseMove = `translate(${e.offsetX}px, ${e.offsetY}px)`;
        e.target.style.transform = `${mouseDiff} ${startPosition} ${mouseMove}`;
    }, true);
}
enableDrag(souceElement);

圖的連線
節(jié)點(diǎn)使用DOM渲染,連線我們使用SVG來渲染,框架使用React,但除了state盡量使用較少的框架相關(guān)的,以防非React技術(shù)棧的同學(xué)看不懂。

首先我們要先組織我們的state,作為一個(gè)圖,顯然應(yīng)該由nodes和edges兩部分組成,我們都使用數(shù)組存儲(chǔ),我們給每個(gè)node一個(gè)唯一的id,使用Map去映射id與對應(yīng)的positon 形如 [x,y]的關(guān)系,而edge有源端與終端的id,通過id去獲得對應(yīng)的坐標(biāo)。

我們先假設(shè)節(jié)點(diǎn)可以完成所有功能了,只考慮連線,可以定義如下的組件

const Edge = ({nodes:[sourceNode,targetNode]}) =>(
    <svg key={sourceNode.id + targetNode.id||''}
        style={{position:'absolute',
        overflow:'visible',
        zIndex:'-1',
        transform:'translate(15px,15px)'}}>
        <path d={`M ${sourceNode.position[0]} ${sourceNode.position[1]}
            C
            ${(targetNode.position[0]  + sourceNode.position[0])/2} ${sourceNode.position[1]}
            ${(targetNode.position[0]  + sourceNode.position[0])/2} ${targetNode.position[1]}
            ${targetNode.position[0]} ${targetNode.position[1]} `}
            strokeWidth={6}
            stroke={'red'}
            fill='none'
        ></path>
    </svg>
)
首先我們應(yīng)該從什么時(shí)候生成一個(gè)連線呢,顯然是dragstart,但這時(shí)還沒有對應(yīng)的終端,因此不應(yīng)該通過加入edges來循環(huán)渲染,而是單獨(dú)渲染一個(gè)出來。在dragstart我們設(shè)置一個(gè)虛擬節(jié)點(diǎn)temNode,并記錄開始節(jié)點(diǎn)的id。

并如果有temNode則渲染一條預(yù)覽的edge。

temNode && (<Edge nodes = {[sourceNode,temNode]}></Edge>)

然后加入這條edge后,我們刪除虛擬節(jié)點(diǎn),變?yōu)檠h(huán)渲染來展示所有的邊。

edges.map(([sourceId,targetId])=>{
    const sourceNode = getNode(sourceId);
    const targetNode = getNode(targetId);
    return (
        <Edge nodes = {[sourceNode,targetNode]}></Edge>);
})
那么我們只考慮邊的展示了,節(jié)點(diǎn)的功能應(yīng)該如何完善呢?

首先是dragstart,我們要設(shè)置起始節(jié)點(diǎn)和虛擬節(jié)點(diǎn)

onDragStart={()=>{
    setStartNodeId(uid);
    setTemNode({position:[x,y]})
}}
然后既然邊可以跟著動(dòng),我們必然要在drag中動(dòng)態(tài)的改變虛擬節(jié)點(diǎn)的位置

onDrag={(event)=>{
    position=[x+event.nativeEvent.offsetX,y+event.nativeEvent.offsetY];
    setTemNode({position})
}}
然后drop時(shí),我們加入一條新的邊

onDrop={
    (event)=>{
        event.preventDefault();
        setEdges(edges.concat([[startNodeId,uid]]))
    }
}
最重要的是,不論在哪里事件結(jié)束了,要?jiǎng)h除虛擬節(jié)點(diǎn)

onDragEnd={
    ()=>{
        setTemNode(null);
    }
}
下面是最終成果:



參考
HTML Drag and Drop API - Web APIs | MDN (mozilla.org)

作者:灰兔呀

https://juejin.cn/post/7075918201359433758

作者:灰兔呀


歡迎關(guān)注微信公眾號(hào) :前端開發(fā)愛好者


添加好友備注【進(jìn)階學(xué)習(xí)】拉你進(jìn)技術(shù)交流群