全栈经典场景:一文搞定前后端文件上传

大家好,我是杨成功。

前段时间我开源了 “仿稀土掘金” 博客系统,实现了掘金的文章和沸点功能,地址在 GitHub。考虑到服务器资源有限,我没有加上传图片的功能。

这几天有小伙伴说想要上传图片,还说上传前可以压缩一下,服务端不会占用很多资源。

我想想也是,所以安排上。

功能分前端和后端两部分实现:前端选择和压缩图片,后端接收和存储图片。

后端实现上传接口

后端使用 Express 框架,如果上传文件,需要用到 multer 模块。

首先安装:

$ yarn add multer

使用 multer 最简单的方式,就是指定一个存储路径,并在 Express 路由中添加一个接收字段:

// 导入模块
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });

// 路由
app.post('/upload', upload.single('avatar'), function (req, res, next) {
  // req.file 就是接受到的文件
  let { path, filename } = req.file;
  res.send('ok');
});

上方代码中的 uploads/ 是文件存储路径,avatar 是上传文件的字段名。

如果 req.file 的值为空,说明没有接收到文件,做一个判断处理:

if (!req.file) {
  res.status(400).send({ message: '没有接收到文件' });
}

如果上传文件指定的字段不是 avatar,那么请求不会走到路由中,而是直接抛出异常。

全栈经典场景:一文搞定前后端文件上传

如果定义了错误处理中间件,该异常会走到错误处理中间件中,你可以输出 JSON 格式的错误。

全栈经典场景:一文搞定前后端文件上传

上传成功后,文件被存储在 uoloads 文件夹下,是这样的:

全栈经典场景:一文搞定前后端文件上传

可以看到,文件被重命名为 32 位的随机字符串,并且没有后缀!

重命名文件并添加后缀

不知道为啥,上面的方式上传后不生成文件后缀,可能是我姿势不对,有了解的同学麻烦告诉我。

没办法,只能手动处理文件名,需要用 diskStorage 实现:

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, '/uploads');
  },
  filename: (req, file, cb) => {
    cb(null, file.fieldname + '.后缀名');
  },
});
const upload = multer({ storage });

上方代码中,destination 方法用于生成文件目录,filename 方法用于生成文件名。

为了防止同名文件被覆盖,使用 crypto 模块生成指定长度的文件名,使用 path 模块获取文件后缀,最后拼接起来就是完整文件名:

const path = require('path')
const crypto = require('crypto')

filename(req, file, cb) {
  let length = 12 // 文件名长度
  let random_name = crypto
    .randomBytes(Math.ceil(length / 2))
    .toString('hex')
    .slice(0, length) // 随机字符
  let { fieldname, originalname } = file
  let after = path.extname(originalname) // 文件后缀
  cb(null, random_name + after)
}

生成目录时,如果是多层级目录,无法自动创建。因此要先创建目录,再保存。

我想把每日上传的文件存储在单独的目录中,就要通过日期动态创建目录。方法如下:

const fs = require('fs')
const dayjs = require('dayjs')

destination(req, file, cb) {
  let dirpath = 'uploads/' + dayjs().format('YYYY-MM-DD')
  // 目录不存在,则递归创建
  if (!fs.existsSync(dirpath)) {
    fs.mkdirSync(dirpath, { recursive: true })
  }
  cb(null, dirpath)
},

最终的效果:

全栈经典场景:一文搞定前后端文件上传

多文件上传

上面的方法是单文件上传,下面看怎么实现多文件上传。

目录和文件的处理逻辑一样,也就是 diskStorage 的部分呢不用动,只要区分单文件和多文件怎么接收。

  • upload.single(‘image’):接收单文件,通过 req.file 获取文件。
  • upload.array(‘images’):接收多文件,通过 req.files 获取文件

定义一个接收多文件的路由:

const upload = multer({ storage });

router.post('/uploads', upload.array('images', 4), (req, res) => {
  let files = req.files
  if (!files || files.length == 0) {
    return res.status(400).send({ message: '文件不能为空' })
  }
  res.send({
    data: files.map(file => ({
      path: file.path
      filename: file.filename,
    })),
  })
})

上方代码中,upload.array(‘images’, 4) 表示上传字段是 images,最多一次上传 4 个文件。

也可以限制单个文件的上传大小,比如不超过 1M:

const upload = multer({
  storage,
  limits: {
    fileSize: 1024 * 1024, // 限制文件大小为1MB
  },
});

前端选择并上传文件

前端实现相对简单一些,主要就是选择文件,并调用上传接口。

宣传一下我的新书《前端开发实战派》,这本书囊括了从前端基础到全栈开发的实战重点知识,包含两套源码。既可以为前端初学者提供清晰的学习路径,又可以为3~5年经验的提供实战进阶方向。
🤡 新书京东5折,详情页有完整目录。源码已经开源,加我好友免费领取。

我们看 3 种常用的上传文件方法。

原生 HTML + JavaScript 上传

定义一个上传文件的输入框:

<imput type="file" onchange="toupload(this)" />

获取到文件后,使用 FormData 添加文件,并通过 fetch 方法实现单文件上传:

const toupload = async (e) => {
  let file = e.files[0];
  let form_data = new FormData();
  form_data.append('image', file, file.name);
  try {
    const response = await fetch('http://xxx/upload', {
      method: 'POST',
      body: form_data,
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    });
    let data = await response.json();
    console.log('上传成功:', data);
  } catch (error) {
    console.error('上传失败:', error);
  }
};

如果是多文件上传,只需要修改选择文件的部分:

let files = e.files;
let form_data = new FormData();
for (let i = 0; i < files.length; i++) {
  form_data.append('images', files[i], files[i].name);
}

上传前压缩文件

如果前端选择了非常大的文件上传,不光会占用服务器带宽,还会大量占用服务器存储空间。

解决这个问题,可以在选择文件后获取文件大小,大于某个值拒绝上传。

实际情况是,用户不会先把文件压缩了,再来上传,除非迫不得已。所以应该先判断文件大小,大于某个值自动压缩文件。

文件压缩使用 image-conversion 这个包,写一个压缩函数:

import { compressAccurately } from 'image-conversion';

const compressImg = (file: File) => {
  // 压缩后的最大值
  let maxsize = 300;
  // 判断文件类型
  let typeList = ['image/jpeg', 'image/png', 'image/gif'];
  let isValid = typeList.includes(file.type);
  let need_press = file.size / 1024 > maxsize;
  if (!isValid) {
    alert('图片格式只能是 JPG/PNG/GIF!');
  }
  return new Promise((resolve, reject) => {
    if (!isValid) {
      return reject();
    }
    if (!need_press) {
      // 不需要压缩,直接返回文件
      return resolve(file);
    }
    compressAccurately(file, maxsize).then((res) => {
      resolve(res);
    });
  });
};

选择文件后,使用这个方法压缩文件,返回新文件,如下:

var final_file = compressImg(file);

Vue3 + Element Plus 文件上传

Element Plus 有一个文件上传组件,有两种上传方式:自动上传和手动上传。

自动上传需要指定上传接口地址,如下:

<el-upload
  action="http://xxxx/upload"
  name="image"
  :headers="headers"
  :show-file-list="false"
  :multiple="false"
  :on-success="uploadSuccess"
  :before-upload="compressImg"
>
  <el-button class="actmo" plain>上传</el-button>
</el-upload>

一般上传文件接口可能有 JWT 验证,所以请求时要带请求头,通过 headers 属性传递:

const headers = {
  Authorization: `Bearer ${localStorage.token}`,
};

组件中的 before-upload 在文件选择后、上传前触发,这里用来执行文件压缩。当上传成功后,on-success 指定的函数触发,参数是上传接口的返回值。

单文件上传,使用上面的自动上传即可。多文件上传,如果要展示文件列表,这里要用手动上传。

<el-upload
  v-show="fileList.length > 0"
  v-model:file-list="fileList"
  action="#" // 不指定上传地址
  list-type="picture-card"
  :auto-upload="false" // 手动上传
  :on-success="uploadSuccess"
></el-upload>

上方代码绑定的 fileList 属性存储的是文件列表,当点击上传时,将文件列表的文件手动上传,方法和前面的 toupload() 方法一致,根据需要看是否压缩。

总结

前面我们从前端到后端完整实现了文件上传的功能,还包括文件压缩,文件目录、命名等规则设置,再严格一点还有上传接口验证,文件大小、格式验证,日常功能足够使用。

详细代码已经上传 GitHub,更多系列文章,请查看我的公众号 程序员成功

原文链接:https://juejin.cn/post/7327864528502669323 作者:杨成功

(0)
上一篇 2024年1月26日 上午10:22
下一篇 2024年1月26日 上午10:33

相关推荐

发表回复

登录后才能评论