如果下載一個G級文件翘单,通過一次請求去下載容易造成內(nèi)存泄露系谐,因此可以把文件分割成幾段返回給前端端,由前端拿到所有片段后合并成一個完整的文件踢匣。
Range: bytes=開始字節(jié)-結束字節(jié)
;
Content-Range: bytes 開始字節(jié)-結束字節(jié)/文件總字節(jié)數(shù)
1. 原理
參考的這個:前端多線程大文件下載實踐
Range更多介紹童社,超全
利用HTTP/1.1
提供的range
字段求厕,在前端請求后端時,請求頭中攜帶Range
扰楼,后端獲取該字段就可以知道當前要下載哪段文件呀癣。
圖1-1
圖1-2
圖1-3
圖1-4
2. 服務端實現(xiàn)
function createFileResHeader(fileName, size) {
return {
// 告訴瀏覽器這是一個需要以附件形式下載的文件(瀏覽器下載的默認行為,前端可以從這個響應頭中獲取文件名:前端使用ajax請求下載的時候弦赖,后端若返回文件流项栏,此時前端必須要設置文件名-主要是為了獲取文件后綴,否則前端會默認為txt文件)
'Content-Disposition': 'attachment; filename=' + encodeURIComponent(fileName),
// 告訴瀏覽器是二進制文件蹬竖,不要直接顯示內(nèi)容
'Content-Type': 'application/octet-stream',
// 下載文件大姓由颉(HEAD請求時,主要獲取Content-Length)
'Content-Length': size,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'X-Requested-With',
'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
//如果不暴露header案腺,那就Refused to get unsafe header "Content-Disposition"
"Access-Control-Expose-Headers":'Content-Disposition'
}
}
// 大文件下載 - 分片下載 (head請求不會返回響應體)
app.get('/slice/download', async (req, res) => {
// 獲取文件路徑
const fileName = req.query.name;
let filePath = path.join(__dirname,'../public/upload/' + fileName);
// 1庆冕、 判斷文件是否存在
try {
fs.accessSync(filePath);
} catch (error) {
res.send({
status: 201,
message: '下載的文件資源不存在'
});
}
try {
// 獲取文件大小
const size = fs.statSync(filePath).size;
const range = req.headers['range'];
const {start, end} = getRange(range);
if (!range) {
// 2康吵、 head請求同時請求頭中不帶range字段劈榨,返回文件大小,前端根據(jù)文件大小去決定要分成幾段
res.writeHead(200, Object.assign({'Accept-Ranges': 'bytes'}, createFileResHeader(fileName, size)));
} else {
const resHeaderParams = {};
// 3晦嵌、檢查請求范圍
if (start >= size || end >= size) {
res.status = 416;
resHeaderParams['Content-Range'] = `bytes */${size}`;
} else {
// 4同辣、返回206:客戶端表明自己只需要目標URL上的部分資源的時候返回的
res.status = 206;
resHeaderParams['Content-Range'] = `bytes ${start}-${end ? end : size - 1}/${size}`;
}
/**
* 這里不能使用res.writeHead前端會報: xxx.net::ERR_CONTENT_LENGTH_MISMATCH 206 (Partial Content)(一個請求的時候正常,多個并發(fā)請求的時候就會報這個惭载,原因暫時未知)
* res.writeHead 和res.setHeader 啥區(qū)別旱函,官網(wǎng)沒有給出明確說明,https://blog.csdn.net/qq_45515863/article/details/103213937
*/
// res.writeHead(res.status, Object.assign({'Accept-Ranges': 'bytes'}, createFileResHeader(fileName, size), resHeaderParams), 200);
res.statusCode = 206;
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Range", `bytes ${start}-${end ? end : size - 1}/${size}`);
/* res.setHeader("Content-Disposition", 'attachment; filename=' + encodeURIComponent(fileName));
res.setHeader("Content-Type", "application/octet-stream"); */
}
// 5描滔、返回部分文件
fs.createReadStream(filePath, {start, end}).pipe(res);
} catch (err) {
res.send({
status: 201,
message: err
})
return;
}
});
- 客戶端第一次請求時使用head請求(head請求不會返回響應體)同時請求頭中不帶range字段棒妨,服務端返回文件大小,客戶端根據(jù)文件大小去決定要分成幾段含长。
-
range范圍不合法券腔,返回416伏穆。
圖2-1 - range范圍合法,返回206纷纫。
圖2-2
http狀態(tài)碼 - res.writeHead
node.js中res.writeHead的用法總結
3. 客戶端實現(xiàn)
<body>
<button onclick="ajaxEvt('head', requestUrl, null, downLoadAjaxEvt)">大文件下載(分片下載)</button>
<script>
const requestUrl = 'http://192.168.66.183:13666/slice/download?name=DOC.zip';
function downloadEvt(url, fileName = '未知文件') {
const el = document.createElement('a');
el.style.display = 'none';
el.setAttribute('target', '_blank');
/**
* download的屬性是HTML5新增的屬性
* href屬性的地址必須是非跨域的地址枕扫,如果引用的是第三方的網(wǎng)站或者說是前后端分離的項目(調(diào)用后臺的接口),這時download就會不起作用辱魁。
* 此時烟瞧,如果是下載瀏覽器無法解析的文件,例如.exe,.xlsx..那么瀏覽器會自動下載染簇,但是如果使用瀏覽器可以解析的文件参滴,比如.txt,.png,.pdf....瀏覽器就會采取預覽模式
* 所以,對于.txt,.png,.pdf等的預覽功能我們就可以直接不設置download屬性(前提是后端響應頭的Content-Type: application/octet-stream剖笙,如果為application/pdf瀏覽器則會判斷文件為 pdf 卵洗,自動執(zhí)行預覽的策略)
*/
fileName && el.setAttribute('download', fileName);
el.href = url;
console.log(el);
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
};
// 根據(jù)header里的contenteType轉(zhuǎn)換請求參數(shù)
function transformRequestData(contentType, requestData) {
requestData = requestData || {};
if (contentType.includes('application/x-www-form-urlencoded')) {
// formData格式:key1=value1&key2=value2,方式二:qs.stringify(requestData, {arrayFormat: 'brackets'}) -- {arrayFormat: 'brackets'}是對于數(shù)組參數(shù)的處理
let str = '';
for (const key in requestData) {
if (Object.prototype.hasOwnProperty.call(requestData, key)) {
str += `${key}=${requestData[key]}&`;
}
}
return encodeURI(str.slice(0, str.length - 1));
} else if (contentType.includes('multipart/form-data')) {
const formData = new FormData();
for (const key in requestData) {
const files = requestData[key];
// 判斷是否是文件流
const isFile = files ? files.constructor === FileList || (files.constructor === Array && files[0].constructor === File) : false;
if (isFile) {
for (let i = 0; i < files.length; i++) {
formData.append(key, files[i]);
}
} else {
formData.append(key, files);
}
}
return formData;
}
// json字符串{key: value}
return Object.keys(requestData).length ? JSON.stringify(requestData) : '';
}
function ajaxEvt(method = 'get', url, params = null, cb, config = {}) {
const _method = method.toUpperCase();
const _config = Object.assign({
contentType: ['POST', 'PUT'].includes(_method) ? 'application/x-www-form-urlencoded' : 'application/json;charset=utf-8', // 請求頭類型
async: true, // 請求是否異步-true異步弥咪、false同步
token: 'token', // 用戶token
range: '',
responseType: ''
}, config);
const ajax = new XMLHttpRequest();
const queryParams = transformRequestData(_config.contentType, params);
const _url = `${url}${_method === 'GET' && queryParams ? '?' + queryParams : ''}`;
ajax.open(_method, _url, _config.async);
ajax.setRequestHeader('Authorization', _config.token);
ajax.setRequestHeader('Content-Type', _config.contentType);
_config.range && ajax.setRequestHeader('Range', _config.range);
// responseType若不設置过蹂,會導致下載的文件可能打不開
_config.responseType && (ajax.responseType = _config.responseType);
// 獲取文件下載進度
ajax.addEventListener('progress', (progress) => {
const percentage = ((progress.loaded / progress.total) * 100).toFixed(2);
const msg = `下載進度 ${percentage}%...`;
console.log(msg);
});
// 如果前端報“xxx.net::ERR_CONTENT_LENGTH_MISMATCH 206 (Partial Content)”,可以考慮是否是后端的header設置不對(ajax.readyState=4 & ajax.status=0)
ajax.onload = function () {
// this指向ajax
(typeof cb === 'function') && cb(this);
};
// send(string): string:僅用于 POST 請求
ajax.send(queryParams);
}
function arrayBufferEvt(response, i, resolve) {
response.response.arrayBuffer().then(result => {
resolve({i, buffer: result});
});
}
// 合并buffer
function concatBuffer(list) {
let totalLength = 0;
for (let item of list) {
totalLength += item.length;
}
// 實際上Uint8Array目前只能支持9位聚至,也就是合并最大953M(999999999字節(jié))的文件
let result = new Uint8Array(totalLength);
let offset = 0;
for (let item of list) {
result.set(item, offset);
offset += item.length;
}
return result;
}
/**
* ajax實現(xiàn)文件下載酷勺、獲取文件下載進度
* @param {String} method - 請求方法get/post
* @param {String} url
* @param {Object} [params] - 請求參數(shù),{name: '文件下載'}
* @param {Object} [config] - 方法配置
*/
function downLoadAjaxEvt(ajaxResponse) {
const fileSize = ajaxResponse.getResponseHeader('Content-Length') * 1;
// 兩種解碼方式扳躬,區(qū)別自行百度: decodeURIComponent/decodeURI(主要獲取后綴名脆诉,否則某些瀏覽器會一律識別為txt,導致下載下來的都是txt)
const fileName = decodeURIComponent((ajaxResponse.getResponseHeader('content-disposition') || '; filename="未知文件"').split(';')[1].trim().slice(9));
// 5M為一片 瀏覽器并發(fā)請求一般6個
const spliceSize = Math.ceil(fileSize / 6);
const length = Math.ceil(fileSize / spliceSize);
console.log('返回', length);
const reqList = [];
for (let i = 0; i < length; i++) {
let start = i * spliceSize;
let end = (i === length - 1) ? fileSize - 1 : (i + 1) * spliceSize - 1;
reqList.push(new Promise((resolve, reject) => {
ajaxEvt('get', `${requestUrl}&time=${Date.now()+i}`, null, (response) => arrayBufferEvt(response, i, resolve), {responseType: 'blob', range: `bytes=${start}-${end}`})
}));
}
Promise.all(reqList).then(res => {
sortList(res);
const arrBufferList = res.map(item => new Uint8Array(item.buffer));
const allBuffer = concatBuffer(arrBufferList);
const blob = new Blob([allBuffer]);
const href = URL.createObjectURL(blob);
downloadEvt(href, fileName);
// 釋放一個之前已經(jīng)存在的贷币、通過調(diào)用 URL.createObjectURL() 創(chuàng)建的 URL 對象
URL.revokeObjectURL(href);
})
}
// 數(shù)組排序
function sortList(_list) {
const length = _list.length;
for(let i = 0; i < length - 1; i++) {
for(let j = i + 1; j < length; j++) {
if (_list[i].i > _list[j].i) {
let temp = null;
temp = _list[j];
_list[j] = _list[i];
_list[i] = temp;
}
}
}
}
</script>
</body>
-
瀏覽器并發(fā)數(shù)一般6個
圖3-1
圖3-2
圖3-3
遺留問題
- 953M以上的文件使用
Uint8Array
合并buffer報Invalid typed array length
- 大文件上傳
WebUploader
工具