koa进阶——上传功能

2,019 阅读9分钟

前言:在之前我们学会最基本的crud,那么接下来我们应该继续解决其他的基本业务,例如,文件上传功能,文件下载功能,导入导出功能等,这里就讲解文件上传功能,主要说明两种常见的上传方式

相关文章:(会不时更新,有兴趣掘友可以点个关注)

# 手把手教你入门koa,让你成为crud工程师的第一步

# koa进阶——下载功能

# 小白入门大文件上传——基于koa2+vue3的大文件上传和断点传续(内附源码)

该篇文章是基于# 手把手教你入门koa,让你成为crud工程师的第一步这篇文章的代码来继续编写的。

图片上传,视频上传,文件上传等都是日常系统使用频繁的功能。上传操作步骤基本为:

  1. 前端根据input组件的upload功能唤起文件管理器,然后选择文件。 日常文件上传类型一般会有两种: ①:以base64字符串上传(使用FileReader对象获取文件的base64字符串)。

    ②:以二进制文件流上传(使用FormData模拟form表单提交)。

  2. 后端根据文件的类型来判别或者进行对应的逻辑处理

  3. 接口返回成功标识(如有需要回显会返回网络链接)。

前置工作:

1.在server文件夹里面controller文件夹创建upload.js文件,作为上传模块的代码编写,并暴露为路由。

2.在server文件夹的router.js里面引入上传模块的路由

//上传文件路由
router.use("/upload",require("./controller/upload"))

3.安装koa2-cors模块,用于配置跨域。

//app.js
//npm install koa2-cors 安装模块

// 配置跨域
const cors = require("koa2-cors");
app.use(
  cors({
    origin: function (ctx) {
      return ctx.header.origin;
    },
    allowMethods: ["POST", "GET"],
    allowHeaders: ["Content-Type", "Authorization", "Accept"],
  })
);

所需模块:

npm install mkdirp:mkdirp这是一款在node.js中像mkdir -p一样递归创建目录及其子目录。

npm install koa-body:koa-body一个全功能的koa身体解析器中间件。支持multiparturlencodedjson请求主体。提供与 Express 的 bodyParser - 相同的功能multer

koa-bodyparser这个模块只能处理post的数据,不能够处理文件类型的传输。

koa-body不仅能处理post请求的数据,同时也能够处理文件类型的上传。

上传格式

base64字符串上传文件

base64是一种基于64个可打印字符来表示二进制数据的表达方法,它常用于处理文本数据的场合,表示、传输、存储一些二进制数据。
图片的base64编码就是将一张图片编码成一串字符串,使用该字符串可以代替图片地址,直接在浏览器打开是可以访问该图片的。

前端实现代码:

<template>
  <el-upload :file-list="fileList" class="upload-demo" :auto-upload="false" action="#" multiple :on-change="handleChange" :limit="3">
    <el-button type="primary">选择文件</el-button>
    <template #tip>
      <div><img :src="imgSrc" alt="" /></div>
    </template>
  </el-upload>
</template>
<script setup>
import { ref, getCurrentInstance } from 'vue'
import axios from 'axios'

const fileList = ref([])
const imgSrc = ref(null)

const handleChange = (file, files) => {
  //防止change事件多次触发
  if (file.status !== 'ready') return
  //创建FileReader对象
  const reader = new FileReader()
  console.log('file', file)
  reader.readAsDataURL(file.raw) // 以base64方式读取文件内容
  reader.onloadstart = function (e) {
    // 当读取操作开始时触发
    const fileSize = e.total
    if (fileSize > 1024 * 500) {
      // 大于500KB时取消上传
      reader.abort()
    } else {
      console.log('开始读取 onloadstart:', e)
    }
  }
  reader.onabort = function (e) {
    // 当读取操作被中断时触发
    console.log('读取中止 onabort:', e)
  }
  reader.onerror = function (e) {
    // 当读取操作发生错误时触发
    console.log('读取错误 onerror:', e)
  }
  reader.onprogress = function (e) {
    // 在读取Blob时触发,读取上传进度,50ms左右调用一次
    console.log('读取中 onprogress:', e)
    console.log('已读取:', Math.ceil((e.loaded / e.total) * 100) + '%')
  }
  const that = this
  reader.onload = async (e) => {
    // 当读取操作成功完成时调用
    console.log('读取成功 onload:', e)
    // 该文件的base64数据,前端可直接用来展示图片,若使用<img id="img">标签展示图片,可直接赋值src属性
    imgSrc.value = e.target.result
    // 调用上传接口,上传base64格式的文件数据
    const fileType = file?.raw.type.slice(file?.raw.type.indexOf('/') + 1)
    var formData = {
      uploadFile: e.target.result,
      fileFormat: fileType,
      fileName:file.name
    }
    console.log('提交数据:', formData)
    axios
      .post('http://localhost:3001/upload/base64Upload', formData, {
         "Content-Type": "application/json;charset=UTF-8"
      })
      .then(
        (res) => {
          // 上传成功后的处理
          console.log('文件上传', res)
        },
        (err) => {
          // 出现错误时的处理
          console.log(err)
        }
      )
  }
}
</script>

image.png

FileReader:FileReader 对象允许Web应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。

这里主要使用FileReader.readAsDataURL()readAsDataURL 方法会读取指定的 Blob 或 File 对象。读取操作完成的时候,readyState 会变成已完成DONE,并触发 loadend 事件,同时 result 属性将包含一个data:URL 格式的字符串(base64 编码)以表示所读取文件的内容。

详情可前往MDN官网查看该api——FileReader - Web API 接口参考 | MDN

后端实现代码:

const Router = require("@koa/router")
const fs = require("fs")
const mkdirp = require('mkdirp');
const router = new Router()

//base64文件上传
router.post('/base64Upload', async (ctx, next) => {
    // console.log('传输的文件:',ctx.request.body)
    let req = ctx.request.body
    let base64 = req.uploadFile.replace(/^data:image\/\w+;base64,/, '');
    let buffer = Buffer.from(base64,"base64");
    ctx.body = { "code": 200, "description": "SUCCESS" };
	const ext = req.fileName.split('.').pop(); // 获取上传文件扩展名
	// console.log('文件名字',ext)
	// 创建文件夹
	const uploadPath = "./dowload"; // 这是我测试的路径
	const flag = fs.existsSync(uploadPath); // 判断文件夹是否存在
	// 同步创建多级文件夹
	if (!flag) {
		mkdirp.sync(uploadPath)
	} else {
		console.log("文件夹已经建立")
	}
	// 文件全路径
	const filePath = `${uploadPath}/${req.fileName}`;
	console.log('上传成功后的文件路径', `${filePath}`)
	return new Promise((resolve, reject) => {
          fs.writeFileSync(`${filePath}`,buffer);
          resolve(buffer)
          ctx.body = { "code": 200, "description": "SUCCESS",url:filePath };
	});
})

module.exports = router.routes()

image.png

image.png

formData格式上传文件

FormData参考文档: FormData - Web API 接口参考 | MDN

formData就是将form表单元素的namevalue进行组合,实现表单数据的序列化,从而减少表单元素的拼接,提高工作效率。

Web API 提供了FormData方法,提供了一种表示表单数据的键值对的构造方式,通过FormData.append(key, value)FormData中添加新的属性值。

前端实现代码:

//在前面的前端代码中更换upload的change事件就可以
const uploadFile = (file, files) => {
  //防止change事件多次触发
  if (file.status !== 'ready') return
  console.log(file)
  //创建一个FormData对象
  let formData = new FormData()
  //FormData接口的append()方法会添加一个新值到 FormData 对象内的一个已存在的键中,如果键不存在则会添加该键。
  formData.append('file', file.raw)
  formData.append('name', file.name)
  console.log('提交数据:', formData)
  axios
    .post('http://localhost:3001/upload/formDataUpload', formData, {
      'Content-Type': 'multipart/form-data'
    })
    .then(
      (res) => {
        // 上传成功后的处理
        console.log('文件上传', res)
      },
      (err) => {
        // 出现错误时的处理
        console.log(err)
      }
    )
}

右下图看出传输的文件就是二进制格式。

image.png

从服务端打印可以得到文件的各个信息参数,这里可能有人因为koa-body的版本不同,打印结果也会不同,一个是PresistentFile类型,一个是File类型,两者可以进行上传,只需要取值字段对应即可。(ps:如果有大佬知道两者区别跪求告知)

1673341039973.png

image.png

后端实现代码:

//二进制文件流上传
router.post('/formDataUpload',  (ctx, next) => {
    /*ctx.request.files是为文件数组,后面的file是定义接收的参数名,也是前端传递的参数名,前后端商量定义的参数名即可*/
    console.log('当前文件信息:',ctx.request.files)
    //可根据获取的文件的类型,大小来此限制上传上传的类型和大小,来进行图片,视频,zip文件等的上传划分
	const file = ctx.request.files.file;
	const filename = ctx.request.files.file.originalFilename
	// const ext = file.name.split('.').pop(); // 获取上传文件扩展名
	// console.log('文件名字',ext)
	// 创建文件夹
	const uploadPath = "./dowload"; // 这是我测试的路径
	const flag = fs.existsSync(uploadPath); // 判断文件夹是否存在
	// 同步创建多级文件夹
	if (!flag) {
		mkdirp.sync(uploadPath)
	} else {
		console.log("文件夹已经建立")
	}
	// 文件全路径
	const filePath = `${uploadPath}/${filename}`;
	console.log('上传成功后的文件路径', filePath)
	return new Promise((resolve, reject) => {
		const reader = fs.createReadStream(file.filepath);
		const upStream = fs.createWriteStream(filePath); // 创建可写流
		// console.log(upStream)
		// 对写入流进行事件监听
		upStream.on('open', function () {
		  console.log("open");
		});
		// 流写入成功后调用的事件,在这里处理返回结果
		upStream.on('finish', function () {
			console.log("finish");
			// 对图片计算md5值的,你也可以处理自己的逻辑,然后通过 resolve() 函数将处理的结果返回即可
			const buf = fs.readFileSync(filePath);
			resolve({
				md5: buf
			});
		});
		upStream.on('close', function () {
		  console.log("close");
		});
		upStream.on('error', function (err) {
			// 有错误的话,在这个里面处理
			console.log("error", err);
			reject(err)
		});
		// 可读流通过管道写入可写流
		reader.pipe(upStream);
		ctx.body = { "code": 200, "description": "上传成功",url:filePath };
	});
})

多文件以及文件夹上传

多文件上传

多文件上传其实跟单文件差不多上传差不多,其实就是通过循环formData.appen()来添加进去,然后传递给后端。后端所做的操作就是读取文件数组,然后循环进行单文件的读取操作。

后端代码示例:

router.post('/manyUpload', async (ctx, next) => {
	const file = ctx.request.files.file;
	console.log('上传文件', ctx.request.files)
	// 创建文件夹
	const uploadPath = "./manyDowload"; // 新开一个文件夹便于观察
	const flag = fs.existsSync(uploadPath); // 判断文件夹是否存在
	// 同步创建多级文件夹
	if (!flag) {
		mkdirp.sync(uploadPath)
	} else {
		console.log("文件夹已经建立")
	}
	for (let i = 0; i < file.length; i++) {
		const filename = file[i].originalFilename
		// console.log('当前文件信息:',file)
		const ext = file[i].originalFilename.split('.').pop(); // 获取上传文件扩展名
		// console.log('文件名字',ext)
		// 文件全路径
		const filePath = `${uploadPath}/${filename}`;
		// console.log('上传成功后的文件路径', filePath)
		new Promise((resolve, reject) => {
			const reader = fs.createReadStream(file[i].filepath);
			const upStream = fs.createWriteStream(filePath); // 创建可写流
			upStream.on('finish', function () {
				// 对图片计算md5值的,你也可以处理自己的逻辑,然后通过 resolve() 函数将处理的结果返回即可
				const buf = fs.readFileSync(filePath);
				resolve({
					md5: buf
				});
			});
			upStream.on('error', function (err) {
				// 有错误的话,在这个里面处理
				console.log("error", err);
				reject(err)
			});
			// 可读流通过管道写入可写流
			reader.pipe(upStream);
		});
	}
	ctx.body = { "code": 200, "description": "SUCCESS" };
})

前端代码示例:

<template>
    <el-upload
      :file-list="fileList"
      class="upload-demo"
      action="#"
      :auto-upload="false"
      multiple
      :on-change="uploadFile"
      :limit="3"
    >
      <el-button type="primary">选择文件</el-button>
    </el-upload>
    <div><el-button @click="submitFile">上传</el-button></div>
</template>
    
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
const fileList = ref([])
    const uploadFile = (file, files) => {
  //防止change事件多次触发
  if (file.status !== 'ready') return
  console.log('file', file)
  console.log('files', files)
  fileList.value = files
  console.log('fileList', fileList)
}
const submitFile = () => {
  //创建一个FormData对象
  let formData = new FormData()
  let files = fileList.value
  files.map((x) => {
    //FormData接口的append()方法会添加一个新值到 FormData 对象内的一个已存在的键中,如果键不存在则会添加该键。
    formData.append('file', x.raw)
  })
  console.log('提交数据:', formData)
  axios
    .post('http://localhost:3001/upload/manyUpload', formData, {
      'Content-Type': 'multipart/form-data'
    })
    .then(
      (res) => {
        // 上传成功后的处理
        console.log('文件上传', res)
      },
      (err) => {
        // 出现错误时的处理
        console.log(err)
      }
    )
}
</script>

image.png image.png

image.png

ps:多文件上传建议upload组件不使用自动上传功能,使用手动上传,因为如果是自动上传,在upload组件的钩子函数基本是在一个文件添加进去就会进行一次完成的上传周期。从下图可知控制台中我们可以得知upload组件多选的时候是依次把文件上传。这样相当于是调用多次接口上传,所以这里建议是使用手动上传。

image.png

image.png

image.png

自动上传前端代码示例,后端接口直接用前面的单文件上传

<el-upload
      :file-list="fileList"
      class="upload-demo"
      action="http://localhost:3001/upload/formDataUpload"
      name="file"
      headers=""
      :auto-upload="true"
      multiple
      :on-change="uploadFile"
      :limit="3"
      :before-upload="beforeUpload"
      :on-success="onSuccess"
      :on-error="onError"
    >

文件夹上传

当你会了前面的操作之后,上传一个文件夹其实思路就很清晰了。我们先通过判断文件夹是否存在,然后判断是否有深层次文件夹,然后进行文件的读写操作。

image.png

image.png

后端实例代码:

//文件夹上传
router.post('/uploadFolder', async (ctx, next) => {
	//ctx.request.files是为文件数组,后面的file是定义传递的参数名,可以根据这个来修改,如果前端传递excel和img参数名,那就需要分两个,.excel和.img来接收
	const file = ctx.request.files.file;
	// console.log('上传文件夹',file)
	// 创建文件夹
	const uploadPath = `./folder`; // 这是我测试的存放临时文件夹路径
	const flag = fs.existsSync(uploadPath); // 判断文件夹是否存在
	// 同步创建多级文件夹
	if (!flag) {
		mkdirp.sync(uploadPath)
	} else {
		console.log("文件夹已经建立")
	}
	for (let i = 0; i < file.length; i++) {
		const filename = file[i].originalFilename
		// console.log('当前文件信息:',file)
		// 文件全路径
		const filePath = `${uploadPath}/${filename}`;
		//上面创建的是总文件夹,下面是创建各个文件夹里面的文件夹
		//获取最后面的文件名前的索引,用于后面创建文件夹
		let fileIndex = filePath.lastIndexOf('/')
		console.log('fileIndex:',fileIndex)
		console.log('文件名前面的路径:',filePath.slice(0,fileIndex))
		let fileFlag = fs.existsSync(filePath.slice(0,fileIndex)) //判断里面文件夹深层次文件夹有没有创建,除掉最后面的文件名,前面的均为文件目录
		//创建多级文件夹
		if(!fileFlag){
			//创建文件夹不能有文件后缀名
			mkdirp.sync(filePath.slice(0,fileIndex))
		}else{
			console.log("文件夹已经建立2")
		}
		// console.log('上传成功后的文件路径', filePath)
		 new Promise((resolve, reject) => {
			const reader = fs.createReadStream(file[i].filepath);
			const upStream = fs.createWriteStream(filePath); // 创建可写流
			// 流写入成功后调用的事件,在这里处理返回结果
			upStream.on('finish', function () {
				const buf = fs.readFileSync(filePath);
				resolve({
					md5: buf
				});
			});
			upStream.on('error', function (err) {
				// 有错误的话,在这个里面处理
				console.log("error", err);
				reject(err)
			});
			// 可读流通过管道写入可写流
			reader.pipe(upStream);
		});
	}
	ctx.body = { "code": 200, "description": "SUCCESS"};
})

前端示例代码:

这里需要用到input的一个属性webkitEntries来使上传功能变为上传文件夹。

详情了解:[HTMLInputElement.webkit目录]

ps:这里前端是使用vue3所以在使用el-upload组件的时候我们添加属性用到document.querySelector()来动态添加。如果在el-upload标签使用ref会发现,vue3删除了$children属性。如果是vue2直接就可以使用$ref[name].$children来修改。

<template>
<el-upload
      :file-list="fileList"
      class="upload-demo"
      action="#"
      :auto-upload="false"
      ref="upload"
      multiple
      :on-change="uploadFile"
    >
      <el-button type="primary">选择文件</el-button>
   </el-upload>
 <div><el-button @click="submitFile">上传</el-button></div>
</template>
<script setup>
//主要代码
onMounted(() => {
//上传文件夹需要用到webkitdirectory属性
  nextTick(() => {
    document.querySelector('.el-upload__input').webkitdirectory = true
  })
})
const uploadFile = (file, files) => {
  //防止change事件多次触发
  if (file.status !== 'ready') return
  console.log('file', file)
  console.log('files', files)
  fileList.value = files
  console.log('fileList', fileList)
}
const submitFile = () => {
  //创建一个FormData对象
  let formData = new FormData()
  let files = fileList.value
  files.map((x) => {
    formData.append('file', x.raw)
  })
  console.log('提交数据:', formData)
  axios
    .post('http://localhost:3001/upload/uploadFolder', formData, {
      'Content-Type': 'multipart/form-data'
    })
    .then(
      (res) => {
        // 上传成功后的处理
        console.log('文件上传', res)
      },
      (err) => {
        // 出现错误时的处理
        console.log(err)
      }
    )
}
</script>

image.png

image.png

image.png

总结:基于上传功能就到了。再继续精深就是复杂的业务判断,以及关于大文件的上传。如果觉得这篇文章对你有所帮助,能否点个赞鼓励鼓励,万分感谢。如果有哪里写的不好或不对的地方,请大佬们指出,让我得以改正进步。