Expres+TS+Vue3实现大文件分片上传、预览、下载(附完整代码)

1,033 阅读5分钟

欢迎阅读手写Express+TS手写对象存储专栏。这系列文章旨在为您提供一个全面的指南,带您一步步构建一个功能齐全的对象存储系统。对象存储系统在大数据时代发挥着重要的作用,能够高效地管理和存储大量非结构化数据,如图片、视频和文档等。

这是专栏中的第四节,本文将带您深入了解文件上传与下载功能的实现。我们将从基础的文件上传与下载开始,逐步介绍如何配置和使用multer处理文件上传,并实现支持分片上传和断点续传的高级功能。此外,我们还将编写高效的文件下载接口,优化文件读取和传输过程,确保用户体验的流畅和系统的稳定。

通过本章的学习,读者将能够掌握文件处理的核心技术,应用于各种实际项目中。

接口文档

文件上传接口

名称地址方法功能说明角色是否要求登录
单文件上传/api/files/upload/singlePOST上传单个文件登录用户
多文件上传/api/files/upload/multiplePOST上传多个文件登录用户
分片上传/api/files/upload/chunkPOST上传文件分片登录用户

文件下载接口

名称地址方法功能说明角色是否要求登录
文件下载/api/files/download/:fileNameGET下载指定文件登录用户
文件列表/api/files/listGET获取文件列表登录用户
文件预览/api/files/preview/:fileNameGET预览指定文件登录用户

上传接口实现

配置中间件

安装multer

首先,我们需要安装multer库。multer是一个Node.js中间件,用于处理multipart/form-data类型的表单数据,主要用于文件上传。

npm install multer
npm i --save-dev @types/multer

配置multer

接下来,我们需要配置multer以支持文件上传。我们会创建一个上传文件夹来存储上传的文件,并配置multer中间件。

// src/config/multerConfig.ts
import multer from 'multer';

// 设置上传文件夹
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    cb(null, `${Date.now()}-${file.originalname}`);
  },
});

const upload = multer({ storage });

export default upload;

单/多文件上传

这部分我们在前两天的文章@2024年了,express中文件上传有变化吗?中讲过就不多赘述了,基本就是一个逻辑

创建上传接口

我们将创建一个文件上传的API接口,并使用multer中间件处理文件上传。

// src/controllers/fileController.ts
import { Request, Response } from 'express';
import upload from '../config/multerConfig';

// 单文件上传
export const uploadSingleFile = (req: Request, res: Response) => {
  upload.single('file')(req, res, (err) => {
    if (err) {
      return res.status(500).json({ message: 'File upload failed', error: err.message });
    }
    return res.status(200).json({ message: 'File uploaded successfully', file: req.file });
  });
};

// 多文件上传
export const uploadMultipleFiles = (req: Request, res: Response) => {
  upload.array('files', 10)(req, res, (err) => {
    if (err) {
      return res.status(500).json({ message: 'File upload failed', error: err.message });
    }
    return res.status(200).json({ message: 'Files uploaded successfully', files: req.files });
  });
};

创建文件上传路由

我们需要在Express应用中创建路由来处理文件上传请求。这里使用了authMiddleware中间件来保护路由

// src/routes/fileRoutes.ts
import express from 'express';
import { uploadSingleFile, uploadMultipleFiles } from '../controllers/fileController';

const router = express.Router();

router.post('/upload/single', authMiddleware, uploadSingleFile);
router.post('/upload/multiple', authMiddleware, uploadMultipleFiles);

export default router;

在主应用中使用文件上传路由

最后,我们需要在主应用中引入并使用文件上传路由。

import express from "express";
import cors from "cors";
import morgan from "morgan";
import helloRoute from "./routes/helloRoute";
import { initializeMongoose } from "./dao/mongodb";
import authRoutes from "./routes/authRoutes";
import userRoutes from "./routes/userRoutes";
import fileRoutes from "./routes/fileRoutes";

const app = express();
const port = process.env.PORT || 3000;

// 初始化
initializeMongoose();

// 配置中间件
app.use(express.json()); // 使用 Express 内置的 JSON 解析中间件
app.use(express.urlencoded({ extended: true })); // 使用 Express 内置的 URL 编码解析中间件
app.use(cors());
app.use(morgan("dev"));

// 配置路由
app.use("/api", helloRoute);
app.use("/api/auth", authRoutes);
app.use("/api/user", userRoutes);
app.use('/api/files', fileRoutes);

app.get("/", (req, res) => {
  res.send("Hello, Object Storage System!");
});

app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

export { app };

分片上传

这里是我们本章的重点——分片上传。这是一种将大型文件分割成多个小片段(分片),然后分别上传这些片段的技术。这种方法可以有效地提高上传大文件的效率和可靠性。下面是分片上传的基本原理及其实现步骤:

基本原理

  1. 文件分割:将一个大文件分割成多个较小的片段(分片)。
  2. 并行上传:同时上传多个分片,以提高上传速度。
  3. 断点续传:如果上传过程中出现网络中断或其他问题,可以从中断点继续上传,而不需要重新上传整个文件。
  4. 合并分片:所有分片上传完成后,服务器端将这些分片重新合并成一个完整的文件。

实现步骤

  1. 分割文件
    • 客户端将大文件按固定大小(例如1MB或5MB)分割成若干个小片段。
    • 每个分片都会被赋予一个唯一的标识符(如序号)。
  2. 上传分片
    • 客户端通过多个并行的HTTP请求上传这些分片。
    • 每个请求包含分片的内容以及相关的元数据信息(如文件ID、分片序号、总分片数等)。
  3. 断点续传
    • 在上传过程中,客户端会记录已成功上传的分片信息。
    • 如果上传过程中断,客户端可以查询已上传的分片,并从未完成的分片开始继续上传。
  4. 分片合并
    • 当所有分片上传完成后,服务器端接收到“完成上传”的请求。
    • 服务器端根据分片的顺序将其合并成一个完整的文件。

优点

  • 提高上传效率:通过并行上传多个分片,可以显著减少上传时间。
  • 增强可靠性:即使网络中断,只需重新上传失败的分片,而不是整个文件。
  • 适应大文件上传:能够处理超出单次上传限制的大文件。

接口设计

理论上应该写三个接口,创建任务/查询任务、上传分片、合并分片。这里比较懒上传分片和合并分片放在一个里面了,当然现在怎么写一起的后面就得怎么拆开~

  1. 初始化上传任务

    • 客户端调用此接口来创建一个新的分片上传任务。
    • 服务器生成一个唯一的任务ID,并返回给客户端。
    POST /upload/initiate
    Content-Type: application/json
     
    {
      "fileName": "largefile.zip",
      "fileSize": 104857600  // 文件大小(字节)
    }
    

    响应示例

    {
      "taskId": "unique-task-id",
      "totalChunks": 100
    }
    
  2. 上传分片

    • 客户端上传每个分片时,携带任务ID、分片索引、总分片数、文件名和当前分片的MD5值等信息。
    • 服务器接收并保存每个分片,并验证分片的完整性。
    • 检查任务是否所有分片都已经完成,已完成的自动合并
    POST /upload/chunk
    Content-Type: multipart/form-data
     
    {
      "taskId": "unique-task-id",
      "chunkIndex": 0,
      "totalChunks": 100,
      "fileName": "largefile.zip",
      "chunkMd5": "md5-value-of-chunk",
      "chunk": <file chunk data>
    }
    

    响应示例

    {
      "message": "Chunk uploaded successfully"
    }
    

代码实现

npm i uuid

npm i --save-dev @types/uuid

初始化上传任务的控制器逻辑
// src/controllers/fileController.ts
import { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import fs from 'fs';

const UPLOAD_DIR = path.resolve(__dirname, "../../uploads");
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB

// 开始任务
export const initiateUpload = (req: Request, res: Response) => {
  const { fileName, fileSize } = req.body;
  const taskId = uuidv4();
  const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
  console.log(`Initiating upload for ${fileName}, taskId: ${taskId}`);

  // 创建任务目录
  const taskDir = path.join(UPLOAD_DIR, taskId);
  if (!fs.existsSync(taskDir)) {
    fs.mkdirSync(taskDir, { recursive: true });
  }

  res.status(200).json({ taskId, totalChunks });
};
上传分片的控制器逻辑
// src/controllers/fileController.ts
import { Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';

const UPLOAD_DIR = path.resolve(__dirname, "../../uploads");
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB

// 分片上传
export const uploadChunk = (req: Request, res: Response) => {
  const { taskId, chunkIndex, totalChunks, fileName, chunkMd5 } = req.body;
  const chunk = req.file;
  if (!chunk) {
    res.status(400).send({ message: "File upload failed" });
    return;
  }

  const chunkDir = path.join(UPLOAD_DIR, taskId);
  if (!fs.existsSync(chunkDir)) {
    res.status(400).send({ message: "Invalid task ID" });
    return;
  }

  const chunkPath = path.join(chunkDir, `${fileName}-chunk-${chunkIndex}`);
  const fileBuffer = fs.readFileSync(chunk.path);
  const computedMd5 = createHash("md5").update(fileBuffer).digest("hex");

  if (computedMd5 !== chunkMd5) {
    res.status(400).send({ message: "MD5 mismatch" });
    return;
  }

  fs.renameSync(chunk.path, chunkPath);

  console.log(`Chunk ${chunkIndex} of ${totalChunks} uploaded`);

  // 检查所有分片是否都已上传完毕
  const uploadedChunks = fs
    .readdirSync(chunkDir)
    .filter((file) => file.startsWith(fileName + "-chunk-")).length;

  console.log(
    `Uploaded chunks: ${uploadedChunks}/${totalChunks}`,
    uploadedChunks === totalChunks
  );

  if (uploadedChunks === Number(totalChunks)) {
    console.log(`All chunks uploaded, starting merge for ${fileName}`);

    const filePath = path.join(UPLOAD_DIR, fileName);
    const writeStream = fs.createWriteStream(filePath);

    const mergeChunks = () => {
      let currentChunk = 0;

      const appendNextChunk = () => {
        if (currentChunk < totalChunks) {
          const chunkFilePath = path.join(
            chunkDir,
            `${fileName}-chunk-${currentChunk}`
          );
          const readStream = fs.createReadStream(chunkFilePath);

          readStream.pipe(writeStream, { end: false });
          readStream.on("end", () => {
            console.log(`Merged chunk ${currentChunk}`);
            currentChunk++;
            appendNextChunk();
          });
          readStream.on("error", (err) => {
            console.error(`Error reading chunk ${currentChunk}: ${err}`);
            res.status(500).send({ message: "Error merging chunks" });
            writeStream.end();
          });
        } else {
          writeStream.end();
        }
      };

      appendNextChunk();
    };

    mergeChunks();

    writeStream.on("finish", () => {
      fs.rm(chunkDir, { recursive: true, force: true }, (err) => {
        if (err) {
          console.error(`Failed to remove chunk directory: ${err}`);
        } else {
          console.log(`Chunk directory ${chunkDir} removed successfully`);
        }
      });
      console.log(`File ${fileName} uploaded successfully`);
      res.status(200).json({ message: "File uploaded successfully" });
    });

    writeStream.on("error", (err) => {
      console.error(`Error writing file: ${err}`);
      res.status(500).send({ message: "Error merging chunks" });
    });
  } else {
    res.status(200).json({ message: "Chunk uploaded successfully" });
  }
};
添加路由
// src/routes/fileRoutes.ts
import { Router } from 'express';
import multer from 'multer';
import { initiateUpload, uploadChunk } from '../controllers/fileController';
import authMiddleware from '../middleware/authMiddleware';

const upload = multer({ dest: 'uploads/' });
const router = Router();

router.post('/upload/initiate', authMiddleware, initiateUpload);
router.post('/upload/chunk', authMiddleware, upload.single('chunk'), uploadChunk);

export default router;
// 添加分片上传路由 src/routes/fileRoutes.ts
router.post('/upload/chunk', authMiddleware,  upload.single('chunk'), uploadChunk);

下载接口实现

文件列表

我们需要创建一个控制器来处理获取文件列表的请求,通过fs.readdir读取存储上传文件的目录,返回目录下的文件列表。

// src/controllers/fileController.ts
import { Request, Response } from 'express';
import path from 'path';
import fs from 'fs';

const UPLOAD_DIR = path.resolve(__dirname, '../../uploads');

export const getFileList = (req: Request, res: Response) => {
  fs.readdir(UPLOAD_DIR, (err, files) => {
    if (err) {
      res.status(500).json({ message: 'Unable to retrieve files', error: err.message });
    } else {
      res.status(200).json({ files });
    }
  });
};

关键代码解释:

  • fs.readdir(UPLOAD_DIR, (err, files) => {...}): 使用 Node.js 的文件系统模块读取指定目录。

文件下载

我们需要创建一个文件下载的API接口,支持文件的读取和传输,通过文件名参数读取文件并将其传输给客户端,实现文件下载功能。

// src/controllers/fileController.ts
import { Request, Response } from 'express';
import path from 'path';
import fs from 'fs';

const UPLOAD_DIR = path.resolve(__dirname, '../../uploads');

export const downloadFile = (req: Request, res: Response) => {
  const { fileName } = req.params;
  const filePath = path.join(UPLOAD_DIR, fileName);

  if (fs.existsSync(filePath)) {
    const safeFileName = path.basename(fileName); // 确保文件名安全
    res.setHeader(
      "Content-Disposition",
      `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`
    );
    const fileStream = fs.createReadStream(filePath);
    fileStream.pipe(res);
    fileStream.on("error", (err) => {
      res
        .status(500)
        .json({ message: "File download failed", error: err.message });
    });
  } else {
    res.status(404).json({ message: "File not found" });
  }
};

关键代码解释:

  • const { fileName } = req.params: 从请求参数中获取文件名。
  • const filePath = path.join(UPLOAD_DIR, fileName): 构建文件的完整路径。
  • if (fs.existsSync(filePath)) {...}: 检查文件是否存在。
  • res.setHeader("Content-Disposition", attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}): 设置响应头,提示浏览器下载文件。
  • const fileStream = fs.createReadStream(filePath): 创建文件读取流。
  • fileStream.pipe(res): 将文件流传输给响应对象。

文件预览

我们需要创建一个文件预览的API接口,支持文件的读取和在线展示。通过文件名参数读取文件并将其传输给客户端,实现文件预览功能。

// src/controllers/fileController.ts
import { Request, Response } from 'express';
import path from 'path';
import fs from 'fs';

const UPLOAD_DIR = path.resolve(__dirname, '../../uploads');

export const previewFile = (req: Request, res: Response) => {
  const { fileName } = req.params;
  const filePath = path.join(UPLOAD_DIR, fileName);

  if (fs.existsSync(filePath)) {
    const fileStream = fs.createReadStream(filePath);
    fileStream.pipe(res);
    fileStream.on("error", (err) => {
      res
        .status(500)
        .json({ message: "File preview failed", error: err.message });
    });
  } else {
    res.status(404).json({ message: "File not found" });
  }
};

关键代码解释:

  • if (fs.existsSync(filePath)) {...}: 检查文件是否存在。
  • const fileStream = fs.createReadStream(filePath): 创建文件读取流。
  • fileStream.pipe(res): 将文件流传输给响应对象。

更新文件路由

// src/routes/fileRoutes.ts
import { Router } from 'express';
import { getFileList, downloadFile, previewFile } from '../controllers/fileController';

const router = Router();

router.get('/list', getFileList);
router.get('/download/:fileName', downloadFile);
router.get('/preview/:fileName', previewFile);

export default router;

通过以上步骤,我们已经实现了文件列表获取、文件下载和文件预览的基本功能。

前端实现

上传和下载目前的前端时候都会比较简单,因为暂时没有经历写前端ha

分片上传

image.png

前端代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Chunk Upload Example</title>
    <!-- 引入 Element Plus 样式 -->
    <link
      rel="stylesheet"
      href="https://unpkg.com/element-plus@2.7.6/dist/index.css"
    />
    <style>
      body {
        font-family: Arial, sans-serif;
        background-color: #f0f2f5;
      }
      #app {
        max-width: 600px;
        margin: 50px auto;
        background-color: white;
        border-radius: 8px;
        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
        overflow: hidden;
      }
      .el-header {
        background-color: #409eff;
        color: white;
        padding: 20px;
        text-align: center;
        font-size: 24px;
      }
      .el-main {
        padding: 20px;
      }
      .file-name {
        text-align: center;
        margin-top: 10px;
        font-size: 16px;
        color: #333;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>

    <!-- 引入 Vue 3 -->
    <script src="https://unpkg.com/vue@3.2.47/dist/vue.global.prod.js"></script>
    <!-- 引入 Element Plus -->
    <script src="https://unpkg.com/element-plus@2.7.6/dist/index.full.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
    <script>
      const { createApp, ref } = Vue;
      const {
        ElUpload,
        ElButton,
        ElContainer,
        ElHeader,
        ElMain,
        ElMessage,
        ElRow,
        ElCol,
        ElIcon,
        UploadFilled,
      } = ElementPlus;

      const App = {
        components: {
          ElUpload,
          ElButton,
          ElContainer,
          ElHeader,
          ElMain,
          ElMessage,
          ElRow,
          ElCol,
          ElIcon,
          UploadFilled,
        },
        template: `
          <el-container>
            <el-header>分片上传示例</el-header>
            <el-main>
              <el-row>
                <el-col :span="24" class="upload-section">
                  <el-upload
                    class="upload-demo"
                    drag
                    :before-upload="beforeUpload"
                    :show-file-list="false"
                    multiple
                  >
                    <el-icon><upload-filled /></el-icon>
                    <div class="el-upload__text">
                      拖拽到此处  或者 <em>点击选择文件</em>
                    </div>
                  </el-upload>
                  <div class="file-name" v-if="fileName">
                    当前选中的文件: {{ fileName }}
                  </div>
                  <div style="width:100%;text-align: center;">
                    <el-button type="success" @click="handleUpload" style="margin-top:20px;">上传</el-button>
                  </div>
                </el-col>
              </el-row>
            </el-main>
          </el-container>
        `,
        setup() {
          const file = ref(null);
          const fileName = ref("");

          const beforeUpload = (rawFile) => {
            file.value = rawFile;
            fileName.value = rawFile.name;
            return false; // 阻止默认的上传行为
          };

          const handleUpload = async () => {
            if (!file.value) {
              ElMessage.warning("Please select a file first.");
              return;
            }

            console.log("File Name: " + file.value.name);
            console.log("File Size: " + file.value.size + " bytes");
            console.log("File Type: " + file.value.type);
            console.log("Last Modified Date: " + file.value.lastModifiedDate);

            const initResponse = await fetch(
              "http://localhost:3000/api/files/upload/initiate",
              {
                method: "POST",
                headers: {
                  "Content-Type": "application/json",
                },
                body: JSON.stringify({
                  fileName: file.value.name,
                  fileSize: file.value.size,
                }),
              }
            );

            console.log("Upload task started successfully");
            const initData = await initResponse.json();
            const { taskId, totalChunks } = initData;

            const chunkSize = 5 * 1024 * 1024; // 5MB
            for (let i = 0; i < totalChunks; i++) {
              const start = i * chunkSize;
              const end = Math.min(file.value.size, start + chunkSize);
              const chunk = file.value.slice(start, end);

              const chunkMd5 = await calculateMd5(chunk);

              const formData = new FormData();
              formData.append("taskId", taskId);
              formData.append("chunkIndex", i.toString());
              formData.append("totalChunks", totalChunks.toString());
              formData.append("fileName", file.value.name);
              formData.append("chunkMd5", chunkMd5);
              formData.append("chunk", chunk);

              await fetch("http://localhost:3000/api/files/upload/chunk", {
                method: "POST",
                body: formData,
              });

              console.log(`Chunk ${i + 1}/${totalChunks} uploaded`);
            }
            ElMessage.success("上传成功!");
          };

          const calculateMd5 = (fileChunk) => {
            return new Promise((resolve, reject) => {
              const reader = new FileReader();
              reader.onload = function (event) {
                const spark = new SparkMD5.ArrayBuffer();
                spark.append(event.target.result);
                resolve(spark.end());
              };
              reader.onerror = function (event) {
                reject(event.target.error);
              };
              reader.readAsArrayBuffer(fileChunk);
            });
          };

          return {
            file,
            fileName,
            beforeUpload,
            handleUpload,
          };
        },
      };

      const app = createApp(App);
      app.use(ElementPlus);
      app.mount("#app");
    </script>
  </body>
</html>

代码逻辑

  1. 文件选择逻辑

    • beforeUpload 方法在文件选择时被调用。它将选中的文件保存到 file 变量中,并将文件名保存到 fileName 变量中。返回 false 以阻止默认的上传行为。
  2. 文件上传逻辑

    • handleUpload 方法执行文件上传操作:
      • 如果没有选中文件,显示警告消息。
      • 打印文件信息。
      • 向服务器发送初始化上传任务的请求,获取任务 ID 和总分片数。
      • 以 5MB 为一个分片大小,循环处理文件分片。
      • 计算每个分片的 MD5 值。
      • 将分片数据和其他必要信息通过 FormData 发送到服务器的上传分片接口。
      • 上传完成后显示成功消息。
  3. 计算文件分片的 MD5 值

    • calculateMd5 方法使用 FileReader 读取文件分片的内容,并使用 SparkMD5 库计算 MD5 值。

通过以上步骤,代码实现了一个简单的文件分片上传功能,并在选择文件后显示文件名。

文件下载

image.png

前端代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>File List and Download</title>
    <!-- 引入 Vue 3 -->
    <script src="https://unpkg.com/vue@3.2.47/dist/vue.global.prod.js"></script>
    <!-- 引入 Element Plus -->
    <link
      rel="stylesheet"
      href="https://unpkg.com/element-plus/dist/index.css"
    />
    <script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
  </head>
  <body>
    <div id="app"></div>

    <script>
      const { createApp } = Vue;
      const {
        ElContainer,
        ElHeader,
        ElMain,
        ElTable,
        ElTableColumn,
        ElButton,
        ElMessage,
      } = ElementPlus;

      const App = {
        template: `
          <el-container>
            <el-header>
              <h2>文件列表</h2>
            </el-header>
            <el-main>
              <el-table :data="files" style="width: 100%">
                <el-table-column prop="name" label="文件名"></el-table-column>
                <el-table-column label="操作">
                  <template #default="scope">
                    <el-button
                      type="primary"
                      size="small"
                      @click="downloadFile(scope.row.name)"
                    >下载</el-button>
                    <el-button
                      type="success"
                      size="small"
                      @click="previewFile(scope.row.name)"
                    >预览</el-button>
                  </template>
                </el-table-column>
              </el-table>
            </el-main>
          </el-container>
        `,
        data() {
          return {
            files: [],
          };
        },
        methods: {
          async fetchFiles() {
            try {
              const response = await fetch(
                "http://localhost:3000/api/files/list"
              );
              const data = await response.json();
              this.files = data.files.map((file) => ({ name: file }));
            } catch (error) {
              ElMessage.error("获取文件列表失败");
            }
          },
          downloadFile(fileName) {
            const link = document.createElement("a");
            link.href = `http://localhost:3000/api/files/download/${fileName}`;
            link.setAttribute("download", fileName);
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
          },
          previewFile(fileName) {
            window.open(`http://localhost:3000/api/files/preview/${fileName}`);
          },
        },
        mounted() {
          this.fetchFiles();
        },
      };

      const app = createApp(App);
      app.use(ElementPlus);
      app.mount("#app");
    </script>
  </body>
</html>

代码解释

  • fetchFiles:异步获取文件列表数据。向服务器发送请求获取文件列表,并将返回的数据保存在 files 数组中。如果请求失败,显示错误消息。
  • downloadFile:创建一个隐藏的链接元素,设置其 href 属性为文件的下载地址,并模拟点击该链接以触发下载,然后移除该链接。
  • previewFile:打开一个新窗口,预览指定文件。

更新主文件,加入静态资源

src/index.ts内更新,加入express.static中间件。

上传页面http://localhost:3000/up.html

文件列表http://localhost:3000/list.html

import express from "express";
import cors from "cors";
import morgan from "morgan";
import helloRoute from "./routes/helloRoute";
import { initializeMongoose } from "./dao/mongodb";
import authRoutes from "./routes/authRoutes";
import userRoutes from "./routes/userRoutes";
import fileRoutes from "./routes/fileRoutes";

const app = express();
const port = process.env.PORT || 3000;

// 初始化
initializeMongoose();

// 配置中间件
app.use(express.json()); // 使用 Express 内置的 JSON 解析中间件
app.use(express.urlencoded({ extended: true })); // 使用 Express 内置的 URL 编码解析中间件
app.use(cors());
app.use(morgan("dev"));

// 静态文件
app.use(express.static('public'));  // ------------------------------配置静态文件目录
app.use(express.static('web'));     // ------------------------------配置web文件目录

// 配置路由
app.use("/api", helloRoute);
app.use("/api/auth", authRoutes);
app.use("/api/user", userRoutes);
app.use('/api/files', fileRoutes);

app.get("/", (req, res) => {
  res.send("Hello, Object Storage System!");
});

app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

export { app };

总结

本文详细探讨了如何在一个基于Node.js和TypeScript的Web应用中实现文件上传与下载功能。我们首先介绍了单文件和多文件上传的基本实现,然后深入探讨了分片上传的原理与实现步骤,确保大文件上传的高效性和可靠性。同时,我们也实现了文件列表、文件下载和文件预览功能,为用户提供了完整的文件管理体验。

在后续第5章中,我们将探讨对象存储系统中的元数据管理。元数据在对象存储系统中扮演着关键角色,它能够提供关于数据的丰富描述和管理功能。我们将介绍元数据的定义、存储和查询方法,并展示如何在实际项目中有效地使用元数据管理文件和对象。通过本章的学习,读者将进一步理解和掌握对象存储系统的核心概念和技术。