有点小九九
简单的事情认真做

在实际项目中,文件上传是一个常见功能。随着业务需求的复杂化,上传方式也不再局限于“选择文件 + 提交”,而是发展出了更灵活的解决方案,比如 分片上传(大文件处理)和 断点续传(网络不稳定下的上传保障)。

本文将结合 @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 组件,支持三种上传模式:

  1. 按钮上传:适合常规文件,简单易用

  2. 分片上传:大文件优化,降低失败率

  3. 断点续传:保障稳定性,适合长时间上传(未完成)

在实际项目中,可以根据 文件大小、网络环境、用户体验要求 来选择合适的上传方式,甚至支持 自动选择策略(例如大于 100MB 时使用分片上传)。

posted on 2025-08-30 13:46  有点小九九  阅读(50)  评论(0)    收藏  举报