美文网首页Vue
2023.13 node+koa+element-plus+ts

2023.13 node+koa+element-plus+ts

作者: wo不是黄蓉 | 来源:发表于2023-04-01 15:10 被阅读0次

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

    首先做一个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

    相关文章

      网友评论

        本文标题:2023.13 node+koa+element-plus+ts

        本文链接:https://www.haomeiwen.com/subject/shokddtx.html