You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在客户端用纯JavaScript将Blob转换为正确格式的Zip文件(无库)

问题原因

你当前的代码只是把多个File对象直接拼接成Blob并标记为application/zip类型,但这完全不符合Zip文件的规范结构——Zip文件需要包含本地文件头、文件内容、中央目录条目、中央目录结束记录等特定格式的字节数据,单纯拼接文件内容系统根本无法识别为有效Zip包。

无第三方库的解决方案

要在客户端不依赖任何库实现正确的Zip打包,需要手动构建符合Zip规范的字节流。下面是一个无压缩版本的实现(压缩需要实现DEFLATE算法,逻辑复杂,无压缩版本足够满足基础需求):

核心逻辑说明

Zip文件的简化结构如下:

  1. 每个文件对应一段「本地文件头 + 文件原始内容」
  2. 所有文件处理完后,追加所有文件的「中央目录条目」
  3. 最后追加「中央目录结束记录」

我们需要将这些结构转换为字节数组,最终合并成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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 02:03:11