我们在平时工作中常常会遇到文件上传的需求。但许久以来大多数人都是直接使用一些框架自带的组件去实现,对于一些复杂的上传场景、原理一直是云里雾里,不得其解。本文旨在对2020年最popular的面试技术点——前端文件上传做详尽的解读,包含前端、node、http等涉及文件上传的完整流程。
这是一个非常好的切入全栈思维的点。
formData
enctype 属性规定在发送到服务器之前应该如何对表单数据进行编码。
application/x-www-form-urlencoded:在发送前编码所有字符(默认)
multipart/form-data:不对字符编码。在使用包含文件上传控件的表单时,必须使用该值。
text/plain:空格转换为 "+" 加号,但不对特殊字符编码。
1. vue
<input type="file" @change="handleFileChange" />
<el-button type="primary" @click="handleUpload">上传</el-button>
2.使用form表单上传文件
① 请求使用post方法
② 设置enctype="multipart/form-data"
multipart代表多部件请求体,就是把每一个表单项分隔为一个部件。
表单项分为普通表单项和文件表单项。
multipart/form-data类型的body为多部请求体。
multipart/form-data既可以上传二进制数据,也可以上传表单键值对,只是最后会转化为一条信息
http请求中的multipart/form-data,会将表单的数据处理为一条消息,以标签为单元,用分隔符分开。
既可以上传键值对,也可以上传文件
当上传的字段是文件,会使用content-type表明文件类型;content-disposition说明字段的一些信息。
由于有boundary隔离,所以multipart/form-data既可以上传文件,也可以上传键值对。
html代码示例:
<body>
<form action="http://127.0.0.1:8001/file_upload" method="POST" enctype="multipart/form-data">
<fieldset>
<input type="file" name="handleFileChange">
<input type="submit" value="上传">
</fieldset>
</form>
</body>
3.使用FormData()上传
可以将表单中的信息添加到formData()对象中,再使用ajax进行数据传递。
FormData传输的数据格式和表单通过submit()方法传输的数据格式相同
通过formData.append('key',value)向formData()对象添加数据
注意:
(1)不要产生跨域,要阻止表单的默认事件。
(2)要设置两个属性为false:contentType/processData
processData:要求为Boolean类型的参数,默认为true。默认情况下,发送的数据将被转换为对象(从技术角度来讲并非字符串)以配合默认内容类型application/x-www-form-urlencoded。如果要发送DOM树信息或者其他不希望转换的信息,请设置为false。
比如var formData = new FormData();
formData.append("xxx","xxxx");
异步发送这个数据的时候必须设置processData:false
<body style="padding:30px;">
<form action="">
<fieldset>
<input type="text" id="title" required placeholder="输入图片名称">
<input type="file" id="fileUpload" accept="image/*"><!-- 替换成普通的按钮 -->
<button onclick="fileUpload(event)">使用ajax提交数据</button>
</fieldset>
</form>
</body>
let formData=newFormData();
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
form.append("filename", this.container.file.name);
form.append("file", this.container.file);
request({ url: '/upload', data: form })
}
4.使用FileReader()上传图片
HTML5的新api,兼容性也不是特别好,只兼容到了IE10。
通过将图片转换成base64(字符串格式)发送给后台,不过这样我们前面的后台代码就无法直接获取到上传的文件了
readAsDataURL 方法会读取指定的 Blob 或 File 对象。读取操作完成的时候,readyState 会变成已完成DONE,并触发 loadend 事件,同时 result 属性将包含一个data:URL格式的字符串(base64编码)以表示所读取文件的内容。
//定义函数
function uploadFile(event) {
//阻止默认事件
event.preventDefault()
//获取输入的内容
var title = document.querySelector("#title").value
var file = document.querySelector("#fileUpload").files[0]
//创建reader对象
var reader = new FileReader()
//读取选择的文件
reader.readAsDataURL(file)
//监听读取事件
reader.onload = function(){
//将输入的内容添加到要发送的对象中
var data = {}
data.title = title
data.file = reader.result
//使用jquey的ajax发送post请求
$.ajax({
url:"http://127.0.0.1:8001/file_upload",
type:"POST",
data:data,
success:function(res){
alert(res)
}})}}
Node服务
Main.js
const http = require("http")
const path = require('path')
const Controller = require('./controller')
const schedule = require('./schedule')
const server = http.createServer()
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存储目录
// schedule.start(UPLOAD_DIR)
const ctrl = new Controller(UPLOAD_DIR)
server.on("request", async (req, res)
=> {
res.setHeader("Access-Control-AllowOrigin", "*")
res.setHeader("Access-Control-AllowHeaders", "*")
if (req.method === "OPTIONS") {
res.status = 200
res.end()
return
}
if (req.method === "POST") {
if (req.url == '/upload') {
await ctrl.handleUpload(req,res)
return
}
}
})
server.listen(3000, () =>console.log("正在监听 3000 端⼝"))
Controller.js
async handleUpload(req, res) {
const multipart = new multiparty.Form()
multipart.parse(req, async (err, field, file) => {
if (err) {
console.log(err)
return
}
const [chunk] = file.file
const [filename] = field.filename
const filePath = path.resolve(this.UPLOAD_DIR, `${fileHash}${extractExt(filename)}`
)
const chunkDir = path.resolve(this.UPLOAD_DIR, fileHash)
// ⽂件存在直接返回
if (fse.existsSync(filePath)) {
res.end("file exist")
return
}
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir)
}
await fse.move(chunk.path, `${chunkDir}/${hash}`)
res.end("received file chunk")
})
}
总结:
1. formData
2. httpServer
3. fs⽂件处理
4. multiparty解析post数据
拖拽
考点: 拖拽事件drop,clipboardData
<div class="drop-box" id="drop-box">
box.addEventListener("drop", function(e) {
e.preventDefault(); //取消浏览器默认拖拽效果
var fileList = e.dataTransfer.files; //获取拖拽中的⽂件对象
var len=fileList.length;//⽤来获取⽂件的⻓度(其实是获得⽂件数量)
const [file] = e.target.files;
if (!file) return;
...上传
}, false);
粘贴
box.addEventListener('paste',function(event) {
var data = (event.clipboardData)
....
});
大文件上传
分片上传
整个流程大致如下:
(1) 将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
(2) 向服务端发送上传请求,上传时携带完整文件的唯一标识(建议使用MD5加密值就行),服务端判断该文件是否存在,不存在是返回同意上传信息。
(3) 接收到服务端同意上传后,按照一定的策略(串行或并行)发送各个分片数据块,每个分片携带分片信息(分片总数,分片索引,分片唯一索引,分片数据等),采用并行可实现分片秒传的功能,采用串行更好实现断点续传的功能。
(4) 发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件。
blob.slice分片
这里要j简单介绍下Blob对象。
Blob(Binary Large Object)对象代表了一段二进制数据,提供了一系列操作接口。其他操作二进制数据的 API(比如 File 对象),都是建立在 Blob 对象基础上的,继承了它的属性和方法。
Blob 对象是一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。File接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。
要从其他非blob对象和数据构造一个 Blob,请使用Blob() 构造函数。要创建一个 blob 数据的子集 blob,请使用slice() 方法:
var blob = instanceOfBlob.slice([start [, end [, contentType]]]};
start 可选,起始位置下标,默认值是0
end 可选,结束位置下标
contentType 可选,设置文档类型type 属性,默认值为 ‘’
const chunks = this.createFileChunk(this.container.file);
createFileChunk(file, size = SIZE) {
// ⽣成⽂件块
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return chunks;
},
切片merge
所有切片挨个发请求,然后merge
接收服务端的返回结果判断上传文件的方式,服务端可返回已上传、未上传、上传一部分三种状态。
如当我们接收到未上传状态后。对文件进行分片(每片建议不超过10M)。依次将分片上传,上传信息有(分片MD5值,分片总数,当前分片索引,分片数据等)。当一片上传完成后再回调函数上传下一片。
我向后台的某个地址发送一个请求,传递文件名和文件的最后修改时间为参数,后台根据这两个参数来找到与前台所选择的文件对应的服务器上的文件,将服务器返回的文件大小return出去,来被插件使用。为什么要传递这两个参数呢?我们在前台无法知道服务器上的这个文件的名称,所以使用原始文件名作为一个辅助标识。为了防止用户在两次上传间隔修改了文件,我们把文件的最后修改时间也传给服务端,让服务端进行比较,若时间不对应则返回已上传大小为0,重新上传此文件。
再来看后台都要做哪些工作。数据库中需要有一张表来记录每个已文件的情况,包含的字段大致有:
const shardSize = 5 * 1024 * 1024; // 以5MB为一个分片 // 计算每一片的起始与结束位置
const start = this.state.i * shardSize;
const end = Math.min(size, start + shardSize); form.append("uuid", uuid); //文件ID
form.append("action", "upload"); // 直接上传分片
form.append("filemd5", filemd5); // 文件MD5form.append("md5", md5); // 分片MD5
form.append("name", name); //文件名
form.append("size", size); // 文件大小
form.append("total", shardCount); // 总片数
form.append("index", this.state.i + 1); // 当前是第几片
form.append("data", file.slice(start, end)); // 使用slice方法用于切出文件的一部分
const fileImport = (file, index, chunkSize, md5) => { // 获取MD5
const chunks = Math.ceil(file.size / chunkSize); // 防止文件截取不完整 所以向上取整
if (file) {
let upSize = index * chunkSize;
if (index > chunks - 1) {
return;
}
let blob = file.slice(upSize, upSize + chunkSize); // 截取文件
try {
let formData = new FormData();
formData.append('file', blob);
formData.append('md5', md5);
formData.append('chunk', index);
formData.append('chunks', chunks);
sliceImportVisualization(formData).then(async res => {
if (res?.isSuccess) {
await fileImport(file, ++index, chunkSize, md5); // 在上一片传输完成后再调用 切记 切记
const { data = {} } = res;
const { file_name } = data;
if (file_name) {
importVisualization({ file_name }).then(res => {
setSpin({spinning: false});
});
}
}
});
} catch (err) {
console.log('分片上传错误...', err);
}
}
};
async handleMerge(req, res) {
const data = await resolvePost(req)
const { fileHash, filename, size } = data
const ext = extractExt(filename)
const filePath = path.resolve(this.UPLOAD_DIR, `${fileHash}${ext}`)
await this.mergeFileChunk(filePath, fileHash, size)
res.end( JSON.stringify({
code: 0,
message: "file merged success"
})
)}
断点续传 + 秒传
md5计算,缓存思想 ⽂件⽤md5计算⼀个指纹,上传之前,先问后端,这个⽂件的hash在不在,在的话就不⽤传了,就是所谓的断点续传,如果整个⽂件都存在了 就是秒传
async handleVerify(req, res) {
const data = await resolvePost(req)
const { filename, hash } = data
const ext = extractExt(filename)
const filePath = path.resolve(this.UPLOAD_DIR, `${hash}${ext}`)
// ⽂件是否存在
let uploaded = false
let uploadedList = []
if (fse.existsSync(filePath)) {
uploaded = true
} else { // ⽂件没有完全上传完毕,但是可能存在部分切⽚上传完毕了
uploadedList = await getUploadedList(path.resolve(this.UPLOAD_DIR, hash))
}
res.end(
JSON.stringify({ uploaded, uploadedList }) // 过滤诡异的隐藏⽂件
)}
计算hash优化
web-worker
大文件的md5太慢了,启⽤webworker计算
JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。
Web Worker的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责UI交互)就会很流畅,不会被阻塞或拖慢。
Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。
具体链接:http://www.ruanyifeng.com/blog/2018/07/web-worker.html
// web-worker
self.importScripts('spark-md5.min.js')
self.onmessage = e => { // 接受主线程的通知
const {chunks} = e.data
const spark = new self.SparkMD5.ArrayBuffer()
let progress = 0; let count = 0
const loadNext = index => {
const reader = new FileReader()
reader.readAsArrayBuffer(chunks[index].file)
reader.onload = e=> {
count++ // 累加器 不能依赖index
spark.append(e.target.result) // 增量计算md5
if(count===chunks.length) {
// 通知主线程,计算结束
self.postMessage({
progress:100,
hash:spark.end()
})
} else {
// 每个区块计算结束,通知进度即可
progress += 100/chunks.length
self.postMessage({ progress })
// 计算下⼀个
loadNext(count)
}
}
}
// 启动
loadNext(0)
}
time-slice
react fiber架构学习,利⽤浏览器空闲时间
requestIdleCallback
相关链接:
https://www.cnblogs.com/songsu/p/12017431.html