前端如何实现压缩包解析?

378 阅读9分钟

1. 需求及实现

实现压缩包内文件的读取,按照压缩包内的文件结构显示到页面上,并可以点击预览文件内容,效果截图如下所示(关于前端预览各种文件的处理方式,转到这篇前端实现在线文件预览,本期主要介绍压缩包的解析及目录结构的呈现)

1.jpg

首先说下大概的实现流程

  1. 当我们进入页面时会从后端会返回我们压缩包的url(当然也可以手动上传)

  2. 拿到文件的地址后,我们使用axios去请求文件地址得到原始原始数据blob

  3. 使用第三方库加载压缩包中的文件

  4. 对压缩包中的文件进行递归处理,将文件结构处理成树的结构

  5. 借助 Ant Design Vue的数组件a-tree呈现数据,大致结构如下,这样只看结构就和我们的结果很像了

    const fileList = [
      {
        label: '冒泡排序.zip',
        key: '1',
        children: [
          {
            label: '资源',
            key: '2',
            children: [
              ...
            ],
          },
          {
            label: 'index.js',
            key: '2',
          },
        ],
      },
    ];
    

    如何选择压缩包解析库?

    那么再看第3步,我们需要一个第三方库来读取压缩包的文件,关于前端压缩包解析的库有jszip ,rarjs ,这两者如同他的名字一样,只能解析对应后缀的压缩包,我们需要解析各种后缀的压缩包,这里我们使用另一款库 libarchive.js - npm,下面是作者给他的介绍。

    Libarchivejs是一个用于浏览器和nodejs的归档工具,它可以提取和创建各种类型的压缩,它是Libarchivejs对WebAssembly和javascript包装器的移植,使其更易于使用。由于它在WebAssembly上运行,性能应该接近本机。支持格式:ZIP, 7-Zip, RAR v4, RAR v5, TAR .等,支持压缩:GZIP, DEFLATE, BZIP2, LZMA .等

使用起来很简单,我们只需要npm i libarchive.js,即可完成依赖的安装,然后调用对应的方法即可完成对压缩包内文件的读取,基本示例可以参照作者的示例代码。

接下来,我们就要进入编码阶段了,我们先来实现下文件内容的读取(注意:文件的读取是异步的,为了做出来加载的效果,我们需要在读取前和读取后来改变icon的样式)

import { Archive } from 'libarchive.js/dist/libarchive';

Archive.init({
    workerUrl: 'libarchive.js/dist/worker-bundle.js'
});

let fileList = ref([]);
// 模拟后端返回的文件列表
let responseData = ref([])
// 需要解析的压缩包后缀名
const fileZipType = ['zip', 'rar', '7z'];

responseData.value.map((item, index) => {
  let fileType = item.fileName.split('.').pop()
  // 如果文件是压缩包
  if (fileZipType.includes(fileType)) {
    // 异步请求文件内容
    axios({ url: item.annexUrl, method: 'get', responseType: 'blob' }).then(async (res) => {
      let record = fileList.value[index]
      record.children = []
      // 标记当前文件是目录
      record.isDir = true
      // 创建实例读取压缩包内容
      let archive = await Archive.open(res.data)
      // 异步加载压缩包内的文件
      archive.extractFiles().then(data => {
        console.log('data:{}', data);
        // 文件读取结束后,我们将icon标记为压缩包的icon
        record.icon = 'fileZipType'
        // 递归生成当前压缩包树结构
        generateMenu(data, record)
      })
    })
  }
  return {
    key: index + 1,
    label: item.fileName,
    url: item.annexUrl,
    size: item.fileSize,
    title: item.fileName,
    fileType,
    // 文件如果是压缩包,我们将树icon的状态设为加载中
    icon: fileZipType.includes(fileType) ? 'LoadingOutlined' : getFileIcon(fileType),
  }
})

//TODO: 生成压缩包树结构菜单
const generateMenu = (fileData, record) => {}

代码运行后我们就能得到控制台的打印:

2.jpg

可以看到,压缩包内的文件我们就读取出来了,不过结构是一个扁平状态的对象,接下来我们要做的就是对这个对象进行循环递归,致使他能够形成我们想要的树结构,那么就又有一个问题来了,我们如何确定一个对象他说文件夹还是压缩包呢?去判断他的键值中有没有.不就行了嘛,index.js就是文件,images.就是文件夹,解决。后来发现,这种做法是错误的,文件夹其实也是可以包含.的。后来仔细观察我们读出来的对象可以发现,文件的原型指向File,而文件夹的原型就指向Objet,那么就简单了,我们只需要判断变量类型就能区分了,generateMenu方法的代码如下:

const generateMenu = (fileData, record) => {
  // 临时数组直接指向children
  let arr = record.children
  // 全部目录
  let allFolders: any = []
  // 对传入的文件进行遍历(key为文件名称)
  for (let key in fileData) {
    let item: any = fileData[key]
    // 文件处理
    if (Object.prototype.toString.call(item) == '[object File]') {
      let fileType = key.split('.').pop()
      arr.push({ key: Math.random(), label: key, title: key, isDir: false, icon: getFileIcon(fileType), file: item, fileType, zipFile: true, url: record.url, zipUrl: record.url  })
    }
    // 文件夹处理 
    else {
      allFolders.push({ name: key, fileData: item, zipFile: true })
    }
  }
  // 如果目录下存在文件夹,递归为文件夹下继续插入文件
  if (allFolders.length > 0) {
    allFolders.forEach((item) => {
      let row = {
        key: Math.random(),
        label: item.name,
        title: item.name,
        isDir: true,
        icon: 'FolderOutlined',
        url: record.url,
        fileData: item.fileData,
        children: [],
      }
      arr.push(row)
      generateMenu(row.fileData, row)
    })
  }
}

// 判断附件图标 (fileZipType,pictureType等是保存文件后缀的数组)
const getFileIcon = (fileType) => {
  if (fileZipType.some(item => item == fileType)) {
    return 'fileZipType';
  }
  else if (pictureType.some(item => item == fileType)) {
    return 'pictureType';
  }
  else if (wordType.some(item => item == fileType)) {
    return 'wordType';
  }
  else if (fileType == 'pdf') {
    return 'pdfType';
  }
  else if (excelType.some(item => item == fileType)) {
    return 'excelType'
  }
  else if (fileType == 'mp4') {
    return 'mp4Type'
  }
  else if (pptType.includes(fileType)) {
    return 'pptType'
  }
  else {
    return 'FileOutlined'
  }
}

2. 优化

文件数量过多和层级过深,导致页面渲染时崩溃?

做完这些我们的基本功能就能得到保障了,但是真的投入测试,问题就出现了,原因是由于用户上传压缩包过大,里面的文件太多,层级太深,就导致当浏览器进行递归生成目录完成后进行渲染时,页面崩了...(还记的上传的是一个项目压缩包,不但上传了node_module文件夹,而且还是node_module套node_module套文件夹套node_module)。

第一时间想到的是限制压缩包上传大小再小点,但是仔细想想,这是一种方式,但是挺影响用户体验的,我上传了一个不到10M的文件你就不给我上传了。。。于是pass了这个想法。最后就面向问题解决问题,直接定义一个需要排除生成子结构的文件夹列表,遍历时匹配文件夹名称,如果包含在这个名称的文件夹我们就把他标记为文件,并跳出本次循环(现在回头看有点掩耳盗铃的感觉)。

// 排除一些文件夹生成目录
const excludeDir = ['node_modules']

  // 对传入的文件进行遍历(key为文件名称)
  for (let key in fileData) {
    let item: any = fileData[key]
    // 需要排除的文件夹
    if (excludeDir.includes(key) && Object.prototype.toString.call(item) == '[object Object]') {
      arr.push({ key: Math.random(), label: key, title: key, zipFile: true, icon: 'FolderOutlined' })
      continue;
    }
    ....
  }

修改后页面不崩了,但是前端项目的依赖文件叫node_module,还有其它项目我又不知道叫什么,那岂不是发现一次就要改一次代码?更何况如果用户就想预览这些依赖文件呢?于是最终这种偷懒的方案也废弃了。

最终选择的方案是,当我们读取完压缩包内的数据并生成树结构后,拷贝一份,第一次之渲染树结构的第一层,当用户点击文件夹的时候我们再为节点的children属性插入数据,于是我们读取完压缩包文件后的代码就变成了这样。

// 异步请求文件内容
axios({ url: ip + item.annexUrl, method: 'get', responseType: 'blob' }).then(async (res) => {
  let record = fileList.value[index]
  record.children = []
  record.isDir = true
  record.url = ip + item.annexUrl
  // 创建实例读取压缩包内容
  let archive = await Archive.open(res.data)
  // 加载压缩包内的文件
  archive.extractFiles().then(data => {
    record.key = index + 1
    record.icon = 'fileZipType'
    // 拷贝一份完整数据,方便后续操作
    record.copyData = generateMenu(data, JSON.parse(JSON.stringify(record))) ?? {}
    // 生成占位的children,避免数据一次全部渲染
    record.children = [{}]
  })
})

最后我们监听树节点的点击,当我们点击文件夹的时候,为节点的children插入子节点并展开当前树节点即可,涉及到的代码如下:

<a-tree :tree-data="fileList" :expandedKeys="expandedKeys" :selectedKeys="currentFileKey">
<template #title="row">
  <div @click="clickHandle(row)"> {{ row.title }}</div>
</template>
</a-tree>
// 点击树判断是否展开,及针对压缩包是否需要加载子文件
const clickHandle = (record: any) => {
  // 点击的是否是文件夹
  if (record.isDir == true) {
    // 如果目录下没有文件,则加载目录下的文件
    if (record.children.filter(item => Object.keys(item).length > 0).length == 0) {
      // 找到需要修改的压缩包
      let zipKey = fileList.value.find(item => item.url == record.url).key
      let node = getNode(fileList.value, zipKey)
      // 如果点击的是压缩包
      if (record.key == zipKey) {
        node.children = node.copyData.map(item => {
          return {
            ...item,
            children: item.isDir ? [{}] : undefined
          }
        })
      } else {
        // 找到需要添加子节点的节点
        getNode(fileList.value, record.key).children = loadChildren(record.key, node.copyData)
      }
    }
    // 判断节点是展开还是收起  
    if (expandedKeys.value.length == 0) {
      expandedKeys.value = [record['key']]
    } else {
      let res = expandedKeys.value.some(item => item == record['key'])
      if (!res) {
        expandedKeys.value.push(record['key'])
      } else {
        expandedKeys.value.splice(expandedKeys.value.indexOf(record['key']), 1)
      }
    }
    autoExpandParent.value = false;
  }
}

// 查询某个节点的信息
const getNode = (arr, key) => {
  for (let i = 0; i < arr.length; i++) {
    let item = arr[i]
    if (item.key == key) return item
    if (item.children && item.children.length > 0) {
      let result = getNode(item.children, key)
      if (result) return result
    }
  }
}

// 递归通过key获取子节点
const loadChildren = (key, data) => {
  for (let i = 0; i < data.length; i++) {
    if (data[i].key == key) {
      return data[i].children.map(item => {
        return {
          ...item,
          // 如果是文件夹的话生成占位符,是文件的话children属性为未定义
          children: item.isDir ? [{}] : undefined
        }
      })
    } else if (data[i].children && data[i].children.length > 0) {
      let res = loadChildren(key, data[i].children)
      if (res) return res
    }
  }
}

如果节点全部被展开造成卡顿怎么办?

这些做完之后,我们的程序就能当点击的时候去动态的加载子节点了,但是问题又来了,虽然这种方式防止了一次渲染很多数据造成页面崩溃的问题,但是如果用户把每个文件夹都点开的话,浏览器的压力还是很大的,最终还是应为渲染的内容过多导致页面操作卡顿,要解决这个问题,我们就要用到虚拟滚动了,通过监听滚动,计算滚动距离去切割数据,只渲染可视区域内的节点,好在Ant Design Vue的数组件是默认开启虚拟滚动的,所以这点就无需我们来实现了。

文件数量过多和层级过,递归计算会不会很耗时?

既然渲染的问题我们已经解决了,那么我们如果面对一个压缩包内的文件很多,层级很深,那么他在递归生成树结构数据的时候会不会很耗时呢?这里我测试了下,这个文件夹下包括目录文件夹共有5000个,这里速度还是比较快的只用了7ms,如果这部分过于耗时就要考虑使用web Worker进行优化了使用 Web Worker - Web API | MDN

1731308563825.jpg

个人觉得性能相关的问题不用刻意去优化,导致代码的可读性降低,当真的需要优化的时候我们再去做也不迟。