做電商網站哪里好優(yōu)化師助理
大文件分片上傳
效果展示
前端
思路
前端的思路:將大文件切分成多個小文件,然后并發(fā)給后端。
頁面構建
先在頁面上寫幾個組件用來獲取文件。
<body><input type="file" id="file" /><button id="uploadButton">點擊上傳</button>
</body>
功能函數(shù):生成切片
切分文件的核心函數(shù)是 slice
,沒錯,就是這么的神奇啊
我們把切好的 chunk
放到數(shù)組里,等待下一步的包裝處理
/*** 默認切片大小 10 MB*/
const SIZE = 10 * 1024 * 1024;/*** 功能:生成切片*/
function handleCreateChunk(file, size = SIZE) {const fileChunkList = [];let cur = 0;while (cur < file.size) {fileChunkList.push({file: file.slice(cur, cur + size),});cur += size;}return fileChunkList;
}
功能函數(shù):請求邏輯
在這里簡單封裝一下 XMLHttpRequest
/*** 功能:封裝請求* @param {*} param0* @returns*/
function request({ url, method = 'post', data, header = {}, requestList }) {return new Promise((resolve, reject) => {let xhr = new XMLHttpRequest();xhr.open(method, url);Object.keys(header).forEach((item) => {xhr.setRequestHeader(item, header[item]);});xhr.onload = function (e) {resolve({data: e.target.response,});};xhr.send(data);});
}
功能函數(shù):上傳切片
/*** 功能: 上傳切片* 包裝好 FormData 之后通過 Promise.all() 并發(fā)所有切片*/
async function uploadChunks(hanldleData, fileName) {const requestList = hanldleData.map(({ chunk, hash }) => {const formData = new FormData();formData.append('chunk', chunk);formData.append('hash', hash);formData.append('filename', fileName);return formData;}).map((formData) => {request({// url: 'http://localhost:3001/upload',url: 'upload',data: formData,});});await Promise.all(requestList);
}/*** 功能:觸發(fā)上傳
*/
document.getElementById('uploadButton').onclick = async function () {// 切片const file = document.getElementById('file').files[0];console.log(file);const fileName = file.name;const fileChunkList = handleCreateChunk(file);// 包裝const hanldleData = fileChunkList.map(({ file }, index) => {return {chunk: file,hash: `${fileName}_${index}`,};});await uploadChunks(hanldleData, fileName);
};
可以在請求中看到有很多個請求并發(fā)的上傳
后端
后端的思路是:
- 把 Node 暫存的
chunk
文件轉移到我想處理的地方(也可以直接處理,看你的)- 創(chuàng)建寫入流,把各個
chunk
合并,前端會給你每個 chunk 的大小,還有hash
值來定位每個chunk
的位置
獲取 chunk 切片文件
先把上傳的接口寫好,
const Koa = require('koa');
const Views = require('koa-views');
const Router = require('koa-router');
const Static = require('koa-static');
const { koaBody } = require('koa-body');
const fs = require('fs');
const fse = require('fs-extra');const app = new Koa();
const router = new Router();
app.use(Views(__dirname));
app.use(Static(__dirname));
app.use(koaBody({multipart: true,formidable: {maxFields: 1000 * 1024 * 1024,},})
);router.get('/', async (ctx) => {await ctx.render('index.html');
});/*** 功能:上傳接口* - 從 ctx.request.body 中獲取 hash 以及 filename* - 從 ctx.request.files 中拿到分片數(shù)據(jù)* - 然后再把 node 幫我們臨時存放的 chunk 文件的 filepath 拿到,之后移動到我們想要存放的路徑下* - filepath 和 hash 是一一對應的關系*/
router.post('/upload', async (ctx) => {const { hash, filename } = ctx.request.body;const { filepath } = ctx.request.files?.chunk;const chunkPath = `${__dirname}/chunkPath/${filename}`;if (!fse.existsSync(chunkPath)) {await fse.mkdirs(chunkPath);}await fse.move(filepath, `${chunkPath}/${hash}`);ctx.body = {code: 1,};
});app.use(router.routes());
app.listen(3000, () => {console.log(`server start: http://localhost:3000`);
});
寫完這些就可以拿到 chunk
合并接口
先寫一個接口,用來拿到 hash
、文件名
/*** 功能: merge 接口* - hasMergeChunk 變量是上面用來記錄的* - mergePath 定義一下合并后的文件的路徑*/
router.post('/merge', async (ctx) => {// console.log(ctx.request.body);const { fileName, size } = ctx.request.body;hasMergeChunk = {};const mergePath = `${__dirname}/merge/${fileName}`;if (!fse.existsSync(`${__dirname}/merge`)) {fse.mkdirSync(`${__dirname}/merge`);}await mergeChunk(mergePath, fileName, size);ctx.body = {data: '成功',};
});
合并分片的功能函數(shù)
然后開始合并
/*** 功能:合并 Chunk* - 1. chunkDir: 是 chunks 文件們所在的文件夾的路徑* - 2. chunkPaths: 是個 Array,數(shù)組中包含所有的 chunk 的 path* - 3. 因為 每個 chunk 的 path 命名是通過 hash 組成的,所以我們先排序一下,* - 算是為 createWriteStream 中的 start 做準備* - 4. 為每個 chunk 的 path 創(chuàng)建寫入流,寫到 mergePath 這個路徑下。因為已經* - 排序了,所以 start 就是每個文件的 index * eachChunkSize* @param {*} mergePath* @param {*} name* @param {*} eachChunkSize*/
async function mergeChunk(mergePath, name, eachChunkSize) {const chunkDir = `${__dirname}/chunkPath/${name}`;const chunkPaths = await fse.readdir(chunkDir);chunkPaths.sort((a, b) => a.split('_')[1] - b.split('_')[1]);await Promise.all(chunkPaths.map((chunk, index) => {const eachChunkPath = `${chunkDir}/${chunk}`;const writeStream = fse.createWriteStream(mergePath, {start: index * eachChunkSize,});return pipeStream(eachChunkPath, writeStream);}));console.log('合并完成');fse.rmdirSync(chunkDir);console.log(`刪除 ${chunkDir} 文件夾`);
}
接著就是寫入流
/*** 功能:創(chuàng)建 pipe 寫文件流* - 1. [首先了解一下什么是輸入輸出流](https://www.jmjc.tech/less/111)* - 2. hasMergeChunk 變量用于記錄一下那些已經合并完成了,也可以寫成數(shù)組,都行。* - 3. 可以檢測輸出流的 end 事件,表示我這個 chunk 已經流完了,然后寫一下善后邏輯。* @param {*} path* @param {*} writeStream* @returns*/
let hasMergeChunk = {};
function pipeStream(path, writeStream) {return new Promise((resolve) => {const readStream = fse.createReadStream(path); // 輸出流readStream.pipe(writeStream); // 輸出通過管道流向輸入readStream.on('end', () => {hasMergeChunk[path] = 'finish';fse.unlinkSync(path); // 刪除此文件resolve();console.log(`合并 No.${path.split('_')[1]}, 已經合并${Object.keys(hasMergeChunk).length}`);});});
}
至此一個基本的邏輯上傳就做好了!
Q & A
發(fā)送片段之后的合并可能出現(xiàn)錯誤
這個情況分析了一下是前端的鍋啊,前端的 await Promise.all()
并不能保證后端的文件流都寫完了。
完整代碼
前端
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Document</title><script src="request.js"></script></head><body><input type="file" id="file" /><button id="uploadButton">點擊上傳</button><button id="mergeButton">點擊合并</button></body><script>/*** 默認切片大小 10 MB*/const SIZE = 10 * 1024 * 1024;/*** 功能:生成切片*/function handleCreateChunk(file, size = SIZE) {const fileChunkList = [];let cur = 0;while (cur < file.size) {fileChunkList.push({file: file.slice(cur, cur + size),});cur += size;}return fileChunkList;}/*** 功能: 上傳切片* - 注意 map 里別忘了寫 return*/async function uploadChunks(hanldleData, fileName) {const requestList = hanldleData.map(({ chunk, hash }) => {const formData = new FormData();formData.append('chunk', chunk);formData.append('hash', hash);formData.append('filename', fileName);return formData;}).map((formData) => {return request({url: 'upload',data: formData,});});await Promise.all(requestList).then((res) => {console.log('所有上傳結束', res);});console.log('發(fā)送合并請求');await request({url: 'merge',headers: {'content-type': 'application/json',},data: JSON.stringify({size: SIZE,fileName,}),});}document.getElementById('uploadButton').onclick = async function () {// 切片const file = document.getElementById('file').files[0];const fileName = file.name;const fileChunkList = handleCreateChunk(file);// 包裝const hanldleData = fileChunkList.map(({ file }, index) => {return {chunk: file,hash: `${fileName}_${index}`,};});await uploadChunks(hanldleData, fileName);};// document.getElementById('mergeButton').onclick = async function () {// await request({// url: 'merge',// headers: {// 'content-type': 'application/json',// },// data: JSON.stringify({// size: SIZE,// fileName: '116 Mb.mkv',// }),// });// };</script>
</html>
后端
const Koa = require('koa');
const Views = require('koa-views');
const Router = require('koa-router');
const Static = require('koa-static');
const { koaBody } = require('koa-body');
const fse = require('fs-extra');const app = new Koa();
const router = new Router();
app.use(Views(__dirname));
app.use(Static(__dirname));
app.use(koaBody({multipart: true,formidable: {maxFields: 1000 * 1024 * 1024,},})
);router.get('/', async (ctx) => {await ctx.render('index.html');
});/*** 功能:上傳接口* - 從 ctx.request.body 中獲取 hash 以及 filename* - 從 ctx.request.files 中拿到分片數(shù)據(jù)* - 然后再把 node 幫我們臨時存放的 chunk 文件的 filepath 拿到,之后移動到我們想要存放的路徑下* - filepath 和 hash 是一一對應的關系*/
router.post('/upload', async (ctx) => {const { hash, filename } = ctx.request.body;const { filepath } = ctx.request.files?.chunk;const chunkPath = `${__dirname}/chunkPath/${filename}`;if (!fse.existsSync(chunkPath)) {await fse.mkdirs(chunkPath);}await fse.move(filepath, `${chunkPath}/${hash}`);ctx.body = {code: 1,};
});/*** 功能:創(chuàng)建 pipe 寫文件流* - 1. [首先了解一下什么是輸入輸出流](https://www.jmjc.tech/less/111)* - 2. hasMergeChunk 變量用于記錄一下那些已經合并完成了,也可以寫成數(shù)組,都行。* - 3. 可以檢測輸出流的 end 事件,表示我這個 chunk 已經流完了,然后寫一下善后邏輯。* @param {*} path* @param {*} writeStream* @returns*/
let hasMergeChunk = {};
function pipeStream(path, writeStream) {return new Promise((resolve) => {const readStream = fse.createReadStream(path); // 輸出流readStream.pipe(writeStream); // 輸出通過管道流向輸入readStream.on('end', () => {hasMergeChunk[path] = 'finish';fse.unlinkSync(path); // 刪除此文件resolve();console.log(`合并 No.${path.split('_')[1]}, 已經合并${Object.keys(hasMergeChunk).length}`);});});
}/*** 功能:合并 Chunk* - 1. chunkDir: 是 chunks 文件們所在的文件夾的路徑* - 2. chunkPaths: 是個 Array,數(shù)組中包含所有的 chunk 的 path* - 3. 因為 每個 chunk 的 path 命名是通過 hash 組成的,所以我們先排序一下,* - 算是為 createWriteStream 中的 start 做準備* - 4. 為每個 chunk 的 path 創(chuàng)建寫入流,寫到 mergePath 這個路徑下。因為已經* - 排序了,所以 start 就是每個文件的 index * eachChunkSize* - 5. 每個寫入流都用 Promise 包裝了一下,然后用 await Promise.all() 等待處理完* @param {*} mergePath* @param {*} name* @param {*} eachChunkSize*/
async function mergeChunk(mergePath, name, eachChunkSize) {const chunkDir = `${__dirname}/chunkPath/${name}`;const chunkPaths = await fse.readdir(chunkDir);chunkPaths.sort((a, b) => a.split('_')[1] - b.split('_')[1]);await Promise.all(chunkPaths.map((chunk, index) => {const eachChunkPath = `${chunkDir}/${chunk}`;// 創(chuàng)建輸入流,并為每個 chunk 定好位置const writeStream = fse.createWriteStream(mergePath, {start: index * eachChunkSize,});return pipeStream(eachChunkPath, writeStream);}));console.log('合并完成');fse.rmdirSync(chunkDir);console.log(`刪除 ${chunkDir} 文件夾`);
}/*** 功能: merge 接口* - hasMergeChunk 變量是上面用來記錄的* - mergePath 定義一下合并后的文件的路徑*/
router.post('/merge', async (ctx) => {// console.log(ctx.request.body);const { fileName, size } = ctx.request.body;hasMergeChunk = {};const mergePath = `${__dirname}/merge/${fileName}`;if (!fse.existsSync(`${__dirname}/merge`)) {fse.mkdirSync(`${__dirname}/merge`);}await mergeChunk(mergePath, fileName, size);ctx.body = {data: '成功',};
});app.use(router.routes());
app.listen(3000, () => {console.log(`server start: http://localhost:3000`);
});
request.js
的封裝
/*** 功能:封裝請求* @param {*} param0* @returns*/
function request({ url, method = 'post', data, headers = {}, requestList }) {return new Promise((resolve, reject) => {let xhr = new XMLHttpRequest();xhr.open(method, url);Object.keys(headers).forEach((item) => {xhr.setRequestHeader(item, headers[item]);});xhr.onloadend = function (e) {resolve({data: e.target.response,});};xhr.send(data);});
}