前端开发那点事:纯原生js大文件上传全流程详解与实现

提示:这个纯原生js写的,您可以手动转成自己项目中的上传组件(vue或者react),也很简单的


前言

在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中断,今天就带大家从头到尾盘清楚其中的技术细节。


一、先看效果:我们要实现什么?

  1. 文件分片: 将大文件分割成小块,便于上传和管理
  2. 分片上传: 并行或串行上传各个分片
  3. 进度监控: 实时显示上传进度
  4. 断点续传: 记录已上传分片,支持从中断处继续上传
  5. 分片合并: 服务器端将所有分片合并为完整文件

二、全流程拆解:从选文件到合并

我们先从宏观视角梳理整个流程,再拆分成前端和后端的具体实现。整个过程可总结为「5 步走」,每一步都有明确的目标和技术要点:

用户选择文件 → 前端分片+算哈希 → 校验文件状态(秒传/断点续传) → 并发上传分片 → 后端合并分片

下面是完整的代码和注释:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>大文件上传示例</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
            color: #333;
            background-color: #f5f7fa;
            padding: 20px;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            background: white;
            border-radius: 10px;
            box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
            padding: 30px;
        }
        h1 {
            text-align: center;
            margin-bottom: 30px;
            color: #2c3e50;
        }
        .upload-area {
            border: 2px dashed #3498db;
            border-radius: 8px;
            padding: 40px 20px;
            text-align: center;
            margin-bottom: 30px;
            transition: all 0.3s;
            background-color: #f8fafc;
        }
        .upload-area:hover {
            border-color: #2980b9;
            background-color: #f0f7ff;
        }
        .upload-area.dragover {
            border-color: #27ae60;
            background-color: #e8f6ef;
        }
        .upload-area p {
            margin-bottom: 15px;
            color: #7f8c8d;
        }
        .file-input {
            display: none;
        }
        .btn {
            display: inline-block;
            background: #3498db;
            color: white;
            padding: 10px 20px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
            transition: background 0.3s;
        }
        .btn:hover {
            background: #2980b9;
        }
        .btn:disabled {
            background: #bdc3c7;
            cursor: not-allowed;
        }
        .btn-success {
            background: #27ae60;
        }
        .btn-success:hover {
            background: #219653;
        }
        .progress-container {
            margin: 20px 0;
        }
        .progress-bar {
            height: 20px;
            background: #ecf0f1;
            border-radius: 10px;
            overflow: hidden;
            margin-bottom: 10px;
        }
        .progress {
            height: 100%;
            background: linear-gradient(90deg, #3498db, #2ecc71);
            width: 0%;
            transition: width 0.3s;
        }
        .progress-text {
            text-align: center;
            font-size: 14px;
            color: #7f8c8d;
        }
        .file-info {
            background: #f8f9fa;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
        .file-info p {
            margin-bottom: 5px;
        }
        .chunk-info {
            margin-top: 20px;
            max-height: 200px;
            overflow-y: auto;
        }
        .chunk-item {
            display: flex;
            justify-content: space-between;
            padding: 8px;
            border-bottom: 1px solid #eee;
        }
        .chunk-item:last-child {
            border-bottom: none;
        }
        .status-pending {
            color: #f39c12;
        }
        .status-uploading {
            color: #3498db;
        }
        .status-completed {
            color: #27ae60;
        }
        .status-error {
            color: #e74c3c;
        }
        .controls {
            display: flex;
            gap: 10px;
            margin-top: 20px;
        }
        .message {
            padding: 10px;
            border-radius: 5px;
            margin-top: 15px;
            display: none;
        }
        .message.success {
            background: #d4edda;
            color: #155724;
            display: block;
        }
        .message.error {
            background: #f8d7da;
            color: #721c24;
            display: block;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>大文件上传示例</h1>
        
        <div class="upload-area" id="uploadArea">
            <p>拖放文件到此处或</p>
            <input type="file" id="fileInput" class="file-input">
            <label for="fileInput" class="btn">选择文件</label>
        </div>
        
        <div class="file-info" id="fileInfo" style="display: none;">
            <p><strong>文件名:</strong> <span id="fileName"></span></p>
            <p><strong>文件大小:</strong> <span id="fileSize"></span></p>
            <p><strong>分片数量:</strong> <span id="chunkCount"></span></p>
        </div>
        
        <div class="progress-container">
            <div class="progress-bar">
                <div class="progress" id="progressBar"></div>
            </div>
            <div class="progress-text" id="progressText">0%</div>
        </div>
        
        <div class="chunk-info" id="chunkInfo" style="display: none;">
            <h3>分片上传状态</h3>
            <div id="chunkList"></div>
        </div>
        
        <div class="controls">
            <button class="btn" id="uploadBtn" disabled>开始上传</button>
            <button class="btn" id="pauseBtn" disabled>暂停上传</button>
            <button class="btn" id="resumeBtn" disabled>继续上传</button>
        </div>
        
        <div class="message" id="message"></div>
    </div>

    <script>
        // 配置参数
        const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB 每个分片大小
        const MAX_CONCURRENT_UPLOADS = 3; // 最大并发上传数
        
        // 全局变量
        let file = null;
        let fileHash = '';
        let chunkList = [];
        let uploadedChunks = new Set();
        let isUploading = false;
        let isPaused = false;
        let uploadedSize = 0;
        
        // DOM 元素
        const uploadArea = document.getElementById('uploadArea');
        const fileInput = document.getElementById('fileInput');
        const fileInfo = document.getElementById('fileInfo');
        const fileName = document.getElementById('fileName');
        const fileSize = document.getElementById('fileSize');
        const chunkCount = document.getElementById('chunkCount');
        const progressBar = document.getElementById('progressBar');
        const progressText = document.getElementById('progressText');
        const chunkInfo = document.getElementById('chunkInfo');
        const chunkListElem = document.getElementById('chunkList');
        const uploadBtn = document.getElementById('uploadBtn');
        const pauseBtn = document.getElementById('pauseBtn');
        const resumeBtn = document.getElementById('resumeBtn');
        const message = document.getElementById('message');
        
        // 事件监听
        fileInput.addEventListener('change', handleFileSelect);
        uploadArea.addEventListener('dragover', handleDragOver);
        uploadArea.addEventListener('dragleave', handleDragLeave);
        uploadArea.addEventListener('drop', handleDrop);
        uploadBtn.addEventListener('click', startUpload);
        pauseBtn.addEventListener('click', pauseUpload);
        resumeBtn.addEventListener('click', resumeUpload);
        
        // 处理文件选择
        function handleFileSelect(e) {
            const selectedFile = e.target.files[0];
            if (selectedFile) {
                prepareFile(selectedFile);
            }
        }
        
        // 处理拖放
        function handleDragOver(e) {
            e.preventDefault();
            uploadArea.classList.add('dragover');
        }
        
        function handleDragLeave(e) {
            e.preventDefault();
            uploadArea.classList.remove('dragover');
        }
        
        function handleDrop(e) {
            e.preventDefault();
            uploadArea.classList.remove('dragover');
            const droppedFile = e.dataTransfer.files[0];
            if (droppedFile) {
                prepareFile(droppedFile);
            }
        }
        
        // 准备文件
        async function prepareFile(selectedFile) {
            file = selectedFile;
            
            // 显示文件信息
            fileName.textContent = file.name;
            fileSize.textContent = formatFileSize(file.size);
            
            // 计算分片
            const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
            chunkCount.textContent = totalChunks;
            
            // 生成文件哈希(简化版,实际应用中应使用更安全的哈希算法)
            fileHash = await generateFileHash(file);
            
            // 初始化分片列表
            chunkList = [];
            for (let i = 0; i < totalChunks; i++) {
                const start = i * CHUNK_SIZE;
                const end = Math.min(start + CHUNK_SIZE, file.size);
                chunkList.push({
                    index: i,
                    start,
                    end,
                    status: 'pending' // pending, uploading, completed, error
                });
            }
            
            // 显示文件信息和分片列表
            fileInfo.style.display = 'block';
            chunkInfo.style.display = 'block';
            renderChunkList();
            
            // 启用上传按钮
            uploadBtn.disabled = false;
            
            // 检查服务器是否已有部分分片
            await checkUploadedChunks();
        }
        
        // 生成文件哈希(简化版)
        async function generateFileHash(file) {
            // 在实际应用中,应使用更安全的哈希算法如SHA-256
            // 这里使用文件名+大小作为简化哈希
            return btoa(`${file.name}-${file.size}`).replace(/[^a-zA-Z0-9]/g, '');
        }
        
        // 渲染分片列表
        function renderChunkList() {
            chunkListElem.innerHTML = '';
            chunkList.forEach(chunk => {
                const chunkItem = document.createElement('div');
                chunkItem.className = 'chunk-item';
                chunkItem.innerHTML = `
                    <span>分片 ${chunk.index + 1}</span>
                    <span class="status-${chunk.status}">${getStatusText(chunk.status)}</span>
                `;
                chunkListElem.appendChild(chunkItem);
            });
        }
        
        // 获取状态文本
        function getStatusText(status) {
            switch(status) {
                case 'pending': return '等待上传';
                case 'uploading': return '上传中';
                case 'completed': return '已完成';
                case 'error': return '上传失败';
                default: return '未知';
            }
        }
        
        // 检查已上传的分片
        async function checkUploadedChunks() {
            try {
                // 在实际应用中,这里应该向服务器发送请求
                // 获取已上传的分片列表
                const response = await fetch('/api/check-chunks', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        fileHash: fileHash,
                        totalChunks: chunkList.length
                    })
                });
                
                if (response.ok) {
                    const data = await response.json();
                    uploadedChunks = new Set(data.uploadedChunks || []);
                    
                    // 更新已上传的分片状态
                    uploadedChunks.forEach(index => {
                        if (chunkList[index]) {
                            chunkList[index].status = 'completed';
                        }
                    });
                    
                    // 更新进度
                    updateProgress();
                    renderChunkList();
                    
                    showMessage('已找到之前上传的记录,可以继续上传', 'success');
                }
            } catch (error) {
                console.error('检查已上传分片失败:', error);
                // 如果检查失败,继续正常上传流程
            }
        }
        
        // 开始上传
        async function startUpload() {
            if (!file || isUploading) return;
            
            isUploading = true;
            isPaused = false;
            uploadBtn.disabled = true;
            pauseBtn.disabled = false;
            resumeBtn.disabled = true;
            
            // 获取待上传的分片
            const chunksToUpload = chunkList.filter(chunk => 
                chunk.status !== 'completed' && !uploadedChunks.has(chunk.index)
            );
            
            // 分批上传
            await uploadChunksInBatches(chunksToUpload);
            
            // 所有分片上传完成后,请求合并
            if (uploadedChunks.size === chunkList.length) {
                await mergeChunks();
            }
            
            isUploading = false;
            pauseBtn.disabled = true;
            resumeBtn.disabled = true;
            
            if (!isPaused) {
                uploadBtn.disabled = false;
                showMessage('文件上传完成!', 'success');
            }
        }
        
        // 分批上传分片
        async function uploadChunksInBatches(chunks) {
            for (let i = 0; i < chunks.length; i += MAX_CONCURRENT_UPLOADS) {
                if (isPaused) break;
                
                const batch = chunks.slice(i, i + MAX_CONCURRENT_UPLOADS);
                await Promise.all(batch.map(chunk => uploadChunk(chunk)));
            }
        }
        
        // 上传单个分片
        async function uploadChunk(chunk) {
            if (uploadedChunks.has(chunk.index)) return;
            
            chunk.status = 'uploading';
            renderChunkList();
            
            try {
                const chunkData = file.slice(chunk.start, chunk.end);
                const formData = new FormData();
                formData.append('file', chunkData);
                formData.append('chunkIndex', chunk.index);
                formData.append('totalChunks', chunkList.length);
                formData.append('fileHash', fileHash);
                formData.append('fileName', file.name);
                
                const response = await fetch('/api/upload-chunk', {
                    method: 'POST',
                    body: formData
                });
                
                if (response.ok) {
                    chunk.status = 'completed';
                    uploadedChunks.add(chunk.index);
                    uploadedSize += chunk.end - chunk.start;
                    updateProgress();
                } else {
                    throw new Error('上传失败');
                }
            } catch (error) {
                console.error(`分片 ${chunk.index} 上传失败:`, error);
                chunk.status = 'error';
            }
            
            renderChunkList();
        }
        
        // 暂停上传
        function pauseUpload() {
            isPaused = true;
            pauseBtn.disabled = true;
            resumeBtn.disabled = false;
        }
        
        // 继续上传
        function resumeUpload() {
            isPaused = false;
            pauseBtn.disabled = false;
            resumeBtn.disabled = true;
            startUpload();
        }
        
        // 更新进度
        function updateProgress() {
            const progress = (uploadedSize / file.size) * 100;
            progressBar.style.width = `${progress}%`;
            progressText.textContent = `${progress.toFixed(2)}%`;
        }
        
        // 合并分片
        async function mergeChunks() {
            try {
                const response = await fetch('/api/merge-chunks', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        fileHash: fileHash,
                        fileName: file.name,
                        totalChunks: chunkList.length
                    })
                });
                
                if (response.ok) {
                    showMessage('文件合并完成!', 'success');
                } else {
                    throw new Error('合并失败');
                }
            } catch (error) {
                console.error('合并分片失败:', error);
                showMessage('文件合并失败,请重试', 'error');
            }
        }
        
        // 显示消息
        function showMessage(text, type) {
            message.textContent = text;
            message.className = 'message';
            message.classList.add(type);
        }
        
        // 格式化文件大小
        function formatFileSize(bytes) {
            if (bytes === 0) return '0 Bytes';
            const k = 1024;
            const sizes = ['Bytes', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }
    </script>
</body>
</html>

核心功能说明

1. 文件分片

  • 将大文件按固定大小(如2MB)分割成多个分片
  • 每个分片包含索引、起始位置和结束位置信息

2. 分片上传

  • 使用FormData对象封装分片数据

  • 支持并发上传(通过MAX_CONCURRENT_UPLOADS控制并发数)

  • 每个分片上传时携带文件哈希、分片索引等信息

3. 进度监控

  • 实时计算已上传数据量占总文件大小的比例

  • 通过进度条和百分比文字展示上传进度

4. 断点续传

  • 使用文件哈希标识唯一文件

  • 上传前检查服务器已存在的分片

  • 暂停后可从断点处继续上传

5. 分片合并

  • 所有分片上传完成后,向服务器发送合并请求

  • 服务器根据分片索引顺序合并文件

服务器端实现要点

虽然本文主要关注前端实现,但服务器端也需要相应支持:

  1. 分片上传接口:接收并存储分片文件
  2. 分片检查接口:返回已上传的分片列表
  3. 分片合并接口:将所有分片合并为完整文件

使用说明

  1. 选择或拖放一个大文件到上传区域
  2. 查看文件信息和分片数量
  3. 点击"开始上传"按钮开始上传
  4. 可随时暂停和继续上传
  5. 上传完成后会显示成功消息

这个实现提供了完整的大文件上传功能,包括分片、进度显示、暂停/继续等关键特性,可以直接用于实际项目或作为学习参考。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值