用js手撸了一个zip saver

背景介绍

最近公司有个需求,要在浏览器端生成一大堆的 word 文件并保存到本地。

生成 word 文件直接用了docx这个库,嗖的一下很快就搞定了。但是交付给需求方的时候他们却说生成的文件乱糟糟的放在下载目录里面他们看着烦,而且还要手动整理每一批文件,问我能不能搞成一个压缩包。我一听这个要求,心想:不就是调的包的事吗,二话不说马上就答应了。

然而,,,,,

搜了很久,也没有找到直接马上就可以用的 js 库来将一大堆文件直接变成一个压缩包。又搜了一下 zip 的文件格式内容,发现好像不是很复杂。那就自己来搞一个包吧。

太长不看?直接用 zip-saver

一、zip 文件格式简介

zip 文件大致可以分成三个个部分:

  1. 文件部分
    • 文件部分包含了所有的文件内容,每个文件都有一个文件头 ,文件头包含了文件的元信息,比如文件名、文件大小、文件的压缩方式等等。
  2. 中央目录部分
    • 中央目录部分包含了所有文件的元信息,比如文件名、文件大小、文件的压缩方式等等。
  3. 目录结束标识 - 目录结束标识标识了中央目录部分的结束。包含了中央目录的开始位置、中央目录的大小等信息。 图片来自:en.wikipedia.org/wiki/ZIP_(f...

对于每一个文件,他在 zip 中包含三部分

  1. 本地文件头( Local File Header)-- 图片来自:goodapple.top/archives/70...
  1. 文件内容
  2. 数据描述符( Data descriptor)-- 图片来自:goodapple.top/archives/70...

数据描述符是可选的,当本地文件头中没有指明 CRC-32 校验码和压缩前后的长度时,才需要数据描述符

中央目录区的数据构成是这样的 -- 图片来自:goodapple.top/archives/70...

目录结束标识的数据构成是这样的 -- 图片来自:goodapple.top/archives/70...

二、代码实现

有了上面的信息之后,不难想到生成一个 zip 文件的步骤:

  1. 生成文件部分
    1. 构造固定的文件信息头
    2. 追加文件内容
    3. 计算文件的 CRC32 校验码
    4. 生成数据描述符
  2. 生成中央目录部分
    1. 构造固定的中央文件信息头
    2. 计算文件的偏移量
  3. 生成目录结束标识
    1. 构造固定的目录结束标识
    2. 计算中央目录的大小和偏移
1. 生成本地文件头(local file header)

根据local file header的结构,我们很容易得知:一个local file header 的大小是 30 + n + m 个字节

其中n是文件名的长度,m是扩展字段的长度,在这里我们不考虑扩展字段,那么最终大小就是30 + n

js中我可以直接用Uint8Array来存储一个字节 ,又因为 zip 是采用小端序,为了方便操作, 那么local file header变量就可以这样定义:

js 复制代码
const length = 30 + filenameLength
const localFileHeaderBytes = new Uint8Array(length)
// 使用DataView可以更方便的操作小端序数据
const localFileHeaderDataView = new DataView(localFileHeaderBytes.buffer)

定义完 local file header 变量后我们就可以往里面塞一些东西了

js 复制代码
// local file header 的起始固定值为 0x04034b50
// setUint第一个参数为偏移量,第二个参数是值,第三个参数为true表示以小端序存储
localFileHeaderDataView.setUint32(0, 0x04034b50, true)
// 设置最低要求的版本号为 0x14
localFileHeaderDataView.setUint16(4, 0x0014, true)
// 设置通用标志位为 0x0808
// 0x0808 使用UTF-8编码且文件头中不包含CRC32和文件大小信息
localFileHeaderDataView.setUint16(6, 0x0808, true)

// 设置压缩方式为 0x0000 表示不压缩
localFileHeaderDataView.setUint16(8, 0x0000, true)
// 设置最后修改时间, 这里假设最后修改时间为当前时间
const lastModified = new Date().getTime()
// last modified time
localFileHeader.setUint16(
  10,
  (date.getUTCHours() << 11) |
    (date.getUTCMinutes() << 5) |
    (date.getUTCSeconds() / 2)
)

// last modified date
localFileHeader.setUint16(
  12,
  date.getUTCDate() |
    ((date.getUTCMonth() + 1) << 5) |
    ((date.getUTCFullYear() - 1980) << 9)
)

// 设置文件名的长度,这里假设文件名已经转换成了字节数组nameBytes
localFileHeaderDataView.setUint16(26, nameBytes.length, true)

// 设置文件名
localFileHeaderBytes.set(nameBytes, 30)

到此,一个local file header就生成好了

2. 文件内容追加

文件内容追加这一步很简单,这里我们不考虑压缩文件,直接将文件转为Uint8Array 并计算文件的 CRC32 校验码,然后追加到local file header后面即可

js 复制代码
    const crc = new CRC32()
    // 获取file数据备用
    const fileBytes = await file.arrayBuffer()
    crc.append(fileBytes)
3. 数据描述符(Data descriptor)生成

数据描述符用来表示文件压缩与的结束,根据他的编码格式,他包含的信息只有四个:固定的标识符、CRC-32校验码,压缩前的大小,压缩后的大小,这里我们暂且不考虑数据的压缩, 要生成他也很简单:

js 复制代码
    const dataDescriptor = new Uint8Array(16)
    const dataDescriptorDataView = new DataView(dataDescriptor.buffer)
    // 0x08074b50 是数据描述符的固定标识字段
    dataDescriptorDataView.setUint32(0, 0x08074b50, true)
    // CRC-32校验码
    dataDescriptorDataView.setUint32(4, crc.value, true)
    // 压缩前的大小
    dataDescriptorDataView.setUint32(8, fileBytes.length, true)
    // 压缩后的大小
    dataDescriptorDataView.setUint32(12, fileBytes.length, true)

至此,一个文件在zip中所有的信息就已经都可以生成了,接下来就需要生成中央目录信息了

4. 中央目录区生成

根据上面的图,我们知道, 中央目录区也是由一个一个的文件头组成,每一个文件头对对应着一个真实文件的信息,每个文件信息大小是46 + n + m + k,其中n是文件名称的大小,m是扩展字段的大小,k是文件注释的大小。 在这里,我们可以暂时不必管扩展字段,先计算一下中央目录区的总大小:

js 复制代码
    // 假设有一个文件列表为flieList
    const wholeLength = flieList.reduce((acc, file) => {
      // 文件名长度
      const nameBufferLength = textEncoder.encode(file.name).length
      // 假设文件有注释字段comment
      const commentBufferLength = textEncoder.encode(file.comment).length
      // 累加起来
      return acc + 46 + nameBufferLength + commentBufferLength
    }, 0)

然后,创建一个变量存储中央目录区的数据

js 复制代码
const centraHeader = new Uint8Array(wholeLength)
const centraHeaderDataView = new DataView(dataDescriptor.buffer)

接下来就可以通过循环,将所有文件的信息都写入中央目录区

js 复制代码
    // 假设有这样一个数据结构存储了文件的信息
    type FileZipInfo = {
        localFileHeader: Uint8Array
        fileBytes: Uint8Array
        dataDescriptor: Uint8Array
        filename: string
        fileComment: string
    }
    
    // offset表示中央目录信息中,当前文件相对于中央目录起始位置的偏移
    // entryOffset 表示一个文件的信息(本地文件头+文件数据+数据描述符)相对于整个zip文件起始位置的偏移
    let entryOffset = 0
    
    for (
      let i = 0, offset = 0;
      i < fileZipInfoList.length;
      i++
    ) {
        const fileZipInfo = fileZipInfoList[i]
        // 设置固定标识符号
        centraHeaderDataView.setUint32(offset, 0x02014b50, true)
        // 设置压缩版本号
        centraHeaderDataView.setUint16(offset + 4, 0x0014, true)
        // 因为中央目录信息中的文件数据一大部份都是本地文件头数据的冗余,所以可以直接复制过来使用
        centraHeader.set(fileZipInfo.localFileHeader.slice(4, 30), offset + 6)
        
        const textEncoder = new TextEncoder()
        // 注释长度
        const commentBuffer = textEncoder.encode(fileZipInfo.fileComment)
        centraHeaderDataView.setUint16(offset + 32, commentBuffer.length, true)
        
        // 对应的本地文件头在整个zip文件中的偏移
        centraHeaderDataView.setUint32(offset + 42, entryOffset, true)
        
        // 文件名
        const filenameBuffer = textEncoder.encode(fileZipInfo.filename)
        centraHeaderDataView.setUint16(filenameBuffer, offset + 46)
        
        // 扩展字段暂时不管,下一个直接设置文件注释
        bufferDataView.set(commentBuffer, offset + 46 + filenameBuffer.length)
        
        // 更新offset的值
        // 下一个中央目录中的文件的offset的值为此次生成的文件信息大小 + 当前的offset
        // 也就是
        offset = offset + 46 + commentBuffer.length + filenameBuffer.length
        
        // entryOffset 的值累加为当前文件信息在整个zip文件中的大小 + 当前的 entryOffset
        entryOffset += fileZipInfo.localFileHeader.length + fileZipInfo.fileBytes.length + fileZipInfo.dataDescriptor.length
        
        
    }
    

最后,再生成 目录结束标识

js 复制代码
    // 目录结束标识的大小为22 + 注释信息(注释信息先忽略)
    const eocdBytes = new Uint8Array(22)
    const eocdDataView = new DataView(eocd.buffer)
    
    // 固定标识值
    eocdDataView.setUint32(eocdOffset, 0x06054b50, true)
    
    // 和分卷有关的数据都可以忽略,他主要是为了处理一个zip文件跨磁盘存储的问题,现在基本没有这种场景
    // 当前分卷号
    eocdDataView.setUint16(4, 0, true)
    // 中央目录开始分卷号
    eocdDataView.setUint16(6, 0, true)
    // 当前分卷的总文件数量
    eocdDataView.setUint16(8, fileZipInfoList.length, true)
    // 总文件数量
    eocdDataView.setUint16(10, fileZipInfoList.length, true)
    // 中央目录的总大小
    eocdDataView.setUint32(12, wholeLength, true)
    // 中央目录在整个zip文件中的目录偏移
    eocdDataView.setUint32(16, entryOffset, true)
    // 最后是注释的信息,先忽略
    
5. 拼接完整数据

完成了上面所有的步骤之后,我们只需要把数据都拼接起来就可以了

js 复制代码
// 所有文件数据都存储在 fileZipInfoList中

// 组合文件数据
const fileBytesList = fileZipInfoList.map(fileZipInfo => {
    return new Uint8Array([
        ...fileZipInfo.localFileHeader,
        ...fileZipInfo.fileBytes,
        ...fileZipInfo.dataDescriptor
    ])
})

const zipBlob = new Blob([
    ...fileBytesList,
    centraHeader,
    eocdBytes
],{
    type: 'application/zip'
})

ok,搞定!

6. 完整的实现

github.com/EatherToo/z...

三、总结

经过上面的步骤,我们就可以生成一个zip文件了,当然,这里只是一个简单的实现,zip文件格式还有很多细节,比如压缩算法、加密压缩等等,这里都没有涉及到,后面有时间再来完善吧。

参考资料:

相关推荐
I_Am_Me_24 分钟前
【JavaEE进阶】 JavaScript
开发语言·javascript·ecmascript
℘团子এ35 分钟前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z40 分钟前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
前端百草阁1 小时前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜1 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
Backstroke fish1 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple1 小时前
typescript里面正则的使用
开发语言·javascript·正则表达式
小五Five1 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript
临枫5411 小时前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript
酷酷的威朗普1 小时前
医院绩效考核系统
javascript·css·vue.js·typescript·node.js·echarts·html5