文件上传的请求体
文件上传本质上也是通过调用后端接口,通过 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,alloruninitialized之一。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();
}
}