任务队列及大文件上传实现(前端篇)

引言

本文将从零开始,详细拆解一个大文件上传项目(前端部分)的完整开发流程。我们会从项目搭建入手,先实现一个支持暂停、重试、并发控制的任务队列(TaskQueue),再基于此构建具备切片上传、哈希生成、进度监控等功能的上传器(Uploader),最终完成可交互的演示 Demo(建议搭配 《大文件上传服务实现(后端篇)》 食用^_^,或自行搭建后端服务 )。

核心目录

bash 复制代码
src
├─App.css
├─App.tsx
├─index.css
├─main.tsx
├─assets
│      react.svg
├─constant
│      index.ts         // 项目中涉及常量的声明位置 
├─playground
│      taskQueue.tsx    // 任务队列演示UI
│      uploader.tsx     // 文件上传演示UI
├─uploader
│      index.ts         // 入口文件
│      taskQueue.d.ts   // 任务队列ts类型声明文件
│      taskQueue.ts     // 任务队列功能实现
│      type.ts          // 任务队列、文件上传类型入口
│      uploader.d.ts    // 文件上传ts类型声明文件
│      uploader.ts      // 文件上传功能实现
└─utils
       index.ts         // 一些工具函数等

搭建过程

准备工作

使用 vite 搭建一个文件上传项目

shell 复制代码
yarn create vite

依次填写/选择以下信息

shell 复制代码
Project name: upload-file-fe
Select a framework: React
Select a variant: Typescript
Use rolldown-vite (Experimental): No
Install with yarn and start now?: Yes

实现任务队列

  • 支持暂停、重启,可手动终止(手动终止所有功能不可用),
  • 支持在执行期间、暂停期间、执行结束时新增新增任务。
  • 支持任务执行失败后自动重试
  • 支持设置最大重试次数、最大task失败数量、task最大并发数量

任务队列类型声明

路径:src/uploader/taskQueue.d.ts

typescript 复制代码
export enum TaskStatus {
  //  默认状态
  Default = 0,
  //  准备运行(任务放入 requestIdleCallback 到开始执行这段时间内的状态)
  PrepareRun = 1,
  // 运行中
  Running = 2,
  // 重试
  Retry = 3,
  // 暂停中
  Pause = 4,
  // 失败
  Failded = 5,
  // 成功
  Success = 6,
}

export type Task<T = unknown> = {
  id: number;
  status: TaskStatus;
  failCount: number;
  error: unknown;
  result: T;
  idleCallbackId?: number;
  executor: (task: Task<T>) => Promise<T>;
};

export type QueueTasksRecord = {
  failedTasks: Task[];
  successTasks: Task[];
  runningTasks: Task[];
  pendingTasks: Task[];
  tasks: Task[];
};

export type QueueTaskConfig = {
  /**
   *  task执行失败后,可重试的次数
   */
  maxRetries: number;
  /**
   *  task执行失败后,可重试的次数
   */
  maxConcurrency: number;
  /**
   *  最大失败任务数量
   *
   *  达到该数量会停止所有任务,队列处理器置为失败状态
   */
  maxFailCount: number;
  /**
   *  待执行的任务队列
   *
   *  队列实例创建后,也可通过实例的 appendTasks 追加任务
   */
  tasks?: Pick<Task, "id" | "executor">[];
  /**
   *  失败任务数量达到上限或所有任务执行完成(执行完成不代表成功)时触发
   */
  onFinish?: (tasksRecord: QueueTasksRecord) => void;
  /**
   *  任务队列状态变更时触发
   */
  onStatusChange?: (status: TaskQueueStatus) => void;
  /**
   *  TODO:任务队列中某个队列(数量、内部某个任务状态)变更时触发
   */
  // onChange?: (tasksRecord: QueueTasksRecord) => void;
};

export type QueueTaskContext = Exclude<QueueTaskConfig, "tasks">;

export enum TaskQueueStatus {
  // 默认状态
  Default = 0,
  // 运行中
  Running = 1,
  // 失败
  Failded = 2,
  // 成功
  Complete = 3,
  // 已暂停
  Pause = 4,
  // 中止
  Cancel = 5,
}

路径:src/uploader/type.ts

typescript 复制代码
export * from "./taskQueue.d";

任务队列功能实现

准备任务队列中涉及的常量,路径:src/constant/index.ts

typescript 复制代码
export const DefaultMaxRetries = 2;
export const DefaultMaxConcurrency = 3;
export const DefaultMaxFailCount = 1;

路径:src/uploader/taskQueue.tsx

typescript 复制代码
import {
  DefaultMaxConcurrency,
  DefaultMaxFailCount,
  DefaultMaxRetries,
} from "../constant";
import {
  TaskQueueStatus,
  TaskStatus,
  type QueueTaskConfig,
  type QueueTaskContext,
  type Task,
} from "./type";

// 所有队列key
const QueueKeys = [
  "runningTasks",
  "pendingTasks",
  "failedTasks",
  "successTasks",
  "tasks",
] as const;

// 可执行的任务状态
const EnableRunTaskStatus = [
  TaskStatus.Retry,
  TaskStatus.Default,
  TaskStatus.Pause,
];

export class TaskQueue {
  // 全部任务
  private tasks: Task[] = [];
  private taskMap: Record<string, Task> = {};

  // 各种队列
  private runningTasks: Task[] = []; // 运行中
  private pendingTasks: Task[] = []; // 待执行
  private failedTasks: Task[] = []; // 失败
  private successTasks: Task[] = []; // 成功

  private ctx!: QueueTaskContext;
  private status!: TaskQueueStatus;

  constructor(config: Partial<QueueTaskConfig> = {}) {
    this.install(config);
    this.updateStatus(TaskQueueStatus.Default);
  }

  getStatus = () => this.status;

  getTaskQueues = () => {
    const queues = {} as Record<(typeof QueueKeys)[number], Task[]>;
    for (const k of QueueKeys) {
      queues[k] = this[k];
    }
    return queues;
  };

  private updateStatus = (status: TaskQueueStatus) => {
    // TaskQueue 已中止,停止一切行为
    if (this.status === TaskQueueStatus.Cancel) {
      console.warn("【TaskQueue】TaskQueue已被中止,修改状态失败");
      return;
    }
    if (status === this.status) return;

    this.status = status;

    this.ctx.onStatusChange?.(status);

    if ([TaskQueueStatus.Failded, TaskQueueStatus.Complete].includes(status)) {
      const queues = this.getTaskQueues();
      this.ctx.onFinish?.(queues);
    }
  };

  private install = async ({
    maxRetries = DefaultMaxRetries,
    maxConcurrency = DefaultMaxConcurrency,
    maxFailCount = DefaultMaxFailCount,
    tasks,
    ...rest
  }: Partial<QueueTaskConfig> = {}) => {
    this.ctx = {
      maxRetries,
      maxConcurrency,
      maxFailCount,
      ...rest,
    };

    if (tasks?.length) this.appendTasks(tasks);
  };

  private cancelAllIdleRunTask(tasks: Task[]) {
    for (const task of tasks) {
      if (task.status === TaskStatus.PrepareRun && task.idleCallbackId) {
        cancelIdleCallback(task.idleCallbackId);
      }
    }
  }

  /**
   *  中止任务队列
   */
  async cancel() {
    this.updateStatus(TaskQueueStatus.Cancel);
    this.cancelAllIdleRunTask(this.runningTasks);
  }

  /**
   *  暂停任务队列
   */
  pause() {
    this.updateStatus(TaskQueueStatus.Pause);
    this.cancelAllIdleRunTask(this.runningTasks);
  }

  start() {
    if (!this.pendingTasks.length && !this.runningTasks.length) {
      throw new Error(
        "【TaskQueue】任务队列不可为空,请在配置中传入 tasks 或通过 appendTasks 添加任务"
      );
    }

    // 避免多个巡检同时进行
    if (this.status === TaskQueueStatus.Running) {
      console.warn("【TaskQueue】已处于执行状态");
      return this;
    }

    this.updateStatus(TaskQueueStatus.Running);
    this.inspectionQueue();
    return this;
  }

  /**
   *  新增task,默认支持去重
   */
  appendTasks(
    tasks: Pick<Task, "id" | "executor">[],
    filterExistedTask = true
  ) {
    tasks.forEach(({ id, executor }) => {
      const isExisted = !!this.taskMap[id];

      if (filterExistedTask && isExisted) return;
      if (!filterExistedTask && isExisted)
        throw new Error(`【TaskQueue】添加任务失败:任务(${id})已存在`);

      const task = {
        id,
        status: TaskStatus.Default,
        failCount: 0,
        executor,
      } as Task;

      this.tasks.push(task);
      this.taskMap[task.id] = task;
      this.pendingTasks.push(task);
    });

    return this;
  }

  private prepareRun(task: Task) {
    task.status = TaskStatus.PrepareRun;
    task.idleCallbackId = window.requestIdleCallback(() => this.run(task));
  }

  /**
   *  执行task
   *  执行完毕后更新任务状态,巡检任务队列
   */
  private async run(task: Task) {
    if (this.status !== TaskQueueStatus.Running) {
      console.info("【TaskQueue】非执行状态");
      return;
    }

    task.status = TaskStatus.Running;

    let isSuccess: boolean;
    let error: unknown;
    let result: unknown;

    try {
      result = await task.executor(task);
      isSuccess = true;
    } catch (err) {
      isSuccess = false;
      error = err;
    }

    if (isSuccess) {
      // task执行成功
      task.status = TaskStatus.Success;
      task.result = result;
    } else if (!isSuccess && task.failCount < this.ctx.maxRetries) {
      // task执行失败重试
      task.status = TaskStatus.Retry;
      task.failCount++;
      delete task.result;
    } else {
      // task执行失败
      console.warn("【TaskQueue】任务执行失败", task);
      task.status = TaskStatus.Failded;
      task.error = error;
    }

    this.inspectionQueue();
  }

  /**
   *  巡检 runningTasks、pendingTasks、failedTasks
   */
  private inspectionQueue = () => {
    const isPauseOrCancel = [
      TaskQueueStatus.Pause,
      TaskQueueStatus.Cancel,
    ].includes(this.status);

    // taskQueue 已暂停、终止,清空 prepareRunTasks
    if (isPauseOrCancel) {
      const prepareRunTasks = this.runningTasks.filter(
        (e) => e.status === TaskStatus.PrepareRun
      );

      for (const task of prepareRunTasks) {
        task.status = TaskStatus.Pause;
        if (task.idleCallbackId) cancelIdleCallback(task.idleCallbackId);
      }

      console.log("队列已暂停或已终止,巡检停止");

      return;
    }

    const failed: Task[] = [];
    const complete: Task[] = [];
    const others: Task[] = [];

    for (const task of this.runningTasks) {
      if (task.status === TaskStatus.Failded) {
        this.failedTasks.push(task);
        failed.push(task);
      } else if (task.status === TaskStatus.Success) {
        this.successTasks.push(task);
        complete.push(task);
      } else {
        others.push(task);
      }
    }
    this.runningTasks = others;

    // 失败task数量达到上限,触发结束(失败)
    if (this.failedTasks.length >= this.ctx.maxFailCount) {
      console.log(
        "【TaskQueue】失败task数量达到上限",
        this.failedTasks,
        this.ctx.maxFailCount
      );
      this.updateStatus(TaskQueueStatus.Failded);
      return;
    }

    // 无可执行任务,触发结束
    if (!this.runningTasks.length && !this.pendingTasks.length) {
      console.log("【TaskQueue】无可执行任务");
      this.updateStatus(TaskQueueStatus.Complete);
      return;
    }

    // runningTasks 存在空缺位置,从 pendingTask 中取出相应数据的 task 补充进去
    const vacancies = this.ctx.maxConcurrency - this.runningTasks.length;
    if (this.pendingTasks.length && vacancies > 0) {
      this.runningTasks.push(...this.pendingTasks.splice(0, 0 + vacancies));
    }

    // 可准备执行的任务
    const prepareRunTask = this.runningTasks.filter((e) =>
      EnableRunTaskStatus.includes(e.status)
    );
    if (prepareRunTask.length) {
      for (const task of prepareRunTask) {
        this.prepareRun(task);
      }
    } else {
      console.log("【TaskQueue】无可准备执行的任务", this.runningTasks);
    }
  };
}

路径:src/uploader/index.ts

typescript 复制代码
export * from './taskQueue'
export * from './type'

任务队列演示Demo

安装tailwindcss

bash 复制代码
yarn add @tailwindcss/vite tailwindcss

完成vite配置,路径:vite.config.ts

diff 复制代码
+ import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
+  plugins: [react(), tailwindcss()],
-  plugins: [react()],
});

路径:src/index.css

css 复制代码
@import "tailwindcss";

路径:src/playground/taskQueue.tsx

typescript 复制代码
import { useRef, useState } from "react";
import { TaskQueue, TaskQueueStatus } from "../uploader";

export function TaskQueueDemo() {
  const lastTaskId = useRef(0);
  const taskQueue = useRef<TaskQueue>(null);

  const [status, setStatus] = useState<TaskQueueStatus>();

  const getTasks = () => {
    const tasks = Array.from({ length: 5 }, (_, i) => {
      const taskId = lastTaskId.current + i;

      return {
        id: taskId,
        executor: async () => {
          console.log(`task(${taskId}) 开始执行`);
          return await new Promise((rs) => {
            setTimeout(() => {
              console.log(`task(${taskId}) 执行完毕`);
              rs(taskId);
            }, 2000);
          });
        },
      };
    });

    lastTaskId.current += tasks.length;
    return tasks;
  };

  const handleTaskQueueRun = () => {
    if (taskQueue.current) {
      const status = taskQueue.current.getStatus();
      if (status === TaskQueueStatus.Running) {
        alert("当前存在任务队列且处于运行中,请取消后再重新初始化");
        return;
      }
    }

    const instance = new TaskQueue({
      tasks: getTasks(),
      onFinish(tasksRecord) {
        console.log("任务队列执行完毕", tasksRecord);
      },
      onStatusChange: (status) => {
        setStatus(status);
      },
    }).start();

    taskQueue.current = instance;
  };

  const handleTaskQueuePause = () => {
    taskQueue.current!.pause();
  };

  const handleTaskQueueAppend = () => {
    taskQueue.current!.appendTasks(getTasks()).start();
  };

  const handleTaskQueueCancel = () => {
    taskQueue.current?.cancel();
    taskQueue.current = null;
  };

  const handleTaskQueueRestart = () => {
    taskQueue.current!.start();
  };

  return (
    <>
      <div>
        <button className="ml-[20px]" onClick={handleTaskQueueRun}>
          初始化并执行
        </button>
        {!!taskQueue.current && (
          <>
            <button className="ml-[20px]" onClick={handleTaskQueueAppend}>
              增加任务并执行
            </button>
            {status === TaskQueueStatus.Pause && (
              <button className="ml-[20px]" onClick={handleTaskQueueRestart}>
                重启
              </button>
            )}
            {status === TaskQueueStatus.Running && (
              <button className="ml-[20px]" onClick={handleTaskQueuePause}>
                暂停
              </button>
            )}

            <button className="ml-[20px]" onClick={handleTaskQueueCancel}>
              终止并销毁
            </button>
          </>
        )}
      </div>
    </>
  );
}

路径:src/App.tsx

typescript 复制代码
import "./App.css";
import { TaskQueueDemo } from "./playground/taskQueue";

function App() {
  return (
    <>
      <TaskQueueDemo />
    </>
  );
}

export default App;

实现Uploader

安装ky、crypto-js

bash 复制代码
yarn add ky crypto-js
yarn add @types/crypto-js -D

Uploader类型声明

路径:src/uploader/uploader.d.ts

typescript 复制代码
import { type Options } from "ky";

// 上传器配置
export interface UploaderConfig {
  /**
   *  上传的文件
   */
  file: File;
  /**
   *  上传服务接口URL
   */
  url: string;
  /**
   *  chunk大小
   *  默认:1024*3,单位:字节(B),1024 - 5*1024(1MB-5MB)之间。
   */
  chunkSize?: number;
  /**
   *  上传失败是否重试、重试次数上限
   *  默认:3,传入 false / 0 会禁止重试,传入大于0数字会设为重试次数上限
   */
  retry?: number | false;
  /**
   *  自定义请求的入参
   */
  getUploadPayload?: (
    chunk: ChunkInfo,
    index: number
  ) => {
    body?: Options["body"];
    searchParams?: Options["searchParams"];
  };
  /**
   *  自定义请求
   *  如果 getUploadPayload 满足不了你的需求,postRequest 可以从根本上解决你的问题
   */
  postRequest?: () => Promise<UploadTask>;
  /**
   *  从服务端获取上传文件状态(文件状态、待上传Chunk)
   *
   */
  getFileStatus?: () => Promise<UploadTask>;

  /**
   *  上传进度更新时(chunk上传结束)触发
   */
  onProgress?: (progress: UploadProgress) => void;
  /**
   *  上传任务结束时触发
   */
  onStatusChange?: (status: UploaderStatus) => void;
}

// 上传器状态
export enum UploaderStatus {
  /** 初始化完成 */
  Created = 0,
  /** 准备生成文件 chunk */
  WillGenerateChunks = 1,
  /** 文件 chunk 成功 */
  ChunksGenerated = 2,
  /** 准备生成文件 hash */
  WillGenerateHash = 3,
  /** 文件 hash 成功 */
  HashGenerated = 4,
  /** 文件上传中 */
  Uploading = 5,
  /** 文件上传成功 */
  Success = 6,
  /**
   *  文件上传成功
   *  包含以下场景:服务端返回chunk所属文件上传失败、任务队列中失败任务数量达到上限、任务队列所有任务执行完成但存在失败任务
   * */
  Failed = 7,
  /** 已暂停 */
  Pause = 8,
}

/**
 *  上传进度
 */
export type UploadProgress = {
  /**
   *  全部chunk数量
   */
  totalChunk: number;
  /**
   *  待上传chunk数量
   */
  pendingChunk: number;
  /**
   *  上传进度百分比
   */
  percentage: number;
  /**
   *  上传速率,单位:字节/秒
   */
  speed: number;
};

/**
 *  文件hash
 *
 *  该hash可能是一个非精准的结果。由于文件较大时直接用文件本身生成hash可能存在性能问题,Uplaoder.generate 通过特殊算法生成表示文件hash。
 */
type FileHash = string;

// 上传器上下文
export interface UploaderContext extends UploaderConfig {
  hash: FileHash;
  chunkSize: number;
  // 所有chunk
  chunks: ChunkInfo[];
  extension?: string;
}

export type ChunkInfo = {
  // chunk 下标
  index: number;
  // 原始File截取的起始下标
  start: number;
  // 原始File截取的结束下标
  end: number;
  // 原始File截取得到的chunk
  blob: Blob;
};

// api响应体
export type API_Response<T = Record<string, unknown>> = {
  code: number;
  data: T;
  success: boolean;
  msg?: string;
};

export type UploadSearchParams = {
  // 文件 hash
  hash: string;
  // chunk 下标
  index: number;
  // 文件大小
  fileSize: number;
  // chunk大小
  chunkSize: number;
  // 文件拓展名
  extension?: string;
};

/**
 * 文件状态
 */
export enum FileStatus {
  /** 上传中 */
  Uploading = 0,
  /** 合并中 */
  Mergeing = 1,
  /** 已上传 */
  Success = 2,
  /** 上传失败 */
  Failed = 3,
}

export type UploadTask = {
  /** 任务ID(chunk index) */
  id: number;
  /** 文件hash */
  hash: string;
  status: FileStatus;
  /** 待上传chunks 仅在文件处于"上传中"返回 */
  pendingUploadChunks?: number[];
};

路径:src/uploader/type.ts

diff 复制代码
export * from "./taskQueue.d";
+ export * from "./uploader.d";

Uploader功能实现

路径:src/constant/index.ts

typescript 复制代码
export const DefaultFileSizeThreshold = 1024 * 1024 * 500;
// 2MB
export const DefaultChunkSize = 1024 * 1024 * 1;
export const DefaultUploadRetry = 3;

/** file hash crypto key */
export const FILE_HASH_CRYPTO_KEY = "FILE_HASH";

路径:src/utils/index.ts

typescript 复制代码
import CryptoJS from "crypto-js";
import { UploaderStatus } from "../uploader/type";

export async function blobToHash(blob: Blob, algorithm = "SHA256") {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = (r) => {
      const result = r.target?.result as ArrayBuffer;

      if (!result) {
        reject(new Error("【blobToHash】blob读取失败"));
        return;
      }

      try {
        // 将 ArrayBuffer 转换为 WordArray
        const wordArray = CryptoJS.lib.WordArray.create(result);
        let hash;

        // 根据算法计算哈希
        switch (algorithm.toUpperCase()) {
          case "MD5":
            hash = CryptoJS.MD5(wordArray);
            break;
          case "SHA1":
            hash = CryptoJS.SHA1(wordArray);
            break;
          case "SHA256":
            hash = CryptoJS.SHA256(wordArray);
            break;
          case "SHA512":
            hash = CryptoJS.SHA512(wordArray);
            break;
          default:
            reject(new Error(`不支持的哈希算法: ${algorithm}`));
            return;
        }

        // 返回十六进制哈希值
        resolve(hash.toString(CryptoJS.enc.Hex));
      } catch (error) {
        reject(error);
      }
    };

    reader.onerror = () => reject(new Error("【blobToHash】blob读取失败"));
    reader.readAsArrayBuffer(blob);
  });
}

const UploaderTextMap = {
  [UploaderStatus.Created]: "初始化完成",
  [UploaderStatus.WillGenerateChunks]: "正在生成文件 Chunks",
  [UploaderStatus.ChunksGenerated]: "文件 Chunks 生成成功",
  [UploaderStatus.WillGenerateHash]: "正在生成文件 Hash",
  [UploaderStatus.HashGenerated]: "文件 Hash 生成成功",
  [UploaderStatus.Uploading]: "文件上传中",
  [UploaderStatus.Failed]: "文件失败",
  [UploaderStatus.Success]: "文件成功",
  [UploaderStatus.Pause]: "已暂停",
};
export function getUploaderText(status?: UploaderStatus) {
  if (status === undefined) return;
  return UploaderTextMap[status];
}

路径:src/uploader/uploader.ts

typescript 复制代码
import ky, { type Options } from "ky";
import {
  FileStatus,
  UploaderStatus,
  type API_Response,
  type ChunkInfo,
  type UploaderConfig,
  type UploaderContext,
  type UploadProgress,
  type UploadSearchParams,
  type UploadTask,
} from "./type";
import { TaskQueue } from "./taskQueue";
import {
  DefaultChunkSize,
  DefaultFileSizeThreshold,
  DefaultUploadRetry,
} from "../constant";
import { blobToHash } from "../utils";

const CustomHashChunkCount = 100;

/**
 *  支持功能
 *
 *  1. 上传速度展示
 *  2. 上传进度展示
 *  3. 单个chunk上传失败重试
 *  4. 切片上传
 *  5. 秒传
 *  6. 断点续传
 *  7. 上传并发控制
 *  8. 支持暂停/重启
 */
export class Uploader {
  private ctx: UploaderContext;

  private taskQueue?: TaskQueue;

  private status!: UploaderStatus;

  constructor({
    chunkSize = DefaultChunkSize,
    retry = DefaultUploadRetry,
    file,
    ...rest
  }: UploaderConfig) {
    this.ctx = Object.assign({ chunkSize, retry, file }, rest, {
      hash: "",
      chunks: [],
    });

    const names = file.name.split(".");
    const extension = names.length > 1 ? names[names.length - 1] : null;
    if (extension) {
      this.ctx.extension = extension;
    }

    this._updateStatus(UploaderStatus.Created);
  }

  private _updateStatus = (status: UploaderStatus, isRestart?: boolean) => {
    if (this.status === UploaderStatus.Pause && !isRestart) return;
    if (this.status === status) return;

    // 文件上传成功或失败,中止任务队列
    if ([UploaderStatus.Success, UploaderStatus.Failed].includes(status)) {
      this.taskQueue?.cancel();
    }

    this.ctx.onStatusChange?.(status);
    this.status = status;
  };

  start = async () => {
    const startAt = performance.now();

    // 生成文件所有chunks
    this._updateStatus(UploaderStatus.WillGenerateChunks);
    this.ctx.chunks = this.ctx.chunks = await this.generateChunks();
    this._updateStatus(UploaderStatus.ChunksGenerated);
    const generatedChunksAt = performance.now();
    console.log(
      "generate chunks success, execution time:",
      `${generatedChunksAt - startAt} ms`
    );

    // 生成hasn
    this._updateStatus(UploaderStatus.WillGenerateHash);
    this.ctx.hash = await this.generateHash();
    this._updateStatus(UploaderStatus.HashGenerated);
    const generatedHashAt = performance.now();
    console.log(
      "generate hash success, execution time:",
      `${generatedHashAt - generatedChunksAt} ms`
    );

    const pendingChunks = await this.getPendingChunk();

    if (pendingChunks?.length) {
      this.taskQueue = new TaskQueue({
        onFinish: (tasks) => {
          const { failedTasks } = tasks;
          console.log(
            "TaskQueue onFinish is emit,but has failedTasks",
            failedTasks
          );

          // 存在失败的任务
          if (failedTasks?.length) {
            this._updateStatus(UploaderStatus.Failed);
          } else {
            // 所有任务都成功了,但服务器仍未返回文件上传成功的结果也视为失败
            this._updateStatus(UploaderStatus.Failed);
          }
        },
      });
      this.appendUploadTask(this.taskQueue, pendingChunks);

      this.taskQueue.start();
    }

    return this;
  };

  pause = () => {
    this._updateStatus(UploaderStatus.Pause);
    this.taskQueue?.pause();
  };

  restart = () => {
    // 当Uploader 暂停之后,刚好有个chunk task执行结束返回文件上传成功、失败时
    this._updateStatus(UploaderStatus.Uploading, true);
    this.taskQueue?.start();
  };

  getPendingChunk = async () => {
    const { getFileStatus = this._getFileStatus } = this.ctx;

    try {
      const ret = await getFileStatus();

      if (ret.status === FileStatus.Success) {
        this._updateStatus(UploaderStatus.Success);
      } else if (ret.status === FileStatus.Uploading) {
        this._updateStatus(UploaderStatus.Uploading);
        return ret.pendingUploadChunks;
      } else if (ret.status === FileStatus.Mergeing) {
        this._updateStatus(UploaderStatus.Uploading);
      } else {
        // 其他情况视为失败
        this._updateStatus(UploaderStatus.Failed);
      }
    } catch (error) {
      console.error("【Uploader】(getPendingChunk) 发生错误", error);
    }
  };

  // 从服务端获取文件的状态
  _getFileStatus = async () => {
    return await this.postChunk({
      chunk: this.ctx.chunks[0],
      index: 0,
      onProgress: this.ctx.onProgress,
    });
  };

  // 将文件转为chunks
  private generateChunks = async () => {
    const { file, chunkSize } = this.ctx;

    const fr = new FileReader();
    return await new Promise<ChunkInfo[]>((resolve) => {
      fr.onload = (r) => {
        const fl = r.target?.result;

        if (!fl) throw new Error("【Uploader】(generateChunks) 读取文件失败");

        let start = 0;

        const chunks = [] as ChunkInfo[];

        while (start < file.size) {
          const preStart = start;
          const nextEnd = (start += chunkSize);

          const blob = file.slice(preStart, nextEnd);
          chunks.push({
            index: chunks.length,
            start: preStart,
            end: nextEnd,
            blob,
          });
        }

        resolve(chunks);
      };

      fr.readAsArrayBuffer(file);
    });
  };

  /**
   *  生成文件hash
   *  由于文件较大时直接用文件本身生成hash可能存在性能问题,这里通过特殊算法生成表示文件的hash
   *
   *  hash的计算:
   *    - 当文件小于500MB时,文件所有chunks + 文件修改时间
   *    - 当文件大于等于500MB时,文件部分chunks(第一个、1%、2%、...、99%、最后一个) + 文件修改时间
   *  
   *  实测1.39GB文件,未使用该算法全量chunk计算hash耗时 41960ms,使用该算法后耗时3066ms。
   */
  private generateHash = async (
    fileSizeThreshold = DefaultFileSizeThreshold
  ) => {
    const { file } = this.ctx;
    const fileSize = file.size;

    let chunks: ChunkInfo[] = [];

    if (fileSize > fileSizeThreshold) {
      const indexOffset = Math.floor(
        this.ctx.chunks.length / CustomHashChunkCount
      );
      const chunkIndexs: number[] = Array.from(
        {
          length: CustomHashChunkCount,
        },
        (_, i) =>
          i === CustomHashChunkCount - 1
            ? CustomHashChunkCount - 1
            : i * indexOffset
      );

      chunks = chunkIndexs.map((i) => this.ctx.chunks[i]);
    } else {
      chunks = this.ctx.chunks;
    }

    const chunkHashs = await Promise.all(chunks.map((e) => blobToHash(e.blob)));
    const chunkHashsStr = JSON.stringify({
      chunkHashs,
      fileLastModified: file.lastModified,
    });

    const chunksHash = await blobToHash(
      new Blob([chunkHashsStr], { type: "application/json" })
    );

    return chunksHash;
  };

  private _getUploadPayload = async (chunk: ChunkInfo, index: number) => {
    const { chunkSize, file, hash, extension } = this.ctx;

    const body = new FormData();
    body.append("chunk", chunk.blob);

    const searchParams: UploadSearchParams = {
      hash: hash,
      index: index,
      fileSize: file.size,
      chunkSize: chunkSize,
      extension,
    };

    return { body, searchParams };
  };

  private postChunk = async ({
    chunk,
    index,
    onProgress,
  }: {
    chunk: ChunkInfo;
    index: number;
    onProgress?: (progress: UploadProgress) => void;
  }) => {
    const {
      getUploadPayload = this._getUploadPayload,
      postRequest = this._postRequest,
    } = this.ctx;
    try {
      const { body, searchParams } = await getUploadPayload(chunk, index);

      const startAt = performance.now();
      const ret = await postRequest(this.ctx.url, body, searchParams);
      const endAt = performance.now();

      // 更新网速、上传进度
      const totalChunk = this.ctx.chunks.length;
      const pendingChunk = ret.pendingUploadChunks?.length ?? 0;
      const percentage =
        Math.floor(((totalChunk - pendingChunk) / totalChunk) * 100 * 100) /
        100;
      const speed = chunk.blob.size / (endAt - startAt);

      onProgress?.({ totalChunk, pendingChunk, percentage, speed });

      return ret;
    } catch (error) {
      return Promise.reject(error);
    }
  };

  private _postRequest = async (
    url: string,
    body?: Options["body"],
    searchParams?: Options["searchParams"]
  ) => {
    try {
      const res = await ky.post<API_Response<UploadTask>>(url, {
        body,
        searchParams: searchParams,
      });
      const ret = await res.json();

      if (ret.success !== true) {
        throw new Error("上传失败");
      }

      return Promise.resolve(ret.data);
    } catch (error: unknown) {
      return Promise.reject(error);
    }
  };

  private appendUploadTask = async (taskQueue: TaskQueue, chunks: number[]) => {
    const tasks = chunks.map((id) => ({
      id,
      executor: async () => {
        try {
          const ret = await this.postChunk({
            chunk: this.ctx.chunks[id],
            index: id,
            onProgress: this.ctx.onProgress,
          });

          if (ret.status === FileStatus.Success) {
            this._updateStatus(UploaderStatus.Success);
          } else if (ret.status === FileStatus.Uploading) {
            this._updateStatus(UploaderStatus.Uploading);
            if (ret.pendingUploadChunks?.length) {
              this.appendUploadTask(taskQueue, ret.pendingUploadChunks);
            }
          } else if (ret.status === FileStatus.Mergeing) {
            this._updateStatus(UploaderStatus.Uploading);
          } else {
            this._updateStatus(UploaderStatus.Failed);
          }

          return ret;
        } catch (error) {
          return Promise.reject(error);
        }
      },
    }));

    taskQueue.appendTasks(tasks);
  };
}

路径:src/uploader/index.ts

diff 复制代码
+ export * from './uploader'
export * from './taskQueue'
export * from './type'

Uploader演示Demo

路径:src/playground/uploader.tsx

typescript 复制代码
import { useMemo, useRef, useState, type ChangeEvent } from "react";
import { getUploaderText } from "../utils";
import { Uploader, UploaderStatus, type UploadProgress } from "../uploader";

const URL = "http://127.0.0.1:3010/api/file/upload";

export function UploaderDemo() {
  const ref = useRef<File>(null);

  const [uploadProgress, setUploadProgress] = useState<UploadProgress | null>(
    null
  );
  const [uploader, setUploader] = useState<Uploader | null>(null);
  const [status, setStatus] = useState<UploaderStatus | null>(null);
  const { percentage, speed } = uploadProgress || {};

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];

    console.log("handleChange", file);

    if (!file) return;

    setUploader(null);
    setStatus(null);
    ref.current = file;
  };

  const handleStart = () => {
    if (!ref.current) {
      alert("请先添加文件");
      return;
    }

    console.log("file", ref.current);

    const uploader = new Uploader({
      url: URL,
      file: ref.current,
      onProgress(progress) {
        setUploadProgress(progress);
      },
      onStatusChange(status) {
        console.log("onStatusChange", status);

        setStatus(status);
      },
    });

    setUploader(uploader);
    uploader.start();
  };

  const handlePause = () => {
    uploader?.pause();
  };
  const handleRestart = () => {
    uploader?.restart();
  };

  const uploadSpeed = useMemo(() => {
    if (typeof speed !== "number") return;
    return Math.floor((speed / 1024) * 100) / 100;
  }, [speed]);

  return (
    <>
      <div className="controner flex items-center">
        <div className="flex-none">
          <input type="file" onChange={handleChange} />
        </div>
        <div className="flex-auto">
          {typeof status !== "number" && (
            <button onClick={handleStart}>开始</button>
          )}
          {status === UploaderStatus.Pause && (
            <button onClick={handleRestart}>重新开始</button>
          )}
          {status === UploaderStatus.Uploading && (
            <button onClick={handlePause}>暂停</button>
          )}
        </div>
      </div>
      <div className="upload-info mt-[20px]">
        <div className="flex">
          <div>
            状态:
            {typeof status === "number" ? getUploaderText(status) : "待上传"}
          </div>
          {status === UploaderStatus.Uploading && (
            <div className="ml-[20px]">速率:{uploadSpeed}KB/ms</div>
          )}
          {typeof status === "number" &&
            [
              UploaderStatus.Uploading,
              UploaderStatus.Failed,
              UploaderStatus.Success,
              UploaderStatus.Pause,
            ].includes(status) && (
              <div className="ml-[20px]">上传进度:{percentage}%</div>
            )}
        </div>
      </div>
    </>
  );
}

路径:src/App.tsx

diff 复制代码
import "./App.css";
- import { TaskQueueDemo } from "./playground/taskQueue";
+ // import { TaskQueueDemo } from "./playground/taskQueue";
+ import { UploaderDemo } from "./playground/uploader";

function App() {
  return (
    <>
-      <TaskQueueDemo />
+      {/* <TaskQueueDemo /> */}
+      <UploaderDemo />
    </>
  );
}

export default App;

总结

该大文件上传项目由 TaskQueueUploader 两个模块组成,项目中任务执行、暂停、重启、取消、并发控制等管理功能是通过TaskQueue实现的。Uploader实现file分块、hash生成、chunk task处理等功能。

TaskQueue核心方法inspectionQueue会巡检所有任task队列完成更新task状态、分发任务队列、更新taskQueue示例状态、控制并发等操作。

Uploader通过特殊算法选取文件部分 chunk 生成哈希,有效优化了大文件全量 chunk 哈希计算的性能问题 ------ 以 1.3GB 文件为例,耗时从 41 秒大幅降至 3 秒。不过,但这种取部分 chunk 计算出哈希并非精确匹配,存在不同文件被误判为相同的可能性。为此,前端会额外向后台提供文件大小、分块大小、文件扩展名等字段,这些信息共同构成文件 "指纹",以此进一步提升文件匹配的准确性。

相关推荐
残冬醉离殇3 小时前
缓存与同步:前端数据管理的艺术
前端
前端西瓜哥3 小时前
常用的两种填充策略:fit 和 fill
前端
Lsx_3 小时前
ECharts 全局触发click点击事件(柱状图、折线图增大点击范围)
前端·javascript·echarts
不吃香菜的猪4 小时前
构建时变量注入:Vite 环境下 SCSS 与 JavaScript 的变量同步机制
前端·javascript·scss
代码哈士奇4 小时前
无界微前端学习和使用
前端·学习
一枚前端小能手4 小时前
🚀 Node.js 25重磅发布!快来看看吧
前端·javascript·node.js
csj504 小时前
前端基础之《React(3)—webpack简介-集成JSX语法支持》
前端·react
JarvanMo4 小时前
🚀 使用 GitHub Actions 自动化 Flutter CI/CD — Android 和 iOS (TestFlight) 部署
前端
濑户川4 小时前
Vue3 项目创建指南(Vue-CLI vs Vite 对比)
前端·javascript·vue.js