likes
comments
collection
share

vue3+cropper+cos 最简上传、存储实践

作者站长头像
站长
· 阅读数 35

最近老是碰到PC端的上传组件需求,本来用的是element-ui的上传组件,因为需要接入COS上传,所以老是感觉用起来不顺手,最后撸了一个简单版凑合用用。

组件总览

组件包括上传区域、文件列表、图片预览以及图片裁剪编辑。

vue3+cropper+cos 最简上传、存储实践

vue3+cropper+cos 最简上传、存储实践

前端部分

前端实现分为了四个模块,下面将会逐一阐述。(使用了elementUI弹窗组件,视情况替换)

① 组件初始化

<input :id="uniqID" type="file" class="upload_input" />

/* 组件初始模块 */
const useInit = props => {
    const uniqID = randomString(6, 'aA')
    const uploadList = computed(() => {
        const value = props.modelValue
        return Array.isArray(value) ? value : [{ url: value }]
    })
    const isUploadLimit = computed(() => uploadList.value.length >= props.limit)
    return {
        tips: props.tips,
        uniqID,
        uploadList,
        isUploadLimit,
    }
}

组件初始化时需要确定几个变量:

uniqID - 用于应对父组件内多个上传组件的情况,组件间的变量虽然是隔离的,但是input上传比较特殊,每次上传完之后需要找到对应的input清除掉它的value,才能继续触发文件选取。

uploadList - 接收外部传入的文件列表,如果外部传入的是字符串则会默认处理成数组,方便后续逻辑处理。

isUploadLimit - 是否达到规定的上传文件数量限制

② 文件列表操作模块

<div class="upload">
    <div class="preview_list">
        <div class="image" v-for="(item, i) in uploadList" :key="i" :style="{ backgroundImage: `url(${item.url})` }">
            <div class="option">
                <div class="option_icon" @click="handlePreview(i)">
                    <img src="./zoomin.svg" alt="" />
                </div>
                <div class="option_icon" @click="handleDelete(i)">
                    <img src="./delete.svg" alt="" />
                </div>
            </div>
        </div>
    </div>
    <div class="upload_area" v-if="!isUploadLimit">
        <input :id="uniqID" type="file" class="upload_input" @change="handleFile" />
        <slot>
            <div class="upload_icon">
                <img src="./upload.svg" alt="" />
            </div>
        </slot>
    </div>
</div>
<el-dialog v-model="previewShow" title="图片预览" :width="800" :destroy-on-close="true">
    <div class="preview">
        <img :src="previewImage" alt="preview-image" />
    </div>
</el-dialog>

/* 文件列表操作模块 */
const useFileOperation = ({ ctx, init, cropper, upload }) => {
    const previewShow = ref(false)
    const previewImage = ref('')
    const handleFile = e => {
        const file = e.target.files[0]
        const ext = file.type.split('/')[1]
        /* 如果需要进行裁剪,可以将逻辑移交给裁剪模块处理 */
        cropper.handleCropperShow(file, ext)
        // upload(file, ext)
    }
    const handleDelete = index => {
        const list = [...init.uploadList.value]
        list.splice(index, 1)
        ctx.emit('update:modelValue', list)
    }
    const handlePreview = index => {
        previewShow.value = true
        previewImage.value = init.uploadList.value[index].url
    }
    return {
        previewShow,
        previewImage,
        /* Function */
        handleFile,
        handleDelete,
        handlePreview,
    }
}

本模块将会处理文件列表操作相关的逻辑,包括预览文件、选取file对象、删除文件。

需要注意的是,本组件对文件的所有处理都是通过emit来实现的,并不会直接处理组件内的文件列表,而是通过父组件的数据变更来驱动组件内的变化。

③ 图片裁剪模块(可选)

本模块是可自由拔插的,如果需要图片裁剪功能则可以加上,不需要的话只需要在setup的时候去掉即可。

<el-dialog v-model="showCropper" title="图片编辑" :width="800" :destroy-on-close="true">
    <div
        class="cropper"
        :style="{
            width: outerCropperOptions.autoCropWidth * 1.5 + 'px',
            height: outerCropperOptions.autoCropHeight * 1.5 + 'px',
        }"
    >
        <vueCropper
            ref="cropper"
            :img="cropperOptions.img"
            :outputSize="1"
            v-bind="{ ...innerCropperOptions, ...outerCropperOptions }"
        />
    </div>
    <div class="action">
        <el-button type="primary" @click="handleSaveImage">确定</el-button>
        <el-button @click="handleCropperClose">取消</el-button>
    </div>
</el-dialog>

/* 图片裁剪模块(可选) */
const useCropper = ({ props, upload }) => {
    const cropper = ref(null)
    const showCropper = ref(false)
    const innerCropperOptions = {
        canMove: true,
        canMoveBox: true,
        autoCrop: true,
        fixedBox: true,
        infoTrue: false,
        enlarge: 1,
    }
    const outerCropperOptions = computed(() => props.cropOptions)
    const cropperOptions = ref({})
    const handleCropperShow = (file, ext) => {
        const fileReader = new FileReader()
        fileReader.readAsDataURL(file)
        fileReader.onload = res => {
            cropperOptions.value = {
                img: res.target.result,
                type: ext,
            }
            showCropper.value = true
        }
    }
    const handleCropperClose = () => {
        showCropper.value = false
    }
    const handleSaveImage = () => {
        cropper.value.getCropBlob(data => {
            upload.handleUpload(data, cropperOptions.value.type, () => {
                handleCropperClose()
            })
        })
    }
    return {
        cropper,
        showCropper,
        cropperOptions,
        innerCropperOptions,
        outerCropperOptions,
        /* Function */
        handleSaveImage,
        handleCropperShow,
        handleCropperClose,
    }
}

vue-cropper文档在此传送门

这个插件需要的参数非常多,而且基本都需要视业务情况调整的,所以会出现template里面出现一堆参数的情况,这个时候就可以使用v-bind来进行优化。

在组件中笔者定义了innerCropperOptionsouterCropperOptions两个变量来处理插件的参数。其中inner是组件内部默认定义的参数,而outer则是父组件传入的参数。

然后在模板中按照{ ...innerCropperOptions, ...outerCropperOptions }这个顺序解构,则可以允许外部参数覆盖默认参数,让参数配置更加灵活。

④ 文件上传模块

/* 文件上传模块 */
const useUpload = ({ ctx, init }) => {
    const handleUpload = (file, ext, cb) => {
        cosInstance.putObject(
            {
                Bucket: '-',
                Region: '-',
                Key: `${randomString(32, 'aA#')}.${ext}`,
                Body: file,
            },
            function (err, data) {
                if (data) {
                    const url = `https://${data.Location}`
                    document.querySelector(`#${init.uniqID}`).value = ''
                    ctx.emit('update:modelValue', [...init.uploadList.value, { url }])
                }
                cb && cb()
            },
        )
    }
    return {
        handleUpload,
    }
}

最后一个模块则是文件上传逻辑处理,如果是后端同事直接提供的接口就没什么好说了,我们这里只看前端直传腾讯云COS的逻辑。

cosInstance是初始化后的SDK实例,在下一部分会具体介绍初始化过程。

腾讯云COS官方文档

这里我们不需要分片,也不会上传大文件所以直接使用putObject方法进行上传就好了。需要注意的是BucketRegion这两个字段需要与初始化SDK的对应上,否则将无法正常调用API。

不知道这两个参数怎么来的同学可以看看下图

vue3+cropper+cos 最简上传、存储实践

上传完毕之后需要自行拼接文件url,然后清空指定input的value(当然也可以给input附一个ref再操作),最后将处理好的文件列表抛出给父组件进行后续的处理。

完整代码

按需查看,逻辑都在前面四部分阐述完毕,剩下的大都是模板和样式,可以先看下一部分的内容。

因篇幅过长,已经迁移到文章末尾 --> 传送门

腾讯云COS处理

一般情况下,上传组件完成之后,前端侧的工作就完成了,但是有时候会需要前端直传文件,然后将文件链接给到后端,这就需要前端和静态存储进行交互了。

前端与静态存储进行交互是需要鉴权的,有以下几种方式:

  1. 将密钥写在前端项目内,然后可以直接与COS进行交互
  2. 密钥存在后端,后端与静态存储交互鉴权,并将鉴权信息缓存,然后返回前端
  3. 方式②的分支,将缓存放到了前端,后端获得鉴权信息后直接交给前端

下面将会以方式②进行阐述,不推荐大家使用方式①,会将密钥暴露到公网

腾讯云COS-SDK

// @/libs/cos

import axios from 'axios'
const COS = require('cos-js-sdk-v5')
const cosInstance = new COS({
    getAuthorization: function (options, callback) {
        axios.post('https://your-api/getAuthorization', {
            bucket: 'your-bucket',
        }).then((res) => {
            if (res.data.code === '0') {
                const { credentials, startTime, expiredTime } = res.data.data
                callback({
                    TmpSecretId: credentials.tmpSecretId,
                    TmpSecretKey: credentials.tmpSecretKey,
                    SecurityToken: credentials.sessionToken,
                    StartTime: startTime,
                    ExpiredTime: expiredTime,
                });
            }
        })
    }
});

export default cosInstance

这里就是初始化COS SDK的地方,也就是上文cosInstance的来源。

因为我们采用的方式是后端缓存鉴权的策略,所以前端只需要在用到的时候直接请求鉴权接口就可以了,不需要再做额外的缓存逻辑。

腾讯云COS-STS

笔者的业务中有现成在跑的node服务,就直接在里面实现了。

为了让大家可以快速调试,补了一下云函数实现,基于最纯净的云函数默认模板开发,基本是拷贝过去就能直接跑。

const STS = require('qcloud-cos-sts')
const LRU = require('lru-cache')
const express = require("express");
const app = express();
const port = 9000;

const cache = new LRU({
  ttl: 1000 * 60 * 5
})

const CACHE_KEY = 'STS-KEYS'

const handleSts = () => {
  const secretId = '-'
  const secretKey = '-'
  const bucket = 'yourbucket-appid'
  const region = 'ap-guangzhou'
  const [shortBucketName, appId] = bucket.split("-");
  const allowActions = ["name/cos:PutObject", "name/cos:PostObject"];
  const policy = {
    version: "2.0",
    statement: [
      {
        action: allowActions,
        effect: "allow",
        principal: { qcs: ["*"] },
        resource: [
          `qcs::cos:${region}:uid/${appId}:prefix//${appId}/${shortBucketName}/*`,
        ],
      },
    ],
  };
  return new Promise((resolve, reject) => {
    STS.getCredential(
      {
        secretId,
        secretKey,
        proxy: "",
        durationSeconds: 1800,
        policy,
      },
      (err, tempKeys) => {
        if (err) reject(err);
        cache.set(CACHE_KEY, tempKeys, 30 * 60 * 1000);
        resolve(tempKeys);
      }
    );
  });
};

app.post("/getAuthorization", async (req, res) => {
  const cacheData = cache.get(CACHE_KEY)
  if (cacheData) {
    return res.send(cacheData)
  }
  const stsRes = await handleSts()
  res.send(stsRes);
});

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

腾讯云 - 临时密钥生成官方文档

生成密钥需要这几个参数:secretIdsecretKeybucketregion

其中bucketregion在对应的存储桶面板中可以找到,secretIdsecretKey则需要在密钥管理中生成。

vue3+cropper+cos 最简上传、存储实践

参数都准备好之后就可以直接调用qcloud-cos-stsgetCredential方法来生成临时密钥,这里笔者使用的是lru-cache来进行密钥缓存,一次生成的有效期是三十分钟,期间前端的请求都会直接从缓存中获取然后返回。

拓展

至此,一个简单可用的上传、存储流程算是做好了,接下来我们还可以做进一步的优化。

图片压缩

这是老生常谈的话题了,现在的压缩插件也十分成熟,这里推荐compressorjs简单易用。

非图片类型上传、预览

一般用到上传组件的地方都是管理后台,所以上传的文件类型就不一定是图片,我们可以通过识别文件的后缀,对不同类型的文件进行处理。

主要处理的是不同类型文件的预览问题,无论什么类型的文件在上传的处理是一致的。比如上传的文件是PDF,我们就可以把预览中的img标签换成iframe标签,可以直接把PDF渲染出来。

视频也是同理,如果是word/ppt这类文件就的额外接入插件了,这类建议直接下载预览,可以保证预览质量和格式。

支持多bucket临时密钥获取

因为临时密钥是跟着bucket走的,如果有其他bucket也需要生成临时密钥的话是不是就要重新写一遍呢?这未免太不优雅了,我们的生成接口是可以做成通用接口的,只需要生成的时候将目标bucket名称传入即可生成不同的密钥。

然后在缓存标识中新增bucket名称,用于区分不同bucket的临时密钥缓存即可支持bucket临时密钥获取。

进阶实践

进阶实践:COS与H5可回溯方案结合

附录:完整代码

使用示例

<template>
    <div style="padding: 30px;">
        <MyUpload v-model="uploadList" :limit="2" :cropOptions="option" tips="请上传尺寸为500x300的图片,最多上传2张图片" />
        <el-button style="margin-top: 20px;" @click="handleSubmit">提交</el-button>
    </div>
</template>

<script>
import { ref } from 'vue'
import MyUpload from '@/components/Upload/Upload.vue'

const pageDetail = {
    components: {
        MyUpload,
    },
    setup() {
        const uploadList = ref([])
        const option = {
            autoCropWidth: 500,
            autoCropHeight: 300,
        }
        const handleSubmit = () => {
            console.log(image.value.map((image) => image.url))
        }
        return {
            option,
            uploadList,
            handleSubmit,
        }
    }
}
export default pageDetail
</script>

上传组件

<template>
    <div class="container">
        <div class="tips" v-if="tips">{{ tips }}</div>
        <div class="upload">
            <div class="preview_list">
                <div
                    class="image"
                    v-for="(item, i) in uploadList"
                    :key="i"
                    :style="{ backgroundImage: `url(${item.url})` }"
                >
                    <div class="option">
                        <div class="option_icon" @click="handlePreview(i)">
                            <img src="./zoomin.svg" alt="" />
                        </div>
                        <div class="option_icon" @click="handleDelete(i)">
                            <img src="./delete.svg" alt="" />
                        </div>
                    </div>
                </div>
            </div>
            <div class="upload_area" v-if="!isUploadLimit">
                <input :id="uniqID" type="file" class="upload_input" @change="handleFile" />
                <slot>
                    <div class="upload_icon">
                        <img src="./upload.svg" alt="" />
                    </div>
                </slot>
            </div>
        </div>
        <el-dialog v-model="showCropper" title="图片编辑" :width="800" :destroy-on-close="true">
            <div
                class="cropper"
                :style="{
                    width: outerCropperOptions.autoCropWidth * 1.5 + 'px',
                    height: outerCropperOptions.autoCropHeight * 1.5 + 'px',
                }"
            >
                <vueCropper
                    ref="cropper"
                    :img="cropperOptions.img"
                    :outputSize="1"
                    v-bind="{ ...innerCropperOptions, ...outerCropperOptions }"
                />
            </div>
            <div class="action">
                <el-button type="primary" @click="handleSaveImage">确定</el-button>
                <el-button @click="handleCropperClose">取消</el-button>
            </div>
        </el-dialog>
        <el-dialog v-model="previewShow" title="图片预览" :width="800" :destroy-on-close="true">
            <div class="preview">
                <img :src="previewImage" alt="preview-image" />
            </div>
        </el-dialog>
    </div>
</template>

<script>
import { computed, ref } from 'vue'
import { VueCropper } from 'vue-cropper'
import cosInstance from '@/libs/cos'

import 'vue-cropper/dist/index.css'

/* 随机字符串通用方法 */
const randomString = (length, chars) => {
    let mask = ''
    if (chars.indexOf('a') > -1) {
        mask += 'abcdefghijklmnopqrstuvwxyz'
    }
    if (chars.indexOf('A') > -1) {
        mask += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    }
    if (chars.indexOf('#') > -1) {
        mask += '0123456789'
    }
    if (chars.indexOf('!') > -1) {
        mask += '~`!@#$%^&*()_+-={}[]:";\'<>?,./|\\'
    }
    let result = ''
    for (let i = length; i > 0; i -= 1) {
        result += mask[Math.floor(Math.random() * mask.length)]
    }
    return result
}

/* 组件初始模块 */
const useInit = props => {
    const uniqID = randomString(6, 'aA')
    const uploadList = computed(() => {
        const value = props.modelValue
        return Array.isArray(value) ? value : [{ url: value }]
    })
    const isUploadLimit = computed(() => uploadList.value.length >= props.limit)
    return {
        tips: props.tips,
        uniqID,
        uploadList,
        isUploadLimit,
    }
}

/* 文件列表操作模块 */
const useFileOperation = ({ ctx, init, cropper, upload }) => {
    const previewShow = ref(false)
    const previewImage = ref('')
    const handleFile = e => {
        const file = e.target.files[0]
        const ext = file.type.split('/')[1]
        /* 如果需要进行裁剪,可以将逻辑移交给裁剪模块处理 */
        cropper.handleCropperShow(file, ext)
        console.log(upload)
        // upload(file, ext)
    }
    const handleDelete = index => {
        const list = [...init.uploadList.value]
        list.splice(index, 1)
        ctx.emit('update:modelValue', list)
    }
    const handlePreview = index => {
        previewShow.value = true
        previewImage.value = init.uploadList.value[index].url
    }
    return {
        previewShow,
        previewImage,
        /* Function */
        handleFile,
        handleDelete,
        handlePreview,
    }
}

/* 图片裁剪模块(可选) */
const useCropper = ({ props, upload }) => {
    const cropper = ref(null)
    const showCropper = ref(false)
    const innerCropperOptions = {
        canMove: true,
        canMoveBox: true,
        autoCrop: true,
        fixedBox: true,
        infoTrue: false,
        enlarge: 1,
    }
    const outerCropperOptions = computed(() => props.cropOptions)
    const cropperOptions = ref({})

    const handleCropperShow = (file, ext) => {
        const fileReader = new FileReader()
        fileReader.readAsDataURL(file)
        fileReader.onload = res => {
            cropperOptions.value = {
                img: res.target.result,
                type: ext,
            }
            showCropper.value = true
        }
    }
    const handleCropperClose = () => {
        showCropper.value = false
    }
    const handleSaveImage = () => {
        console.log(upload)
        cropper.value.getCropBlob(data => {
            upload.handleUpload(data, cropperOptions.value.type, () => {
                handleCropperClose()
            })
        })
    }
    return {
        cropper,
        showCropper,
        cropperOptions,
        innerCropperOptions,
        outerCropperOptions,
        /* Function */
        handleSaveImage,
        handleCropperShow,
        handleCropperClose,
    }
}

/* 文件上传模块 */
const useUpload = ({ ctx, init }) => {
    const handleUpload = (file, ext, cb) => {
        cosInstance.putObject(
            {
                Bucket: '-',
                Region: '-',
                Key: `${randomString(32, 'aA#')}.${ext}`,
                Body: file,
            },
            function (err, data) {
                if (data) {
                    const url = `https://${data.Location}`
                    document.querySelector(`#${init.uniqID}`).value = ''
                    ctx.emit('update:modelValue', [...init.uploadList.value, { url }])
                }
                cb && cb()
            },
        )
    }
    return {
        handleUpload,
    }
}

export default {
    components: {
        VueCropper,
    },
    props: {
        tips: String,
        modelValue: [Array, String],
        limit: {
            type: Number,
            default: 1,
        },
        inputAttr: {
            type: Object,
            default: () => {},
        },
        cropOptions: {
            type: Object,
            default: () => {},
        },
    },
    emits: ['update:modelValue'],
    setup(props, ctx) {
        const init = useInit(props)
        const upload = useUpload({ ctx, init })
        const cropper = useCropper({ props, upload })
        const fileOperation = useFileOperation({ ctx, init, upload, cropper })
        return {
            ...init,
            ...upload,
            ...cropper,
            ...fileOperation,
        }
    },
}
</script>

<style lang="less" scoped>
.container {
    .tips {
        margin-bottom: 15px;
    }
    .upload {
        display: flex;

        .upload_area {
            width: 150px;
            height: 150px;
            border: 1px dashed #999999;
            border-radius: 6px;
            position: relative;
            margin-right: 15px;
            display: flex;
            align-items: center;
            justify-content: center;

            .upload_input {
                position: absolute;
                width: 150px;
                height: 150px;
                top: 0;
                left: 0;
                opacity: 0;
                z-index: 100;
                cursor: pointer;
            }

            .upload_icon {
                color: #8c939d;
                img {
                    width: 50px;
                }
            }
        }

        .action {
            margin-top: 20px;
            text-align: right;
        }

        .preview_list {
            display: flex;

            .image {
                width: 150px;
                height: 150px;
                background-size: contain;
                background-repeat: no-repeat;
                background-position: center center;
                display: flex;
                justify-content: center;
                align-items: center;
                margin-right: 15px;
                position: relative;
                border-radius: 6px;
                overflow: hidden;

                img {
                    max-width: 100%;
                }

                .option {
                    display: flex;
                    width: 150px;
                    height: 150px;
                    display: flex;
                    justify-content: space-around;
                    align-items: center;
                    position: absolute;
                    top: 0;
                    left: 0;
                    background-color: rgba(0, 0, 0, 0.5);
                    opacity: 0;
                    z-index: 100;

                    &:hover {
                        opacity: 1;
                    }

                    .option_icon {
                        width: 40px;
                        cursor: pointer;
                    }
                }
            }
        }
    }
    .cropper {
        margin-bottom: 20px;
    }
    .preview {
        width: 100%;
        img {
            max-width: 100%;
        }
    }
}
</style>
转载自:https://juejin.cn/post/7177274291010928700
评论
请登录