Reactで再レンダリングを抑えたリストのドラッグアンドドロップを実装する

`React`, `ReactDOM`, `nanoid` 以外の依存ライブラリZEROでお送りします。
こういうのができる

[![Image from Gyazo](https://i.gyazo.com/8b01afd70ee65362244bdf29ac4b5e22.gif)](https://gyazo.com/8b01afd70ee65362244bdf29ac4b5e22)

repo: https://github.com/konbu310/react-dnd-impl
demo: https://konbu310.github.io/react-dnd-impl/index.html

`src/Dnd.tsx`がメインです。
まずは必要なrefなどを定義していきます。

```tsx:Dnd.tsx（42行目あたり~）
  // リストアイテムの親コンポーネント。inserBeforeするために使う
  const containerRef = useRef<HTMLDivElement | null>(null);

  // 各リストアイテム。idをkeyにするこの方法を思いついたときは歓喜した。
  const itemsRef = useRef<{ [id: string]: HTMLDivElement | null }>({});

  // ドラッグ中のリストアイテム。
  const draggingElmId = useRef<string | null>(null);

  // Stateの複製。余計な再レンダリングを防ぐためにuseRefで保持しておく。
  const listClone = useRef<State>(list);

  // propsから新しいStateが渡されてきた時にrefを更新するため。
  useEffect(() => {
    listClone.current = list;
  }, [list]);

  // touchmoveでスクロールしてしまうのを防ぐ。最適解がわからない。
  useEffect(() => {
    if (!itemsRef.current || !listClone.current) return;
    listClone.current.map(
      item => {
        itemsRef.current[item.id]?.addEventListener("touchmove", ev =>
          ev.preventDefault()
        );
      },
      { passive: false }
    );
  }, [itemsRef]);
```

refの指定とかはこんなかんじ。

```tsx:Dnd.tsx（157行目あたり~）
  return (
    <div
      className="container"
      // containerRef
      ref={containerRef}
      onDrop={preventDefaultEvent}
      onDragOver={preventDefaultEvent}
      onDragEnter={preventDefaultEvent}
    >
      {list.map(item => (
        <div
          key={item.id}
          className="list-item"
          // TouchEventで使うためのカスタムデータ属性。これも最適解がわからない。
          data-id={item.id}
          // itemsRef
          ref={elm => (itemsRef.current[item.id] = elm)}
          draggable
          // DragEventとTouchEventのハンドラたち
          onDragStart={onDrag.start(item.id)}
          onDragEnter={onDrag.enter(item.id)}
          onDragEnd={onDrag.end(item.id)}
          onDragOver={preventDefaultEvent}
          // onDropも指定しておかないとドラッグ終了時にドラッグ中の画像が初期位置に戻るようにアニメーションしちゃう
          // ちょっと言葉で説明するの無理ある
          onDrop={preventDefaultEvent}
          onTouchStart={onTouch.start(item.id)}
          onTouchMove={onTouch.move(item.id)}
          onTouchEnd={onTouch.end(item.id)}
        >
          {item.value}
        </div>
      ))}
    </div>
  );
```

DragEventのハンドラはこんなかんじ
`TypeScript`且つ`Strict Mode`なのでif文がめちゃんこ多い

```tsx:Dnd.tsx（63行目あたり~）
  // まとめてみたけどまとめないほうがいいかも
  const onDrag: DragHandlers = {

    // `onDragStart` ドラッグを開始した要素で発火
    start: id => ev => {
      // ドラッグ中の要素のidを保持しておく
      draggingElmId.current = id;
      // これは多分やっておいたほうがいい
      // 画像を差し替えたいときは`ev.dataTranser.setDragImage()`
      ev.dataTransfer.effectAllowed = "move";
      // 1frずらしてやると本来は指定できない`opacity: 0`とかも指定できる
      requestAnimationFrame(() => {
        itemsRef.current[id]?.classList.add("list-item-dragging");
      });
    },

    // `onDragEnter` draggable要素をドラッグ中のマウスカーソルが進入してきた時に進入された要素で発火
    enter: id => ev => {
      // 親要素
      const containerElm = containerRef.current;
      if (!draggingElmId.current || !containerElm) return;

      // ドラッグ中の要素
      const draggingElm = itemsRef.current[draggingElmId.current];
      // ドラッグ先の要素（マウスカーソルの下にある要素）
      const underElm = itemsRef.current[id];
      if (!underElm || !draggingElm || underElm === draggingElm) return;

      // ドラッグ中の要素のインデックス
      const draggingElmIndex = listClone.current.findIndex(
        item => item.id === draggingElmId.current
      );
      // 移動先の要素のインデックス
      const underElmIndex = listClone.current.findIndex(item => item.id === id);

      // インデックスの大小に応じてinsertBeforeを使用して並び替え
      if (draggingElmIndex === underElmIndex) {
        return;
      } else if (draggingElmIndex < underElmIndex) {
        containerElm.insertBefore(underElm, draggingElm);
      } else if (draggingElmIndex > underElmIndex) {
        containerElm.insertBefore(draggingElm, underElm);
      }

      // 配列のデータも移動させておく。`arrayMove`は`src/util.ts`で定義してるインデックスで配列の要素を移動させるための関数。
      listClone.current = arrayMove(
        listClone.current,
        draggingElmIndex,
        underElmIndex
      );
    },

    // `onDragEnd` ドラッグ終了時に発火
    end: id => ev => {
      // 必要に応じてclassNameを削除したりremoveAttributeしたり
      itemsRef.current[id]?.classList.remove("list-item-dragging");
      // 変更をstateに適用する
      setList(listClone.current);
    }
  };
```

TouchEventのハンドラも似たようなかんじ
`touchmove`だけ少しトリッキー
あとスクロールを無理やり封じてしまったのでviewport外の要素にいけない(つらい)

```tsx:Dnd.tsx（109行目あたり~）
  const onTouch: TouchHandlers = {
    // `ontouchstart` タッチを開始した要素で発火
    start: id => ev => {
      draggingElmId.current = id;
      requestAnimationFrame(() => {
        itemsRef.current[id]?.classList.add("list-item-dragging");
      });
    },

    // `ontouchmove` タッチした状態で指を動かした時に発火
    // ここがDragEventとの違い。`ontouchenter`ほしい...
    move: id => ev => {
      const containerElm = containerRef.current;
      const draggingElm = itemsRef.current[id];
      if (!containerElm || !draggingElm) return;

      // タッチしてるとこの座標から無理くりドラッグ先の要素を取得する
      const { clientX, clientY } = ev.targetTouches[0];
      const underElm = document.elementFromPoint(clientX, clientY);
      if (!underElm || draggingElm === underElm) return;

      // カスタムデータ属性からidを取得する
      // これ何かいい方法ないですかねぇ...
      const underElmId = underElm.getAttribute("data-id");
      if (!underElmId) return;

      const draggingElmIndex = listClone.current.findIndex(
        item => item.id === draggingElmId.current
      );
      const underElmIndex = listClone.current.findIndex(
        item => item.id === underElmId
      );
      if (draggingElmIndex === underElmIndex) {
        return;
      } else if (draggingElmIndex < underElmIndex) {
        containerElm?.insertBefore(underElm, draggingElm);
      } else if (draggingElmIndex > underElmIndex) {
        containerElm?.insertBefore(draggingElm, underElm);
      }

      listClone.current = arrayMove(
        listClone.current,
        draggingElmIndex,
        underElmIndex
      );
    },

    end: id => ev => {
      itemsRef.current[id]?.classList.remove("list-item-dragging");
      setList(listClone.current);
    }
  };
```

もっとスマートな方法ありそう。
次は仮想スクロールと合体させたい。