如何在客户端用纯JavaScript将Blob转换为正确格式的Zip文件(无库)
问题原因
你当前的代码只是把多个File对象直接拼接成Blob并标记为application/zip类型,但这完全不符合Zip文件的规范结构——Zip文件需要包含本地文件头、文件内容、中央目录条目、中央目录结束记录等特定格式的字节数据,单纯拼接文件内容系统根本无法识别为有效Zip包。
无第三方库的解决方案
要在客户端不依赖任何库实现正确的Zip打包,需要手动构建符合Zip规范的字节流。下面是一个无压缩版本的实现(压缩需要实现DEFLATE算法,逻辑复杂,无压缩版本足够满足基础需求):
核心逻辑说明
Zip文件的简化结构如下:
- 每个文件对应一段「本地文件头 + 文件原始内容」
- 所有文件处理完后,追加所有文件的「中央目录条目」
- 最后追加「中央目录结束记录」
我们需要将这些结构转换为字节数组,最终合并成Blob进行下载。
完整代码
<input type="file" onchange="makeArchive(this.files)" multiple>
function makeArchive(files) { // 辅助函数:字符串转UTF-8字节数组 function strToUtf8Bytes(str) { return new TextEncoder().encode(str); } // 辅助函数:数字转小端序字节数组(指定字节长度) function numToLittleEndian(num, byteLength) { const arr = new Uint8Array(byteLength); for (let i = 0; i < byteLength; i++) { arr[i] = num & 0xff; num >>= 8; } return arr; } // 构建本地文件头 function buildLocalFileHeader(fileName, fileSize, lastModifiedTime) { const header = new Uint8Array(30 + fileName.length); // 本地文件头签名:0x04034b50 header.set(numToLittleEndian(0x04034b50, 4), 0); // 版本(20=支持UTF-8文件名) header.set(numToLittleEndian(20, 2), 4); // 通用标记(0x0800=UTF-8文件名) header.set(numToLittleEndian(0x0800, 2), 6); // 压缩方法(0=无压缩) header.set(numToLittleEndian(0, 2), 8); // 最后修改时间/日期(简化处理,用文件的修改时间) const date = new Date(lastModifiedTime); const timeBytes = ((date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1)) & 0xffff; const dateBytes = (((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()) & 0xffff; header.set(numToLittleEndian(timeBytes, 2), 10); header.set(numToLittleEndian(dateBytes, 2), 12); // CRC32(先填0,简化处理跳过校验) header.set(numToLittleEndian(0, 4), 14); // 压缩后大小(无压缩等于原大小) header.set(numToLittleEndian(fileSize, 4), 18); // 未压缩大小 header.set(numToLittleEndian(fileSize, 4), 22); // 文件名长度 header.set(numToLittleEndian(fileName.length, 2), 26); // 额外字段长度(0) header.set(numToLittleEndian(0, 2), 28); // 文件名 header.set(strToUtf8Bytes(fileName), 30); return header; } // 构建中央目录条目 function buildCentralDirectoryEntry(fileName, fileSize, localHeaderOffset, lastModifiedTime) { const entry = new Uint8Array(46 + fileName.length); // 中央目录条目签名:0x02014b50 entry.set(numToLittleEndian(0x02014b50, 4), 0); // 版本(20) entry.set(numToLittleEndian(20, 2), 4); // 版本所需(20) entry.set(numToLittleEndian(20, 2), 6); // 通用标记(0x0800) entry.set(numToLittleEndian(0x0800, 2), 8); // 压缩方法(0) entry.set(numToLittleEndian(0, 2), 10); // 最后修改时间/日期 const date = new Date(lastModifiedTime); const timeBytes = ((date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1)) & 0xffff; const dateBytes = (((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()) & 0xffff; entry.set(numToLittleEndian(timeBytes, 2), 12); entry.set(numToLittleEndian(dateBytes, 2), 14); // CRC32(0) entry.set(numToLittleEndian(0, 4), 16); // 压缩后大小 entry.set(numToLittleEndian(fileSize, 4), 20); // 未压缩大小 entry.set(numToLittleEndian(fileSize, 4), 24); // 文件名长度 entry.set(numToLittleEndian(fileName.length, 2), 28); // 额外字段长度(0) entry.set(numToLittleEndian(0, 2), 30); // 文件注释长度(0) entry.set(numToLittleEndian(0, 2), 32); // 磁盘号(0) entry.set(numToLittleEndian(0, 2), 34); // 内部文件属性(0) entry.set(numToLittleEndian(0, 2), 36); // 外部文件属性(0x8100=普通文件) entry.set(numToLittleEndian(0x8100, 4), 38); // 本地文件头偏移量 entry.set(numToLittleEndian(localHeaderOffset, 4), 42); // 文件名 entry.set(strToUtf8Bytes(fileName), 46); return entry; } // 构建中央目录结束记录 function buildEndOfCentralDirectory(numEntries, centralDirSize, centralDirOffset) { const record = new Uint8Array(22); // 结束记录签名:0x06054b50 record.set(numToLittleEndian(0x06054b50, 4), 0); // 磁盘号(0) record.set(numToLittleEndian(0, 2), 4); // 中央目录起始磁盘号(0) record.set(numToLittleEndian(0, 2), 6); // 本磁盘的条目数 record.set(numToLittleEndian(numEntries, 2), 8); // 总条目数 record.set(numToLittleEndian(numEntries, 2), 10); // 中央目录大小 record.set(numToLittleEndian(centralDirSize, 4), 12); // 中央目录起始偏移量 record.set(numToLittleEndian(centralDirOffset, 4), 16); // 注释长度(0) record.set(numToLittleEndian(0, 2), 20); return record; } // 处理所有文件,收集字节数据 const zipParts = []; const centralDirEntries = []; let currentOffset = 0; // 先处理每个文件的本地头和内容 const filePromises = Array.from(files).map(async file => { const fileBuffer = await file.arrayBuffer(); const fileName = file.name; const fileSize = fileBuffer.byteLength; const lastModified = file.lastModified; // 本地文件头 const localHeader = buildLocalFileHeader(fileName, fileSize, lastModified); zipParts.push(localHeader); currentOffset += localHeader.byteLength; // 文件内容 zipParts.push(new Uint8Array(fileBuffer)); currentOffset += fileSize; // 中央目录条目 const centralEntry = buildCentralDirectoryEntry(fileName, fileSize, currentOffset - fileSize - localHeader.byteLength, lastModified); centralDirEntries.push(centralEntry); }); // 等待所有文件处理完成 Promise.all(filePromises).then(() => { // 追加中央目录条目 const centralDirSize = centralDirEntries.reduce((sum, entry) => sum + entry.byteLength, 0); const centralDirOffset = zipParts.reduce((sum, part) => sum + part.byteLength, 0); zipParts.push(...centralDirEntries); // 追加结束记录 const endRecord = buildEndOfCentralDirectory(files.length, centralDirSize, centralDirOffset); zipParts.push(endRecord); // 合并所有字节为Blob const zipBlob = new Blob(zipParts, { type: 'application/zip' }); // 下载并释放URL对象 const a = document.createElement('a'); a.href = URL.createObjectURL(zipBlob); a.download = 'some.zip'; a.click(); URL.revokeObjectURL(a.href); }); }
注意事项
- 这个实现是无压缩的,文件大小会和原文件总和一致,适合小文件场景;如果需要压缩,需手动实现DEFLATE算法,逻辑会复杂很多。
- 代码中跳过了CRC32校验,若需要校验完整性,可添加CRC32计算逻辑。
- 只支持普通文件,不支持文件夹嵌套,若需支持文件夹,需额外处理目录条目。
内容的提问来源于stack exchange,提问作者kawopaj537
相关产品推荐
相关产品推荐

