这三项功能是 Web 应用中出镜率极高的交互场景,从后台管理的列表整理到用户内容上传,再到报表导出,几乎每个项目都会涉及。下面分别讲解它们的实现思路、核心 API 和需要注意的坑点。
31.4.1 拖拽排序
拖拽排序(Drag and Drop Sort)允许用户通过鼠标拖拽(或触摸滑动)来重新排列一组元素的顺序。原生浏览器提供了拖拽事件体系,但在移动端的支持并不理想,因此实际生产环境中常搭配第三方库(如 SortableJS)来获得跨平台体验。
原生实现核心步骤
- 为每个可拖拽元素设置
draggable="true"
这一步告诉浏览器该元素可以被拖拽。
- 监听拖拽源事件
dragstart:拖拽开始时触发,可以在这里设置拖拽影像和传递数据(例如被拖拽元素的索引data-index)。dragend:拖拽结束时触发,常用于清理样式。
- 监听目标区域事件
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.createObjectURL 或 FileReader。
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 级别时,一次上传容易因网络抖动而失败,同时占满带宽。此时需要将文件切片,逐片上传,并支持断点续传。
核心步骤:
- 文件分片:使用
Blob.prototype.slice方法将文件切分成固定大小的块(例如 5MB)。 - 计算文件唯一标识:常使用 SparkMD5 计算整个文件的 MD5(可在 Web Worker 中执行,避免卡顿)。
- 上传分片:依次或并发上传切片,后端返回每个分片的状态。
- 续传判断:上传前调用后端接口,获取已上传的分片列表,只上传缺失的分片。
- 合并分片:所有分片上传完成后,通知后端合并为完整文件。
简化版代码示例:
// 分片大小 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 读取并逐步处理,但会占用大量内存,一般不推荐直接改造。
总结: 这三项功能虽然常见,但想要做到稳定、体验流畅,需要前后端默契配合。拖拽排序重在交互细节与移动端兼容;文件上传要处理好尺寸限制、进度反馈和失败重试;下载则要留意二进制数据的正确传递与文件名的美观。掌握这些基础方案并能根据业务定制,是前端开发者的核心能力之一。