
大文件分片上传这件事我在不同的项目里前前后后做了快五年。每次做都觉得已经熟了但每次都会被真实场景再上一课要么是用户传一个 4K 视频直接白屏要么是服务端把几十个分片合到一起时内存爆掉。最近正好把一个比较完整的上传模块整理成了可复用的网页组件里面包含了分片上传、断点续传、并发控制、视频预览与压缩这些能力。这篇就把思路和源码从头到尾梳理一遍适合正在做文件上传模块的前端、全栈开发者也适合后端同学拿来对照设计接口。1. 大文件为什么一定要做分片1.1 三次直传失败的亲身体会先看最直接的场景用户在后台管理系统里拖进来一个 2GB 的视频文件点上传。如果你直接把整个文件塞进一个请求里接下来会发生三件让我印象特别深刻的事。第一HTTP 请求超时。大多数网关、反向代理设备都有默认超时时间像常用的 Nginx 的 proxy_read_timeout 默认 60 秒很多 Java 容器底层的连接超时也不长。2GB 的文件就算本地上行带宽 20MB/s也要 100 秒更别说真实用户上行的速度通常只有几 MB/s。超时之后连接被端掉前端拿到的就是 502 或者 504用户看到的只有一个失败提示第二次操作跟第一次一样慢。第二浏览器内存扛不住。把整个文件读进内存再 POST 出去对大文件来说就是灾难。用 FileReader 读 ArrayBuffer 也好直接把 File 对象塞进 FormData 也好虽然浏览器对 File 对象本身有惰性读盘的优化但你要做进度、做校验、做重试时难免要把数据读出来处理。文件一大内存占用直接飙升用户电脑稍差一点页面就卡成幻灯片。第三失败了就得重头再来。直传一旦断开已经传出去的部分全部作废。我见过用户在弱网环境下传一个 1GB 的视频连续失败了三次每次都等到 90% 左右才断最后干脆放弃这个功能不用了。对一个面向真实业务的上传模块来说这种体验是不可接受的。1.2 分片上传到底解决了什么问题分片上传的核心思路就是把一个大文件切割成多个小块逐个上传最后在服务端合并。这个思路能同时解决上面三个问题。每一片都很小所以单个请求耗时短不容易触发超时前端只需要一次性缓存一片数据内存占用可控更重要的是传输过程可以被记录和恢复。哪个分片失败了就只重传那一片已经成功的分片可以做持久化记录断网刷新后能直接从断点继续传。这也是视频上传几乎都走分片的原因。视频文件天然就是大文件而且用户对“上传成功”的预期很强没有人愿意传到 80% 然后从头再来。不过要提醒一句分片不是万能药。分片本身会增加请求数量对后端的文件处理逻辑和存储设计提出了新要求。如果文件只有几十 MB 甚至几 MB直传就行没必要分片。我一般以 100MB 到 200MB 作为分片与直传的切换分界线具体还要看业务场景和用户带宽分布。2. 分片方案设计要点2.1 分片大小怎么定2MB、5MB 还是 20MB分片大小是分片方案里第一个要定的参数也是最容易被轻视的参数。我常用的默认值是 5MB这个数字不是拍脑袋来的背后有几个考量。第一个是超时与重试成本的平衡。单片太大重试成本就高单片太小请求数量太多服务端要处理的请求次数暴涨网络往返开销也变大。5MB 对大多数内网和公网环境来说单次上传时间能控制在 1 秒到 3 秒偶发失败重试一次也就几秒钟。第二个是并发时的带宽分配。假设并发 3 片同时传5MB 一片就是同时有 15MB 的数据在路上。对一个上行带宽 5MB/s 的用户来说这个数已经接近上限了。第三个与业务相关如果是传几百 GB 的大型数据文件比如项目里的数据集、二进制固件单片可以放大到 20MB减少请求数量如果是传手机拍的照片和短视频单片 2MB 就够用。分片的大小还需要能均匀切分。用 File 的 slice 方法切时最后一片会小于设定值这是正常的但要注意把边界算对避免出现中间块和末尾块重叠或者丢失。切片时的边界计算我后面在源码里会展示这里先记住一个公式每片的 start 是 index 乘以 chunkSizeend 取 start 加 chunkSize 与文件总大小的较小值。2.2 并发数、字节级进度与带宽分配分片方案里第二个关键参数是并发数。我见过有人把所有分片一次性全部发出去结果用户带宽不够几十个请求挤在一起互相抢带宽总吞吐反而下降服务端也被连接数打挂了。比较稳妥的做法是控制并发在 2 到 4 之间。前端维护一个任务队列每次从队列里取出几个分片并发上传完成一个就补充一个。这样吞吐稳定服务端压力也可控。我在做并发控制时用的模式是“固定数量 Worker 共享队列”每个 Worker 从队列头部取一个分片索引去上传上传完再取下一条并发数永远是常量不会因为某个分片重试导致并发数暴涨。进度计算是个容易被忽略的细节。如果你按“已上传分片数 / 总分片数”算进度在分片大小不均匀时会不准。更合理的算法是按字节算progress 已成功上传的字节总和 / 文件总字节数。每次分片成功后累加该片的实际字节数最后除以文件大小。按字节算出来的进度条是平滑的按片数算出来的进度条会忽快忽慢用户看着心里发慌。2.3 断点续传与秒传的文件指纹设计断点续传的实现思路分三层内存记录、本地持久化和服务端校验。内存记录解决的是页面不刷新时的续传本地持久化解决的是页面刷新后的续传一般用 localStorage 记录文件指纹、分片索引和成功列表服务端校验解决的是跨终端或者换设备后的续传需要后端提供“查询已接收分片列表”的接口前端上传前先问一下哪些片已经传过了跳过已成片。说到文件指纹这里有个常见误区。给整个大文件算一个 MD5 或 SHA-256在大文件场景下耗时很长而且会让前端直接卡住。我在实际项目里的做法是对视频和图片类文件用“文件大小 文件名 首尾各 1MB 的采样哈希”组合成一个轻量的指纹。这个指纹既用于断点续传识别文件也用于秒传校验。秒传的逻辑是前端算好指纹后先调一个检查接口如果服务端已经有相同指纹的文件直接返回“已存在”前端不用传任何分片用户秒开成功页。完整文件哈希在大文件场景下性价比很低除非你有强一致性需求否则别用。3. 视频上传的特殊处理3.1 视频预览与格式校验视频上传和普通大文件上传的差异主要体现在两个地方文件更大、用户对体验更敏感。预览是第一个体验点。用户拖入或选中视频后希望在点击“上传”之前就能看到视频信息缩略图、时长、分辨率、文件大小。前端可以用 URL.createObjectURL(file) 创建本地 URL赋给 video 元素的 src实现开箱即用的本地预览不需要等上传完成。同时用 video 元素的 loadedmetadata 事件拿到 duration 和 videoWidth、videoHeight把这些信息展示在卡片上。这里有个细节在移动端或者部分浏览器上读取元数据需要视频先解码到关键帧loadedmetadata 事件不一定能在第一时间触发。我的经验是监听多个事件以 timeupdate 事件作为兜底只要拿到 duration 就立刻展示拿不到就先显示文件大小。格式校验也不能只看扩展名。后端要校验真实格式前端至少做一层基础校验用 video 元素的 canPlayType 判断浏览器是否支持该容器格式或者用文件头部几个字节的魔数判断真实格式。比如 MP4 文件开头几个字节通常是 ftyp box可以通过 ArrayBuffer 读出来校验。改扩展名伪装成视频文件的情况在真实业务里太常见了后端如果只信扩展名合并之后文件根本播放不了。3.2 视频压缩与转码一条简单路线和一条硬核路线视频文件大的一个解决方案是在用户本地上传前用浏览器能力做转码压缩。现代浏览器里可行两条路线。一条是 canvas 抽帧配合 WebCodecs。以较新版本 Chrome 支持的 WebCodecs 为例可以用 VideoDecoder 解码原始视频逐帧交给 canvas 画出来再用 VideoEncoder 重新编码成 H.264最后封装成 MP4。这条路线能做真正的转码压缩但代码量大还要自己处理封装格式不是所有人都有精力做。另一条是 MediaRecorder实现简单得多。代码骨架大概是下面这样核心思路是把 video 的播放画面实时画到 canvas 上再通过 captureStream 交给 MediaRecorder 重新编码一次async function compressVideo(file, targetWidth 1280, targetBitrate 2_000_000) { const url URL.createObjectURL(file); const video document.createElement(video); video.src url; video.muted true; await new Promise((resolve) (video.onloadedmetadata resolve)); const canvas document.createElement(canvas); const scale Math.min(1, targetWidth / video.videoWidth); canvas.width video.videoWidth * scale; canvas.height video.videoHeight * scale; const ctx canvas.getContext(2d); const stream canvas.captureStream(30); const recorder new MediaRecorder(stream, { mimeType: video/webm;codecsvp9, videoBitsPerSecond: targetBitrate }); const chunks []; recorder.ondataavailable (e) chunks.push(e.data); const stopped new Promise((resolve) (recorder.onstop resolve)); // 这里的关键是播放的同时持续把画面画到 canvas 上 // 完整实现需要配合 requestAnimationFrame 或 requestVideoFrameCallback video.play(); const drawLoop () { if (!video.ended) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); requestAnimationFrame(drawLoop); } }; drawLoop(); recorder.start(); await new Promise((resolve) (video.onended resolve)); recorder.stop(); await stopped; URL.revokeObjectURL(url); return new Blob(chunks, { type: video/webm }); }注意这个骨架没有处理音频轨道真实使用时还要把 video 元素本身的声音轨道通过 AudioContext 传给 MediaRecorder否则压出来的视频没有声音。另外画帧循环里的定时器去掉后要记得清理避免组件卸载后还在跑。我的建议是小项目用 MediaRecorder大项目且有桌面端需求的话再上 WebCodecs。压缩的参数可以这么定同一分辨率下目标码率按原码率的 0.6 起步压缩到原文件 50%-70% 是比较合理的预期。压得太狠画质肉眼可见变差用户不会买账。还有一点要注意转码是 CPU 密集型操作在低端设备上会把 UI 线程卡住。用 Web Worker 做转码能避免页面假死但因为 WebCodecs 和 canvas 在 Worker 中的兼容性还不一致转移策略要分环境处理。我的策略是默认在 Worker 里做遇到不支持的环境就降级为“不转码直接上传原文件”绝不阻塞用户操作。4. 把上传能力封装成网页组件4.1 用原生 Web Components 还是框架组件这里说的“网页组件”很多人第一反应是框架组件比如 Vue 组件、React 组件。但更标准的做法是用原生 Web Components也就是自定义元素加 Shadow DOM。它的最大优势是跟框架无关在 Vue 项目里能用在 React 项目里也能用哪怕直接插到一个纯 HTML 页面里也能用。做成一个独立发布的组件包任何技术栈都能引入。但要注意原生 Web Components 有一些现实问题。一个是 Shadow DOM 的样式隔离外部通过全局 class 改内部样式会失效需要开放 CSS 变量或者 shadow parts 才能定制外观。另一个是表单提交如果封装的是文件选择控件需要处理好内部 input 的 name 属性和 form 关联否则表单提交时拿不到文件。如果项目已经深度绑定某个框架且没有多框架复用的需求直接用框架组件反而更省事。我的判断标准很简单同一个上传能力有几套系统都要用且技术栈不同就做原生组件只在某一个框架里用就做框架组件别过度设计。4.2 组件接口设计与事件通信组件接口设计是决定这个组件好不好用的关键。我对外暴露的属性一般覆盖分片大小、并发数、自动上传开关、接受的 MIME 类型、单个文件大小上限、重试次数。对外暴露的方法包括选择文件、启动上传、暂停、继续、取消。对外派发的事件包括文件选择、上传进度、单分片成功、整体完成、失败、重试中。调用方不需要了解分片细节只要监听事件就能把状态同步到自己的业务界面。自定义元素的事件名称建议统一用带前缀的事件名避免和业务代码里的事件冲突比如 chunk-upload-progress、chunk-upload-success、chunk-upload-error。同时通过 getter/setter 暴露当前状态让调用方可以主动读取。组件内部还需要有一个状态机来管理生命周期从 idle 到 processing、paused、completed、failed。状态变化时向外派发一个状态变更事件。否则外部调用方无法准确判断组件当前处于什么状态容易在错误的时机调用错误的方法比如还没选文件就点上传或者上传已经完成还在点暂停。4.3 Shadow DOM 下的表单与样式问题Shadow DOM 的样式隔离是一把双刃剑。内部用 shadow parts 暴露可定制的部分是个好办法。在组件内部的样式表里用 ::part(trigger) 这样的伪元素暴露关键节点外部通过 ::part 选择器设置样式既不破坏隔离又给了调用方定制空间。表单问题必须专门处理。input typefile 放在 Shadow DOM 里时组件在 form 提交中是“看不见”的。如果上传组件需要作为一个字段参与表单提交方案有两个一是把文件数据复制写到 Shadow DOM 外部的隐藏 input 里二是放弃表单提交改为组件事件驱动把文件数据通过接口传递。多数实际场景是后者但如果你在做旧系统改造表单提交路径一定要提前想清楚。另外还有一个小细节Shadow DOM 内部的 label 和 input 的关联在某些浏览器下会有兼容问题点击 label 不能触发文件选择框。我踩过这个坑后统一改成用一个透明的 input typefile 盖在选择按钮上层点击事件天然落到 input 上避免 label 关联问题。5. 核心源码实现5.1 分片引擎类实现整个上传模块的核心是一个不依赖 UI 的分片上传引擎。UI 只是壳所有分片、并发、重试、进度逻辑都应该放在这个引擎里这样才能在组件和普通页面里复用。先看初始化部分class ChunkUploadEngine { constructor({ file, chunkSize 5 * 1024 * 1024, concurrency 3, retries 3 }) { this.file file; this.chunkSize chunkSize; this.concurrency concurrency; this.retries retries; this.totalChunks Math.ceil(file.size / chunkSize); this.progress 0; // 已上传字节数 this.queue []; this.failed new Map(); this.fileId this.genFileId(file); } genFileId(file) { // 生产环境建议加随机串和用户标识避免同名文件冲突 return ${file.name}_${file.size}_${file.lastModified}; } getRemainingChunks(uploadedList []) { const uploadedSet new Set(uploadedList); const remaining []; for (let i 0; i this.totalChunks; i) { if (!uploadedSet.has(i)) remaining.push(i); } return remaining; } }genFileId 是断点续传和秒传的基石。只取文件名加大小加修改时间在大多数场景够用但同一用户传两个同名同大小文件时会有隐患。我在正式项目里会再拼一段随机串或者用户会话 ID这样 fileId 基本不会撞车。单分片上传的核心逻辑把切片、FormData 组装和请求封装在一个方法里uploadOne(index) { return new Promise((resolve, reject) { const start index * this.chunkSize; const end Math.min(start this.chunkSize, this.file.size); const blob this.file.slice(start, end); const fd new FormData(); fd.append(file, blob, chunk-${index}); fd.append(fileId, this.fileId); fd.append(index, index); fd.append(total, this.totalChunks); const xhr new XMLHttpRequest(); xhr.open(POST, this.uploadUrl); xhr.onload () { if (xhr.status 200 xhr.status 300) { this.progress blob.size; resolve(); } else { reject(new Error(HTTP ${xhr.status})); } }; xhr.onerror () reject(new Error(network error)); xhr.upload.onprogress (e) { if (e.lengthComputable) { // 单片实时进度可用于展示 } }; xhr.send(fd); }); }这里要说清楚为什么用 XMLHttpRequest 而不是 fetch。fetch 虽然简洁但它的上传进度接口到现在也不是标准想拿到真实的上传进度还是得依赖 XMLHttpRequest 的 upload.onprogress。这是分片上传场景下典型的“旧技术比新技术更合适”的例子别盲目追新。并发控制的实现是核心中的核心。用固定数量 Worker 加共享队列的模式async start(uploadedList []) { const remaining this.getRemainingChunks(uploadedList); this.queue [...remaining]; const workers Array.from({ length: this.concurrency }, () this.worker()); await Promise.all(workers); return this.progress this.file.size; } async worker() { while (this.queue.length 0) { const index this.queue.shift(); let success false; for (let attempt 0; attempt this.retries; attempt) { try { await this.uploadOne(index); success true; break; } catch (err) { if (attempt this.retries) { this.failed.set(index, err); } else { // 指数退避300ms、600ms、1200ms... await new Promise((r) setTimeout(r, 300 * Math.pow(2, attempt))); } } } if (!success) { throw new Error(分片 ${index} 最终失败终止上传); } } }这个模式的好处是并发数始终固定不会因为某个分片重试导致并发数失控。失败重试采用了指数退避避免多个重试请求同时打向服务端。重试次数耗尽后立即终止整个上传避免后续分片继续发出去造成更大的脏数据。5.2 组件封装示例与用法引擎搞定后UI 组件就是一层薄壳。以原生自定义元素为例class ChunkUploader extends HTMLElement { static get observedAttributes() { return [chunk-size, concurrency, accept, max-size, upload-url]; } constructor() { super(); this.attachShadow({ mode: open }); this.state idle; } connectedCallback() { this.render(); this.bindEvents(); } render() { this.shadowRoot.innerHTML style .dropzone { border: 2px dashed #ccc; padding: 24px; text-align: center; } .progress-bar { height: 8px; background: #eee; border-radius: 4px; } .progress-inner { height: 100%; width: 0; background: #4caf50; border-radius: 4px; } /style div classdropzone partdropzone input typefile partinput / /div div classprogress-bar div classprogress-inner partprogress/div /div ; } bindEvents() { const input this.shadowRoot.querySelector(input); input.addEventListener(change, (e) { const file e.target.files[0]; if (!file) return; const maxSize Number(this.getAttribute(max-size) || 0); if (maxSize file.size maxSize) { this.dispatchEvent(new CustomEvent(chunk-upload-error, { detail: { code: FILE_TOO_LARGE } })); return; } this.engine new ChunkUploadEngine({ file, chunkSize: Number(this.getAttribute(chunk-size) || 5 * 1024 * 1024), concurrency: Number(this.getAttribute(concurrency) || 3), uploadUrl: this.getAttribute(upload-url) }); this.dispatchEvent(new CustomEvent(chunk-file-selected, { detail: { file, fileId: this.engine.fileId } })); }); } async startUpload() { if (!this.engine) return; this.state processing; this.dispatchEvent(new CustomEvent(chunk-state-change, { detail: { state: this.state } })); this.engine.onProgress (percent) { this.shadowRoot.querySelector(.progress-inner).style.width percent %; this.dispatchEvent(new CustomEvent(chunk-upload-progress, { detail: { percent } })); }; try { // uploadedList 来自服务端 status 接口用于断点续传 await this.engine.start(this.uploadedList || []); this.state completed; this.dispatchEvent(new CustomEvent(chunk-upload-success, { detail: { fileId: this.engine.fileId } })); } catch (err) { this.state failed; this.dispatchEvent(new CustomEvent(chunk-upload-error, { detail: { message: err.message } })); } } } customElements.define(chunk-uploader, ChunkUploader);使用方代码非常简洁chunk-uploader chunk-size5242880 concurrency3 acceptvideo/* upload-url/api/upload/chunk /chunk-uploader script const uploader document.querySelector(chunk-uploader); uploader.addEventListener(chunk-file-selected, (e) { // 此时可以读取视频元信息并展示可以请求服务端 status 接口 // 然后把已上传分片列表回填给组件 }); uploader.addEventListener(chunk-upload-progress, (e) { document.querySelector(#percent).textContent Math.round(e.detail.percent) %; }); uploader.addEventListener(chunk-upload-success, () { // 跳转、刷新文件列表、通知后端触发转码 }); /script用起来就是这样谁要接入上传能力不需要知道分片怎么切、并发怎么控、重试怎么退避只要监听几个事件更新 UI 就完成集成。这也是组件化的意义所在。5.3 后端接口对照实现后端是分片上传完整闭环里不可缺少的一半。前端设计得再好后端不配合也白搭。这里给出一个 Node.js 后端的最小实现方便前后端对照理解。分片接收接口用 multipart 接收单个分片文件const express require(express); const multer require(multer); const fs require(fs); const path require(path); const app express(); const upload multer({ dest: tmp/ }); const TMP_DIR path.join(__dirname, tmp); const UPLOAD_DIR path.join(__dirname, uploads); app.post(/api/upload/chunk, upload.single(file), (req, res) { const { fileId, index, total } req.body; const chunkDir path.join(TMP_DIR, fileId); fs.mkdirSync(chunkDir, { recursive: true }); // 用 rename 是因为 multer 默认会生成一串随机临时文件名 fs.renameSync(req.file.path, path.join(chunkDir, chunk-${index})); res.json({ ok: true, index: Number(index) }); });查询已上传分片接口供前端断点续传时跳过已成功分片app.get(/api/upload/status, (req, res) { const { fileId, total } req.query; const chunkDir path.join(TMP_DIR, fileId); const uploaded []; if (fs.existsSync(chunkDir)) { for (let i 0; i Number(total); i) { if (fs.existsSync(path.join(chunkDir, chunk-${i}))) uploaded.push(i); } } res.json({ uploaded }); });合并接口按顺序把所有分片拼接成完整文件app.post(/api/upload/merge, (req, res) { const { fileId, total, filename } req.body; const chunkDir path.join(TMP_DIR, fileId); if (!fs.existsSync(chunkDir)) { return res.status(400).json({ error: no chunks }); } // path.basename 能过滤掉 ../ 这类路径穿越写法 const safeName path.basename(filename); const dest path.join(UPLOAD_DIR, safeName); const writeStream fs.createWriteStream(dest); for (let i 0; i Number(total); i) { const chunkPath path.join(chunkDir, chunk-${i}); if (!fs.existsSync(chunkPath)) { writeStream.destroy(); return res.status(400).json({ error: 缺少分片 ${i} }); } // 逐片读取写入避免把整个大文件一次性读进内存 writeStream.write(fs.readFileSync(chunkPath)); fs.unlinkSync(chunkPath); } writeStream.end(); fs.rmdirSync(chunkDir, { recursive: true }); res.json({ ok: true, url: /files/${safeName} }); });后端有几个容易忽略的点。合并时一定要逐片读取、逐片写入不要先把所有分片读进内存再一次性写出不然 2GB 文件合并时会直接把进程内存打爆。文件名校验用 path.basename 防止路径穿越是最基本的安全底线。真实项目里建议再加一层文件总大小校验、MIME 校验、分片完整性哈希校验以及合并完成后的文件权限校验。6. 常见问题排查速查表与避坑6.1 高频问题速查表我在不同项目里和不少同事都排查过这些问题整理成速查表遇到类似情况可以直接对照定位。现象常见原因定位思路上传到一半报 504网关或反向代理超时加长超时时间确认单片大小是否过大、单片传输时间是否超标分片越传越慢总吞吐下降并发数设置过高分片互相抢带宽把并发调低到 2-3 片实测总吞吐往往反而提升页面刷新后断点续传失效localStorage 没有完整持久化成功列表确认续传时是否先请求了服务端 status 接口合并时提示缺少第 N 片某一片最终失败但前端没终止上传检查重试耗尽后的中断逻辑服务端增加分片完整性校验大文件上传时页面卡死切片或算哈希把主线程占满了将切片、哈希计算放到 Web Worker同时传多个大文件时服务器内存爆掉合并时一次性读入全部分片改为逐片读写并控制合并任务并发上限视频传完后播放不了只按扩展名判断没有真实校验格式服务端合并后用媒体工具校验或读取真实 MIME 头6.2 几个印象深刻的坑第一个坑是进度条倒着走。早期版本我用“已成功分片数 / 总分片数”算百分比结果在分片大小不均时进度条忽快忽慢用户总以为卡住了。后来改成按字节累计进度立刻平滑了。这个细节后来我写的每个上传组件都直接按字节算从源头避免问题。第二个坑是并发数设置成 10 导致服务端连接被打满。当时测试环境只有一台比较弱的后端服务我在本地把并发调到 10 自测服务端撑不住直接返回 500前端看到失败就自动重试重试又全部并发出去服务端更崩。最后我把前端并发限制在 3重试也做成串行的问题立刻消失。这个教训告诉我并发数和重试策略必须一起设计不能只调一个参数。第三个坑是断点续传的文件标识冲突。早期直接用文件名作为标识结果两个用户传了同名文件分片互相覆盖合并出来的文件是坏的。后来改成“文件名 文件大小 修改时间 随机串”生成 fileId并且在服务端把 fileId 与用户会话绑定问题才彻底解决。第四个坑是视频转码后元数据丢失。用 MediaRecorder 转码后生成的 webm 常常不带时长信息导致播放器显示 00:00。后来我在转码完成后手动写入元数据或者干脆让后端在转码阶段重新生成元数据才解决。第五个坑跟框架集成有关。在 Vue 项目里对自定义元素组件上的属性做动态绑定如果组件内部没有在 observedAttributes 里声明并处理属性变化拿到的就是旧值。组件要么用 attributeChangedCallback 做同步要么使用方通过 setAttribute 方法设置参数。React 18 对自定义元素的属性支持已经比较完善直接传 props 基本没问题。6.3 后续扩展方向最后再说说这个组件后续还能怎么扩展。第一个方向是秒传前端算好文件指纹后先调秒传检查接口服务端已有相同指纹就直接返回已存在一个分片都不用传。第二个方向是服务端异步转码与上传状态打通视频上传完成后通常要转码抽帧前端可以通过轮询或者消息推送拿到转码进度。我在组件里预留了 statusUrl 配置上传完成后组件自动开始轮询状态接口把结果以事件形式抛给业务方。第三个方向是批量上传在引擎外层套一个任务管理器用同样的队列思路管理多个文件每个任务内部再走一遍分片引擎单文件和批量上传共用同一套引擎。我的体会是分片上传这个技术核心不在于代码写得有多花哨而在于把边界情况想清楚分片边界、并发边界、失败边界、内存边界、安全边界。每个边界都踩一遍之后你写的上传模块就能在真实环境里扛得住。这套组件我用到现在最大的感受是“组件化”带来的红利需求方只需要关心业务状态底层分片逻辑几乎不再改动。如果你正准备做类似的上传能力建议从引擎加薄壳这个结构入手先把分片引擎打磨稳再套一层 UI后面无论接 Vue、React 还是纯 HTML 页面都是顺手的事。