
从浏览器原生拖拽事件说起
React并没有内置拖拽上传的组件,通常需要结合浏览器提供的Drag and Drop API来实现。核心事件有四个:dragenter、dragover、dragleave和drop。其中dragover必须调用preventDefault(),否则浏览器会默认在新窗口打开拖入的文件,导致上传逻辑无法触发。另外drop事件也需要阻止默认行为,并通过event.dataTransfer.files拿到用户拖入的文件列表。
在React函数组件中,直接给div元素写onDragOver={handleDragOver}这样的内联事件处理器没有任何问题,但需要注意一个细节:拖拽状态通常需要一个布尔值来控制高亮样式。许多开发者会习惯性地在dragenter时设置setDragging(true),在dragleave时设置setDragging(false)。但是当鼠标从拖拽区域的一个子元素移动到另一个子元素时,dragleave和dragenter会连续触发,导致高亮闪烁。为了避免这个问题,可以使用一个计数器ref来记录当前进入拖拽区域的深度,只有深度归零时才取消高亮。
下面是一段处理拖拽状态的示例代码,使用useRef保存计数器,避免频繁setState带来的性能损耗。
import React, { useRef, useState } from 'react';
function DragUploadArea() {
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);
const inputRef = useRef(null);
const handleDragEnter = (e) => {
e.preventDefault();
dragCounter.current += 1;
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
dragCounter.current -= 1;
if (dragCounter.current === 0) {
setIsDragging(false);
}
};
const handleDragOver = (e) => {
e.preventDefault(); // 必须调用,否则无法触发drop
e.dataTransfer.dropEffect = 'copy';
};
const handleDrop = (e) => {
e.preventDefault();
dragCounter.current = 0;
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
uploadFiles(files);
}
};
return (
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={{
border: '2px dashed #ccc',
padding: '40px',
textAlign: 'center',
background: isDragging ? '#e6f7ff' : '#fff',
transition: 'background 0.2s'
}}
>
将文件拖拽到这里,或
<button onClick={() => inputRef.current.click()}>点击选择文件</button>
<input
ref={inputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => uploadFiles(e.target.files)}
/>
</div>
);
}
上述代码中dragCounter的用法是拖拽组件常见的防抖处理。当用户拖入子元素时,dragenter先触发并加一,随后dragleave也会触发并减一,但不会归零,所以高亮不会消失。只有当鼠标真正离开整个上传区域,计数器才会变为0,此时才取消高亮。同时为了支持点击上传,嵌入了一个隐藏的<input type="file">并通过ref触发点击。
结合XMLHttpRequest实现上传进度显示
拖拽到位之后,需要将文件上传到服务器并显示进度条。虽然可以使用fetch API,但fetch目前不支持上传进度事件,所以最直接的方式是使用XMLHttpRequest。XHR提供了upload.onprogress事件,可以在上传过程中获取loaded和total两个值,从而计算百分比。如果项目中使用axios,也可以利用axios的onUploadProgress配置项,它底层同样基于XHR。
实现进度条的关键是在React状态中维护每个文件的上传进度。由于可能同时上传多个文件,建议使用一个对象或数组来保存每个文件对应的进度。在uploadFiles函数中遍历文件列表,为每个文件创建一个FormData对象,然后发起独立的XHR请求。每个请求的onprogress回调中更新对应文件的进度state。这里需要谨慎处理闭包问题:如果直接在回调中引用循环变量,会出现所有文件进度都指向最后一个文件的情况。解决方案是为每个文件创建一个独立的函数作用域,或者使用let配合块级作用域。
function uploadFiles(files) {
const fileArray = Array.from(files);
fileArray.forEach((file, index) => {
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
// 初始化该文件的进度为0
setProgressMap(prev => ({
...prev,
[file.name]: 0
}));
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://api.ipipp.com/upload');
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
setProgressMap(prev => ({
...prev,
[file.name]: percent
}));
}
};
xhr.onload = () => {
if (xhr.status === 200) {
setProgressMap(prev => ({
...prev,
[file.name]: 100
}));
console.log(`${file.name} 上传成功`);
} else {
setProgressMap(prev => ({
...prev,
[file.name]: -1 // -1表示失败
}));
}
};
xhr.onerror = () => {
setProgressMap(prev => ({
...prev,
[file.name]: -1
}));
};
xhr.send(formData);
});
}
进度条的UI可以用div嵌套实现,外层是灰色背景,内层div的宽度根据进度百分比动态设置。为了更好的用户体验,还可以在上传过程中显示文件名和百分比数字。当上传失败(进度为-1)时,用红色文字提示错误,并提供重试按钮。
需要注意,xhr.upload.onprogress的回调中event.total可能是0或者不准确,所以要用event.lengthComputable判断是否可计算。另外如果上传速度很快,progress事件可能只触发一两次,百分比直接从0跳到100,这是正常现象。
组件化的进度列表与状态管理优化
把上传区域和进度列表拆分成两个组件会让代码更清晰:DragUploadArea负责交互和文件选择,UploadProgressList负责展示每个文件的上传状态和进度条。父组件维护一个包含文件信息(名称、大小、进度、状态)的数组,并通过props传递给进度列表组件。
为了管理更复杂的状态,可以使用useReducer替代多个useState。例如定义action类型:ADD_FILES、UPDATE_PROGRESS、UPLOAD_SUCCESS、UPLOAD_FAIL。reducer根据action更新对应的文件记录。这样做的好处是所有状态变更集中处理,避免多个setState之间的竞态问题。
const initialState = { files: [] };
function uploadReducer(state, action) {
switch (action.type) {
case 'ADD_FILES':
const newFiles = action.payload.map(file => ({
id: `${file.name}_${Date.now()}`,
name: file.name,
size: file.size,
progress: 0,
status: 'pending'
}));
return { files: [...state.files, ...newFiles] };
case 'UPDATE_PROGRESS':
return {
files: state.files.map(f =>
f.id === action.payload.id
? { ...f, progress: action.payload.progress, status: 'uploading' }
: f
)
};
case 'UPLOAD_SUCCESS':
return {
files: state.files.map(f =>
f.id === action.payload.id
? { ...f, progress: 100, status: 'success' }
: f
)
};
case 'UPLOAD_FAIL':
return {
files: state.files.map(f =>
f.id === action.payload.id
? { ...f, status: 'failed' }
: f
)
};
default:
return state;
}
}
在上传逻辑中,需要为每个文件生成一个唯一id,XHR回调中通过dispatch({ type: 'UPDATE_PROGRESS', payload: { id, progress } })来更新进度。重试功能只需要重新发起上传请求,将对应文件的状态重置为pending并重新执行XHR即可。
另外要注意组件卸载时取消未完成的上传请求,避免内存泄漏。可以在useEffect的清理函数中遍历保存的XHR实例并调用abort()。如果使用axios,可以通过CancelToken实现取消。对于拖拽上传场景,取消操作不太常见,但仍然是良好实践。
最后,针对大文件上传,可以考虑加入断点续传或分片上传,但这部分超出了本文的范围。当前实现的单文件XHR上传足以应对大多数中小型文件场景。
React拖拽上传Drag and Drop API上传进度条修改时间:2026-09-28 01:14:20