img

文件上传

Deeruby 1年前 ⋅ 545 阅读

Hello, 各位勇敢的小伙伴, 大家好, 我是你们的嘴强王者小五, 身体健康, 脑子没病.

本人有丰富的脱发技巧, 能让你一跃成为资深大咖.

一看就会一写就废是本人的主旨, 菜到抠脚是本人的特点, 卑微中透着一丝丝刚强, 傻人有傻福是对我最大的安慰.

欢迎来到小五随笔系列一文带你吃透文件上传.

前言

本文新手向,带领大家从单文件上传出发,逐步扩展至分片上传及断点续传。文章会将所涉及知识点逐一列出,诸位看官可放心食用。

代码采用 React Hook + Koa2 进行编写:

双手奉上代码链接: fe-uploadbe-upload

ts2.gif

基础拾遗

此部分内容为本文所需知识点,如需扩充,请各位看官自行查阅相关资料

在HTML表单中,上传文件的唯一控件为 <input type="file" />。同时需满足 "Content-Type": "multipart/form-data" && "method": "post"

fecth 无法监听文件上传进度,笔者选用 axios

FormData

用于「序列化表单」或「创建与表单格式相同的数据」,若表单的 enctypemultipart/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 应用程序异步读取存储在用户计算机上的文件,使用 FileBlob 对象指定要读取的文件或数据

  • **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():管道,用于连接流文件

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端」 🦅

upload1.png

「上传逻辑 - 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」

upload2.png

「controller」

upload3.png

拖拽上传

drag1.png

在拖拽时获取到文件信息,然后执行 upload() 方法即可

tips:将图片拖拽到页面,浏览器默认行为会在新窗口打开图片,故需禁用默认行为及阻止事件冒泡

const stopEvent = e => {
  e.preventDefault()
  e.stopPropagation()
}

upload4.png

文件上传进度

axiosconfig 中,使用 onUploadProgress 方法可获取到 loadedtotallengthComputable,其中 loaded 表示发送了多少字节,total 表示文件总大小, lengthComputable 表示当前进度是否具有可计算的长度,若没有,total0

shard2.png

shard3.png

upload5.png

取消上传

使用 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-typeslookup 方法获取 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

分片上传

对文件进行切割,每次上传一部分内容,记录其顺序。全部上传完毕后,按顺序将分片内容合并成文件。

shard6.png

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)
}

merge1.png

「进度条」 🦅

$累加所有已上传字节 / 总字节数$

node端

通过传入的 hash 创建文件夹,按照 index-hash 形式向文件夹中写入分片,收到 merge 请求时对文件夹中的分片做合并处理

upload6.png

upload7.png

「router」 🦅

upload8.png

「controller」 🦅

upload9.png

秒传

若文件存在则不进行传输,直接返回文件地址,该操作即为秒传

md5 加密后的 hash 是唯一的,故判断当前上传文件是否在 uploads 文件夹下;若存在,返回文件地址,否则做相关上传操作。

断点续传

若分片上传的文件未传输完毕,上传相同文件时继续上次进度上传,即为断点续传

将已上传的分片信息返回给前端,由前端根据索引判断上传哪些分片即可

「router」 🦅

upload10.png

「controller」 🦅

upload11.png

参考链接

【ikoala】想学Node.js,stream先有必要搞清楚

【zz_jesse】写给新手前端的各种文件上传攻略,从小图片到大文件断点续传

【前端劝退师】120行代码实现一个交互完整的拖拽上传组件

other28.gif


全部评论: 0

    我有话说: