electron-vue 實踐 2 —— excel 合併

前言

之前使用 vue-clielectron-vue 創建了工程,接下來就開始實現具體的邏輯,我們的目標很簡單,就是將一張或多張表中的所有 sheet 頁內容都垂直或水平合併在一個 sheet 中,並生成一張新的表。

 

UI 佈局文件

新加一個 .vue 後綴的文件,vue 的 UI 文件格式大致如下,不瞭解的可以查看 官方入門文檔

<template>
    <!--html佈局內容-->
</template>
<script src="xxx.js"></script>
<style>
    /* 佈局組件的個性化樣式設置 */
</style>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>選中 【{{xlsList.length}}】 張表</p><!-- ,共 {{bytesToSize(this.size)}} -->
        </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">
                    {{item.file.name}}
                    </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"> -->
                        => {{item.content}}
                    </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><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>選中 【{{xlsList.length}}】 張表</p><!-- ,共 {{bytesToSize(this.size)}} -->
        </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">
                    {{item.file.name}}
                    </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"> -->
                        => {{item.content}}
                    </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"/><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 = ''
    },// 添加
    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><div class="upload_warp_right" @drop="drop($event)" @dragenter="dragenter($event)" @dragover="dragover($event)">
      <p>或將文件夾拖到此處</p>
    </div>

    然後在腳本中實現 dropdragenterdragover 三個方法:

    需要引入文件處理模塊 fs

    
    const fs = require("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)
        })
    },
    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: [],// 表格路徑表
    }
},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
        });
    }
},// 文件添加到管理列表
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();
    }
},
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;
    }
},
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 文件數據,支持 xlsxlsx 格式文件,需要依賴 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': { ... },
            ...
        }
    }
}// 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$ npm install --save xlsx
  • js 引用:

    
    const XLSX = require("xlsx");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("------ 傳入的表是空的")
            }
        },    // 垂直合併
        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);
    });
}); _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);
        }
    }
}// 創建新的表格
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
    })
}// 打開通用提示
function showDialog(content){
    remote.dialog.showMessageBox({
        type: 'info',
        // buttons: ['確定'],
        defaultId: 0,
        title: '提示',
        message: content
    })
}

使用時直接調用:


showDialog('合併失敗!失敗原因:'+error);showDialog('合併失敗!失敗原因:'+error);

 

打開資源目錄

合併的表格這裏直接生成到桌面的 LMExcel 目錄下:

// 文件輸出目錄
let exportPathRoot = remote.app.getPath('desktop')+"/LMExcel\\";
// 打開目錄
showExport(exportPathRoot);
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);
}
function showExport(path){
    // 判斷目錄是否存在
    fs.exists(path, function(exists) {  
        if(!exists){
            gLog('----------- 目錄不存在');
        }
    });
    shell.openExternal(path);
}

 

性能優化

參考 XCel 項目總結:Electron 與 Vue 的性能優化 對當前項目做一些優化:

1. 讀錶速度慢

讀取表格數據速度慢,要等待幾秒才響應,測試了一下才發現 XLSX.utils.sheet_to_jsonXLSX.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;
}
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$ 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}}
        }
    };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;
    }// 標題欄格式
    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});XLSX.writeFile(workbook, xlsxPath, {cellStyles: true});

  • 讀取樣式信息:

    上面只能在寫入的時候進入設置,假如需要讀取到原表的樣式設置,可以在讀取表格信息的時候設置讀取參數:

    
    _workbook = XLSX.readFile(_xlsx_path, {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:
    ...Error: C:\Users\Administrator\AppData\Local\electron-builder\cache\nsis\nsis-3.0.1.13\Bin\makensis.exe exited with code 1
    Output:
    ...

    發現是因爲之前打出過 Setup 包,並安裝到本機中,通過控制檯卸載掉再重新打包即正常。

 

參考:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章