在实际项目中,文件上传是一个常见功能。随着业务需求的复杂化,上传方式也不再局限于“选择文件 + 提交”,而是发展出了更灵活的解决方案,比如 分片上传(大文件处理)和 断点续传(网络不稳定下的上传保障)。
本文将结合 @rpldy/uploady,演示一个前端组件 CustomUpload,实现三种上传方式:
-
按钮上传:最基础的文件上传
-
分片上传:适合大文件,避免一次性上传失败
- 断点续传:结合
Tus协议,支持中断后恢复
一、核心组件结构
下面是一个封装好的上传组件 CustomUpload,它根据 props.type 的不同,动态渲染三种上传模式。
import Uploady from "@rpldy/uploady"; // 拖拽上传 import ChunkUpload from "./ChunkUpload"; // 按钮上传 import ButtonUpload from "./ButtonUpload"; // 断点续传 import TusUpload from "./TusUpload"; // 上传接口配置 const destination = { url: '/api/uploads', headers: { 'Authorization': localStorage.getItem('token') } }; const CustomUpload = ({ children, ...props }) => { const mergedProps = { accept: 'image/*', multiple: false, destination, ...props, }; const { type = 'button' } = props; return ( <Uploady {...mergedProps}> {type === 'button' && ( <div> <h1>按钮上传</h1> <ButtonUpload {...mergedProps} multiple={false} /> </div> )} {type === 'chunk' && ( <div> <h1>分片上传</h1> <ChunkUpload {...mergedProps} /> </div> )} {type === 'tus' && ( <div> <h1>断点续传</h1> <TusUpload {...mergedProps} /> </div> )} </Uploady> ); }; export default CustomUpload;
-
Uploady作为核心上传容器,提供统一的上下文。 -
根据传入的
type,渲染不同的上传方式。 -
destination定义了上传接口和请求头(这里通过token做鉴权)。
二、按钮上传(Button Upload)
按钮上传是最常见的上传方式:点击按钮选择文件,上传到后台。
它的特点是:简单直观、适合小文件。
代码中的 ButtonUpload 就是封装了一个基础按钮,并绑定了 Uploady 的上传逻辑。
import { forwardRef, useState } from "react";
import { useRequestPreSend, useAbortItem, useItemAbortListener } from "@rpldy/uploady";
import UploadButton, { asUploadButton } from "@rpldy/upload-button";
import PreviewUpload from "./PreviewUpload";
import { UploadResultContext } from "./UploadResultContext"
/**
* @description 预上传处理
*/
const PreUploadHandle = () => {
useRequestPreSend(async (item) => {
// console.log("item", item);
const file = item.file;
// 你可以在这里做压缩、加密、转 Blob 等处理
// const processed = await myCustomProcess(file);
// item.file = processed;
return {
options: {
destination: {
headers: {
"X-Unique-Upload-Id": `6666`,
}
}
}
};
});
}
// 自定义上传按钮
const CustomButtonUploadComponent = forwardRef((props, ref) => {
const { onClick, ...buttonProps } = props;
const buttonClick = (e) => {
if (onClick) {
onClick(e);
}
}
return (
<UploadButton
{...buttonProps}
ref={ref}
extraProps={{ onClick: buttonClick }}
>
<div className="flex flex-col justify-center items-center border rounded-md w-52" style={{ width: 120, height: 120 }}>
<div style={{ fontSize: '2rem' }}>+</div>
<div className="text-md">上传</div>
</div>
</UploadButton>
)
})
const ButtonUpload = asUploadButton(CustomButtonUploadComponent);
const ButtonUploadComponent = (props) => {
// 上传成功结果
const handleUploadSuccess = async (result) => {
let uploadResult = JSON.parse(result.response);
};
// 传递给展示组件的已完成停止上传结果
const [abortResult, setAbortResult] = useState();
// 停止上传方法
const abort = useAbortItem();
// 预览组件中传过来的已清除的项,在这里停止上传
const abortUpload = (item) => {
for (const key of item) {
if (typeof key === 'string') {
abort(key)
} else {
abort(key.id)
}
}
}
// 监听停止上传
useItemAbortListener((item) => {
// console.log(`${item.id}已经停止上传`);
setAbortResult(item.id)
})
return (
<UploadResultContext.Provider
className="select-none"
value={{ abortResult }}
>
<PreUploadHandle />
<div className="relative">
<ButtonUpload
className="absolute top-0 left-0"
accept="image/*"
grouped={false}
isSuccessfulCall={handleUploadSuccess}
multiple={false}
{...props}
/>
<PreviewUpload
className="absolute top-0 left-0"
parent={'ButtonUpload'}
abortHandle={abortUpload}
/>
</div>
</UploadResultContext.Provider>
)
};
export default ButtonUploadComponent;
3.1、UploadResultContext
import { createContext } from 'react';
export const UploadResultContext = createContext({});
三、分片上传(Chunk Upload)
当文件体积过大(比如视频、压缩包),一次性上传容易失败,也可能导致网络阻塞。这时候就需要 分片上传:
-
将大文件切割成多个小块(chunk)
-
每个小块单独上传
-
后端在接收时重新合并
这种方式能够 提升成功率,并且支持 并行上传 提高速度。
在组件中只需要传入 type="chunk" 即可:
import { useCallback, forwardRef, useState, useImperativeHandle } from 'react';
import { UploadDropZone } from '@rpldy/upload-drop-zone';
import { ChunkedUploady, useChunkStartListener, useChunkFinishListener, useRequestPreSend, useAbortItem, useItemAbortListener, useAbortAll, useAbortBatch, useBatchAbortListener, useBatchAddListener, useAllAbortListener, useBatchStartListener, useBatchFinishListener } from '@rpldy/chunked-uploady';
import { Button } from "antd";
import { asUploadButton } from "@rpldy/upload-button";
import PreviewUpload from "./PreviewUpload";
import { UploadResultContext } from "./UploadResultContext";
import { useRef } from 'react';
/**
* @description: 分片上传信息配置与监听
* @param {*}
* @return {*} 配置项
*/
const ChunkUploadStartListenerComponent = () => {
useChunkStartListener((data) => {
// console.log(data, "分片上传信息", data.chunk.index);
return {
url: `${data.url}`,
};
})
};
/**
* @description: 上传完成监听
* @return {*}
*/
const ChunkUploadFinishListenerComponent = () => {
useChunkFinishListener(({ item, chunk, uploadData }) => {
// console.log(`上传完成的分块Id ${chunk.id} - 上传的数据:${uploadData}`,);
// console.log(`上传完成的进度`, item.completed);
});
};
const ChunkUploadAddListener = ({ onBatchStart }) => {
useBatchAddListener((batch) => {
// console.log(`批量 ${batch.id} 添加`);
onBatchStart(batch)
})
};
// 停止所有上传的监听
const ChunkedUploadAbortAllListener = () => {
useAllAbortListener(() => {
console.log("调用了abortAll,全部停止上传");
});
};
// 分批次上传监听开始
const BatchUploadStartListener = () => {
useBatchStartListener((batch) => {
// console.log('✅ 批次开始上传,batch.id =', batch.id);
// console.log('✅ 批次包含的文件:', batch.items.map(i => i.id));
});
};
// 分批次上传成功监听
const BatchFinishListener = ({ onBatchFinish }) => {
useBatchFinishListener((batch) => {
// console.log('批次已完成:', batch.id, '状态:', batch.state);
onBatchFinish(batch)
});
};
// 监听终止批次成功的回调
const BatchAbortListener = ({ onBatchAbort }) => {
useBatchAbortListener((batch) => {
// console.log('批次已取消:', batch.id);
onBatchAbort(batch)
});
};
// 监听终止单个上传的回调
const UploadAbortItemListener = ({ onAbortItem }) => {
useItemAbortListener((item) => {
onAbortItem(item)
})
};
// 支持点击选择上传
const MyClickableDropZone = forwardRef((props, ref) => {
const { onClick, ...buttonProps } = props;
// 预上传处理
useRequestPreSend(async (item) => {
// console.log("item", item);
const file = item.file;
// 你可以在这里做压缩、加密、转 Blob 等处理
// const processed = await myCustomProcess(file);
// item.file = processed;
return {
options: {
destination: {
headers: {
"X-Unique-Upload-Id": `example-unique-${Date.now()}`,
}
}
}
};
});
const onZoneClick = useCallback(e => {
if (onClick) {
onClick(e);
}
}, [onClick]);
return (
<UploadDropZone
{...buttonProps}
ref={ref}
onDragOverClassName="drag-active"
extraProps={{ onClick: onZoneClick }}
grouped
maxGroupSize={10}
/>
);
});
// 给可拖拽项添加点击上传
const DropZoneButton = asUploadButton(MyClickableDropZone);
const ChunkUpload = (props) => {
// console.log(props, "chunkedUpload-Props");
// 当前正在上传的批次ID
const [activeBatches, setActiveBatches] = useState([]);
// 传递给展示组件的已完成停止上传结果
const [abortResult, setAbortResult] = useState();
// 停止单个文件上传按钮ref
const abortItemBtnRef = useRef();
/**
* @description: 获取添加的批次信息
* @param {*} batchId
* @return {*}
*/
const handleBatchStart = (batch) => {
let id = batch.id;
// console.log('批次开始上传:', id);
setActiveBatches(prev => [...prev, id]);
};
/**
* @description: 取消单个上传任务
* @param {*} item
* @return {*}
* @state 未完成函数
*/
const abortUpload = async (item) => {
for (const key of item) {
// console.log("分片停止上传->", key);
if (typeof key === 'string') {
abortItemBtnRef.current.abort(key);
} else {
abortItemBtnRef.current.abort(key.id);
}
}
}
// 停止单个上传按钮
const UploadAbortItemButton = forwardRef((props, ref) => {
const abort = useAbortItem();
useImperativeHandle(ref, () => {
return {
abort: (id) => abort(id)
}
})
return (
<Button
className="w-16 h-6 border rounded-sm p-6"
style={{
display: "none"
}}
onClick={() => abort(item)}
>
取消上传
</Button>
);
})
// 停止单个上传的回调函数
const handleAbortItem = (item) => {
// console.log("停止单个上传的item", item);
setAbortResult(item)
}
// 停止批次上传按钮
const UploadAbortBatchButton = ({ batchId }) => {
const abortBatch = useAbortBatch();
return (
<Button
className="w-16 h-6 border rounded-sm p-6"
onClick={() => abortBatch(batchId)}
>
取消批次 {batchId}
</Button>
);
}
/**
* @description: 监听获取已停止的批次id
* @return {*}
*/
const handleBatchAbort = (batch) => {
// console.log(batch, "批次已停止");
let abortBatchResult = batch.items.map(item => item.id)
setAbortResult(abortBatchResult)
setActiveBatches(prevActiveBatches => prevActiveBatches.filter((batchId) => batchId !== batch.id))
}
// 批次已完成(!!!注意:单个取消全部批次上传时,取消会触发)
const onBatchFinish = (batch) => {
// console.log("批次已完成");
setActiveBatches(prevActiveBatches => prevActiveBatches.filter((batchId) => batchId !== batch.id))
}
// 停止所有上传按钮
const UploadAbortAllButton = () => {
const abortAll = useAbortAll();
return (
<Button
className="w-16 h-6 border rounded-sm p-6"
onClick={abortAll}
>
取消全部All
</Button>
);
};
return (
<UploadResultContext.Provider
className="select-none"
value={{ abortResult }}
>
<ChunkedUploady
// debug // 开启debug模式
// autoUpload={false}
accept='video/*' // 接受的文件类型 默认全部
chunked={true} // 开启分片上传
destination={{ url: '/api/uploads', headers: { 'Authorization': localStorage.getItem('token') } }} // 上传配置:接口、headers
chunkSize={1024 * 1024 * 1} // 分片大小
sendWithFormData={true} // 是否发送表单数据
params={{ fileItemId: 123 }} // 上传参数
concurrent // 开启并发
maxConcurrent={3} // 最大并发数
retries={5}
{...props} // 继承props
multiple // 是否多选,放在props后面多选
>
{/* 终止所有上传按钮组件 */}
<UploadAbortAllButton />
{/* 监听全部终止上传组件 */}
<ChunkedUploadAbortAllListener />
{/* 监听添加上传内容组件 */}
<ChunkUploadAddListener onBatchStart={handleBatchStart} />
{/* 监听开始上传组件 */}
<ChunkUploadStartListenerComponent />
{/* 监听上传完成组件 */}
<ChunkUploadFinishListenerComponent />
{/* 监听 分批次上传 开始 组件 */}
<BatchUploadStartListener />
{/* 监听终止批次成功的回调组件 */}
<BatchAbortListener onBatchAbort={handleBatchAbort} />
{/* 监听中止单个上传的监听组件 */}
<UploadAbortItemListener onAbortItem={handleAbortItem} />
{/* 分批次上传成功组件(好像是每个分片都是独立的) */}
<BatchFinishListener onBatchFinish={onBatchFinish} />
{/* 停止单个上传按钮组件(测试添加组件才能成功) */}
<UploadAbortItemButton ref={abortItemBtnRef} />
{activeBatches.length > 0 && activeBatches.map((batchId) => (
<UploadAbortBatchButton key={batchId} batchId={batchId} />
))}
<DropZoneButton>
<div className='w-full h-auto flex flex-col justify-center items-center p-4 rounded-md border border-inherit cursor-pointer select-none' >
<div className='text-lg' style={{ marginBottom: '1rem' }}>单击或拖动文件到此区域进行上传</div>
<div className='text-sm text-gray-500'>支持单个文件上传</div>
</div>
</DropZoneButton>
<PreviewUpload parent={'ChunkUpload'} abortHandle={abortUpload} />
</ChunkedUploady>
</UploadResultContext.Provider>
)
};
export default ChunkUpload;
四、断点续传(Tus Upload)
在网络不稳定的情况下,如果上传中断,传统上传方式往往需要重新上传整个文件,非常耗时。
为了解决这个问题,可以使用 Tus 协议,它支持:
-
记录已上传的进度
-
上传中断后,从断点继续
-
特别适合大文件和长时间上传任务
import React, { forwardRef, useCallback } from "react";
import TusUploady, { useClearResumableStore, useTusResumeStartListener, useRequestPreSend, useBatchFinishListener } from "@rpldy/tus-uploady";
import { asUploadButton } from "@rpldy/upload-button";
import { UploadDropZone } from '@rpldy/upload-drop-zone';
import PreviewUpload from "./PreviewUpload";
// 自定义上传按钮
const CustomButtonUploadComponent = forwardRef((props, ref) => {
const { onClick, ...buttonProps } = props;
// 预上传处理
useRequestPreSend(async (item) => {
return {
options: {
destination: {
headers: {
"X-Unique-Upload-Id": `custom-${Date.now()}`,
}
},
params:{
"x-test-param": "foo"
}
}
};
});
const onZoneClick = useCallback(e => {
if (onClick) {
onClick(e);
}
}, [onClick]);
return (
<UploadDropZone
{...buttonProps}
ref={ref}
onDragOverClassName="tus-drag-active"
extraProps={{ onClick: onZoneClick }}
grouped
maxGroupSize={10}
>
<div className='w-full h-auto flex flex-col justify-center items-center p-4 rounded-md border border-inherit cursor-pointer select-none' >
<div className='text-lg' style={{ marginBottom: '1rem' }}>单击或拖动文件到此区域进行上传</div>
<div className='text-sm text-gray-500'>支持单个文件上传</div>
</div>
</UploadDropZone>
)
})
const TusZoneUpload = asUploadButton(CustomButtonUploadComponent);
/**
* @description: 默认情况下tus会存储已上传文件的 URL,以便查询服务器状态并跳过标记为已上传的块。URLs 被保存在本地存储中。这个钩子允许你清除之前保存的 URLs
* @return {*}
*/
const ClearHistoryUploadLocalStoreButton = () => {
// 清除历史上传记录
const clearResumable = useClearResumableStore();
const onClear = () => {
console.log('清除历史上传记录');
clearResumable();
};
return (
<button className="bg-green-500 text-white px-4 py-2 rounded-md mt-2 w-32" onClick={onClear}>
清除上传记录
</button>
);
}
// tus上传监听组件
const TusResumeStartListener = ({ onIsResume }) => {
useTusResumeStartListener(({ url, item, resumeHeaders }) => {
console.log('tusResumeStart', url, item, resumeHeaders);
const isResume = onIsResume(item);
if (!isResume) {
console.log('🚫 取消续传,重新上传', url);
return false
}
// 继续续传,可额外加自定义头
console.log('✅ 继续续传', url);
return {
resumeHeaders: {
'x-another-header': 'foo',
'x-test-override': 'def',
},
};
});
}
// 分批次上传成功监听
const BatchFinishListener = ({ onBatchFinish }) => {
useBatchFinishListener((batch) => {
// console.log('批次已完成:', batch.id, '状态:', batch.state);
onBatchFinish(batch)
});
};
// tus上传组件
const TusUploadComponent = (props) => {
return (
<TusUploady
destination={{ url: '/api/tus' }}
{...props}
chunkSize={1024 * 1024 * 1}
sendDataOnCreate
multiple
concurrent
maxConcurrent={3} // 最大并发数
retries={5}
>
{/* <BatchFinishListener onBatchFinish={(batch) => {
console.log("批次已完成:", batch);
}}
/> */}
<TusResumeStartListener onIsResume={() => {
console.log("是否需要续传?现在默认需要")
return true;
}} />
<TusZoneUpload className="mb-2 shrink-0" />
<ClearHistoryUploadLocalStoreButton className="shrink-0" />
<PreviewUpload className="shrink-0" parent={'TusUpload'} />
</TusUploady>
)
}
export default TusUploadComponent;
五、总结
本文基于 @rpldy/uploady 封装了一个 CustomUpload 组件,支持三种上传模式:
-
按钮上传:适合常规文件,简单易用
-
分片上传:大文件优化,降低失败率
-
断点续传:保障稳定性,适合长时间上传(未完成)
在实际项目中,可以根据 文件大小、网络环境、用户体验要求 来选择合适的上传方式,甚至支持 自动选择策略(例如大于 100MB 时使用分片上传)。
浙公网安备 33010602011771号