携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第20天,点击查看活动详情
前端开发中,常见的文件上传场景有:
- 单个文件上传
- 多个文件上传
- 目录上传
- 拖拽上传
- 剪贴板上传
- 大文件分块上传
其中面试中常问的就是大文件分块上传了,不过由于前三种文件上传都是使用原生input标签的一些属性就可以完成的,所以本篇我们先来实现前面的三个比较简单的文件上传场景,会使用Koa作为后端,前端则使用vue
单文件上传
首先我们来搭建一下前端环境,为了方便,直接使用antfu大神的vitesse-lite模板,其已经内置好了很多常用的开发工具,感兴趣的可以打开vitesse文档看一看
直接使用degit工具将模板克隆到本地
npx degit antfu/vitesse-lite single-file-upload
cd single-file-upload
pnpm i
搭建完毕后运行看到如下界面即可
使用axios封装文件上传接口请求
首先我们简单封装一下axios,主要就是创建一个axios实例,配置接口的基础前缀地址和超时时间
src/utils/http.ts
import axios from 'axios'
export const request = axios.create({
baseURL: 'http://localhost:3000/api',
timeout: 10000,
})
然后去封装文件上传的接口请求,由于文件上传传输的是文件,也就是二进制数据,所以一般使用FormData的方式进行传递,因此我们封装的接口请求函数需要接收一个key作为FormData中对应文件的key,然后还要接收选择的文件本身
import { FileUploadAPI } from '~/constants'
import { request } from '~/utils'
/**
* @description 上传文件 -- 会转成 formData 上传
* @param fieldName formData 的 字段名 不指定则默认为 file
* @param file input 中选择的文件
*/
export const singleUploadFile = (fieldName: string, file: File) => {
const formData = new FormData()
formData.set(fieldName, file)
request.post(FileUploadAPI.SINGLE_UPLOAD, formData, {
// 计算上传进度
onUploadProgress: (progressEvent: ProgressEvent) => {
const uploadedPercent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
)
console.log(uploadedPercent)
},
})
}
这里我们先打印一下文件上传的进度,之后后端接口实现完,进行验证成功之后再去完善前端进度条的效果
编写简单的文件上传页面
接下来来到src/pages/index.vue中,将原本的内容删除,然后编写一个简单的文件上传表单
<script setup lang="ts">
import { uploadFile } from '~/api'
// 文件上传表单元素
const uploadFileRef = ref<HTMLInputElement>()
const handleUploadFileBtnClick = () => {
// 没选择文件则不调用接口
if (!uploadFileRef.value?.files?.length) return
// 获取选择的第一个文件并调用接口上传
const file = uploadFileRef.value.files[0]
uploadFile('file', file)
}
</script>
<template>
<!-- 文件上传容器 -->
<div>
<input type="file" accept="image/*" ref="uploadFileRef" />
<button @click="handleUploadFileBtnClick">上传</button>
</div>
</template>
至此前端部分先告一段落,我们转去实现后端接口
使用Koa实现文件上传接口
由于上传的文件我们希望直接保存在电脑本地,所以要用到koa的静态资源中间件koa-static
而至于跨域问题,有两种解决方案,一种是前端解决,一种是后端解决,这个我们稍后再讲
koa默认是不支持FormData的数据的,同样需要使用中间件的方式让其具备处理FormData的能力,这就要用到@koa/multer中间件了
当然,最基本的还有路由中间件,这个不用多说
首先创建后端项目
mkdir backend && cd backend
pnpm init
pnpm i koa koa-static @koa/router @koa/multer multer
pnpm i typescript ts-node @types/node
pnpm i @types/koa @types/koa__multer @types/koa__router @types/koa-static -D
创建完毕后开始配置各个中间件
@koa/multer -- 处理上传的文件存储问题
对于通过FormData传入的文件,通过该中间件进行存储,可以利用它的diskStorage磁盘存储引擎去处理
创建src/multer.ts
import multer from '@koa/multer'
import { existsSync, mkdirSync } from 'fs'
import { UPLOADED_DIR } from './constants'
import { getUploadedFileName } from './utils'
// 使用磁盘存储引擎管理上传的文件
const storage = multer.diskStorage({
destination: (req, file, cb) => {
if (!existsSync(UPLOADED_DIR)) {
// 确保保存目录存在
mkdirSync(UPLOADED_DIR)
}
// 指定上传的文件的保存路径
cb(null, UPLOADED_DIR)
},
filename: (req, file, cb) => {
// 设置保存的文件名命名格式 -- 时间-文件名
cb(null, getUploadedFileName(file))
},
})
// 创建负责处理文件上传 FormData 的 multer 对象
export const uploadMulter = multer({ storage })
这样就可以将文件存储的功能交给中间件去处理,我们的接口只需要检查中间件处理过程中是否遇到错误,没错误则直接返回接口信息即可,简化上传逻辑处理
@koa/router -- 实现单文件上传接口
创建src/router.ts,在里面去实现我们的文件上传接口,处理上传的逻辑
import KoaRouter from '@koa/router'
import { SERVER_URL } from './constants'
import { uploadMulter } from './multer'
import { generateUniversalResponseData, getUploadedFileName } from './utils'
export const router = new KoaRouter()
// 设置公共前缀
router.prefix('/api')
const enum API_ROUTES {
UPLOAD = '/upload',
}
/**
* @description 单文件上传接口
*/
router.post(
API_ROUTES.UPLOAD,
async (ctx, next) => {
try {
// 直接交给 uploadMulter 中间件去处理 处理完毕后返回信息即可
await next()
ctx.body = generateUniversalResponseData(0, 'upload successfully!', {
'download-url': `${SERVER_URL}/${getUploadedFileName(ctx.file)}`,
})
} catch (e) {
ctx.body = generateUniversalResponseData(
500,
`[UPLOAD_FILE_ERROR]: ${e}`,
null,
)
}
},
// 文件上传交给 uploadMulter 中间件处理
uploadMulter.single('file'),
)
这里封装了生成统一响应体的逻辑和上传的文件名存储到服务器时的最终文件名的逻辑,在src/utils.ts中
import type { File } from '@koa/multer'
/**
* @description 封装统一响应体
* @param code API 调用状态码 -- 0 表示正常
* @param msg API 调用信息
* @param data API 返回的数据
*/
export const generateUniversalResponseData = <T>(
code: number,
msg: string,
data: T,
) => {
return {
code,
msg,
data,
}
}
/**
* @description 获取 koa multer 保存的文件名格式
* @param file koa 接收到的文件
*/
export const getUploadedFileName = (file: File) => {
return `${Date.now()}-${file.originalname}`
}
最后在src/index.ts中注册这些中间件即可
import Koa from 'koa'
import serve from 'koa-static'
import { PORT, SERVER_URL, UPLOADED_DIR } from './constants'
import { router } from './router'
const app = new Koa()
/**
* @description 初始化 Koa 服务
*/
const setupApp = (app: Koa) => {
// 静态资源服务
app.use(serve(UPLOADED_DIR))
// 路由
app.use(router.routes())
// 开启服务监听
app.listen(PORT, () => {
console.log(
`server listening at port ${PORT}, access server with ${SERVER_URL}`,
)
})
}
setupApp(app)
使用 ApiFox 测试
最后我们来测试一下我们的接口是否能够正常上传文件,可以使用ApiFox的快捷请求方便地测试
可以看到,上传成功了,接下来看看对应的保存目录是否有上传的文件
可以正常上传,说明接口的业务逻辑正常,接下来就是前后端对接的问题了
前后端对接
跨域问题解决方案
前面编写页面的时候实际上就已经算是对接好了,那么我们直接来上传试试吧
很不幸,遇到了跨域问题,一般来说在开发阶段遇到跨域问题,可以由前端通过代理服务器去解决,也可以通过后端配置cors来解决,这里我们两种方案都讲解一下
前端解决方案
通过在vite中配置代理服务器即可解决这个问题
server: {
// 通过代理服务器,前端不直接与后端接口服务器交互,而是由代理服务器去请求接口
// 并将数据返回给开发环境服务器,从而解决跨域问题
proxy: {
'/api': {
target: BASE_URL,
changeOrigin: true,
},
},
},
这里没有使用rewrite,我们不能盲目跟着官方文档教程的配置去写,而要明白它用rewrite的原因
用rewrite是因为前端访问的接口地址和后端的接口前缀不一致才需要进行重写,比如前端访问接口的地址是/api/v1/single-upload,而后端地址为/api/single-upload,这个时候就需要进行重写,把v1去掉
这里由于前端访问的是/api/single-upload,并且后端地址也是这个,所以没必要进行重写
改了代理服务器后别忘记把axios的BASE_URL改成代理服务器的url,也就是/api
export const request = axios.create({
baseURL: '/api',
timeout: 10000,
})
这次就可以看到上传成功了,控制台打印出了上传进度
后端解决方案
后端解决跨域的话,就要配置CORS,这样前端的axios的BASE_URL就不需要修改,直接访问后端接口即可,我们先把BASE_URL改回来
export const request = axios.create({
baseURL: 'http://localhost:3000/api',
timeout: 10000,
})
然后后端安装@koa/cors中间件
pnpm i @koa/cors
pnpm i @types/cors -D
直接在setupApp中注册该中间件即可
/**
* @description 初始化 Koa 服务
*/
const setupApp = (app: Koa) => {
// 后端配置 cors 解决跨域问题
app.use(cors())
// ...
}
不需要进行任何配置,默认的配置就已经能够解决跨域问题了,因为从@koa/cors的官方文档就可以知道,默认会添加上Access-Control-Allow-Origin为发起请求方的源,也就是相当于允许任何源访问了
可以打开浏览器的开发者工具的网络面板验证一下
实际开发时,跨域要交给前端解决还是后端解决这个完全看团队的需求,我们只要两种方案都掌握了并且懂得原理就不怕了