面试中常常出现的大文件上传,你搞懂了吗?

133 阅读7分钟

大文件上传是我们 Web 开发中一个常见且关键的需求。无论是上传高清视频、大型数据库备份还是其他大容量文件,如何高效、稳定地完成上传操作,对用户体验和系统性能都有着重要影响。他也是我们前端面试的常客,所以搞懂他是非常有必要的!

一、Blob 对象:大文件上传的基石

Blob(Binary Large Object)对象在大文件上传中扮演着举足轻重的角色。它表示不可变的、原始数据的类文件对象,数据可以按文本或者二进制的形式进行读取,也能转换成 ReadableStream 来用于数据操作。在前端实现大文件上传时,Blob 对象是实现文件分块、封装和传输的基础。

1.1 Blob 对象的基本特性

  • 不可变性:一旦创建,Blob 对象的数据内容就不可更改,这保证了数据在传输和处理过程中的一致性和完整性。
  • 类文件特性:Blob 对象具有类似文件的接口,例如可以获取其大小、类型等属性,这使得它在处理文件相关操作时非常方便。
  • 灵活的数据读取方式:支持以文本或二进制形式读取数据,开发者可以根据具体需求选择合适的读取方式。例如,使用text()方法可以将 Blob 对象的数据读取为文本字符串,而arrayBuffer()方法则可以读取为二进制数组,为后续的数据处理提供了更多可能性。

1.2 在大文件上传中的应用

在大文件上传流程中,Blob 对象主要用于以下几个关键步骤:

  1. 文件读取与分块:前端通过FileReader对象读取本地文件,得到文件对象。然后利用slice方法对文件对象进行分块,每个分块都是一个 Blob 类型的文件对象。这种分块操作将大文件分割成多个小的部分,便于后续的上传和管理。
  2. 数据封装与传输:使用FormData对象将 Blob 类型的文件对象转换成 FormData 表单类型的对象,以便通过 HTTP 请求发送给后端。在这个过程中,Blob 对象的数据被正确地封装在 FormData 中,确保数据能够准确无误地传输到服务器。

二、前端大文件上传实现步骤

2.1 读取本地文件

在前端,使用input元素的type="file"属性可以让用户选择本地文件。当用户选择文件后,通过change事件获取选中的文件对象。例如:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
</head>

<body>
    <input type="file" id="fileInput">
    <script>
        const fileInput = document.getElementById('fileInput');
        fileInput.addEventListener('change', function () {
            const file = this.files[0];
            if (file) {
                console.log('选中的文件:', file);
                // 后续处理文件分块等操作
            }
        });
    </script>
</body>

</html>

2.2 文件分块

使用slice方法对文件对象进行分块,slice方法会返回一个新的 Blob 对象,表示文件的一部分。例如,将一个文件分成 1MB 大小的块:

const chunkSize = 1024 * 1024; // 1MB
function sliceFile(file) {
    const chunks = [];
    let start = 0;
    let end = chunkSize;
    while (start < file.size) {
        const chunk = file.slice(start, end);
        chunks.push(chunk);
        start = end;
        end += chunkSize;
    }
    return chunks;
}

2.3 使用 FormData 封装

将分块后的 Blob 对象封装到FormData中,以便通过 HTTP 请求发送。FormData对象提供了一种简单的方式来构造一个包含表单数据的对象,并且可以直接作为fetchaxios请求的body参数。

function createFormData(chunk, index) {
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('chunkIndex', index);
    formData.append('filename', 'example.txt'); // 假设文件名
    return formData;
}

2.4 发送请求

使用axios发送请求,将切片一个一个发送给后端。可以使用Promise.all进行并发请求,以提高上传效率,但需要注意并发数量的控制,避免对网络和服务器造成过大压力。

import axios from 'axios';

function uploadChunks(chunks) {
    const promises = chunks.map((chunk, index) => {
        const formData = createFormData(chunk, index);
        return axios.post('/upload', formData, {
            headers: {
                'Content-Type':'multipart/form-data'
            }
        });
    });
    return Promise.all(promises);
}

三、后端大文件上传处理

当前端将文件切片发送到后端时,后端需要解析请求中的表单数据,并将切片保存到临时目录中。

实现步骤:

  • 使用 multiparty.Form 解析请求中的表单数据。
  • 将切片保存到以文件名为命名空间的临时目录中,通常以 filename-chunks 的形式命名。
  • 每个切片按索引顺序保存,例如 chunk-0chunk-1 等。

代码实现

          if (req.url === '/upload') {
        // 后端能不能读懂fromData数据,借助第三方工具 multiparty 来解析
        // 安装:npm install multer --save
        // 引入:const multiparty = require('multiparty');
        const form = new multiparty.Form();
        form.parse(req, (err, fields, files) => {
            // fields 是普通表单字段,files 是文件字段
            console.log(fields); //切片的描述
            console.log(files);  // 切片的二进制资源被处理成对象
            const [file] = files.file;
            const [Filename] = fields.fileName
            const [chunkName] = fields.chunkName
            // 保存切片
            const chunkDir = path.resolve(UPLOAD_DIR, `${Filename}-chunks`);
            if (!fse.existsSync(chunkDir)) {
                fse.mkdirSync(chunkDir);
            }
            // 存入
            fse.moveSync(file.path, `${chunkDir}/${chunkName}`);
            
            res.end(JSON.stringify({
                code: 0,
                message: '切片上传成功'
            }))
        })

3.2 处理切片上传

在上述代码中,upload.single('chunk')表示接收单个名为chunk的文件切片。在请求处理函数中,可以获取切片的相关信息,如文件名、切片索引等,并进行相应的处理,如保存切片到指定目录、记录上传状态等。

四、秒传实现原理与应用

4.1 秒传的概念与优势

秒传是一种高效的文件上传方式,它能够在极短的时间内完成文件上传操作,大大提升了用户体验。其核心原理是利用文件的唯一标识(如 MD5 值)来判断服务器上是否已经存在相同的文件。如果存在,则无需重新上传文件内容,只需进行一些简单的记录和关联操作,即可完成上传。

4.2 实现步骤

  1. 计算 MD5 值:在上传文件之前,先使用Spark - MD5库计算文件的 MD5 值。Spark - MD5是一个用于计算 MD5 哈希值的 JavaScript 库,它可以高效地计算大文件的 MD5 值。

import SparkMD5 from'spark-md5';

function calculateMD5(file) {
    return new Promise((resolve, reject) => {
        const spark = new SparkMD5.ArrayBuffer();
        const fileReader = new FileReader();
        fileReader.readAsArrayBuffer(file);
        fileReader.onload = function () {
            spark.append(this.result);
            const md5 = spark.end();
            resolve(md5);
        };
        fileReader.onerror = reject;
    });
}
  1. 检查文件是否存在:将计算得到的 MD5 值发送给服务器,服务器通过查询数据库或文件存储系统,检查是否已经有相同 MD5 值的文件存在。
  2. 完成秒传:如果服务器已有该文件,则直接完成上传操作,返回上传成功的响应给前端。前端接收到响应后,即可告知用户上传已完成。

五、断点续传:保障上传稳定性

5.1 断点续传的重要性

在文件上传过程中,由于网络不稳定、客户端崩溃或服务器故障等原因,上传中断的情况时有发生。断点续传技术的出现,有效地解决了这一问题。它允许在上传中断后,再次上传时能够从上次中断的位置继续上传,而无需重新开始整个文件的上传过程,大大提高了上传的成功率和效率。

5.2 实现原理

  1. 切片上传:将大文件分割成多个小的切片,分别上传这些切片。这样在上传中断时,只需要重新上传未完成的切片,而不是整个文件。
  2. 记录上传状态:前端和后端都需要记录哪些切片已经上传成功。前端通常使用数组来存储已上传切片的索引,而后端可以使用数据库、文件或内存数据结构(如对象)来记录每个文件的已上传切片信息。
  3. 检查与重传:当上传中断后重新开始上传时,前端会向服务器请求未上传的切片索引,然后只上传这些未上传的切片。

5.3 关键代码实现

前端代码
const uploadedChunks = [];
async function uploadChunk(chunk, index) {
    const formData = createFormData(chunk, index);
    try {
        await fetch('/upload', {
            method: 'POST',
            body: formData
        });
        uploadedChunks.push(index);
    } catch (error) {
        console.error('上传切片失败:', error);
    }
}

async function resumeUpload(totalChunks) {
    const response = await fetch(`/check?filename=${filename}&totalChunks=${totalChunks}`);
    const { missingChunks } = await response.json();
    for (let i of missingChunks) {
        const chunk = chunks[i];
        await uploadChunk(chunk, i);
    }
}
后端代码
const uploadedChunks = {};
app.post('/upload', upload.single('chunk'), (req, res) => {
    const { filename, chunkIndex } = req.body;
    if (!uploadedChunks[filename]) {
        uploadedChunks[filename] = [];
    }
    uploadedChunks[filename].push(chunkIndex);
    res.sendStatus(200);
});

app.get('/check', (req, res) => {
    const { filename, totalChunks } = req.query;
    const uploaded = uploadedChunks[filename] || [];
    const missingChunks = [];
    for (let i = 0; i < totalChunks; i++) {
        if (!uploaded.includes(i.toString())) {
            missingChunks.push(i);
        }
    }
    res.json({ missingChunks });
});

六、断开重连重传:应对网络波动

6.1 断开重连重传的原理

断开重连重传本质上是断点续传在网络断开场景下的一种具体应用。当网络断开后又重新连接时,上传任务会继续进行,从上次中断的位置接着上传未完成的文件切片。其实现原理主要包括网络状态监听和断点续传机制复用。

6.2 实现步骤

  1. 网络状态监听:前端利用window.addEventListener('online', callback)监听网络重新连接事件。当检测到网络从断开状态变为连接状态时,触发重新上传的操作。
  2. 断点续传机制复用:在网络重新连接后,前端调用断点续传的逻辑,向服务器请求未上传的切片索引,然后继续上传这些未上传的切片。

6.3 关键代码实现

window.addEventListener('online', async () => {
    // 网络重新连接,执行断点续传逻辑
    const response = await fetch(`/check?filename=${filename}&totalChunks=${totalChunks}`);
    const { missingChunks } = await response.json();
    for (let i of missingChunks) {
        const chunk = chunks[i];
        await uploadChunk(chunk, i);
    }
});

后端代码与断点续传的后端代码相同,主要是记录已上传切片信息和提供检查未上传切片的接口。

七、合并切片:完成文件上传的最后一步

7.1 合并切片的流程

当所有切片都成功上传到服务器后,需要将这些切片合并成一个完整的文件。合并切片的过程涉及到文件读取、数据传输和写入操作。

  1. 读取切片数据:使用fs.createReadStream创建可读流,从对应的切片文件中读取数据。
  2. 传输数据:通过pipe方法将可读流读取到的数据传输给可写流。pipe方法会自动管理数据的传输过程,包括数据的读取、缓冲和写入,大大简化了数据传输的代码实现。
  3. 写入合并文件:使用fs.createWriteStream创建可写流,将接收到的数据写入到最终合并后的文件中。

7.2 关键代码实现

const express = require('express');
const app = express();
const path = require('path');
const fs = require('fs');

app.post('/merge', (req, res) => {
    const { filename, totalChunks } = req.body;
    const mergeFilePath = path.join('uploads', filename);
    const writeStream = fs.createWriteStream(mergeFilePath);

    for (let i = 0; i < totalChunks; i++) {
        const chunkPath = path.join('uploads', `${filename}-${i}`);
        const readStream = fs.createReadStream(chunkPath);
        readStream.pipe(writeStream, { end: false });
        readStream.on('end', () => {
            fs.unlinkSync(chunkPath);
            if (i === totalChunks - 1) {
                writeStream.end();
                res.sendStatus(200);
            }
        });
    }
});
  1. 路由定义与请求处理函数

    • app.post('/merge', (req, res) => {:定义一个处理 HTTP POST 请求的路由,路径为/merge。当客户端向该路径发送 POST 请求时,会执行后面的回调函数。req是请求对象,包含了客户端发送的请求信息,如请求头、请求体等;res是响应对象,用于向客户端返回响应数据。
  2. 获取请求参数

    • const { filename, totalChunks } = req.body;:使用对象解构从请求体req.body中获取filename(文件名)和totalChunks(切片总数)这两个参数。这两个参数是前端在发起合并请求时传递过来的,用于确定要合并的文件以及切片的数量。
  3. 确定合并文件的路径

    • const mergeFilePath = path.join('uploads', filename);:使用path.join方法拼接合并后文件的存储路径。path.join会根据不同操作系统的路径分隔符,将uploads目录和filename组合成一个合法的文件路径。例如,在 Linux 系统下可能是uploads/filename,在 Windows 系统下可能是uploads\filename
  4. 创建可写流

    • const writeStream = fs.createWriteStream(mergeFilePath);:使用fs.createWriteStream创建一个可写流对象writeStream,用于将合并后的文件内容写入到mergeFilePath指定的文件中。可写流负责将数据写入文件,为后续的切片合并操作做准备。
  5. 循环读取并合并切片

    • for (let i = 0; i < totalChunks; i++) {:开始一个循环,循环次数为切片总数totalChunks。每次循环处理一个切片。
    • const chunkPath = path.join('uploads', {i});:根据当前循环的索引i,拼接每个切片的文件路径。例如,当i为 0 时,路径可能是uploads/filename - 0 ,以此类推。
    • const readStream = fs.createReadStream(chunkPath);:针对每个切片路径,使用fs.createReadStream创建一个可读流对象readStream,用于读取切片文件的内容。
    • readStream.pipe(writeStream, { end: false });:使用pipe方法将可读流readStream和可写流writeStream连接起来。pipe方法会自动处理数据的读取、缓冲和写入操作,将切片文件的内容传输到合并文件中。{ end: false }选项表示当可读流读取完数据后,不会自动关闭可写流,以便后续继续写入其他切片数据。
    • readStream.on('end', () => {:为可读流readStream添加一个end事件监听器。当可读流读取完当前切片的所有数据时,会触发这个事件。
    • fs.unlinkSync(chunkPath);:在当前切片数据读取并写入合并文件后,使用fs.unlinkSync同步删除该切片文件。fs.unlinkSync会直接删除指定路径的文件,释放磁盘空间。
    • if (i === totalChunks - 1) {:判断当前处理的切片是否是最后一个切片。如果是最后一个切片,执行以下操作。
    • writeStream.end();:关闭可写流writeStream,表示所有切片数据都已写入合并文件,文件写入操作完成。
    • res.sendStatus(200);:向客户端返回 HTTP 状态码 200,表示合并操作成功。客户端接收到这个状态码后,可以知道文件合并已顺利完成。

END

大文件上传是我们的必修课,大体的实现我可以总结为如下文字,以便帮大家备战春招!!

前端拿到整个文件后利用文件 Blob 原型上的 slice 方法进行切割,将得到的切片数组 chunkList 添加一些信息,比如文件名和下标,得到 uploadChunkList ,但是 uploadChunkList 想要传给后端还需要将其转换成表单数据格式,通过 Promise.all 并发发给后端,传输完毕后发送一个合并请求,合并请求带上文件名和切片大小信息

后端拿到前端传过来的表单格式数据需要 multiparty 依赖来解析这个表单数据,然后把切片解析出来去存入切片,存入到提前创建好的目录中,最后将切片按照下标进行排序再来合并切片,合并切片的实现比较复杂,需要创建一个可以写入流的文件,将每个片段读成流类型,再写入到可写流中