对项目中文件上传组件的一次重构

50 阅读1分钟

前言

最近开发需求的时候,不同的页面,ui设计师提供了不同的文件上传样式,在现有的上传组件中修改难免会造成耦合,便想着重构一下。

实现思路

介于上传的样式多种多样,所以考虑将文件选择和上传,同ui样式做分离,这样基础组件只需要负责文件的选取和上传,而ui样式则交给其它的组件来负责。

代码结构

微信截图_20230412165906.png

c-upload 负责文件的选取和公共上传逻辑

FileInfo类表明文件实例

utils提供一些公共方法

picture-upload为ui组件之一,负责样式的渲染。

具体实现

c-upload

文件上传基础组件,只负责文件的选取和上传。

通过传入slot来自定义文件上传样式。

action属性为传入的上传方法,如果非选中直接上传,可以忽略该方法,在ui组件内部进行手动上传。

beforeUpload负责在上传前进行拦截

<template>
    <div class="c-upload" @click="triggerFileInput">
        <slot></slot>
        <input
            ref="fileInput"
            class="file-input"
            @change="handleFileChange"
            :multiple="multiple"
            :accept="accept"
            type="file" />
    </div>
</template>

<script>
import FileInfo, { EnumFileStatus } from './FileInfo'

export {
    FileInfo,
    EnumFileStatus
}
/**
 * 错误类型
 */
export const EErrorType = {
    ActionNotDefined: 1,
    BeforeUploadFalse: 1 << 1,
    uploadError: 1 << 2
}

export default {
    props: {
        accept: {
            type: String,
            default: '*'
        },
        // 是否多选
        multiple: {
            type: Boolean,
            default: false
        },
        // 是否禁用
        disabled: {
            type: Boolean,
            default: false
        },
        // 上传方法
        action: {
            type: Function
        },
        // 上传前
        beforeUpload: {
            type: Function
        }
    },
    methods: {
        // 触发文件上传
        triggerFileInput() {
            // 禁用 中止
            if (this.disabled) {
                return
            }
            this.$refs.fileInput.click()
        },
        // 监听文件改变
        handleFileChange(e) {
            const { action } = this
            const newFiles = [...e.target.files]
            const newFileInfos = []
            newFiles.forEach(file => newFileInfos.push(new FileInfo(file)))
            this.$emit('change', newFileInfos)
            this.clearFileInputValue()
            action && this.executeFileUpload(newFileInfos)
        },
        // 执行文件上传
        executeFileUpload(fileInfos) {
            // 批量文件上传
            fileInfos.forEach(fileInfo => this.uploadFile(fileInfo))
        },
        // 上传单个文件
        async uploadFile(fileInfo) {
            const { action, beforeUpload } = this
            if (!action) {
                this.emitError(fileInfo, { code: EErrorType.ActionNotDefined, msg: 'action is not defined' })
                return
            }
            // 上传前钩子
            if (beforeUpload) {
                const res = beforeUpload(fileInfo)
                // 返回false 中止上传
                if (res === false) {
                    this.emitError(fileInfo, { code: EErrorType.BeforeUploadFalse, msg: 'beforeUpload返回false,中止上传' })
                    return
                }
            }
            try {
                // 上传中
                fileInfo.setStatus(EnumFileStatus.Uploading)
                await action(fileInfo)
                fileInfo.setStatus(EnumFileStatus.UploadSucceed)
                this.$emit('success', fileInfo)
            } catch (err) {
                this.emitError(fileInfo, { code: EErrorType.uploadError, msg: err })
            }
        },
        // 通知失败
        emitError(fileInfo, err) {
            // 失败
            fileInfo.setStatus(EnumFileStatus.UploadFailed)
            console.error(err)
            this.$emit('error', fileInfo, err)
        },
        // 清空选中的file文件
        clearFileInputValue() {
            this.$refs.fileInput.value = ''
        }
    }
}
</script>

<style lang="scss" scoped>
.c-upload {
    display: inline-block;
    .file-input {
        display: none;
        width: 0;
        height: 0;
        font-size: 0;
    }
}
</style>

FileInfo

定义了文件实例,status有四种状态

import { isFile, isString } from '../utils'
/**
 * 文件状态
 */
export const EnumFileStatus = {
    // 未上传
    NotUploaded: 1,
    // 上传中
    Uploading: 2,
    // 上传成功
    UploadSucceed: 3,
    // 上传失败
    UploadFailed: 4
}

/**
 * 文件实例
 */
export default class FileInfo {
    name = ''
    url = ''
    _status = EnumFileStatus.NotUploaded
    _progress = 0
    // Blob
    _file = null
    constructor(data) {
        if (isFile(data)) {
            this.name = data.name
            this._file = data
        } else if (isString(data)) {
            this.url = data
            this.setProgress(100)
            this._status = EnumFileStatus.UploadSucceed
        }
    }

    get progress() {
        return this._progress
    }

    get file() {
        return this._file
    }

    get status() {
        return this._status
    }

    get success() {
        return this.status === EnumFileStatus.UploadSucceed
    }

    // 设置上传状态
    setStatus(status) {
        this._status = status
    }

    // 设置url
    setUrl(url) {
        this.url = url
    }

    // 设置进度
    setProgress(progress) {
        this._progress = progress
    }
}

utils

公共方法,uploadFile为默认的文件上传方法,可通过传入action属性进行自定义。

/**
 * 获取类型
 * @param {*} value
 * @returns null | string
 */
const getType = value => {
    const matched = Object.prototype.toString.call(value).match(/^\[object\s(.*?)\]$/)
    return matched ? matched[1] : null
}

export const isFile = value => getType(value) === 'File'

export const isString = value => getType(value) === 'String'

// 文件上传公用方法
export async function uploadFile(fileInfo) {
    try {
        const formData = new FormData()
        formData.append('iconFile', fileInfo.file)
        let curUpLoadProgress = 0
        fileInfo.setProgress(0)
        const res = await this.$http({
            url: '上传的url',
            data: formData,
            headers: {
                'Content-Type': 'multipart/form-data'
            },
            // 上传进度
            onUploadProgress(progressEvent) {
                if (progressEvent.lengthComputable) {
                    curUpLoadProgress = Math.floor(progressEvent.loaded / progressEvent.total * 100)
                    fileInfo.setProgress(curUpLoadProgress)
                }
            }
        })
        // 上传成功
        if (res.status === '0' && res.data) {
            fileInfo.setUrl(res.data)
        } else {
            // 上传失败
            return Promise.reject(new Error('数据格式异常'))
        }
    } catch (error) {
        return Promise.reject(error)
    }
}


picture-upload

负责ui样式的展示,在接收到选取的文件后进行展示。

<template>
    <div class="pic-upload">
        <div class="inner-container">
            <div class="list-item" v-for="(item, index) in fileList" :key="index">
                <el-progress
                    v-if="item.status === EnumFileStatus.Uploading"
                    :width="70"
                    type="circle"
                    :percentage="item.progress"></el-progress>
                <div class="img-container" v-else-if="item.url">
                    <img :src="item.url" alt="图片" />
                    <i @click.stop="handleDelete(index)" class="img-delete el-icon-error"></i>
                </div>
            </div>
            <!-- 超出限制 隐藏上传按钮 -->
            <CUpload
                v-if="!uploadDisabled"
                accept="image/*"
                :action="uploadFile"
                :before-upload="beforeUpload"
                @change="handleUploadChange"
                @error="handleUploadError">
                <div class="upload-container">
                    <div class="upload-btn">
                        <i class="el-icon-plus"></i>
                        <p>上传</p>
                    </div>
                </div>
            </CUpload>
        </div>
    </div>
</template>

<script>
import CUpload, { EnumFileStatus, EErrorType } from '../c-upload'
import { uploadFile } from '../utils'
import emitter from 'element-ui/src/mixins/emitter'

export {
    EnumFileStatus
}

export default {
    mixins: [emitter],
    components: {
        CUpload
    },
    props: {
        value: {
            type: Array,
            default: () => []
        },
        limit: {
            type: Number
        },
        beforeUpload: {
            type: Function
        }
    },
    data() {
        return {
            EnumFileStatus
        }
    },
    computed: {
        fileList: {
            get() {
                return this.value || []
            },
            set(value) {
                this.$emit('input', value)
            }
        },
        // 是否禁止上传
        uploadDisabled() {
            const { limit, fileList } = this
            if (limit && fileList.length >= limit) {
                return true
            }
            return false
        }
    },
    methods: {
        // 选择文件
        handleUploadChange(fileInfos) {
            // 保存新选中的实例
            this.fileList.push(...fileInfos)
            this.dispatch('ElFormItem', 'el.form.change', this.fileList)
        },
        // 删除文件
        handleDelete(index) {
            this.deleteFileByIndex(index)
        },
        // 上传失败
        handleUploadError(fileInfo, err) {
            // 删除保存的文件实例
            const index = this.findIndexByFile(fileInfo)
            if (~index) {
                this.deleteFileByIndex(index)
            }
            if (err.code & EErrorType.uploadError) {
                this.$message.error('文件上传失败')
            }
        },
        // 根据下标 删除文件
        deleteFileByIndex(index) {
            this.fileList.splice(index, 1)
            this.dispatch('ElFormItem', 'el.form.change', this.fileList)
        },
        // 根据文件 寻找下标
        findIndexByFile(fileInfo) {
            return this.fileList.findIndex(item => item === fileInfo)
        },
        // action
        uploadFile
    }
}
</script>

<style lang="scss" scoped>
.pic-upload{
    .inner-container {
        display: flex;
        flex-wrap: wrap;
        padding: 8px;
        margin-right: -20px;
        margin-bottom: -20px;
        overflow: hidden;
        .list-item,
        .upload-container {
            width: 80px;
            height: 80px;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            margin: 0 20px 20px 0;
            border-radius: 10px;
            border: 1px dashed #ccc;
        }
        .list-item {
            .img-container {
                position: relative;
                width: 100%;
                height: 100%;
                padding: 10px;
                > img {
                    display: block;
                    width: 100%;
                    height: 100%;
                    object-fit: scale-down;
                }
                .img-delete {
                    position: absolute;
                    top: -8px;
                    right: -8px;
                    color: #bbb;
                    font-size: 20px;
                    cursor: pointer;
                }
            }
        }
        .upload-container {
            cursor: pointer;
            .upload-btn {
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                padding-top: 10px;
                > i {
                    font-size: 26px;
                    line-height: 26px;
                    color: #ccc;
                    margin-bottom: 5px;
                }
                > p {
                    font-size: 13px;
                    line-height: 13px;
                    color: #aaa;
                }
            }
        }
    }
}
</style>

</style>

基本使用

<template>
    <PictureUpload
        v-model="fileList"
        accept="image/png,image/jpg"
        :before-upload="beforeUpload"
        :limit="1"></PictureUpload>
</template>

<script>
import {} from '@/compon'
export default {
    data() {
        return {
            fileList: []
        }
    },
    methods: {
        // 上传前拦截钩子
        beforeUpload(fileInfo) {
            if (/\.(png|jpg)$/.test(fileInfo.name)) {
                return true
            }
            this.$message.warning('仅支持.png,.jpg格式')
            return false
        }
    }
}
</script>
上传前上传后
image.png微信截图_20230414104721.png