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

在JavaScript中重构解压后Zip文件的目录结构

如何从解压后的Tar.gz ArrayBuffer中重构文件结构并生成FileList?

你现在遇到的核心问题其实很明确:pako只是帮你完成了字节流的解压,但它完全不关心Tar归档的内部结构。Tar文件本身是有固定格式的,每个文件/目录都有标准化的头部和内容块,你需要先解析这个格式,才能拆分出单个文件的位置和元数据。下面我一步步帮你解决这两个问题:


问题1:确定单个文件的起止位置

Tar归档的结构非常规整,每个条目(不管是文件还是目录)都遵循这个规则:

  • 每个条目以512字节的头部开头,里面包含文件名、大小、类型等元数据
  • 头部之后是文件内容,内容长度会向上取整到512字节的倍数(不足的部分用空字节填充)
  • 整个归档的结尾是两个连续的全0 512字节块

所以解析步骤是:

  1. 把pako返回的ArrayBuffer转换成Uint8Array,方便逐字节读取
  2. 从偏移量0开始,每次读取512字节作为当前条目的头部
  3. 解析头部里的文件大小字段(八进制字符串,需要转十进制),计算出内容的结束位置
  4. 记录当前文件的起止范围,然后跳到下一个条目的头部(对齐到512字节倍数),直到遇到全0的结束块

问题2:判断文件的原始类型

这里分两种场景:

  1. 归档内的条目类型:从Tar头部的typeFlag字段判断,比如'0'是普通文件、'5'是目录、'1'是硬链接。如果你不需要保留目录,可以直接跳过目录条目。
  2. 文件的MIME类型:这才是你创建File对象时需要的type参数,有两种可靠方式:
    • 根据文件名后缀推断(比如.png对应image/png),适合大部分常见文件
    • 读取文件内容的前几个字节(也就是「魔数」/文件签名),这是最准确的方式(比如PNG的魔数是89 50 4E 47 0D 0A 1A 0A)

完整实现代码

结合你现有的解压代码,我写了一个Tar解析函数,直接可以生成File数组和模拟的FileList(和网页上传的FileList行为一致):

import pako from 'pako'; 
import isFunction from 'lodash/isFunction'

// 你的FileStreamer类和decompress函数保持不变
class FileStreamer {
 constructor(file, chunkSize = 64 * 1024) {
 this.file = file;
 this.offset = 0;
 this.chunkSize = chunkSize; // bytes
 this.rewind();
 }
 rewind() {
 this.offset = 0;
 }
 isEndOfFile() {
 return this.offset >= this.getFileSize();
 }
 readBlock() {
 const fileReader = new FileReader();
 const blob = this.file.slice(this.offset, this.offset + this.chunkSize);
 return new Promise((resolve, reject) => {
 fileReader.onloadend = (event) => {
 const target = (event.target);
 if (target.error) {
 return reject(target.error);
 }
 this.offset += target.result.byteLength;
 resolve({ data: target.result, progress: Math.min(this.offset / this.file.size, 1) });
 };
 fileReader.readAsArrayBuffer(blob);
 });
 }
 getFileSize() {
 return this.file.size;
 }
}

export async function decompress(zipfile, onProgress) {
 const fs = new FileStreamer(zipfile);
 const inflator = new pako.Inflate();
 let block;
 while (!fs.isEndOfFile()) {
 block = await fs.readBlock();
 inflator.push(block.data, fs.isEndOfFile());
 if (inflator.err) {
 throw inflator.err
 }
 if (isFunction(onProgress)) onProgress(block.progress)
 }
 return inflator.result;
}

// 新增:Tar归档解析函数
function parseTar(buffer) {
  const uint8 = new Uint8Array(buffer);
  const fileEntries = [];
  let currentOffset = 0;
  const entryBlockSize = 512;

  while (currentOffset < uint8.length) {
    // 读取当前条目头部
    const headerBlock = uint8.slice(currentOffset, currentOffset + entryBlockSize);
    
    // 检查是否是归档结束标记(全0块)
    const isArchiveEnd = headerBlock.every(byte => byte === 0);
    if (isArchiveEnd) break;

    // 解析头部元数据
    // 文件名:0-99字节,ASCII编码,去除末尾空字符
    const fileName = new TextDecoder('ascii').decode(headerBlock.slice(0, 100)).trimEnd();
    // 文件大小:124-135字节,八进制字符串转十进制
    const fileSizeStr = new TextDecoder('ascii').decode(headerBlock.slice(124, 136)).trimEnd();
    const fileSize = parseInt(fileSizeStr, 8);
    // 条目类型:156字节
    const entryType = new TextDecoder('ascii').decode(headerBlock.slice(156, 157));

    // 跳过目录条目(不需要保留目录结构)
    if (entryType === '5') {
      currentOffset += entryBlockSize;
      continue;
    }

    // 计算文件内容的起止位置
    const contentStart = currentOffset + entryBlockSize;
    const contentEnd = contentStart + fileSize;
    // 下一个条目的偏移量需要对齐到512字节倍数
    const nextEntryOffset = Math.ceil(contentEnd / entryBlockSize) * entryBlockSize;

    // 提取文件内容的ArrayBuffer片段
    const fileContentBuffer = buffer.slice(contentStart, contentEnd);

    // 推断MIME类型
    let mimeType = '';
    // 方式1:根据后缀推断
    const fileExt = fileName.split('.').pop().toLowerCase();
    const mimeMapping = {
      txt: 'text/plain',
      png: 'image/png',
      jpg: 'image/jpeg',
      jpeg: 'image/jpeg',
      json: 'application/json',
      js: 'application/javascript',
      css: 'text/css',
      // 可以根据需求添加更多后缀映射
    };
    mimeType = mimeMapping[fileExt] || '';

    // 方式2:用文件魔数校验(更准确)
    if (!mimeType && fileSize >= 8) {
      const magicBytes = uint8.slice(contentStart, contentStart + 8);
      const magicHex = Array.from(magicBytes)
        .map(byte => byte.toString(16).padStart(2, '0'))
        .join('');
      
      if (magicHex === '89504e470d0a1a0a') mimeType = 'image/png';
      else if (magicHex.startsWith('ffd8')) mimeType = 'image/jpeg';
      else if (magicHex.startsWith('47494638')) mimeType = 'image/gif';
      else if (magicHex.startsWith('25504446')) mimeType = 'application/pdf';
    }

    // 创建File对象
    const file = new File([fileContentBuffer], fileName, { type: mimeType });
    fileEntries.push(file);

    // 跳到下一个条目
    currentOffset = nextEntryOffset;
  }

  // 模拟原生FileList(可选,数组本身已经是可迭代对象)
  const simulatedFileList = Object.create(FileList.prototype);
  Object.defineProperty(simulatedFileList, 'length', { value: fileEntries.length });
  fileEntries.forEach((file, index) => {
    Object.defineProperty(simulatedFileList, index, { value: file });
  });

  return {
    files: fileEntries, // 可迭代数组
    fileList: simulatedFileList // 模拟的FileList对象
  };
}

// 使用示例
// const tarGzFile = document.getElementById('upload').files[0];
// const decompressedBuffer = await decompress(tarGzFile, progress => console.log(`解压进度:${progress*100}%`));
// const { files, fileList } = parseTar(decompressedBuffer);
// 现在files是File数组,fileList可以像原生FileList一样使用(比如for...of循环、按索引访问)

注意事项

  1. Tar格式变体:上面的解析函数处理的是标准Ustar格式的Tar归档,如果你遇到GNU Tar或Posix Tar的扩展特性(比如长文件名、扩展头部),可能需要调整解析逻辑,但大部分日常场景已经够用。
  2. Zip vs Tar.gz:你标题里提到了Zip,但代码是处理Tar.gz——如果是处理Zip文件,逻辑类似,但Zip的格式和Tar完全不同,需要解析Zip的中央目录结构,不过你的现有代码是针对Tar.gz的,所以这里聚焦Tar的解析。
  3. FileList模拟:原生FileList是只读的,上面的模拟方式让它拥有和原生一致的接口,如果你不需要严格的FileList类型,直接使用files数组即可,它本身就是可迭代的。

内容的提问来源于stack exchange,提问作者Daniel Schreij

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.05.29 08:21:29