人人都会AI编程

31.4 拖拽排序、文件上传、下载功能

更新时间:2026-07-11

这三项功能是 Web 应用中出镜率极高的交互场景,从后台管理的列表整理到用户内容上传,再到报表导出,几乎每个项目都会涉及。下面分别讲解它们的实现思路、核心 API 和需要注意的坑点。


31.4.1 拖拽排序

拖拽排序(Drag and Drop Sort)允许用户通过鼠标拖拽(或触摸滑动)来重新排列一组元素的顺序。原生浏览器提供了拖拽事件体系,但在移动端的支持并不理想,因此实际生产环境中常搭配第三方库(如 SortableJS)来获得跨平台体验。

原生实现核心步骤

  1. 为每个可拖拽元素设置 draggable="true"

这一步告诉浏览器该元素可以被拖拽。

  1. 监听拖拽源事件
  • dragstart:拖拽开始时触发,可以在这里设置拖拽影像和传递数据(例如被拖拽元素的索引 data-index)。
  • dragend:拖拽结束时触发,常用于清理样式。
  1. 监听目标区域事件
  • dragover:必须调用 event.preventDefault() 来允许放置,否则 drop 事件不会触发。
  • drop:放置时触发,从事件对象中取出被拖拽的索引,与当前目标的位置作比较,重新排列数据并更新 DOM。
// 简化的拖拽排序示例(以列表为例)
const list = document.querySelector('#sortable-list');
let draggedIndex = null;

list.addEventListener('dragstart', (e) => {
  draggedIndex = +e.target.dataset.index;
  e.target.classList.add('dragging');
  e.dataTransfer.effectAllowed = 'move';
});

list.addEventListener('dragover', (e) => {
  e.preventDefault(); // 必须
});

list.addEventListener('drop', (e) => {
  e.preventDefault();
  const target = e.target.closest('li');
  if (!target || draggedIndex === null) return;
  const dropIndex = +target.dataset.index;

  // 重新排列数据数组(伪代码)
  const items = [...dataArray];
  const [movedItem] = items.splice(draggedIndex, 1);
  items.splice(dropIndex, 0, movedItem);

  // 根据新数组重新渲染列表
  renderList(items);
  draggedIndex = null;
});

list.addEventListener('dragend', (e) => {
  e.target.classList.remove('dragging');
});

移动端兼容与推荐方案

原生拖拽 API 在移动端的浏览器中行为不一致(例如 iOS Safari 部分版本不支持)。为了解决这个问题,并且获得更流畅的排序动画,通常选择:

  • SortableJS(推荐):高性能、支持触屏、拖拽列表、网格等,且提供 Vue / React / Angular 专用封装。
  • react-beautiful-dnd(React 生态):功能强大但已停止维护,可用于学习思路。
  • interact.js:兼顾手势与拖放,适合更复杂的交互。

SortableJS 的使用非常简单:

import Sortable from 'sortablejs';

const el = document.getElementById('sortable-list');
new Sortable(el, {
  animation: 150,
  onEnd(evt) {
    // 根据 evt.oldIndex 和 evt.newIndex 更新数据
    console.log(`从 ${evt.oldIndex} 移动到 ${evt.newIndex}`);
  }
});

关键注意点

  • 拖拽过程中避免频繁的大面积 DOM 重排,可以给容器设置 min-height 防止坍缩。
  • 如果列表数据是从后端获取的,排序后通常需要将新的顺序同步到服务端(例如调用排序接口)。
  • 拖拽影像在复杂布局中可能会偏移,可通过 dragstart 中设置自定义幽灵元素或使用库来解决。

31.4.2 文件上传

文件上传是前端高频功能,涉及文件选择、预览、上传进度展示、大文件分片、断点续传等多个层级。我们从基础到进阶逐层展开。

基础版:表单上传

<input type="file" id="fileInput" multiple>
<button id="uploadBtn">上传</button>
const input = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');

uploadBtn.addEventListener('click', async () => {
  const files = input.files;
  if (files.length === 0) return alert('请选择文件');

  const formData = new FormData();
  for (const file of files) {
    formData.append('files', file);
  }

  try {
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
    });
    const result = await response.json();
    console.log('上传成功', result);
  } catch (err) {
    console.error('上传失败', err);
  }
});

说明

  • 使用 FormData 是最简单的上传方式,enctype="multipart/form-data" 由 fetch / XMLHttpRequest 自动设置。
  • multiple 属性支持多选,后端需对应处理。

上传进度展示

原生的 fetch API 目前仍无法直接监听上传进度(只能监听下载进度),因此需要借助 XMLHttpRequest 或第三方库(如 Axios)。

Axios 示例

import axios from 'axios';

const formData = new FormData();
formData.append('file', file);

axios.post('/api/upload', formData, {
  headers: { 'Content-Type': 'multipart/form-data' },
  onUploadProgress(progressEvent) {
    const percent = Math.round(
      (progressEvent.loaded * 100) / progressEvent.total
    );
    console.log(`上传进度:${percent}%`);
  }
});

原生 XHR 实现

const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
  if (e.lengthComputable) {
    const percent = (e.loaded / e.total) * 100;
    // 更新进度条
  }
});

图片预览

用户选择图片后,希望在页面上看到缩略图。可以使用 URL.createObjectURLFileReader

input.addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file.type.startsWith('image/')) return;

  // 方法1:创建临时URL(推荐,性能好)
  const objectUrl = URL.createObjectURL(file);
  const img = document.createElement('img');
  img.src = objectUrl;
  previewContainer.appendChild(img);

  // 注意:使用完成后最好释放内存
  img.onload = () => URL.revokeObjectURL(objectUrl);

  // 方法2:FileReader 转为 Base64(适合小图,需存储)
  const reader = new FileReader();
  reader.onload = (ev) => {
    img.src = ev.target.result;
  };
  reader.readAsDataURL(file);
});

大文件分片上传与断点续传

当文件超过几十 MB 甚至 GB 级别时,一次上传容易因网络抖动而失败,同时占满带宽。此时需要将文件切片,逐片上传,并支持断点续传。

核心步骤

  1. 文件分片:使用 Blob.prototype.slice 方法将文件切分成固定大小的块(例如 5MB)。
  2. 计算文件唯一标识:常使用 SparkMD5 计算整个文件的 MD5(可在 Web Worker 中执行,避免卡顿)。
  3. 上传分片:依次或并发上传切片,后端返回每个分片的状态。
  4. 续传判断:上传前调用后端接口,获取已上传的分片列表,只上传缺失的分片。
  5. 合并分片:所有分片上传完成后,通知后端合并为完整文件。

简化版代码示例:

// 分片大小 5MB
const CHUNK_SIZE = 5 * 1024 * 1024;

async function uploadFile(file) {
  const chunks = Math.ceil(file.size / CHUNK_SIZE);
  const fileHash = await calculateHash(file);   // 使用 SparkMD5 计算 hash

  // 获取已上传的分片列表(略)
  for (let i = 0; i < chunks; i++) {
    const start = i * CHUNK_SIZE;
    const end = Math.min(file.size, start + CHUNK_SIZE);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('hash', fileHash);
    formData.append('index', i);
    formData.append('total', chunks);

    await axios.post('/api/upload-chunk', formData);
  }

  // 通知后端合并
  await axios.post('/api/merge-chunks', { hash: fileHash, total: chunks });
}

关键注意点

  • 并发上传可能导致后端压力过大,需要设置并发数限制(如同时只传 3 个分片)。
  • 分片失败需重试机制,重试一定次数后仍失败则标记为上传失败。
  • 计算文件 MD5 是性能大户,务必在 Web Worker 中进行,避免主线程堵塞。
  • 断点续传方案需要后端配合,只返回缺失切片索引。

拖拽上传
给一个容器绑定 drop 事件,即可实现拖拽上传:

dropZone.addEventListener('drop', (e) => {
  e.preventDefault();
  const files = e.dataTransfer.files;
  handleFiles(files);
});

31.4.3 文件下载

文件下载分为服务端返回资源和前端生成文件两种场景。

场景一:从服务端下载文件

通常服务端返回的二进制流需要通过 JavaScript 触发浏览器下载。

Axios 实现(需指定 responseType: 'blob'):

async function downloadFile(url, filename) {
  try {
    const response = await axios.get(url, {
      responseType: 'blob',   // 关键:以二进制流形式接收
    });

    // 创建下载链接
    const blob = new Blob([response.data]);
    const downloadUrl = window.URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = downloadUrl;
    a.download = filename;     // 指定文件名
    document.body.appendChild(a);
    a.click();
    a.remove();
    window.URL.revokeObjectURL(downloadUrl);
  } catch (error) {
    console.error('下载失败', error);
  }
}

如果将 responseType 设为 json(默认值),二进制数据会被强制转码导致文件损坏。

场景二:前端生成文件并下载

例如导出 CSV、JSON、文本或 Canvas 绘制的图片。

// 生成 CSV 并下载
const data = [['姓名', '年龄'], ['张三', 28], ['李四', 32]];
const csvContent = data.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' }); // BOM 解决中文乱码
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `导出数据_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);

要点

  • 添加 BOM \uFEFF 可让 Excel 正确识别 UTF-8 编码的 CSV。
  • Canvas 可使用 canvas.toBlob() 然后触发下载。
  • 对于超大文件(如几百 MB),前端生成需注意内存消耗,可能更应走服务端生成后提供下载链接。

下载进度与续传

浏览器下载大文件时,自身有进度显示,但如果需要在前端定制进度条,可以使用 fetch 读取流并写入到本地?——前端无法直接写入文件系统(除非借助 File System Access API,但兼容性有限)。实际做法通常是让服务端提供下载链接,浏览器原生的下载管理更成熟。如需实时进度,可让服务端在响应头中返回 Content-Length,前端通过 fetch 流的 ReadableStream 读取并逐步处理,但会占用大量内存,一般不推荐直接改造。

总结: 这三项功能虽然常见,但想要做到稳定、体验流畅,需要前后端默契配合。拖拽排序重在交互细节与移动端兼容;文件上传要处理好尺寸限制、进度反馈和失败重试;下载则要留意二进制数据的正确传递与文件名的美观。掌握这些基础方案并能根据业务定制,是前端开发者的核心能力之一。