Drag&Drop 拖放API簡介以及在React中的實踐

最近有個需求,須要產品導航欄支持拖放。
雖然開源社區已有很多成熟的拖放庫,但考慮到代碼可控性和可定製性,仍是本身寫吧。css

選型

關於選型,前端實現拖放功能,無外乎幾種:
一、經過樣式佈局+鼠標事件,採用此方案的插件如:@shopify/draggable
二、Canvas繪製,插件如:konva
三、Drag&Drop接口,插件如:dragulahtml

通過一番研究,最終選擇了原生Drag&Drop的方案,緣由以下:
一、原生拖放事件,順應JS語言發展趨勢;
二、兼容性符合項目要求;
三、在Can I use...中有以下描述:
前端

插圖-1
最少的代碼,最方便的方法,就是它了。

事件

一個拖放行爲,天然牽涉到兩部分元素,即拖動元素和釋放區域元素。
與之相關的事件總共有8個,其中綁定在拖動元素的事件有三個:dragdragstartdragend
剩下5個事件綁定在釋放區域元素上:dragenterdragoverdragleavedragexitdrop
具體定義能夠參考mdnreact

定義可拖動元素

瀏覽器中,有三種元素,默認是能夠被拖動的,它們是:
一、被選中後的文本;
二、圖片;
三、連接
其餘元素要轉成可拖動元素,必須添加draggable="true",如:git

<div draggable="true"></div>
複製代碼

注意:這裏不能略寫,如寫成:github

<div draggable></div>
複製代碼

是無效的。瀏覽器

定義可被釋放區域

要使一塊元素可被釋放,首先須要綁定dragenterdragover事件,而後阻止事件,以下:app

<div ondragover="return false">
<div ondragover="event.preventDefault()"> 複製代碼

由於,這兩個事件的默認行爲就是「不觸發」drop事件,因此要定義成可被釋放區域,就反其道而行之便可。框架

DataTransfer對象

一個完整的拖放操做,除了拖動一個元素,在指定區域釋放以外,還有最重要的一步,就是將元素攜帶的信息在被釋放區域中展現。
好比,拖放一張圖片,本質上就是獲取到被拖動的圖片src屬性值,並在釋放時,在釋放區域展現一張相同src的圖片。
而這個信息,就存儲在DataTransfer對象中。
對於非默承認拖放元素來講,其包含的信息須要在dragstart事件中設置,使用DataTransfer.setData(),如:dom

dragItem.ondragstart = e => {
  e.dataTransfer.setData('text/plain', 'drag info');
}
複製代碼

若是但願拖動時,展現自定義的圖片,還能夠調用dataTransfer.setDragImage,如:

dragItem1.ondragstart = e => {
  const img = new Image(); 
  img.src = 'img_url.jpg'; 
  e.dataTransfer.setDragImage(img, 0, 0);
}
複製代碼

drop事件中,能夠取得拖放元素的信息,並將指定信息經過dom操做,展現在特定區域,如:

dropArea.ondrop = e => {
  e.preventDefault();
  const data = event.dataTransfer.getData("text/plain");
  const div = document.createElement("div");
  div.textContent = data;
  e.target.appendChild(div);
}
複製代碼

在DataTransfer對象還有一對屬性,用來確保釋放區域只能釋放特定類型的拖拽元素,即dropEffecteffectAllowed
effectAllowed只能在dragstart事件中設置,在dragenterdragover事件中,須要設置dropEffect的值與effectAllowed一致,才能觸發drop事件。如:

dragItem.ondragstart = e => {
  e.dataTransfer.effectAllowed = "move";
}

dropArea.ondragover = e => {
  e.preventDefault();
  e.dataTransfer.dropEffect = "move";
}
複製代碼

其餘屬性及方法,詳細能夠查看mdn

跨終端能力

跨終端能力是drag&drop最大的特色。
最多見的跨終端需求,就是從用戶的本地拖放文件到瀏覽器中指定區域實現上傳功能。
在指定區域的drop事件中,經過DataTransfer對象的files屬性,便可得到文件列表信息,如:

dropArea.ondrop = e => {
  e.preventDefault();
  const files = e.dataTransfer.files;
  if (files.length) {
    Array.prototype.forEach.call(files, f => {
      console.log(f.name); //打印文件名
    });
  }
}
複製代碼

在React中實踐

在React項目中使用drag&drop,依然遵循React數據驅動的原則,即事件->數據->DOM更新
因此,像以前提到的,經過DataTransfer對象傳遞數據的方式,在React項目中,能夠改成操做組件對象屬性,保證數據流的清晰。
但除此以外,在實際實踐中,仍是遇到了一些問題,須要特殊處理。具體以下:

一、必須保留dataTransfer.setData

起初,爲保證數據流清晰,在React組件中,綁定onDragStart,僅負責監聽事件,數據的變更和傳遞所有修改組件屬性,可是會遇到Firefox瀏覽器沒法拖放的兼容問題。經查發現,在Firefox中,可拖放元素必須知足:
一、添加draggable="true"
二、綁定事件dragstart
三、在dragstart中,dataTransfer.setData設置數據
因此,即便e.dataTransfer.setData('text', '');設置空字符串,也必須添加上這一條。

二、防止跨終端拖拽或不合法拖拽

drop&drag跨終端能力有時也會成爲干擾。在項目中,會發現,若是沒有作判斷,同一個頁面同時打開兩個瀏覽器tab,其拖放元素能夠跨tab拖動,可能會形成意外BUG。爲此,須要增長判斷。
一種方式,在組件實例構建時,生成一個隨機字符,藉助dataTransfer.setData,爲拖放元素打上標記。同時,在drop事件中執行判斷。
固然,若是拖放元素和釋放區域分屬不一樣組件,則須要在他們的父組件中,生成隨機字符,以props形式,傳遞到兩個子組件。

三、防止Firefox自動打開新頁面

在上述提到的爲拖放元素打標籤中,起初採用的是這樣的寫法:

e.dataTransfer.setData('text', uniqDataTransferTag);
複製代碼

結果在Firefox中,每次drop事件觸發時,瀏覽器會自動打開新tab並搜索uniqDataTransferTag(隨機字符)
根據官方解釋,須要在drop事件中調用e.preventDefault(),同時阻止冒泡e.stopPropagation(),但通過嘗試,依然不生效。初步判斷,可能與React的SyntheticEvent機制有關。因而只好曲線救國,改成設置自定義的MIME type,如:

e.dataTransfer.setData('ucloud_drag_tag', uniqDataTransferTag);
複製代碼

四、節流與避免event被回收

在項目中,須要在onDragOver中,判斷被拖放元素當前位置,並執行DOM操做。
根據定義,dragover事件會在被拖放元素拖到釋放區域上時,每幾百毫秒觸發一次,顯然不作任何處理會很是影響性能。這裏,天然想到採用節流throttle方式優化。
因爲節流是異步操做,而根據React的SyntheticEvent,event對象會在當前事件循環結束後移除,除非調用e.persist(),才能在異步操做中訪問到。

五、HACK拖放元素拖動過程當中,實現「被拖走」的視覺效果

根據設計師要求,項目中但願實現元素拖動開始後要被拖走,以下圖:

插圖-2
但默認的拖放效果,實際上是這樣:
插圖-3
很惋惜,官方並無提供對被拖放元素拖動開始後設置效果的接口。通過嘗試,找到一個經過樣式HACK方法,以下:
一、新增一個 css class,包含樣式:

transform: translateX(-9999px);
複製代碼

二、對被拖放元素添加樣式:

transition: transform 0.1s;
複製代碼

3。在拖動開始後,添加上述第一步的css class

六、實現長按元素激活拖放效果

根據交互設計,須要實現長按元素必定時長後才能夠觸發拖拽。
起初,採用的方案是,綁定鼠標事件mousedown,觸發setTimeout,達到固定時長後觸發state更新,改變拖放元素的draggable值。但實際測試中發現,這種方法存在必定的失敗率,即明明已經達到了長按的時長,依然不能拖放。並且,在Firefox中這個問題更加明顯。
推測,多是draggable的更新偶爾會晚於dragstart事件,致使拖放失敗。
因而轉變思路,增設組件的屬性做爲判斷標誌,在mousedown事件中更新判斷標誌,而draggable始終設爲true。以下:

// mousedown事件處理函數
handleLongPress = e => {
    this.resetDragTimer(); // 清除定時器

    return (this.triggerDragTimer = setTimeout(() => {
      this.isMenuDraggable = true; // 判斷標誌
    }, this.triggerDragInterval));
};

// dragstart事件處理函數
handleDragStart = e => {
    if (!this.isMenuDraggable) {
        e.preventDefault();
    } else {
      ...
    }
};
複製代碼

總結

Drag&Drop做爲原生拖放API,能夠用最少代碼實現拖放,看似「簡單」,實際並不是如此。在實踐中,仍是須要對官方接口定義,以及各瀏覽器差別有足夠了解,才能避免各類未知錯誤。而在React這類數據驅動的框架中運用時,如何處理事件監聽,同時又不打亂組件的數據流,仍是須要好好設計一番。

相關文章
相關標籤/搜索