如果下载一个G级文件,通过一次请求去下载容易造成内存泄露,因此可以把文件分割成几段返回给前端端,由前端拿到所有片段后合并成一个完整的文件。
Range: bytes=开始字节-结束字节
;
Content-Range: bytes 开始字节-结束字节/文件总字节数
1. 原理
参考的这个:前端多线程大文件下载实践
Range更多介绍,超全
利用HTTP/1.1
提供的range
字段,在前端请求后端时,请求头中携带Range
,后端获取该字段就可以知道当前要下载哪段文件。
图1-2
图1-3
图1-4
2. 服务端实现
function createFileResHeader(fileName, size) {
return {
// 告诉浏览器这是一个需要以附件形式下载的文件(浏览器下载的默认行为,前端可以从这个响应头中获取文件名:前端使用ajax请求下载的时候,后端若返回文件流,此时前端必须要设置文件名-主要是为了获取文件后缀,否则前端会默认为txt文件)
'Content-Disposition': 'attachment; filename=' + encodeURIComponent(fileName),
// 告诉浏览器是二进制文件,不要直接显示内容
'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字段,返回文件大小,前端根据文件大小去决定要分成几段
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)(一个请求的时候正常,多个并发请求的时候就会报这个,原因暂时未知)
* res.writeHead 和res.setHeader 啥区别,官网没有给出明确说明,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字段,服务端返回文件大小,客户端根据文件大小去决定要分成几段。
-
range范围不合法,返回416。
图2-1 - range范围合法,返回206。
图2-2
http状态码 - res.writeHead
node.js中res.writeHead的用法总结
3. 客户端实现
<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属性的地址必须是非跨域的地址,如果引用的是第三方的网站或者说是前后端分离的项目(调用后台的接口),这时download就会不起作用。
* 此时,如果是下载浏览器无法解析的文件,例如.exe,.xlsx..那么浏览器会自动下载,但是如果使用浏览器可以解析的文件,比如.txt,.png,.pdf....浏览器就会采取预览模式
* 所以,对于.txt,.png,.pdf等的预览功能我们就可以直接不设置download属性(前提是后端响应头的Content-Type: application/octet-stream,如果为application/pdf浏览器则会判断文件为 pdf ,自动执行预览的策略)
*/
fileName && el.setAttribute('download', fileName);
el.href = url;
console.log(el);
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
};
// 根据header里的contenteType转换请求参数
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'}是对于数组参数的处理
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字节)的文件
let result = new Uint8Array(totalLength);
let offset = 0;
for (let item of list) {
result.set(item, offset);
offset += item.length;
}
return result;
}
/**
* ajax实现文件下载、获取文件下载进度
* @param {String} method - 请求方法get/post
* @param {String} url
* @param {Object} [params] - 请求参数,{name: '文件下载'}
* @param {Object} [config] - 方法配置
*/
function downLoadAjaxEvt(ajaxResponse) {
const fileSize = ajaxResponse.getResponseHeader('Content-Length') * 1;
// 两种解码方式,区别自行百度: decodeURIComponent/decodeURI(主要获取后缀名,否则某些浏览器会一律识别为txt,导致下载下来的都是txt)
const fileName = decodeURIComponent((ajaxResponse.getResponseHeader('content-disposition') || '; filename="未知文件"').split(';')[1].trim().slice(9));
// 5M为一片 浏览器并发请求一般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);
// 释放一个之前已经存在的、通过调用 URL.createObjectURL() 创建的 URL 对象
URL.revokeObjectURL(href);
})
}
// 数组排序
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>
-
浏览器并发数一般6个
图3-1
图3-2
图3-3
遗留问题
- 953M以上的文件使用
Uint8Array
合并buffer报Invalid typed array length
- 大文件上传
WebUploader
工具
网友评论