koa+vue 简单实现文件切片上传

130 阅读4分钟

不知道大家会不会和我一样,看到一篇好的干货,点开前很积极,点开后看一点后就不想看了,最后把它埋没在收藏夹(收藏===学会)里面,日积月累就会积压很多,所以这篇文章的输出——来自《陈旧的收藏集》

我的记忆告诉我好像是去年,有段时间各种的公众号、掘金的帖子都在说实现大文件上传、切片上传、断点续传,尤其是断点续传,听起来就很让人很兴奋,哇,好厉害,怎么做呀?原理是什么?今天我们来动手自己实现一个demo,理解它内部的原理

这篇文章的输出主要是以思路为主,代码贴上去会有很多不齐全,我觉得最好去看一下源码,也不会很多,本来想写小白教程的,写出来的话会很长,而且耐心的看下去也是个问题,所以选择了以思路这种方式去写,github地址

实现功能

  • 切片上传
  • 开始 / 暂停
  • 删除
  • 预览

背景

首先我们要了解一下为什么会有切片上传的需求,在网络传输的过程中,我们避免不了网络信号不好而导致请求超时,比如说上传1G的视频,默认上传方式的话网络一不好就会引发超时,那就避免不了要重新上传

但是如果是切片的话,就可以记录在哪一部分出问题了,到时候就可以从这部分开始重新上传,而且传输的速度也会快很多,以上是我的理解

具体实现

背景了解后,我们先来实现前端部分,先了解一下什么是File

File

MDN的描述说File 是特殊的Blob 类型

image.png

切片

既然这样Blob 类型里面是有一个slice 方法的,这样就可以实现切片了

// 前端

// 生成每3个一组 chunks
const generateChunks = (file: File, size: number) => {
  const totalChunks: chunk[][] = []
  const fileChunks: chunk[] = []

  // 切片 根据 size 切成想要的大小
  // hash 是为了记录切片的位置
  for (let [cur, index] = [0, 0]; cur < file.size; cur += size) {
    fileChunks.push({ hash: index++, chunk: file.slice(cur, cur + size) })
  }

  // 分成3组发一次请求,为了点段续传
  for (let i = 0; i < fileChunks.length; i += 3) {
    totalChunks.push(fileChunks.slice(i, i + 3))
  }

  return { totalChunks, total: fileChunks.length }
}

最终切出来的格式是这样的,接下来就开始发送请求了

image.png

发送请求

这边传的参数稍微有点简单,不过也能实现

先说一下为什么分成一组一组去发送请求

  1. 为了性能优化,如果切片有100个的话就要for 循环100个请求,如果分成一组一组的话,可以等待一组接受完再去发送下一组减少性能消耗
  2. 当上传失败的时候,就可以从这一组重新上传,可以当做前端记录失败点,然后再从这个位置去上传,这就是断点上传的
// 发送请求
const chunkRequest = async (map: chunk[][], name: string, total: number) => {
    const [firstArr, ...rest] = map

    if (!firstArr) return

    const requestMap = firstArr.map(item => {
      const formData = new FormData()
      formData.append('filename', name)
      formData.append('hash', String(item.hash))
      formData.append('chunk', item.chunk)
      formData.append('total', String(total))
      return fetch('<http://localhost:3000/upload>', {
        method: 'POST',
        signal: fileMap[name].controller.signal, // 中断请求
        body: formData
      })
    })

    try {
      await Promise.all(requestMap)

      // 计算进度条
      const hash = firstArr.at(-1)!.hash + 1
      fileMap[name].propress = Math.floor((100 * hash) / total)

			// 递归
      chunkRequest(rest, name, total)
    } catch (err) {
      console.log(err, 'er')
    }
  }

现在来写一下后端部分, 后端是用koa 写的,这里主要展示一下流程,细节的东西可以去看一下源码,跑起来运行一下,相信你很快就会理解

// 后端

// 上传
router.post('/upload', async ctx => {
// 这里用到了 @koa/cors koa-body
  const { filename, hash, total, cur } = ctx.request.body
  const filepath = ctx.request.files.chunk.filepath
  ctx.body = {
    code: 200,
    filename,
    total,
    cur,
    msg: '上传成功'
  }
// 这里用了 Map储存上传的临时文件路径 
  addStore(filename, obj => {
    obj.total = total
    obj.chunk[hash] = filepath
  })
})

// --------------------------------- ./utils/store.js

export const store = new Map()

export const addStore = (key, cb) => {
  const hasKey = store.has(key)
  if (!hasKey) {
    store.set(key, {
      total: 0,
      chunk: {}
    })
  }
  const keyValue = store.get(key)
  cb(keyValue)

  const { chunk, total } = keyValue
  const list = Object.values(chunk)

  if (list.length === total * 1) {
		// 主要通过管道流来写入
    const writer = createWriteStream(initPath(UPLOAD_DIR, key))
    streamWrite(key, list, writer)
  }
}

// 这是核心 上传文件的拼接
const streamWrite = (key, list, writer) => {
  const [path, ...rest] = list
  if (!path) return

  const reader = createReadStream(path)
	// end:false 很重要 能解决文件生成残缺,内存泄漏问题
  reader.pipe(writer, { end: false })
  reader.on('end', () => {
    streamWrite(key, rest, writer)
  })
}

到这里就可以实现一个完整的切片上传了,接下来实现断点续传

断点续传

先来实现个开始暂停功能,原理其实就是中断请求,然而fetch 里面是没有自带中断的,只能需要依靠 AbortController ,搞笑的是这里只要中断之后,再重新请求就会发不出,这里是MDN的描述,因为它是只读的,所以需要重新new一下实例,让它的状态消失

image.png

// 前端

// 点击开始暂停
  const handleStartAndStop = async (data: file) => {
    const { name } = data
    data.isStop = !data.isStop

    if (data.isStop) {
      data.controller.abort()
      data.controller = new AbortController()
      return
    }
		// 获取到最后上传的 hash位置重新请求 后端的话不需要改
    reloadUpload(name)
  }

预览

这个比较简单,只需后端返回一个路径,前端打开就好了

// 后端

import serve from 'koa-static'
// 开启静态服务文件
app.use(serve(initPath(UPLOAD_DIR)))

// 预览
router.post('/preview', ctx => {
  const { name } = JSON.parse(ctx.request.body)
  ctx.body = {
    code: 200,
    data: {
      url: `http://localhost:3000/${name}`
    }
  }
})

最后

有不懂的可以留言,希望大家可以动手实现,动手===学会,谢谢观看