前端技术: Vue3 + Vant + Axios
后端技术: Koa + fs + Koa-static + Koa-bodyparser
前提条件
图片上传和普通网络请求不一样,对上传的格式有要求,这里后端需要进行一些配置
这里使用 koa-body
const { koaBody } = require('koa-body');
app.use(koaBody({
multipart: true,
formidable: {
uploadDir: path.join(dirname, `/uploads/`),
keepExtensions: true,
multipart: true,
},
jsonLimit: '10mb',
formLimit: '10mb',
textLimit: '10mb'
}));
后端需要开启静态资源访问,否则即使服务端在开启情况下前端也无法访问服务资源。
这里用 Koa-static 来做这件事:
const Koa = require('koa');
const staticFiles = require('koa-static');
const path = require("path");
const dirname = path.resolve();
const app = new Koa();
app.use(staticFiles(dirname + '/'));
app.listen(3000, () => {
console.log('server is running at http://localhost:3000')
})
除此之外,后端还需要对跨域进行处理,从而让不同域名不同接口的web端进行访问
这里用 koa-cors 来解决
app.use(async (ctx, next) => {
ctx.set('Access-Control-Allow-Origin', '*')
ctx.set('Access-Control-Allow-Headers', 'Content-Type,Content-Length,Authorization,Accept,X-Requested-With')
ctx.set('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS')
if (ctx.method == 'OPTIONS') {
ctx.body = 200;
} else {
await next()
}
})
前端部分
这里本来该用Vant中的 van-uploader 组件来做,但是图片上传需要考虑裁剪问题,所以需要手写上传逻辑,并借助裁剪工具 cropper.js 来完成前端部分的工作。
直接贴代码:
Cropper.vue (图片裁剪组件)
<template>
<div class="zyg-uploader__upload">
<!-- 删除按钮icon -->
<i id="zyg-delete-icon" class="van-icon van-icon-clear van-uploader__preview-delete"></i>
<!-- 照相机icon -->
<i id="zyg-upload-icon" class="van-icon van-icon-photograph van-uploader__upload-icon"></i>
<!-- 上传中 -->
<div v-show="uploadLoading" class="van-uploader__mask">
<div class="van-loading van-loading--circular van-uploader__loading">
<span class="van-loading__spinner van-loading__spinner--circular"><svg viewBox="25 25 50 50"
class="van-loading__circular">
<circle cx="50" cy="50" r="20" fill="none"></circle>
</svg></span>
</div>
<div class="van-uploader__mask-message">上传中...</div>
</div>
<img :src="imgUrl" alt="" id="img" class="zyg-image__img" />
<input type="file" id="fileId" class="zyg-uploader__input" accept="image/png,image/jpg,image/jpeg"
@change="change($event)" />
</div>
</template>
<script>
import Cropper from "cropperjs";
import Exif from "exif-js";
export default {
props: {
widthRate: {
type: Number,
default: 1,
},
heightRate: {
type: Number,
default: 1,
},
imgUrl: {
type: String,
default: function () {
return "";
},
},
uploadLoading: {
type: Boolean,
default: false,
},
},
data() {
return {};
},
mounted() {
this.iconFun();
},
methods: {
iconFun() {
if (document.getElementById("img").src == "") {
document.getElementById("zyg-upload-icon").style.display = "block";
} else {
document.getElementById("zyg-upload-icon").style.display = "none";
}
},
change(event) {
let self = this;
let image = document.getElementById("img"); //预览对象
let file = document.getElementById("fileId").files[0]; //获取文件流
self.$emit("before-upload", file, function (flag) {
if (flag == false) {
return;
}
self.clip(event, {
resultObj: image,
aspectWithRatio: Number(self.widthRate),
aspectHeightRatio: Number(self.heightRate),
});
});
},
initilize(opt) {
let self = this;
this.options = opt;
this.createElement();
this.resultObj = opt.resultObj;
this.cropper = new Cropper(this.preview, {
aspectRatio: opt.aspectWithRatio / opt.aspectHeightRatio, // 裁剪框比例 默认NaN 例如:: 1 / 1,//裁剪框比例 1:1
// aspectRatio: 1/1,
autoCropArea: opt.autoCropArea || 0.8,
viewMode: 2,
guides: true, // 是否在剪裁框上显示虚线
cropBoxResizable: false, //是否通过拖动来调整剪裁框的大小
cropBoxMovable: true, //是否通过拖拽来移动剪裁框。
dragCrop: false,
dragMode: "move", //‘crop’: 可以产生一个新的裁剪框3 ‘move’: 只可以移动3 ‘none’: 什么也不处理
center: false, // 是否显示裁剪框中间的+
zoomable: true, //是否允许放大图像。
zoomOnTouch: true, //是否可以通过拖动触摸来放大图像。
scalable: true, // 是否允许缩放图片
// minCropBoxHeight: 750,
// minCropBoxWidth: 750,
background: false, // 容器是否显示网格背景
checkOrientation: true,
checkCrossOrigin: true,
zoomOnWheel: false, // 是否允许鼠标滚轴缩放图片
toggleDragModeOnDblclick: false,
ready: function () {
// console.log(self.cropper.rotate(90))
if (opt.aspectRatio == "Free") {
let cropBox = self.cropper.cropBox;
cropBox.querySelector("span.cropper-view-box").style.outline =
"none";
self.cropper.disable();
}
},
});
},
//创建一些必要的DOM,用于图片裁剪
createElement() {
//初始化图片为空对象
this.preview = null;
// <img src="../../assets/app/loading.gif">
let str =
'<div><img id="clip_image" src="originUrl"></div><button type="button" id="cancel_clip">取消</button><button type="button" id="clip_button">确定</button>';
str +=
'<div class="crop_loading"><div class="crop_content"><div class="crop_text">修剪中...</div></div></div>';
str +=
'<div class="crop_success"><div class="crop_success_text">上传成功</div></div></div>';
let body = document.getElementsByTagName("body")[0];
this.reagion = document.createElement("div");
this.reagion.id = "clip_container";
this.reagion.className = "container";
this.reagion.innerHTML = str;
//添加创建好的DOM元素
body.appendChild(this.reagion);
this.preview = document.getElementById("clip_image");
//绑定一些方法
this.initFunction();
},
//初始化一些函数绑定
initFunction() {
let self = this;
this.clickBtn = document.getElementById("clip_button");
this.cancelBtn = document.getElementById("cancel_clip");
//确定事件
this.addEvent(this.clickBtn, "click", function () {
self.crop();
});
//取消事件
this.addEvent(this.cancelBtn, "click", function () {
self.destoried();
});
//清空input的值
this.addEvent(this.fileObj, "click", function () {
this.value = "";
});
},
//外部接口,用于input['file']对象change时的调用
clip(e, opt) {
let self = this;
this.fileObj = e.srcElement;
let files = e.target.files || e.dataTransfer.files;
if (!files.length) return false; //不是图片直接返回
//调用初始化方法
this.initilize(opt);
this.picValue = files[0];
this.originUrl = this.getObjectURL(this.picValue);
//每次替换图片要重新得到新的url
if (this.cropper) {
this.cropper.replace(this.originUrl);
}
},
//图片转码方法
getObjectURL(file) {
let url = null;
if (window.createObjectURL != undefined) {
// basic
url = window.createObjectURL(file);
} else if (window.URL != undefined) {
// mozilla(firefox)
url = window.URL.createObjectURL(file);
} else if (window.webkitURL != undefined) {
// webkit or chrome
url = window.webkitURL.createObjectURL(file);
}
return url;
},
//点击确定进行裁剪
crop() {
let self = this;
let image = new Image();
let croppedCanvas;
let roundedCanvas;
// Crop
document.querySelector(".crop_loading").style.display = "block";
setTimeout(function () {
croppedCanvas = self.cropper.getCroppedCanvas();
// Round
roundedCanvas = self.getRoundedCanvas(croppedCanvas);
let imgData = roundedCanvas.toDataURL();
image.src = imgData;
self.postImg(imgData);
// 判断图片如果显示,则把相机给隐藏掉
if (self.resultObj.src !== "") {
document.getElementById("zyg-upload-icon").style.display = "none";
}
let params = {
content: image.src, // 图片的base64码
file: document.getElementById("fileId").files[0], //获取文件流
};
self.$emit("after-upload", params); //这里代表上传的图片裁剪完成,需要通知父级来进行上传处理
}, 20);
},
//获取裁剪图片资源
getRoundedCanvas(sourceCanvas) {
let canvas = document.createElement("canvas");
let context = canvas.getContext("2d");
let width = sourceCanvas.width;
let height = sourceCanvas.height;
canvas.width = width;
canvas.height = height;
context.imageSmoothingEnabled = true;
context.drawImage(sourceCanvas, 0, 0, width, height);
context.globalCompositeOperation = "destination-in";
context.beginPath();
context.rect(0, 0, width, height);
context.fill();
return canvas;
},
//销毁原来的对象
destoried() {
let self = this;
//移除事件
this.removeEvent(this.clickBtn, "click", null);
this.removeEvent(this.cancelBtn, "click", null);
this.removeEvent(this.fileObj, "click", null);
//移除裁剪框
this.reagion.parentNode.removeChild(this.reagion);
//销毁裁剪对象
this.cropper.destroy();
this.cropper = null;
},
//图片旋转
rotateImg(img, direction, canvas) {
//最小与最大旋转方向,图片旋转4次后回到原方向
const min_step = 0;
const max_step = 3;
if (img == null) return;
//img的高度和宽度不能在img元素隐藏后获取,否则会出错
let height = img.height;
let width = img.width;
let step = 2;
if (step == null) {
step = min_step;
}
if (direction == "right") {
step++;
//旋转到原位置,即超过最大值
step > max_step && (step = min_step);
} else {
step--;
step < min_step && (step = max_step);
}
//旋转角度以弧度值为参数
let degree = (step * 90 * Math.PI) / 180;
let ctx = canvas.getContext("2d");
switch (step) {
case 0:
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0);
break;
case 1:
canvas.width = height;
canvas.height = width;
ctx.rotate(degree);
ctx.drawImage(img, 0, -height);
break;
case 2:
canvas.width = width;
canvas.height = height;
ctx.rotate(degree);
ctx.drawImage(img, -width, -height);
break;
case 3:
canvas.width = height;
canvas.height = width;
ctx.rotate(degree);
ctx.drawImage(img, -width, 0);
break;
}
},
//图片压缩
compress(img, Orientation) {
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
//瓦片canvas
let tCanvas = document.createElement("canvas");
let tctx = tCanvas.getContext("2d");
let initSize = img.src.length;
let width = img.width;
let height = img.height;
//如果图片大于四百万像素,计算压缩比并将大小压至400万以下
let ratio;
if ((ratio = (width * height) / 4000000) > 1) {
console.log("大于400万像素");
ratio = Math.sqrt(ratio);
width /= ratio;
height /= ratio;
} else {
ratio = 1;
}
canvas.width = width;
canvas.height = height;
// 铺底色
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
//如果图片像素大于100万则使用瓦片绘制
let count;
if ((count = (width * height) / 1000000) > 1) {
count = ~~(Math.sqrt(count) + 1); //计算要分成多少块瓦片
// 计算每块瓦片的宽和高
let nw = ~~(width / count);
let nh = ~~(height / count);
tCanvas.width = nw;
tCanvas.height = nh;
for (let i = 0; i < count; i++) {
for (let j = 0; j < count; j++) {
tctx.drawImage(
img,
i * nw * ratio,
j * nh * ratio,
nw * ratio,
nh * ratio,
0,
0,
nw,
nh
);
ctx.drawImage(tCanvas, i * nw, j * nh, nw, nh);
}
}
} else {
ctx.drawImage(img, 0, 0, width, height);
}
//修复ios上传图片的时候 被旋转的问题
if (Orientation != "" && Orientation != 1) {
switch (Orientation) {
case 6: //需要顺时针(向左)90度旋转
this.rotateImg(img, "left", canvas);
break;
case 8: //需要逆时针(向右)90度旋转
this.rotateImg(img, "right", canvas);
break;
case 3: //需要180度旋转
this.rotateImg(img, "right", canvas); //转两次
this.rotateImg(img, "right", canvas);
break;
}
}
//进行最小压缩
// let ndata = canvas.toDataURL( 'image/jpeg' , 0.1);
let ndata = canvas.toDataURL("image/png", 0.1);
console.log("压缩前:" + initSize);
console.log("压缩后:" + ndata.length);
console.log(
"压缩率:" + ~~((100 * (initSize - ndata.length)) / initSize) + "%"
);
tCanvas.width = tCanvas.height = canvas.width = canvas.height = 0;
return ndata;
},
//添加事件
addEvent(obj, type, fn) {
if (obj.addEventListener) {
obj.addEventListener(type, fn, false);
} else {
obj.attachEvent("on" + type, fn);
}
},
//移除事件
removeEvent(obj, type, fn) {
if (obj.removeEventListener) {
obj.removeEventListener(type, fn, false);
} else {
obj.detachEvent("on" + type, fn);
}
},
},
};
</script>
父级使用:
<Cropper
:uploadLoading="uploadLoading"
:imgUrl="imgUrl"
@before-upload="beforeRead"
@after-upload="cropUpload"
>
</Cropper>
父组件使用Cropper,绑定 图片地址imgUrl ,上传状态uploadLoading, 以及监听上传前 和裁剪成功的事件。
子组件裁剪成功,父级监听到后 改变imgUrl值并将图片存储起来。
这里没有立即请求后端来进行上传,而是跟别的表单信息一起传递给后端
const cropUpload = (file) => {
console.log("裁剪点击确定");
filename.value = file.file.name;
imgUrl.value = file.content;
};
当用户点击提交按钮后:
const onSubmit = () => {
const formData = new FormData();
formData.append("username", username.value);
formData.append("password", password.value);
formData.append("file", imgUrl.value); //提前存储的图片
formData.append("filename", filename.value); //提前存储的图片文件名
axiosInstance("/user/upload", {
headers: {
"content-type": "multipart/form-data", //注意这里请求头为form-data
},
method: "POST",
data: formData,
}).then((res) => {
console.log(res.data);
showNotify({type: 'primary', message: "注册完成,请登录", duration: 1000 });
router.push({ path: "/login" });
});
};
后端部分处理
//路由任务分发
router.post("/upload", userController.upload);
//controller处理
upload: async (ctx) => {
console.log("收到!");
const res = await userService.registerAndUpload(ctx);
if (res) {
ctx.body = {
res: res,
code: 200,
status: "注册成功!",
};
} else {
ctx.body = {
res: res,
code: 500,
status: "请输入正确的用户名密码!",
};
}
},
//持久层处理图片并存储图片信息
registerAndUpload: async (ctx) => {
let { username, password, filename } = ctx.request.body;
//console.log(username, password, filename);
let id = generateMixed(6);
transferToImg(ctx);
let imgUrl = "http://localhost:3000/uploads/" + filename;//拼接后端服务器静态资源地址
const sql = `insert into users (id,username, password,imgUrl) values ('${id}','${username}', '${password}','${imgUrl}')`;
const res = await database.getData(sql);
return res;
}
这里持久层用到的图片处理方法如下
const path = require("path");
const fs = require("fs");
let transferToImg = (ctx) => {
const { filename, file } = ctx.request.body;
let base64Data = file.replace(/^data:image\/\w+;base64,/, "");
let dataBuffer = Buffer.from(base64Data, "base64");
const allowExtname = ["png", "jpg", "jpeg", "webp", "bmp"]; //支持的图片格式
let extname = "";
let filterResult = allowExtname.filter((item) => {
return file.includes(item);
});
extname = "." + filterResult[0];
let targetPath = path.resolve(__dirname, "../uploads"); //自定义文件夹
fs.writeFileSync(`${targetPath}/${filename}`, dataBuffer);
};