在现代 Web 应用中,文件上传是一个常见且重要的功能。为了更高效地处理大文件上传以及应对网络不稳定等情况,文件切片上传和断点续传技术应运而生。这里简单的讲述一下大文件上传的机处理制,后文有完整代码。
一、文件上传
前端部分
前端主要负责读取本地文件、对文件进行切片处理、将切片处理成 FormData 对象并发送给后端。
1. 读取本地文件并切片
在 index.html
文件中,通过 HTML 的 <input type="file">
元素让用户选择本地文件。当用户选择文件后,通过 addEventListener
监听 change
事件,获取文件对象。然后调用 createChunk
函数对文件进行切片处理,默认每个切片大小为 5MB。
js
/**
* 对文件进行切片处理
* @param {File} file - 要切片的文件对象
* @param {number} size - 每个切片的大小,默认为 5MB(5 * 1024 * 1024 字节)
* @returns {Array<Blob>} - 包含所有文件切片的数组
*/
function createChunk(file, size = 5 * 1024 * 1024) {
// 用于存储切片结果的数组
const chunkList = [];
// 当前切片的起始位置,初始化为 0
let cur = 0;
// 循环切片,只要当前位置小于文件大小,就继续切片
while (cur < file.size) {
// 使用 File 对象的 slice 方法进行切片
// slice 方法接收两个参数,分别是开始位置和结束位置,范围是左闭右开区间
// 这里从当前位置 cur 开始,截取长度为 size 的文件片段
const chunk = file.slice(cur, cur + size);
// 将切片后的文件片段添加到切片列表中
chunkList.push(chunk);
// 更新当前位置,指向下一个切片的起始位置
cur += size;
}
// 返回包含所有切片的数组
return chunkList;
}
2. 处理切片并封装成 FormData 对象
将切片列表传入 handleChunk
函数,该函数会为每个切片添加相关信息,如 chunkName
、fileName
等,并将其封装成 FormData
对象。
js
/**
* 处理切片列表,为每个切片添加相关信息,并封装成 FormData 对象
* @param {Array<Blob>} chunkList - 文件切片列表
* @returns {Array<Object>} - 包含 FormData 对象和切片索引的数组
*/
function handleChunk(chunkList) {
// 为每个切片添加额外信息,如 chunkName、fileName、index 等
const handleChunkList = chunkList.map((chunk, index) => {
return {
// 当前切片的文件对象
file: chunk,
// 当前切片的大小
size: chunk.size,
// 切片的名称,格式为 文件名-索引
chunkName: `${fileObj.file.name}-${index}`,
// 原始文件的名称
fileName: fileObj.file.name,
// 当前切片的索引
index
};
});
// 将每个添加了额外信息的切片封装成 FormData 对象
const formDataChunkList = handleChunkList.map(({ file, chunkName, fileName, index }) => {
// 创建一个新的 FormData 对象
const formData = new FormData();
// 向 FormData 对象中添加文件切片
formData.append('file', file);
// 向 FormData 对象中添加原始文件名
formData.append('fileName', fileName);
// 向 FormData 对象中添加切片名
formData.append('chunkName', chunkName);
// 返回包含 FormData 对象和切片索引的对象
return { formData, index };
});
// 返回封装好的 FormData 对象列表
return formDataChunkList;
}
3. 发送切片到后端
使用 axios
库将封装好的 FormData
对象发送给后端。通过 Promise.all
确保所有切片都上传完成后,再发送合并请求。
js
/**
* 上传切片到后端,所有切片上传完成后发送合并请求
* @param {Array<Object>} handleChunkList - 处理后的切片列表,每个元素包含文件切片及相关信息
*/
function uploadChunks(handleChunkList) {
// 调用 handleChunk 函数将处理后的切片列表封装成 FormData 对象列表
const formDataChunkList = handleChunk(handleChunkList);
// 为每个 FormData 对象创建一个 axios 的 POST 请求
const requestList = formDataChunkList.map(({ formData, index }) => {
// 发送 POST 请求到后端的 /upload 接口,携带 FormData 对象
return axios.post('http://localhost:3000/upload', formData);
});
// 使用 Promise.all 并行执行所有切片的上传请求
Promise.all(requestList).then(res => {
// 当所有切片上传完成后,发送合并请求到后端的 /merge 接口
axios.post('http://localhost:3000/merge', {
// 每个切片的大小
size: 5 * 1024 * 1024,
// 原始文件的名称
fileName: fileObj.file.name
});
});
}
前端完整代码:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<!-- 引入 axios 库,用于发送 HTTP 请求 -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<!-- 文件选择输入框 -->
<input type="file" id="input">
<!-- 上传按钮 -->
<button id="btn">上传</button>
<script>
// 获取文件选择输入框元素
const input = document.getElementById('input');
// 获取上传按钮元素
const btn = document.getElementById('btn');
// 存储选中的文件对象
const fileObj = {
file: null
};
// 监听文件选择输入框的 change 事件
input.addEventListener('change', function(e) {
// 获取选中的文件
const [ file ] = e.target.files;
// 如果没有选择文件,则直接返回
if (!file) return;
// 将选中的文件存储到 fileObj 中
fileObj.file = file;
});
// 监听上传按钮的 click 事件
btn.addEventListener('click', function() {
// 如果没有选择文件,则直接返回
if (!fileObj.file) return;
// 对选中的文件进行切片处理
const chunkList = createChunk(fileObj.file);
// 处理切片列表
handleChunk(chunkList);
});
// 对文件进行切片处理
function createChunk(file, size = 5 * 1024 * 1024) {
const chunkList = [];
let cur = 0;
// 循环切片,直到文件全部切完
while (cur < file.size) {
// 使用 slice 方法对文件进行切片,参数为开始位置和结束位置,左闭右开
const chunk = file.slice(cur, cur + size);
// 将切片添加到切片列表中
chunkList.push(chunk);
cur += size;
}
return chunkList;
}
// 处理切片列表
function handleChunk(chunkList) {
// 为每个切片添加额外信息,如 chunkName、fileName、index 等
const handleChunkList = chunkList.map((chunk, index) => {
return {
file: chunk,
size: chunk.size,
chunkName: `${fileObj.file.name}-${index}`,
fileName: fileObj.file.name,
index
};
});
// 上传切片
uploadChunks(handleChunkList);
}
// 上传切片
function uploadChunks(handleChunkList) {
// 将每个切片封装成 FormData 对象
const formDataChunkList = handleChunkList.map(({ file, chunkName, fileName, index }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', fileName);
formData.append('chunkName', chunkName);
return {formData, index};
});
// 发送每个切片的上传请求
const requestList = formDataChunkList.map(({ formData, index }) => {
return axios.post('http://localhost:3000/upload', formData);
});
// 当所有切片上传完成后,发送合并请求
Promise.all(requestList).then(res => {
axios.post('http://localhost:3000/merge', {
size: 5 * 1024 * 1024,
fileName: fileObj.file.name
});
});
}
</script>
</body>
</html>
后端部分
后端主要负责接收前端发送的切片文件、将切片文件存储到本地以及合并切片文件。
1. 接收前端的 FormData 对象
在 app.js
文件中,使用 http
模块创建服务器。当接收到 /upload
请求时,使用 multiparty
插件解析 FormData
对象。
js
// 创建一个 HTTP 服务器实例
const server = http.createServer(async (req, res) => {
// 检查请求的 URL 是否为 /upload
if (req.url === '/upload') {
// 创建一个 multiparty 的 Form 实例,用于解析表单数据
const form = new multiparty.Form();
// 解析请求中的表单数据
form.parse(req, async (err, fields, files) => {
// 处理解析过程中可能出现的错误
if (err) {
console.error('解析表单数据时出错:', err);
return;
}
// 从解析结果中获取上传的文件对象
const [file] = files.file;
// 从解析结果中获取文件名
const [fileName] = fields.fileName;
// 从解析结果中获取切片名
const [chunkName] = fields.chunkName;
// 计算存储切片的目录路径
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`);
// 检查切片存储目录是否存在
if (!fs.existsSync(chunkDir)) {
// 如果目录不存在,则创建该目录
await fs.mkdirs(chunkDir);
}
// 将上传的临时文件移动到指定的切片存储目录中
fs.move(file.path, `${chunkDir}/${chunkName}`, (moveErr) => {
if (moveErr) {
console.error('移动文件时出错:', moveErr);
res.statusCode = 500;
res.end('切片上传失败');
} else {
// 移动成功,向客户端返回响应
res.end('切片上传成功');
}
});
});
}
});
2. 合并切片文件
当接收到 /merge
请求时,后端会根据前端传递的文件名和切片大小,将存储在本地的切片文件合并成一个完整的文件。
js
/**
* 合并文件切片
* @param {string} filePath - 合并后的文件路径
* @param {string} fileName - 文件名
* @param {number} size - 每个切片的大小
*/
const mergeFileChunk = async (filePath, fileName, size) => {
// 计算切片存储的目录路径
const chunkDir = path.resolve(`${UPLOAD_DIR}/${fileName}-chunks`);
// 读取切片目录下的所有切片文件路径
let chunkPaths = fs.readdirSync(chunkDir);
// 对切片文件路径按切片编号进行排序
chunkPaths.sort((a, b) => a.split('-').pop() - b.split('-').pop());
// 为每个切片文件创建一个读取并写入到合并文件的操作
const arr = chunkPaths.map((chunkPath, index) => {
return popeStream(
// 切片文件的完整路径
path.resolve(chunkDir, chunkPath),
// 创建一个可写流,用于将切片内容写入到合并文件的指定位置
fs.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
);
});
// 并行执行所有切片的读取和写入操作,等待所有操作完成
await Promise.all(arr);
};
// 在服务器请求处理逻辑中,检查请求的 URL 是否为 /merge
if (req.url === '/merge') {
// 解析 POST 请求中的数据
const data = await resolvePost(req);
// 从解析结果中获取文件名和切片大小
const { fileName, size } = data;
// 计算合并后文件的完整路径
const filePath = path.resolve(UPLOAD_DIR, fileName);
// 调用合并文件切片的函数
await mergeFileChunk(filePath, fileName, size);
// 向客户端返回合并成功的响应
res.end('合并成功');
}
完整代码:
js
const http = require('http');// 引入 Node.js 的 http 模块,用于创建 HTTP 服务器
const multiparty = require('multiparty');// 引入 multiparty 模块,用于解析表单数据
const path = require('path');// 引入 path 模块,用于处理文件路径
const fs = require('fs-extra');// 引入 fs-extra 模块,提供更强大的文件操作功能
// 定义上传文件的存储目录
const UPLOAD_DIR = path.resolve(__dirname, 'qiepian');
// 处理 POST 请求的数据
const resolvePost = req => {
return new Promise(resolve => {
let chunk = '';
// 监听请求的数据事件,将数据拼接起来
req.on('data', data => {
chunk += data;
});
// 监听请求结束事件,将拼接好的数据解析为 JSON 对象
req.on('end', () => {
resolve(JSON.parse(chunk));
});
});
};
// 合并文件切片
const mergeFileChunk = async (filePath, fileName, size) => {
// 获取切片存储的目录
const chunkDir = path.resolve(`${UPLOAD_DIR}/${fileName}-chunks`);
// 获取切片文件列表
let chunkPaths = fs.readdirSync(chunkDir);
console.log(chunkPaths);
// 对切片文件列表进行排序
chunkPaths.sort((a, b) => a.split('-').pop() - b.split('-').pop());
// 循环读取每个切片文件,并写入到合并后的文件中
const arr = chunkPaths.map((chunkPath, index) => {
return popeStream(
path.resolve(chunkDir, chunkPath),
fs.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
);
});
// 等待所有切片文件写入完成
await Promise.all(arr);
};
// 读取文件流并写入到指定的可写流中
const popeStream = (path, writeStream) => {
return new Promise((resolve) => {
// 创建可读流
const readStream = fs.createReadStream(path);
// 监听可读流的 end 事件,当读取完成后删除该文件
readStream.on('end', () => {
fs.unlinkSync(path);
resolve();
});
// 将可读流的数据写入到可写流中
readStream.pipe(writeStream);
});
};
// 创建 HTTP 服务器
const server = http.createServer(async (req, res) => {
// 设置响应头,允许跨域请求
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
// 处理 OPTIONS 请求
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// 处理文件切片上传请求
if (req.url === '/upload') {
const form = new multiparty.Form();
// 解析表单数据
form.parse(req, async (err, fields, files) => {
if (err) {
console.log(err);
return;
}
// 获取上传的文件
const [file] = files.file;
// 获取文件名
const [fileName] = fields.fileName;
// 获取切片名
const [chunkName] = fields.chunkName;
// 获取切片存储的目录
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`);
console.log(chunkDir);
// 判断切片存储的目录是否存在,如果不存在则创建
if (!fs.existsSync(chunkDir)) {
await fs.mkdirs(chunkDir);
}
// 将上传的文件移动到切片存储的目录中
fs.move(file.path, `${chunkDir}/${chunkName}`);
// 返回响应信息
res.end('切片上传成功');
});
}
// 处理文件合并请求
if (req.url === '/merge') {
// 解析 POST 请求的数据
const data = await resolvePost(req);
const { fileName, size } = data;
// 获取合并后的文件路径
const filePath = path.resolve(UPLOAD_DIR, fileName);
// 合并文件切片
await mergeFileChunk(filePath, fileName, size);
// 返回响应信息
res.end('合并成功');
}
});
// 启动服务器,监听 3000 端口
server.listen(3000, () => {
console.log('Server is running on port 3000');
});
运行正常的话,当你在前端上传一个文件,就会在后端的文件夹下,发现一个 qiepian 的文件夹,里面就是上传的合并之后文件切片 。
二、文件断点续传
文件断点续传的核心思想是当文件片段上传到一半时,暂停传输,之后继续传输时,前端过滤掉已经传输完毕的片段,只传输剩下的片段。像 QQ 上传文件就是断点续传。
实现步骤
- 当文件上传过程中暂停后,前端记录已上传的切片信息。
- 当前端点击继续传输按钮后,先发一个校验请求来获取后端已经接收到的文件的片段信息(文件名字和编号)。
- 前端根据后端返回的信息,过滤掉已经传输完毕的片段,继续传输剩下的片段。
代码实现思路
在现有代码基础上,可以在后端添加一个接口用于返回已接收的切片信息,前端在继续上传时调用该接口,过滤已上传的切片后再进行上传。
js
// 后端:当接收到的请求 URL 为 /check 时,执行以下逻辑,该接口用于返回已接收的切片信息
if (req.url === '/check') {
// 调用 resolvePost 函数解析 POST 请求的数据,这是一个异步操作,会返回一个 Promise
const data = await resolvePost(req);
// 从解析后的数据中提取文件名
const { fileName } = data;
// 计算存储该文件切片的目录路径
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`);
// 检查该切片目录是否存在
if (fs.existsSync(chunkDir)) {
// 如果目录存在,读取该目录下的所有切片文件的路径
const chunkPaths = fs.readdirSync(chunkDir);
// 对每个切片文件的路径进行处理,提取出切片的编号(文件名中 - 后面的部分),并转换为整数
const uploadedChunks = chunkPaths.map(chunkPath => parseInt(chunkPath.split('-').pop()));
// 将已上传的切片编号数组转换为 JSON 字符串,并作为响应返回给前端
res.end(JSON.stringify(uploadedChunks));
} else {
// 如果切片目录不存在,说明还没有该文件的切片上传,返回一个空数组的 JSON 字符串给前端
res.end('[]');
}
}
// 前端:继续上传文件,过滤掉已上传的切片,只上传剩余切片、
async function continueUpload() {
// 发送一个 POST 请求到后端的 /check 接口,携带文件名,用于获取已上传的切片信息
const response = await axios.post('http://localhost:3000/check', {
fileName: fileObj.file.name
});
// 从响应中提取已上传的切片编号数组
const uploadedChunks = response.data;
// 使用 filter 方法过滤掉已经上传的切片,只保留未上传的切片
const remainingChunks = chunkList.filter((_, index) => !uploadedChunks.includes(index));
// 调用 handleChunk 函数处理剩余的切片,继续进行上传操作
handleChunk(remainingChunks);
}
前端代码(index.html
)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<!-- 引入 axios 库,用于发送 HTTP 请求 -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<!-- 文件选择输入框 -->
<input type="file" id="input">
<!-- 上传按钮 -->
<button id="btn">上传</button>
<!-- 继续上传按钮,用于断点续传 -->
<button id="continueBtn">继续上传</button>
<script>
const input = document.getElementById('input');
const btn = document.getElementById('btn');
const continueBtn = document.getElementById('continueBtn');
const fileObj = {
file: null
};
// 存储文件切片列表
let chunkList = [];
// 监听文件选择事件
input.addEventListener('change', function(e) {
const [ file ] = e.target.files;
if (!file) return;
fileObj.file = file;
});
// 监听上传按钮点击事件
btn.addEventListener('click', function() {
if (!fileObj.file) return;
// 先将文件切片
chunkList = createChunk(fileObj.file);
// 处理切片
handleChunk(chunkList);
});
// 监听继续上传按钮点击事件
continueBtn.addEventListener('click', async function() {
if (!fileObj.file) return;
// 发送校验请求,获取已上传的切片信息
const uploadedChunks = await getUploadedChunks(fileObj.file.name);
// 过滤掉已经上传的切片
const remainingChunks = chunkList.filter((_, index) =>!uploadedChunks.includes(index));
// 处理剩余的切片
handleChunk(remainingChunks);
});
// 切片函数,将文件按指定大小进行切片
function createChunk(file, size = 5 * 1024 * 1024) {
const chunkList = [];
let cur = 0;
while (cur < file.size) {
// slice 方法的参数是开始位置和结束位置,左闭右开
const chunk = file.slice(cur, cur + size);
chunkList.push(chunk);
cur += size;
}
return chunkList;
}
// 处理切片函数,将切片信息封装成对象
function handleChunk(chunkList) {
const handleChunkList = chunkList.map((chunk, index) => {
return {
file: chunk,
size: chunk.size,
chunkName: `${fileObj.file.name}-${index}`,
fileName: fileObj.file.name,
index
};
});
// 上传切片
uploadChunks(handleChunkList);
}
// 上传切片函数,将切片数据封装成 FormData 并发送请求
function uploadChunks(handleChunkList) {
const formDataChunkList = handleChunkList.map(({ file, chunkName, fileName, index }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', fileName);
formData.append('chunkName', chunkName);
return {formData, index};
});
// 将 formDataChunkList 中的 formData 一份一份发送给服务器
const requestList = formDataChunkList.map(({ formData, index }) => {
return axios.post('http://localhost:3000/upload', formData);
});
Promise.all(requestList).then(res => {
// 所有切片上传完成后,发送合并请求
axios.post('http://localhost:3000/merge', {
size: 5 * 1024 * 1024,
fileName: fileObj.file.name
});
});
}
// 获取已上传的切片信息
async function getUploadedChunks(fileName) {
const response = await axios.get(`http://localhost:3000/check?fileName=${fileName}`);
return response.data;
}
</script>
</body>
</html>
后端代码(app.js
)
javascript
const http = require('http');
const multiparty = require('multiparty');
const path = require('path');
const fs = require('fs-extra');
// 定义切片文件存储目录
const UPLOAD_DIR = path.resolve(__dirname, 'qiepian');
// 处理 post 数据: get 的参数直接 req.url 获取,post 的参数需要解析
const resolvePost = req => {
return new Promise(resolve => {
let chunk = '';
// data 事件,获取请求体数据
req.on('data', data => {
chunk += data;
});
// 自动触发,获取完数据后触发
req.on('end', () => {
resolve(JSON.parse(chunk)); // 将字符串转为对象
});
});
};
// 合并切片
const mergeFileChunk = async (filePath, fileName, size) => {
const chunkDir = path.resolve(`${UPLOAD_DIR}/${fileName}-chunks`);
let chunkPaths = fs.readdirSync(chunkDir);
console.log(chunkPaths);
// 按切片编号排序
chunkPaths.sort((a, b) => a.split('-').pop() - b.split('-').pop());
const arr = chunkPaths.map((chunkPath, index) => {
return popeStream(
path.resolve(chunkDir, chunkPath),
fs.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
);
});
await Promise.all(arr);
};
// 读取文件流
const popeStream = (path, writeStream) => {
return new Promise((resolve) => {
const readStream = fs.createReadStream(path); // 读取该文件流
readStream.on('end', () => {
fs.unlinkSync(path); // 删除该文件
resolve();
});
// 将读取的文件流写入到指定的文件流中
readStream.pipe(writeStream);
});
};
const server = http.createServer(async (req, res) => {
// 设置跨域请求头
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (req.url === '/upload') {
const form = new multiparty.Form();
form.parse(req, async (err, fields, files) => {
if (err) {
console.log(err);
return;
}
const [file] = files.file;
const [fileName] = fields.fileName;
const [chunkName] = fields.chunkName;
// 保存片段
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`);
console.log(chunkDir);
// 判断文件夹是否存在
if (!fs.existsSync(chunkDir)) {
await fs.mkdirs(chunkDir);
}
// 将切片文件移动到指定目录
fs.move(file.path, `${chunkDir}/${chunkName}`);
res.end('切片上传成功');
});
}
if (req.url === '/merge') { // 切片发送完成,合并某一文件切片
const data = await resolvePost(req);
const { fileName, size } = data;
const filePath = path.resolve(UPLOAD_DIR, fileName);
// 将 path 路径对应的文件夹下的所有文件合并
await mergeFileChunk(filePath, fileName, size);
res.end('合并成功');
}
if (req.url.startsWith('/check')) {
const query = req.url.split('?')[1];
const params = new URLSearchParams(query);
const fileName = params.get('fileName');
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`);
if (fs.existsSync(chunkDir)) {
const chunkPaths = fs.readdirSync(chunkDir);
const uploadedChunks = chunkPaths.map(chunkPath => parseInt(chunkPath.split('-').pop()));
res.end(JSON.stringify(uploadedChunks));
} else {
res.end(JSON.stringify([]));
}
}
});
server.listen(3000, () => {
console.log('Server is running on port 3000');
});
三、总结
通过文件切片上传和断点续传技术,可以有效地提高大文件上传的效率和稳定性。前端通过对文件进行切片处理,将切片分批上传到后端,后端接收切片并在合适的时候进行合并。同时,断点续传功能可以让用户在网络不稳定或其他原因导致上传中断时,继续上传未完成的部分,提升用户体验。