【项目总结】文件上传原理及多场景实现

文件上传的请求体

文件上传本质上也是通过调用后端接口,通过 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-DispositionContent-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>

三个状态的效果图如下所示:

文件上传逻辑和原理

根据页面变化,文件上传核心步骤也可分为三步:

  1. 文件选择:通过 input[type="file"] 选择需要上传的文件,即 file 对象;
  2. 在线预览:如果是图片进行在线预览;
  3. 接口调用:调用文件上传接口,显示进度条,提供上传取消的功能;

==文件选择==

文件选择是通过 <input type="file"> 元素来完成的。

input[type='file'] 有三个附加属性,acceptcapturemultiple

当指定布尔类型属性 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 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 FileBlob 对象指定要读取的文件或数据。

FileReader 有以下3个属性,均为只读:

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.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']multiplewebkitdirectory 属性。

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个如下标准属性:

File 对象除了来自用户在一个 input 元素上选择文件后返回的 FileList 对象外,也可以是来自由拖放操作生成的 DataTransfer 对象,或者来自 HTMLCanvasElement 上的 mozGetAsFile() API。

通过 DataTransfer.files 属性可以拿到 FileList 对象,但是如果是文件夹拿到的只是文件夹的信息而不是文件。

针对文件夹这种场景,可以使用 DataTransferItem 来获取文件

DataTransferItem 的 2个属性:

DataTransferItem 的3个方法:

此功能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>

裁剪上传

文件的裁剪上传需要使用到 canvasCanvasRenderingContext2D.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();
    }
}
相关推荐
百万蹄蹄向前冲2 小时前
2024不一样的VUE3期末考查
前端·javascript·程序员
alikami2 小时前
【若依】用 post 请求传 json 格式的数据下载文件
前端·javascript·json
wakangda3 小时前
React Native 集成原生Android功能
javascript·react native·react.js
吃杠碰小鸡3 小时前
lodash常用函数
前端·javascript
emoji1111113 小时前
前端对页面数据进行缓存
开发语言·前端·javascript
一个处女座的程序猿O(∩_∩)O3 小时前
vue3 如何使用 mounted
前端·javascript·vue.js
User_undefined3 小时前
uniapp Native.js原生arr插件服务发送广播到uniapp页面中
android·javascript·uni-app
麦兜*3 小时前
轮播图带详情插件、uniApp插件
前端·javascript·uni-app·vue
陈大爷(有低保)3 小时前
uniapp小案例---趣味打字坤
前端·javascript·vue.js
博客zhu虎康4 小时前
ElementUI 的 form 表单校验
前端·javascript·elementui