这篇文章讲解实现基础的文件上传的前后端逻辑,是大文件上传实现专栏的第一篇文章。
下面将分为后端部分实现的讲解和前端部分实现的讲解,先讲后端后讲前端。
- 后端部分
后端部分,用 Express.js 来做。先捋一下需求:接口要能够接收文件并保存到特定文件夹下面,能够指定文件名。
sh
# 创建项目
mkdir server-by-express
cd server-by-express
npm init -y
# 安装将用到的依赖
npm i express fs-extra cors

新建项目之后,在vscode中打开,新建一个index.js文件,键入如下代码:
js
const express = require("express");
const cors = require("cors");
const fs = require("fs-extra");
const app = express();
app.use(cors());
app.get("/", (req, res) => {
res.json({ message: "Hello World" });
});
app.listen(8080, () => {
console.log("Server is running on port 3000");
});
在项目根目录下执行 node index.js
跑起来看下

测试一下没问题

先确保文件上传的目录存在
js
// 临时文件夹,文件将会被上传到这个文件夹中
const TEMP_DIR = path.resolve(__dirname, "temp");
// 确保临时文件夹存在
fs.ensureDir(TEMP_DIR);
然后定义一个辅助函数,用来将可读流流入到可写流:
js
/**
* 数据从可读流流向可写流
* @param {ReadableStream} rs 可读流
* @param {WritableStream} ws 可写流
* @returns 返回一个Promise,当流结束时,Promise会被resolve
*/
function pipeStream(rs, ws) {
return new Promise((resolve, reject) => {
rs.pipe(ws).on("finish", resolve).on("error", reject);
});
}
接下来实现上传文件接口:
js
app.post("/upload/:filename", async (req, res) => {
// 获取文件名
const { filename } = req.params;
// 拼接文件路径
const filePath = path.resolve(TEMP_DIR, filename);
// 创建可读流
const ws = fs.createWriteStream(filePath);
// 将可读流中的数据写入到可写流中,完成文件上传
await pipeStream(req, ws);
res.json({ success: true });
});
注意这里以流的形式接收文件,而不是FormData

为了方便测试,全局安装下 npm i nodemon -g
, 然后在package.json
添加一条script

然后执行 npm run start
重启下,后面修改了就会自动重启了。
下面用postman来测试下

用 post 请求,body 里面选择二进制文件,测试没问题,后端基础上传文件部分就完成了
- 前端部分
将要做的基础前端部分功能如下:

用vite
初始化项目,选择 vue
, 用 javascript
就好, 项目名为 client-by-vue
sh
# 创建和启动项目
npm create vite@latest
cd client-by-vue
npm i
npm run dev

先把不用的文件删除掉,保持简单

打开网址看看,没问题。

然后把后面会用到依赖安装了先,分别是element-plus
和 axios
, element-plus
安装按照官网指南全量安装,包括图标
sh
npm i element-plus @element-plus/icons-vue axios

我们试下:

好了,以上就是前端需要准备的部分,下面开始开发文件上传功能
先把样式写出来:画一个文件选择框和一个按钮,鼠标悬浮和边框变色
html
<template>
<div id="upload-container">
<el-icon :size="30"><Files /></el-icon>
</div>
<el-button>开始上传</el-button>
</template>
<script setup></script>
<style scoped>
#upload-container {
height: 200px;
line-height: 200px;
text-align: center;
border: 1px dashed #ccc;
margin-bottom: 20px;
cursor: pointer;
}
#upload-container:hover,
#upload-container.is-dragover {
border: 1px dashed #0037ff;
color: #0037ff;
}
</style>

实现拖拽选择文件功能
拖拽获取到文件,需要用到drog
事件,在 event.dataTransfer
读取到,同时要阻止浏览器默认事件; isDragover用于控制拖拽的高亮
html
<div
id="upload-container"
:class="{ 'is-dragover': isDragover }"
@drop="handleDrop"
@dragover.prevent
@dragenter.prevent="isDragover = true"
@dragleave.prevent="isDragover = false"
>
<el-icon :size="30"><Files /></el-icon>
</div>
js
const { isDragover } = toRefs(
reactive({
isDragover: false,
})
);
const handleDrop = (e) => {
e.preventDefault();
const { files } = e.dataTransfer;
console.log("files: ", files);
};
效果是这样的

这样就拿到了拖拽的File
文件对象了,可以用来上传
实现点击选择文件功能
除了拖拽上传,常用的方式还有点击选择文件的,也来实现一下:利用input:type=file的点击事件,可以打开选择文件的弹框。
html
<div
id="upload-container"
...
@click="handleClick"
...
>
<el-icon :size="30"><Files /></el-icon>
</div>
js
const handleClick = () => {
const input = document.createElement("input");
input.type = "file";
input.addEventListener("change", (e) => {
const { files } = e.target;
console.log("files: ", files);
});
input.click();
};
效果同样可以拿到文件对象

例子以视频和图片讲解,来展示一下拿到视频文件和图片文件吧
js
// 新增一个 selectedFile 用来存放文件信息
const { isDragover, selectedFile } = toRefs(
reactive({
isDragover: false,
selectedFile: { url: "", file: null },
})
);
const handleDrop = (e) => {
e.preventDefault();
const { files } = e.dataTransfer;
if (files.length === 0) return;
// 限制只能上传图片或视频
const isMedia =
files[0].type.indexOf("image") > -1 || files[0].type.indexOf("video") > -1;
if (!isMedia) {
return ElMessage.warning("只能上传图片或视频");
}
// 拿到文件之后,存放到 selectedFile 中, url 为文件的本地路径,file 为文件对象
selectedFile.value = {
url: URL.createObjectURL(files[0]),
file: files[0],
};
};
const handleClick = () => {
const input = document.createElement("input");
input.type = "file";
input.addEventListener("change", (e) => {
const { files } = e.target;
if (files.length === 0) return;
// 限制只能上传图片或视频
const isMedia =
files[0].type.indexOf("image") > -1 ||
files[0].type.indexOf("video") > -1;
if (!isMedia) {
return ElMessage.warning("只能上传图片或视频");
}
// 拿到文件之后,存放到 selectedFile 中, url 为文件的本地路径,file 为文件对象
selectedFile.value = {
url: URL.createObjectURL(files[0]),
file: files[0],
};
});
input.click();
};
html
<div
id="upload-container"
...
>
<template v-if="selectedFile.url">
<img
v-if="selectedFile.file.type.indexOf('image') > -1"
:src="selectedFile.url"
/>
<video
controls
v-if="selectedFile.file.type.indexOf('video') > -1"
:src="selectedFile.url"
></video>
</template>
<el-icon v-else :size="30"><Files /></el-icon>
</div>
效果是这样的

好了,选择文件功能搞定,接下来我们简单封装一下axios
, 用来发送上传文件请求
src 目录下面新建一个 axiosInstance.js 文件,内容如下
js
// axios 设置baseURL 响应拦截器
import axios from "axios";
const axiosInstance = axios.create({
baseURL: "http://localhost:8080",
});
axiosInstance.interceptors.response.use(
(response) => {
if (response.data && response.data.success) {
return response.data;
}
},
(error) => {
console.log("error: ", error);
return Promise.reject(error);
}
);
export default axiosInstance;
在 App.js 文件中引入 axiosInstance.js 来发送请求
js
import axiosInstance from "./axiosInstance";
...
const handleUpload = () => {
if (!selectedFile.value.file) {
return ElMessage.warning("请先选择文件");
}
const file = selectedFile.value.file;
axiosInstance
.post(`/upload/${file.name}`, file, {
headers: {
"Content-Type": "application/octet-stream",
},
})
.then((res) => {
ElMessage.success("上传成功");
})
.catch((err) => {
ElMessage.error("上传失败");
});
}
开始发送请求,注意 请求头设置 application/octet-stream
我们试一下

上面演示的都是小文件,本地上传速度很快,接下来我们试一下大一点的文件,我找了一个1.5G的视频,我们来试一下

在本地没有网络延迟的情况下,虽然上传成功了,但是有明显的等待感,网络请求一直pending,体验不是很好,我们加个显示上传进度来优化一下
js
// 添加一个对象,用来保存进度信息
const {
...
progressInfo
} = toRefs(
reactive({
...
progressInfo: {
name: "",
percent: 0,
},
})
);
const handleUpload = () => {
...
const file = selectedFile.value.file;
axiosInstance
.post(`/upload/${file.name}`, file, {
headers: {
"Content-Type": "application/octet-stream",
},
// 这里更新进度信息
onUploadProgress(progressEvent) {
progressInfo.value = {
name: file.name,
percent: Math.round(
(progressEvent.loaded * 100) / progressEvent.total
),
};
},
})
...
};
html
<div class="progress" v-if="progressInfo.percent > 0">
<span>{{ progressInfo.name }}</span>
<el-progress :percentage="progressInfo.percent"></el-progress>
</div>
我们试一下效果

到这里,前端基础上传文件的部分也完成了。代码可以跟着上面的讲解敲一遍加深印象,同时我放到gitee上面,有需要自取。
大文件上传实现 的下一篇文章主题是"分片上传",将在这篇文章的实现基础上进行改造升级,敬请期待。