如何优雅地实现文件上传+文件夹上传+拖拽上传+进度追踪+...?

如何优雅地实现文件上传+文件夹上传+拖拽上传+进度追踪+...?

需求分析:

基础功能

  • 显示

    • 上传文件或文件夹的名字类型大小状态

      • 类型

        • 文件夹的类型可以"文件夹"或或者没有
        • 文件的类型范围MIME
      • 大小

        • 文件夹的大小为该文件夹及其子文件夹下所有文件的大小总和
      • 状态

        • 最少应该有的状态(假设上传一定成功,不会出现错误)

          • 未上传
          • 上传中(当处于此状态的时候要实时显示上传进度)
          • 上传完成
        • 其他扩展状态

          • 暂停
          • ...
    • 文件夹显示可以展开和折叠
    • 上传按钮在上传中是disabled,并且在没有文件上传的时候点击是不会触发上传的(可以在此时给出一个提醒)
  • 多个文件或文件夹上传
  • 文件选中方式

    • 通过input[type="file"]来选中
    • 拖拽
  • 进度追踪

    • 一个文件的上传进度为loaded/total
    • 一个文件夹的上传进度为该文件夹下(包括子文件夹)的所有文件的loaded之和除以所有文件的total之和。

image.png

扩展功能

  • 取消某些选中的将要上传文件
  • 暂停

    • 可以暂停上传的文件,之后再继续上传的时候不重新开始上传而是从上传完成的部分之后上传。
  • 中断重试

    • 和暂停功能类似,只是这里的中断是不是人主动要求的,而是外部的一些不可预料的事件(网络中断、服务器故障..)造成的。
  • 文件上传性能优化

    • 大文件分片上传
    • 并发上传
    • ....

我自己用react+tailwindcss尝试勉强实现了基础功能。
维护了一个树结构来记录文件上传的状态。
其中每个节点的字段含义

  • type

    • 0表示文件, 1表示文件夹
  • file

    • File: 对于文件来说是File对象,记录了文件的一些信息。
    • { name: <directory name>, size: <directory size>, type: "文件夹"}: 对于文件夹来说只是记录了文件夹的名字、大小和类型。
  • children:

    • 对于文件来说该字段是没有的。
    • 对于文件夹来说它是一个数组,包含一些节点,表示该文件夹下的所有文件和文件夹。
  • parent

    • [node, idx]: node表示该节点的父节点,idx表示该节点在其父节点的children中位于第几个。

      • 问题: 如果我们想要删除某一个节点(相当于取消某个文件的上传)

        • 方案1 splice

          • 其后面的兄弟节点的parent也要改变,所有依赖idx的数据也改变。
          • 当我们需要恢复该节点的时候,还需要回退我们的修改(需要记住之前的idx,否则渲染的顺序就发生变化了。)
          • 实现起来还是比较麻烦,但没有占据不必要的空间。
        • 方案2 假的删除

          • 在其父元素的children数组的对应位置(idx)设置为null,表示该节点已经被删除。
            • 恢复的时候也容易。
          • 实现起来简单,但是可能浪费了一些空间,特别是当一层中文件条目比较多的时候。
        • 你有什么更好的方案吗?也可以重新设计这个node,使得删除、恢复更容易,且占据的空间少。
    • null: 表明该节点为根节点。
  • progress:

    • num: 对于文件来说,它就是一个数值。由XMLHttpRequest实例的中的upload对象上progress事件中得到的event.targetevent.loaded计算而来得到。(loaded * 100 /tatoal)。
    • {loaded: [...], total: [....]}: 对于文件夹来说,我们需要统计其子节点的loadedtoatal之后再计算得到其最终的进度。

      • 当一个子节点的的进度发生改变的时候,它会顺着parent,去修改其祖先节点中progress.loadedprogress.total。(这就是我们需要parent字段的原因)

问题:这棵树在该react项目中为一个状态,应该保持它的不可变性。对于这种复杂的数据结构该怎么保持它的不可变性呢?借助immer?

下面为更新进度的过程,该怎么修改保持不可变性呢?

// src/utils.js
export function uploadProcess(uploadFileList, setUploadFileList, setStatus) {
  // TopNode用作哨兵,让边界条件处理更容易。
  for (let i = 0; i < uploadFileList.length; ++i) {
    uploadFileList[i].parent = [TopNode, i];
    TopNode.children.push(uploadFileList[i]);
  }
  TopNode.progress = {
    loaded: Array(uploadFileList.length).fill(0),
    total: Array(uploadFileList.length).fill(0),
  };
  uploadFileOrDirectory(
    TopNode,
    () => {
      setUploadFileList([...uploadFileList]);
    },
    setStatus
  );
}
function uploadFileOrDirectory(entry, update, setStatus) {
  if (entry === null) return;
  if (entry.type === 0) {
    const xhr = new XMLHttpRequest();
    const data = new FormData();
    data.append("file", entry.file);
    xhr.open("POST", UPLOAD_URL);

    xhr.upload.addEventListener("progress", (e) => {
      const percent = Number(((e.loaded * 100) / e.total).toFixed(0));
      entry.progress = percent;
      propagateProcessUpward(entry, e.loaded, e.total, setStatus);
      update();
    });
    xhr.send(data);
  } else {
    for (const e of entry.children) {
      uploadFileOrDirectory(e, update, setStatus);
    }
  }
}

function propagateProcessUpward(entry, loaded, total, setStatus) {
  if (entry.parent) {
    const [parent, idx] = entry.parent;
    parent.progress.loaded[idx] = loaded;
    parent.progress.total[idx] = total;

    propagateProcessUpward(
      parent,
      parent.progress.loaded.reduce((acc, cur) => acc + cur, 0),
      parent.progress.total.reduce((acc, cur) => acc + cur, 0),
      setStatus
    );
  } else {
    // topNode
    if (
      entry.progress.loaded.every(
        (item, idx) => entry.progress.total[idx] === item
      )
    )
      setStatus(2);
  }
}
  • 我的实现一点也不优雅而且应该还有很多问题,欢迎指正。
  • 如果你有更好的的方案,欢迎在解答区分享。最好有完整的代码和较为详细的思路讲解。

附:

阅读 1.1k
avatarAI BotBETA

你的实现已经包含了大部分需要的功能,并且你对数据结构的设计也很到位。然而,确实有一些可以改进和优化的地方。以下是我对你的问题的一些解答和建议:

问题1:如何优雅地删除和恢复节点?

一种更优雅的方法是使用一种称为"路径枚举"或"路径压缩"的技术。你可以将每个节点的路径(从根节点到该节点的所有父节点的索引)存储在每个节点中,而不是仅仅存储父节点的索引。这样,当你需要删除或恢复一个节点时,你只需要更新该节点的路径,而不需要更新其所有祖先节点的子节点。这种方法可以在O(1)的时间内完成删除和恢复操作,而不需要遍历整个树。

问题2:如何保持数据结构的不可变性?

对于保持数据结构的不可变性,你可以使用一种称为"函数式编程"的技术。在这种编程风格中,你永远不会直接修改数据,而是创建新的数据来替代旧的数据。在JavaScript中,你可以使用immer库来帮助你实现函数式编程。immer允许你以一种更直观、更易于理解的方式创建新的数据,而不需要手动创建所有的新数据。

问题3:如何更新进度并保持数据结构的不可变性?

对于更新进度,你可以将进度信息存储在每个节点中,并在上传过程中更新这些信息。当你需要更新整个数据结构的进度时,你可以遍历整个树并收集所有节点的进度信息。然后,你可以使用这些信息来创建一个新的数据结构,其中包含最新的进度信息。

下面是一个简化的示例代码,展示了如何使用immer和函数式编程来更新进度并保持数据结构的不可变性:

import produce from "immer";

const initialState = {
  // 你的初始状态
};

function updateProgress(draftState, entry, loaded, total) {
  const traverse = (node) => {
    if (node.type === 0) {
      node.progress = (loaded * 100) / total;
    } else {
      node.progress = {
        loaded: node.children.reduce((acc, cur) => acc + cur.progress.loaded, 0),
        total: node.children.reduce((acc, cur) => acc + cur.progress.total, 0),
      };
      node.progress.loaded = (node.progress.loaded * 100) / node.progress.total;
      node.children.forEach(traverse);
    }
  };

  traverse(draftState.find((node) => node.id === entry.id));
}

function handleUpload(state, action) {
  return produce(state, (draftState) => {
    switch (action.type) {
      case "UPDATE_PROGRESS":
        updateProgress(draftState, action.payload.entry, action.payload.loaded, action.payload.total);
        break;
      // 处理其他动作
      default:
        break;
    }
  });
}

// 在你的组件中使用handleUpload来更新状态

在这个示例中,updateProgress函数使用递归来遍历树并更新每个节点的进度。handleUpload函数使用immerproduce函数来创建一个新的状态,其中包含最新的进度信息。

请注意,这只是一个简化的示例,你可能需要根据你的具体需求来修改和扩展它。另外,你可能还需要处理其他类型的动作,如添加、删除和恢复节点等。你可以使用类似的方法来处理这些动作,以保持数据结构的不可变性。

1 个回答
import produce from "immer";

const initialState = {
  files: []
};

const uploadReducer = produce((draft, action) => {
  switch (action.type) {
    case 'ADD_FILE':
      draft.files.push(action.payload);
      break;
    case 'UPDATE_PROGRESS':
      const file = draft.files.find(file => file.id === action.payload.id);
      if (file) {
        file.progress = action.payload.progress;
      }
      break;
    default:
      break;
  }
}, initialState);

function updateProgress(id, progress) {
  return {
    type: 'UPDATE_PROGRESS',
    payload: { id, progress }
  };
}
撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题
宣传栏