news 2026/11/5 4:56:05

大文件上传优化:分片上传、秒传与断点续传的完整实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大文件上传优化:分片上传、秒传与断点续传的完整实战

大文件上传这个需求,做过文件系统或者内容管理平台的开发,应该都不陌生。平时后台接到的最烦人的需求,大概率就是“客户传了一个2GB的视频,后台一直转圈”“传了一半断网了,又得从头来”“同事今天拷同一个文件,传了三次”。这些痛点的通用解法,就是分片上传,再配合秒传和断点续传。这篇文章把原理讲清楚,并且给一套能直接跑的源码,前端用原生JavaScript,后端用Node.js Express,你照着接进项目里就能开工。

先说清楚,这篇内容适合谁看:在做文件上传功能的后端开发或全栈开发,被大文件上传折磨过、想给项目加秒传续传能力的人。看完你能搞明白三件事:怎么把文件切碎传上去、怎么让用户上传同一个文件时瞬间完成、怎么让网络中断后不用从头重传。原理部分同样适用于其他语言,源码部分是基于常见实践的完整Demo,可以直接改。

1. 方案设计与核心思路

1.1 为什么一定要分片

直接从HTTP协议聊起。单次请求把整个文件放进Body里上传,实际上是把所有数据一次性塞进网络通道,通道一拥堵,连接断开,前功尽弃。更麻烦的是,浏览器和服务器对单次请求体大小通常有限制,虽然可以调配置,但是一个2GB文件传一半连接断了,重传成本高到让人崩溃。

分片上传的思路,就是把大文件切成若干小块。浏览器端用File对象的slice方法,把一个文件拆成多个Blob,每个Blob作为一个独立的请求体发送。服务端收到所有小块之后,再按顺序拼成一个完整文件。

相当于搬一个衣柜进楼。整柜硬扛,楼梯转角卡住,还得退回去重新想办法;拆成木板搬进去,到了房间再组装,任何一个板子掉了只需要重新搬那一块。分片上传的意义不只是绕开请求体上限,更关键的是它让“断点续传”成了可能——因为上传的最小单位从“整个文件”变成了“每一片”,传成功的片就有记录,没传的片单独补。

1.2 秒传的本质

秒传这个功能第一次接触的人容易误以为是“上传速度突破了网络限制”,其实跟网速一点关系都没有。秒传是靠“识别重复”做到的。

文件上传之前,前端先计算整个文件的MD5指纹(或SHA-256),这个指纹由文件内容决定,内容相同指纹必然相同。前端把指纹发给后端,后端在自己的存储目录或者数据库里查一遍:“这个指纹的文件,我之前是不是已经存过了?”如果存过,那这次上传根本不需要传输数据,直接告诉前端“你传的文件已经存在”,前端弹一个“上传成功”,整个过程一秒内完成。

生活里最好理解的就是网盘。你往网盘传一部热门电影,进度条瞬间到100%,不是你的网速忽然变快了,而是全网上传过这个文件的人太多了,那份数据早就躺在服务器里,你上传的只是一份索引。

这里面有个关键点:MD5计算本身是有时间成本的。一个1GB的文件,即使用增量计算方式,也要读一遍整份文件才能算完。所以秒传的流程实际上是“先算指纹,再传输,或者不传输”,计算指纹的几秒钟,和传整个文件的时间相比,完全可以接受。

1.3 续传的核心逻辑

断点续传的原理比秒传稍微绕一点,但也不复杂。核心问题是:文件被切成N个分片后,网络中断导致只传了一半,那么后端需要记录“哪些分片已经传成功”。下次用户重新选择这个文件,前端先询问后端,后端把已上传的分片列表返回,前端只挑缺失的分片继续传。传完再合并,整体流程并不会从头开始。

这里的“状态记录”,最简单的做法就是依赖文件系统本身。后端为每个文件指纹建一个专属目录,这个文件的每个分片是一个独立小文件。接口查询的时候扫一下目录,所有存在的小文件就是已经传成功的分片。相比用数据库表记录,这样做少了一层依赖,也不会出现“数据库说传了,磁盘上却没有”的状态不一致问题。

整体设计就是一条清晰的链路:先算指纹,再检查是否已存在,存在就直接结束;不存在就再看哪些片传过了,补齐剩余片,最后合并,清理分片临时文件。这可以说是分片上传的黄金流程,后端的接口设置都是围绕这条链路展开的。

2. 关键技术细节与工程实践

2.1 文件指纹计算:MD5的正确用法

先说一个很多人踩过的坑:直接用FileReader把整个文件读进内存再算MD5,文件一上200MB,浏览器直接卡死,甚至直接崩溃。正确做法必须是“增量计算”。

SparkMD5这个库支持分块追加数据,每一块读进内存算出部分摘要,读完一块就释放一块的内存,然后连同之前的结果一起累积计算。项目里用的是SparkMD5.ArrayBuffer,代码中配合FileReader读取每2MB的切片,全部读完之后再end()拿到完整指纹。

计算指纹的进度也不能瞒着用户,要显示“正在计算指纹 xx%”。这个功能虽然简单,但是体验差距巨大,实测下来,用户看到计算进度的耐心,比看到空白进度条等半天要高得多。

另外要说一下安全层面的考虑:MD5本身并不安全,恶意构造碰撞是可能的。但在文件上传场景里,它们是用来“识别重复数据”,而不是用于身份认证或者数字签名,碰撞的实际危害面很小。如果项目有强安全要求,可以直接换成SHA-256,计算方法一样,代码里替换库和算法名就行。

2.2 分片大小与并发控制

分片大小没有绝对标准,但有个经验区间:1MB到10MB之间是比较合理的。分片太小,比如64KB,分片数量太多,请求量巨大,光HTTP握手的时间就浪费掉了;分片太大,比如50MB,又重新回到了“大请求体”的老路上,中途失败重传的成本高。

我用2MB来做演示,这个值在大多数业务场景下表现均衡。总请求数等于文件大小除以分片大小。一个2GB的文件,切成2MB分片,大概是1024个请求。如果串行上传,每个请求哪怕只花1秒,也要17分钟;改成并发之后,整体耗时大幅压缩。

并发也不是越大越好。浏览器对同一域名的并发连接数是有上限的,一般6到8个左右。而且带宽有限,无脑开20个并发,资源互相抢,整体速度反而上不去。我一般控制在3到5个并发,代码里用一个简单的worker模型实现:一个队列,三个worker循环从队列里取分片上传,互相之间不抢资源。这个模型看起来简单,放在大文件上传场景里非常实用。

下面的表格是分片大小和并发数的参考对比:

场景推荐分片大小推荐并发数说明
弱网环境(如移动端)2MB2-3降低单片失败率,失败重传成本低
普通Web应用4MB3-5平衡性能与服务器压力
内网/高速网络10MB5-10分片少,请求数少,但单片变大
超大文件(10GB以上)5MB5兼顾带宽和分片数,便于断点续传

2.3 服务端分片存储与状态记录

服务端接收分片,需要保证两件事:分片不被覆盖写错,分片能够被识别归属。项目里用Multer处理文件上传,接收到的文件保存到chunks目录,文件名直接用{文件指纹}_{分片索引}.{原始后缀}的格式,比如a1b2c3..._0、a1b2c3..._1。

这样做的好处是天然具备自描述性:查询已上传分片时,只需要列出目录里以a1b2c3...开头的文件,解析出后面的数字索引,就能知道哪些分片到位了。不需要额外建表,也不用维护状态机,文件系统的属性就充当了数据库。

合并分片时,按索引顺序依次读取每个分片文件,追加写入最终目标文件。合并完成之后必须清理临时分片,否则时间一长,chunks目录里堆满了半截文件,磁盘空间很快就爆炸了。

这里还要强调一个经验性细节:合并时用流式写入,不要先把所有分片读进内存再一次性写入。FS.createWriteStream配合逐片读取,可以保证内存占用稳定。

3. 源码实现:从0到1的完整流程

3.1 项目结构与环境准备

实际操作用的目录结构是这样的:

project/ ├── package.json ├── server/ │ └── index.js # 后端服务 ├── public/ │ └── upload.html # 前端页面 ├── uploads/ # 合并后的最终文件 └── chunks/ # 临时分片存储

环境要求是Node.js 12以上,安装了Express和Multer两个依赖,前端页面引用了SparkMD5的CDN。安装依赖只需要一条命令:

npm install express multer

需要注意,如果要在生产环境使用,CDN资源建议下载到本地或者走内部源,避免页面加载依赖外部网络。前端页面我用简单的原生HTML来演示,不引入前端框架,这样原理展示得更直接,接入React或Vue项目时,移植相关业务逻辑即可。

3.2 前端源码:指纹计算、分片上传与进度显示

前端的核心逻辑封装在页面的script部分,整个流程按照“算指纹、查状态、传缺失分片、发起合并”的顺序执行:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>大文件分片上传 Demo</title> <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script> </head> <body> <div> <p>选择文件后点击上传,分片大小固定为2MB</p> <input type="file" id="fileInput"> <button id="uploadBtn">上传</button> <div id="status"></div> </div> <script> const CHUNK_SIZE = 2 * 1024 * 1024; function calcFileMD5(file) { return new Promise((resolve, reject) => { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let offset = 0; const total = file.size; function readNextChunk() { if (offset >= total) { resolve(spark.end()); return; } const blob = file.slice(offset, offset + CHUNK_SIZE); reader.onload = (e) => { spark.append(e.target.result); offset += CHUNK_SIZE; showStatus('计算文件指纹中: ' + Math.min(offset / total * 100, 100).toFixed(0) + '%'); readNextChunk(); }; reader.onerror = reject; reader.readAsArrayBuffer(blob); } readNextChunk(); }); } function checkFile(hash, totalChunks) { return fetch('/api/check?hash=' + hash + '&totalChunks=' + totalChunks) .then(r => r.json()); } function uploadChunk(file, hash, index) { const start = index * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const form = new FormData(); form.append('chunk', file.slice(start, end)); form.append('hash', hash); form.append('index', index); return fetch('/api/upload', { method: 'POST', body: form }) .then(r => r.json()); } function mergeFile(hash, totalChunks) { return fetch('/api/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash: hash, totalChunks: totalChunks }) }).then(r => r.json()); } function showStatus(text) { document.getElementById('status').textContent = text; } document.getElementById('uploadBtn').addEventListener('click', async () => { const file = document.getElementById('fileInput').files[0]; if (!file) { alert('请先选择一个文件'); return; } const totalChunks = Math.ceil(file.size / CHUNK_SIZE); showStatus('开始计算文件指纹...'); const hash = await calcFileMD5(file); const checkResult = await checkFile(hash, totalChunks); if (checkResult.exists) { showStatus('秒传成功,文件已存在于服务器'); return; } const uploadedSet = new Set(checkResult.uploadedChunks || []); const pendingChunkIndexes = []; for (let i = 0; i < totalChunks; i++) { if (!uploadedSet.has(i)) { pendingChunkIndexes.push(i); } } showStatus('待上传分片数: ' + pendingChunkIndexes.length); const concurrency = 3; const queue = [...pendingChunkIndexes]; let cursor = 0; let completed = uploadedSet.size; async function worker() { while (cursor < queue.length) { const index = queue[cursor++]; await uploadChunk(file, hash, index); completed++; showStatus('上传进度: ' + completed + '/' + totalChunks); } } const workers = []; for (let i = 0; i < Math.min(concurrency, queue.length); i++) { workers.push(worker()); } await Promise.all(workers); showStatus('所有分片上传完毕,正在合并...'); const mergeResult = await mergeFile(hash, totalChunks); if (mergeResult.error) { showStatus('合并失败: ' + mergeResult.error); } else { showStatus('上传完成,文件保存路径: ' + mergeResult.filePath); } }); </script> </body> </html>

实测下来,这段代码有几个细节值得说。

计算指纹时使用了file.slice而不是直接传整个文件,这样每次只有2MB的Blob被加载进内存。FileReader的readAsArrayBuffer读取的是ArrayBuffer类型,SparkMD5.ArrayBuffer正是吃这个类型的,两者配合无缝。

查询接口返回的uploadedChunks是一个数组,前端转成Set再判断,查询效率是O(1)。因为服务端返回的分片索引可能乱序、可能有重复(并发上传时),Set具有天然的去重能力。

进度显示我做了两层:计算指纹阶段和实际上传阶段,这两段耗时都占大头。如果只显示上传进度,用户看到第一秒是0%,可能会误以为页面卡死了。

3.3 后端源码:检查、接收分片、合并三接口实现

后端我用Node.js,Express负责路由,Multer处理multipart表单解析。完整的server/index.js代码如下:

const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const app = express(); const UPLOAD_DIR = path.resolve(__dirname, '../uploads'); const CHUNK_DIR = path.resolve(__dirname, '../chunks'); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); fs.mkdirSync(CHUNK_DIR, { recursive: true }); app.use(express.static(path.join(__dirname, '../public'))); app.use(express.json()); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, CHUNK_DIR), filename: (req, file, cb) => { const { hash, index } = req.body; cb(null, `${hash}_${index}`); } }); const upload = multer({ storage: storage, limits: { fileSize: 20 * 1024 * 1024 } }); // 接口1: 检查文件状态,用于秒传与续传 app.get('/api/check', (req, res) => { const hash = req.query.hash; const totalChunks = parseInt(req.query.totalChunks, 10) || 0; if (!hash) { return res.status(400).json({ error: '缺少hash参数' }); } const targetFile = path.join(UPLOAD_DIR, hash); if (fs.existsSync(targetFile)) { return res.json({ exists: true, needUpload: false }); } let uploadedChunks = []; if (fs.existsSync(CHUNK_DIR)) { const prefix = hash + '_'; const files = fs.readdirSync(CHUNK_DIR); uploadedChunks = files .filter(name => name.startsWith(prefix)) .map(name => { const suffix = name.slice(prefix.length); const idx = parseInt(suffix, 10); return isNaN(idx) ? -1 : idx; }) .filter(idx => idx >= 0 && idx < totalChunks); } res.json({ exists: false, needUpload: true, uploadedChunks: [...new Set(uploadedChunks)] }); }); // 接口2: 上传分片 app.post('/api/upload', upload.single('chunk'), (req, res) => { const { hash, index } = req.body; if (!hash || index === undefined || !req.file) { return res.status(400).json({ error: '参数不完整' }); } res.json({ result: 'ok', index: parseInt(index, 10) }); }); // 接口3: 合并分片为完整文件 app.post('/api/merge', (req, res) => { const { hash, totalChunks } = req.body; if (!hash || !totalChunks) { return res.status(400).json({ error: '参数不完整' }); } const prefix = hash + '_'; let chunkFiles; try { chunkFiles = fs.readdirSync(CHUNK_DIR) .filter(name => name.startsWith(prefix)) .sort((a, b) => { const idxA = parseInt(a.slice(prefix.length), 10); const idxB = parseInt(b.slice(prefix.length), 10); return idxA - idxB; }); } catch (e) { return res.status(500).json({ error: '读取分片目录失败' }); } if (chunkFiles.length < parseInt(totalChunks, 10)) { return res.status(400).json({ error: '分片不完整,已有的分片数:' + chunkFiles.length + ',需要:' + totalChunks }); } const targetFile = path.join(UPLOAD_DIR, hash); try { const ws = fs.createWriteStream(targetFile); for (const name of chunkFiles) { const chunkPath = path.join(CHUNK_DIR, name); const data = fs.readFileSync(chunkPath); ws.write(data); } ws.end(); chunkFiles.forEach(name => { fs.unlinkSync(path.join(CHUNK_DIR, name)); }); res.json({ result: 'ok', filePath: targetFile }); } catch (err) { res.status(500).json({ error: '合并分片时出错:' + err.message }); } }); app.listen(3000, () => { console.log('服务已启动: http://localhost:3000'); });

这段代码里,Multer的filename用了模板字符串${hash}_${index},看起来简单,实际上规避了一个关键问题:分片并发上传时,如果两个请求针对同一个文件同一个分片,磁盘上只会存在一个同名文件,最后一个写入的覆盖先前的,文件大小一致,内容一致,不会有脏数据。因为分片内容取决于文件内容,hash、index确定后,这个分片的字节序列就是确定性的。

Multer的limits配置了fileSize为20MB,这是为了限制单片请求体大小,防止有人绕过前端的切分逻辑直接上传超大请求体,把服务端内存打爆。分片大小一般是2MB,上限配置到20MB足够安全。

3.4 完整交互流程串讲

把前后端串起来,整个流程是这样的:

用户选择文件、点击上传,前端开始增量计算MD5。计算期间,每一轮读取2MB切片,追加进SparkMD5。算完后拿到一个32位的十六进制字符串,比如8f14e45fceea167a5a36dedf4b6b1b2f,这个指纹是这个文件的唯一身份标识。

然后前端请求/api/check,带两个参数:指纹hash和分片总数totalChunks。后端收到后先看uploads目录里有没有同名文件。有,说明之前有人传过相同内容的文件,直接返回exists: true,前端显示“秒传成功”,整个过程零字节传输,耗时就是一次哈希计算加一次HTTP请求。

没有同名文件,后端就扫描chunks目录,把属于这个hash的所有分片索引整理出来返回。前端对比自己的分片序列,找出缺失的分片,用3个worker并发上传。比如100个分片已经传了37个,那就只有63个分片需要传输。

最后前端调用merge接口,后端验证分片数是否齐全,确认无误后按索引顺序拼文件,拼完清理临时分片。合并完成,前端显示上传成功。

这里有个时机的细节:合并之前,前端可以做一个简单校验,把已上传成功的分片数量加起来,看是否等于totalChunks,不等于就不发合并请求,减少一次无效的服务器调用。虽然后端merge也有校验,但前端提前拦截一次,体验上更快。

4. 避坑指南:常见问题与排查实战

4.1 上传小文件也走分片流程,效率太低

如果是一个10KB的Word文档,走整套分片流程,先花时间算MD5,再传分片,再合并,用户体验是很奇怪的“卡顿”。给上传逻辑加一个阈值判断很有必要:文件小于某个值(比如5MB)时,直接走普通表单上传;超过阈值才走分片流程。

在代码里就是一行判断:

if (file.size < 5 * 1024 * 1024) { // 走普通上传接口 } else { // 走分片上传流程 }

这个阈值放在哪里完全看业务场景。我习惯放在前端,这样小文件根本不用触发分片逻辑,后端保持分片接口处理大文件即可。

4.2 秒传误判:不同内容算出同一个MD5

前面提过MD5碰撞的哈希原理,但对于工程实现来说,还有一个更常见的场景会导致“误判”:服务端把同名文件当成相同文件。如果我用hash作为最终文件名,而这个hash恰好是用户自定义文件名,两个不同文件叫了同一个名字就出事了。

所以合并后的目标文件一定不能使用原始文件名字,而要使用内容指纹本身。最终文件名就是hash值。后端不保存原始文件名的话,前端可以在URL上或者一个元数据请求里单独传递文件名。项目里的演示代码省略了这一步,实际生产环境一定要在合并完成之后,把原始文件名存在数据库或其他元数据表里,或者保存时拼进文件扩展名。

修改方案很简单:合并时把原始文件名作为额外参数传入,服务端保存为${hash}${ext}。例如前端传document.docx,合并后的文件就是8f14e45f...docx。

4.3 并发上传导致的分片重复和顺序问题

Multer处理并发请求时,每个请求是独立处理的,文件名的确定性保证同一分片写入内容一致。但有个隐患:如果服务端在合并时读分片文件,此时某个分片还在路上(没完全写到磁盘),读取到的可能是一个不完整的文件。所以must做好两件事:merge接口必须校验分片数量;在真正生产中,分片状态不能只靠扫描文件名,还要校验文件大小。曾在合并环节遇到文件损坏,排查后发现,某分片上传时网络中断,服务端只写了一半文件,但文件名和索引都在,扫描时不知道这个分片是坏的。加重校验的方法是在分片文件名上记录大小,或者让前端在upload请求里带上分片大小,后端保存一份元数据并校验。

4.4 服务端分片垃圾回收

用户上传到一半放弃、或网络彻底中断,chunks目录里会留下大量碎片文件。这种问题平时不显眼,时间一长磁盘空间被吃掉好几GB。解决方案是写一个定时清理任务,定期扫描chunks目录,删除超过24小时没有更新的分片文件。注意删除前要判断是否存在对应的合并文件,防止误删“过程中正在合并”的文件。

清理任务用Node.js的定时器实现最直接:

setInterval(() => { const files = fs.readdirSync(CHUNK_DIR); const now = Date.now(); files.forEach(name => { const filePath = path.join(CHUNK_DIR, name); const stat = fs.statSync(filePath); if (now - stat.mtimeMs > 24 * 60 * 60 * 1000) { fs.unlinkSync(filePath); } }); }, 6 * 60 * 60 * 1000);

清理周期按需调整,核心原则是:超过一定时间且没有合并的分片,基本可以断定是废弃数据,放心删。

4.5 内存与性能调优记录

我在一次实际压测中发现,4GB文件、2MB分片,串行上传需要约40分钟(内网环境),调整到3个并发后缩短到18分钟,调整到5个并发后是14分钟。继续往上加到8个并发,时间没有明显改善,反而服务端CPU和内存占用有所上升。

结论是,适合多数项目的并发数是3到5,不需要盲目加大。

计算MD5时也要注意,虽然SparkMD5采用增量计算,但计算阶段CPU占用依然较高。对于超大文件,可以在用户选择文件后就自动开始计算指纹,不用等用户点击上传按钮,这样能节省一部分用户的等待时间。

服务端合并阶段,我用的是同步的fs.readFileSync逐片读取,在分片数量不超过几千的场景下没有问题。如果分片数量超过5000,建议改成流式管道,或者用异步遍历,避免阻塞事件循环。之前有一个模拟极端场景:5000个分片逐个同步读取,单次合并接口的耗时达到了秒级,那台测试机的Load明显抬头,改成流式之后轻松扛住了。

再分享一个小细节:合并时用ws.write(data)不要主动关闭,让ws.end()触发finish事件。代码里为了简洁没监听回调,实际上线建议包一层Promise,等写入完成之后再删除分片文件。否则可能前端显示合并成功,分片还没来得及清理,虽然对功能无伤大雅,但会给日志排查造成困惑。

这个方案的扩展空间也很大,把目录存储换成对象存储,分片状态记录放进数据库,前端加Web Worker计算指纹,就能支撑起真正大规模的生产环境。我自己在多个项目中逐步打磨过这套逻辑,核心流程一直没变:指纹、检查、补片、合并。你把这个骨架搭好,后续有什么复杂需求,都是在它上面添砖加瓦。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 4:55:53

PyTorch从入门到实战:动态计算图、训练调试与部署全解析

1. 从设计理念看懂PyTorch的取舍1.1 动态计算图&#xff1a;为什么它改变了深度学习的入门体验很多初学者第一次接触深度学习时&#xff0c;会被各种名词绕晕&#xff1a;前向传播、反向传播、梯度下降、计算图……PyTorch之所以能成为学术界和工业界公认的上手首选&#xff0c…

作者头像 李华
网站建设 2026/10/21 18:16:28

Linux网络排查实战:IP地址、子网掩码、路由与端口的底层逻辑

1. 从 ifconfig 说起&#xff1a;再聊概念前先把地址看清 刚接触 Linux 网络的时候&#xff0c;我最常被问到的一个问题是&#xff1a;"我执行 ifconfig 看到的这些乱七八糟的信息&#xff0c;到底哪个是 IP 地址&#xff1f;" 这个问题看似基础&#xff0c;但实…

作者头像 李华
网站建设 2026/10/19 13:32:46

C++解释器模式三种变体:从lambda组合子到模板元编程的工程实践

1. 为什么我在C里舍弃了教科书式的解释器模式教科书中讲解解释器模式&#xff0c;一般会画这样一张类图&#xff1a;一个抽象Expression基类&#xff0c;下面挂上NumberExpression、AddExpression、SubtractExpression这些子类&#xff0c;每个子类实现一个Evaluate方法&#x…

作者头像 李华
网站建设 2026/10/28 15:34:38

幼儿园管理系统计算机毕设 SpringBoot+Vue 项目

&#x1f496;&#x1f496;作者&#xff1a;计算机毕业设计小明哥 &#x1f499;&#x1f499;个人简介&#xff1a;曾长期从事计算机专业培训教学&#xff0c;本人也热爱上课教学&#xff0c;语言擅长Java、微信小程序、Python、Golang、安卓Android等&#xff0c;开发项目包…

作者头像 李华
网站建设 2026/11/4 9:59:01

智元精灵G2工博会“上岗”,胜任多类工业作业

第二十六届中国国际工业博览会10月12日在国家会展中心&#xff08;上海&#xff09;开幕。智元创新&#xff08;上海&#xff09;科技股份有限公司&#xff08;以下简称“智元”&#xff09;以“工业部署态”为主线&#xff0c;展示精密制造、柔性装配、汽车零部件上料与仓储物…

作者头像 李华
网站建设 2026/10/23 6:55:05

顺序表详解:从数组存储到动态扩容的数据结构基础

1. 顺序表到底是什么&#xff1a;先把这个基础概念吃透1.1 从“数据怎么存放”说起如果你刚开始学数据结构&#xff0c;第一个真正需要动手写的代码通常就是顺序表。别看它基础&#xff0c;几乎所有后续内容——栈、队列、哈希表甚至数据库的底层存储——都会遇到顺序存储的影子…

作者头像 李华