1. 项目概述:为什么“Ajax+FormData多文件上传”至今仍是前端工程里的高频刚需
你有没有遇到过这样的场景:用户在表单里要一次性选5张产品图、3份合同扫描件、2段现场视频,然后点击“提交”——结果页面卡住、进度条不动、刷新后文件全丢?或者后端同学一脸困惑地问:“前端传来的文件字段名怎么是file[]?我该怎么解析?”这背后,往往不是技术做不到,而是没选对路子。Ajax + FormData 实现多文件上传,表面看只是个基础功能,实测下来却是检验一个前端工程师是否真正理解“浏览器能力边界”“HTTP协议本质”“前后端协作契约”的试金石。它不依赖任何第三方库,原生支持IE10+(实际项目中IE已基本退出),兼容所有现代浏览器,且能精准控制上传过程中的每一个环节:文件预览、分片校验、进度监听、错误重试、取消上传、并发控制。我带过的几个新人团队,在做内容管理后台时,第一版上传功能用的是<input type="file" multiple>直接submit表单,结果用户一传大文件就超时,日志里全是504;第二版改用fetch+FormData,但没处理好Content-Type自动设置逻辑,后端始终收不到文件;直到第三版才真正跑通带进度条、可取消、支持100MB以上单文件的完整链路。这个过程里踩的坑,比写十个React组件还扎实。本文不讲概念复读,只拆解真实项目中从零搭建这一功能的每一步:前端如何构造FormData、如何监听进度、如何处理不同格式的返回体;后端用Node.js(Express)和Python(Flask)两种主流方案分别实现接收逻辑,重点标注每个参数的取舍理由、常见报错根源、以及生产环境必须加的防护措施。如果你正在开发一个需要稳定上传能力的系统——无论是内部工具、SaaS平台还是电商后台——这篇就是你该抄的第一份作业。
2. 核心设计思路与方案选型逻辑:为什么不用<form>直接提交?为什么拒绝XMLHttpRequest手动拼接?
2.1 放弃传统表单提交的三个硬性理由
很多开发者第一反应是用<form enctype="multipart/form-data">直接提交,看似最省事。但实际项目中,这种方案在三个关键节点上会直接失效:
无法获取实时上传进度:表单提交是黑盒操作,浏览器不提供
onprogress事件。当用户上传一个200MB的视频时,你只能干等,无法显示百分比、剩余时间或暂停按钮。而真实业务中,用户看到“转圈圈”超过10秒就会关闭页面,这是明确的体验断点。无法动态控制请求头与请求体:
<form>提交强制使用multipart/form-data编码,但你无法手动设置Authorization、X-Request-ID等业务必需的Header。比如你的API要求每个请求带JWT token,表单提交根本没法注入。错误处理粒度太粗:表单提交失败,你只能拿到整个页面的HTTP状态码(如500),无法区分是“文件过大”“类型不合法”还是“后端存储服务不可用”。而用户需要的是精准提示:“您上传的PDF文件超过50MB限制,请压缩后重试”。
提示:我曾在一个教育平台项目中坚持用表单提交,结果上线后客服每天收到20+条“上传卡住”的反馈。切到Ajax方案后,7天内相关投诉归零——不是功能变强了,而是把不可见的问题变成了用户可感知、可操作的交互。
2.2 为什么选FormData而不是手动拼接multipart字符串?
有人会想:既然XMLHttpRequest支持发送二进制数据,那我手动构造multipart/form-data的boundary和body不就行了?理论上可行,但实操中会撞上三堵墙:
Boundary生成规则复杂:RFC 7578规定boundary必须是“不会出现在文件内容中的随机字符串”,长度至少6位,且不能以
--开头。手动实现既要保证唯一性,又要避免与文件二进制内容冲突,极易出错。我试过用Date.now()+Math.random()生成,结果在上传含特殊字符的Word文档时,boundary被误识别为分隔符,导致后端解析错乱。文件流处理成本高:手动拼接需将File对象读取为ArrayBuffer,再转换为Uint8Array,最后按boundary切分。这个过程会吃掉大量内存,尤其当用户一次选10个10MB文件时,浏览器可能直接崩溃。而
FormData.append()由浏览器原生实现,内部做了内存优化和流式处理。浏览器兼容性陷阱:老版本Chrome对手动拼接的
multipart支持不一致,某些情况下会静默丢弃文件字段。而FormData自Chrome 22起就稳定支持,且所有现代浏览器行为完全一致。
2.3 Ajax方案的核心优势:可控、可测、可扩展
选择fetch(或XMLHttpRequest)+FormData组合,本质是把上传过程从“浏览器自动托管”切换为“开发者主动掌控”。这种掌控力体现在三个维度:
可控性:你能精确决定何时开始、何时暂停、何时重试。比如检测到网络波动时,自动降级为单文件串行上传;发现某个文件MD5已存在,直接跳过上传。
可测性:所有环节都可通过代码注入Mock。你可以模拟1%的上传失败率,测试错误提示是否准确;可以伪造50MB/s的上传速度,验证进度条是否平滑;这些在表单提交模式下根本无法实现。
可扩展性:后续增加断点续传、秒传、分片上传等功能时,底层结构无需重构。比如加秒传,只需在
append前加一段MD5计算逻辑;加分片,只需把单个File对象切分为多个Blob再循环append。
注意:本文默认使用
fetch而非XMLHttpRequest,因为fetch更符合现代JS规范,且天然支持AbortController取消请求。但如果你的项目仍需兼容IE11,则必须回退到XMLHttpRequest——我会在实操章节给出双版本代码。
3. 前端核心实现:从HTML结构到进度监听的完整闭环
3.1 HTML结构设计:兼顾语义化与交互扩展性
一个健壮的上传组件,HTML结构必须同时满足可访问性(a11y)和后续JS控制需求。以下是经过多个项目验证的最小可用结构:
<!-- 上传容器,用于动态插入进度条、预览图等 --> <div class="upload-container" id="uploadContainer"> <!-- 文件选择区域 --> <label for="fileInput" class="upload-drop-area"> <div class="upload-icon">📁</div> <p class="upload-text">点击选择文件,或拖拽文件到此区域</p> <p class="upload-hint">支持图片、PDF、视频,单个文件不超过100MB</p> </label> <!-- 隐藏的原生input,负责触发文件选择 --> <input type="file" id="fileInput" class="upload-input" multiple accept="image/*,.pdf,.mp4,.mov,.avi" aria-label="选择要上传的文件" > <!-- 已选文件列表,用于展示预览和取消操作 --> <div class="file-list" id="fileList"></div> <!-- 上传控制按钮 --> <div class="upload-actions"> <button type="button" id="uploadBtn" disabled>开始上传</button> <button type="button" id="cancelBtn" disabled>取消上传</button> </div> <!-- 全局进度条 --> <div class="progress-bar" id="globalProgress"> <div class="progress-fill" id="progressFill"></div> <span class="progress-text" id="progressText">0%</span> </div> </div>关键设计点解析:
<label for="fileInput">包裹整个拖拽区:点击任意位置都能触发文件选择,提升触屏设备体验。aria-label确保屏幕阅读器能正确播报。accept属性双重过滤:既限制浏览器文件选择对话框的默认类型(如只显示图片),又为后续JS校验提供依据。注意:accept只是提示,不能替代后端校验。<input>设为隐藏而非display:none:display:none会导致部分浏览器(如旧版Safari)无法触发change事件,应使用position: absolute; left: -9999px等视觉隐藏方式。file-list独立于input:避免直接操作input.files,而是将选中的File对象存入数组并渲染到file-list,这样即使用户再次点击选择,也不会清空已渲染的预览。
3.2 JavaScript核心逻辑:从文件选择到FormData构造
以下代码基于ES6+语法,已通过TypeScript类型检查,可直接用于生产环境:
// 1. 初始化变量 const fileInput = document.getElementById('fileInput'); const fileList = document.getElementById('fileList'); const uploadBtn = document.getElementById('uploadBtn'); const cancelBtn = document.getElementById('cancelBtn'); const progressFill = document.getElementById('progressFill'); const progressText = document.getElementById('progressText'); // 存储已选文件的数组,每个元素包含File对象和唯一ID let selectedFiles = []; let uploadController = null; // 用于取消上传的AbortController实例 // 2. 文件选择事件处理 fileInput.addEventListener('change', (e) => { const files = Array.from(e.target.files); // 过滤掉重复文件(基于name+size判断) const newFiles = files.filter(file => { return !selectedFiles.some(f => f.file.name === file.name && f.file.size === file.size); }); if (newFiles.length === 0) return; // 校验文件大小和类型 const validFiles = newFiles.filter(file => { const maxSize = 100 * 1024 * 1024; // 100MB const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'video/mp4']; if (file.size > maxSize) { alert(`文件 ${file.name} 超过100MB限制`); return false; } if (!validTypes.includes(file.type)) { alert(`文件 ${file.name} 类型不支持,请上传图片、PDF或MP4格式`); return false; } return true; }); // 添加到selectedFiles并渲染预览 validFiles.forEach(file => { const fileId = Date.now() + Math.random().toString(36).substr(2, 9); selectedFiles.push({ id: fileId, file: file, previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null, status: 'pending' // pending | uploading | success | error }); }); renderFileList(); updateButtonState(); }); // 3. 渲染文件列表(含预览图和取消按钮) function renderFileList() { fileList.innerHTML = ''; selectedFiles.forEach(item => { const li = document.createElement('li'); li.className = 'file-item'; li.dataset.id = item.id; let previewHtml = ''; if (item.previewUrl) { previewHtml = `<img src="${item.previewUrl}" alt="${item.file.name}" class="file-preview">`; } else { previewHtml = `<div class="file-icon">${getFileTypeIcon(item.file.type)}</div>`; } li.innerHTML = ` <div class="file-info"> ${previewHtml} <div class="file-details"> <div class="file-name">${item.file.name}</div> <div class="file-size">${formatFileSize(item.file.size)}</div> </div> </div> <button type="button" class="remove-btn">// 替换startUpload函数中的fetch调用部分 async function startUploadWithProgress() { if (selectedFiles.length === 0) return; const formData = new FormData(); selectedFiles.forEach(item => { formData.append('files[]', item.file, item.file.name); }); // 将FormData转换为可读流 const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); // 创建一个Promise来等待流写入完成 const writePromise = (async () => { const parts = []; const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substr(2, 9); // 手动构造multipart body(仅用于进度计算,实际仍走fetch) // 此处省略具体构造逻辑,因过于复杂且易出错 // 生产环境推荐使用第三方库如formdata-polyfill,或直接回退到XMLHttpRequest })(); // 更务实的方案:使用XMLHttpRequest(兼容性更好,API更成熟) return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); // 进度事件监听(关键!) xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); progressFill.style.width = `${percent}%`; progressText.textContent = `${percent}%`; } }); // 上传完成事件 xhr.upload.addEventListener('load', () => { progressFill.style.width = '100%'; progressText.textContent = '100%'; }); // 上传错误事件 xhr.upload.addEventListener('error', () => { alert('上传过程中发生网络错误'); reject(new Error('Network error')); }); // 请求完成(无论成功失败) xhr.addEventListener('loadend', () => { if (xhr.status >= 200 && xhr.status < 300) { try { const result = JSON.parse(xhr.responseText); resolve(result); } catch (e) { reject(new Error('Invalid JSON response')); } } else { reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`)); } }); xhr.open('POST', '/api/upload'); xhr.setRequestHeader('Authorization', 'Bearer ' + getAuthToken()); // 添加业务Header // 发送FormData xhr.send(formData); }); }实操心得:在2023年后的项目中,我已全面转向
XMLHttpRequest处理文件上传。不是因为它更先进,而是fetch的进度监听方案在Safari上存在兼容性问题,且代码复杂度远高于收益。XMLHttpRequest的upload属性提供了稳定、标准的进度事件,且所有浏览器实现一致。真正的工程决策,不是选“最新”,而是选“最稳”。
4. 后端接收实现:Express与Flask双方案详解
4.1 Node.js + Express方案:使用Multer中间件的深度配置
Express本身不解析multipart/form-data,必须借助中间件。Multer是最成熟的方案,但其默认配置在生产环境有严重隐患,必须深度定制:
const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs').promises; const app = express(); // 1. 存储配置:绝不使用MemoryStorage! // MemoryStorage会将整个文件加载到内存,上传100MB文件直接OOM const storage = multer.diskStorage({ destination: async (req, file, cb) => { // 动态创建按日期划分的目录,避免单目录文件过多 const uploadDir = path.join(__dirname, 'uploads', new Date().toISOString().split('T')[0]); try { await fs.access(uploadDir); } catch { await fs.mkdir(uploadDir, { recursive: true }); } cb(null, uploadDir); }, filename: (req, file, cb) => { // 生成唯一文件名,防止同名覆盖 const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); const ext = path.extname(file.originalname); cb(null, file.fieldname + '-' + uniqueSuffix + ext); } }); // 2. 文件过滤:严格校验类型和大小 const fileFilter = (req, file, cb) => { // 检查文件类型(注意:file.mimetype由客户端提供,不可信!) const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'video/mp4']; if (!allowedTypes.includes(file.mimetype)) { return cb(new Error(`不支持的文件类型:${file.mimetype}`), false); } // 检查文件大小(单位:字节) const maxSize = 100 * 1024 * 1024; // 100MB if (file.size > maxSize) { return cb(new Error(`文件大小超过限制:${formatFileSize(maxSize)}`), false); } cb(null, true); }; // 3. Multer实例化:关键参数详解 const upload = multer({ storage: storage, fileFilter: fileFilter, limits: { // 字段数量限制:防止恶意提交大量字段耗尽内存 fields: 10, // 单个文件大小限制(与fileFilter双重校验) fileSize: 100 * 1024 * 1024, // 总体请求体大小限制(包括所有文件+文本字段) // 必须大于单个文件限制,否则多文件上传会失败 fieldSize: 200 * 1024 * 1024 } }); // 4. 上传路由处理 app.post('/api/upload', upload.array('files[]', 10), (req, res) => { try { // req.files 是Multer解析后的文件数组 // req.body 包含其他文本字段(如category_id, user_id) // 安全校验:重新检查文件扩展名(mimetype可伪造,扩展名更可靠) const invalidFiles = req.files.filter(file => { const ext = path.extname(file.originalname).toLowerCase(); return !['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.mp4'].includes(ext); }); if (invalidFiles.length > 0) { // 删除已保存的非法文件 invalidFiles.forEach(file => { fs.unlink(file.path).catch(() => {}); }); return res.status(400).json({ error: '检测到非法文件扩展名' }); } // 业务逻辑:生成访问URL、记录数据库、触发异步处理等 const uploadedFiles = req.files.map(file => ({ originalName: file.originalname, fileName: file.filename, size: file.size, url: `/uploads/${path.basename(path.dirname(file.path))}/${file.filename}`, mimeType: file.mimetype })); // 返回成功响应 res.status(200).json({ success: true, message: '上传成功', files: uploadedFiles, total: uploadedFiles.length }); } catch (error) { console.error('上传处理异常', error); res.status(500).json({ error: '服务器内部错误' }); } }); // 5. 全局错误处理中间件 app.use((err, req, res, next) => { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: '单个文件大小不能超过100MB' }); } if (err.code === 'LIMIT_UNEXPECTED_FILE') { return res.status(400).json({ error: '请勿上传多余文件字段' }); } next(err); });关键参数解析:
storage配置中的destination异步创建目录:避免并发上传时mkdir报错。fs.access()先检查目录是否存在,比直接mkdir(..., {recursive:true})更安全。limits.fieldSize必须大于fileSize:fieldSize限制整个请求体大小,而fileSize只限制单个文件。当上传5个20MB文件时,总大小100MB,此时fieldSize必须≥100MB,否则Multer会在解析中途报错。upload.array('files[]', 10)的第二个参数10是最大文件数:防止用户恶意提交1000个文件耗尽服务器资源。这个值必须与前端<input multiple>的webkitdirectory等属性配合。双重文件类型校验:先用
file.mimetype快速过滤(效率高),再用path.extname()二次校验(防伪造)。这是生产环境的必备防护。
4.2 Python + Flask方案:使用Werkzeug的原生解析能力
Flask的request.files直接暴露Werkzeug的FileStorage对象,无需额外中间件,但需手动处理存储和校验:
from flask import Flask, request, jsonify from werkzeug.utils import secure_filename import os import uuid from datetime import datetime app = Flask(__name__) # 配置 UPLOAD_FOLDER = 'uploads' MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200MB 总请求体大小 ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'mp4'} app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH # 确保上传目录存在 os.makedirs(UPLOAD_FOLDER, exist_ok=True) def allowed_file(filename): """检查文件扩展名是否合法""" return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @app.route('/api/upload', methods=['POST']) def upload_files(): try: # 1. 检查是否有文件字段 if 'files[]' not in request.files: return jsonify({'error': '未找到文件字段'}), 400 # 2. 获取文件列表 files = request.files.getlist('files[]') # 3. 校验文件数量 if len(files) == 0: return jsonify({'error': '未选择文件'}), 400 if len(files) > 10: # 限制最多10个文件 return jsonify({'error': '最多允许上传10个文件'}), 400 # 4. 处理每个文件 uploaded_files = [] for file in files: # 检查文件名是否为空 if file.filename == '': continue # 安全校验文件名 filename = secure_filename(file.filename) # 检查扩展名 if not allowed_file(filename): return jsonify({'error': f'文件 {filename} 类型不支持'}), 400 # 检查文件大小(Werkzeug已通过MAX_CONTENT_LENGTH全局限制) # 但这里可做二次校验 file.seek(0, os.SEEK_END) file_size = file.tell() file.seek(0) # 重置指针 if file_size > 100 * 1024 * 1024: # 100MB return jsonify({'error': f'文件 {filename} 超过100MB限制'}), 400 # 生成唯一文件名 unique_id = str(uuid.uuid4()) date_str = datetime.now().strftime('%Y-%m-%d') safe_filename = f"{unique_id}_{filename}" # 创建按日期划分的子目录 date_folder = os.path.join(app.config['UPLOAD_FOLDER'], date_str) os.makedirs(date_folder, exist_ok=True) # 保存文件 file_path = os.path.join(date_folder, safe_filename) file.save(file_path) # 记录文件信息 uploaded_files.append({ 'original_name': file.filename, 'saved_name': safe_filename, 'size': file_size, 'url': f'/uploads/{date_str}/{safe_filename}', 'mimetype': file.mimetype }) return jsonify({ 'success': True, 'message': '上传成功', 'files': uploaded_files, 'total': len(uploaded_files) }), 200 except Exception as e: print(f'上传异常: {str(e)}') return jsonify({'error': '服务器内部错误'}), 500 # 错误处理器 @app.errorhandler(413) def request_entity_too_large(error): return jsonify({'error': '请求体过大,请检查文件总大小'}), 413Flask方案的关键差异点:
secure_filename()是必须调用的:它会移除文件名中的路径遍历字符(如../),防止攻击者通过恶意文件名写入系统目录。file.seek(0, os.SEEK_END)获取文件大小:Werkzeug的FileStorage对象不直接暴露size属性,必须通过seek操作计算。注意seek(0)重置指针,否则后续file.save()会写入空文件。uuid.uuid4()生成唯一ID:比时间戳+随机数更可靠,避免高并发下的重复风险。
5. 常见问题与实战排查技巧:那些文档里不会写的坑
5.1 前端典型问题速查表
| 问题现象 | 根本原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
fetch上传后端收不到文件,req.files为空 | 前端设置了Content-Type头 | 检查fetch配置中是否手动设置了headers: {'Content-Type': '...'} | 删除所有手动设置的Content-Type,让浏览器自动处理 |
| 上传大文件时页面卡死、内存飙升 | 使用URL.createObjectURL()后未释放 | 在控制台执行performance.memory查看内存占用,上传后是否回落 | 在文件移除或上传成功后,立即调用URL.revokeObjectURL(url) |
拖拽上传不触发drop事件 | dragover事件未阻止默认行为 | 在drop事件监听器前,检查是否绑定了dragover并调用了e.preventDefault() | 为容器添加dragover事件:e => { e.preventDefault(); return false; } |
| 进度条永远显示0% | XMLHttpRequest.upload事件未绑定 | 检查xhr.upload.addEventListener('progress', ...)是否在xhr.open()之后调用 | 必须在xhr.open()之后、xhr.send()之前绑定事件 |
| iOS Safari上传失败,控制台无报错 | iOS对<input type="file">的multiple支持不一致 | 在iOS设备上测试,尝试去掉multiple属性 | 对iOS UA做降级处理:`if (/iPad |
5.2 后端典型问题速查表
| 问题现象 | 根本原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
Multer报错Unexpected field | 前端formData.append()的字段名与后端upload.array()参数不一致 | 检查前端append('files[]', ...)和后端upload.array('files[]', ...)是否完全相同 | 字段名必须一字不差,包括方括号[] |
Flask收到文件但file.filename为空字符串 | 前端未传filename参数给formData.append() | 在前端调试中打印file.name,确认是否为有效字符串 | formData.append('files[]', file, file.name)必须传第三个参数 |
| 上传成功但文件内容损坏(如图片打不开) | 后端存储时编码错误 | 用hexdump -C查看保存的文件头是否与原始文件一致 | Flask中直接调用file.save()即可,不要用file.read()再写入 |
并发上传时出现EACCES: permission denied | 多进程同时创建同一目录 | 查看服务器日志,搜索EACCES错误 | 在destination函数中使用fs.access()预检查,而非直接mkdir |
5.3 生产环境必加的五道防护
Nginx反向代理层限流:在Nginx配置中添加
client_max_body_size 200M;,并在location /api/upload块中设置limit_req zone=upload burst=5 nodelay;,防止恶意用户发起海量小文件请求。文件病毒扫描集成:上传成功后,调用ClamAV或商业杀毒API扫描文件。我所在团队在Flask中增加了异步扫描钩子,扫描失败的文件自动删除并通知管理员。
临时文件自动清理:设置定时任务,每天凌晨删除
uploads/目录下7天前的空目录和临时文件。避免磁盘被占满。CDN回源鉴权:上传后的文件URL不应直接暴露源站地址。我们使用签名URL,有效期2小时,过期后自动返回403。
前端二次校验:除了
accept属性,上传前用FileReader读取文件头(magic number)校验真实类型。例如JPEG文件头必须是FF D8 FF,PDF必须是25 50 44 46。
我踩过最深的坑是在一个金融客户项目中,没做文件头校验,攻击者上传了一个
.jpg后缀的PHP木马,通过修改Content-Type绕过accept检查,最终导致服务器被植入后门。从此以后,所有项目都强制加入magic number校验——它增加不了多少代码,但能挡住90%的文件上传漏洞。
6. 进阶能力延伸:从基础上传到企业级文件服务
6.1 断点续传的实现原理与关键代码
断点续传不是“高级功能”,而是大文件上传的生存底线。其核心是将文件切分为固定大小的块(如1MB),每块单独上传,并记录已上传块的MD5。当上传中断时,前端先请求后端校验已上传块,再从第一个未上传块继续。
关键步骤:
前端切片:
file.slice(start, end)生成Blob,用FileReader读取并计算MD5(推荐spark-md5库)。后端校验接口:
POST /api/upload/check接收文件MD5和块索引,返回已上传块列表。合并接口:所有块上传完成后,调用
POST /api/upload/merge触发服务端合并。
// 前端切片上传伪代码 async function uploadChunk(file, chunkIndex, totalChunks) { const chunkSize = 1024 * 1024; //