前端实现分片上传

前言

公司代码重构,一些技术需要调整,就比如上传下载功能。上个版本使用的是华为云的obs ,因为某些原因后端架构调整,导致前端也要进行改变,经讨论使用MiniIo和OnlyOffice。

基本思路

  1. 点击上传文件拿到文件大小
  2. 根据分片大小把文件进行分割拿到分片数量
  3. 请求创建文件分片接口,拿到后端给的分片信息和单个文件上传的地址
  4. 请求每个单个文件上传地址
  5. 上传完成每个单个文件地址后请求后端合并上传接口通知他已经上传完成。

代码实现-完整代码在最下方

html 复制代码
<template>
    <div>
        <el-upload action="#" :before-upload="handleBeforeUpload" :http-request="handleHttpRequest" :show-file-list="false" :multiple="multiple" :drag="drag">
            <el-button type="primary">点击上传</el-button>
        </el-upload>
        <!-- 上传文件的列表 -->
        <div class="fileBox" v-if="showFile">
            <div v-for="task in uploadTaskList" :key="task.uid" class="fileItem">
                <div class="rowBox">
                    <div class="name" @click.stop="openPreview(task)">{{ task.fileName }}</div>
                    <div class="delBox">
                        <i class="el-icon-close" @click.stop="handleRemove(task.uid)"></i>
                    </div>
                </div>
                <el-progress :percentage="task.totalPercent" :stroke-width="2" style="width: 100%;" v-if="task.totalPercent < 100" />
            </div>
        </div>
    </div>
</template>

分析

使用element ui上传的手动上传功能来实现,节省开发时间。上面主要代码为before-upload和http-request

上传前的操作:调用element ui的before-upload上传前的钩子 这里为了控制上传文件类型、上传大小、最大个数,实现方式如下

js 复制代码
        // 上传前回调
        handleBeforeUpload(file) {
            if (this.uploadTaskList.length >= this.limit) {
                this.$message.error(`最多上传${this.limit}个文件`)
                return false
            }
            let fileSize = file.size / 1024 / 1024
            // 文件大小限制
            if (fileSize > this.sizeLimit) {
                this.$message.error(`文件大小不能超过${this.sizeLimit}M`)
                return false
            }
            // 分片大小 默认 5M
            this.chunkSize = this.sliceSize * 1024 * 1024
            return true
        }

其中uploadTaskList上传任务列表即上传文件列表 limit最大个数 sizeLimit为文件大小限制

手动上传

调用elementui的http-request方法实现手动上传,这里就要做上面基本思路的操作了

js 复制代码
  // 上传
        async handleHttpRequest(options) {
            // 获取文件
            const file = options.file
            // 获取分片数量
            const chunksCount = Math.ceil(file.size / this.chunkSize)
            // 创建上传任务
            let task = {
                index: this.uploadTaskList.length,
                uid: file.uid,
                // 文件信息
                file: file,
                fileName: file.name,
                fileUrl: '',
                size: file.size,
                // 获取文件大小
                totalSize: file.size,
                // 获取分片数量
                chunksCount: chunksCount,
                // 已上传的总字节数
                totalUploaded: 0,
                // 进度相关:记录每个分片已上传的字节数
                uploadedSizePerChunk: new Array(chunksCount).fill(0),
                // 上传进度
                totalPercent: 0,
                // 创建控制器
                controller: new AbortController(),
                // 分片地址列表
                chunkUrlList: [],
                // 上传文件id
                uploadId: '',
                // 上传文件参数
                filePrefix: '',
                // 上传文件名称
                finalName: '',
                // 标记正在上传
                isUploading: true
            }
            this.uploadTaskList.push(task)
            // 更新进度的方法
            const updateProgress = () => {
                const percent = Math.ceil((task.totalUploaded / task.totalSize) * 100)
                task.totalPercent = percent
                // el-upload 会监听这个
                options.onProgress({ percent: percent });
            };
            // 获取-创建分片上传的参数
            let params = {
                bucketName: 'files',
                chunkSize: chunksCount,
                fileName: file.name
            }
            // 创建分片上传
            try {
                const initRes = await createMultipartUpload(params)
                if (initRes.code == 500) {
                    this.$message.error(initRes.msg)
                    return
                }
                // 获取创建分片的参数
                task.uploadId = initRes.uploadId
                task.filePrefix = initRes.filePrefix
                task.finalName = initRes.finalName
                // 获取分片地址列表并排序
                task.chunkUrlList = []
                for (let i = 0; i < task.chunksCount; i++) {
                    const key = `chunk_${i}`
                    if (!initRes[key]) {
                        throw new Error(`缺少第 ${i + 1} 个分片上传地址`)
                    }
                    task.chunkUrlList[i] = initRes[key]
                }
                // ---------------------------------------------------开始分片上传-------------------------------------------------
                // 并发上传所有分片
                const uploadPromises = []
                for (let i = 0; i < task.chunksCount; i++) {
                    // 获取分片开始位置
                    const start = i * this.chunkSize
                    // 获取分片结束位置
                    const end = Math.min(start + this.chunkSize, task.totalSize)
                    // 分割文件
                    const chunkBlob = file.slice(start, end)
                    // 分片上传
                    const promise = this.uploadChunk(task.chunkUrlList[i], chunkBlob, task.controller.signal, (progressEvent) => {
                        // 计算这个分片上传了多少
                        const chunkUploaded = progressEvent.loaded;
                        // 更新"该分片"上传量(防止重复叠加)
                        task.totalUploaded = task.totalUploaded - task.uploadedSizePerChunk[i] + chunkUploaded;
                        task.uploadedSizePerChunk[i] = chunkUploaded;
                        // 更新进度条
                        updateProgress();
                    })
                    uploadPromises.push(promise)
                }
                // 等待所有分片完成
                await Promise.all(uploadPromises)
                const mergeData = {
                    bucketName: 'files',
                    objectName: task.filePrefix + task.finalName,
                    uploadId: task.uploadId,
                }
                // 合并分片完成上传
                const res = await completeMultipartUpload(mergeData)
                if (res.code == 200) {
                    task.fileUrl = res.data.uploadUrl
                    // this.$message.success(`${file.name} 上传成功!`)
                    // 通知 el-upload 上传成功
                    options.onSuccess(res);
                } else {
                    console.log(res, '-------------res');
                    this.$message.error(`${file.name} 合并失败:${res.msg}`)
                    options.onError(new Error(res.msg));
                }

            } catch (error) {
                console.log(error, '-------------error', task);
                let index = this.uploadTaskList.findIndex(v => v.uid === task.uid)
                if (index > -1) {
                    this.uploadTaskList.splice(index, 1)
                }
                if (error.name === 'AbortError' || error.message.toLowerCase().includes('canceled') || error.code === 'ERR_CANCELED') {
                    this.$message.info('用户取消上传')
                } else {
                    this.$message.error('上传失败')
                    options.onError(error)
                }
            } finally {
                task.isUploading = false
                // 检查是否所有任务都已完成
                this.$nextTick(() => {
                    this.checkAllUploaded()
                })
            }
        }
js 复制代码
         // 上传单个分片
        uploadChunk(url, fileBlob, signal, onUploadProgress) {
            return axios.put(url, fileBlob, {
                signal: signal,
                // 设置请求进度
                onUploadProgress
            })
        }
js 复制代码
         // 检查是否所有文件都上传完成
        checkAllUploaded() {
            const allFinished = this.uploadTaskList.every(task => !task.isUploading)
            if (allFinished && this.uploadTaskList.length) {
                this.handleAfterAllUploads()
            }
        }
js 复制代码
//  所有文件上传完成后的回调
        handleAfterAllUploads() {
            // console.log('上传完成', this.uploadTaskList);
            this.$emit('success', this.uploadTaskList)
        }
js 复制代码
          // 删除文件
        handleRemove(uid) {
            const index = this.uploadTaskList.findIndex(v => v.uid === uid)
            if (index !== -1) {
                const task = this.uploadTaskList[index]
                if (task.isUploading) {
                    task.controller.abort()
                }
                this.uploadTaskList.splice(index, 1)
            }
        },
        // 预览
        openPreview(val) {
            if (val.fileUrl) {
                previewShowView(val.fileUrl)
            }
        }

完整代码如下

js 复制代码
<template>
    <div>
        <el-upload action="#" :before-upload="handleBeforeUpload" :http-request="handleHttpRequest" :show-file-list="false" :multiple="multiple" :drag="drag">
            <el-button type="primary">点击上传</el-button>
        </el-upload>
        <!-- 列表 -->
        <div class="fileBox" v-if="showFile">
            <div v-for="task in uploadTaskList" :key="task.uid" class="fileItem">
                <div class="rowBox">
                    <div class="name" @click.stop="openPreview(task)">{{ task.fileName }}</div>
                    <div class="delBox">
                        <i class="el-icon-close" @click.stop="handleRemove(task.uid)"></i>
                    </div>
                </div>
                <el-progress :percentage="task.totalPercent" :stroke-width="2" style="width: 100%;" v-if="task.totalPercent < 100" />
            </div>
        </div>
    </div>
</template>

<script>
import axios from 'axios'
import { createMultipartUpload, completeMultipartUpload } from '@/api/common'
import { previewShowView } from '@/utils/previewShowView.js'
export default {
    props: {
        // 是否支持多选文件
        multiple: {
            type: Boolean,
            default: false
        },
        // 最大允许上传个数
        limit: {
            type: Number,
            default: 5
        },
        // 限制大小默认10M
        sizeLimit: {
            type: Number,
            default: 300
        },
        // 分片大小
        sliceSize: {
            type: Number,
            default: 5
        },
        // 是否支持拖拽上传
        drag: {
            type: Boolean,
            default: false
        },
        // 文件类型
        fileList: {
            type: Array,
            default: () => []
        },
        // 是否显示上传后的数据
        showFile: {
            type: Boolean,
            default: true,
        },
    },
    data() {
        return {
            // 分片大小 
            chunkSize: 0,
            // 上传任务列表
            uploadTaskList: []
        }
    },
    watch: {
        fileList: {
            handler(val) {
                this.uploadTaskList = val.map(v => {
                    let uid = v.uid || Date.now() + Math.random()
                    return {
                        ...v,
                        isUploading: false,
                        uid: uid
                    }
                })
            },
            immediate: true
        }

    },
    methods: {
        // 上传前回调
        handleBeforeUpload(file) {
            if (this.uploadTaskList.length >= this.limit) {
                this.$message.error(`最多上传${this.limit}个文件`)
                return false
            }
            let fileSize = file.size / 1024 / 1024
            // 文件大小限制
            if (fileSize > this.sizeLimit) {
                this.$message.error(`文件大小不能超过${this.sizeLimit}M`)
                return false
            }
            // 分片大小 默认 5M
            this.chunkSize = this.sliceSize * 1024 * 1024
            return true
        },
        // 上传
        async handleHttpRequest(options) {
            // 获取文件
            const file = options.file
            // 获取分片数量
            const chunksCount = Math.ceil(file.size / this.chunkSize)
            // 创建上传任务
            let task = {
                index: this.uploadTaskList.length,
                uid: file.uid,
                // 文件信息
                file: file,
                fileName: file.name,
                fileUrl: '',
                size: file.size,
                // 获取文件大小
                totalSize: file.size,
                // 获取分片数量
                chunksCount: chunksCount,
                // 已上传的总字节数
                totalUploaded: 0,
                // 进度相关:记录每个分片已上传的字节数
                uploadedSizePerChunk: new Array(chunksCount).fill(0),
                // 上传进度
                totalPercent: 0,
                // 创建控制器
                controller: new AbortController(),
                // 分片地址列表
                chunkUrlList: [],
                // 上传文件id
                uploadId: '',
                // 上传文件参数
                filePrefix: '',
                // 上传文件名称
                finalName: '',
                // 标记正在上传
                isUploading: true
            }
            this.uploadTaskList.push(task)
            // 更新进度的方法
            const updateProgress = () => {
                const percent = Math.ceil((task.totalUploaded / task.totalSize) * 100)
                task.totalPercent = percent
                // el-upload 会监听这个
                options.onProgress({ percent: percent });
            };
            // 获取-创建分片上传的参数
            let params = {
                bucketName: 'files',
                chunkSize: chunksCount,
                fileName: file.name
            }
            // 创建分片上传
            try {
                const initRes = await createMultipartUpload(params)
                if (initRes.code == 500) {
                    this.$message.error(initRes.msg)
                    return
                }
                // 获取创建分片的参数
                task.uploadId = initRes.uploadId
                task.filePrefix = initRes.filePrefix
                task.finalName = initRes.finalName
                // 获取分片地址列表并排序
                task.chunkUrlList = []
                for (let i = 0; i < task.chunksCount; i++) {
                    const key = `chunk_${i}`
                    if (!initRes[key]) {
                        throw new Error(`缺少第 ${i + 1} 个分片上传地址`)
                    }
                    task.chunkUrlList[i] = initRes[key]
                }
                // ---------------------------------------------------开始分片上传-------------------------------------------------
                // 并发上传所有分片
                const uploadPromises = []
                for (let i = 0; i < task.chunksCount; i++) {
                    // 获取分片开始位置
                    const start = i * this.chunkSize
                    // 获取分片结束位置
                    const end = Math.min(start + this.chunkSize, task.totalSize)
                    // 分割文件
                    const chunkBlob = file.slice(start, end)
                    // 分片上传
                    const promise = this.uploadChunk(task.chunkUrlList[i], chunkBlob, task.controller.signal, (progressEvent) => {
                        // 计算这个分片上传了多少
                        const chunkUploaded = progressEvent.loaded;
                        // 更新"该分片"上传量(防止重复叠加)
                        task.totalUploaded = task.totalUploaded - task.uploadedSizePerChunk[i] + chunkUploaded;
                        task.uploadedSizePerChunk[i] = chunkUploaded;
                        // 更新进度条
                        updateProgress();
                    })
                    uploadPromises.push(promise)
                }
                // 等待所有分片完成
                await Promise.all(uploadPromises)
                const mergeData = {
                    bucketName: 'files',
                    objectName: task.filePrefix + task.finalName,
                    uploadId: task.uploadId,
                }
                // 合并分片完成上传
                const res = await completeMultipartUpload(mergeData)
                if (res.code == 200) {
                    task.fileUrl = res.data.uploadUrl
                    // this.$message.success(`${file.name} 上传成功!`)
                    // 通知 el-upload 上传成功
                    options.onSuccess(res);
                } else {
                    console.log(res, '-------------res');
                    this.$message.error(`${file.name} 合并失败:${res.msg}`)
                    options.onError(new Error(res.msg));
                }

            } catch (error) {
                console.log(error, '-------------error', task);
                let index = this.uploadTaskList.findIndex(v => v.uid === task.uid)
                if (index > -1) {
                    this.uploadTaskList.splice(index, 1)
                }
                if (error.name === 'AbortError' || error.message.toLowerCase().includes('canceled') || error.code === 'ERR_CANCELED') {
                    this.$message.info('用户取消上传')
                } else {
                    this.$message.error('上传失败')
                    options.onError(error)
                }
            } finally {
                task.isUploading = false
                // 检查是否所有任务都已完成
                this.$nextTick(() => {
                    this.checkAllUploaded()
                })
            }
        },
        // 上传单个分片
        uploadChunk(url, fileBlob, signal, onUploadProgress) {
            return axios.put(url, fileBlob, {
                signal: signal,
                // 设置请求进度
                onUploadProgress
            })
        },
        // 检查是否所有文件都上传完成
        checkAllUploaded() {
            const allFinished = this.uploadTaskList.every(task => !task.isUploading)
            if (allFinished && this.uploadTaskList.length) {
                this.handleAfterAllUploads()
            }
        },
        //  所有文件上传完成后的回调
        handleAfterAllUploads() {
            // console.log('上传完成', this.uploadTaskList);
            this.$emit('success', this.uploadTaskList)
        },
        // 删除文件
        handleRemove(uid) {
            const index = this.uploadTaskList.findIndex(v => v.uid === uid)
            if (index !== -1) {
                const task = this.uploadTaskList[index]
                if (task.isUploading) {
                    task.controller.abort()
                }
                this.uploadTaskList.splice(index, 1)
            }
        },
        // 预览
        openPreview(val) {
            if (val.fileUrl) {
                previewShowView(val.fileUrl)
            }
        }
    }
}
</script>

<style lang="scss" scoped>
.fileBox {
    margin-top: 10px;
    .fileItem {
        margin-top: 5px;
        .rowBox {
            position: relative;
            cursor: pointer;
            display: flex;
            justify-content: space-between;
            align-items: center;
            &:hover {
                background-color: #f5f7fa;
                .name {
                    color: #409eff;
                }
                .delBox {
                    opacity: 1;
                }
            }
            .name {
                color: #606266;
                display: block;
                margin-right: 40px;
                overflow: hidden;
                padding-left: 4px;
                text-overflow: ellipsis;
                transition: color 0.3s;
                white-space: nowrap;
                text-align: left;
            }
            .delBox {
                cursor: pointer;
                opacity: 0;
                color: #606266;
            }
        }
    }
}
</style>
相关推荐
BigYe程普5 分钟前
出海技术栈集成教程(五):域名邮箱配置教程
前端·saas·全栈
BigYe程普17 分钟前
出海技术栈集成教程(四):Resend邮件服务
前端·后端·全栈
辛-夷18 分钟前
JS的学习5
前端·javascript
啃火龙果的兔子25 分钟前
Form.Item中判断其他Form.Item的值
开发语言·前端·javascript
coding随想26 分钟前
CSSStyleSheet:掌控网页样式的“幕后黑手”,你真的了解吗?
前端
Undoom32 分钟前
Trae x Figma MCP一键将设计稿转化为精美网页
前端
情绪的稳定剂_精神的锚34 分钟前
git 提交前修改文件校验和commit提交规范
前端
天高任鸟飞dyz42 分钟前
vue文件或文件夹拖拽上传
前端·javascript·vue.js
EndingCoder1 小时前
Next.js 中间件:自定义请求处理
开发语言·前端·javascript·react.js·中间件·全栈·next.js
Andy_GF1 小时前
纯血鸿蒙 HarmonyOS Next 调试证书过期解决流程
前端·ios·harmonyos