vue3实战九、vue3+vue-cropper实现头像修改
效果

实现步骤
第一步、安装vue-cropper
javascript
npm install vue-cropper@next -d --save
第二步、组件引入vue-cropper
javascript
import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";
第三步、实现点击修改弹窗布局
使用img
环境elementPlus
的el-dialog弹窗组件,分为两个区域,一个是裁剪区,一个是结果区域,底部添加向左旋转,向右旋转,放大,缩小的功能。
javascript
<template>
<div>
<!-- 点击头像区域打开裁剪器 -->
<div class="user-info-head" @click="editCropper">
<img :src="options.img" title="点击上传头像" class="img-circle img-lg" />
</div>
<!-- 裁剪弹窗 -->
<el-dialog
v-model="open"
:title="title"
width="800px"
append-to-body
@opened="modalOpened"
@close="closeDialog"
>
<el-row>
<!-- 裁剪区域 -->
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<vue-cropper
ref="cropperRef"
:img="options.img"
:info="true"
:auto-crop="options.autoCrop"
:auto-crop-width="options.autoCropWidth"
:auto-crop-height="options.autoCropHeight"
:fixed-box="options.fixedBox"
:output-type="options.outputType"
@real-time="realTime"
v-if="visible"
/>
</el-col>
<!-- 预览区域 -->
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<div class="avatar-upload-preview">
<img :src="previews.url" :style="previews.img" />
</div>
</el-col>
</el-row>
<br />
<!-- 操作按钮 -->
<el-row>
<!-- 选择图片 -->
<el-col :lg="2" :sm="3" :xs="3">
<el-upload
action="#"
:http-request="requestUpload"
:show-file-list="false"
:before-upload="beforeUpload"
>
<el-button size="small">
选择
<SvgIcon name="ele-UploadFilled" size="small"></SvgIcon>
<!-- <el-icon class="el-icon--right"><Upload /></el-icon> -->
</el-button>
</el-upload>
</el-col>
<!-- 缩放 -->
<el-col :lg="{ span: 1, offset: 2 }" :sm="2" :xs="2">
<el-button
style="text-align: center"
size="small"
@click="changeScale(1)"
>
<SvgIcon size="small" name="ele-Plus"></SvgIcon>
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
<el-button
style="text-align: center"
size="small"
@click="changeScale(-1)"
><SvgIcon size="small" name="ele-Minus"></SvgIcon
></el-button>
</el-col>
<!-- 旋转 -->
<el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
<el-button size="small" style="text-align: center" @click="rotateLeft"
><SvgIcon size="small" name="ele-RefreshLeft"></SvgIcon
></el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
<el-button
size="small"
style="text-align: center"
@click="rotateRight"
><SvgIcon size="small" name="ele-RefreshRight"></SvgIcon
></el-button>
</el-col>
<!-- 提交 -->
<el-col :lg="{ span: 2, offset: 6 }" :sm="2" :xs="2">
<el-button type="primary" size="small" @click="uploadImg"
>提 交</el-button
>
</el-col>
</el-row>
</el-dialog>
</div>
</template>
第四步、实现图片裁剪及上传逻辑
-
导入必要的模块和依赖 首先导入了
Vue 3
的基本钩子函数(如ref
,reactive
, 生命周期钩子等)以及vue-cropper
插件相关的资源,引入了项目中使用的状态管理库Pinia
的store
模块 (useAuthStore
)。使用了防抖函数debounce
来优化resize
事件的处理。引入了Element Plus UI
库中的图标组件Upload
。javascriptmport { ref, reactive, onMounted, onBeforeUnmount } from "vue"; import "vue-cropper/dist/index.css"; import { VueCropper } from "vue-cropper"; // 假设你使用 Pinia 并已创建了 authStore import { useAuthStore } from "../../stores/auth"; // import { uploadAvatar } from "@/api/system/user"; import { debounce } from "@/utils/index.js"; // Element Plus 图标 (用于 Upload 按钮) import { Upload } from "@element-plus/icons-vue";
-
定义
Props
和Refs
定义了一个Prop user
,用于接收用户信息对象,默认为空对象,使用ref
和reactive
创建响应式变量来控制弹窗显示、裁剪器配置选项、预览数据等。javascriptconst props = defineProps({ user: { type: Object, default: () => ({}), }, }); const authStore = useAuthStore(); const open = ref(false); // 控制弹窗显示 const visible = ref(false); // 控制 vue-cropper 组件的显示 (解决 resize 问题) const cropperRef = ref(null); // 引用 vue-cropper 组件 const resizeHandler = ref(null); // 存储防抖后的 resize 事件处理器 const title = "修改头像";
-
初始化裁剪器配置
options
对象初始化了裁剪器的基本设置,包括图片来源、自动裁剪尺寸、固定裁剪框等。
javascript
const options = reactive({
img: authStore.userInfo?.imageUrl || "", // 使用 Pinia store 的 userInfo.imageUrl
autoCrop: true,
autoCropWidth: 200,
autoCropHeight: 200,
fixedBox: true,
outputType: "png",
});
const previews = ref({}); // 预览数据
- 实现功能方法
editCropper
: 方法用于打开编辑头像的弹窗。modalOpened
:方法在弹窗打开时调用,添加窗口大小调整监听器以刷新裁剪器。- 提供了几个方法用于操作裁剪器:
refresh
刷新裁剪器,rotateLeft
向左旋转图片,rotateRight
向右旋转图片,changeScale
调整图片缩放比例。beforeUpload
:方法用于验证上传文件格式,并将选择的图片转换为Base64
格式以便在裁剪器中显示。uploadImg
: 方法负责获取裁剪后的图片Blob
数据并通过表单提交更新用户的头像(注释部分显示了如何与后端API
进行交互,但实际代码被注释掉了)。realTime
:方法用于实时预览裁剪效果。closeDialog
:方法在关闭对话框时恢复原始头像并清理resize
事件监听器。
javascript
<script setup>
// 编辑头像 - 打开弹窗
const editCropper = () => {
open.value = true;
};
// 弹窗打开后回调
const modalOpened = () => {
visible.value = true;
// 添加 resize 事件监听器 (带防抖)
if (!resizeHandler.value) {
resizeHandler.value = debounce(() => {
refresh();
}, 100);
}
window.addEventListener("resize", resizeHandler.value);
};
// 刷新裁剪器
const refresh = () => {
cropperRef.value?.refresh();
};
// 覆盖默认上传行为 (空函数)
const requestUpload = () => {
// do nothing, use beforeUpload instead
};
// 向左旋转
const rotateLeft = () => {
cropperRef.value?.rotateLeft();
};
// 向右旋转
const rotateRight = () => {
cropperRef.value?.rotateRight();
};
// 图片缩放
const changeScale = (num = 1) => {
cropperRef.value?.changeScale(num);
};
// 上传前处理
const beforeUpload = (file) => {
if (!file.type.startsWith("image/")) {
ElMessage.error("文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。");
return false; // 阻止上传
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
options.img = reader.result;
};
return false; // 阻止 el-upload 的默认上传,使用 requestUpload 或这里处理
};
// 上传图片
const uploadImg = () => {
cropperRef.value?.getCropBlob(async (data) => {
const formData = new FormData();
formData.append("avatarfile", data, "avatar.png"); // 添加文件名和类型
// try {
// const response = await uploadAvatar(formData);
// // 假设 response 包含新的图片 URL
// const newImageUrl = response.imgUrl; // 或 response.data.imgUrl, 根据你的 API 响应结构调整
// // 更新本地状态和 Store
// open.value = false;
// options.img = import.meta.env.VITE_APP_BASE_API + newImageUrl; // 使用 Vite 环境变量
// // 假设你的 authStore 有一个 action 来更新用户信息或头像
// // 方式1: 直接更新 imageUrl (如果 store 允许)
// // authStore.userInfo.imageUrl = options.img
// // 方式2: 调用一个 action 来更新 (推荐)
// await authStore.updateUserInfo({ imageUrl: options.img }); // 假设有一个 updateUserInfo action
// ElMessage.success("修改成功");
// visible.value = false;
// } catch (error) {
// console.error("上传头像失败:", error);
// ElMessage.error("修改失败");
// // 可选: 根据需要处理错误
// }
});
};
// 实时预览
const realTime = (data) => {
previews.value = data;
};
// 关闭弹窗
const closeDialog = () => {
// 关闭时恢复原始头像 (如果上传未完成或取消)
options.img = authStore.userInfo?.imageUrl || "";
visible.value = false;
// 移除 resize 事件监听器
if (resizeHandler.value) {
window.removeEventListener("resize", resizeHandler.value);
resizeHandler.value = null;
}
};
</script>
第五步、房东方法处理
javascript
/**
* @param {Function} func
* @param {number} wait
* @param {boolean} immediate
* @return {*}
*/
export function debounce(func, wait, immediate) {
let timeout, args, context, timestamp, result
const later = function () {
// 据上一次触发时间间隔
const last = +new Date() - timestamp
// 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait
if (last < wait && last > 0) {
timeout = setTimeout(later, wait - last)
} else {
timeout = null
// 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
if (!immediate) {
result = func.apply(context, args)
if (!timeout) context = args = null
}
}
}
return function (...args) {
context = this
timestamp = +new Date()
const callNow = immediate && !timeout
// 如果延时不存在,重新设定延时
if (!timeout) timeout = setTimeout(later, wait)
if (callNow) {
result = func.apply(context, args)
context = args = null
}
return result
}
}
第六步、调用方式
javascript
<AvatarCropper />
const AvatarCropper = defineAsyncComponent(
() => import("../../components/userAvatar/index.vue")
);
第七步、查看效果

总体代码:
javascript
<template>
<div>
<!-- 点击头像区域打开裁剪器 -->
<div class="user-info-head" @click="editCropper">
<img :src="options.img" title="点击上传头像" class="img-circle img-lg" />
</div>
<!-- 裁剪弹窗 -->
<el-dialog
v-model="open"
:title="title"
width="800px"
append-to-body
@opened="modalOpened"
@close="closeDialog"
>
<el-row>
<!-- 裁剪区域 -->
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<vue-cropper
ref="cropperRef"
:img="options.img"
:info="true"
:auto-crop="options.autoCrop"
:auto-crop-width="options.autoCropWidth"
:auto-crop-height="options.autoCropHeight"
:fixed-box="options.fixedBox"
:output-type="options.outputType"
@real-time="realTime"
v-if="visible"
/>
</el-col>
<!-- 预览区域 -->
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<div class="avatar-upload-preview">
<img :src="previews.url" :style="previews.img" />
</div>
</el-col>
</el-row>
<br />
<!-- 操作按钮 -->
<el-row>
<!-- 选择图片 -->
<el-col :lg="2" :sm="3" :xs="3">
<el-upload
action="#"
:http-request="requestUpload"
:show-file-list="false"
:before-upload="beforeUpload"
>
<el-button size="small">
选择
<SvgIcon name="ele-UploadFilled" size="small"></SvgIcon>
<!-- <el-icon class="el-icon--right"><Upload /></el-icon> -->
</el-button>
</el-upload>
</el-col>
<!-- 缩放 -->
<el-col :lg="{ span: 1, offset: 2 }" :sm="2" :xs="2">
<el-button
style="text-align: center"
size="small"
@click="changeScale(1)"
>
<SvgIcon size="small" name="ele-Plus"></SvgIcon>
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
<el-button
style="text-align: center"
size="small"
@click="changeScale(-1)"
><SvgIcon size="small" name="ele-Minus"></SvgIcon
></el-button>
</el-col>
<!-- 旋转 -->
<el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
<el-button size="small" style="text-align: center" @click="rotateLeft"
><SvgIcon size="small" name="ele-RefreshLeft"></SvgIcon
></el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
<el-button
size="small"
style="text-align: center"
@click="rotateRight"
><SvgIcon size="small" name="ele-RefreshRight"></SvgIcon
></el-button>
</el-col>
<!-- 提交 -->
<el-col :lg="{ span: 2, offset: 6 }" :sm="2" :xs="2">
<el-button type="primary" size="small" @click="uploadImg"
>提 交</el-button
>
</el-col>
</el-row>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from "vue";
import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";
// 假设你使用 Pinia 并已创建了 authStore
import { useAuthStore } from "../../stores/auth";
// import { uploadAvatar } from "@/api/system/user";
import { debounce } from "@/utils/index.js";
// Element Plus 图标 (用于 Upload 按钮)
import { Upload } from "@element-plus/icons-vue";
// --- Props ---
const props = defineProps({
user: {
type: Object,
default: () => ({}),
},
});
const authStore = useAuthStore();
const open = ref(false); // 控制弹窗显示
const visible = ref(false); // 控制 vue-cropper 组件的显示 (解决 resize 问题)
const cropperRef = ref(null); // 引用 vue-cropper 组件
const resizeHandler = ref(null); // 存储防抖后的 resize 事件处理器
const title = "修改头像";
const options = reactive({
img: authStore.userInfo?.imageUrl || "", // 使用 Pinia store 的 userInfo.imageUrl
autoCrop: true,
autoCropWidth: 200,
autoCropHeight: 200,
fixedBox: true,
outputType: "png",
});
const previews = ref({}); // 预览数据
// 编辑头像 - 打开弹窗
const editCropper = () => {
open.value = true;
};
// 弹窗打开后回调
const modalOpened = () => {
visible.value = true;
// 添加 resize 事件监听器 (带防抖)
if (!resizeHandler.value) {
resizeHandler.value = debounce(() => {
refresh();
}, 100);
}
window.addEventListener("resize", resizeHandler.value);
};
// 刷新裁剪器
const refresh = () => {
cropperRef.value?.refresh();
};
// 覆盖默认上传行为 (空函数)
const requestUpload = () => {
// do nothing, use beforeUpload instead
};
// 向左旋转
const rotateLeft = () => {
cropperRef.value?.rotateLeft();
};
// 向右旋转
const rotateRight = () => {
cropperRef.value?.rotateRight();
};
// 图片缩放
const changeScale = (num = 1) => {
cropperRef.value?.changeScale(num);
};
// 上传前处理
const beforeUpload = (file) => {
if (!file.type.startsWith("image/")) {
ElMessage.error("文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。");
return false; // 阻止上传
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
options.img = reader.result;
};
return false; // 阻止 el-upload 的默认上传,使用 requestUpload 或这里处理
};
// 上传图片
const uploadImg = () => {
cropperRef.value?.getCropBlob(async (data) => {
const formData = new FormData();
formData.append("avatarfile", data, "avatar.png"); // 添加文件名和类型
// try {
// const response = await uploadAvatar(formData);
// // 假设 response 包含新的图片 URL
// const newImageUrl = response.imgUrl; // 或 response.data.imgUrl, 根据你的 API 响应结构调整
// // 更新本地状态和 Store
// open.value = false;
// options.img = import.meta.env.VITE_APP_BASE_API + newImageUrl; // 使用 Vite 环境变量
// // 假设你的 authStore 有一个 action 来更新用户信息或头像
// // 方式1: 直接更新 imageUrl (如果 store 允许)
// // authStore.userInfo.imageUrl = options.img
// // 方式2: 调用一个 action 来更新 (推荐)
// await authStore.updateUserInfo({ imageUrl: options.img }); // 假设有一个 updateUserInfo action
// ElMessage.success("修改成功");
// visible.value = false;
// } catch (error) {
// console.error("上传头像失败:", error);
// ElMessage.error("修改失败");
// // 可选: 根据需要处理错误
// }
});
};
// 实时预览
const realTime = (data) => {
previews.value = data;
};
// 关闭弹窗
const closeDialog = () => {
// 关闭时恢复原始头像 (如果上传未完成或取消)
options.img = authStore.userInfo?.imageUrl || "";
visible.value = false;
// 移除 resize 事件监听器
if (resizeHandler.value) {
window.removeEventListener("resize", resizeHandler.value);
resizeHandler.value = null;
}
};
</script>
<style scoped lang="scss">
.user-info-head {
position: relative;
display: inline-block;
height: 40px;
text-align: center;
}
.user-info-head:hover:after {
content: "+";
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
color: #eee;
background: rgba(0, 0, 0, 0.5);
font-size: 24px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: pointer;
margin: 0px auto;
line-height: 40px;
border-radius: 50%;
}
/* image */
.img-circle {
border-radius: 50%;
}
.img-lg {
width: 40px;
height: 40px;
}
.avatar-upload-preview {
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 200px;
height: 200px;
border-radius: 50%;
box-shadow: 0 0 4px #ccc;
overflow: hidden;
}
</style>
防抖代码:
javascript
/**
* @param {Function} func
* @param {number} wait
* @param {boolean} immediate
* @return {*}
*/
export function debounce(func, wait, immediate) {
let timeout, args, context, timestamp, result
const later = function () {
// 据上一次触发时间间隔
const last = +new Date() - timestamp
// 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait
if (last < wait && last > 0) {
timeout = setTimeout(later, wait - last)
} else {
timeout = null
// 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
if (!immediate) {
result = func.apply(context, args)
if (!timeout) context = args = null
}
}
}
return function (...args) {
context = this
timestamp = +new Date()
const callNow = immediate && !timeout
// 如果延时不存在,重新设定延时
if (!timeout) timeout = setTimeout(later, wait)
if (callNow) {
result = func.apply(context, args)
context = args = null
}
return result
}
}