1. 需求及实现
实现压缩包内文件的读取,按照压缩包内的文件结构显示到页面上,并可以点击预览文件内容,效果截图如下所示(关于前端预览各种文件的处理方式,转到这篇前端实现在线文件预览,本期主要介绍压缩包的解析及目录结构的呈现)
首先说下大概的实现流程:
-
当我们进入页面时会从后端会返回我们压缩包的url(当然也可以手动上传)
-
拿到文件的地址后,我们使用axios去请求文件地址得到原始原始数据blob
-
使用第三方库加载压缩包中的文件
-
对压缩包中的文件进行递归处理,将文件结构处理成树的结构
-
借助 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) => {}
代码运行后我们就能得到控制台的打印:
可以看到,压缩包内的文件我们就读取出来了,不过结构是一个扁平状态的对象,接下来我们要做的就是对这个对象进行循环递归,致使他能够形成我们想要的树结构,那么就又有一个问题来了,我们如何确定一个对象他说文件夹还是压缩包呢?去判断他的键值中有没有.
不就行了嘛,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
个人觉得性能相关的问题不用刻意去优化,导致代码的可读性降低,当真的需要优化的时候我们再去做也不迟。