Vue+ Node 实现文件上传和显示

前端技术: 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);
};

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值