实现文件下载断点续传功能:从零到一
前言
在现代Web应用中,大文件下载是一个常见的需求。为了提高用户体验,实现断点续传功能是必不可少的。本文将详细介绍如何使用Vue3和NestJS实现一个支持断点续传的文件下载功能。
技术栈
- 前端:Vue3 + Element Plus
- 后端:NestJS
- 文件系统:Node.js fs模块
功能特点
- 支持文件列表展示
- 支持断点续传
- 支持暂停/继续/停止操作
- 实时显示下载进度
- 优雅的错误处理
后端实现
typescript
@Controller('download')
export class DownloadController {
private readonly targetDir: string;
constructor() {
this.targetDir = path.resolve(process.cwd(), 'target');
// 确保目录存在
if (!fs.existsSync(this.targetDir)) {
fs.mkdirSync(this.targetDir, { recursive: true });
}
}
@Get(':filename')
async downloadFile(@Param('filename') filename: string, @Res() res: Response) {
try {
const decodedFilename = decodeURIComponent(filename);
const filePath = path.join(this.targetDir, decodedFilename);
if (!fs.existsSync(filePath)) {
return res.status(200).json({
code: 200,
message: '',
requestedFile: decodedFilename,
data: fs.readdirSync(this.targetDir)
});
}
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const range = res.req.headers.range;
if (range) {
// 处理断点续传
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const file = fs.createReadStream(filePath, { start, end });
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'application/octet-stream',
});
file.pipe(res);
} else {
// 普通下载
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename=${encodeURIComponent(decodedFilename)}`,
'Accept-Ranges': 'bytes',
});
fs.createReadStream(filePath).pipe(res);
}
} catch (error) {
return res.status(500).json({
message: '下载文件失败',
error: error.message
});
}
}
}
前端实现
vue
<template>
<div class="download-container">
<h1>文件下载</h1>
<div class="file-list">
<el-table v-loading="loading" :data="fileList" style="width: 100%">
<el-table-column prop="filename" label="文件名" />
<el-table-column prop="size" label="文件大小" />
<el-table-column prop="createdAt" label="创建时间">
<template #default="scope">
{{ formatDate(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<div class="operation-column">
<template v-if="!downloadingStates[scope.row.filename]">
<el-button type="primary" @click="startDownload(scope.row.filename)">
下载
</el-button>
</template>
<template v-else>
<el-button
v-if="!downloadingStates[scope.row.filename].paused"
type="warning"
@click="pauseDownload(scope.row.filename)"
>
暂停
</el-button>
<el-button
v-else
type="success"
@click="resumeDownload(scope.row.filename)"
>
继续
</el-button>
<el-button
type="danger"
@click="stopDownload(scope.row.filename)"
>
停止
</el-button>
</template>
<el-progress
v-if="downloadingStates[scope.row.filename]"
:percentage="downloadProgress[scope.row.filename] || 0"
:stroke-width="15"
:show-text="false"
style="width: 100px; margin-left: 10px;"
/>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
核心功能实现
1. 下载控制
typescript
const startDownload = async (filename) => {
if (downloadingStates.value[filename]) return;
downloadingStates.value[filename] = {
paused: false,
receivedLength: 0,
totalSize: 0
};
downloadControllers.value[filename] = new AbortController();
await downloadFile(filename);
};
const pauseDownload = (filename) => {
if (downloadingStates.value[filename]) {
downloadingStates.value[filename].paused = true;
downloadControllers.value[filename].abort();
}
};
const resumeDownload = async (filename) => {
if (downloadingStates.value[filename]?.paused) {
downloadingStates.value[filename].paused = false;
downloadControllers.value[filename] = new AbortController();
await downloadFile(filename);
}
};
const stopDownload = (filename) => {
if (downloadingStates.value[filename]) {
downloadControllers.value[filename].abort();
delete downloadingStates.value[filename];
delete downloadControllers.value[filename];
downloadProgress.value[filename] = 0;
downloading.value = '';
ElMessage.info('下载已停止');
}
};
2. 文件下载实现
typescript
const downloadFile = async (filename) => {
const state = downloadingStates.value[filename];
try {
const response = await fetchWithRetry(`http://localhost:3030/download/${filename}`, {
headers: {
'Accept': 'application/octet-stream',
'Range': `bytes=${state.receivedLength}-`
},
signal: downloadControllers.value[filename].signal
});
if (!downloadingStates.value[filename]) return;
const contentLength = response.headers.get('Content-Length');
const totalSize = parseInt(contentLength, 10) + state.receivedLength;
state.totalSize = totalSize;
const reader = response.body.getReader();
const chunks = [];
let receivedLength = state.receivedLength;
while(true) {
if (!downloadingStates.value[filename]) return;
if (state.paused) break;
const {done, value} = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
state.receivedLength = receivedLength;
const progress = Math.round((receivedLength / totalSize) * 100);
downloadProgress.value[filename] = progress;
}
if (!state.paused && downloadingStates.value[filename]) {
const blob = new Blob(chunks);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
ElMessage.success('下载成功');
delete downloadingStates.value[filename];
delete downloadControllers.value[filename];
downloadProgress.value[filename] = 0;
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('下载已暂停或停止');
} else {
ElMessage.error(error.message || '下载失败,请重试');
delete downloadingStates.value[filename];
delete downloadControllers.value[filename];
downloadProgress.value[filename] = 0;
}
}
};
后续优化方向
- 添加下载速度显示
- 实现多文件并行下载
- 添加下载历史记录
- 实现下载队列管理
- 添加文件校验功能
希望这篇文章对你有所帮助!如果你有任何问题或建议,欢迎在评论区留言讨论。