在JavaScript中重构解压后Zip文件的目录结构
如何从解压后的Tar.gz ArrayBuffer中重构文件结构并生成FileList?
你现在遇到的核心问题其实很明确:pako只是帮你完成了字节流的解压,但它完全不关心Tar归档的内部结构。Tar文件本身是有固定格式的,每个文件/目录都有标准化的头部和内容块,你需要先解析这个格式,才能拆分出单个文件的位置和元数据。下面我一步步帮你解决这两个问题:
问题1:确定单个文件的起止位置
Tar归档的结构非常规整,每个条目(不管是文件还是目录)都遵循这个规则:
- 每个条目以512字节的头部开头,里面包含文件名、大小、类型等元数据
- 头部之后是文件内容,内容长度会向上取整到512字节的倍数(不足的部分用空字节填充)
- 整个归档的结尾是两个连续的全0 512字节块
所以解析步骤是:
- 把pako返回的ArrayBuffer转换成
Uint8Array,方便逐字节读取 - 从偏移量0开始,每次读取512字节作为当前条目的头部
- 解析头部里的文件大小字段(八进制字符串,需要转十进制),计算出内容的结束位置
- 记录当前文件的起止范围,然后跳到下一个条目的头部(对齐到512字节倍数),直到遇到全0的结束块
问题2:判断文件的原始类型
这里分两种场景:
- 归档内的条目类型:从Tar头部的
typeFlag字段判断,比如'0'是普通文件、'5'是目录、'1'是硬链接。如果你不需要保留目录,可以直接跳过目录条目。 - 文件的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循环、按索引访问)
注意事项
- Tar格式变体:上面的解析函数处理的是标准Ustar格式的Tar归档,如果你遇到GNU Tar或Posix Tar的扩展特性(比如长文件名、扩展头部),可能需要调整解析逻辑,但大部分日常场景已经够用。
- Zip vs Tar.gz:你标题里提到了Zip,但代码是处理Tar.gz——如果是处理Zip文件,逻辑类似,但Zip的格式和Tar完全不同,需要解析Zip的中央目录结构,不过你的现有代码是针对Tar.gz的,所以这里聚焦Tar的解析。
- FileList模拟:原生FileList是只读的,上面的模拟方式让它拥有和原生一致的接口,如果你不需要严格的FileList类型,直接使用
files数组即可,它本身就是可迭代的。
内容的提问来源于stack exchange,提问作者Daniel Schreij
相关产品推荐
相关产品推荐

