likes
comments
collection
share

前端切片上传文件(可暂停),以链接形式返回

作者站长头像
站长
· 阅读数 33

前言

不管是在使用他人的网页还是自己设计的网页,有一项功能可以说是很常见的,上传文件。常见的有图片(.jpg .png)、音频(.MP3 .MP4),文档(.doc)等。一般都要前后端的配合,但是懂得都懂前端还有NodeJS,所以开始干活。(一定还有更厉害的方案,更厉害的代码,有什么更好的实现,可以在评论中说说,最好直接上代码)

样例展示

前端切片上传文件(可暂停),以链接形式返回

前端切片上传文件(可暂停),以链接形式返回

搭环境

我们还是使用我们稍微熟悉点的NodeJS框架-KOA2

安装依赖

npm install koa --save
npm install koa-router --save
npm i formidable --save
npm install koa-static --save
npm i @koa/cors --save

测试环境

环境没有问题

var Koa = require('koa');
var app = new Koa();
var Router = require('koa-router')();
const fs = require('fs')
const formidable = require('formidable')
const path = require('path')
const koaStatic = require('koa-static')
app.use(koaStatic(path.join(__dirname, 'public')))
//开启跨域
const cors = require("@koa/cors")
app.use(cors())
app.use(async (ctx, next) => {
    ctx.set('Access-Control-Allow-Origin', '*');
    ctx.set('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild');
    ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
    if (ctx.method == 'OPTIONS') {
        ctx.body = 200;
    } else {
        await next();
    }
});

Router.get('/', async (ctx) => {
    ctx.body = "ok"
})

app
    .use(Router.routes())   	//启动路由
    .use(Router.allowedMethods());
app.listen(3000);

前端切片上传文件(可暂停),以链接形式返回

思路及实现

前端思路

既然是文件的切片上传,那么切片要有吧,我们使用Blobslice方法,我们就可以对二进制文件进行拆分。一个个文件有了那就上传吧,我们可以使用axios上传文件,最后差一个控制暂停和继续的,可以理解为取消请求和重新发送请求,只不过在重新发送请求的时候要判断哪些切片是上传过的。(代码有点长,尽量打上注释)

前端代码

<template>
  <input type="file" @change="onChange" ref="img" />
  <button @click="onContinue">继续</button>
  <button @click="noSuspend">暂停</button>
  <div class="progress-frame">
    <div class="progress"></div>
  </div>
</template>

<script >
import { onMounted, ref } from "vue";
import axios from "axios";
//为下面取消请求做准备
const CancelToken = axios.CancelToken;
export default {
  name: "App",
  setup() {
    let img = ref();
    //用来计算进度条 (alreadyUpload/uploadTotal)*100
    let alreadyUpload = ref(0);
    let uploadTotal = ref(1);
    //请求取消的数组
    let cancel = ref([]);
    let isChange = ref(false);
    async function onChange() {
      let sum = [];
      let file = img.value.files[0];
      let size = 1024 * 20; //20kB 切片大小,可以调大点
      let fileChunks = [];
      let index = 0; //切片序号
      isChange.value = true;
      // 获取已上传的切片
      await axios({
        method: "get",
        url: "http://127.0.0.1:3000/alreadyFile",
        params: {
          filename: file.name,
        },
        //用于取消请求
        cancelToken: new CancelToken(function executor(c) {
          cancel.value.push(c);
        }),
      })
        .then((response) => {
          //返回值是一个已上传切片数组
          sum = response.data;
          alreadyUpload.value = sum.length;
        })
        // eslint-disable-next-line @typescript-eslint/no-empty-function
        .catch(() => {});
      // 对二进制文件进行切片
      for (let cur = 0; cur < file.size; cur += size) {
        fileChunks.push({
          hash: index++,
          chunk: file.slice(cur, cur + size),
        });
      }
      uploadTotal.value = fileChunks.length;
      // 判断切片是否全部上传完毕
      if (sum.length != fileChunks.length) {
        for (let i = 0; i < fileChunks.length; i++) {
          let item = fileChunks[i];
          let formData = new FormData();
          formData.append("filename", file.name);
          formData.append("hash", String(item.hash));
          formData.append("chunk", item.chunk);
          //判断某个切片是否上传完毕
          if (sum.indexOf(item.hash) == -1) {
            // 上传切片
            axios({
              method: "post",
              url: "http://127.0.0.1:3000/upload",
              data: formData,
              cancelToken: new CancelToken(function executor(c) {
                cancel.value.push(c);
              }),
            })
              .then(() => {
                alreadyUpload.value = alreadyUpload.value + 1;
              })
              // eslint-disable-next-line @typescript-eslint/no-empty-function
              .catch(() => {});
          } else {
            continue;
          }
        }
      } else {
        console.log("上传已结束");
      }
      //请求文件链接
      await axios({
        method: "get",
        url: "http://127.0.0.1:3000/fileLink",
        params: {
          filename: file.name,
        },
        cancelToken: new CancelToken(function executor(c) {
          cancel.value.push(c);
        }),
      })
        .then((response) => {
          //返回文件链接
          console.log(response.data);
        })
        // eslint-disable-next-line @typescript-eslint/no-empty-function
        .catch(() => {});
    }
    
    //点击暂停按钮
    const noSuspend = () => {
      console.log("请求已取消");
      cancel.value.forEach((val) => {
        val();
      });
    };
    //点击继续按钮
    const onContinue = () => {
      if (isChange.value) {
        onChange();
      }
    };
    return {
      img,
      onChange,
      alreadyUpload,
      uploadTotal,
      noSuspend,
      onContinue,
    };
  },
};
</script>

<style scoped>
.progress-frame {
  width: 500px;
  height: 32px;
  background-color: aqua;
}
.progress {
  width: v-bind("(alreadyUpload/uploadTotal)*100+'%'");
  height: 32px;
  background-color: red;
}
</style>

后端思路及代码

这个地方合在一起说,后端我们准备两个文件夹一个用来保存同一个文件的切片,一个用来保存切片合并后的文件(也就是链接返回的文件)。

前端切片上传文件(可暂停),以链接形式返回

在全局里配置路径,一个是切片的文件目录,一个是切片合并后的文件目录

const TEMPORARY_FILES = path.join(__dirname, 'temporary')
const STATIC_FILES = path.join(__dirname, 'public')

获取已上传切片

添加一个路由,返回已上传切片的名字数组

Router.get("/alreadyFile", async (ctx) => {
    const { filename } = ctx.query;
    const already = []
    //fs.readdirSync,该方法将返回一个包含“指定目录下所有文件名称”的数组对象
    //切片文件目下没有 `${filename}`的文件夹,那就是还没开始上传
    if (!fs.existsSync(`${TEMPORARY_FILES}\\${filename}`)) {
        ctx.body = already;
    }
    else {
        //有文件夹,那就获取该文件夹下面切片的文件名
        fs.readdirSync(`${TEMPORARY_FILES}\\${filename}`).forEach((name) => {
            already.push(Number(name))
        })
        ctx.body = already
    }
})

返回的就是 0-18的字符串数组['1','2','3',...,'18']

前端切片上传文件(可暂停),以链接形式返回

切片的上传

添加路由

主要功能是创建切片,并放入同一个文件夹,再把文件夹放入切片目录下。也是重点。

Router.post('/upload', async (ctx) => {
    let form = new formidable.IncomingForm();
    form.parse(ctx.req, (err, value, files) => {
        //切片保存在temporary目录下的那个文件夹下
        let dir = `${TEMPORARY_FILES}\\${value.filename}`
        //第几个切片
        let hash = value.hash;
        let chunk = files.chunk;
        const buffer = fs.readFileSync(chunk.filepath)
        try {
            // 是否存在这个文件夹
            if (!fs.existsSync(dir)) {
                //创建
                fs.mkdirSync(dir)
            }
            // 创建切片文件
            const ws = fs.createWriteStream(`${dir}\\${hash}`)
            // 切片写入
            ws.write(buffer)
            ws.close()
        } catch (error) {
            console.error(error)
        }
    })
    ctx.body = "ok"
})

返回文件链接

添加路由

主要功能是看是否存已经合并的文件,如果存在返回文件链接,否则先合并切片再返回文件链接。

Router.get('/fileLink', async (ctx) => {
    const { filename } = ctx.query
    try {
        // 在public目录下是否存在这个文件,有就直接返回链接,ctx.origin获取域名
        if (fs.existsSync(`${STATIC_FILES}\\${filename}`)) {
            ctx.body = { "url": `${ctx.origin}/${filename}` }
        }
    } catch (error) {
        console.error(error);
    }

    try {
        let len = 0
        //fs.readdirSync,该方法将返回一个包含“指定目录下所有文件名称”的数组对象
        const bufferList = fs.readdirSync(`${TEMPORARY_FILES}\\${filename}`).map((hash, index) => {
            //读取切片数据
            const buffer = fs.readFileSync(`${TEMPORARY_FILES}\\${filename}\\${index}`)
            len += buffer.length
            return buffer
        });
        //合并切片文件
        // 返回一个连接了 list 中所有 Buffer 的新 Buffe
        const buffer = Buffer.concat(bufferList, len);
        //在public下创建文件
        const ws = fs.createWriteStream(`${STATIC_FILES}\\${filename}`)
        ws.write(buffer);
        ws.close();
    } catch (error) {
        console.error(error);
    }
    ctx.body = { "url": `${ctx.origin}/${filename}` }
})

结语

文件的切片上传差不多就是这样,不过其中还是有很多问题,比如两个文件的名字一样怎么办,还有文件很大切太多了,那会同时发很多的请求等。有懂的可以评论说一下,博主也不是很懂。对于文件的切片上传,说简单理解了后感觉也是挺简单的,说难吧也是挺难的,这代码没有人的指点出不来出不来或许还是菜了。