年前正准备休假。同事火急火燎的让我帮个忙,我还没反应过来,她已经给我发来了20个Excel文件,打看一看,这是一份统计数据,几张表的数据总量在5万内。同事告诉我,做如下的操作:
- 将20张表的数据,汇总到一个表格中。
- 为每个表格增加一列,这一列标注来源。
- 替换表格内的表头数据,该表头数据根据用户指定的含义设置。
我看了看这20张表,再看看同事紧急的样子,没想太多就搞起来。挨个打开文件,复制粘贴,增加列,修改表头;有的数据列由于信息缺失,在粘贴的时候竟然粘错了位置,于是撤销重来。如此操作,中午的一个半小时就过去了。下午我好奇的问同事,这个工作你做了多久,她回答几乎每月会统计好几次。我说每次都是这样操作吗?她说是的,繁琐的操作让她眼花缭乱,手工操作下来,有时还会丢失数据,她被这种统计工作,折腾的精疲力竭。当即,我就在想能不能做个小的工具来解决这个问题呢?于是我立即罗列了一下涉及到的问题。
- 多个文件打开、复制。 -> 读取excel文件。
- 对文件增加列 -> 读取文件前的配置操作。
- 表头设置 -> 读取文件后,生成文件前的标题配置。
- 粘贴、汇总到一张表内 -> 合并数据,并生成excel文件。
整理出来后,其实就是个简单的excel数据读取、合并、生成过程。复工后,用空闲时间把这个小工具搞了下,同事在年后的统计工作中,正式使用了这个小工具,表示很好用,因为将之前的一两小时时间缩短到了一两分钟。那来看下目前的实现效果吧。
项目实现,基于VUE3框架,插件主要使用了xlsx,VUE3创建项目过程不再赘述,根据官网即可完成,下面主要讲一下文件处理方面的。
一、读取文件
读取文件过程,主要涉及三方面内容,文件选择容器、文件读取插件、文件虚拟滚动。
1.1 文件选择容器
面向用户的操作中,需要给用户提供操作入口,HTML中的input type="file">
元素允许用户从当前设备中选择文件。在聊聊web中关于文件的使用,及大文件分片上传的实践一文中对该元素做了详细说明。这里我直接使用Element-Plus的Upload组件给用户提供选择文件的入口。注:该组件本质也是对input type="file">
做了封装。
html
<el-upload
class="upload-wrapper"
ref="upload"
action
multiple
accept=".xlsx,.xls"
:auto-upload="false"
:on-change="handleFiles"
:show-file-list="false"
>
<template #trigger>
<el-button ref="uploadRef" type="primary">选择文件</el-button>
</template>
</el-upload>
1.2 xlsx读取文件
当用户选择多个文件后,需要对选择文件依次读取。在讲解具体处理方法前,大家可通过官网来了解excel中的基础概念如何抽象成xlsx的数据模型的。有了前置知识,在使用xlsx的API时,可以有更好的理解。
- 读取文件:
XLSX.read(data, options)
- 读取数据转化为json对象数组:
XLSX.utils.sheet_to_json(worksheet, opts)
根据官网API指引,在读取文件操作上,主要需要使用到上面列出的两个API。第一个read接口,可根据数据类型将读取的data数据,解析为对应的工作簿对象。这里的data由FileReader 来读取,fileReader提供不同的read方式,不同read接口返回的数据类型不同,如FileReader.readAsArrayBuffer()
返回的是读取文件的 ArrayBuffer
数据对象,FileReader.readAsDataURL()
返回URL格式的 Base64 字符串。XLSX的read接口则根据fileReader的data类型,来选择type。
js
function readLocalFile(file) {
const reader = new FileReader()
reader.readAsArrayBuffer(file.raw) // 按照arraybuffer格式读取,这里的读取方式决定了XLSX read接口的type选择
}
1.3 文件的虚拟滚动
文件在读取过程中,通常是一次性读取整个excel当中的数据,当数据量过大时,会出现页面卡顿、空白的现象,这样会导致页面假死,用户体验差。因此虚拟滚动需要考虑到。虚拟滚动,提供一种优化一次性读取大数据量的方式,通过只展示视窗内的数据,而非全部数据,来提高页面性能。这里选择 vue3-virtual-scroller
实现虚拟滚动效果。 注意,该插件在实现虚拟滚动上要求数据提供唯一id,因此在读取数据过程中,会自动生成uuid,来满足该插件的要求。
js
<div class="table-container">
<table-head :header="columnDatas" v-if="columnDatas.length" class="header-wrapper"/>
<RecycleScroller
class="virtual-table-container"
:items="tableDatas"
:item-size="30"
key-field="EXCEL_READ_UUID" // 唯一UUID
>
<template #default="{ item }">
<table-row :item="item"></table-row>
</template>
</RecycleScroller>
二、合并文件
文件读取之后,对文件进行合并。合并相对简单,只需要对文件进行concat
即可。
ini
tableDatas.value = tableDatas.value.concat(datas)
三、配置表头
由于在生成文件时,可能会根据用户要求,更改excel表头数据,因此在生成文件前,提供了表头的配置功能。满足个性化的表头配置工作。这里提供的是,根据表头生成的可编辑表格弹窗组件。使用el-form和el-table结合。这里需要做表头数据的转化,稍后把代码都贴上。
js
<el-form ref="cfgForm">
<el-table :data="tableResults" border height="560">
<template v-for="(column, idx) in tableColumns" :key="idx">
<el-table-column :label="column.name" :prop="column.id">
<template #header>
<span class="required-symbol" v-if="idx === 1">*</span>
<span>{{ column.name }}</span>
</template>
<template #default="scope">
<el-form-item :label="tableResults[scope.$index][column.id]" v-if="!idx"/>
<el-form-item :prop="tableResults[scope.$index][column.id]" v-else>
<el-input v-model="modelResults[scope.$index][column.id]"></el-input>
</el-form-item>
</template>
</el-table-column>
</template>
</el-table>
</el-form>
四、生成文件
文件读取、表头配置完成后,可以生成文件了,生成文件时,按照excel的文档结构进行构造。
- 使用XLSX.utils.json_to_sheet生成工作表。
- 使用XLSX.utils.book_new生成工作簿。
- 使用XLSX.utils.book_append_sheet将工作表附加到工作簿。
- 使用writeFileXLSX将数据导出。
导出的数据会生成配置之后的excel表格。
js
function exportFile(res) {
const ws = utils.json_to_sheet(res);
const wb = utils.book_new();
utils.book_append_sheet(wb, ws, "Data");
writeFileXLSX(wb, "导出数据.xlsx");
tableRes.value = []
}
部分重要代码如下
js
<!--页面内容-->
<template>
<el-upload
class="upload-wrapper"
ref="upload"
action
multiple
accept=".xlsx,.xls"
:auto-upload="false"
:on-change="handleFiles"
:show-file-list="false">
<template #trigger>
<el-button ref="uploadRef" type="primary">选择文件</el-button>
</template>
<el-button class="ml-30" type="success" @click="submitUpload" :disabled="!tableDatas.length"> 生成文件</el-button>
<el-button class="ml-30" type="warning" @click="reset">清空选择</el-button>
<span class="tip-wrapper ml-30">当前已合并<span>{{ tableDatas.length }}</span>条数据</span>
</el-upload>
<div class="table-container">
<table-head :header="columnDatas" v-if="columnDatas.length" class="header-wrapper"/>
<RecycleScroller
class="virtual-table-container"
:items="tableDatas"
:item-size="30"
key-field="EXCEL_READ_UUID">
<template #default="{ item }">
<table-row :item="item"></table-row>
</template>
</RecycleScroller>
</div>
<HeaderCfg :visible="cfg" :tableResults="tableTitleRes" @close="closeCfg"/>
</template>
js
<!--逻辑处理-->
// 读取当前文件
function readLocalFile(file) {
const reader = new FileReader()
reader.onload = (event) => {
tLoading.value = false
extractFileDatas(event, file)
}
reader.onprogress = (event) => {
if (event.lengthComputable) {
tLoading.value = true
}
};
reader.readAsArrayBuffer(file.raw)
}
// 处理文件数据
function extractFileDatas(event, file) {
const data = new Uint8Array(event.target.result);
const workbook = read(data, {type: 'array'});
const worksheetName = workbook.SheetNames[0] // 读取第一个sheet内容
const worksheet = workbook.Sheets[worksheetName]; // 获取指定工作表的数据
const header = utils.sheet_to_json(worksheet, {header: 1}); // 读取表头
const fileDatas = utils.sheet_to_json(worksheet, {defval: ""}); // 读取表格内容
const fileName = file.name.split(".")[0]
const combineFileDatas = webpackDatas(fileDatas, header, fileName) // 包装合并数据
return combineData(header, combineFileDatas, file)
}
// 包装数据
function webpackDatas(fileDatas, header, fileName ) {
const combineFileDatas =
fileDatas.map(fileData => {
return {
'EXCEL_READ_UUID': uuidv4(),
...fileData,
}
})
header[0].unshift('EXCEL_READ_UUID')
return combineFileDatas
}
// 合并
function combineData(header, datas, file) {
if (datas.length) {
if (columnDatas.value.length &&
!arraysEqual(columnDatas.value, header[0])) {
ElMessage.error(`合并文件表头需一致,请检查文件[${ file.name }]!`)
return
}
columnDatas.value = header[0]
tableDatas.value = tableDatas.value.concat(datas)
}
}
// 转化表头
function transferHeader(datas) {
cfg.value = true
tableTitleRes.value = Object.keys(datas[0]).map(data => {
return {
column: data,
value: data
}
})
tableTitleRes.value.shift()
}
// 生成文件
function generateExcel(titleDatas) {
const res = tableDatas.value.map(data => {
const obj = {}
titleDatas.forEach(title => {
const key = Object.keys(data).filter(key => key === title.column)[0]
if (key) {
obj[title.value] = data[key]
}
})
return obj
})
exportFile(res)
}
// 导出excel文件
function exportFile(res) {
const ws = utils.json_to_sheet(res);
const wb = utils.book_new();
utils.book_append_sheet(wb, ws, "Data");
writeFileXLSX(wb, "导出数据.xlsx");
tableRes.value = []
}
js
<!--表头配置页面-->
<template>
<el-dialog title="标题配置" :model-value="visible" @close="closeDialog(false)" class="header-cfg-dialog" :top="'10vh'">
<el-form ref="cfgForm">
<el-table :data="tableResults" border height="560">
<template v-for="(column, idx) in tableColumns" :key="idx">
<el-table-column :label="column.name" :prop="column.id">
<template #header>
<span class="required-symbol" v-if="idx === 1">*</span>
<span>{{ column.name }}</span>
</template>
<template #default="scope">
<el-form-item :label="tableResults[scope.$index][column.id]" v-if="!idx"/>
<el-form-item :prop="tableResults[scope.$index][column.id]" v-else>
<el-input v-model="modelResults[scope.$index][column.id]"></el-input>
</el-form-item>
</template>
</el-table-column>
</template>
</el-table>
</el-form>
</el-dialog>
</template>
<script setup>
const tableColumns = [
{ id: 'column', name: '列名' },
{ id: 'value', name: '替换名' }
]
const props = defineProps({
visible: {
type: Boolean,
default: false
},
tableResults: {
type: Array,
default: () => []
}
})
const modelResults = computed(() => props.tableResults)
const emit = defineEmits(['close'])
function closeDialog(flag) {
emit('close', flag, modelResults.value)
}
</script>
一次帮同事的小忙,促使我做了这个小工具,从时间成本上来说,帮同事节省了时间。从自身来说,探索了一种新的处理excel文档的方法。有时问题就在那里,就看我们是否想用更有效率的方式去处理。
当然,该小工具还存在不够完善的地方,后续的思路是,完善读取前后的配置工作,比如是否生成特殊列,读取文件后,如何支持数据的过滤操作;针对明显错误的数据,能否高亮识别等。