2023.13 node+koa+element-plus+ts实现文件上传

最近看了一个关于大文件切片上传,就想自己实现一下包含普通文件上传、切片上传、切片上传后合并、断点续传等功能

首先做一个checkList,按照checkList逐个实现

  • 严格验证文件格式和大小
  • 实现上传百分比
  • 上传相同文件处理
  • 文件预览
  • 切片上传,合并上传文件
  • 断点续传

项目搭建

搭建客户端

vite官网

创建vite项目,我使用的是vue3+ts
npm create vite@latest

安装依赖
npm run install

运行项目
npm run dev

引入element+plus

npm install element-plus --save

//main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)

app.use(ElementPlus)
app.mount('#app')

引入axios

npm install axios --save

const app = createApp(App)
app.config.globalProperties.$axios = axios

封装请求
src目录下新建index.ts

import axios from "axios"
//声明请求参数类型
export interface RequestParams {
  url: string
  method: string
  params: any
  isFile?: boolean
}
//axios配置
axios.defaults.baseURL = "http://localhost:3000"
axios.defaults.headers.common["Access-Control-Allow-Origin"] = "*"
//设置代理的时候会用到
axios.defaults.baseURL = "/api"

//封装请求方法
export function request(config: RequestParams) {
  const { url, method, params, isFile } = config
  return new Promise((resolve, reject) => {
    axios({
      method: method,
      url: url,
      params: params,
      headers: {
      //设置的默认请求头内容类型,也可以自己传参进行覆盖
        "Content-Type": "multipart/form-data",
      },
    })
      .then((res) => {
        resolve(res)
      })
      .catch((error) => {
        reject(error)
      })
  })
}

对接口进行封装后,这样可以通过在vue文件中使用uploadFileApi().then()的方式来调用接口,比较方便,或者可以直接使用axios().then()直接调用的方式也可以

//api.ts
import { request } from "./index"
export function uploadFileApi(data: any) {
  return request({
    url: "/upload",
    method: "post",
    params: data,
  })
}

创建upload组件

<template>
   <el-upload
    v-model:file-list="fileList"
    class="upload-demo"
    multiple
    :on-preview="handlePreview"
    :on-remove="handleRemove"
    :before-remove="beforeRemove"
    :limit="3"
    :on-exceed="handleExceed"
    :before-upload="beforeUpload"
    :http-request="httpRequest"
  >
    <el-button type="primary">点击上传</el-button>
    <template #tip>
      <div class="el-upload__tip">
        jpg/png files with a size less than 500KB.
      </div>
    </template>
  </el-upload>
</template>
<script lang="ts" setup>
//文件预览
const handlePreview: UploadProps["onPreview"] = (uploadFile) => {
  console.log(uploadFile)
}
//文件移除事件
const handleRemove: UploadProps["onRemove"] = (file, uploadFiles) => {
  console.log(file, uploadFiles)
}
//文件移除前事件
const beforeRemove: UploadProps["beforeRemove"] = (uploadFile, uploadFiles) => {
  return ElMessageBox.confirm(
    `Cancel the transfert of ${uploadFile.name} ?`
  ).then(
    () => true,
    () => false
  )
}
//文件大小超出限制事件
const handleExceed: UploadProps["onExceed"] = (files, uploadFiles) => {
  ElMessage.warning(
    `The limit is 3, you selected ${files.length} files this time, add up to ${
      files.length + uploadFiles.length
    } totally`
  )
}
//上传前钩子钩子函数
const beforeUpload: UploadProps["beforeUpload"] = async (uploadFile) => {}
//自定义上传方法
const httpRequest = async function (options: UploadRequestOptions) {}
</script>

App.vue中引入组件

搭建服务端

src同级目录下新建server目录,新建index.cjs,使用koa创建服务端,使用koa-body处理参数信息,使用koa-router创建服务端路由,使用koa-cors解决服务端跨域问题

//index.cjs
const Koa = require("koa")
const { koaBody } = require("koa-body")
const cors = require("koa-cors")

const router = require("./routes.cjs")
//读取流
const app = new Koa()

app
  .use(
    koaBody({
      multipart: true,
      formidable: {
        maxFileSize: 200 * 1024 * 1024, // 设置上传文件大小最大限制,默认2M
      },
    })
  )
  .use(router.routes())
  .use(cors())
  .listen(3000)

路由信息配置

const Router = require("koa-router")
//引入路由
const router = new Router()

module.exports = router

package.json中配置server快速启动命令。

{
   "serve": "node ./server/index.cjs", 
}

严格验证文件格式和大小

通常我们会用后缀名来验证文件格式,但是这样是不准确的,其实每种类型的文件读取为二进制文件时都有特定的标头,参考

代码实现,在上传前钩子里面进行校验

//util.js
type UtilsTypes = {
  readBuffer(file: File, start: number, end: number): Promise<any>
  //string和String有区别,String被认为是一个类
  getFileSuffix(unit8Array: Uint8Array): string
}

//截取文件流的后缀
const Utils: UtilsTypes = {
  readBuffer(file, start = 0, end = 2) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.onload = () => {
        resolve(reader.result)
      }

      reader.onerror = () => {
        reject()
      }

      reader.readAsArrayBuffer(file.slice(start, end))
    })
  },
  //根据截取的文件流内容获取文件后缀
  getFileSuffix(unit8Array) {
    let suffix = ""
    switch (unit8Array.join(" ")) {
      case "137 80 78 71 13 10 26 10":
        suffix = ".png"
        break
      case "47 49 46 38 39(37) 61":
        suffix = "gif"
        break
      case "ff d8 ff":
        suffix = "jpeg"
        break
      case "ff d9 ff":
        suffix = ".jpg"
        break
      case "ff d9 ff":
        suffix = ".jpg"
        break
      default:
        break
    }
    return suffix
  },
}
export default Utils


文件大小的判断,根据文件里面的属性size,可以读取到这个属性,然后和规定的文件大小做判断就可以了

import { ref, toRaw } from "vue"
import {
  ElMessage,
  ElMessageBox,
  ProgressProps,
  UploadFile,
  UploadRequestOptions,
} from "element-plus"
import Utils from "../util"
//可接受文件类型
const accepts = [".png"]
//单位M
const size = 10
const beforeUpload: UploadProps["beforeUpload"] = async (uploadFile) => {
  // console.log(Utils.readBuffer(uploadFile, 0, 8))
  //读取文件验证文件后缀,防止通过修改文件名更新文件格式
  const fileBufferPrefix = await Utils.readBuffer(uploadFile, 0, 8)

  const unit8Array = new Uint8Array(fileBufferPrefix)

  const fileSuffix = Utils.getFileSuffix(unit8Array)
  //文件后缀判断
  if (!fileSuffix || !accepts.includes(fileSuffix)) {
    ElMessage(`请选择支持的文件格式${accepts.join("、")}`)
    return false
  }
  
    //文件大小判断
  const isLt10M = uploadFile.size / 1024 / 1024 < size
  if (!isLt10M) {
    ElMessage(`请选择小于${size}M的文件进行上传!`)
    return false
  }
  
}

实现上传百分比

axios里面提供的有一个方法onUploadProgress,上传处理进度事件,可以监听到上传进度

代码实现

vue部分实现,需要给上传文件加一个进度条


  <!--文件上传百分比-->
  <el-progress
    v-if="showProgress"
    :percentage="uploadPercentage"
    status="success"
  />

js部分实现

//进度条
let uploadPercentage = ref<Number>(0)
//控制是否展示进度条
let showProgress = ref<Boolean>(false)
const httpRequest = function (options: UploadRequestOptions) {
  let formData = new FormData()
  formData.append("uploadFile", options.file)
  axios({
    url: "/api/upload",
    method: "post",
    data: formData,
    // `onUploadProgress` 允许为上传处理进度事件
    onUploadProgress: function (progressEvent) {
      const { loaded, total = 0 } = progressEvent
      uploadPercentage.value = (loaded / total) * 100
      showProgress.value = true
    },
  }).then(async (res) => {
    if (res.status) {
      ElMessage(`上传成功!`)
      //上传成功后将进度条置为0,隐藏进度条
      showProgress.value = false
      uploadPercentage.value = 0
    }
  })
}

上传相同文件处理

使用spark-md5根据文件内容生成唯一hash值,代码实现,在httpRequest里面,处理上传文件名

const httpRequest = function (options: UploadRequestOptions) {
  let formData = new FormData()
  formData.append("uploadFile", options.file)
  console.log(options.file)
  const name = options.file.name
  const suffix = name.split(".")[1]

  const buffer = getBuffer(options.file)
  //读取到文件对象,将文件名替换为hash值的文件名
  const spark = new sparkMd5.ArrayBuffer()
  spark.append(buffer)
  console.log(spark.end())
  const fileName = spark.end()
  // options.file.name = fileName

  formData.append("fileName", `${fileName}.${suffix}`)
  //文件上传axios请求内容
}

后端upload接口实现

//request.cjs
module.exports = {
  //上传文件
  upload: "/upload",
  //上传切片
  uploadChunks: "/uploadChunks",
  //合并文件
  mergeFiles: "/mergeFiles",
  //新-切片上传
  uploadChunksNew: "/uploadChunksNew",
  //合并文件
  mergeFilesNew: "/mergeFilesNew",
  //检查合并后得文件
  checkUploaded: "/checkUploaded",
}

通过koabody接收上传的文件信息,不做重命名的时候koa-body里面会自己做处理的,所以我们需要自己根据文件内容生成一个hash值来处理上传相同文件这样的场景

//routes.cjs
const path = require("path")
//获取文件名
const sparkMd5 = require("spark-md5")
const requestApi = require("./request.cjs")
const { koaBody } = require("koa-body")
router.post(
  requestApi.upload,
  koaBody({
    multipart: true, //支持多文件上传
    formidable: {
      uploadDir: path.join(__dirname, "./upload"),
      onFileBegin: (name, file) => {
        //如果不会自动做上传内容重复处理的,可以自己进行判断
        //判断文件是否存在
        fs.stat(
          path.join(__dirname, `./upload/${fileName}.${suffix}`),
          function (err, stats) {
            if (!stats) {
              //文件处理操作
              //会自动进行去重处理
              const spark = new sparkMd5.ArrayBuffer()
              spark.append(file)
              const fileName = spark.end()
              // console.log(fileName)

              const suffix = file.originalFilename.split(".")[1]
              const newFileName = file.filepath.slice(
                file.filepath.indexOf("upload") + 7
              )

              file.filepath = file.filepath.replace(
                newFileName,
                `${fileName}.${suffix}`
              )
            } else {
              return false
            }
          }
        )
      },
      onError: (error) => {
        //上传失败处理
      },
    },
  }),
  async (ctx) => {
    const body = ctx.request.body
    const files = ctx.request.files.uploadFile

    //读取文件并且保存文件
    ctx.response.body = {
      status: true,
      message: "操作成功",
      result: {
        fileName: files.newFilename,
      },
    }
  }
)

文件预览

上传成功后,预览文件,实际情况可能返回一个url链接,直接给el-image组件赋值就可以了,我自己模拟的没有用这个,就直接读取文件然后赋值了

//图片预览地址
let imgUrl = ref<String>("")
//文件预览地址
let previewList = ref<String[]>([])
//上传成功后的处理里面
   const imageUrl = await previewImage(options.file)
   imgUrl.value = imageUrl
   previewList.value = [imageUrl]
      
 //文件预览
function previewImage(file: File): Promise<string> {
  return new Promise((resolve) => {
    let fileReader = new FileReader()
    fileReader.readAsDataURL(file)
    fileReader.onload = (ev) => {
      resolve(ev.target!.result as string)
    }
  })
}     

切片上传

读取文件,将文件按照大小进行切片,然后对每个块进行标识,然后上传,然后再进行合并文件,我将读取到的切块的文件放在temp目录,然后合并完成后将所有的片再删除

// 单位kb
const is10Kb = 20 * 1024
const fileList = ref<UploadUserFile[]>([])
let sliceFiles = ref<BlobFile[]>([])
interface BlobFile {
  chunk: Blob
  fileName: string
}
//切片上传
const httpRequest = async function (options: UploadRequestOptions) {
  let originFileName = options.file.name
  const fileArr = sliceFiles.value
  let filename = ""
  fileArr.forEach(async (item: BlobFile, index: number) => {
    const { chunk, fileName } = item
    filename = fileName
   
      const formData = new FormData()
      formData.append("chunk", chunk)
      formData.append("fileName", fileName)
      console.log(chunk)
      axios({
        url: "/api/uploadChunksNew",
        method: "post",
        data: formData,
        headers: {},
      }).then((res) => {
        //合并文件
      
        mergeFiles(filename, originFileName)

      })
      // })

  })
}

//合并文件
function mergeFiles(fileName: string, originFileName: string) {
  axios({
    url: "/api/mergeFilesNew",
    method: "post",
    data: {
      fileName: fileName,
      originFileName,
    },
  }).then((res) => {
    ElMessage(`合并文件成功!`)
  })
}

切片上传后端实现

const TEMP_DIR = path.resolve(__dirname, "temp")
//切片上传
router.post(requestApi.uploadChunksNew, async (ctx) => {
  const file = ctx.request.files.chunk
  const fileName = ctx.request.body.fileName

  const chunkPath = path.join(TEMP_DIR, `${fileName}`)
  await fs.promises.copyFile(file.path || file.filepath, chunkPath)
  await fs.promises.unlink(file.path || file.filepath)

  ctx.body = {
    success: true,
  }
})

合并文件后端实现


//合并文件
router.post(requestApi.mergeFilesNew, async (ctx, next) => {
  const fileName = ctx.request.body.fileName.split("-")[0]
  const fileArr = Number(ctx.request.body.fileName.split("-")[1])
  //原始文件名称
  const originFileName = ctx.request.body.originFileName

  const suffix = originFileName.split(".")[1]
  //读取改文件夹下得文件
  const filePath = path.join(__dirname, `./temp`)
  const outputPath = path.join(__dirname, "./upload", fileName)

  fs.readdir(filePath, async function (err, files) {
    if (err) throw err
    if (files.length !== fileArr) {
      ctx.response.body = {
        message: "文件长度不一致",
        status: false,
      }
    }

    files.sort().forEach((filename, index) => {
      const fullFilePath = path.join(filePath, `${filename}`)
      fs.stat(fullFilePath, (err, stats) => {
        if (err) throw err
        const isFile = stats.isFile()
        if (isFile) {
          fs.appendFileSync(
            path.join(__dirname, `./upload/${fileName}.${suffix}`),
            fs.readFileSync(path.join(__dirname, `./temp/${filename}`))
          )
          //合并完之后删除temp下得数据
          fs.unlinkSync(path.join(__dirname, `./temp/${filename}`))
        }
      })
    })

    ctx.response.body = {
      status: true,
      message: "合并完成",
      result: null,
    }
  })
})

断点续传

写一个检查该切片是否已经上传的方法,如果判断已经上传就跳过,没有上传就执行uploadchunk方法

//切片上传
const httpRequest = async function (options: UploadRequestOptions) {
  let originFileName = options.file.name
  const fileArr = sliceFiles.value
  let filename = ""

  fileArr.forEach(async (item: BlobFile, sliceFilesindex: number) => {
    const { chunk, fileName } = item
    filename = fileName
    const isExist = await checkUploadedChunks(fileName)
    //文件名
    if (!isExist) {
      const formData = new FormData()
      formData.append("chunk", chunk)
      formData.append("fileName", fileName)
      console.log(chunk)
      debugger
      axios({
        url: "/api/uploadChunksNew",
        method: "post",
        data: formData,
        headers: {},
      }).then((res) => {
        //合并文件
        if (index === fileArr.length - 1 && res.status) {
          mergeFiles(filename, originFileName)
        }
      })
      // })
    }
  })
}

//断点续传,检查上传项
function checkUploadedChunks(chunkName: string): Promise<boolean> {
  return new Promise((resolve) => {
    axios({
      url: "/api/checkUploaded",
      method: "post",
      data: {
        chunkName,
      },
    }).then((res) => {
      if (res.data.status) {
        resolve(res.data.result as boolean)
      }
    })
  })
}

后端检查上传方法

//断点续传
router.post(requestApi.checkUploaded, async (ctx) => {
  const originalChunkName = ctx.request.body.chunkName
  const chunkPath = path.join(__dirname, `./temp/${originalChunkName}`)
  try {
    await fs.promises.stat(chunkPath, (err, stats) => {
      if (err) throw err
      ctx.response.body = {
        status: true,
        result: true,
      }
    })
  } catch (error) {
    ctx.response.body = {
      status: true,
      result: false,
    }
  }
})

完整代码地址gitee

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,049评论 5 473
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,478评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,109评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,097评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,115评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,280评论 1 279
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,748评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,398评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,553评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,440评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,487评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,176评论 3 317
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,750评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,821评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,049评论 1 257
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,559评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,150评论 2 341

推荐阅读更多精彩内容