关于【因文件数量过多导致antd Upload上传组件卡死】的替换方案

536 阅读5分钟

1. 分析问题

对文件夹中的所有文件进行上传时,如果目标文件夹中的文件数量过多(比如:大于 10 万个文件),直接使用 antd 中的文件上传组件(Upload)必然出现卡死页面的现象。在查阅资料之后,我们发现出现该现象的原因有以下几点:

  • 事件处理负担过重
    • 每个文件在上传过程中都会触发一系列的事件,如 onChangeonProgressonError 等。Upload 组件需要对这些事件进行监听和处理。当文件数量庞大时,事件的触发频率会非常高,从而导致页面卡顿现象。
  • 渲染性能问题
    • 在文件上传的过程中,Upload 组件需要对每一个文件生成对应的 ui 展示文件的上传状态等信息。如果文件过多,频繁地操作 dom 结构极大占用了浏览器的资源,出现页面卡顿现象。

image.png image.png

2. 解决思路

2.1. 从生活中寻找解决方法

在现实生活中,并不缺少支持用户上传本地文件到云端的平台(比如:百度网盘、夸克网盘)。当用户将文件拖入平台的上传页面时,这些平台并没有因为文件的数量过多、体积多大而出现崩溃的现象。由此可知,一定存在解决方案。

2.2. 思路

原生 html 中的 input 文件上传组件并不会频繁地触发相关事件也不会频繁地渲染 ui 结构,它只是当所有文件全部获取完毕之后只触发一次 onchange方法。开发者可以从 onchange 回调函数中获取用户上传的所有文件。

<input type="file" webkitdirectory directory multiple
   // 只会调用一次
  onChange={(data)=>{
    console.log(data,'fileData')
  }}
/>

image.png image.png 仅使用原生的 input 组件完成需求的话,开发效率真的很高吗?真的是最优解吗?

试想一下:如果我们使用原生的 input 完成,确实解决了页面卡顿的现象。但是,文件列表的 ui 结构、对已上传的文件的操作、文件上传状态等基础功能全部需要手写!这工作量非同一般。而且,Upload 组件对文件上传列表功能的实现效果堪称完美。于是,我们决定了最终的方案:

  1. 在 Upload 组件下放置一个 input 组件,并利用 css 将其隐藏。
  2. 当用户点击 Upload 组件时,进行事件的劫持,将事件转移到 input 组件中。
  3. 当用户选择完毕目标文件夹后,利用 input 中的 onchange 方法得到所有的文件。
  4. 在 onchange 事件回调函数中处理文件的批量上传逻辑。

3. 将思路转为代码

我们根据以上的思路,完成代码逻辑:

  1. 构建 UI 结构
  2. 当用户点击 Upload 组件时,进行事件的劫持,将事件转移到 input 组件中。
export default ()=>{
  // input 组件实例对象
  const inputRef = useRef();
  return <>
    <Upload
      action="https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload"
      directory
      fileList={fileUploadErrorList}
      // 其他逻辑自行补充
      >
      <Button
        onClick={(e) => {
          // 阻止事件冒泡
          e.stopPropagation();
          // 点击后,将事件转移到 input 组件上
          inputRef.current.click();
        }}
        >
        上传文件
      </Button>
    </Upload>
    <input
      style={{ display: "none" }}
      ref={inputRef}
      type="file"
      webkitdirectory
      directory
      multiple
      />
  </>
}
  1. 当用户选择完毕目标文件夹后,利用 input 中的 onchange 方法得到所有的文件。
  2. 在 onchange 事件回调函数中处理文件的批量上传逻辑。
// input 组件 onChange方法回调函数
const onChange = (data) => {
  upload(Object.values(data.target.files));
};

// 分批上传,默认5个一组
const upload = async (list, size = 5) => {
  const total = list.length;
  for (let i = 0; i < total; i += size) {
    const requests = list.slice(i, i + size).map((item) => {
      const formData = new FormData();
      formData.append("file", item);
      // uploadReq返回值是一个异步任务
      return uploadReq(formData);
    });
    // 该批次的异步任务统一执行
    const resultData = await Promise.allSettled(requests);
    // 由于文件过多,只将上传失败的文件进行展示。
    let errorList = [];
    resultData.map((res) => {
      errorList = [];
      if (!res?.value?.result?.success) {
        // 得到FormData中的文件对象file
        const file = res?.value?.file?.get("file");
        file.status = "error";
        file.response = res?.value?.result;
        errorList.push(file);
      }
    }); 
  }
};

// 上传文件--请求
const uploadReq = async (formData) => {
  try {
    const response = await fetch("http://localhost:5173/upload/file", {
      method: "POST",
      body: formData,
      credentials: "include",
      withCredentials: true,
    });
    return { result: await response.json(), file: formData };
  } catch (error) {
    return {
      result: error?.message || "网络异常,请稍后再试!",
      file: formData,
    };
  }
};

通过以上代码,已经可以完美的解决业务问题。写到这里,先给自己来一个一键三连赞👍🏻👍🏻👍🏻。其实,在 B 端页面开发领域,文件上传业务可谓司空见惯。如果能将以上的分批上传逻辑抽离成一个独立的 hook ,既可以提高团队的开发效率又可以在面试的过程中作为一个业务难点分享给面试官,一举两得岂不美哉。看到这里,你是不是会想直接把上述代码中的 upload 的方法提取出去,不就行了!如果你要这样想的话,我只能说:“兄弟,格局稍微打开一点。”

废话不多说,上才艺!

4. 构建异步任务栈,实现文件分批上传

  • 首先,在自定义的 hook 中,必须维护一个栈类型的数据结构。
  • 其次,hook 向外暴漏一个追加异步任务的方法 addTask
  • 最后,暴漏执行异步任务的方法,此方法接受一个回调函数。异步任务执行时,将执行结果通过调用回调函数的方式传递给外界。
export function useSyncFileLoadstack(request) {
  const stack = [];
  let uploadedCount = 0;

  const addTask = (data) => {
    if (Array.isArray(data) && data.length > 0) {
      // 进栈
      stack.push(...data);
    } else {
      stack.push(data);
    }
    return { run };
  };

  const run = async (callback = () => {}) => {
    const len = stack.length;
    while (uploadedCount < len) {
      const data = stack.pop();
      uploadedCount++;
      try {
        const res = await data.request?.();
        callback(res);
      } catch (error) {
        callback(res);
      }
    }
  };
  return { addTask };
}
// 测试使用 -- input 的 onChange 方法改写
const App = () => {
  // 导入 hook
  const { addTask } = useSyncFileLoadstack();
  const onChange = async (data) => {
    const files = Object.values(data.target.files).map((item) => ({
      request: () => {
        const formData = new FormData();
        formData.append("file", item);
        // 这是一个异步任务-发请求
        return uploadReq(formData);
      },
    }));

    addTask(files).run((res) => {
      // 每一个任务的执行结果可以从这里获取。
      console.log(res, "file");
    });
  };
  // xxx其他逻辑代码xxx
};