在实际项目中,文件上传是一个常见功能。随着业务需求的复杂化,上传方式也不再局限于“选择文件 + 提交”,而是发展出了更灵活的解决方案,比如 分片上传(大文件处理)和 断点续传(网络不稳定下的上传保障)。
本文将结合 @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.ProviderclassName="select-none"value={{ abortResult }}><PreUploadHandle /><div className="relative"><ButtonUploadclassName="absolute top-0 left-0"accept="image/*"grouped={false}isSuccessfulCall={handleUploadSuccess}multiple={false}{...props}/><PreviewUploadclassName="absolute top-0 left-0"parent={'ButtonUpload'}abortHandle={abortUpload}/></div></UploadResultContext.Provider>
)
};export default ButtonUploadComponent;
3.1
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 }}groupedmaxGroupSize={10}/>
);
});
// 给可拖拽项添加点击上传
const DropZoneButton = asUploadButton(MyClickableDropZone);const ChunkUpload = (props) => {// console.log(props, "chunkedUpload-Props");// 当前正在上传的批次IDconst [activeBatches, setActiveBatches] = useState([]);// 传递给展示组件的已完成停止上传结果const [abortResult, setAbortResult] = useState();// 停止单个文件上传按钮refconst 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 (<ButtonclassName="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 (<ButtonclassName="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 (<ButtonclassName="w-16 h-6 border rounded-sm p-6"onClick={abortAll}>取消全部All</Button>
);};return (<UploadResultContext.ProviderclassName="select-none"value={{ abortResult }}><ChunkedUploady// debug // 开启debug模式// autoUpload={false}accept='video/*' // 接受的文件类型 默认全部chunked={true} // 开启分片上传destination={{ url: '/api/uploads', headers: { 'Authorization': localStorage.getItem('token') } }} // 上传配置:接口、headerschunkSize={1024 * 1024 * 1} // 分片大小sendWithFormData={true} // 是否发送表单数据params={{ fileItemId: 123 }} // 上传参数concurrent // 开启并发maxConcurrent={3} // 最大并发数retries={5}{...props} // 继承propsmultiple // 是否多选,放在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 }}groupedmaxGroupSize={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 (<TusUploadydestination={{ url: '/api/tus' }}{...props}chunkSize={1024 * 1024 * 1}sendDataOnCreatemultipleconcurrentmaxConcurrent={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 时使用分片上传)。
