HTML5 Drag and Drop 的一些總結

YouKnowNothing發表於2018-10-08

參考

Demo

Demo Link

HTML5 Drag and Drop 的一些總結

Drag and Drop Life Cycle

HTML5 Drag and Drop 的一些總結

注 1: onDragExit (dragexit) 事件只有 Firefox 支援,忽略之

注 2: onDragLeave (dragleave) 和 onDrop (drop) 在同一個 drop target component 上只會發生一個,要麼 onDragLeave,要麼 onDrop

Note

Drag and drop 的整個生命週期如上所示,在整個週期中,有 drag source 和 drop target 兩類 component,需要將 drag source 的 draggable 屬性置為 true,才能被拖動 (對 drop target 沒有要求),兩者之間通過 event.dataTransfer (後面會詳細講一下 dataTransfer 的使用) 或者全域性變數傳遞資料。

我們一般會在生命週期中進行以下操作:

  • 在 onDragStart 中儲存拖動的資料來源,修改 drag source 的樣式

      var dragSrcEl = null
      function handleDragStart(e) {
        console.log('drag start:', e.target.id)
    
        // this / e.target is current target element
        this.style.opacity = '0.4'
        dragSrcEl = this
        e.dataTransfer.setData('text/html', this.innerHTML)
      }
    複製程式碼
  • onDrag 事件一般情況下我們不關心,除非你的頁面要根據 drag 的距離,時間來發生變化,則需要處理

  • 在 onDragEnter 中修改 drop target 的樣式

      function handleDragEnter(e) {
        console.log('drag enter:', e.target.id)
        this.classList.add('over')
      }
    複製程式碼
  • 在 onDragOver 中,一般只要進行的操作就是執行 event.preventDefault() 來阻止拖拽的預設操作。尚不清楚這個預設操作是什麼,但如果你不在 onDragOver 事件中執行這個操作,onDrop 事件就不會發生,Chrome 和 Firefox 皆如此。因為這個事件發生很頻繁,所以不適合在這個事件中處理過於繁重的事情,比如修改樣式,這就是為什麼我們要把修改樣式的工作放到 onDragEnter 中

      function handleDragOver(e) {
        console.log('drag over:', e.target.id)
        e.preventDefault() // Necessary. Allows us to drop.
      }
    複製程式碼
  • 在 onDragLeave 中,恢復 drop target 的樣式 (如果發生了 onDrop,則 onDragLeave 不會發生,所以恢復 drop target 樣式的工作在 onDrop 或 onDragEnd 中也要處理)

      function handleDragLeave(e) {
        console.log('drag leave:', e.target.id)
        this.classList.remove('over')
      }
    複製程式碼
  • 在 onDrop 中,處理拖拽的真正邏輯,一般是實現資料交換

      function handleDrop(e) {
        console.log('drop:', e.target.id)
        e.preventDefault() // stop the browser from redirection
    
        // swap
        if (dragSrcEl != this) {
          dragSrcEl.innerHTML = this.innerHTML
          this.innerHTML = e.dataTransfer.getData('text/html')
        }
      }
    複製程式碼

    另外,在 onDrop,event.preventDefault() 也是幾乎必須執行的操作,用來阻止拖拽的預設操作。但實際這個操作只是為了相容 Firefox,在 Chrome 上不執行這個操作是沒有問題的。在 Firefox 上,如果 event.dataTransfer 中的 data 是連結,那麼拖拽的預設操作是跳轉到此連結。

    假如在 onDragStart 中 dataTransfer 中設定的資料是這樣的:

      function handleDragStart(e) {
        e.dataTransfer.setData('text/plain', 'https://www.google.com')
        // or
        // e.dataTransfer.setData('text', 'anything')
      }
    複製程式碼

    那麼在 onDrop 中,如果沒有 event.preventDefault(),在 Firefox 上的效果這樣的:

    HTML5 Drag and Drop 的一些總結

    e.dataTransfer.setData('text', 'anything') 的預設操作是跳轉到 https://www.anything.com

    但如果設定是的 e.dataTransfer.setData('aaa', 'anything') 則不會發生跳轉,anyway,就乾脆加上 event.preventDefault() 就對了。也許你會問,為什麼會往 dataTransfer 中設定 'aaa' 這樣奇怪的資料呢,不往 dataTransfer 中設定資料行不行,答案是不行,在 Firefox 上如果 dataTransfer 中沒有 data,Firefox 會認為你不是真的想拖拽,然後拖拽就會失敗,所以我們就要往裡面隨便塞點資料。

    在 Chrome 上不會發生跳轉,所以不也強制在 onDragStart 中往 dataTransfer 存資料。

  • 在 onDragEnd 中進行一些清理工作,比如恢復 drag source 和 drop target 的樣式,因為 onDragLeave 和 onDrop 並不能保證都發生,但 onDragEnd 肯定是會發生的

      function handleDragEnd(e) {
        console.log('drag end:', e.target.id)
        // reset
        dragSrcEl = null
        this.style.opacity = ''
        [].forEach.call(cols, function (col) {
          col.classList.remove('over')
        });
      }
    複製程式碼

示例及特殊情況

將 column-1 element 拖動,經歷 column-2 和 column-3,最終在 column-3 放下,整個過程是這樣的:

HTML5 Drag and Drop 的一些總結

HTML5 Drag and Drop 的一些總結

column-1 (drag source) column-2 (drop target) column-3 (drop target)
onDragStart
onDrag...
... onDragEnter
... onDragOver...
... onDragOver
... onDragLeave
... onDragEnter
... onDragOver...
onDrag onDragOver
  onDrop
onDragEnd

在 Chrome 上執行時,整個過程和預期一致,但在 Firefox 上執行,不知道是不是 Firefox 的 bug,經常會出現後一個 component 的 enter 事件出現在前一個 component 的 leave 事件之前。

HTML5 Drag and Drop 的一些總結

React

在 React 中處理 Drag and Drop 和處理原生事件差不多。

renderItem = (item_id, index) => {
  const item = this.state.data.items[item_id]
  return (
    <div className={this.getItemClassName(item)}
        key={item.id}
        draggable={true}

        onDragStart={(e)=>this.handleDragStart(e, item, index)}
        onDrag={(e)=>this.handleDrag(e, item)}
        onDragEnd={(e)=>this.handleDragEnd(e, item)}

        onDragEnter={(e)=>this.handleDragEnter(e, item)}
        onDragOver={(e)=>this.handleDragOver(e, item)}
        onDragExit={(e)=>this.handleDragExit(e, item)}
        onDragLeave={(e)=>this.handleDragLeave(e, item)}
        onDrop={(e)=>this.handleDrop(e, item, index)}>
      <h1>{item.title}</h1>
    </div>
  )
}
複製程式碼

但在 Firefox 上,和原生的 Drag and Drop 事件一樣,也存在相同的問題,後一個 component 的 enter 事件發生在前一個 component 的 leave 之前。

HTML5 Drag and Drop 的一些總結

更糟糕的是,在 Chrome 和 Firefox 還存在一個問題,會連續發生兩次 onDragEnter,再連續兩次 onDragLeave,如下圖所示 (React 16.5.2,Chrome: 69.0.3497.100,Firefox: 62.0):

HTML5 Drag and Drop 的一些總結

這導致的問題是,onDragLeave 變得不可靠,我們一般會在 onDragLeave 中恢復 drop target 的樣式,但實際在連續兩次 onDragEnter 後的第一次 onDragLeave 發生後,拖拽並沒有離開這個 drop target,drop target 還會響應 onDragOver 事件。

不確實這是不是 React 的 bug,但我現在不會在 onDragLeave 中處理任何邏輯,而是把它放到 onDrop 或 onDragEnd 中處理。

dataTransfer

前面我們說道 dataTransfer 是用來在拖拽源和放置目標之間傳遞資料用的,你可能或疑問,為什麼需要這個東東,我直接把源資料放在全域性變數裡不行嗎?

如果只是在同一個頁面內操作,實際上是可以的,像用 React 實現時,我們就必須放在 state 中。

但是 HTML5 的 Drag and Drop API 是支援從網頁中拖拽到桌面,或是從桌面拖拽到網頁中的,這個時候,全域性變數的方法就不可行的,我想這是為什麼需要 dataTransfer 的原因。而 Firefox 也強制你必須在 onDragStart 中往 dataTransfer 中設定 data,即使是在同一個頁面內拖拽,否則認為你不是真的想要拖拽 (沒資料你拖拽啥),從而導致拖拽不可用,所以一般這時候我們就會隨便往 dataTransfer 中賦一些值,Chrome 沒有這個要求。

因為支援從桌面拖拽到網頁,所以在上面第一個例子的 onDrop 事件中,可能存在 dragSrcEl 為 null 的情況,我們要加上判斷以增強程式的健壯性。

function handleDrop(e) {
  console.log('drop:', e.target.id)
  e.preventDefault() // stop the browser from redirection in firefox

  // swap
  // drop event maybe caused by draging from desktop, so dragSrcEl maybe null
  if (dragSrcEl && dragSrcEl != this) {
    dragSrcEl.innerHTML = this.innerHTML
    this.innerHTML = e.dataTransfer.getData('text/html')
  }
}
複製程式碼

我們在 onDrop 中列印一下 event.dataTransfer,然後拖拽一個檔案進來看看這個值是什麼。

function handleDrop(e) {
  console.log(e.dataTransfer)
  ...
}
複製程式碼

在 Firefox 上的輸出:

HTML5 Drag and Drop 的一些總結

在 Chrome 上的輸出:

HTML5 Drag and Drop 的一些總結

這種情況下,沒有 onDragStart/onDrag/onDragEnd 事件,只有 onDragEnter/onDragOver/onDragLeave 或 onDrop 事件。

但 Firefox 和 Chrome 上 dataTransfer 的值不一樣,需要做相容性處理。

dataTransfer 的常見方法和屬性:

  • setData(key, value)
  • getData(key)
  • setDragImage(imgElement, x, y)
  • effectAllowed: none, copy, copyLink, copyMove, link, linkMove, move, all 和 uninitialized
  • dropEffect: none, copy, link, move

setData(key, value)getData(key) 是配套使用的。在 HTML5 中,key 可以是任意值,但如果 key 是標準中指定的一些值,比如 'text' 或 'text/plain', 'url' 等,則在 onDrop 中如果沒有 preventDefault(),則會觸發預設操作,比如跳轉到 value 中指定的網址去。(在以前或一些瀏覽器上,key 只支援某些固定值,比如 'text' 或 'url')

setDragImage() 是用來改變拖拽時隨游標一起移動的圖片,預設是拖拽源的映象但透明度更低。(用到時再補充)

effectAllowed 和 dropEffect 也是配套使用的,兩個值要匹配,才能拖拽成功,否則失敗。

effectAllowed 是在 onDragStart 中給拖拽源所賦的值,dropEffect 是在 onDragOver 中給放置目標所賦的值。effectAllowed 表明拖拽源所期望放置目標的型別,比如 effectAllowed 值為 copy,則遇到 dropEffect 也為 copy 的放置目標時才能放置,否則不行。如果 effectAllowed 為 copyLink,則表明可接受的 dropEffect 必須為 copy 或 link。

我們在 onDragStart 中設定 effectAllowed 為 copyLink

function handleDragStart(e) {
  ...
  e.dataTransfer.effectAllowed = 'copyLink'
}
複製程式碼

在 onDragOver 中,檢測如果 id 為 column-4,則 dropEffect 為 move,其餘為 copy 或 link。

function handleDragOver(e) {
  ...
  if (e.target.id === 'column-4') {
    e.dataTransfer.dropEffect = 'move';
  } else if (e.target.id === 'column-3') {
    e.dataTransfer.dropEffect = 'link';
  } else {
    e.dataTransfer.dropEffect = 'copy';
  }
}
複製程式碼

那麼,column-1 ~ column-3 可以相互拖拽,但不能拖拽到 column-4 上,效果如下所示。

HTML5 Drag and Drop 的一些總結

另外,可以發現,不同的 dropEffect,拖拽時滑鼠樣式也有所不同,copy 的滑鼠樣式是一個綠色的十字,而 link 是一個連結樣式。

(但不知道除了改變滑鼠樣式外,effectAllowed 和 dropEffect 還有什麼實際用途,我們應該也不會用這兩個屬性作為約束能否拖拽的邏輯吧。)

相關文章