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】写给新手前端的各种文件上传攻略,从小图片到大文件断点续传
注意:本文归作者所有,未经作者允许,不得转载