vue3+cropper+cos 最简上传、存储实践
最近老是碰到PC端的上传组件需求,本来用的是element-ui的上传组件,因为需要接入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
来进行优化。
在组件中笔者定义了innerCropperOptions
和outerCropperOptions
两个变量来处理插件的参数。其中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实例,在下一部分会具体介绍初始化过程。
这里我们不需要分片,也不会上传大文件所以直接使用putObject
方法进行上传就好了。需要注意的是Bucket
、Region
这两个字段需要与初始化SDK的对应上,否则将无法正常调用API。
不知道这两个参数怎么来的同学可以看看下图
上传完毕之后需要自行拼接文件url,然后清空指定input的value(当然也可以给input附一个ref再操作),最后将处理好的文件列表抛出给父组件进行后续的处理。
完整代码
按需查看,逻辑都在前面四部分阐述完毕,剩下的大都是模板和样式,可以先看下一部分的内容。
因篇幅过长,已经迁移到文章末尾 --> 传送门
腾讯云COS处理
一般情况下,上传组件完成之后,前端侧的工作就完成了,但是有时候会需要前端直传文件,然后将文件链接给到后端,这就需要前端和静态存储进行交互了。
前端与静态存储进行交互是需要鉴权的,有以下几种方式:
- 将密钥写在前端项目内,然后可以直接与COS进行交互
- 密钥存在后端,后端与静态存储交互鉴权,并将鉴权信息缓存,然后返回前端
- 方式②的分支,将缓存放到了前端,后端获得鉴权信息后直接交给前端
下面将会以方式②进行阐述,不推荐大家使用方式①,会将密钥暴露到公网
腾讯云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}`);
});
生成密钥需要这几个参数:secretId
、secretKey
、bucket
、region
其中bucket
、region
在对应的存储桶面板中可以找到,secretId
、secretKey
则需要在密钥管理中生成。
参数都准备好之后就可以直接调用qcloud-cos-sts
的getCredential
方法来生成临时密钥,这里笔者使用的是lru-cache
来进行密钥缓存,一次生成的有效期是三十分钟,期间前端的请求都会直接从缓存中获取然后返回。
拓展
至此,一个简单可用的上传、存储流程算是做好了,接下来我们还可以做进一步的优化。
图片压缩
这是老生常谈的话题了,现在的压缩插件也十分成熟,这里推荐compressorjs简单易用。
非图片类型上传、预览
一般用到上传组件的地方都是管理后台,所以上传的文件类型就不一定是图片,我们可以通过识别文件的后缀,对不同类型的文件进行处理。
主要处理的是不同类型文件的预览问题,无论什么类型的文件在上传的处理是一致的。比如上传的文件是PDF,我们就可以把预览中的img
标签换成iframe
标签,可以直接把PDF渲染出来。
视频也是同理,如果是word/ppt这类文件就的额外接入插件了,这类建议直接下载预览,可以保证预览质量和格式。
支持多bucket临时密钥获取
因为临时密钥是跟着bucket走的,如果有其他bucket也需要生成临时密钥的话是不是就要重新写一遍呢?这未免太不优雅了,我们的生成接口是可以做成通用接口的,只需要生成的时候将目标bucket名称传入即可生成不同的密钥。
然后在缓存标识中新增bucket名称,用于区分不同bucket的临时密钥缓存即可支持bucket临时密钥获取。
进阶实践
附录:完整代码
使用示例
<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