electron-vue 实践 2 —— 表格合并桌面工具
前言
之前使用 vue-cli
和 electron-vue
创建了工程,接下来就开始实现具体的逻辑,我们的目标很简单,就是将一张或多张表中的所有 sheet 页内容都垂直或水平合并在一个 sheet 中,并生成一张新的表。
UI 布局文件
新加一个 .vue
后缀的文件,vue 的 UI 文件格式大致如下,不了解的可以查看 官方入门文档 :
<template>
<!--html布局内容-->
</template>
<script src="xxx.js"></script>
<style>
/* 布局组件的个性化样式设置 */
</style>
我们最终应用的 UI 布局文件内容如下:
<template>
<div class="excel-merge">
<div class="upload">
<div class="upload_warp">
<div class="upload_warp_left" @click="fileClick">
<img src="static/imgs/ms-excel.png">
</div>
<div class="upload_warp_right" @drop="drop($event)" @dragenter="dragenter($event)" @dragover="dragover($event)">
<p>或将文件夹拖到此处</p>
</div>
</div>
<div class="upload_warp_text">
<p>选中 【 】 张表</p><!-- ,共 -->
</div>
<input @change="fileChange($event)" type="file" accept=".xls, .xlsx" id="upload_file" multiple style="display: none"/>
<div class="upload_warp_img" v-show="xlsList.length!=0">
<div class="upload_warp_img_div" v-for="(item,index) of xlsList">
<div class="upload_warp_img_div_top">
<div class="upload_warp_img_div_text">
</div>
<img src="static/imgs/close.png" class="upload_warp_img_div_del" @click="fileDel(index)">
</div>
<img :src="item.file.src">
</div>
</div>
</div>
<transition>
<button id="VMBtn" @click="verticalMergeClick()">垂直合并</button>
</transition>
<button id="HMBtn" @click="horizontalMergeClick()">水平合并</button>
<button id="CABtn" @click="cleanAllClick()">清空重来</button>
<!--结果输出栏-->
<div class="result" v-show="resLogs.length!=0">
<p>结果:</p>
<div class="" v-for="(item,index) of resLogs">
<div class="">
<div>
<!-- <img src="../assets/arrow.png"> -->
=>
</div>
</div>
</div>
<button id="OpenResult" @click="openExportDir()" v-show=OpenResVisible >打开输出目录</button>
</div>
</div>
</template>
<!-- 业务脚本 -->
<script src="../logic/excel_merge.js"></script>
<style scoped>
...
</style>
引入表格之前的初始界面:
引入表格之后的表格列表展示界面:
合并成功界面如下:
获取文件
可以通过点击按钮从文件管理器中选择文件列表,也可以通过选中多个文件然后拖动到应用的指定区域来获取文件列表。
-
通过文本编辑器输入的方式,限制文件后缀名为
.xls
或.xlsx
:<input @change="fileChange($event)" type="file" accept=".xls, .xlsx" id="upload_file" multiple style="display: none"/>
类型
multiple
表示可以输入多个文件。在脚本中重写fileChange(el)
方法,在输入文件时会自动调用此接口:// 添加 fileChange(el) { let filePath = el.target.files[0].path; let xlsxData = XLSX.readFile(filePath); // 空表过滤 if (xlsxData.Sheets.length == 0){ gLog("------------------ 引入一个空表"); return; } // 传入文件列表处理 this.fileList(el.target); // 释放内存 el.target.value = '' },
-
通过拖拽方式输入文件列表:
<div class="upload_warp_right" @drop="drop($event)" @dragenter="dragenter($event)" @dragover="dragover($event)"> <p>或将文件夹拖到此处</p> </div>
然后在脚本中实现
drop
、dragenter
和dragover
三个方法:需要引入文件处理模块
fs
:const fs = require("fs");
// 拖拽相关 dragenter(el) { el.stopPropagation(); el.preventDefault(); }, dragover(el) { el.stopPropagation(); el.preventDefault(); }, drop(el) { el.stopPropagation(); el.preventDefault(); this.fileList(el.dataTransfer); } // 拖拽输入的文件列表处理 fileList(fileList) { let files = fileList.files; for (let i = 0; i < files.length; i++) { console.log("--------------------- files[" + i + "].name = "+ files[i].name); //判断是否为文件夹 if (!fs.lstatSync(files[i].path).isDirectory()) { this.fileAdd(files[i]); } else { //文件夹处理 this.folders(fileList.items[i]); } } }, //文件夹处理 folders(files) { let _this = this; //判断是否为原生file if (files.kind) { files = files.webkitGetAsEntry(); } files.createReader().readEntries(function (file) { for (let i = 0; i < file.length; i++) { if (file[i].isFile) { _this.foldersAdd(file[i]); } else { _this.folders(file[i]); } } }) }, foldersAdd(entry) { let _this = this; entry.file(function (file) { _this.fileAdd(file) }) },
获取到的文件信息存放在两个数组中:
data() { return { xlsList: [], // 表格名称表 size: 0, // 表格文件总大小 xlsPathList: [],// 表格路径表 } },
xlsList
表格名称列表,仅用于 UI 上展示使用;xlsPathList
表格完整路径列表,用于后续获取表格数据使用。
// 文件添加到管理列表 fileAdd(file) { //总大小 this.size = this.size + file.size; //判断是否为表格 if (file.name.indexOf('.xlsx') == -1 && file.name.indexOf('.xls') == -1) { console.log("--------------- 添加的不是表格文件"); } else { let xlsxPath = file.path; console.log("--------------- 添加表格文件:" + xlsxPath); let _this=this; file.width=50; file.height=50; // 表格图标 file.src = "./src/renderer/assets/xls.png"; _this.xlsList.push({ file }); _this.xlsPathList.push({ xlsxPath }); } },
也可以在列表中移除某个表格文件,调用 fileDel
方法:
// 删除表格 fileDel(index) { this.size = this.size - this.xlsList[index].file.size;//总大小 this.xlsList.splice(index, 1); this.xlsPathList.splice(index, 1); if(this.size == 0){ this.cleanAllClick(); } },
一键清空重来功能其实就是清除数据结构中的数据而已:
// 清空重来 cleanAllClick(){ if(lockBtn){ return; } this.OpenResVisible = false; this.addResultLog(null, true); if(this.xlsPathList.length > 0){ for(var i=0; i< this.xlsList.length; i ++){ this.xlsList.splice(i, 1); } this.xlsList.length = 0; this.xlsPathList.length = 0; } },
Excel 读写库
目前支持 Excel 读写的 Node.js
模块大致有:
js-xlsx: 目前 Github 上 star 数量最多的处理 Excel 的库,支持解析多种格式表格
XLSX
/XLSM
/XLSB
/XLS
/CSV
,解析采用纯 js 实现,写入需要依赖Node.js
或者 FileSaver.js 实现生成写入Excel,可以生成子表 Excel ,功能强大,但上手难度稍大。不提供基础设置 Excel 表格 API 例单元格宽度,文档有些乱,不适合快速上手;node-xlsx: 基于
Node.js
解析 Excel 文件数据及生成 excel 文件,仅支持 xlsx 格式文件;excel-parser: 基于
Node.js
解析 Excel 文件数据,支持xls
及xlsx
格式文件,需要依赖 python ,太重不太实用;excel-export : 基于
Node.js
将数据生成导出 Excel 文件,生成文件格式为xlsx
,可以设置单元格宽度, API 容易上手,无法生成worksheet
字表,比较单一,基本功能可以基本满足;-
node-xlrd: 基于
Node.js
从 Excel 文件中提取数据,仅支持xls
格式文件,不支持xlsx
,有点过时。
结合我们的需求,最终选择了 js-xlsx
作为我们项目的核心工具库,提交了解此库关于表格数据的封装对象:
workbook 对象,指的是整份 Excel 文档。我们在使用 js-xlsx 读取 Excel 文档之后就会获得 workbook 对象。
worksheet 对象,指的是 Excel 文档中的表。我们知道一份 Excel 文档中可以包含很多张表,而每张表对应的就是 worksheet 对象。
cell 对象,指的就是 worksheet 中的单元格,一个单元格就是一个 cell 对象。
格式如下:
// workbook { SheetNames: ['sheet1', 'sheet2'], Sheets: { // worksheet 'sheet1': { // cell 'A1': { ... }, // cell 'A2': { ... }, ... }, // worksheet 'sheet2': { // cell 'A1': { ... }, // cell 'A2': { ... }, ... } } }
SheetNames
是字符串数组,而Sheets
是一个 Map 表。
接下来,先了解一下库的引入步骤:
-
npm 安装:
$ npm install --save xlsx
-
js 引用:
const XLSX = require("xlsx");
表格合并
合并分为水平合并和垂直合并:
// 垂直合并 verticalMergeClick(){ if(lockBtn){ return; } if(this.size > 0){ // 发给主线程,分发给 background 渲染线程去完成融合 // ipcRenderer.send('Start-Ver-Merge', this.xlsPathList, exportPathRoot); this.addResultLog('开始垂直合并表格 ...', true); lockBtn = true; this.OpenResVisible = false; // 延迟 0.5s setTimeout(()=>{ exportPathRoot = exportPathRoot.replace('/', '\\'); resultStr = verticalMerge(this.xlsPathList, exportPathRoot); this.addResultLog('【垂直】合并表格结束,表格输出路径:'+exportPathRoot); // 显示打开输出目录的按钮 this.OpenResVisible = true; lockBtn = false; }, 500); }else{ this.addResultLog('未选择用于【垂直】合并的表格!', true); gLog("------ 传入的表是空的") } }, // 水平合并 horizontalMergeClick(){ if(lockBtn){ return; } if(this.size > 0){ this.addResultLog('开始水平合并表格 ...', true); lockBtn = true; this.OpenResVisible = false; // 延迟 0.5s setTimeout(()=>{ exportPathRoot = exportPathRoot.replace('/', '\\'); horizontalMerge(this.xlsPathList, exportPathRoot); this.addResultLog('【水平】合并表格结束,表格输出路径:'+exportPathRoot); // 显示打开输出目录的按钮 this.OpenResVisible = true; lockBtn = false; }, 500); }else{ this.addResultLog('未选择用于【水平】合并的表格!', true); gLog("------ 传入的表是空的") } },
在前面的文件获取中,我们已经得到了表格文件的路径列表 this.xlsPathList
,接下来我们就通过路径来获取表格的数据:
let _workbook = null; let _xlsx_path = ""; let jsons = null; xlsPathList.forEach(element => { _xlsx_path = element.xlsxPath; console.log("---------------- 开始读取表格:" + _xlsx_path); _workbook = XLSX.readFile(_xlsx_path); let _worksheet = null; _workbook.SheetNames.forEach(sheetName => { console.log("----------------- sheetName:" + sheetName); _worksheet = _workbook.Sheets[sheetName]; let json = XLSX.utils.sheet_to_json(_worksheet) // console.log(json.length); if(jsons == null){ jsons = json; }else{ // 合并数据 jsons = Array.prototype.concat.apply(jsons, json); // 优化内存 json.length = 0; json = null; } // console.log("【 total count 】: "+jsons.length); }); });
SheetNames
是一个字符串数组,是一个 WorkBook 中所有 sheet 名称的数组;Sheets
是 sheet 名称与数据映射表,格式为[{sheetName: WorkSheet},{sheetName: WorkSheet},...]
;
先通过 XLSX.utils.sheet_to_json
将数据转为 json 格式,然后用 Array.prototype.concat.apply
来合并数组,最终所有表格的 sheet 数据都存放在 jsons
中,接下来我们就将此数据写入到一张新建的 Excel 表中:
// 创建新的表格 function createResultXlsx(sheetData, xlsxPath){ var workbook = { SheetNames: ['total'], Sheets: { 'total': sheetData } }; var index = xlsxPath.lastIndexOf('\\'); var pathRoot = xlsxPath.substring(0, index); let result_str = ''; // gLog('-----------输出目录:'+pathRoot); fs.exists(pathRoot, function(exists) { if(!exists){ gLog('----------- 创建目录'); fs.mkdir(pathRoot) result_str = WriteXLSXFile(workbook, xlsxPath); }else{ result_str = WriteXLSXFile(workbook, xlsxPath); } }); gLog('-----------输出result_str:'+result_str); return result_str } // 导出表格 function WriteXLSXFile(workbook, xlsxPath){ try { XLSX.writeFile(workbook, xlsxPath, {cellStyles: true}); gLog("表格生成成功,路径:"+xlsxPath); } catch (error) { if(String(error).includes('Error: EBUSY: resource busy or locked, open')){ gLog('先关闭文件:' + xlsxPath); showDialog('合并失败!需要先关闭 Excel 中打开的文件:' + xlsxPath); }else{ gLog(error) showDialog('合并失败!失败原因:'+error); } } }
通过 XLSX.utils.json_to_sheet
将上面合并得到的 json 数据转回 WorkSheet 数据类型,然后构建一个 WorkBook 对象,通过 XLSX.writeFile
创建表格。
通用弹窗
使用 eletron 提供的 dialog.showMessageBox
接口来打开系统提示框:
// 打开通用提示 function showDialog(content){ remote.dialog.showMessageBox({ type: 'info', // buttons: ['确定'], defaultId: 0, title: '提示', message: content }) }
使用时直接调用:
showDialog('合并失败!失败原因:'+error);
打开资源目录
合并的表格这里直接生成到桌面的 LMExcel
目录下:
// 文件输出目录 let exportPathRoot = remote.app.getPath('desktop')+"/LMExcel\\"; // 打开目录 showExport(exportPathRoot);
然后通过 electron.shell
工具实现打开资源目录:
// 打开输出文件夹 function showExport(path){ // 判断目录是否存在 fs.exists(path, function(exists) { if(!exists){ gLog('----------- 目录不存在'); } }); shell.openExternal(path); }
性能优化
参考 XCel 项目总结:Electron 与 Vue 的性能优化 对当前项目做一些优化:
1. 读表速度慢
读取表格数据速度慢,要等待几秒才响应,测试了一下才发现 XLSX.utils.sheet_to_json
和 XLSX.utils.json_to_sheet
两个接口都很耗时(每个 sheet 转化需要消耗长达 6s 的时间),因为直接使用 WorkSheet 数据来完成数据拼接:
// 24 个字母,列号(A-Z,AA-AZ,BA-BZ,...ZA-ZZ,AAA-AAZ,ABA-ABZ,..AZA-AZZ,BAA-BAZ,..)以此规律类推 let Signals = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']; function verticalMerge(xlsPathList, exportPath){ let _workbook = null; let _xlsx_path = ""; // gLog(xlsPathList); let jsons = null; let sheetsData = null; xlsPathList.forEach(element => { _xlsx_path = element.xlsxPath; gLog("---------------- 开始读取表格:" + _xlsx_path + "," + new Date().getTime()); _workbook = XLSX.readFile(_xlsx_path); let _worksheet = null; // 最后一行行号 let _lastRowNum = 0; // 最后一列列号 let _lastColNum = 0; _workbook.SheetNames.forEach(sheetName => { gLog("----------------- sheetName:" + sheetName); _worksheet = _workbook.Sheets[sheetName]; // 非 json 格式 gLog( '范围:'+_worksheet['!ref']); let ref = _worksheet['!ref']; let cellKeys = ref.split(':'); let startCell = cellKeys[0]; let endCell = cellKeys[1]; gLog( '起点:'+startCell+', 终点:'+endCell); //分离出列号和行号(正则表达式) let s_col = startCell.match(/\D+/); let s_low = startCell.substring(String(s_col).length, startCell.length); // gLog( '行号:'+s_col+', 列号:'+s_low); let e_col = endCell.match(/\D+/); let e_low = endCell.substring(String(e_col).length, endCell.length); // gLog( '行号:'+e_low+', 列号:'+e_col); if(sheetsData == null){ sheetsData = _worksheet; // 记录第一行(用于后续对应匹配),并记录最好一行的行号 _lastRowNum = Number(e_low); _lastColNum = getColNumByColName(e_col); }else{ let e_col_num = getColNumByColName(e_col); let col_name = ""; let cell_name = ""; let cell_data = null; let newRowNum = 0; // 过滤空白行使用 let noEmptyRowNum = 0; // 写入写表中的单元格名称 let newCell_name = ""; let e_low_num = Number(e_low); for(let i=1; i <= e_col_num; i++){ col_name = getColNameByColNum(i); for(let j=1; j <= e_low_num; j++){ cell_name = col_name + j; // 有数据则拷贝到新表中 cell_data = _worksheet[cell_name] if(cell_data){ newRowNum = j+_lastRowNum; newCell_name = col_name + newRowNum; sheetsData[newCell_name] = cell_data; gLog('-------------- 单元格名称:' + newCell_name); } } } // 刷新行列号 if(e_col_num > _lastColNum){ _lastColNum = e_col_num; } _lastRowNum += e_low_num; // 刷新新表的让范围值 let newRef = 'A1:'+getColNameByColNum(_lastColNum)+_lastRowNum; sheetsData['!ref'] = newRef; // 优化内存 _worksheet.length = 0; _worksheet = null; } // gLog(sheetsData) gLog("----------------- "+sheetName+" 数据读完了" + "," + new Date().getTime()); }); }); createResultXlsx(sheetsData, exportPath+getFinalExcelName(xlsPathList)); } // 通过列序号获取列名称 function getColNameByColNum(colNum){ let colName = ""; let temNum = colNum; let remain = 0; while(temNum >= 26){ remain = temNum%26; temNum = (temNum - remain)/26; if(remain>0){ colName = Signals[remain-1] + colName; }else if(remain == 0){ temNum -= 1; colName = Signals[25] + colName; } } if(temNum > 0){ colName = Signals[temNum-1] + colName; } return colName; } // 通过列符获取列序号 function getColNumByColName(colName){ let magnif = 1; let _char = ""; let index = 0; let resNum = 0; let len = String(colName).length; for(let i=0; i< len; i++){ _char = String(colName).substring(len-1-i, len-i); index = getSignalIndex(_char); if(index > 0){ resNum += index * magnif; magnif *= 26; } } return resNum; } // A-Z序号 function getSignalIndex(signal){ for(let i=0;i<Signals.length;i++){ if(Signals[i] == signal){ return i+1; } } return 0; }
修改之后合并的耗时直接提升了好几倍。
将数据转为 json 除了速度较慢之外,由于 json 数据需要以每列的第一行数据作为 key 以该列数据作为 value。所以,假如有同名的 key 还会导致第一行数据被修改(被加上
"_X"
格式的后缀)。最终使用遍历后续每个 sheet 中每个单元格数据的方式,逐个将数据写入新表中,效率上也是最高的,而且可以做各种自定义的优化,例如:剔除空白的行或者列,多张字表第一行 key 一致的存在同一列中。
2. 界面 UI 响应延迟
耗时操作会导致页面响应式内容无法实时更新,解决办法就是启动一个没有页面的后台子线程(另一个渲染线程),当前渲染线程将消息发给主线程,主线程再透传给后台子线程,由后台子线程完成任务再通过消息告知主线程,主线程再回传给当前显示的渲染线程。主线程担任消息传递媒介。目前, demo 还没写完这部分。
3. 样式引入
Sheet/js-xlsx
库并不支持样式的添加,参考 js-xlsx纯前端excle文件导出实践(vuedemo) 和 xSirrioNx/js-xlsx设置基本样式输出excel文件 ,使用改版后的库 xSirrioNx/js-xlsx ,此库融合了 protobi/js-xlsx ,可以实现样式的引入,下面是实现步骤:
-
替换库:
使用 npm 移除已安装的原版
js-xlsx
库,替换成改版的版本:$ npm rm xlsx $ npm i --save git+https://[email protected]/xSirrioNx/js-xlsx
-
写入样式信息:
在每个 cell 数据中,会有一个
s
字段用于存储样式信息,格式如下:excelCell.s = { fill: { patternType: "none", // none / solid fgColor: {rgb: "FF000000"}, bgColor: {rgb: "FFFFFFFF"} }, font: { name: 'Times New Roman', sz: 16, color: {rgb: "#FF000000"}, bold: false, italic: false, underline: false }, alignment: { vertical: "center", horizontal: "center", indent:0, wrapText: true }, border: { top: {style: "thin", color: {auto: 1}}, right: {style: "thin", color: {auto: 1}}, bottom: {style: "thin", color: {auto: 1}}, left: {style: "thin", color: {auto: 1}} } };
可以设置单元格的背景色,对齐方式和文字格式等信息。例如,标题栏文字居中且设置为红色:
// 标题栏格式 let head_style = { font: { // 字体 color : {rgb: "FFFF0000"} }, alignment: { // 对齐方式 vertical: "center", horizontal: "center", } } // 标题栏 if(j == 1){ cell_data.s = head_style; }
写入时设置为带样式的写入方式:
XLSX.writeFile(workbook, xlsxPath, {cellStyles: true});
-
读取样式信息:
上面只能在写入的时候进入设置,假如需要读取到原表的样式设置,可以在读取表格信息的时候设置读取参数:
_workbook = XLSX.readFile(_xlsx_path, {cellStyles:true}); // 读取样式
但似乎不起作用,官方说是
xlsx-style
插件的问题。最近发现,原来原版的库也支持了写入时的样式属性设置,但读取时无法读取到样式信息,Note on extended features (styles, PivotTables, etc) 中指出了只有 pro 版本才有提供读取样式信息的接口。
其他
使用现成的表格合并库
excel-merge
,使用其'mutiple'
模式进行融合。
-
npm run build
打包时出现报错:Error: C:\Users\Administrator\AppData\Local\electron-builder\cache\nsis\nsis-3.0.1.13\Bin\makensis.exe exited with code 1 Output: ...
发现是因为之前打出过 Setup 包,并安装到本机中,通过控制台卸载掉再重新打包即正常。