大文件上传实现-基础上传功能(Vue + Express)

1,130 阅读6分钟

本文讲解实现文件上传的前后端逻辑,是大文件上传实现专栏的第一篇文章,为专栏后面的其他进阶功能做铺垫。

本文最终实现:后端上传接口,前端交互(点击选择文件、拖拽选择文件、上传文件以及上传进度条显示)。

值得注意的是:我们接口处理文件并不是用的multer(解析FormData), 而是以流的方式接收和处理。

后端部分

后端部分,用 Express.js 来做。需求是:要接收文件 并 以指定文件名 保存到特定文件夹下面。

先创建项目和安装依赖:

# 创建项目
mkdir server-by-express
cd server-by-express
npm init -y
# 安装将用到的依赖
npm i express fs-extra cors

image.png

新建项目之后,在vscode中打开,新建一个index.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 跑起来看下

image.png

测试一下没问题

image.png

下面开始业务相关代码:

先确保文件上传的目录存在

// 临时文件夹,文件将会被上传到这个文件夹中
const TEMP_DIR = path.resolve(__dirname, "temp");
// 确保临时文件夹存在
fs.ensureDir(TEMP_DIR);

然后定义一个辅助函数,用来将可读流流入到可写流:

/**
 * 数据从可读流流向可写流
 * @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);
  });
}

接下来实现上传文件接口:

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 });
});

注意这里以流的形式接收文件,而不是借助 multer 处理 FormData数据。

image.png

为了热更新,代码改完及时生效,安装下 npm i nodemon, 然后在package.json添加一条script

image.png

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

下面用 postman 来测试下:

testupload.gif

用 post 请求,body 里面选择二进制文件,测试没问题,后端接口就完成了。

前端部分

先看看最终效果:

8.gif

准备工作

vite初始化项目,选择 vue, 用 javascript 就好, 项目名为 client-by-vue

# 创建和启动项目
npm create vite@latest
cd client-by-vue
npm i
npm run dev

image.png

先把无关的文件删除掉,保持简单。

image.png

打开网址看看,没问题。

image.png

把后面会用到依赖安装了,分别是element-plusaxioselement-plus安装按照官网指南全量安装,包括图标:

npm i element-plus @element-plus/icons-vue axios

image.png

试一下组件库引入:

image.png

好了,以上就是前端需要准备的部分,下面开始开发文件上传功能

样式

先把样式写出来:画一个文件选择框和一个按钮,鼠标悬浮和边框变色

<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>

image.png

实现拖拽选择文件功能

拖拽获取到文件,需要用到drop事件,在 event.dataTransfer读取到,同时要阻止浏览器默认事件; isDragover用于控制拖拽的高亮(注意看上面的css样式):

  <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>
const { isDragover } = toRefs(
  reactive({
    isDragover: false,
  })
);

const handleDrop = (e) => {
  e.preventDefault();
  const { files } = e.dataTransfer;
  console.log("files: ", files);
};

效果是这样的:

02.gif

这样就拿到了拖拽的File文件对象了,可以用来上传。

实现点击选择文件功能

除了拖拽获取到文件,还有点击选择文件交互,也来实现一下:利用input:type=file的点击事件,可以打开选择文件的弹框。

  <div
    id="upload-container"
    ...
    @click="handleClick"
    ...
  >
    <el-icon :size="30"><Files /></el-icon>
  </div>
const handleClick = () => {
  const input = document.createElement("input");
  input.type = "file";
  input.addEventListener("change", (e) => {
    const { files } = e.target;
    console.log("files: ", files);
  });
  input.click();
};

效果同样可以拿到文件对象

3.gif

文件展示

例子以视频和图片讲解,来展示一下拿到视频文件和图片文件吧。

思路上就是用一个响应式对象,选完文件,就存一下 url(URL.createObjectURL(file))和原文件对象,template 中根据类型用imgvideo进行展示

// 新增一个 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();
};

  <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>

效果是这样的:

4.gif

文件上传

好了,选择文件功能搞定,接下来简单封装一下axios, 用来发送上传文件请求。

src 目录下面新建一个 axiosInstance.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 来发送请求:

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("上传失败");
    });
}
<!-- 按钮添加上点击事件 -->
<el-button @click="handleUpload">开始上传</el-button>

开始发送请求,注意请求头设置 application/octet-stream

看看上传效果:

5.gif

上传进度展示

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

6.gif

在本地没有网络延迟的情况下,虽然上传成功了,但是有明显的等待感,网络请求一直 pending, 体验不是很好,下面加个显示上传进度来优化一下:

// 添加一个对象,用来保存进度信息
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
          ),
        };
      },
    })
    ...
};
  <div class="progress" v-if="progressInfo.percent > 0">
    <span>{{ progressInfo.name }}</span>
    <el-progress :percentage="progressInfo.percent"></el-progress>
  </div>

通过axios 发送请求配置项的 onUploadProgress回调可以实时更新上传的进度。

最终就达到了上面展示的效果:

7.gif

总结

本文带大家起项目,先开发后端上传接口,然后大篇幅演示实现拖拽选择文件、点击选择文件、上传文件以及上传进度展示等前端交互。

代码可以跟着上面的讲解敲一遍加深印象,同时我放到gitee上面,有需要自取。

如果有帮助也望能顺手点个赞和收藏,这是我继续更文的重要动力来源哦~

大文件上传实现专栏 的下一篇文章主题是“分片上传”,将在本文实现基础上进行改造升级,点击这里继续阅读吧。