Hello, 各位勇敢的小伙伴, 大家好, 我是你们的嘴强王者小五, 身体健康, 脑子没病.
本人有丰富的脱发技巧, 能让你一跃成为资深大咖.
一看就会一写就废是本人的主旨, 菜到抠脚是本人的特点, 卑微中透着一丝丝刚强, 傻人有傻福是对我最大的安慰.
欢迎来到
小五的随笔系列之一文带你吃透文件上传.
前言
本文新手向,带领大家从单文件上传出发,逐步扩展至分片上传及断点续传。文章会将所涉及知识点逐一列出,诸位看官可放心食用。
代码采用 React Hook + Koa2 进行编写:
双手奉上代码链接: fe-upload 、be-upload
基础拾遗
此部分内容为本文所需知识点,如需扩充,请各位看官自行查阅相关资料
在HTML表单中,上传文件的唯一控件为  <input type="file" />。同时需满足 "Content-Type": "multipart/form-data" && "method": "post"。
因 fecth 无法监听文件上传进度,笔者选用 axios
FormData
用于「序列化表单」或「创建与表单格式相同的数据」,若表单的 enctype 为 multipart/form-data,则会使用表单的 submit() 方法发送数据
formData 的存储形式为 key / value 的键值对,可通过 append 进行值的追加
const formData = new FormData()
formData.append('f1', chunk1)
formData.append('f1', chunk2)
formData.append('f1', chunk3)
formData.getAll('f1') // [chunk1, chunk2, chunk3]
FileReader
FileReader 对象允许 Web 应用程序异步读取存储在用户计算机上的文件,使用 File 或 Blob 对象指定要读取的文件或数据
- **FileReader. fs.statSync(path) if (stats.isDirectory()) { ... } if (stats.isFile()) { ... }
**文件/目录是否存在**:`fs.exists()`、`fs.existsSync()`
```js
fs.existsSync(folder)
创建文件夹:fs.mkdir()、fs.mkdirSync()
fs.mkdirSync(folder)
删除文件:fs.unlink()、fs.unlinkSync()
fs.unlink(fname)
删除目录:fs.rmdir()、fs.rmdirSync()
只有当目录为空时才可删除,若不为空需遍历文件,逐一删除文件后在删除目录
流(stream)
stream 无需将文件全部读取后再返回,而是一边读取一边返回。
- 
fs.createReadStream():可读流,用来读取数据
- 
fs.createWriteStream():可写流,用来写入数据
- 
.pipe():管道,用于连接流文件
const fs = require('fs')
const readerStream = fs.createReadStream('input.txt')
const writerStream = fs.createWriteStream('output.txt')
readerStream.pipe(writerStream)
普通上传
「页面结构」 🦅
<input
  ref={fileRef} // 用于触发 input 的点击事件
  value={fileValue} // 上传前需清空该值,否则相同文件无法上传
  style={{display: 'none'}} // 隐藏原始样式,在新样式中通过 fileRef.current.click() 触发上传动作
  type="file"
  name="file"
  accept={accept} // 接收的文件格式
  onChange={upload} // 上传事件
  multiple={multiple} // 是否开启多文件上传
/>
「上传逻辑 - web端」 🦅
「上传逻辑 - node端」 🦅
若需探究原理,可跳转:【zihanzy.com】NodeJs原生文件上传理解、 【陈煮酒】从 koa-body 入手分析,搞懂 Node.js 文件上传流程
我们使用 koa-body 库来实现文件的保存,其默认存储到系统的临时目录,可配置该目录
通过 ctx.request.files.f1 来获取文件信息,其中 f1 为 input file 所指定的名称
app.use(koaBody({
  formidable: {
    uploadDir: path.resolve(__dirname, 'public/uploads'), // 文件上传目录
    // keepExtensions: boolean 保持文件后缀
    // maxFieldsSize: number 文件上传大小
    // onFileBegin: (name, file) => void 文件上传前事件
  },
  multipart: true, // 支持文件上传
  // encoding: string 压缩方式
}))
通过 koa-static 来开启静态资源文件的访问
app.use(koaStatic(__dirname + 'public'))
「router」
「controller」
拖拽上传
在拖拽时获取到文件信息,然后执行 upload() 方法即可
tips:将图片拖拽到页面,浏览器默认行为会在新窗口打开图片,故需禁用默认行为及阻止事件冒泡
const stopEvent = e => {
  e.preventDefault()
  e.stopPropagation()
}
文件上传进度
在 axios 的 config 中,使用 onUploadProgress 方法可获取到 loaded、 total 及 lengthComputable,其中 loaded 表示发送了多少字节,total 表示文件总大小, lengthComputable 表示当前进度是否具有可计算的长度,若没有,total 为 0
取消上传
使用 axios.cancelToken 来取消 ajax 请求,取消后,在请求相同接口时需重新赋值。
分片及断点续传时,可通过其实现暂停和继续操作
let source = axios.CancelToken.source()
const upload = async () => {
  let config = {
    cancelToken: source.token,
  }
}
const cancelUpload = () => {
  source.cancel()
  source = axios.CancelToken.source() 
}
图片回显
设置 content-type,将读取后的文件赋值给 ctx.body 即可
可通过 mime-types 的 lookup 方法获取 content-type
const mime = require('mime-types')
let filePath = path.join(__dirname, `public/uploads/${readFileName}`)
let file = null
try {
  file = fs.readFileSync(filePath)
} catch(err) {
  console.log(err)
}
let mimeType = mime.lookup(filePath)
ctx.set('content-type', mimeType)
ctx.body = file
分片上传
对文件进行切割,每次上传一部分内容,记录其顺序。全部上传完毕后,按顺序将分片内容合并成文件。
web端
「如何对文件进行分割」 🦅
通过 Blob.prototype.slice 方法对文件进行切片
const chunkSize = 2 * 1024 * 1024 // 每片大小
let chunks = [] // 分片数组
if (files.size > chunkSize) {
  let start = 0
  let end = 0
  while (true) {
    end += chunkSize
    const blob = files.slice(start, end)
    start += chunkSize
    if (!blob.size) break
    chunks.push(blob)
  }
} else {
  chunks.push(files)
}
「如何将文件转换为 Buffer 格式」 🦅
通过 FileReader
const fileParse = (files) => {
  return new Promise((resolve, reject) => {
    let fileRead = new FileReader()
    fileRead.readAsArrayBuffer(files)
    fileRead. e => {
      resolve(e.target.result)
    }
  })
}
「如何归类 相同文件 的切片」 🦅
通过 md5 做加密生成 hash,相同 hash 即为同一文件的切片
import SparkMD5 from 'spark-md5'
const buffer = await fileParse(files)
const spark = new SparkMD5.ArrayBuffer()
spark.append(buffer)
let hash = spark.end()
formData.append('token', hash)
「如何确保按顺序合并分片」 🦅
向 formData 追加索引
formData.append('index', index)
「什么时候对分片进行合并」 🦅
当所有分片都上传完毕后,向后端发送一个 type=merge 的请求,后端接收后进行合并处理
if (sendChunkCount === chunkCount) { // 全部上传完毕后
  const mergeFormData = new FormData()
  mergeFormData.append('type', 'merge')
  mergeFormData.append('token', hash)
  mergeFormData.append('chunkCount', chunkCount)
  mergeFormData.append('filename', files.name)
  const data = await axios.post(action, mergeFormData, config)
}
「进度条」 🦅
$累加所有已上传字节 / 总字节数$
node端
通过传入的 hash 创建文件夹,按照 index-hash 形式向文件夹中写入分片,收到 merge 请求时对文件夹中的分片做合并处理
「router」 🦅
「controller」 🦅
秒传
若文件存在则不进行传输,直接返回文件地址,该操作即为秒传
md5 加密后的 hash 是唯一的,故判断当前上传文件是否在 uploads 文件夹下;若存在,返回文件地址,否则做相关上传操作。
断点续传
若分片上传的文件未传输完毕,上传相同文件时继续上次进度上传,即为断点续传
将已上传的分片信息返回给前端,由前端根据索引判断上传哪些分片即可
「router」 🦅
「controller」 🦅
参考链接
【ikoala】想学Node.js,stream先有必要搞清楚
【zz_jesse】写给新手前端的各种文件上传攻略,从小图片到大文件断点续传
注意:本文归作者所有,未经作者允许,不得转载
 
     
             
 
					 
					 
					 
					