基于JavaScript+SpringBoot實(shí)現(xiàn)大文件分片上傳
1. 前言
在很多 Web 應(yīng)用場景下,我們需要上傳體積很大的文件(視頻、鏡像包、數(shù)據(jù)包等)。一次性將整個(gè)文件上傳往往會(huì)面臨以下問題:
- 網(wǎng)絡(luò)不穩(wěn)定時(shí)容易中斷:導(dǎo)致上傳失敗,需要重頭再來
- 服務(wù)器內(nèi)存/磁盤壓力大:一次性接收大文件可能瞬間占滿帶寬或?qū)憹M臨時(shí)目錄
- 用戶體驗(yàn)差:上傳過程中無法做到斷點(diǎn)續(xù)傳或重試
為了解決上述問題,分片上傳(Chunked Upload)應(yīng)運(yùn)而生。它將大文件拆分成一個(gè)個(gè)小塊,按序上傳并在后臺(tái)合并,既可以實(shí)現(xiàn)斷點(diǎn)續(xù)傳,也能平滑流量、降低服務(wù)器壓力。
本文博主將帶著小伙伴了解如何基于 前端原生 JavaScript + Spring Boot 實(shí)現(xiàn)大文件分片上傳。
2. 為什么要分片

- 斷點(diǎn)續(xù)傳
每個(gè)分片上傳完成后都會(huì)得到確認(rèn),下次重試只需上傳未成功的分片,用戶體驗(yàn)更佳。 - 可控并發(fā)
前端可以設(shè)置并發(fā)上傳的分片數(shù)量(比如同時(shí) 3~5 個(gè)),既能提高吞吐量,又不至于瞬時(shí)壓垮網(wǎng)絡(luò)或服務(wù)器。 - 流量均衡
小塊數(shù)據(jù)平滑地傳輸,避免一次性大流量沖擊。 - 兼容性與安全
后端可對(duì)每個(gè)分片做校驗(yàn)(大小、哈希、格式等),在合并前即可過濾非法內(nèi)容。
分片上傳的核心優(yōu)勢
| 痛點(diǎn) | 分片方案 | 收益 |
|---|---|---|
| 超時(shí)中斷 | 小片獨(dú)立上傳 | 避免整體失敗 |
| 內(nèi)存壓力 | 單片流式處理 | 內(nèi)存占用<10MB |
| 網(wǎng)絡(luò)波動(dòng) | 失敗分片重試 | 帶寬利用率提升40%+ |
| 大文件傳輸 | 并行上傳機(jī)制 | 速度提升3-5倍 |
| 意外中斷 | 斷點(diǎn)續(xù)傳支持 | 節(jié)省90%重復(fù)流量 |
3. 實(shí)現(xiàn)思路與流程
前端
用戶選中文件后,按固定大?。ㄈ?1MB)切片;
依次(或并發(fā))將每個(gè)分片通過 fetch/XMLHttpRequest 上傳到后端;
上傳完所有分片后,通知后端開始合并;
后端(Spring Boot)
接收每個(gè)分片時(shí),根據(jù)文件唯一標(biāo)識(shí)(如 MD5)與分片序號(hào),保存到臨時(shí)目錄;
接收 “合并請(qǐng)求” 時(shí),按序讀取所有分片并寫入最終文件;
合并完成后,可刪除臨時(shí)分片,返回成功。
4. 完整實(shí)現(xiàn)方案
1、前端分片邏輯實(shí)現(xiàn)
首先我們編寫前端的分片、上傳邏輯
<input type="file" id="largeFile">
<button onclick="startUpload()">開始上傳</button>
<div id="progressBar"></div>
<script>
async function startUpload() {
const file = document.getElementById('largeFile').files[0];
if (!file) return;
// 配置參數(shù)
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB分片
const TOTAL_CHUNKS = Math.ceil(file.size / CHUNK_SIZE);
const FILE_ID = `${file.name}-${file.size}-${Date.now()}`;
// 創(chuàng)建進(jìn)度跟蹤器
const uploadedChunks = new Set();
// 并行上傳控制(最大5并發(fā))
const parallelLimit = 5;
let currentUploads = 0;
let activeChunks = 0;
for (let chunkIndex = 0; chunkIndex < TOTAL_CHUNKS; ) {
if (currentUploads >= parallelLimit) {
await new Promise(resolve => setTimeout(resolve, 500));
continue;
}
if (uploadedChunks.has(chunkIndex)) {
chunkIndex++;
continue;
}
currentUploads++;
activeChunks++;
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
uploadChunk(chunk, chunkIndex, FILE_ID, TOTAL_CHUNKS, file.name)
.then(() => {
uploadedChunks.add(chunkIndex);
updateProgress(uploadedChunks.size, TOTAL_CHUNKS);
})
.catch(err => console.error(`分片${chunkIndex}失敗:`, err))
.finally(() => {
currentUploads--;
activeChunks--;
});
chunkIndex++;
}
// 檢查所有分片完成
const checkCompletion = setInterval(() => {
if (activeChunks === 0 && uploadedChunks.size === TOTAL_CHUNKS) {
clearInterval(checkCompletion);
completeUpload(FILE_ID, file.name);
}
}, 1000);
}
async function uploadChunk(chunk, index, fileId, total, filename) {
const formData = new FormData();
formData.append('file', chunk, filename);
formData.append('chunkIndex', index);
formData.append('totalChunks', total);
formData.append('fileId', fileId);
return fetch('/api/upload/chunk', {
method: 'POST',
body: formData
}).then(res => {
if (!res.ok) throw new Error('上傳失敗');
return res.json();
});
}
async function completeUpload(fileId, filename) {
return fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId, filename })
}).then(res => {
if (res.ok) alert('上傳成功!');
else alert('合并失敗');
});
}
function updateProgress(done, total) {
const percent = Math.round((done / total) * 100);
document.getElementById('progressBar').innerHTML = `
<div style="width: ${percent}%; background: #4CAF50; height: 20px;">
${percent}%
</div>
`;
}
</script>
2、SpringBoot后端實(shí)現(xiàn)
首先配置一下SpringBoot 上傳的一些限制
# application.yml
spring:
servlet:
multipart:
max-file-size: 10MB # 單片最大尺寸
max-request-size: 1000MB # 總請(qǐng)求限制
file:
upload-dir: /data/upload
分片上傳控制器Controller
@RestController
@RequestMapping("/api/upload")
public class FileUploadController {
@Value("${file.upload-dir}") //
private String uploadDir;
// 分片上傳接口
@PostMapping("/chunk")
public ResponseEntity<?> uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("fileId") String fileId) {
try {
// 創(chuàng)建分片存儲(chǔ)目錄
String chunkDir = uploadDir + "/chunks/" + fileId;
Path dirPath = Paths.get(chunkDir);
if (!Files.exists(dirPath)) {
Files.createDirectories(dirPath);
}
// 保存分片文件
String chunkFilename = chunkIndex + ".part";
Path filePath = dirPath.resolve(chunkFilename);
Files.copy(file.getInputStream(), filePath,
StandardCopyOption.REPLACE_EXISTING);
return ResponseEntity.ok().body(Map.of(
"status", "success",
"chunk", chunkIndex
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"status", "error",
"message", e.getMessage()
));
}
}
// 合并文件接口
@PostMapping("/merge")
public ResponseEntity<?> mergeChunks(
@RequestBody MergeRequest request) {
try {
String fileId = request.getFileId();
String filename = request.getFilename();
Path chunkDir = Paths.get(uploadDir, "chunks", fileId);
Path outputFile = Paths.get(uploadDir, filename);
// 檢查分片完整性
long expectedChunks = Files.list(chunkDir).count();
if (expectedChunks != request.getTotalChunks()) {
return ResponseEntity.badRequest().body(
"分片數(shù)量不匹配");
}
// 按序號(hào)排序分片
List<Path> chunks = Files.list(chunkDir)
.sorted((p1, p2) -> {
String f1 = p1.getFileName().toString();
String f2 = p2.getFileName().toString();
return Integer.compare(
Integer.parseInt(f1.split("\\.")[0]),
Integer.parseInt(f2.split("\\.")[0]));
})
.collect(Collectors.toList());
// 合并文件
try (OutputStream out = Files.newOutputStream(outputFile,
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
for (Path chunk : chunks) {
Files.copy(chunk, out);
}
}
// 清理分片目錄
FileUtils.deleteDirectory(chunkDir.toFile());
return ResponseEntity.ok().body(Map.of(
"status", "success",
"file", filename,
"size", Files.size(outputFile)
));
} catch (Exception e) {
return ResponseEntity.status(500).body(
"合并失敗: " + e.getMessage());
}
}
// 請(qǐng)求體定義
@Data
public static class MergeRequest {
private String fileId;
private String filename;
private int totalChunks;
}
}
3、擴(kuò)展斷點(diǎn)續(xù)傳
如果你的項(xiàng)目沒有斷點(diǎn)續(xù)傳的需求,可以直接參考 ? ?前后端代碼即可,否則可以在分片上傳接口中添加續(xù)傳支持,增加代碼如下:
// 在分片上傳接口中添加續(xù)傳支持
@GetMapping("/check")
public ResponseEntity<?> checkChunks(
@RequestParam("fileId") String fileId,
@RequestParam("totalChunks") int totalChunks) {
Path chunkDir = Paths.get(uploadDir, "chunks", fileId);
if (!Files.exists(chunkDir)) {
return ResponseEntity.ok().body(Map.of(
"exists", false
));
}
try {
// 獲取已上傳分片索引
Set<Integer> uploaded = Files.list(chunkDir)
.map(p -> Integer.parseInt(
p.getFileName().toString().split("\\.")[0]))
.collect(Collectors.toSet());
return ResponseEntity.ok().body(Map.of(
"exists", true,
"uploaded", uploaded
));
} catch (IOException e) {
return ResponseEntity.status(500).body(
"檢查失敗: " + e.getMessage());
}
}
前端調(diào)用檢查接口:
async function checkUploadStatus(fileId, totalChunks) {
const res = await fetch(`/api/upload/check?fileId=${fileId}&totalChunks=${totalChunks}`);
const data = await res.json();
return data.exists ? data.uploaded : new Set();
}
// 在上述前端代碼 startUpload函數(shù)中加入
const uploadedChunks = await checkUploadStatus(FILE_ID, TOTAL_CHUNKS);
5. 高級(jí)優(yōu)化方案
通過上面的代碼示例,你已經(jīng)可以輕松使用大文件的分片上傳了,如果你還有一些優(yōu)化需求,博主這里簡單羅列三個(gè),供小伙伴們參考
5.1 分片秒傳優(yōu)化
// 在保存分片前計(jì)算哈希
String hash = DigestUtils.md5DigestAsHex(file.getBytes());
String chunkFilename = hash + ".part"; // 哈希作為文件名
// 檢查是否已存在相同分片
if (Files.exists(dirPath.resolve(chunkFilename))) {
return ResponseEntity.ok().body(Map.of(
"status", "skip",
"chunk", chunkIndex
));
}
5.2 并行合并加速
// 使用并行流合并文件
List<Path> chunks = ... // 排序后的分片列表
try (OutputStream out = Files.newOutputStream(outputFile)) {
chunks.parallelStream().forEach(chunk -> {
try {
Files.copy(chunk, out);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
5.3 安全增強(qiáng)措施
// 文件名安全過濾
String safeFilename = filename.replaceAll("[^a-zA-Z0-9\\.\\-]", "_");
// 文件類型檢查
String mimeType = Files.probeContentType(filePath);
if (!mimeType.startsWith("video/")) {
throw new SecurityException("非法文件類型");
}
結(jié)語:構(gòu)建可靠的大文件傳輸體系
本文示例演示了一個(gè)從前端分片、并發(fā)上傳,到后端按序存儲(chǔ)與合并的完整流程。并可以按需提供斷點(diǎn)續(xù)傳,以及部分優(yōu)化的方案參考,這樣我們就提高大文件上傳的穩(wěn)定性與用戶體驗(yàn)。
通過本文實(shí)現(xiàn)的分片上傳方案,我們成功解決了大文件傳輸?shù)暮诵奶魬?zhàn):
- 穩(wěn)定性提升:分片機(jī)制有效規(guī)避了網(wǎng)絡(luò)波動(dòng)影響
- 資源優(yōu)化:內(nèi)存占用從GB級(jí)降至MB級(jí)
- 用戶體驗(yàn):進(jìn)度可視化 + 斷點(diǎn)續(xù)傳
- 擴(kuò)展能力:秒傳、并行合并等優(yōu)化空間
以上就是基于JavaScript+SpringBoot實(shí)現(xiàn)大文件分片上傳的詳細(xì)內(nèi)容,更多關(guān)于JavaScript SpringBoot文件分片上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
判斷是否存在子節(jié)點(diǎn)的實(shí)現(xiàn)代碼
下面小編就為大家?guī)硪黄袛嗍欠翊嬖谧庸?jié)點(diǎn)的實(shí)現(xiàn)代碼。小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2016-05-05
利用ajaxfileupload插件實(shí)現(xiàn)文件上傳無刷新的具體方法
利用ajaxfileupload插件實(shí)現(xiàn)文件上傳無刷新的具體方法,需要的朋友可以參考一下2013-06-06
JavaScript SHA1加密算法實(shí)現(xiàn)詳細(xì)代碼
這篇文章主要為大家詳細(xì)介紹了JavaScript SHA1加密算法實(shí)現(xiàn)代碼,具有一定的參考價(jià)值,感興趣的朋友可以參考一下2016-10-10
通過javascript進(jìn)行UTF-8編碼的實(shí)現(xiàn)方法
下面小編就為大家?guī)硪黄ㄟ^javascript進(jìn)行UTF-8編碼的實(shí)現(xiàn)方法。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2016-06-06
JavaScript中this關(guān)鍵字用法實(shí)例分析
這篇文章主要介紹了JavaScript中this關(guān)鍵字用法,結(jié)合實(shí)例形式總結(jié)分析了javascript中this關(guān)鍵字在不同條件下的指向問題與相關(guān)操作技巧,需要的朋友可以參考下2018-08-08

