記一次利用sheetjs將前臺表格轉換excel的經歷

1 關鍵技術與難點

本文記錄一次在前臺利用sheetjs,將js數據轉化爲excel表格的實踐經歷.

其中涉及到的一些難點包含:

  1. 多行合併列的處理;(均爲表頭,不含表體)
  2. 表格數據的設計與處理;

表頭截圖


2 前置說明

注:如果不是自定義表格設計,而是固定列表格設計,則可忽略2.1的數據庫設計,直接從2.2的實例數據參考即可.

2.1 數據庫設計

此處主要介紹表格數據的column(列)和data(表體數據)的設計,
僅介紹與表格設計相關的字段,忽略項目邏輯相關的字段.實際開發字段需要酌情增加.

  1. column數據庫表設計
字段名 說明
code 列編碼
name 列名稱/title
parent_code 父列編碼,用以記錄列的父子間關係
data_type 數據類型,如string,integer,decimal,none(僅用於無數據的父列),date,select(選擇),boolean(布爾),calc(公式),也可根據自身需求增加
select_source 選擇來源,僅當數據類型爲selectcalc時有用
sort 排序

以上介紹的,是最基礎的一些字段,實際還可以考慮增加如min_width(最小寬度),bold_font(是否粗體)這樣的字段.
對於data_type字段,還有一些附加的設計內容,本文涉及不多,以後如果有需求,也許會另開一篇博客介紹.

實例數據(json格式):

[
    {
        code:'xiangmu',
        name:'項目',
        parentCode:'',
        dataType:'none',
        selectSource:''null'',
        sort:1
    },
    {
        code:'xiangmumingcheng',
        name:'項目名稱',
        parentCode:'xiangmu',
        dataType:'string',
        selectSource:'',
        sort:2
    },
    {
        code:'caigoupinmu',
        name:'採購品目',
        parentCode:'xiangmu',
        dataType:'select',
        selectSource:'CGPM',
        sort:3
    },
    {
        code:'danjia',
        name:'單價',
        parentCode:'',
        dataType:'decimal',
        selectSource:'',
        sort:4
    },
    ...
]
  1. data數據庫表設計
字段名 說明
column_code 列編碼
row_code 行編碼,我將之設計爲uuid格式,這樣前臺可直接生成.
value 值,以字符串方式保存所有值
show_content 展示內容,適用於像selectboolean這樣的列

同樣爲最基礎字段.有些可以擴展.

實例數據(json格式):

[
    {
        columnCode:'xiangmumingcheng'
        rowCode:'9cd97861-ebab-4002-8cc3-c12ceaa92cab',
        value:'**業務經費',
        showContent:''
    },
    {
        columnCode:'caigoupinmu',
        rowCode:'9cd97861-ebab-4002-8cc3-c12ceaa92cab',
        value:'BGSB',
        showContent:'辦公設備'
    },
    {
        columnCode:'danjia',
        rowCode:'9cd97861-ebab-4002-8cc3-c12ceaa92cab',
        value:'13.5',
        showContent:''
    },
]
2.2 前臺數據體現

簡單來說,就是將columndata轉化爲對象集合的格式.
對於data中的showContent,採用前綴_show_加編碼作爲key.
轉換方法本身比較簡單,且非本文重點,就不再贅述了.

實例數據(json格式):

[
    {
        xiangmumingcheng:'**業務經費',
        caigoupinmu:'BGSB',
        _show_caigoupinmu:'辦公設備',
        danjia:'13.5'
    }
]
2.3 excel導出的標準方法

根據中文api,sheetjs將js數據轉換爲表格的方式,有:

  • aoa_to_sheet 把轉換JS數據數組的數組爲工作表。
  • json_to_sheet 把JS對象數組轉換爲工作表。
  • table_to_sheet 把DOM TABLE元素轉換爲工作表。

本文采用aoa_to_sheet作爲核心方法.
項目內已經寫好了直調方法:

/* 需要npm中導入sheetjs */
import XLSX from 'xlsx';

/**
 * 將數組轉換爲excel
 * @param key 列key數組,按照index確認
 * @param data 數據數組
 * @param title 表頭數組,如果爲合併表頭模式(merge不爲空)時,title爲二維數組
 * @param filename 文件名&sheet名
 * @param autoWidth 自動寬度
 * @param merge 合併表頭數據(實際上excel內的所有單元格都可合併)
 */
export const export_array_to_excel = ({key, data, title, filename, autoWidth,merge}) => {
    const wb = XLSX.utils.book_new();
    const arr = json_to_array(key, data);
    if(merge){
      arr.unshift(...title)
    }else{
      arr.unshift(title);
    }
    const ws = XLSX.utils.aoa_to_sheet(arr);
    if(autoWidth){
        auto_width(ws, arr);
    }
    //合併單元格,適用於有父行的內容
    //格式可參考https://zhuanlan.zhihu.com/p/141328581
    if(merge){
      ws['!merges']=merge;
    }
    XLSX.utils.book_append_sheet(wb, ws, filename);
    XLSX.writeFile(wb, filename + '.xlsx');
}
//輔助:自動寬度
function auto_width(ws, data){
    /*set worksheet max width per col*/
    const colWidth = data.map(row => row.map(val => {
        /*if null/undefined*/
        if (val == null) {
            return {'wch': 10};
        }
        /*if chinese*/
        else if (val.toString().charCodeAt(0) > 255) {
            return {'wch': val.toString().length * 2};
        } else {
            return {'wch': val.toString().length};
        }
    }))
    /*start in the first row*/
    let result = colWidth[0];
    for (let i = 1; i < colWidth.length; i++) {
        for (let j = 0; j < colWidth[i].length; j++) {
            if (result[j]['wch'] < colWidth[i][j]['wch']) {
                result[j]['wch'] = colWidth[i][j]['wch'];
            }
        }
    }
    ws['!cols'] = result;
}

3 核心代碼

參數方面,arrTableData參考自2.2的實例數據.arrColumn則參考2.1的列的實例數據.
如果不是自定義列,則arrColumn就需要手動寫死了.

import { isEmpty } from '@/common/about-string'
import { getShowContentKey } from '@/common/about-table'

/**
 * 導出默認表格
 * @param arrTableData
 * @param arrColumn
 * @param filename
 */
export const exportDefaultTable = (arrTableData, arrColumn, { filename = '' } = { filename: '' }) => {
  // 1.數據預處理
  let mapColumn = new Map()

  // console.log('1');

  // 默認父類均在子列前
  let arrAncestorCode = []
  let intRowIndex = 0
  let intColIndex = -1
  let intMaxRowIndex = 0
  let intMaxColIndex = 0
  let mapColumnIndex = new Map()
  for (let c of arrColumn) {
    mapColumn.set(c.code, c)

    if (isEmpty(c.parentCode)) {
      intRowIndex = 0
      intColIndex += 1
      arrAncestorCode = [c.code]
    } else {
      let intParentCodeIndex = arrAncestorCode.findIndex(s => s === c.parentCode)
      if (intParentCodeIndex < arrAncestorCode.length - 1) {
        // 左移
        intRowIndex = intParentCodeIndex + 1
        intColIndex += 1
        arrAncestorCode.splice(intRowIndex, arrAncestorCode.length - intParentCodeIndex, c.code)
      } else {
        // 下移
        intRowIndex += 1
        arrAncestorCode.push(c.code)
      }
    }
    if (intMaxRowIndex < intRowIndex) {
      intMaxRowIndex = intRowIndex
    }
    mapColumnIndex.set(c.code, { sr: intRowIndex, sc: intColIndex })
  }
  intMaxRowIndex += 1
  intMaxColIndex = intColIndex + 1
  // console.log('2',mapColumnIndex);
  // 反填,根據行和列記錄columnCode
  let mapStartIndex = new Map()
  for (let s of mapColumnIndex.keys()) {
    let { sr, sc } = mapColumnIndex.get(s)
    if (typeof mapStartIndex.get(sc) === 'undefined') {
      mapStartIndex.set(sc, new Map())
    }
    mapStartIndex.get(sc).set(sr, s)
  }
  // 遍歷列和行,父子表頭站位
  for (let ic = 0; ic < intMaxColIndex; ic++) {
    for (let ir = 0; ir < intMaxRowIndex; ir++) {
      if (typeof mapStartIndex.get(ic) !== 'undefined') {
        if (typeof mapStartIndex.get(ic).get(ir) !== 'undefined') {
          // do nothing
        } else {
          let strLastMark = mapStartIndex.get(ic).get(ir - 1)
          if (typeof strLastMark !== 'undefined') {
            mapStartIndex.get(ic).set(ir, strLastMark)
          } else {
            strLastMark = mapStartIndex.get(ic - 1).get(ir)
            mapStartIndex.get(ic).set(ir, strLastMark)
          }
        }
      }
    }
  }
  // console.log('3',mapStartIndex);
  // 再反填,根據名稱推測站位父子(不一定是按照標準順序的)
  let mapIndexArr = new Map()
  for (let [ic, mapIndexEach] of mapStartIndex.entries()) {
    for (let [ir, s] of mapIndexEach.entries()) {
      if (typeof mapIndexArr.get(s) === 'undefined') {
        mapIndexArr.set(s, [])
      }
      mapIndexArr.get(s).push({ c: ic, r: ir })
    }
  }
  // console.log('4',mapIndexArr);
  // 列出需要合併的列
  let arrColumnMerge = []
  for (let [s, arrPosition] of mapIndexArr.entries()) {
    if (arrPosition.length > 1) {
      let ms = arrPosition[0]
      let me = arrPosition[arrPosition.length - 1]
      arrColumnMerge.push({
        s: ms,
        e: me
      })
    }
  }
  // console.log('5',arrColumnMerge,intMaxRowIndex,intMaxColIndex);
  // 構建key和title
  let arrKey = []
  let arrTitle = []
  for (let ir = 0; ir < intMaxRowIndex; ir++) {
    arrTitle.push([])
    for (let ic = 0; ic < intMaxColIndex; ic++) {
      let strMark = mapStartIndex.get(ic).get(ir)
      arrTitle[ir].push(mapColumn.get(strMark).name)
      if (ir === intMaxRowIndex - 1) {
        arrKey.push(strMark)
      }
    }
  }
  // console.log('6',arrTitle);
  // 2.表格數據整理導出版
  let arrTableDataExport = []
  for (let [i, mRow] of arrTableData.entries()) {
    let mNew = { ...mRow }
    for (let k of arrKey) {
      let mColumn = mapColumn.get(k)
      if (typeof mColumn !== 'undefined') {
        switch (mColumn.dataType) {
          case 'integer':
          case 'decimal':
          case 'calc':
            if (isEmpty(mNew[k])) {
              mNew[k] = 0
            } else {
              mNew[k] = Number(mNew[k])
            }
            break
          case 'select':
          case 'boolean':
            mNew[k] = mNew[getShowContentKey(k)]
            break
          default:
            break
        }
      }
    }

    arrTableDataExport.push(mNew)
  }
  // console.log('7',arrTableData,arrTableDataExport);
  // 2.excel構建
  const mTable = {
    title: arrTitle,
    key: arrKey,
    data: arrTableDataExport,
    autoWidth: true,
    merge: arrColumnMerge,
    filename
  }

    //此處即是調用最核心的方法
  export_array_to_excel(mTable)
}

經過測試,該方法是生效的.
儘管在合併表頭方面,代碼有些過長.以後有時間的話,我再考慮如何精簡吧.

以下附加兩個輔助方法:

/**
 * 是否爲空(主要適用於字符串)
 * @param str
 * @returns {boolean}
 */
export const isEmpty = str => {
  return typeof str === 'undefined' || str === null || str === '' || str === '--'
}

const PREFIX_SHOW = '_show_'

/**
 * 獲取展示內容key
 * @param key
 * @returns {string}
 */
export const getShowContentKey = key => {
  return PREFIX_SHOW + key
}

4 參考說明


5 其他

本文中的表格的表頭列,本質上還是屬於'樹狀數據'.
按照以往的經驗,樹數據的設計加上level,既可避免出現互爲父子的矛盾,也可使一些計算環節更加簡單.
以後若是有時間,可以考慮加上這個字段,甚至直接增加一個祖先後代關聯表,查看是否可以減少一些計算量.

end

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