精讀《結合 React 使用原生 Drag Drop API》

黃子毅發表於2020-02-24

1 引言

拖拽是前端非常常見的互動操作,但顯然拖拽是強 DOM 互動的,而 React 繞過了 DOM 這一層,那麼基於 React 的拖拽方案就必定值得聊一聊。

結合 How To Use The HTML Drag-And-Drop API In React 這篇文章,讓我們談談 React 拖拽這些事。

2 概述

原文說的比較簡單,筆者先快速介紹其中重點部分。

首先拖拽主要的 API 有 4 個:dragEnter dragLeave dragOver drop,分別對應拖入、拖出、正在當前元素範圍內拖拽、完成拖入動作。

基於這些 API,我們可以利用 React 實現一個拖入區域:

import React from "react";

const DragAndDrop = props => {
  const handleDragEnter = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDragLeave = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDragOver = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDrop = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  return (
    <div
      className={"drag-drop-zone"}
      onDrop={e => handleDrop(e)}
      onDragOver={e => handleDragOver(e)}
      onDragEnter={e => handleDragEnter(e)}
      onDragLeave={e => handleDragLeave(e)}
    >
      <p>Drag files here to upload</p>
    </div>
  );
};
export default DragAndDrop;
複製程式碼

preventDefault 指的是阻止預設響應,這個響應可能是跳轉頁面之類的,stopPropagation 是阻止冒泡,這樣同樣監聽了事件的父元素就不會收到響應,我們可以精準作用於巢狀的子元素。

接下來是拖拽狀態管理,提到了 useReducer,順便複習一下用法:

...
const reducer = (state, action) => {
  switch (action.type) {
    case 'SET_DROP_DEPTH':
      return { ...state, dropDepth: action.dropDepth }
    case 'SET_IN_DROP_ZONE':
      return { ...state, inDropZone: action.inDropZone };
    case 'ADD_FILE_TO_LIST':
      return { ...state, fileList: state.fileList.concat(action.files) };
    default:
      return state;
  }
};
const [data, dispatch] = React.useReducer(
  reducer, { dropDepth: 0, inDropZone: false, fileList: [] }
)
...
複製程式碼

最後一個關鍵點在於拖入後的處理,利用 dispatch 增加拖入檔案、設定拖入狀態即可:

const handleDrop = e => {
  ...
  let files = [...e.dataTransfer.files];

  if (files && files.length > 0) {
    const existingFiles = data.fileList.map(f => f.name)
    files = files.filter(f => !existingFiles.includes(f.name))

    dispatch({ type: 'ADD_FILE_TO_LIST', files });
    e.dataTransfer.clearData();
    dispatch({ type: 'SET_DROP_DEPTH', dropDepth: 0 });
    dispatch({ type: 'SET_IN_DROP_ZONE', inDropZone: false });
  }
};
複製程式碼

e.dataTransfer.clearData 函式用於清除拖拽過程中產生的臨時變數,這些臨時變數可以通過 e.dataTransfer.xxx = 的方式賦值,一般用於拖拽過程中值的傳遞。

總結一下,利用 HTML5 的 API 將拖拽轉化為狀態,最終通過狀態對映到 UI。

原文內容還是比較簡單的,筆者在精讀部分再擴充一些更體系化的內容。

3 精讀

現階段拖拽主要分為兩種,一種是 HTML5 原生規範的拖拽,這種方式在拖拽過程中不會影響 DOM 結構。另一種是完全所見即所得的拖拽方式,拖拽過程中 DOM 位置會隨之變動,好處是可以立即反饋拖拽結果,當然缺點是華而不實,一旦用在生產環境,這種拖拽過程可能導致頁面結構頻繁跳動,反而看不清拖拽效果。

由於本文也採用了第一種拖拽方案,因為筆者再重新整理一遍自己的封裝思路。

從使用角度反推,假設我們擁有一個拖拽庫,那必定要擁有兩個 API:

import { DragContainer, DropContainer } from 'dnd'

const DragItem = (
  <DragContainer>
    {({ dragProps }) => (
      <div {...dragProps} />
    )}
  </DragContainer>
)

const DropItem = (
  <DropContainer>
    {({ dropProps }) => (
      <div {...dropProps} />
    )}
  </DropContainer>
)
複製程式碼

DragContainer 包裹可以被拖拽的元素,DragContainer 包裹可以被拖入的元素,而至於 dragPropsdropProps 需要透傳到子元素的 dom 節點,是為了利用 DOM API 控制拖拽效果,這也是拖拽唯一對 DOM 的要求,雙方元素都需要有實體 DOM 承載。

而上面例子中給出 dragPropsdropProps 的方式屬於 RenderProps,我們可以將 children 當作函式執行以達到效果:

const DragContainer = ({ children, componentId }) => {
  const { dragProps } = useDnd(componentId)

  return children({
    dragProps
  })
}

const DropContainer = ({ children, componentId }) => {
  const { dropProps } = useDnd(componentId)

  return children({
    dropProps
  })
}
複製程式碼

那麼這裡建立了一個自定義 Hook useDnd 接收 dragPropsdropProps,這個自定義 Hook 可以這麼寫:

const useDnd = ({ componentId }) => {
  const dragProps = {}
  const dropProps = {}

  return { dragProps, dropProps }
}
複製程式碼

接下來,我們就要分別實現 dragdrop 了。

drag 來說,只要實現 onDragStartonDragEnd 即可:

const dragProps = {
  onDragStart: ev => {
    ev.stopPropagation()
    ev.dataTransfer.setData('componentId', componentId)
  },
  onDragEnd: ev => {
    // 做一些拖拽結束的清理工作
  }
}
複製程式碼

stopPropagation 的作用在原文簡介中已經介紹過了,setData 則是通知拖拽方,當前拖拽的元件 id 是什麼,這是由於拖拽由 drag 發起而由 drop 響應,因此必須有個資料傳輸過程,而 dataTransfer 就最適合做這件事。

對於 drop 來說,只要實現 onDragOveronDrop 即可:

const dropProps = {
  onDropOver: ev => {
    // 做一些樣式處理,提示使用者此時鬆手會將元素防止在何處
  },
  onDrop: ev => {
    ev.stopPropagation()
    const componentId = ev.dataTransfer.getData('componentId')
    // 通過 componentId 修改資料,通過 React Rerender 重新整理 UI
  }
}
複製程式碼

重點在 onDrop,它是實現拖拽效果的 “真正執行處”,最終通過修改 UI 的方式更新資料。

存在一種場景,一個容器既可以被拖動,也可以被拖入,這種情況一般這個元件是個容器,但這個容器可以被拖入到其他容器中,可以自由巢狀。

實現這種場景的方式就是將 DragContainerDropContainer 作用到一個元件上:

const Box = (
  <DragContainer>
    {({ dragProps }) => (
      <DropContainer>
        {({ dropProps }) => {
          <div {...dragProps} {...dropProps} />
        }}
      </DropContainer>
    )}
  </DragContainer>
)
複製程式碼

之所以能巢狀,在於 HTML5 的 API 允許一個元素同時擁有 onDragStartonDrop 這兩種屬性,而上面的語法不過是同時將這兩種屬性傳給元件 DOM。

所以,動手實現一個拖拽庫就是這麼簡單,只要活用 HTML5 的拖拽 API,結合 React 一些特殊語法便夠了。

4 總結

最後留下一個思考題,許多具有拖拽功能的系統都具備 “拖拽 placeholder” 的功能,即拖拽元素的過程中,在其 “落點” 位置展示一條橫線或豎線,引匯出鬆手後元素位置落點,如圖所示:

精讀《結合 React 使用原生 Drag Drop API》

那麼這條輔助線是通過什麼方式實現的呢?歡迎在評論區留言!如果你有輔助線實現方案解析的文章,歡迎分享,也可以期待筆者未來專門寫一篇 “拖拽 placeholder” 實現剖析的精讀。

討論地址是:精讀《手寫 JSON Parser》 · Issue #233 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

精讀《結合 React 使用原生 Drag Drop API》

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章