NoteZ_技术博客 NoteZ_技术博客
🏠 首页
  • 📚 Web技术
  • 📋 Npm笔记
  • 📑 Markdown
  • 📄 Git笔记
  • 📝 Nginx文档
  • 📓 Linux文档
  • 📖 技术文档
  • 📜 其他文档
  • 🧊 NodeJs
  • 🎡 Express
  • 🔥 Rust
  • 🎉 Koa2
  • 🍃 MongoDB
  • 🐬 MySql
  • 🥦 Oracle
  • 🍁 Python
  • 🍄 JavaScript
  • 🌰 CSS
  • 🧄 HTML
  • 🥑 Canvas
  • 🌽 Nuxt
  • 🍆 React
  • 🥜 Vue
  • 🧅 TypeScript
  • 🌶️ AI
  • 📘 分类
  • 📗 标签
  • 📙 归档
⚜️ 在线编辑 (opens new window)
  • 📁 站点收藏
  • 📦 前端组件库
  • 📊 数据可视化
  • 🌈 开源插件
  • 🎗️ 关于我
  • 🔗 友情链接
GitHub (opens new window)

NoteZ_技术博客

前端界的小学生
🏠 首页
  • 📚 Web技术
  • 📋 Npm笔记
  • 📑 Markdown
  • 📄 Git笔记
  • 📝 Nginx文档
  • 📓 Linux文档
  • 📖 技术文档
  • 📜 其他文档
  • 🧊 NodeJs
  • 🎡 Express
  • 🔥 Rust
  • 🎉 Koa2
  • 🍃 MongoDB
  • 🐬 MySql
  • 🥦 Oracle
  • 🍁 Python
  • 🍄 JavaScript
  • 🌰 CSS
  • 🧄 HTML
  • 🥑 Canvas
  • 🌽 Nuxt
  • 🍆 React
  • 🥜 Vue
  • 🧅 TypeScript
  • 🌶️ AI
  • 📘 分类
  • 📗 标签
  • 📙 归档
⚜️ 在线编辑 (opens new window)
  • 📁 站点收藏
  • 📦 前端组件库
  • 📊 数据可视化
  • 🌈 开源插件
  • 🎗️ 关于我
  • 🔗 友情链接
GitHub (opens new window)
  • Express

  • Koa2

  • MongoDB

  • MySql

  • NodeJs

    • 2021 年值得使用的 Node.js 框架
    • CentOS 7上安装 Node.js 的 4 种方法
    • Linux 下安装 NodeJs 以及版本的升级和降级
    • node 中 path 模块 normalize 函数格式化路径
    • Node 中 使用 fs.stat() 读取文件状态
    • Node 中使用 compressing 压缩文件夹为 zip 文件
    • Node 中的实现 EventEmitter (event)方法
    • Node 代理 Demo 示例
    • node 判断文件或文件夹是否存在
    • node 复制文件的五种方式
    • Node 常用模块之 fs-extra
    • node 操作 mongodb 数据库备份与还原-示例代码
    • node 获取请求 ip
    • Node 读取和写入 json 文件
    • Node 递归或 mkdirp 创建多层目录
    • Node 遍历目录输出树形文件目录结构
    • Node(publish-sftp)命令上传本地文件到服务器
    • Node.js 中使用 compressing 实现zip文件的解压,解决文件名中文乱码
    • node.js 读取文件目录下的所有读取文件目录
    • Node.js把前台传来的 base64 码转成图片
    • nodeJs + vueJs 实现大文件分片上传
      • 简单上传
      • 优化思路
      • 异步并发控制
      • 唯一文件ID
      • 文件切分
      • 小文件合并的顺序
      • 续传
      • 合并文件
      • 进阶优化
      • 完整代码
      • Dome地址
      • 参考文档
    • NodeJs 使用 jszip 或者 zip-dir 压缩文件夹(zip)
    • NodeJs 利用 jszip 压缩文件、文件夹,以及解压压缩文件中的文件
    • NodeJs 实现简单 WebSocket 即时通讯
    • NodeJs 框架 Express 的两种使用方式
    • NodeJs与Nginx获取客户端真实IP方法
    • NodeJs之大文件断点下载
    • NodeJS使用node-fetch下载文件并显示下载进度
    • NodeJs将图片进行压缩生成缩略图
    • nodejs递归读取所有文件
    • Node中复制文件的四种方法总结
    • node中的读取流createReadStream、写入流createWriteStream和管道流pipe
    • Node开发笔记
    • Node递归创建多层目录并写入文件
    • Node递归创建文件夹
    • PM2配置文件的使用、管理多个Node.js项目
    • 使用 koa-generator 快速搭建 Koa2 项目
    • 使用 pm2 部署 nodejs 项目
    • 使用 supervisor 自动重启 NodeJs 提高开发效率
    • 使用node反向代理解决跨域问题
    • 使用node实现保存(上传)图片的功能
    • 使用Node搭建超高压缩比的图片(webp)压缩服务
    • 前端部署从静态到node再到负载均衡
    • 在 NodeJs 中使用 compressing 压缩和解压缩文件或文件夹
    • 在 nodejs 执行 shell 指令
    • 基于 Node 生成 vue 模板文件
    • 如何使用 Nodejs 备份 MongoDB 数据
    • 如何使用NodeJs实现base64和png文件相互转换
    • 如何在 Linux 服务器上使用 Nodejs 连接远程 Oracle 数据库
    • 混淆、编译 Node.js 源代码的几种方法
    • Node.js包之archiver压缩打包文件或目录为zip格式
    • node.js获取目录内所有文件大小总和
    • NodeJs 中使用 resize-img 制作缩略图
    • nodeJS中的http模块基本介绍
    • nodejs图片处理工具gm用法
    • node使用ffmpeg将swf转mp4 截取mp4视频第一帧为jpg图片
    • 如何在Node.js中执行shell命令
    • NodeJs 中复制(拷贝)文件或文件夹的多种方式
    • 在 NodeJs 中使用 archiver 压缩超大文件夹
    • Node.js 中使用 ssh2-sftp-client 上传文件并实时获取速率大小和进度
    • Node.js 中使用 ssh2-sftp-client 上传文件到服务器示例
    • node 使用 ssh2-sftp-client 实现 FTP 的文件上传和下载功能
    • ssh2-sftp-client 上传文件夹时获取上传速度和文件夹大小
    • 基于 node 使用 UDP 上传文件示例
    • 利用Node.js监控文件变化并使用sftp上传到服务器
    • 利用 Node 监控文件夹或文件夹变化可用的 npm 包汇总
    • NodeJS获取当前目录、运行文件所在目录、运行文件的上级目录
    • Node与GLIBC_2.27不兼容解决方案
  • Oracle

  • Rust

  • Python

  • 后端开发
  • NodeJs
NoteZ
2020-07-11
目录

nodeJs + vueJs 实现大文件分片上传

在常规的应用场景中,很少需要在浏览器上传几百兆、几千兆的文件,但假如在特殊场景中需要浏览器上传超大文件,那么我们如何上传?如何优化?总体来说,主要还是因为上传时间太长容易引发不可控的意外:

  • 网络波动无法控制
  • 想暂停,如何续传
  • 先来一个简单上传

1664352179293-1.png

# 简单上传

前端代码:

<!-- App.vue 页面模板 -->
<template>
 <div>
   <input type="file" @change="uploadFile">
 </div>
</template>
1
2
3
4
5
6
// axios/index.js axios 请求
import Axios from 'axios'

const Server = Axios.create({
 baseURL: '/api'
})

export default Server

// main.js
import Vue from 'vue'
import App from './App.vue'

import Axios from './axios'

Vue.config.productionTip = false
Vue.prototype.$http = Axios

new Vue({
 render: h => h(App),
}).$mount('#app')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// App.vue js
<script>
export default {
  methods: {
    // input改变事件监听
    uploadFile(e) {
      const file = e.target.files[0]
      this.sendFile(file)
    },
    // 文件上传方法
    sendFile(file) {
      let formdata = new FormData()
      formdata.append("file", file)

      this.$http({
        url: "/upload/file",
        method: "post",
        data: formdata,
        headers: { "Content-Type": "multipart/form-data" }
      }).then(({ data }) => {
        console.log(data, 'upload/file')
      })
    },
  }
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

nodeJs:

const Koa = require('koa')
const router = require('koa-router')() // koa路由模块
const koaBody = require('koa-body') //解析文件上传的插件
const fs = require('fs') // nodeJs内置文件模块
const path = require('path') // nodeJs内置路径模块

const uploadPath = path.join(__dirname, 'public/uploads') // 定义文件上传目录

// 如果初始没有改文件目录,则自动创建
if (!fs.existsSync(uploadPath)) {
 fs.mkdirSync(uploadPath)
}

const app = new Koa() // 实例化

// 一些自定义的全局请求处理
app.use(async (ctx, next) => {
 console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);

 if (ctx.request.method === 'OPTIONS') {
   ctx.status = 200
 }

 try {
   await next();
 } catch (err) {
   ctx.status = err.statusCode || err.status || 500
   ctx.body = {
     code: 500,
     msg: err.message
   }
 }
})

// 加载文件上传中间件
app.use(koaBody({
 multipart: true,
 formidable: {
   // keepExtensions: true, // 保持文件后缀
   uploadDir: uploadPath, // 初始指定文件存放地址,否则将会放入系统临时文件目录
   maxFileSize: 10000 * 1024 * 1024    // 设置上传文件大小最大限制,默认20M
 }
}))

// 文件上传处理
function uploadFn(ctx) {
 return new Promise((resolve, reject) => {
   const { name, path: _path } = ctx.request.files.file // 拿到上传的文件信息
   const filePath = path.join(uploadPath, name) // 重新组合文件名

   // 将临时文件重新设置文件名及地址
   fs.rename(_path, filePath, (err) => {
     if (err) {
       return reject(err)
     }
     resolve(name)
   })
 })
}

// 文件上传接口
router.post('/api/upload/file', async function uploadFile(ctx) {
 await uploadFn(ctx).then((name) => {
   ctx.body = {
     code: 0,
     url: path.join('http://localhost:3000/uploads', name),
     msg: '文件上传成功'
   }
 }).catch(err => {
   ctx.body = {
     code: -1,
     msg: '文件上传失败'
   }
 })
})

app.use(router.routes())

// 用端口3000启动服务
app.listen(3000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

以上为一个简单的文件上传,前端用一个input元素上传文件,然后使用ajax将整个文件传输给后端,后端接收文件并保存在服务器。

# 优化思路

  • 既然小文件的上传处理没有问题,而大文件有不可预料的问题和体验,我们不能控制用户的网路状态和用户的想法,那么我们换个思路,可以把整个大的文件在前端分割成一个个小文件,然后再由前端将这些小文件一个个上传,等所有的小文件上传完成之后,再通知后端将这些小文件合并成大文件。如果用户在上传过程中突然断网或者想暂停,当用户再次上传之后,之前已上传的直接跳过,只上传之前没有上传的那些内容。

但是这里又会出现新的问题:

  • 如何确定不同的文件上传不会错乱
  • 大文件分割成很多个小文件,再由小文件合并成大文件时,顺序需要与分割的一样
  • 在上传之前,如何知道哪些有上传完,哪些没有上传

在尝试解决这些问题之前,我们还有一个加速上传的优化:异步并发控制。

听到并发这个词,大家在脑海里的第一反应是后端服务器在同一时间对大量请求的同时响应。但是在 http 进入 1.1 版本后,浏览器处理请求的时候实现了TCP并发请求,2.0 实现了HTTP并发处理,而且现在的浏览器 http 版本基本都是 1.1 版本后的,所以我们可以利用这个机制,对于前端大量的请求,可以对请求进行并发处理。

正好大文件上传需要分割成几十上百个小文件上传请求,与前端并发控制需要的场景完美嵌合,加上并发处理,达到加速整个文件的上传。

# 异步并发控制

先来熟悉前端对于请求的异步并发控制的代码实现:

/**
 * 异步并发控制
 * arr {Array} 异步任务队列
 * max {Number} 允许同时执行的最大任务数
 * callback {Function} 所有任务完成之后的回调函数
 */
function sendRequest(arr, max = 5, callback) {
  let i = 0 // 数组下标
  let fetchArr = [] // 正在执行的请求

  let toFetch = () => {
    // 如果异步任务都已开始执行,剩最后一组,则结束并发控制
    if (i === arr.length) {
      return Promise.resolve()
    }

    // 执行异步任务
    let it = fetch(arr[i++])
    // 添加异步事件的完成处理
    it.then(() => {
      fetchArr.splice(fetchArr.indexOf(it), 1)
    })
    // 添加新任务
    fetchArr.push(it)

    let p = Promise.resolve()
    // 如果并发数达到最大数,则等其中一个异步任务完成再添加
    if (fetchArr.length >= max) {
      p = Promise.race(fetchArr)
    }

    // 执行递归
    return p.then(() => toFetch())
  }

  toFetch().then(() => 
    // 最后一组全部执行完再执行回调函数
    Promise.all(fetchArr).then(() => {
      callback()
    })
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

异步并发控制的原理:先创建一个任务队列,任务队列的容量可以自己设立,然后结合Promise等来监听每一个异步任务。如果队列没满就直接将新的任务放进去,然后设置一下该任务的完成的回调操作:当该任务完成时,将当前任务从任务队列中移除,并继续往任务队列中添加一个待处理的任务,这样我们可以时刻保持满队列执行。直到所有待处理任务都已处理完成,最后再执行所有任务处理完成的回调函数。

# 唯一文件ID

我们需要确定每个文件都有一个唯一的id,这样上传到后端才能确定文件不会错乱,如何为每个文件生成一个唯一的id,我们可以使用一个现有的插件:SparkMD5。 用 SparkMD5 生成文件 id 后,在服务器上以 id 为名称生成一个文件夹来存放所有切分的小文件。

import SparkMD5 from 'spark-md5'

export default {
  methods: {
    uploadFile(e) {
      const file = e.target.files[0]
      this.createFileMd5(file).then(fileMd5 => {
        // fileMd5 为文件唯一的id,只要文件内容没变,那这id就不会变
        console.log(fileMd5, 'md5')
      })
    },
    createFileMd5(file) {
      return new Promise((resolve, reject) => {
        const spark = new SparkMD5.ArrayBuffer()
        const reader = new FileReader()
        reader.readAsArrayBuffer(file)
        
        reader.addEventListener('loadend', () => {
          const content = reader.result
          spark.append(content)
          const hash = spark.end()
          resolve(hash, content)
        })
        
        reader.addEventListener('error', function _error(err) {
          reject(err)
        })
      })
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

# 文件切分

第一步就是需要将大文件切分成一个个小文件,然后将这些小文件依次全部上传到后端。

// 文件分割
cutBlob(fileHash, file) {
  const chunkArr = [] // 所有切片缓存数组
  const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
  const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片总数

  return new Promise((resolve, reject) => {
    let startIndex = ''
    let endIndex = ''
    let contentItem = ''

    for(let i = 0; i < chunkNums; i++) {
      startIndex = i * this.chunkSize // 片段起点
      endIndex = (i + 1) * this.chunkSize // 片段尾点
      endIndex > file.size && (endIndex = file.size)

      // 切割文件
      contentItem = blobSlice.call(file, startIndex, endIndex)

      chunkArr.push({
        index: i, // 当前文件片段顺序索引,一并传给后端确定顺序
        chunk: contentItem // 当前文件片段内容
      })
    }

    this.fileInfo = {
      hash: fileHash,
      total: chunkNums,
      name: file.name,
      size: file.size
    }
    resolve(chunkArr)
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

# 小文件合并的顺序

前端将大文件切割成小文件后,在上传的时候为小文件的请求加上一个递增的 index 参数,后端接收完数据生成文件的时候将index作为文件名后缀,在合并的时候根据文件名后缀数字的顺序来合并。

// 分片文件上传接口
router.post('/api/upload/snippet', async function snippet(ctx) {
  const { index, hash } = ctx.request.body

  // 切片上传目录
  const chunksPath = path.join(uploadPath, hash, '/')

  if(!fs.existsSync(chunksPath)) {
    fs.mkdirSync(chunksPath)
  }

  // 切片文件, index作为文件名后缀用来将来合并的时候确定顺序
  const chunksFileName = chunksPath + hash + '-' + index
  
  await uploadFn(ctx, chunksFileName).then(name => {
    ctx.body = {
      code: 0,
      msg: '切片上传完成'
    }
  }).catch(err => {
    console.log(err)
    ctx.body = {
      code: -1,
      msg: '切片上传失败'
    }
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 续传

当用户在上传过程中主动暂停或者网络断了,再次上传的时候,之前已上传完成的不应该再次上传,所以我们可以在每一次开始上传之前发送一个请求来获取该文件是否有上传过小文件,如果有则返回所有已上传的小文件的 index 前缀或者文件名。这样在前端上传时就可以过滤掉已上传的小文件。

// 前端js
// 请求已上传文件
getUploadedChunks(hash) {
  return this.$http({
    url: "/upload/checkSnippet",
    method: "post",
    data: { hash }
  })
}

// nodeJs
// 查询分片文件是否上传
router.post('/api/upload/checkSnippet', function snippet(ctx) {
  const { hash } = ctx.request.body

  // 切片上传目录
  const chunksPath = path.join(uploadPath, hash, '/')

  let chunksFiles = []

  if(fs.existsSync(chunksPath)) {
    // 切片文件
    chunksFiles = fs.readdirSync(chunksPath)
  }

  ctx.body = {
    code: 0,
    data: chunksFiles,
    msg: '查询成功'
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

# 合并文件

当所有文件片段上传完成之后,则需要通知后端在服务器进行合并。

// 前端js
// 请求合并
chunkMerge(data) {
  this.$http({
    url: "/upload/merge",
    method: "post",
    data,
  }).then(res => {
    console.log(res.data)
  })
}

// nodeJs
// 删除文件夹及内部所有文件
function deleteFiles(dirpath) {
  if (fs.existsSync(dirpath)) {
    fs.readdir(dirpath, (err, files) => {
      if (err) throw err
      while(files.length) {
        fs.unlinkSync(dirpath + files.shift())
      }
      fs.rmdir(dirpath, () => {})
    })
  }
}

/**
 * 文件异步合并
 * @param {String} dirPath 分片文件夹
 * @param {String} filePath 目标文件
 * @param {String} hash 文件hash
 * @param {Number} total 分片文件总数
 * @returns {Promise}
 */
function mergeFile(dirPath, filePath, hash, total) {
  return new Promise((resolve, reject) => {
    fs.readdir(dirPath, (err, files) => {
      if (err) {
        return reject(err)
      }
      if(files.length !== total || !files.length) {
        return reject('上传失败,切片数量不符')
      }

      const fileWriteStream = fs.createWriteStream(filePath)
      function merge(i) {
        return new Promise((res, rej) => {
          // 合并完成
          if (i === files.length) {
            fs.rmdir(dirPath, (err) => {
              console.log(err, 'rmdir')
            })
            return res()
          }
          const chunkpath = dirPath + hash + '-' + i
          fs.readFile(chunkpath, (err, data) => {
            if (err) return rej(err)

            // 将切片追加到存储文件
            fs.appendFile(filePath, data, () => {
              // 删除切片文件
              fs.unlink(chunkpath, () => {
                // 递归合并
                res(merge(i + 1))
              })
            })
          })

        })
      }
      merge(0).then(() => {
        // 默认情况下不需要手动关闭,但是在某些文件的合并并不会自动关闭可写流,比如压缩文件,所以这里在合并完成之后,统一关闭下
        resolve(fileWriteStream.close())
      })
    })
  })
}

/**
 * 文件合并接口
 * 1、判断是否有切片hash文件夹
 * 2、判断文件夹内的文件数量是否等于total
 * 4、然后合并切片
 * 5、删除切片文件信息
 */
router.post('/api/upload/merge', async function uploadFile(ctx) {
  const { total, hash, name } = ctx.request.body
  const dirPath = path.join(uploadPath, hash, '/')
  const filePath = path.join(uploadPath, name) // 合并文件

  // 已存在文件,则表示已上传成功
  if (fs.existsSync(filePath)) {
    deleteFiles(dirPath) // 删除临时文件片段包
    ctx.body = {
      code: 0,
      url: path.join('http://localhost:3000/uploads', name),
      msg: '文件上传成功'
    }
  // 如果没有切片hash文件夹则表明上传失败
  } else if (!fs.existsSync(dirPath)) {
    ctx.body = {
      code: -1,
      msg: '文件上传失败'
    }
  } else {
    // 合并文件
    await mergeFile(dirPath, filePath, hash, total).then(() => {
      ctx.body = {
        code: 0,
        url: path.join('http://localhost:3000/uploads', name),
        msg: '文件上传成功'
      }
    }).catch(err => {
      ctx.body = {
        code: -1,
        msg: err
      }
    })
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120

# 进阶优化

对于越大的文件计算 hash 值则越久,这是有点瑕疵的。对于这一块可以继续优化:

利用 webWork 机制,在计算的时候新开一个线程单独做这件事,计算完成之后将结果返回给主线程。

// vue中需要安装 worker-loader
npm install worker-loader -D

// App.vue js
import Worker from './hash.worker.js'

createFileMd5(file) {
  return new Promise(() => {
    const worker = new Worker()

    worker.postMessage({file, chunkSize: this.chunkSize})

    worker.onmessage = event => {
      resolve(event.data)
    }
  })
}

// hash.worker.js
import SparkMD5 from 'spark-md5'

onmessage = function(event) {
  getFileHash(event.data)
}

function getFileHash({file, chunkSize}) {
  console.log(file, chunkSize)
  const spark = new SparkMD5.ArrayBuffer()
  const reader = new FileReader()
  reader.readAsArrayBuffer(file)

  reader.addEventListener('loadend', () => {
    const content = reader.result
    spark.append(content)

    const hash = spark.end()
    postMessage(hash)
  })

  reader.addEventListener('error', function _error(err) {
    postMessage(err)
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

采用抽样 hash 来计算,但是抽样方式会损失一定的精度,有可能将本不是一个文件生成了一个同样的 hash。

抽样方式的做法是,在大文件切分一个个小片段后,我们可以分别取每个片段的开头10个字符,中间10个字符,末尾10个字符(规则自定)等方式组合生成的 id。这种方式的效率是非常快的。

createFileMd5(file) {
  return new Promise((resolve, reject) => {
    const spark = new SparkMD5.ArrayBuffer()
    const reader = new FileReader()
    reader.readAsArrayBuffer(file)

    reader.addEventListener('loadend', () => {
      console.time('抽样hash计算:')
      const content = reader.result
      // 抽样hash计算
      // 规则:每半个切片大小取前10个
      let i = 0

      while(this.chunkSize / 2 * (i + 1) + 10 < file.size) {
        spark.append(content.slice(this.chunkSize / 2 * i, this.chunkSize / 2 * i + 10))
        i++
      }

      const hash = spark.end()
      console.timeEnd('抽样hash计算:')
      resolve(hash)
    })

    reader.addEventListener('error', function _error(err) {
      reject(err)
    })
  })
}

// 这是用一个9M大小的文件,切片大小为100K分别计算得到的时间,
// 全部内容hash计算:: 101.6240234375 ms
// 抽样hash计算:: 2.216796875 ms
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 完整代码

<!-- 页面代码 -->
<template>
  <div>
    <input type="file" @change="uploadFile">
  </div>
</template>
1
2
3
4
5
6

前端js处理App.vue js

<script>
import Worker from './hash.worker.js'

export default {
  data() {
    return {
      fileInfo: null,
      chunkSize: 100 * 1024 // 切片大小
    }
  },
  methods: {
    // input改变事件监听
    uploadFile(e) {
      const file = e.target.files[0]
      // 如果文件大小大于文件分片大小的5倍才使用分片上传
      if (file.size / this.chunkSize < 5) {
        this.sendFile(file)
        return
      }
      this.createFileMd5(file).then(async fileMd5 => {
        // 先查询服务器是否已有上传完的文件切片
        let {data} = await this.getUploadedChunks(fileMd5)
        let uploaded = data.data.length ? data.data.map(v => v.split('-')[1] - 0) : []
        // 切割文件
        const chunkArr = await this.cutBlob(fileMd5, file, uploaded)
        // 开始上传
        this.sendRequest(chunkArr, 5, this.chunkMerge)
      })
    },
    createFileMd5(file) {
      return new Promise((resolve) => {
        const worker = new Worker()

        worker.postMessage({file, chunkSize: this.chunkSize})

        worker.onmessage = event => {
          resolve(event.data)
        }
      })
    },
    // 文件分割
    cutBlob(fileHash, file, uploaded) {
      const chunkArr = [] // 所有切片缓存数组
      const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
      const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片总数

      return new Promise(resolve => {
        let startIndex = ''
        let endIndex = ''
        let contentItem = ''

        for(let i = 0; i < chunkNums; i++) {
          // 如果已上传则跳过
          if (uploaded.includes(i)) continue

          startIndex = i * this.chunkSize // 片段起点
          endIndex = (i + 1) * this.chunkSize // 片段尾点
          endIndex > file.size && (endIndex = file.size)

          // 切割文件
          contentItem = blobSlice.call(file, startIndex, endIndex)

          chunkArr.push({
            index: i,
            chunk: contentItem
          })
        }
        this.fileInfo = {
          hash: fileHash,
          total: chunkNums,
          name: file.name,
          size: file.size
        }
        resolve(chunkArr)
      })
    },
    // 请求并发处理
    sendRequest(arr, max = 6, callback) {
      let fetchArr = []

      let toFetch = () => {
        if (!arr.length) {
          return Promise.resolve()
        }

        const chunkItem = arr.shift()

        const it = this.sendChunk(chunkItem)
        it.then(() => {
          // 成功从任务队列中移除
          fetchArr.splice(fetchArr.indexOf(it), 1)
        }, err => {
          // 如果失败则重新放入总队列中
          arr.unshift(chunkItem)
          console.log(err)
        })
        fetchArr.push(it)

        let p = Promise.resolve()
        if (fetchArr.length >= max) {
          p = Promise.race(fetchArr)
        }

        return p.then(() => toFetch())
      }

      toFetch().then(() => {
        Promise.all(fetchArr).then(() => {
          callback()
        })
      }, err => {
        console.log(err)
      })
    },
    // 请求已上传文件
    getUploadedChunks(hash) {
      return this.$http({
        url: "/upload/checkSnippet",
        method: "post",
        data: { hash }
      })
    },
    // 小文件上传
    sendChunk(item) {
      if (!item) return
      let formdata = new FormData()
      formdata.append("file", item.chunk)
      formdata.append("index", item.index)
      formdata.append("hash", this.fileInfo.hash)
      // formdata.append("name", this.fileInfo.name)

      return this.$http({
        url: "/upload/snippet",
        method: "post",
        data: formdata,
        headers: { "Content-Type": "multipart/form-data" }
      })
    },
    // 文件上传方法
    sendFile(file) {
      let formdata = new FormData()
      formdata.append("file", file)

      this.$http({
        url: "/upload/file",
        method: "post",
        data: formdata,
        headers: { "Content-Type": "multipart/form-data" }
      }).then(({ data }) => {
        console.log(data, 'upload/file')
      })
    },
    // 请求合并
    chunkMerge() {
      this.$http({
        url: "/upload/merge",
        method: "post",
        data: this.fileInfo,
      }).then(res => {
        console.log(res.data)
      })
    }
  }
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
// hash.worker.js
import SparkMD5 from 'spark-md5'

onmessage = function(event) {
  getFileHash(event.data)
}

function getFileHash({file, chunkSize}) {
  const spark = new SparkMD5.ArrayBuffer()
  const reader = new FileReader()
  reader.readAsArrayBuffer(file)

  reader.addEventListener('loadend', () => {
    const content = reader.result
    // 抽样hash计算
    // 规则:每半个切片大小取前10个
    let i = 0

    while(chunkSize / 2 * (i + 1) + 10 < file.size) {
      spark.append(content.slice(chunkSize / 2 * i, chunkSize / 2 * i + 10))
      i++
    }

    const hash = spark.end()
    postMessage(hash)
  })

  reader.addEventListener('error', function _error(err) {
    postMessage(err)
  })
}

// 或者(推荐)
// import SparkMD5 from 'spark-md5'
self.importScripts("/calculate/spark-md5.min.js"); // 导入脚本

onmessage = function (event) {
  // getFileHash(event.data)
  let spark = new self.SparkMD5.ArrayBuffer()
  let chunkArr = event.data;


  let sliceArr = [chunkArr[0]]
  if (chunkArr.lenght > 1) {
    sliceArr.push(chunkArr[chunkArr.lenght - 1])
  }
  console.log(sliceArr);
  let chunkPromise = sliceArr.map(function (item) {
    return getFileReader(item.chunk)
  })

  Promise.all(chunkPromise).then(function (res) {
    res.forEach(content => {
      spark.append(content)
    });
    let hash = spark.end()
    console.log(hash);
    postMessage(hash)
    // Worker 线程
    self.close();
  }).catch(function (err) {
    console.log(err);
    postMessage()
    self.close();
  })

}

function getFileReader(file) {
  return new Promise(function (resolve, reject) {
    let reader = new FileReader()
    reader.readAsArrayBuffer(file)

    reader.addEventListener('loadend', function () {
      let content = reader.result;
      resolve(content)
    })

    reader.addEventListener('error', function (err) {
      reject(err)
    })
  })

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84

NodeJs处理:

const Koa = require('koa')
const router = require('koa-router')() // koa路由模块
const koaBody = require('koa-body') //解析文件上传的插件
const fs = require('fs') // nodeJs内置文件模块
const path = require('path') // nodeJs内置路径模块

const uploadPath = path.join(__dirname, 'public/uploads') // 定义文件上传目录

// 如果初始没有改文件目录,则自动创建
if (!fs.existsSync(uploadPath)) {
  fs.mkdirSync(uploadPath)
}

const app = new Koa() // 实例化

// 一些自定义的全局请求处理
app.use(async (ctx, next) => {
  console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);

  if (ctx.request.method === 'OPTIONS') {
    ctx.status = 200
  }

  try {
    await next();
  } catch (err) {
    ctx.status = err.statusCode || err.status || 500
    ctx.body = {
      code: 500,
      msg: err.message
    }
  }
})

// 加载文件上传中间件
app.use(koaBody({
  multipart: true,
  formidable: {
    // keepExtensions: true, // 保持文件后缀
    uploadDir: uploadPath, // 初始指定文件存放地址,否则将会放入系统临时文件目录
    maxFileSize: 10000 * 1024 * 1024    // 设置上传文件大小最大限制,默认20M
  }
}))

// 文件上传处理
function uploadFn(ctx, destPath) {
  return new Promise((resolve, reject) => {
    const { name, path: _path } = ctx.request.files.file // 拿到上传的文件信息
    const filePath = destPath || path.join(uploadPath, name) // 重新组合文件名

    // 将临时文件重新设置文件名及地址
    fs.rename(_path, filePath, (err) => {
      if (err) {
        return reject(err)
      }
      resolve(filePath)
    })
  })
}

// 查询分片文件是否上传
router.post('/api/upload/checkSnippet', function snippet(ctx) {
  const { hash } = ctx.request.body

  // 切片上传目录
  const chunksPath = path.join(uploadPath, hash, '/')

  let chunksFiles = []

  if(fs.existsSync(chunksPath)) {
    // 切片文件
    chunksFiles = fs.readdirSync(chunksPath)
  }

  ctx.body = {
    code: 0,
    data: chunksFiles,
    msg: '查询成功'
  }
})

// 分片文件上传接口
router.post('/api/upload/snippet', async function snippet(ctx) {
  const { index, hash } = ctx.request.body

  // 切片上传目录
  const chunksPath = path.join(uploadPath, hash, '/')

  if(!fs.existsSync(chunksPath)) {
    fs.mkdirSync(chunksPath)
  }

  // 切片文件
  const chunksFileName = chunksPath + hash + '-' + index
  
  await uploadFn(ctx, chunksFileName).then(name => {
    ctx.body = {
      code: 0,
      msg: '切片上传完成',
      data: name
    }
  }).catch(err => {
    ctx.body = {
      code: -1,
      msg: '切片上传失败',
      data: err
    }
  })
})

// 文件上传接口
router.post('/api/upload/file', async function uploadFile(ctx) {
  await uploadFn(ctx).then((name) => {
    ctx.body = {
      code: 0,
      url: path.join('http://localhost:3000/uploads', name),
      msg: '文件上传成功'
    }
  }).catch(err => {
    ctx.body = {
      code: -1,
      msg: '文件上传失败'
    }
  })
})

// 删除文件夹及内部所有文件
function deleteFiles(dirpath) {
  if (fs.existsSync(dirpath)) {
    fs.readdir(dirpath, (err, files) => {
      if (err) throw err
      // 删除文件
      while(files.length) {
        fs.unlinkSync(dirpath + files.shift())
      }
      // 删除目录
      fs.rmdir(dirpath, () => {})
    })
  }
}
/**
 * 文件异步合并
 * @param {String} dirPath 分片文件夹
 * @param {String} filePath 目标文件
 * @param {String} hash 文件hash
 * @param {Number} total 分片文件总数
 * @returns {Promise}
 */
function mergeFile(dirPath, filePath, hash, total) {
  return new Promise((resolve, reject) => {
    fs.readdir(dirPath, (err, files) => {
      if (err) {
        return reject(err)
      }
      if(files.length !== total || !files.length) {
        return reject('上传失败,切片数量不符')
      }

      // 创建文件写入流
      const fileWriteStream = fs.createWriteStream(filePath)
      function merge(i) {
        return new Promise((res, rej) => {
          // 合并完成
          if (i === files.length) {
            fs.rmdir(dirPath, (err) => {
              console.log(err, 'rmdir')
            })
            return res()
          }
          const chunkpath = dirPath + hash + '-' + i
          fs.readFile(chunkpath, (err, data) => {
            if (err) return rej(err)

            // 将切片追加到存储文件
            fs.appendFile(filePath, data, () => {
              // 删除切片文件
              fs.unlink(chunkpath, () => {
                // 递归合并
                res(merge(i + 1))
              })
            })
          })

        })
      }
      merge(0).then(() => {
        // 默认情况下不需要手动关闭,但是在某些文件的合并并不会自动关闭可写流,比如压缩文件,所以这里在合并完成之后,统一关闭下
        resolve(fileWriteStream.close())
      })
    })
  })
}

/**
 * 文件合并接口
 * 1、判断是否有切片hash文件夹
 * 2、判断文件夹内的文件数量是否等于total
 * 4、然后合并切片
 * 5、删除切片文件信息
 */
router.post('/api/upload/merge', async function uploadFile(ctx) {
  const { total, hash, name } = ctx.request.body
  const dirPath = path.join(uploadPath, hash, '/')
  const filePath = path.join(uploadPath, name) // 合并文件

  // 已存在文件,则表示已上传成功
  if (fs.existsSync(filePath)) {
    // 删除所有的临时文件
    deleteFiles(dirPath)
    ctx.body = {
      code: 0,
      url: path.join('http://localhost:3000/uploads', name),
      msg: '文件上传成功'
    }
  // 如果没有切片hash文件夹则表明上传失败
  } else if (!fs.existsSync(dirPath)) {
    ctx.body = {
      code: -1,
      msg: '文件上传失败'
    }
  } else {
    // 开始合并
    await mergeFile(dirPath, filePath, hash, total).then(() => {
      ctx.body = {
        code: 0,
        url: path.join('http://localhost:3000/uploads', name),
        msg: '文件上传成功'
      }
    }).catch(err => {
      ctx.body = {
        code: -1,
        msg: err
      }
    })
  }
})

app.use(router.routes())

// 用端口3000启动服务
app.listen(3000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241

# Dome地址

https://gitee.com/zlluGitHub/big-file-upload.git

# 参考文档

  • 谈谈前端大文件分片上传 (opens new window)
  • 如何实现大文件分片上传 (opens new window)
  • 文件上传断点续传和跨端续传 (opens new window)
#NodeJs#Vue
上次更新: 2024/01/30, 00:35:17
Node.js把前台传来的 base64 码转成图片
NodeJs 使用 jszip 或者 zip-dir 压缩文件夹(zip)

← Node.js把前台传来的 base64 码转成图片 NodeJs 使用 jszip 或者 zip-dir 压缩文件夹(zip)→

最近更新
01
Gitea数据备份与还原
03-10
02
Linux 中使用 rsync 同步文件目录教程
03-10
03
Linux 使用 rsync 互相传输同步文件的简单步骤
03-08
更多文章>
Theme by Vdoing | Copyright © 2019-2025 NoteZ,All rights reserved | 冀ICP备2021027292号-1
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式