文件上传的请求体
文件上传本质上也是通过调用后端接口,通过 http
请求将二进制文件传递给后端服务器,一般文件上传是通过创建 FormData
对象来完成。下面是一个简单的上传接口:
js
function upload(file, onProgress, onFinish) {
const formData = new FormData();
formData.append('fileChuck', file.fileChuck);
formData.append('fileChuckHash', file.fileChuckHash);
formData.append('fileHash', file.fileHash);
formData.append('isCompleted',file.isCompleted)
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.addEventListener("readystatechange", () => {
if (xhr.readyState === 4 && xhr.status === 200) {
onFinish(JSON.parse(xhr.responseText));
}
})
xhr.addEventListener("progress", (e) => {
let percent = (uploadCtr.index + e.loaded / e.total) / uploadCtr.total;
onProgress(percent);
})
xhr.send(formData);
return () => {
xhr.abort();
}
}
当发起请求后,http
请求头会自动设置 Content-Type:multipart/form-data; boundary=----WebKitFormBoundary2u7XXrIsr7HuQMbH
。其中 Content-Type
用于指示资源的 MIME 类型, boundary
表示分隔符
常见 MIME 类型列表 - HTTP | MDN (mozilla.org)
MIME 类型即媒体类型 (也通常称为多用途互联网邮件扩展 或 MIME 类型)是一种标准,用来表示文档、文件或一组数据的性质和格式
请求体的格式如下图:
结合代码,请求头和请求体可知,创建的FormData
对象作为请求体进行传输时,会取出请求头 Content-Type
定义的 boundary
属性,再其开头添加 --
作为分隔符将 FormData
对象的属性划分出的不同部分。每一部分有自己的实体,Content-Disposition
和 Content-Type
用于文件上传字段。
前端页面
总结目前主要流行的 ui 框架,文件上传组件中通常会有三种状态:待上传,上传中,上传完成。
HTML 和 CSS 代码则根据这三种状态来进行切换,核心代码如下:
html
<style>
.upload {
position: relative;
}
.upload-select,
.upload-progress,
.upload-result,
.upload > img {
display: none;
position: absolute;
}
.upload.select .upload-select,
.upload.progress .upload-progress,
.upload.result .upload-result {
display: block;
}
.upload.select > img{
display: none;
}
.upload.progress > img{
display: block;
z-index: -1;
}
.upload.result > img{
display: block;
}
</style>
<div class="upload select" id="upload-container">
<div class="upload-select pointer" id="upload-select">
<input type="file" name="file" class="upload__input" id="file">
</div>
<div class="upload-progress">
<div class="progress-bar" style="--percent: 0%;" id="progress-bar">
<button class="btn" id="upload-cancel">取消</button>
</div>
</div>
<div class="upload-result">
<button class="btn" id="upload-result">X</button>
</div>
<img src="./images/demo.jpg" alt="" srcset="" id="upload-img-preview">
</div>
三个状态的效果图如下所示:
文件上传逻辑和原理
根据页面变化,文件上传核心步骤也可分为三步:
- 文件选择:通过
input[type="file"]
选择需要上传的文件,即file
对象; - 在线预览:如果是图片进行在线预览;
- 接口调用:调用文件上传接口,显示进度条,提供上传取消的功能;
==文件选择==
文件选择是通过 <input type="file">
元素来完成的。
input[type='file']
有三个附加属性,accept
、capture
和multiple
。当指定布尔类型属性
multiple
(en-US) 时,文件 input 允许用户选择多个文件。
accept
(en-US) 属性是一个字符串,它定义了文件 input 应该接受的文件类。这个字符串是一个以逗号为分隔的唯一文件类型说明符列表。
capture
(en-US) 属性是一个字符串,如果accept
(en-US) 属性指出了 input 是图片或者视频类型,则它指定了使用哪个摄像头去获取这些数据。值user
表示应该使用前置摄像头和(或)麦克风。值environment
表示应该使用后置摄像头和(或)麦克风。
html
<!--
唯一文件类型说明符是一个字符串,表示在 file 类型的 <input> 元素中用户可以选择的文件类型。
每个唯一文件类型说明符可以采用下列形式之一:
1. 一个以英文句号(".")开头的合法的不区分大小写的文件名扩展名。例如:.jpg、.pdf 或 .doc。
2. 一个不带扩展名的 MIME 类型字符串。
3. 字符串 audio/*,表示"任何音频文件"。
4. 字符串 video/*,表示"任何视频文件"。
5. 字符串 image/*,表示"任何图片文件"。
下面表示接受任何图片和PDF
-->
<input type="file" accept="image/*,.pdf" />
除了上面列出来的三个属性外,还存在一个非标准属性 webkitdirectory
如果出现布尔属性
webkitdirectory
(或者 mozdirectory odirectory),表示在文件选择器界面中用户只能选择目录。如果设置为true
,则input
元素只允许选择目录;如果设置为false
,则只允许选择文件。
html
<!--允许用户选择一个或多个目录(文件夹) -->
<input type="file" id="filepicker" name="fileList" webkitdirectory multiple />
<script>
document.getElementById("filepicker").addEventListener(
"change",
(event) => {
let output = document.getElementById("listing");
for (const file of event.target.files) {
let item = document.createElement("li");
item.textContent = file.webkitRelativePath;
output.appendChild(item);
}
},
false,
);
</script>
被选择的文件以 HTMLInputElement.files
属性返回,它是包含一系列 File
对象的 FileList
类数组。
每个 File
对象拥有6个属性:
name
:文件名lastModified
:一个数字,指定文件最后一次修改的日期和时间,以 UNIX 新纪元(1970 年 1 月 1 日午夜)以来的毫秒数表示。lastModifiedDate
:一个Date
对象,表示文件最后一次修改的日期和时间。这被弃用,使用lastModified
作为替代。size
:以字节数为单位的文件大小type
:文件的 MIME 类型webkitRelativePath
:一个字符串,指定了相对于在目录选择器中选择的基本目录的文件路径(即,一个设置了webkitdirectory
属性的file
选择器)。这是非标准的,谨慎使用。
file
对象的1个方法(继承自 Blob
对象):
返回一个新的 Blob
对象,它包含有源 Blob
对象中指定范围内的数据。
==在线预览==
使用 input[type='file']
拿到了 file
对象,通过 FileReader
, URL.createObjectURL()
来可以处理二进制的 Blob 对象,完成图片的在线预览。
FileReader
对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用File
或Blob
对象指定要读取的文件或数据。
FileReader 有以下3个属性,均为只读:
FileReader.error
:一个DOMException
,表示在读取文件时发生的错误。FileReader.readyState
:表示FileReader
状态的数字。FileReader.result
:文件的内容。该属性仅在读取操作完成后才有效,数据的格式取决于使用哪个方法来启动读取操作。
js
const file = HTMLInputElement.files[0];
const reader = new FileReader();
console.log("🚀 ~ EMPTY:", reader.readyState) // 0:还没有加载任何数据
reader.readAsDataURL(file);
console.log("🚀 ~ LOADING:", reader.readyState) // 1:数据正在被加载
reader.onloadend = () => {
console.log("🚀 ~ DONE:", reader.readyState)// 2:已完成全部的读取请求
selectors.imgPreview.src = reader.result;
}
常用的事件处理:
FileReader.onabort
:处理abort
事件。该事件在读取操作被中断时触发。FileReader.onerror
:处理error
事件。该事件在读取操作发生错误时触发。FileReader.onload
:处理load
事件。该事件在读取操作完成时触发。
常用的方法:
FileReader.readAsDataURL()
:开始读取指定的Blob
中的内容。一旦完成,result
属性中将包含一个data:
URL 格式的 Base64 字符串以表示所读取文件的内容。FileReader.readAsText()
:开始读取指定的Blob
中的内容。一旦完成,result
属性中将包含一个字符串以表示所读取的文件内容。
接下来我们用代码实现以上三个步骤
html
<div class="upload select" id="upload-container">
<div class="upload-select pointer" id="upload-select" draggable>
<input type="file" name="file" class="upload__input" id="file">
</div>
<div class="upload-progress">
<div class="progress-bar" style="--percent: 0%;" id="progress-bar">
<button class="btn" id="upload-cancel">取消</button>
</div>
</div>
<div class="upload-result">
<button class="btn" id="upload-result">X</button>
</div>
<img src="./images/demo.jpg" alt="" srcset="" id="upload-img-preview">
</div>
js
const $ = document.querySelector.bind(document);
const UPLOADPHASENUMS = {
select: 'select',
progress: 'progress',
result: 'result',
}
// 元素选择器对象
const selectors = {
container: $("#upload-container"),
file: $("#file"),
upload: $("#upload-select"),
progressBar: $("#progress-bar"),
result: $("#upload-result"),
imgPreview: $("#upload-img-preview"),
cancel: $("#upload-cancel"),
}
// 更改页面元素的显示
function setPhase(phase) {
selectors.container.className = `upload ${phase}`;
}
// 触发 input[type='file'] 的点击事件
selectors.upload.addEventListener('click', () => {
selectors.file.click();
})
selectors.result.addEventListener('click', () => {
setPhase(UPLOADPHASENUMS.select);
})
selectors.cancel.addEventListener('click', () => {
setPhase(UPLOADPHASENUMS.select);
uploadCtr.cancelFn();
})
const uploadCtr = {
index: 0,
total: 1,
cancelFn: null
}
selectors.file.addEventListener('change', () => {
Array.from(selectors.file.files).forEach((file, index) => {
fileChangeHandler(file)
})
});
async function fileChangeHandler(file) {
setPhase(UPLOADPHASENUMS.progress)
imagePreview(file, selectors.imgPreview)
uploadCtr.cancelFn =
uploadAjax(
file,
(percent) => {
selectors.progressBar.style = `--percent:${percent * 100}%;`
},
(result) => {
console.log("上传成功",result);
}
);
}
function imagePreview(file, selector) {
// 文件预览
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
selector.src = reader.result;
}
}
/**
* @description: 上传文件到服务器
* @param {*} file
* @param {*} onProgress 上传过程中执行的回调
* @param {*} onFinish 上传完成后执行的回调
* @return {*}
*/
function uploadAjax(file, onProgress, onFinish) {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', file.name);
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:8080/upload');
xhr.addEventListener("readystatechange", () => {
if (xhr.readyState === 4 && xhr.status === 200) {
onFinish(JSON.parse(xhr.responseText));
}
})
xhr.addEventListener("progress", (e) => {
let percent = (uploadCtr.index + e.loaded / e.total) / uploadCtr.total;
onProgress(percent);
})
xhr.send(formData);
return () => {
xhr.abort();
}
}
不同的上传场景
多文件(夹)上传
对于多文件和文件夹上传需求,借助的就是 input[type='file']
的 multiple
和 webkitdirectory
属性。
html
<input type="file" multiple webkitdirectory />
文件夹上传演示:
对于支持 webkitdirectory
属性,得到的结果将包括该文件夹下所有的文件,打印 HTMLInputElement.files
结果如下:
在实际开发中通常是会隐藏 input
元素,通过 HTMLInputElement.click()
手动触发 input
元素的点击事件。
拖拽上传
方式一:
input[type='file']
标签是支持拖拽,将要上传的文件拖拽到 input
元素后,监听 change
事件后能拿到 FileList
对象。
使用这种方式只需要将 input
元素 opacity:0
html
<style>
.upload__input {
opacity: 0;
width: 100%;
height: 100%;
}
</style>
<div class="upload-select" id="upload-select">
<input type="file" name="file" class="upload__input" id="file" webkitdirectory>
</div>
方式二:
在实际开发中通常是会隐藏 input
元素,使用 div
自定义上传的样式,对于这种情况就需要通过 HTML 拖放 API 来实现。
和拖拽上传相关的拖拽事件有三个,dragover
,dragenter
,drop
事件,dragover
,dragenter
需要阻止默认事件才能触发 drop
事件。
通过监听 div
元素的 drop
事件 , 通过 DragEvent.dataTransfer
属性能拿到托拽操作中的数据。DataTransfer
有5个如下标准属性:
DataTransfer.dropEffect
:获取当前选定的拖放操作类型或者设置的为一个新的类型。值必须为none
,copy
,link
或move
。DataTransfer.effectAllowed
:提供所有可用的操作类型。必须是none
,copy
,copyLink
,copyMove
,link
,linkMove
,move
,all
oruninitialized
之一。DataTransfer.files
:包含数据传输中可用的所有本地文件的列表。如果拖动操作不涉及拖动文件,则此属性为空列表。DataTransfer.items
:只读,提供一个包含所有拖动数据列表的DataTransferItemList
类数组对象,包含了表示拖动操作中被拖动项的DataTransferItem
对象。DataTransfer.types
:只读,一个提供dragstart
事件中设置的格式的strings
数组。
File
对象除了来自用户在一个input
元素上选择文件后返回的FileList
对象外,也可以是来自由拖放操作生成的DataTransfer
对象,或者来自HTMLCanvasElement
上的mozGetAsFile
() API。
通过 DataTransfer.files
属性可以拿到 FileList
对象,但是如果是文件夹拿到的只是文件夹的信息而不是文件。
针对文件夹这种场景,可以使用 DataTransferItem
来获取文件
DataTransferItem
的 2个属性:
DataTransferItem.kind
只读,拽项的种类,string
或是file
。DataTransferItem.type
只读,拖拽项的类型,一般是一个 MIME 类型。
DataTransferItem
的3个方法:
DataTransferItem.getAsFile()
:返回一个关联拖拽项的File
对象(当拖拽项不是一个文件时返回 null)。DataTransferItem.getAsString()
:使用拖拽项的字符串作为参数执行指定回调函数。DataTransferItem.webkitGetAsEntry()
非标准,返回一个基于FileSystemEntry
(en-US) 的对象来表示文件系统中选中的项目。通常是返回一个FileSystemFileEntry
或是FileSystemDirectoryEntry
对象。FileSystemDirectoryEntry
从它的父接口FileSystemEntry
(en-US) 继承了方法createReader()
(en-US),它可以用于读取文件夹下的所有文件和文件夹。FileSystemFileEntry
从它的父接口FileSystemEntry
(en-US) 继承了方法file()
(en-US)创建新的File
对象,它可以用于读取文件。
此功能
webkitGetAsEntry()
在此时非包含 Firefox 的非 WebKit 浏览器中实现; 它可能会getAsEntry()
在以后简单地重命名,所以你应该进行防御性编码,寻找两者。
html
<div class="upload-select pointer" id="upload-select">
<input type="file" name="file" class="upload__input" id="file" multiple
webkitdirectory mozdirectory odirectory>
</div>
<script>
const $ = document.querySelector.bind(document);
const selectors = {
container: $("#upload-container"),
file: $("#file"),
upload: $("#upload-select"),
progressBar: $("#progress-bar"),
result: $("#upload-result"),
imgPreview: $("#upload-img-preview"),
cancel: $("#upload-cancel"),
}
function setPhase(phase) {
selectors.container.className = `upload ${phase}`;
}
selectors.upload.addEventListener('click', () => {
selectors.file.click();
})
selectors.upload.addEventListener("dragenter", (e) => {
e.preventDefault();
})
selectors.upload.addEventListener("dragover", (e) => {
e.preventDefault();
})
selectors.upload.addEventListener("drop", async (e) => {
e.preventDefault();
uploadFiles(e.dataTransfer);
})
async function uploadFiles(dataTransfer) {
function _uploadFiles(entry) {
if (entry.isDirectory) {
// 目录
const reader = entry.createReader();
reader.readEntries((entries) => {
entries.forEach(async (en) => {
_uploadFiles(en);
})
})
} else {
entry.file((file) => {
// 文件
fileChangeHandler(file);
})
}
}
for (const item of dataTransfer.items) {
const entry = item.getAsEntry ? item.getAsEntry() : item.webkitGetAsEntry();
_uploadFiles(entry);
}
}
</script>
裁剪上传
文件的裁剪上传需要使用到 canvas
的 CanvasRenderingContext2D.drawImage()
进行图片的裁剪,裁剪后通过 HTMLCanvasElement.toBlob()
得到 Blob
对象,通过 new File([Blob])
得到 file
对象。
关于 drawImage
的使用可以参照 从0开始canvas系列二 --- 文本和图像
toBlob
方法接受三个参数无返回值,参数说明如下:
js
toBlob(callback, type, quality)
/**
callback:回调函数,可获得一个单独的 Blob 对象参数。如果图像未被成功创建,可能会获得 null 值。
type:可选,DOMString 类型,指定图片格式,默认格式(未指定或不支持)为 image/png。
quality :可选,Number 类型,值在 0 与 1 之间,
当请求图片格式为 image/jpeg 或者 image/webp 时用来指定图片展示质量。
如果这个参数的值不在指定类型与范围之内,则使用默认值,其余参数将被忽略。
**/
以下是一个demo案例,功能是上传文件后预览,点击图片裁剪按钮后得到裁剪后的 file 对象,最后调用上传接口
html
<div class="test-container">
<input type="file" id="test1"/>
<img src="./images/demo.jpg" alt="" srcset="" id="upload-test-img-preview">
<div>
<button id="clip">图片裁剪</button>
</div>
<canvas id="clip-canvas"></canvas>
</div>
<script>
$("#test1").addEventListener('change', (e) => {
console.log(e, e.target.files);
imagePreview(e.target.files[0], $("#upload-test-img-preview"))
})
$('#clip').addEventListener('click',async () => {
const clipOption = {
cutWidth: 200,
cutHeight: 200,
cutX: 50,
cutY: 50,
width: 100,
height: 100,
}
const file =await clipImageData($('#upload-test-img-preview'), clipOption);
fileChangeHandler(file);
})
// 图片裁剪
function clipImageData(imgEle, clipOption) {
const {
cutWidth,
cutHeight,
cutX,
cutY,
width,
height,
} = clipOption;
const canvas = $('#clip-canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgEle,
cutX, cutY,
cutWidth, cutHeight,
0, 0,
width, height
);
return new Promise(resolve=>{
canvas.toBlob(blob => {
const file = new File([blob], 'test.jpg', {
type: "image/jpeg",
});
resolve(file);
}, "image/jpeg")
})
}
</script>
切片上传
文件的切片上传本质上是将文件按照指定大小切分成小的文件块,可以通过 file
对象的 slice
方法来完成,代码如下:
js
// 文件切片
function sliceFile(file, size) {
const fileChuckList = [];
let curSize = 0;
let fileSize = file.size;
while (curSize <= fileSize) {
let end = (curSize + size <= fileSize) ? (curSize + size) : fileSize;
fileChuckList.push(file.slice(curSize, end));
curSize += size;
}
return fileChuckList;
}
const uploadCtr = {
index: 0,
total: 0,
cancelFn: null,
mode: '', // 'slice' or 'file'
sliceSize: 1024 * 2, // 1MB
}
selectors.file.addEventListener('change', () => {
Array.from(selectors.file.files).forEach((file, index) => {
fileChangeHandler(file)
})
});
async function fileChangeHandler(file) {
setPhase(UPLOADPHASENUMS.progress)
imagePreview(file, selectors.imgPreview)
if (uploadCtr.mode === 'slice') {
fileUpload(file, uploadCtr.sliceSize);
} else {
fileUpload(file);
}
}
async function fileUpload(file, sliceSize) {
sliceSize = sliceSize || file.size;
const fileChuckList = sliceFile(file, sliceSize);
const fileHashObj = await getFileHash(fileChuckList);
uploadCtr.total = fileChuckList.length;
function _upload(i) {
const file = {
fileChuck: fileChuckList[i],
fileChuckHash: fileHashObj.fileChucks[i],
fileHash: fileHashObj.fileHash,
isCompleted: i >= fileChuckList.length - 1
}
if (i >= fileChuckList.length) {
setPhase(UPLOADPHASENUMS.result);
return;
}
uploadCtr.cancelFn = uploadAjax(file,
(percent) => {
selectors.progressBar.style = `--percent:${percent * 100}%;`
},
(result) => {
uploadCtr.index = i + 1;
_upload(i + 1);
}
)
}
_upload(0)
}
// 计算每个文件块的 hash 值
function getFileHash(fileChuckList) {
return new Promise((resolve) => {
const fileHash = {
file: '',
fileChucks: []
}
const spark = new SparkMD5();
function _read(i) {
if (i >= fileChuckList.length) {
fileHash.file = spark.end();
resolve(fileHash);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const bytes = e.target.result;
fileHash.fileChucks.push(SparkMD5.ArrayBuffer.hash(bytes))
spark.append(bytes);
_read(i + 1);
}
reader.readAsArrayBuffer(fileChuckList[i]);
}
_read(0);
})
}
function uploadAjax(file, onProgress, onFinish) {
const formData = new FormData();
formData.append('fileChuck', file.fileChuck);
formData.append('fileChuckHash', file.fileChuckHash);
formData.append('fileHash', file.fileHash);
formData.append('isCompleted', file.isCompleted)
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:8080/upload');
xhr.addEventListener("readystatechange", () => {
if (xhr.readyState === 4 && xhr.status === 200) {
onFinish(JSON.parse(xhr.responseText));
}
})
xhr.addEventListener("progress", (e) => {
let percent = (uploadCtr.index + e.loaded / e.total) / uploadCtr.total;
onProgress(percent);
})
xhr.send(formData);
return () => {
xhr.abort();
}
}