考试遇到拖拽题型,如何做才能让考生体验感拉满...

303 阅读5分钟

前言

看过我之前文章的应该知道我们学校为了降低成本选择自主开发考试系统,并对小编所在的实验室委以重任,原以为只是单纯的选择填空,实则不然,领导:“我们要做点跟别人不一样的”,并且搬出拖拽答题等题型拓展需求。

实现效果

13395392079590241.gif

技术选型&对比

实现拖拽效果网上已经罗列出很多了,小编就介绍以下两种:

利用原生h5特性: 在元素上添加 draggable 属性,取值为布尔值,为true表示可以用鼠标按钮拖动元素。拖拽操作期间,会有一个可拖拽元素的半透明快照跟随着鼠标指针。

优点:

  • 性能良好: 性能优于三方库。
  • 浏览器兼容性好: 因其是HTML5标准的一部分,大多数浏览器都支持该特性。

缺点:

  • 优化复杂; 在实际项目中需要注册多个事件优化细节(拖动的时候分为开始、进行中、结束,拖动后放入相应区域又分为进入时、进入后、离开、放置)
  • 功能有限: 无法满足复杂的需求场景

第三方库: vue.draggablereact-dnd

优点:

  • 功能丰富: 提供了更强大的功能,如支持复杂的拖拽排序、拖拽复制、拖拽到不同容器等
  • 易用性高: 封装了复杂的逻辑,开发者可以更简单地实现拖拽功能

缺点:

  • 依赖外部库: 增加了项目依赖
  • 学习成本: 有一定的学习成本

实战场景

因其复杂的场景需求以及项目框架,在该项目中我们选用了 react-dnd 来实现最终效果。

安装相关依赖:

npm install react-dnd react-dnd-html5-backend

查看处理原始数据后的结果(题目、选项、已选):

image.png

接着实现静态组件(注意: 父组件需要使用 DndProvider 包裹):

// 选项组件
const Option = ({key, option, index }: OptionProps) => {

  return (
    <div  className='drag-question-option'>
      <div >
        {option}
      </div>
    </div>
  );
};

// 填空组件
const DropTarget = ({ droppedItems }: DropTargetProps) => {

  return (
    <div className='drag-question-dragItem'>
      {droppedItems.map((item, idx) => (
        <div>
          <div >{item.option}</div>
        </div>
      ))}
    </div>
  );
};

// 父组件
export default function DragQuestion(questionArr: ExamType) {
  
  const { questionTitle, Questions, Options, optionTitle, title } = parseMarkdownToQuestionData(markdown);
  const [localOptions, setLocalOptions] = useState<string[]>(Options);
  const [droppedItems, setDroppedItems] = useState<{ questionIndex: number; option: string; originalIndex: number }[]>([]);

  
  return (
    <DndProvider backend={HTML5Backend}>
      <div>
        <div className='drag-questionTitle'>{questionTitle}</div>
        <div>
          <div className='drag-question-option-box'>
            <div className='drag-question-title'>{optionTitle}</div>
            {localOptions.map((option, index) => (
              <Option key={option} option={option} index={index} />
            ))}
          </div>
          <div className='drag-question-question-box'>
            <div className='drag-question-title'>{title}</div>
            {
              Questions.map((question, questionIndex) => (
                <div key={questionIndex} className='drag-question-question' >
                  {question}
                  <DropTarget 
                    droppedItems
                  />
                </div>
              ))
            }
          </div>
        </div>
      </div>
    </DndProvider>
  );
}

效果如图:

屏幕截图 2025-06-25 143903.png

现在我们想把 Option 拖拽到 DropTarget 里,需要怎么实现呢?

dnd 是 drag and drop 的意思,api 也分有两个 useDrag 和 useDrop。

Option 部分用 useDrag 让元素可以拖拽:

const Option = ({key, option, index }: OptionProps) => {
+  const [, drag, preview] = useDrag(
+    () => ({
+      type: 'option',
+      item: { option, key, index }
+    }),
+    []
+  );

  return (
    <div ref={preview} className='drag-question-option'>
      <div ref={drag}>
        {option}
      </div>
    </div>
  );
};

至于 DropTarget 则用 useDrop 使元素可以放置:

+const DropTarget = ({ questionIndex, onDrop, droppedItems }: DropTargetProps) => {
+  const [, drop] = useDrop(() => ({
+    accept: 'option',
+    drop: (item: { option: string; index: number }) => onDrop(item, questionIndex)
+  }));

  return (
    <div ref={drop} className='drag-question-dragItem'>
      {droppedItems.map((item, idx) => (
        <div>
          <div>{item.option}</div>
        </div>
      ))}
    </div>
  );
};

一图讲清item、type参数的含义:

image.png

并且在父组件中添加对应的监听事件(handleDrop):

  const handleDrop = (item: { option: string; index: number }, questionIndex: number) => {
    setDroppedItems((prevItems) => {
      let newItems = prevItems;
      // 添加新项
      newItems = [
        ...newItems.filter(droppedItem => droppedItem.option !== item.option),
        { questionIndex, option: item.option, originalIndex: item.index }
      ];
      return newItems;
    });
    // 移除已拖拽的选项
    setLocalOptions((prevOptions) => prevOptions.filter(option => option !== item.option));
  };
  
  // 将监听事件绑定对应元素上
  <DropTarget 
    questionIndex={questionIndex} 
+    onDrop={handleDrop}            
+    droppedItems={
+      droppedItems
+        .filter(item => item.questionIndex === questionIndex)
+        .map(item => ({ option: item.option, originalIndex: item.originalIndex }))
+    }
  />

实现后效果如图:

13395311068056296.gif

如果考生选错了选项,打算移除该选项又该如何实现呢?

注册对应的监听事件 handleRemove :

const handleRemove = (item: { option: string; index: number }) => {
    setDroppedItems((prevItems) => prevItems.filter(droppedItem => droppedItem.option !== item.option));
    // 将选项移回原始位置
    setLocalOptions((prevOptions) => {
      const newOptions = [...prevOptions];
      // 找到该选项的原始索引
      const originalIndex = droppedItems.find(droppedItem => droppedItem.option === item.option)?.originalIndex;
      if (originalIndex !== undefined) {
        // 在原始索引位置插入选项
        newOptions.splice(originalIndex, 0, item.option);
      } else {
        // 如果找不到原始索引,直接添加到末尾
        newOptions.push(item.option);
      }
      return newOptions;
    });
  };

并绑定到对应的元素:

// 父组件
<DropTarget 
    questionIndex={questionIndex} 
    onDrop={handleDrop}
+    onRemove={handleRemove}
    droppedItems={
      droppedItems
        .filter(item => item.questionIndex === questionIndex)
        .map(item => ({ option: item.option, originalIndex: item.originalIndex }))
    }
  />
  
// 子组件
const DropTarget = ({ questionIndex, onDrop, onRemove, droppedItems }: DropTargetProps) => {
  return (
    <div ref={drop} className='drag-question-dragItem'>
      {droppedItems.map((item, idx) => (
+        <div onClick={() => onRemove({ option, index: originalIndex })}>
          <div>{item.option}</div>
        </div>
      ))}
    </div>
  );
};

效果如图:

13395320348205878.gif

可以发现,点击后移出该选项后,并没有回到正确的位置上:

屏幕截图 2025-06-25 182110.png

定位问题:

const originalIndex = droppedItems.find(droppedItem => droppedItem.option === item.option)?.originalIndex;

因为保存的是原始索引,所以会根据它原始的下标进行排序,才会导致该问题。

既然找出问题,那就该解决问题了:保存每个选项的原始索引,remove 时找到第一个原始索引比当前选项大的,插入该位置。

// 改变 localOptions 的结构
const [localOptions, setLocalOptions] = useState<{option: string; originalIndex: number}[]>(Options);

// 修改触发 remove 回调时的逻辑
setLocalOptions((prevOptions) => {
      const newOptions = [...prevOptions];
      // 找到该选项的原始索引
      const originalIndex = droppedItems.find(droppedItem => droppedItem.option === item.option)?.originalIndex;
      if (originalIndex !== undefined) {
        // 找到该选项在 newOptions 中的位置
        const movedIndex = newOptions.findIndex(item => item.originalIndex > originalIndex );
        newOptions.splice(movedIndex, 0, {option: item.option, originalIndex});
      } else {
        // 如果找不到原始索引,直接添加到末尾
        newOptions.push({option: item.option, originalIndex: newOptions.length});
      }
      return newOptions;
    });

实现效果如图:

13395337685700737.gif

这样一个基本的拖拽题型已经完成了,但既然要给考生带来极致的体验,仅仅这样还远远不够:如果考生着急改答案,就必须移出已填项才能填入新选项

如何在考生将选项移入已存在答案的框内,让该答案自动回到未选的选项里,将新的答案填入

如图,可以发现一个框竟然填入了多个答案

13395338315266680.gif

方案:修改 handleDrop 逻辑

  1. 移入时,先查找是否存在现有项
  2. 如果存在,将踢回 localOptions 并从 droppedItems 中移除
const handleDrop = (item: { option: string; index: number }, questionIndex: number) => {
    setDroppedItems((prevItems) => {
      // 查找现有项
+      const existingItem = prevItems.find(droppedItem => droppedItem.questionIndex === questionIndex);
      let newItems = prevItems;
+      // 如果存在现有项,将其移回 localOptions 并从 droppedItems 中移除
+      if (existingItem) {
+        const originalIndex = existingItem.originalIndex;
+         // 将现有项移回 localOptions 的原始位置
+        setLocalOptions((prevOptions) => {
+        const newOptions = [...prevOptions];
+        newOptions.splice(originalIndex, 0, {option: existingItem.option, originalIndex});
+        return newOptions;
+      });
+        newItems = prevItems.filter(droppedItem => droppedItem.questionIndex !== questionIndex);
      }
      // 添加新项
      newItems = [
        ...newItems.filter(droppedItem => droppedItem.option !== item.option),
        { questionIndex, option: item.option, originalIndex: item.index }
      ];
      return newItems;
    });
    // 移除已拖拽的选项
    setLocalOptions((prevOptions) => prevOptions.filter(option => option.option !== item.option));
  };

实现后效果如图:

13395338922667072.gif

如果考生将选项填到框内,想要修改答案还是得将已填答案移出,再将该选项移入想要的框里

如何将答案方框也实现为可拖拽呢?

// 定义一个新的组件,使方框变为可拖拽
const DropTargetItem = ({ option, originalIndex, onRemove }: { option: string; originalIndex: number; onRemove: (item: { option: string; index: number }) => void }) => {
  const [, drag, preview] = useDrag(
    () => ({
      type: ItemTypes.OPTION,
      item: { option, index: originalIndex }
    }),
    [option, originalIndex]
  );

  return (
    <div ref={preview} onClick={() => onRemove({ option, index: originalIndex })}>
      <div ref={drag}>{option}</div>
    </div>
  );
};

// 修改原先的 DropTarget 组件
const DropTarget = ({ questionIndex, onDrop, onRemove, droppedItems }: DropTargetProps) => {
  const [, drop] = useDrop(() => ({
    accept: ItemTypes.OPTION,
    drop: (item: { option: string; index: number }) => onDrop(item, questionIndex)
  }));

  return (
    <div ref={drop} className='drag-question-dragItem'>
      {droppedItems.map((item, idx) => (
+        <DropTargetItem key={idx} option={item.option} originalIndex={item.originalIndex} onRemove={onRemove} />
      ))}
    </div>
  );
};

实现效果如图:

13395391908387466.gif

总结

本文主要介绍了拖拽场景在考试系统中的实现,从如何实现 拖拽 -> 移除后如何保证选项顺序 -> 一空只能存在一个 -> 已填答案可拖拽 一系列难关,这都是我在开发过程中遇到的问题和解决方案,如果各位大佬有什么其它更好的方案可以在评论区指点一下。