【前端新手向】文件上传攻略手册

1,960 阅读5分钟

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

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

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

欢迎来到小五随笔系列文件上传攻略手册.

前言

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

代码采用 React + 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.onload:该事件在读取操作完成时触发

  • FileReader.readAsArrayBuffer:读取 Blob 内容,保存为 ArrayBuffer 数据对象

  • FileReader.abort:终止读取操作

node基础

「path」 🦅

  • __dirname:返回该文件所在文件夹的位置

  • process.cwd():运行 node 命令时所在文件夹的位置

  • path.join():连接路径 ~ 将全部 path 片段拼接,生成路径

  • path.resolve():解析路径 ~ 将多个路径解析为一个绝对路径(类似于 cd 操作)

文件操作

重命名文件fs.rename()fs.renameSync()

fs.renameSync(oldPath, newPath)

读取目录fs.readdir()fs.readdirSync()

用于读取目录,返回一个包含文件和目录的数组

fs.readdirSync(path)

文件/目录信息fs.stat()fs.statSync()

其参数为一个文件或目录,返回一个对象,包含该文件或目录的具体信息

  • stats.isDirectory():判断是否为目录

  • stats.isFile():判断是否为文件

let stats = fs.statSync(path)
if (stats.isDirectory()) { ... }
if (stats.isFile()) { ... }

文件/目录是否存在fs.exists()fs.existsSync()

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.onload = 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