大文件上傳是個非常普遍的場景,在面試中也會經常被問到,大文件上傳的實現思路和流程。在日常開發中,無論是云存儲、視頻分享平臺還是企業級應用,大文件上傳都是用戶與服務器之間交互的重要環節。隨著現代網絡應用的日益復雜化,大文件上傳已經成為前端開發中不可或缺的一部分。
然而,在實現大文件上傳時,我們通常會面臨以下幾個挑戰:
上傳超時:一般前端請求都會限制最大請求時長,比如axios設置timeout,或者是 nginx(或其它代理/網關) 限制了最大請求時長。
服務器壓力:大文件上傳會給服務器帶來較大的壓力,甚至可能導致服務器崩潰。
文件大小超限:一般后端都會對上傳文件的大小做限制,比如nginx和server都會限制。
用戶體驗:上傳過程中用戶需要等待較長時間,用戶體驗差。
網絡波動:各種網絡原因導致上傳失敗,比如網絡不穩定可能導致上傳過程中斷,且失敗之后需要從頭開始。
對于前三點,雖說可以通過一定的配置來解決,但有時候也相當麻煩,或者服務器就規定不允許上傳大型文件,需要兼顧實際場景。上傳慢的話倒是無傷大雅,忍一忍是可以接受的,只是體驗不好,但是失敗后在重頭開始上傳,在網絡環境差的時候簡直就是災難。為了應對以上挑戰,我們就需要用到切片上傳、斷點續傳等技術手段。
整體流程圖如下:
思路如下:
每個文件要有自己唯一的標識,因此在進行分片上傳前,需要對整個文件進行MD5加密,生成MD5碼,在后面上傳文件每次調用接口時以formData格式上傳給后端。可以使用spark-md5 計算文件的內容hash,以此來確定文件的唯一性將文件hash發送到服務端進行查詢。以此來確定該文件在服務端的存儲情況,這里可以分為三種:未上傳、已上傳、上傳部分。
根據服務端返回的狀態執行不同的上傳策略。已上傳:執行秒傳策略,即快速上傳,實際上沒有對該文件進行上傳,因為服務端已經有這份文件了。未上傳、上傳部分:執行計算待上傳分塊的策略并發上傳還未上傳的文件分塊。當傳完最后一個文件分塊時,向服務端發送合并的指令,即完成整個大文件的分塊合并,實現在服務端的存儲。
上傳過程:
分割文件:將要上傳的文件切割成多個小文件片段。主要使用JavaScript的File API中的slice方法來實現。
上傳文件分片:使用XMLHttpRequest或者Fetch API將分片信息以formData格式,并攜帶相關信息,如文件名、文件ID、當前片段序號等參數傳給分片接口。
后端接收并保存文件片段:后端接收到每個文件片段后,將其保存在臨時位置,并記錄文件片段的序號、文件ID和文件MD5 hash值等信息。
續傳處理:如果上傳過程中斷,下次繼續上傳時,通過查詢后端已保存的文件片段信息,得知需要上傳的文件片段,從斷點處繼續上傳剩余的文件片段。
合并文件:當所有文件片段都上傳完成后,后端根據文件ID將所有片段合并成完整的文件。
切片上傳原理:通過使用JavaScript的File API中的slice方法將大文件分割成多個小片段(chunk),然后逐個上傳每個片段,在上傳完切片后,前端通知后臺再將文件片段拼接為一個完整的文件。
這樣做的優點是可以并行多個請求一起上傳文件,提高上傳效率,并且在上傳過程中如果某個片段因為某些原因上傳失敗,也不會影響其它文件切片,只需要重新上傳該失敗片段即可,不必重新上傳整個文件。
實現思路:
在JavaScript中,文件File對象是Blob對象的子類,Blob對象包含了slice方法,通過這個方法,可以對二進制文件進行拆分。循環發送多個上傳請求,然后返回結果后計數,當計數達到file片段長度后終止上傳。
<input type="file" name="file" id="file" />
const eleFile = document.getElementById('file');
eleFile.addEventListener('change', (event) => {
const file = event.target.files[0];
// 上傳分塊大小,單位Mb
const chunkSize = 1024 * 1024 * 1;
// 當前已執行分片數位置
let currentPosition = 0;
//初始化分片方法,兼容問題
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
while(currentPosition < file.size) {
const chunk = blobSlice.call(file, currentPosition, currentPosition + chunkSize);
uploadChunk(chunk);
currentPosition += chunkSize;
}
})
function uploadChunk(chunk) {
// 將分片信息以formData格式作為參數傳給分片接口
let formData = new FormData();
formData.append('fileChunk', chunk);
// 根據項目實際情況
axios.post(
'/api/oss/upload/file',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 600000,
}
).then(res => {
// 上傳成功
console.log('分片上傳成功', res)
}).catch(error => {
// 上傳失敗
console.log('分片上傳失敗', error)
})
}
并發上傳相對要優雅一下,將文件分割成小片段后,使用Promise.all()把所有請求都放到一個Promise.all里,它會自動判斷所有請求都完成然后觸發 resolve 方法。并發上傳可以同時上傳多個片段而不是依次上傳,進一步提高效率。
實現思路:
1、使用slice方法對二進制文件進行拆分,并把拆分的片段放到chunkList里面。
2、使用map將chunkList里面的每個chunk映射到一個Promise上傳方法。
3、把所有請求都放到一個Promise.all里,它會自動判斷所有請求都完成然后觸發 resolve 方法,上傳成功后通知后端合并分片文件。
代碼實現如下:
const eleFile = document.getElementById('file');
eleFile.addEventListener('change', (event) => {
const file = event.target.files[0];
// 上傳分塊大小,單位Mb
const chunkSize = 1024 * 1024 * 1;
// 當前已執行分片數位置
let currentPosition = 0;
// 存儲文件的分片
let chunkList = [];
//初始化分片方法,兼容問題
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
while(currentPosition < file.size) {
const chunk = blobSlice.call(file, currentPosition, currentPosition + chunkSize);
chunkList.push(chunk);
currentPosition += chunkSize;
}
uploadChunk(chunkList, file.name)
})
function uploadChunk(chunkList, fileName) {
const uploadPromiseList = chunkList.map((chunk, index) => {
// 將分片信息以formData格式作為參數傳給分片接口
let formData = new FormData();
formData.append('fileChunk', chunk);
// 可以根據實際的需要添加其它參數,比如切片的索引
formData.append('index', index);
// 根據項目實際情況
return axios.post(
'/api/oss/upload/file',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 600000,
}
)
})
Promise.all(uploadPromiseList).then(res => {
// 上傳成功并通知后端合并分片文件
axios.post(
'/api/oss/file/merge',
{
message: fileName
},
{
headers: { 'Content-Type': 'application/json' },
timeout: 600000,
}
).then(data => {
console.log('文件合并成功', data)
})
}).catch(error => {
// 上傳錯誤
console.log('上傳失敗', error)
})
}
斷點續傳允許在網絡中斷或其它原因導致上傳失敗時,從上次上傳中斷的位置繼續上傳,而不是重新從頭上傳整個文件。
實現斷點續傳需要后端配合記錄上傳的進度,并且在前端重新上傳時,需要先查詢已上傳的進度,讓后從斷點處繼續上傳。
const eleFile = document.getElementById('file');
eleFile.addEventListener('change', (event) => {
const file = event.target.files[0];
// 上傳分塊大小,單位Mb
const chunkSize = 1024 * 1024 * 1;
// 當前已執行分片數位置
let currentPosition = 0;
// 存儲文件的分片
let chunkList = [];
//初始化分片方法,兼容問題
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
while(currentPosition < file.size) {
const chunk = blobSlice.call(file, currentPosition, currentPosition + chunkSize);
chunkList.push(chunk);
currentPosition += chunkSize;
}
axios.post(
'/api/upload/file/history',
{
fileName: file.name
},
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 600000,
}
).then(res => {
const historyChunks = res.uploadedChunks;
const remainChunks = chunkList.filter((item, index) => !historyChunks.includes(index));
// 并發上傳剩余分片
uploadChunk(remainChunks, file.name)
})
})
function uploadChunk(chunkList, fileName) {
const uploadPromiseList = chunkList.map((chunk, index) => {
// 將分片信息以formData格式作為參數傳給分片接口
let formData = new FormData();
formData.append('fileChunk', chunk);
// 可以根據實際的需要添加其它參數,比如切片的索引
formData.append('index', index);
// 根據項目實際情況
return axios.post(
'/api/oss/upload/file',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 600000,
}
)
})
Promise.all(uploadPromiseList).then(res => {
// 剩余分片上傳成功并通知后端合并分片文件
axios.post(
'/api/oss/file/merge',
{
message: fileName
},
{
headers: { 'Content-Type': 'application/json' },
timeout: 600000,
}
).then(data => {
console.log('文件合并成功', data)
})
}).catch(error => {
// 上傳錯誤
console.log('上傳失敗', error)
})
}
以上是一個簡易版的斷點續傳實現流程代碼,但在實際場景應用中我們還需要更嚴謹的處理來實現斷點續傳功能。不如,上傳文件前通常需要生成文件的唯一標識,比如文件名與文件大小的組合、文件的hash值或者文件hash值與文件大小的組合來支持斷點續傳的邏輯。請繼續看下面的代碼實現!!!
已上傳的執行秒傳策略,即快速上傳,實際上沒有對該文件進行上傳,因為服務端已經有這份文件了。
秒傳的關鍵在于計算文件的唯一性標識。文件的不同不是命名的差異,而是內容的差異,所以我們將整個文件的二進制碼作為入參,計算 Hash 值,將其作為文件的唯一性標識。一般而言,這樣做就夠了,但是摘要算法是存在碰撞概率的,我們如果想要再嚴謹點的話,可以將文件大小也作為衡量指標,只有文件摘要和文件大小同時相等,才認為是相同的文件。
<input type="file" name="file" id="file" @change="changeFile" />
計算文件hash值可以使用spark-md5。
import SparkMD5 from 'spark-md5'
通過input的change事件獲取要上傳的文件。
function changeFile(event) {
const file = event.target.files[0];
handleUploadFile(file, 1)
}
接下來對文件進行分片和hash計算:
/**
* @param {File} file 目標上傳文件
* @param {number} size 上傳分塊大小,單位Mb
* @returns {filelist:ArrayBuffer,fileHash:string}
*/
async function handleSliceFile(file, size = 1) {
return new Promise((resolve, reject) => {
// 上傳分塊大小,單位Mb
const chunkSize = 1024 * 1024 * size;
// 分片數
const totalChunkCount = file && Math.ceil(file.size / chunkSize);
// 當前已執行分片數位置
let currentChunkCount = 0;
// 存儲文件的分片
let fileList = [];
//初始化分片方法,兼容問題
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
// 文件讀取對象
const fileReader = new FileReader();
// spark-md5 計算文件hash值SparkMD5對象
const spark = new SparkMD5.ArrayBuffer();
// 存儲計算后的文件hash值
let fileHash = "";
// 錯誤
fileReader.onerror = function () {
reject('Error reading file');
};
fileReader.onload = (e) => {
//當前讀取的分塊結果 ArrayBuffer
const curChunk = e.target.result;
//將當前分塊追加到spark對象中
spark.append(curChunk);
currentChunkCount++;
fileList.push(curChunk);
//判斷分塊是否完成
if (currentChunkCount >= totalChunkCount) {
// 全部讀取,獲取文件hash
fileHash = spark.end();
resolve({ fileList, fileHash });
} else {
readNext();
}
};
//讀取下一個分塊
const readNext = () => {
//計算分片的起始位置和終止位置
const start = chunkSize * currentChunkCount;
let end = start + chunkSize;
if (end > file.size) {
end = file.size
}
//讀取文件,觸發onLoad
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
readNext()
})
}
文件上傳,首選調用接口獲取需要上傳的文件index,返回的集合length等于0執行秒傳,如果返回的集合length不等于0執行需要過濾得到需要上傳的remainingChunks,使用map將remainingChunks里面的每個chunk映射為一個Promise上傳方法,把所有請求都放到一個Promise.all里,上傳成功后通知后端合并分片文件。
sync function handleUploadFile(file, chunkSize) {
const { fileList, fileHash } = await handleSliceFile(file, chunkSize);
// 存放切片
let chunkList = fileList;
// 顯示上傳的進度條
let process = 0;
// 獲取文件上傳狀態
const { data } = await axios.post('/api/upload/file/history', {
fileHash,
totalCount: chunkList.length,
extname: file.name,
})
// 返回已經上傳的
const { needUploadChunks } = data;
// 已上傳,無待上傳文件,秒傳
if (!needUploadChunks.length) {
process = 100;
return;
}
// 此處包含了未上傳和上傳部分的情況
// 過濾剩余需要上傳的分片序列
const remainingChunks = chunkList.filter((item, index) => needUploadChunks.includes(index + 1));
// 同步上傳進度,斷點續傳情況下
progress = ((chunkList.length - needUploadChunks.length) / chunkList.length) * 100;
// 上傳
if (remainingChunks.length) {
const uploadPromiseList = remainingChunks.map(async (chunk, index) => {
const response = await uploadChunk(chunk, index + 1, fileHash);
//更新進度
progress += Math.ceil(100 / allChunkList.length);
if (progress >= 100) progress = 100;
return response;
});
Promise.all(uploadPromiseList).then(() => {
// 清空已上傳的切片
chunkList = [];
//發送請求,通知后端進行合并
axios.post(
'/api/file/merge',
{
fileHash,
extname: 'fileName.mp4'
},
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 600000,
}
).then(res => {
console.log('合并完成', res)
}).catch(error => {
// 合并錯誤
console.log('合并錯誤', error)
})
}).catch(error => {
// 上傳錯誤
console.log('上傳錯誤', error)
})
}
}
上傳函數返回一個promise,參數為formData。
function uploadChunk(chunk, index, fileHash) {
// 將分片信息以formData格式作為參數傳給分片接口
let formData = new FormData();
formData.append('fileChunk', new Blob([chunk]));
// 可以根據實際的需要添加其它參數,比如切片的索引
formData.append('index', index);
// 文件的標識hash值
formData.append('fileHash', fileHash);
// 根據項目實際情況
return axios.post(
'/api/upload/file',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 600000,
}
)
}
我們在 fileReader 里面使用了 readAsArrayBuffer 方法做轉換并分割,因此傳入的chunk的類型是ArrayBuffer,而formData中文件的類型應該是Blob,所以需要時用new Blob() 將每一個chunk轉為Blob類型。
斷點續傳的重點是文件的切片與合并,整個上傳流程需要前后端配合好,細節較多。
注意事項:
計算整個文件的 MD5 值,當大文件比較大時會比較慢,耗時,更好地做法是將這部分任務放在 Web Worker 中執行。Web Worker 是 HTML5 標準的一部分,它允許一段 JavaScript 程序運行在主線程之外的另外一個線程中。這樣計算任務就不會影響到當前線程的渲染任務。可以和當前線程間使用 postMessage 的方式進行通訊。
可以根據文件切片的狀態,發送上傳請求,由于存在并發限制,需要限制 request 創建個數,避免頁面卡死。
在上傳大文件時,應提供適當的進度反饋和錯誤處理以確保良好的用戶體驗。
對于文件切片、并發上傳和斷點續傳,后端需要能夠接受文件片段,并能夠處理并發請求和斷點數據,因此需要合后端人員密切配合。
該文章在 2024/7/25 15:15:29 編輯過