React | 借助Pragmatic Drag and Drop实现高性能拖拽

2024-05-05 17:25:26 浏览数 (1)

封面封面

一. Pragmatic Drag and Drop 简介

Pragmatic Drag and Drop正如名字一样是一个拖放库。因为使用的是浏览器支持的原拖拽功能,并且极小的核心包(不到5kb),在近期迅速火起来。所以今天来结合React快速实现结合一下。

二. 快速上手

2.1 环境准备

没有使用React官方推荐的Next脚手架,而是选择了create-react-app,并且使用TypeScript模板。再手动引入拖放库。核心库版本选择如下:

  • React:18.3.1
  • typescript: 4.9.5
  • @atlaskit/pragmatic-drag-and-drop: 1.1.7

更详细的依赖放在了文章末尾

2.2 目标介绍

可能由于版本和配置原因,跟着官方教程会出现各种报错。所以本案例是根据照官方的最终实现效果以及核心逻辑重新写了一部分代码并加以解读。

官方示例官方示例

代码结构介绍

代码结构代码结构

2.3 初始化棋盘

官方的案例中采用了国际象棋的一个极简化的棋盘,所以我们所实现的第一步就是画棋盘。因为棋盘是一个比较规则的8x8正方形,所以落实到代码上便采用二维数组的形式。

核心代码如下:

代码语言:jsx复制
const renderSquares = () => {
    // 8 X 8的棋盘数组
    const squares = [];
    for (let row = 0; row < 8; row  ) {
        for (let col = 0; col < 8; col  ) {
          // 计算颜色
          const isDark = (row   col) % 2 === 1;
          squares.push(
            <div
              css={squareStyles}
              style={{ backgroundColor: isDark ? 'lightgrey' : 'white' }}
            >
            {/* 棋子组件 TODO */}
            </div>,
          );
        }
      }
    return squares;
}

2.4 让棋子可“拖”

接下来进入正题,如何将一个元素变得可以拖动呢?官方给出的API就是@atlaskit/pragmatic-drag-and-drop/element/adapter下的draggable

使用useEffect的return来配置,具体见下

代码语言:jsx复制
const Piece = (props: PieceProps) => {

    // 传入参数解构
    const { image, alt } = props;
    // 依赖
    const ref = useRef(null);
    // 拖动的状态
    const [dragging, setDragging] = useState<boolean>(false);
    
    // 初始化和再次渲染调用的钩子
    useEffect(() => {
        const el = ref.current;
        // 判断是否null,是则抛出异常
        invariant(el);

        return draggable({
            element: el,
            onDragStart: () => setDragging(true),
            onDrop: () => setDragging(false),
        });
    }, []);

    return (
        <img
            css={imageStyles}
            style={dragging ? { opacity: 0.4 } : {}}
            src={image}
            alt={alt}
            ref={ref}
        />
    )
}

2.5 加入棋盘

棋盘在初始化时,先设置棋子的位置,然后在renderSquares的TODO中添加棋子组件

代码语言:jsx复制
    // 初始化位置
    const pieces: PieceRecord[] = [
        { type: 'king', location: [3, 2] },
        { type: 'pawn', location: [1, 6] },
    ];

2.6 让棋子可“放”

完成以上过程只是实现了拖(drag),想要实现放(drop)之前,还需要dropTargetForElements这个函数来实现目标容器可放置。

首先,改造棋盘格子,让他变成可以放元素的格子。

renderSquares中,修改div成一个单独的格子组件

调用方

代码语言:jsx复制
squares.push(
    <Square location={squareCoord}>
        {piece && pieceLookup[piece.type]()}
    </Square>
);

格子组件

代码语言:jsx复制
/** 格子 */
const Square = (props: SquareProps) => {
    const { location, children } = props;
    const ref = useRef(null);
    const [isDraggedOver, setIsDraggedOver] = useState(false);
  
    useEffect(() => {
      const el = ref.current;
      invariant(el);
  
      return dropTargetForElements({
        element: el,
        onDragEnter: () => setIsDraggedOver(true),
        onDragLeave: () => setIsDraggedOver(false),
        onDrop: () => setIsDraggedOver(false),
      });
    }, []);
  
    const isDark = (location[0]   location[1]) % 2 === 1;
  
    return (
      <div
        css={squareStyles}
        style={{ backgroundColor: getColor(isDraggedOver, isDark) }}
        ref={ref}
      >
        {children}
      </div>
    );
}

目前效果

可以“放”的格子可以“放”的格子

可以看到,容器能放了,但是松开鼠标,还没移过去。不过先不着急实现下一步,先把游戏规则加入进去:设置能否“放”。规则代码非重点,此处略过。

2.8 链接拖与放

在这一步,主要使用monitorForElements。使用这个“监听器”的好处就是减少不同组件间的相互传值。

代码语言:jsx复制
// 初始化位置
const [pieces, setPieces] = useState<PieceRecord[]>([
    { type: 'king', location: [3, 2] },
    { type: 'pawn', location: [1, 6] },
]);

useEffect(() => {
    return monitorForElements({
      onDrop({ source, location }) {
        // 取得目标位置
        const destination = location.current.dropTargets[0];
        if (!destination) {
          return;
        }
        const destinationLocation = destination.data.location;
        const sourceLocation = source.data.location;
        const pieceType = source.data.type;

        if (
          !isCoord(destinationLocation) ||
          !isCoord(sourceLocation) ||
          !isPieceType(pieceType)
        ) {
          return;
        }

        const piece = pieces.find(p =>
          isEqualCoord(p.location, sourceLocation),
        );
        const restOfPieces = pieces.filter(p => p !== piece);

        if (
          canMove(sourceLocation, destinationLocation, pieceType, pieces) &&
          piece !== undefined
        ) {
          setPieces([
            { type: piece.type, location: destinationLocation },
            ...restOfPieces,
          ]);
        }
      },
    });
  }, [pieces]);

2.9 修改后的源码

结构

└─playChess Chessboard.tsx Piece.tsx PieceType.tsx Square.tsx

Chessboard.tsx

代码语言:jsx复制
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import { isEqualCoord, PieceRecord } from './Piece';
import { pieceLookup, PieceType } from './PieceType';
import Square from './Square';
import { useEffect, useState } from 'react';
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';

/** 坐标 */
export type Coord = [number, number];

/** 判断可以移动 */
export function canMove(
    start: Coord,
    destination: Coord,
    pieceType: PieceType,
    pieces: PieceRecord[],
  ) {
    const rowDist = Math.abs(start[0] - destination[0]);
    const colDist = Math.abs(start[1] - destination[1]);

    if (pieces.find(piece => isEqualCoord(piece.location, destination))) {
        return false;
    }

    switch (pieceType) {
        case 'king':
            return [0, 1].includes(rowDist) && [0, 1].includes(colDist);
        case 'pawn':
            return colDist === 0 && start[0] - destination[0] === -1;
        default:
            return false;
    }
}

export function isCoord(token: unknown): token is Coord {
    return (
        Array.isArray(token) &&
        token.length === 2 &&
        token.every(val => typeof val === 'number')
    );
}

const pieceTypes: PieceType[] = ['king', 'pawn'];

export function isPieceType(value: unknown): value is PieceType {
    return typeof value === 'string' && pieceTypes.includes(value as PieceType);
}

/**
 * 绘制棋盘
 */
const renderSquares = (pieces: PieceRecord[]) => {
    // 8 X 8的棋盘数组
    const squares = [];
    for (let row = 0; row < 8; row  ) {
        for (let col = 0; col < 8; col  ) {

            // 判断是否重叠,重叠则不放入
            const squareCoord: Coord = [row, col];
            const piece = pieces.find(piece =>
                isEqualCoord(piece.location, squareCoord),
            );

            squares.push(
                <Square 
                    key={`square-${squareCoord[0]}-${squareCoord[1]}`}
                    pieces={pieces}
                    location={squareCoord}>
                    {piece && pieceLookup[piece.type](squareCoord)}
                </Square>,
            );
        }
      }
    return squares;
}

/**
 * 棋盘组件
 */
const Chessboard = () => {

    // 初始化位置
    const [pieces, setPieces] = useState<PieceRecord[]>([
        { type: 'king', location: [3, 2] },
        { type: 'pawn', location: [1, 6] },
      ]);

    useEffect(() => {
        return monitorForElements({
          onDrop({ source, location }) {
            // 取得目标位置
            const destination = location.current.dropTargets[0];
            if (!destination) {
              return;
            }
            const destinationLocation = destination.data.location;
            const sourceLocation = source.data.location;
            const pieceType = source.data.type;
    
            if (
              !isCoord(destinationLocation) ||
              !isCoord(sourceLocation) ||
              !isPieceType(pieceType)
            ) {
              return;
            }
    
            const piece = pieces.find(p =>
              isEqualCoord(p.location, sourceLocation),
            );
            const restOfPieces = pieces.filter(p => p !== piece);
    
            if (
              canMove(sourceLocation, destinationLocation, pieceType, pieces) &&
              piece !== undefined
            ) {
              setPieces([
                { type: piece.type, location: destinationLocation },
                ...restOfPieces,
              ]);
            }
          },
        });
      }, [pieces]);

    return (
        <div css={chessboardStyles}>
            {renderSquares(pieces)}
        </div>
    );
}

/** 棋盘样式 */
const chessboardStyles = css({
    display: 'grid',
    gridTemplateColumns: 'repeat(8, 1fr)',
    gridTemplateRows: 'repeat(8, 1fr)',
    width: '500px',
    height: '500px',
    border: '3px solid lightgrey',
});



export default Chessboard;

Piece.tsx

代码语言:jsx复制
/** @jsxImportSource @emotion/react */

import { useEffect, useRef, useState } from "react";
import invariant from "tiny-invariant";
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { css } from "@emotion/react";
import { PieceType } from "./PieceType";
import { Coord } from "./Chessboard";

/** 棋子记录 */
export type PieceRecord = {
    type: PieceType;
    location: Coord;
};

/** 棋子属性 */
export type PieceProps = PieceRecord & {
    image: string;
    alt: string;
};

/** 是否坐标相等 */
export function isEqualCoord(c1: Coord, c2: Coord): boolean {
    return c1[0] === c2[0] && c1[1] === c2[1];
}

const Piece = (props: PieceProps) => {

    // 传入参数解构
    const { image, alt, location, type} = props;
    // 依赖
    const ref = useRef(null);
    // 拖动的状态
    const [dragging, setDragging] = useState<boolean>(false);
    
    // 初始化和再次渲染调用的钩子
    useEffect(() => {
        const el = ref.current;
        // 判断是否null,是则抛出异常
        invariant(el);

        return draggable({
            element: el,
            getInitialData: () => ({ location, type }),
            onDragStart: () => setDragging(true),
            onDrop: () => setDragging(false),
        });
    }, [location, type]);

    return (
        <img
            css={imageStyles}
            style={dragging ? { opacity: 0.4 } : {}}
            src={image}
            alt={alt}
            ref={ref}
        />
    )
}

const imageStyles = css({
    width: 45,
    height: 45,
    padding: 4,
    borderRadius: 6,
    boxShadow:
      '1px 3px 3px rgba(9, 30, 66, 0.25),0px 0px 1px rgba(9, 30, 66, 0.31)',
    '&:hover': {
      backgroundColor: 'rgba(168, 168, 168, 0.25)',
    },
  });

export default Piece;

PieceType.tsx

代码语言:jsx复制
import { ReactElement } from "react";
import Piece from "./Piece";
import { Coord } from "./Chessboard";

const king = '/icon/king.png';
const pawn = '/icon/pawn.png';

/** 棋子类型 */
export type PieceType = 'king' | 'pawn';

type PieceProps = {
    location: Coord
}

export function King(props: PieceProps) {
    const { location } = props;
    return (
      <Piece location={location} type={'king'} image={king} alt="King" />
    );
  }
  
  export function Pawn(props: PieceProps) {
    const { location } = props;
    return (
      <Piece location={location} type={'pawn'} image={pawn} alt="Pawn" />
    );
  }

  export const pieceLookup: {
    [Key in PieceType]: (location: [number, number]) => ReactElement;
  } = {
    king: location => <King location={location} />,
    pawn: location => <Pawn location={location} />,
  };

Square.tsx

代码语言:jsx复制
/** @jsxImportSource @emotion/react */
import { ReactNode, useEffect, useRef, useState } from "react";
import { PieceRecord } from "./Piece";
import invariant from "tiny-invariant";
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { css } from "@emotion/react";
import { canMove, Coord, isCoord, isPieceType } from "./Chessboard";

/** 悬停状态 */
type HoveredState = 'idle' | 'validMove' | 'invalidMove';

/** 格子属性 */
type SquareProps = {
    pieces: PieceRecord[];
    location: Coord;
    children: ReactNode;
}

/** 取得格子背景颜色 */
function getColor(state: HoveredState, isDark: boolean): string {
    if (state === 'validMove') {
      return 'lightgreen';
    } else if (state === 'invalidMove') {
      return 'pink';
    }
    return isDark ? 'lightgrey' : 'white';
  }

/** 格子 */
const Square = (props: SquareProps) => {
    const { location, children, pieces } = props;
    const ref = useRef(null);
    const [state, setState] = useState<HoveredState>('idle');
  
    useEffect(() => {
      const el = ref.current;
      invariant(el);
  
      return dropTargetForElements({
        element: el,
        getData: () => ({ location }),
        onDragEnter: ({ source }) => {
          // 移动参数校验
          if (
            !isCoord(source.data.location) ||
            !isPieceType(source.data.type)
          ) {
            return;
          }
  
          // 根据移动规则,设置格子北京颜色
          if (
            canMove(source.data.location, location, source.data.type, pieces)
          ) {
            setState('validMove');
          } else {
            setState('invalidMove');
          }
        },
        onDragLeave: () => setState('idle'),
        onDrop: () => setState('idle'),
      });
    }, [location, pieces]);
  
    const isDark = (location[0]   location[1]) % 2 === 1;
  
    return (
        <div
        css={squareStyles}
        style={{ backgroundColor: getColor(state, isDark) }}
        ref={ref}
      >
        {children}
      </div>
    );
}
  
/** 格子样式 */
const squareStyles = css({
    width: '100%',
    height: '100%',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
});

export default Square;

package.json 部分

代码语言:json复制
  "dependencies": {
    "@atlaskit/analytics-next": "^9.3.0",
    "@atlaskit/css-reset": "^6.9.0",
    "@atlaskit/pragmatic-drag-and-drop": "^1.1.7",
    "@atlaskit/pragmatic-drag-and-drop-docs": "^1.0.10",
    "@emotion/react": "^11.11.4",
    "@emotion/styled": "^11.11.5",
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^27.5.2",
    "@types/node": "^16.18.96",
    "@types/react": "^18.3.1",
    "@types/react-dom": "^18.3.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-scripts": "5.0.1",
    "styled-components": "^6.1.9",
    "tiny-invariant": "^1.3.3",
    "typescript": "^4.9.5",
    "web-vitals": "^2.1.4"
  }

三. 总结

按照官方的文档操作下来,确实感到很简约。但目前还存在一些体验问题。

比如:

  • 自动引入包的时候,vscode没有给出正确提示。
  • 官方给的沙盒代码和文档不完全匹配。
  • 演示需要引入其他依赖等。

不过这只是开放初期,还是未来可期的。

0 人点赞