文件上传的核心逻辑
两端一协议
- 两端:客户端 / 服务端
- 客户端:样式、交互逻辑、运算
- 服务端:存储、安全、访问控制
- 网络协议协议:消息格式、传输方式(HTTP)
上传文件步骤
- 网络通信
- 界面交互
点击图片上传的请求体格式示例:
# upload-test.http POST /upload/single HTTP/1.1 Host: test.com:5173 Content-Type: multipart/form-data; boundary=aaa # 界符 --aaa Content-Disposition: form-data; name="avatar"; filename="small.jpg" # 根据接口文档填写 name, 服务器读取的后缀名是 filename 中的文件名 Content-Type: image/jpeg # MIME 图片的二进制数据 ---aaa---
所以图片上传不需要什么别的东西,只需要一个 HTTP 即可。
但文件点击上传(带进度监控以及取消上传)
function upload(file, onProgress, onFinish) { const xhr = new XMLHttpRequest(); xhr.onload = function() { const res = JSON.parse(xhr.responseText); onFinish(resp); }; xhr.upload.onprogress = (e) => { const precent = Math.floor((e.loaded / e.total) * 100); onProgress(precent); }; xhr.open('POST', 'http://test.com:5173/upload/single'); const form = new FormData(); form.append('avatar', file); xhr.send(form); return function() { xhr.abort(); } }
拖拽上传
拖拽上传在上传的过程和点击上传也是同样的,而拖拽是交互层面的内容,所以我们只需要考虑拖拽如何实现。
在点击上传中,使用
<input type=’file’ /> 来实现上传,它原生支持拖拽上传,同样只需要监听 onchange 事件,来拿到需要上传的文件。如果为了考虑浏览器兼容问题,不能够使用 input 标签,那么就需要让一个元素成为可拖拽目标,这就可以用到 H5 的拖拽 API,为拖拽到的元素注册几个事件:
dom.ondragenter = e => { e.preventDefault(); // 添加拖拽到目标元素的样式 } dom.ondragover = e => { e.preventDefault(); // 清空拖拽样式 } dom.ondrop = e => { // 拖拽到指定位置放下之后 e.preventDefault(); console.log(e.dataTransfer.files) if (!e.dataTransfer.types.indludes('Files')) { // 如果拖拽的是页面的图片,那么 type 为 text/html,正常的文件是 Files alert('请上传文件类型') } dom.selectFile.files = e.dataTransfer.files; changeHandler(); }
base64 上传
如果文件上传的场景是 base64 上传
- 请求路径 /upload/base64
- 请求方法 POST
- 消息格式 json
{ "ext": ".png", "avatar": "图片的 base64 格式“ }
- 允许的后缀名 […]
- …
function uploadBase64(file, onProgress, onFinish) { const ext = '.' + file.name.split('.').pop(); // 拿到文件拓展名 const reader = new FileReader(); let xhr; reader.onload = (e) => { data = e.target.result.split(',').pop(); // 拿到文件 base64 编码 xhr = new XMLHttpRequest(); xhr.onload = function() { const res = JSON.parse(xhr.responseText); onFinish(resp); }; xhr.upload.onprogress = (e) => { const precent = Math.floor((e.loaded / e.total) * 100); onProgress(precent); }; xhr.open('POST', 'http://test.com:5173/upload/base64'); xhr.setRequestHeader('content-type', 'application/json'); xhr.send(JSON.stringify({ ext, avatar: data })); } reader.readAsDataURL(file); // 异步函数,从 onload 中拿到最后的结果 return function() { xhr && xhr.abort(); } }
二进制格式上传
二进制上传比较简单:
- 消息类型:binary(application/octet-stream)
- 请求头 x-ext:文件的后缀名
# binary.http POST /upload/binary HTTP/1.1 Host: test.com:5173 Content-Type: application/octet-stream x-ext: .jpg 图片的二进制数据
function upload(file, onProgress, onFinish) { const xhr = new XMLHttpRequest(); xhr.onload = function() { const res = JSON.parse(xhr.responseText); onFinish(resp); }; xhr.upload.onprogress = (e) => { const precent = Math.floor((e.loaded / e.total) * 100); onProgress(precent); }; xhr.open('POST', 'http://test.com:5173/upload/single'); // 设置请求头 xhr.setRequestHeader('content-type', 'application/octet-stream'); xhr.setRequestHeader('x-ext', file.name.split('.').pop()); xhr.send(file); // 直接放 file 对象 return function() { xhr.abort(); } }
其余场景
一次上传多个文件?
给 input 标签加上一个 boolean 类型的属性 multiple:
<input type="file" multiple>
所有的文件都会添加到 Files 对象中。
如何上传一个文件夹?
添加
…directory 属性之后(浏览器兼容),选择一个文件夹会递归查找这个文件夹内的所有文件,并最后添加到 Files 对象中。<input type="file" webkitdirectory directory mozdirectory odirectory />
如何拖拽多个文件或文件夹?
依旧使用拖拽 api,如果是文件的话,那么可以通过遍厉
e.dataTransfer.items 对象,通过 entry 来拿到所有的 file 对象。但是对于目录,我们需要自己递归进行上述步骤,直到遍历完所有的文件,汇总到 files 中。
dom.ondrag = (e) => { e.preventDefault(); for (const item of e.dataTransfer.items) { console.log(item); const entry = item.webkitGetAsEntry(); if (entry.isDirectory) { // 目录 const reader = entry.createReader(); reader.readEntries(entries => { for (const entr of entries) { entr.file(f => { // 可能是 file 也啃根是 目录,所以需要递归进行这些事情,拿到所有的 file 对象 console.log(f); }) } }) } else { entry.file(f => { // file 对象 console.log(f); }) } } }
如何实现多文件上传的网络传输?
两种思路:
- 把所有的文件合并到一个请求发送给服务端
- 每个文件都是独立的请求发送给服务端
图片裁剪上传
- 如何实现本地预览?
- 如何实现图像的部分上传?
<body> <input type="file" /> <img class="preview" /> <button>生成截图后的 File 对象</button> <script> const inputFile = document.querySelector('input[type="file"]'); const img = document.querySelector(".preview"); const btn = document.querySelector("button"); inputFile.onchange = (e) => { const file = e.target.files[0]; const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = (e) => { console.log(e.target.result); img.src = e.target.result; // 实现裁剪 }; }; // 生成截图后的 File 对象 btn.onclick = () => { // 模拟裁剪信息 const cutInfo = { x: 500, y: 500, cutWidth: 200, cutHeight: 200, width: 100, height: 100, }; const canvas = document.createElement("canvas"); canvas.width = cutInfo.width; canvas.height = cutInfo.height; const ctx = canvas.getContext("2d"); ctx.drawImage( img, cutInfo.x, cutInfo.y, cutInfo.cutWidth, cutInfo.cutHeight, 0, 0, cutInfo.width, cutInfo.height, ); canvas.toBlob((blob) => { const file = new File([blob], "cut-image.jpg", { type: "image/jpeg", }); console.log(file); // ajax --- file ---> 服务器 }, "image/jpeg"); document.body.appendChild(canvas); }; </script> </body>
大文件上传
<body> <input type="file" /> <script> const file = document.querySelector('input[type="file"]'); file.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const chunks = createChunks(file, 10 * 1024 * 1024); const hashValue = await hash(chunks); console.log(hashValue); }; function hash(chunks) { // 计算哈希值的逻辑 return new Promise((resolve) => { const spark = new SparkMD5(); function _read(i) { if (i >= chunks.length) { resolve(spark.end()); return; } const blob = chunks[i]; const reader = new FileReader(); reader.onload = (e) => { const bytes = e.target.result; // 读取到的字节数组 spark.append(bytes); // 通过增量算法计算哈希值 _read(i + 1); }; reader.readAsArrayBuffer(blob); } _read(0); }); } function createChunks(file, chunkSize) { const result = []; for (let i = 0; i < file.size; i += chunkSize) { const chunk = file.slice(i, i + chunkSize); result.push(chunk); } return result; } </script> </body>
