需求
Compressor.js 是一个用于在客户端(即在浏览器中)对图片进行压缩的 JavaScript 库。使用它有以下几个优点和意义:
-
减少文件大小: 图片通常是网页中占用大量带宽的资源之一。通过使用 Compressor.js 对图片进行压缩,可以显著减少图片文件的大小,从而减少页面加载时间,提高网页性能。
-
节省带宽: 在移动设备上访问网页时,特别是在使用移动数据连接(如4G或5G)时,大文件大小的图片会消耗大量的数据流量。通过压缩图片,可以节省用户的数据流量,降低用户的数据费用。
-
提高用户体验: 加载速度快的网页可以提供更好的用户体验。通过减少图片大小,可以加快网页的加载速度,使用户能够更快地访问和浏览网页内容。
-
支持移动端开发: 在移动端开发中,特别是在开发需要上传图片的应用时,通常需要在客户端对上传的图片进行压缩以减少上传时间和数据传输量。Compressor.js 提供了一个方便的方式来在移动设备上对图片进行压缩,使得开发人员能够轻松地实现这一功能。
-
保持图片质量: Compressor.js 在压缩图片的同时,尽可能地保持图片的质量,避免出现明显的视觉损失。尤其对于相机直拍的照片,有很好的加速作用,试想当服务器网络质量差,带宽只有数十K的时候,上传一张5M的高清图至少需要一分钟以上。而通过压缩操作后,5M照片可以压缩至200~300K且不损失质量,上传至需要几秒即可完成,体验立马上去一个档次。
而Element-UI的上传组件,默认就是使用FormData进行的直传,如果可以和压缩功能结合,当图片较大时自动处理。则在移动端上传直拍照片可以获得很好的体验效果。
集成方案
按照comppressor.js的文档(compressorjs - npm)。我们可以新建一个Compressor,在其success回调里进行上传。其入口和回调返回都是File结构,这样我们便可以处理文件流的压缩
针对element-ui的el-upload组件,有一个扩展点,即http-request可以传入一个UploadRequestHandler结构:
export declare type UploadRequestHandler = (options: UploadRequestOptions) => XMLHttpRequest | Promise<unknown>;
返回一个Promise进行对应的上传处理,这样我们即可绕过el-upload自带的上传逻辑,建立我们自己的上传及处理返回值逻辑
我们定义处理器如下:
// 文件上传切面,支持图片压缩和适配权限,默认激活策略是:图片大小大于300k(jpg)或500k(png),比例缩放到1334大小
export function optImgHttpRequest(options: UploadRequestOptions) {
// el-upload的图片压缩适配
const SIZE_LIMIT = 1334
const JPG_KB_START = 300 * 1024
const PNG_KB_START = 500 * 1024
const thumbWidth = options.data['thumbWidth']
const thumbHeight = options.data['thumbHeight']
const uploadProcess = (resolve: Function, reject: Function, file: File) => {
const formData = new FormData()
formData.append(
'file',
file,
options.file.name.toLowerCase().endsWith('.png') && options.file.size > 5000000
? options.file.name.replace(/(\.PNG|\.png)$/i, '.jpg') //超过5M的png会转化为jpg,文件名改变
: file.name
)
if (thumbHeight) {
formData.append('thumbHeight', thumbHeight.toString())
}
if (thumbWidth) {
formData.append('thumbWidth', thumbWidth.toString())
}
return API.adminTools.upload
.request({}, formData, {
headers: {
'Content-Type': 'multipart/form-data' // 覆盖类型为form-data上传
}
})
.then((res: defs.FileInfo) => {
resolve(res)
})
.catch((err) => {
reject(err)
})
}
if (
// jpg和png要大于尺寸才开启压缩
((options.file.name.toLowerCase().endsWith('.jpg') || options.file.name.toLowerCase().endsWith('.jpeg')) &&
options.file.size > JPG_KB_START) ||
(options.file.name.toLowerCase().endsWith('.png') && options.file.size > PNG_KB_START)
) {
return new Promise((resolve, reject) => {
new Compressor(options.file, {
quality: 0.8,
maxWidth: SIZE_LIMIT,
maxHeight: SIZE_LIMIT,
success(result: File) {
uploadProcess(resolve, reject, result)
}
})
})
} else {
// 否则直接上传
return new Promise((resolve, reject) => {
uploadProcess(resolve, reject, options.file)
})
}
}
上传组件
这里是ccframe的一个input风格的上传组件,支持预览。按照定制的错误规则展示信息,以及当5M以上大小PNG自动更换后缀名(compressorjs默认5M以上PNG会压缩为JPG格式)等功能的支持
<!--
CcFrame的Element Plus Upload的上传组件
支持2种布局模式:
1.默认的图片上传布局,只支持图片
2.兼容Input风格的上传组件,支持预览,支持多种文件类型
支持生成指定大小的缩略图(需要后端支持)缩放为contain模式
@Author Jim 24/07/27
-->
<template>
<div class="inline-block leading-0 pt-10px pr-10px relative">
<el-icon
v-if="fileInfo.url && fileInfo.url.length > 0"
:size="25"
class="absolute! right-0 top-0 z-10 cursor-pointer"
color="#71727A"
@click="doDelete"
><CircleClose
/></el-icon>
<el-image
v-if="props.shape === 'image'"
:src="fileInfo.url"
fit="scale-down"
:preview-src-list="[fileInfo.url || '']"
:preview-teleported="true"
v-bind="$attrs"
class="relative z-0"
>
<template #error>
<div class="image-slot h-100% cursor-pointer" @click="dialogVisible = true">
<el-icon
:size="35"
color="#71727A"
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2"
><Plus
/></el-icon>
</div>
</template>
</el-image>
</div>
<el-input
v-if="props.shape === 'input'"
:placeholder="placeholder"
v-model="fileInfo.filename"
:size="size"
:readonly="true"
v-bind="$attrs"
>
<template #suffix>
<div class="flex items-center" style="height: 36px">
<svg-icon name="upload" class="toolIcon" @click="dialogVisible = true" title="上传" />
<svg-icon name="preview" class="toolIcon" @click="showUploadFile" style="padding-left: 2px" title="预览" />
<svg-icon name="delete" class="toolIcon delIcon" @click="doDelete" title="清除" />
</div>
</template>
</el-input>
<el-dialog
title="文件上传"
:append-to-body="true"
v-model="dialogVisible"
width="400px"
:before-close="checkUploading"
>
<el-upload
ref="upload"
style="width: 360px"
action="/admin/fileInf/upload"
:accept="accept"
:data="withExtraData"
drag
:limit="1"
:on-error="showFail"
:on-success="updateAndClose"
:before-upload="beforeUpload"
:http-request="optImgHttpRequest"
>
<i class="el-icon-upload" />
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">上传文件不能超过10M</div>
</template>
</el-upload>
</el-dialog>
<transition name="fade">
<div
v-if="showPreview"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-20px z-99999"
@click="showPreview = false"
>
<img class="preview-img" :src="fileInfo.url" />
</div>
</transition>
</template>
<script lang="ts" setup>
import {
watch,
ref,
onMounted,
reactive,
defineModel,
withDefaults,
defineProps,
nextTick,
getCurrentInstance
} from 'vue'
import { UPDATE_MODEL_EVENT, CHANGE_EVENT, INPUT_EVENT } from 'element-plus/es/constants/event.mjs'
import { UploadFile } from 'element-plus'
import { UploadAjaxError } from 'element-plus/es/components/upload/src/ajax'
import { tools, optImgHttpRequest } from '@/main'
import '@/services'
import { useUserStore } from '@/commons/store/modules/user'
const { userInfo } = useUserStore()
const props = withDefaults(
defineProps<{
modelValue: string | undefined // 双向绑定数据
shape?: 'input' | 'image' // Input风格还是图片区块风格
accept?: string // 选择器过滤的类型
checkType?: string // 如果要检查图片,使用 'image/gif,image/jpeg,image/png'
placeholder?: string // 占位提示信息,input风格需要
thumbHeight?: number // 指定缩略图高度,不传则不生成缩略图(需要后端配合)
thumbWidth?: number // 指定缩略图宽度,不传则不生成缩略图(需要后端配合)
size?: 'default' | 'small' | 'large' // input风格尺寸
}>(),
{
modelValue: undefined,
shape: 'image',
accept: '.gif,.jpg,.png,image/gif,image/jpeg,image/png', // 注意,默认是上传图片
checkType: 'image/gif,image/jpeg,image/png', // 默认检查图片
placeholder: '请选择文件上传',
size: 'large'
}
)
const withExtraData: Promise<Record<string, any>> = new Promise((resolve, reject) => {
const extraData: Record<string, any> = {}
if (props.thumbWidth) {
extraData.thumbWidth = props.thumbWidth.toString()
}
if (props.thumbHeight) {
extraData.thumbHeight = props.thumbHeight.toString()
}
resolve(extraData)
})
const emit = defineEmits([UPDATE_MODEL_EVENT, CHANGE_EVENT, INPUT_EVENT])
// dom
const upload = ref<any>()
// data
const fileInfo = reactive<defs.FileInfo>({
url: undefined, // 回显图片地址
filename: '', // 图片名称
ext: '', // 扩展名类型
path: '' //图片路径
})
const uploading = ref<boolean>(false)
const dialogVisible = ref<boolean>(false)
const showPreview = ref<boolean>(false)
watch(
// modelValue重新赋值时,根据值解析path和filename、ext、url
() => props.modelValue,
(val) => {
unpackVal(val)
}
)
// methods
const unpackVal = (val?: string) => {
// 从数据库读取时拆URL
if (val) {
// eslint-disable-next-line no-control-regex
const extract = /((\w+\/)*)([^\0-\x1F\\/:*?"<>|]+\.([^.]+))$/.exec(val)
if (extract) {
fileInfo.filename = extract[3]
fileInfo.ext = extract[4]
fileInfo.path = extract[1]
fileInfo.url = userInfo.fileDomain + fileInfo.path + fileInfo.filename
}
} else {
// 清空
fileInfo.filename = ''
fileInfo.ext = ''
fileInfo.url = ''
fileInfo.path = ''
}
}
const getFileUrl: () => string = () => {
return fileInfo.url || ''
}
const beforeUpload: (file: File) => boolean = (file) => {
if (props.checkType && !props.checkType.includes(file.type)) {
tools.alert(`文件格式不正确 ${file.type}`)
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
tools.alert('上传文件大小不能超过 10MB!')
} else {
uploading.value = true
}
return isLt10M
}
const checkUploading: (done: any) => void = (done) => {
if (uploading.value) {
tools
.confirm('上传进度未结束,您确定要终止上传吗?')
.then(() => {
uploading.value = false
upload.value.abort()
done()
})
.catch(() => {
// NOOP
})
} else {
done()
}
}
const updateAndClose: (fileInfo: defs.FileInfo, file: UploadFile, fileList: UploadFile[]) => void = (res) => {
// 只有在整合optImgHttpRequest后,才会返回FileInfo
uploading.value = false
dialogVisible.value = false
Object.assign(fileInfo, res)
const fileVal = '' + fileInfo.path + fileInfo.filename
upload.value.clearFiles() //手动清理队列
emit(UPDATE_MODEL_EVENT, fileVal === '' ? undefined : fileVal)
emit(CHANGE_EVENT, fileVal === '' ? undefined : fileVal)
}
const doDelete: () => void = () => {
emit(UPDATE_MODEL_EVENT, undefined)
emit(CHANGE_EVENT, undefined)
emit(INPUT_EVENT, undefined)
}
const showUploadFile: () => void = () => {
if (!props.modelValue) {
tools.toast('您还未上传文件,无法预览', 'error')
} else {
if (['jpg', 'gif', 'png'].includes((fileInfo.ext || '').toLowerCase())) {
// 图片展示
showPreview.value = true
} else {
// 非图片下载
const aTag = document.createElement('a')
aTag.download = fileInfo.filename || '' // 下载的文件名
aTag.href = fileInfo.url || ''
aTag.click()
}
}
}
const showFail: (err: Error, file: UploadFile, fileList: UploadFile[]) => void = (err, file, fileList) => {
let errorText = '网络请求失败'
if (err instanceof UploadAjaxError) {
// 需要验证,会不会走这里
if (err.status) {
switch (err.status) {
case 500: {
errorText = '上传失败' // 正常需要在组件限制文件大小,如果未限制会失败。默认最大的文件为10M
break
}
case 504:
case 404: {
errorText = '上传URL错误'
break
}
}
}
} else {
errorText = err.message
}
tools.toast(errorText, 'error')
uploading.value = false
}
onMounted(() => {
unpackVal(props.modelValue)
})
</script>
<style scoped lang="scss">
.toolIcon {
font-family: iconfont;
user-select: none;
width: 24px;
height: 24px;
line-height: 24px;
cursor: pointer;
margin-left: 7px;
color: #409eff;
:hover {
color: #66b1ff;
}
:active {
color: #3a8ee6;
}
}
.delIcon {
color: #f56c6c;
:hover {
color: #f78989;
}
:active {
color: #dd6161;
}
}
.x-screen-box {
position: fixed;
top: 70px;
bottom: 70px;
left: 70px;
right: 70px;
z-index: 99999;
}
.preview-img {
object-fit: scale-down;
width: 100%;
height: 100%;
}
</style>
最终效果
点击上传按钮
上传完毕
点击预览按钮