zzxworld

使用 JavaScript 前端分片上传解决大文件上传问题

最近碰到一个数据导入的业务功能,一开始业务人员提供的样本数据不大,图方便我就用了采用了提交整个文件上传的方式处理。随着功能投入使用,开始收到一些文件上传失败的问题反馈。找业务要来数据一看,原来是文件的大小超出了预期。

来看个简单的例子,下面这是一个普通的文件上传选择标签:

<input type="file" @change="handleUpload" />

点击这个标签会弹出文件选择窗口,选择一个文件就会触发 handleUpload 方法开始上传文件。下面是处理文件上传的代码示例:

function uploadTo(url, formData) {
    return fetch(url, {
        method: 'POST',
        body: formData,
    }).then(response => {
        console.log(response)
    })
}

function handleUpload(e) {
    const uploadData = new FormData()
    uploadData.append('file_data', e.target.files[0])
    uploadTo('/files', uploadData)
}

Web 服务使用的是 Nginx,当上传的文件小于 1M 时,以上功能没有任何问题,一旦超过 1M,就会出现 413 Request Entity Too Large 的响应,看这个消息就知道是文件太大了。

最简单的方式是修改 Nginx 配置,添加这样一行配置代码:

client_max_body_size 2M;

这样可以把限制提升到 2M,算是一个不错的快速处理方案。但一旦有更大的文件要上传时,我又得改配置。另外后端使用了 PHP,在超过 2M 后,会触发 PHP 的默认请求限制大小,每次这样改太被动,我想一劳永逸的解决这个上传问题。

当然我也可以考虑一次性把 Nginx 和 PHP 的请求大小限制改到最大,但这不是一个很优雅的解决方式。在了解了一些文件上传方案后,我决定尝试一下基于前端 JavaScript 方式的文件分片上传方案。

这个方案的基础是 File 对象提供的 slice 方法,能对选择上传文件做切片处理。基于这个方法,我写了一个对上传文件做切片处理的自定义函数:

function chunkFile(file, chunkLimit) {
    const fileChunks = []
    const chunkTotal = Math.ceil(file.size / chunkLimit)

    let chunkPos = 0
    let chunkStart = 0

    while (chunkPos < chunkTotal) {
        let chunkEnd = chunkStart+chunkLimit
        if (chunkEnd >= file.size) {
            chunkEnd = file.size
        }

        fileChunks.push([chunkStart, chunkEnd])

        chunkPos++;
        chunkStart = chunkLimit * chunkPos
    }

    return fileChunks
}

这个函数只需要提供两个参数:

  • file: 要上传的文件对象。
  • chunkLimit: 文件分片大小限制,单位为字节。

在上传方法中使用这个函数的代码如下:

let chunks = chunkFiles(e.target.files[0], 1024*1024)

上面的代码表示把一个上传文件按 1M 大小做切分。函数会返回一个包含切分数量和切分起始偏移值的数组。比如我选了一个 2.3M 的文件应用此函数后返回的结果如下:

[
    [ 0, 1048576 ],
    [ 1048576, 2097152 ],
    [ 2097152, 2258401 ]
]

有了这个数组格式的切片起始数据,就可以水到渠成的开始做分片上传文件的操作:

const now = new Date()

chunks.forEach((offset, i) => {
    const uploadData = new FormData()

    uploadData.append('file_id', now.getTime()+'.'+now.getMilliseconds())
    uploadData.append('file_name', file.name)
    uploadData.append('file_data', file.slice(offset[0], offset[1]))
    uploadData.append('file_chunk_total', fileChunks.length)
    uploadData.append('file_chunk_pos', i+1)

    uploadTo('/files', uploadData)
})

由于是分片上传,后端无法像处理正常文件上传那样获取到一些基础文件信息,比如文件名,所以需要在分片上传时补充相关的自定义字段。另外为了方便后端识别文件片段的归属,又专门添加了一个基于时间戳的 file_id字段。

另外需要注意的是在定义文件切片大小限制值时不能和 Nginx 的限制相同。比如 Nginx 限制 1M,如果文件切片限制也定为 1M,那可能还是会返回 413 响应。这是因为在提交时文件切片大小加上附加字段的大小超出了 1M,所以文件切片应该小于 Nginx 限制,给附加字段留点余量。

另外上面使用 forEach 方法处理文件切片数组有个坑,它不会按顺序提交切片文件,需要后端在收到所有文件片段后重新按序号排序再合并,所以建议通过 Promise 队列的方式来顺序处理,后端会省事很多。比如使用 PHP 按顺序接收合并文件片段的代码:

$file = $_FILES['file_data'];

$extension = explode('.', $_POST['file_name']);
$extension = end($extension);
$filename = $_POST['file_id'].'.'.$extension;

file_put_contents($filename, file_get_contents($file['tmp_name']), FILE_APPEND);

这个分片传输的功能还有一些可以优化的空间,比如使用 localStorage 接口保存分片提交状态,可以实现断点续传功能;或者根据分片数量实现上传进度条功能等……,目前还用不上,也就不研究了。