二次封装 XLSX 插件为 Book 类()

简介

XLSX 插件默认提供的都是一些函数,或者是对象下的函数,使用起来多少有些不便,因此,将插件的导出二次封装为 Book 类,并将部分功能重新组合,使之更便于使用。
适用版本:XLSX 0.18.5

已封装的功能介绍

  • 新建 WorkBook 对象(单张表): const book = new Book({ data: excelData, sheetName: ‘表1’ });
  • 新建 WorkBook 对象(多张表):const book = new Book([
    { data: this.excelData, sheetName: ‘表1’ },
    { data: this.excelData, sheetName: ‘表2’ },
    ]);

  • 新建 WorkSheet 对象:let sheet = book.createSheet(this.excelData, sheetOptions);
  • 添加 WorkSheet :book.appendSheet(sheet, ‘表2’);
  • 新建并添加 WorkSheet :book.addSheet(‘表3’, this.excelData, sheetOptions);
  • 获取 WorkSheet 对象,提供了两种方式:

    一种与 XLSX 相同,通过 book.Sheets[sheetName] 访问
    另一种是重新封装的 book.getSheets(sheetIdentity, basedIndex),第一个参数可以提供表名字符串或者表的索引,第二个参数指示索引从 0 或是 1 开始,默认从 1 开始

  • 一种与 XLSX 相同,通过 book.Sheets[sheetName] 访问
  • 另一种是重新封装的 book.getSheets(sheetIdentity, basedIndex),第一个参数可以提供表名字符串或者表的索引,第二个参数指示索引从 0 或是 1 开始,默认从 1 开始
  • 自定义表头文案:添加了 headers 属性后,默认忽略原表头(sheetOptions.skipHeader = true);注意此处只是忽略原表头并替换为新表头,并不是删除表头const book = new Book(
    { data: this.excelData, sheetName: ‘表1’ },
    {
    headers: {
    id: ‘编号’,
    age: ‘年龄’,
    gender: ‘性别’,
    },
    }
    );

  • 合并单元格:支持多种形式表示合并区域:对象形式、数组形式,合并区域的内容可以直接传字符串或者符合 XLSX 插件的单元格对象形式 { t, v, l, f s, …}const book = new Book({
    data: this.excelData,
    sheetName: ‘表1’,
    options: {
    // 双对象,对象属性为数组
    // merges: {
    // ranges: [this.mergeRange],
    // contents: [{ v: ‘111’ }],
    // },
    // 双对象,对象属性为对象
    // merges: {
    // ranges: {
    // 1: this.mergeRange,
    // },
    // contents: {
    // 1: { v: ‘111’ },
    // },
    // },
    // 数组,数组元素为单个合并区域
    merges: [
    {
    range: this.mergeRange,
    content: ‘111’,
    // content: { v: ‘111’ },
    },
    ],
    origin: ‘A3’, // 注意该属性还是手动添加;或者可以自行添加一段逻辑,将最高的合并单元格的下一行作为数据的起始行
    },
    });

  • 导出到本地: book.writeFile(‘info’); ;第二个参数为扩展名,默认 ‘xlsx’,可以修改

公共方法:

  • 列序号与字母转换:Excel.Digit2ColChars(digit, capitalized)、 Excel.ColChars2Digit(chars, capitalized)
  • 从 R1C1 或 A1 表示的字符串转换为对象形式 ( { s: { r: 1, c: 1} } ):Excel.FromA1(range, basedIndex)、Excel.FromR1C1(range, basedIndex)
  • 从对象形式转换为 R1C1 或 A1 形式:Excel.ToA1(range)、Excel.ToR1C1(range)’

几个常用的正则规则:

  • 行号有效范围 Excel.RegexRowIndex = /104857[0-6]|10485[0-6][0-9]|1048[0-4][0-9]{2}|104[0-7][0-9]{3}|10[0-3][0-9]{4}|[1-9][0-9]{1,5}|[1-9]/
  • 列字母有效范围:Excel.RegexColumnChar = /XF[A-D]|XE[A-Z]|[A-W][A-Z]{2}|[A-Z]{1,2}/g;
  • R1C1 有效范围:Excel.RegexR1C1 = /R(?104857[0-6]|10485[0-6][0-9]|1048[0-4][0-9]{2}|104[0-7][0-9]{3}|10[0-3][0-9]{4}|[1-9][0-9]{1,5}|[1-9])C(?1638[0-4]|163[0-7][0-9]|16[0-2][0-9]{2}|1[0-5][0-9]{3}|[1-9][1-9]{1,3}|[1-9])/;

data 对象示例:

excelData = [
  {
    id: 1,
    age: 17,
    gender: 'male',
  },
  {
    id: 2,
    age: 20,
    gender: 'male',
  },
  {
    id: 3,
    age: 18,
    gender: 'female',
  },
],

实现

外部依赖

import * as XLSX from 'xlsx';
import { isEmpty } from '../validator/jsValidator'; // 自定义的验证对象是否问空的方法,对象必须带自有属性,才会返回 true;空对象返回 false

const { utils } = XLSX;

基本结构

class Book {
  _book = null; // 将 XLSX 插件生成的 book 对象作为当前 Book 类实例的一个属性,避免部分函数使用时还需要将 book 作为参数传递
  _basedIndex = false; // 为真表示 Sheets 索引从 1 开始

  /**
   *
   * @param {包含 data, sheetName 和 options 的对象,或由对象组成的数组} params
   * @param {所有 WorkSheet 的默认配置,且以各 WorkSheet 的独立配置为优先} defaultOptions
   */
  constructor(params, defaultOptions) {
    this.book = utils.book_new();
    this._initBook(params, defaultOptions);
  }

  get Sheets() {
    return this._book.Sheets;
  }
  ...
}

初始化

表格数据会交由 initBook 方法,创建 WorkSheet 对象并添加到 XLSX 插件生成的 book 对象

  _initBook(params, defaultOptions = {}) {
    // 初始化 WorkBook
    this._basedIndex = defaultOptions.indexBase;
    // 初始化 WorkSheet
    if (params instanceof Array) {
      Object.values(params).forEach((param) => this._initSheet(param));
    } else if (params instanceof Object) {
      this._initSheet(params);
    } else {
      throw new Error('Type Error: params should be a object or an array');
    }
  }

  _initSheet({ data, sheetName, options }) {
    let sheetData = [].concat(data);
    let sheetOptions = options || defaultOptions;
    const headers = options.headers;
    // 自定义表头文本
    if (!isEmpty(headers)) {
      sheetData.unshift(headers);
      sheetOptions.skipHeader = true; // 自定义表头时默认忽略原表头
    }
    const sheet = this.createSheet(sheetData, sheetOptions);
    this.appendSheet(sheet, sheetName);
  }

创建表格、添加表格

  createSheet(data, options = {}) {
    if (data?.length > 0) {
      let sheet = utils.json_to_sheet(data, options);

      const { merges, origin } = options;
      if (merges) {
        // 将 merges 参数统一为两个数组
        const { mergeRanges, mergeContents } = this._normalizeMerges(merges);
        if (origin) {
          // 将字符串表示的合并区域转为对象形式
          const mergeConfigs = this._setupMergeConfigs(mergeRanges);
          this._setMergeConfigs(sheet, mergeConfigs);
          Object.entries(mergeConfigs).forEach(([i, config]) => {
            let { r: sr, c: sc } = config.s,
              { r: er, c: ec } = config.e;
            // 由于 mergeConfigs 中将开始索引设置为 0,此处需要重新加上
            const s = Excel.ToA1({ s: { r: sr + 1, c: sc + 1 } }),
              e = Excel.ToA1({ s: { r: er + 1, c: ec + 1 } });
            const content = mergeContents[i];
            if (content) {
              if (!content.t) {
                this.setSheetContent(sheet, s, { v: content });
              } else {
                this.setSheetContent(sheet, s, content);
              }
            } else {
              throw new Error(
                `Reference Error: content for mergeRange ${s}:${e} is empty!`
              );
            }
          });
        } else {
          throw new Error(
            'Reference Error: options.origin is necessary when using merge feature!'
          );
        }
      }
      return sheet;
    }
    return null;
  }

  appendSheet(sheet, sheetName) {
    if (!sheet) {
      throw new Error('Fail to append, sheet is empty');
    } else {
      let book = this._book;
      utils.book_append_sheet(book, sheet, sheetName);
    }
  }

  // 一步添加新表
  addSheet(sheetName, data, options) {
    const sheet = this.createSheet(data, options);
    this.appendSheet(sheet, sheetName);
  }

获取表格对象

  /**
   * 
   * @param {表的索引} index
   * @param {表的开始索引,默认为 true,表示从 1 开始;不影响内存中的表数组} basedIndex
   * @returns
   */
  getSheetNameByIndex(index, basedIndex = true) {
    const book = this._book;
    if (index < 0 || index > book.SheetNames.length) {
      throw new Error(`Range Error: sheet index exceeded!`);
    } else {
      return book.SheetNames[index - basedIndex];
    }
  }

  /**
   *
   * @param {表的索引或表名的字符串} sheetIdentity
   * @param {同上} basedIndex
   * @returns Sheet 对象
   */
  getSheet(sheetIdentity, basedIndex) {
    switch (typeof sheetIdentity) {
      case 'number':
        sheetIdentity = this.getSheetNameByIndex(sheetIdentity, basedIndex);
      case 'string':
        return this.Sheets[sheetIdentity];
      default:
        throw new Error(
          'Type Error: please provide a WorkSheet index or WorkSheet name string'
        );
    }
  }

设置表格内容

  /**
   *
   * @param {同上} sheetIdentity
   * @param {单元格区域,R1C1 或 A1 表示} range
   * @param {字符串或符合 XLSX 单元格对象的格式} content
   * @param {同上} basedIndex
   */
  setSheetContent(sheetIdentity, range, content, basedIndex) {
    if (sheetIdentity instanceof Object) {
      sheetIdentity[range] = content;
    } else {
      let sheet = this.getSheet(sheetIdentity, basedIndex);
      sheet[range] = content;
    }
  }

合并单元格

  // 将所有形式提供的 merges 参数统一为两个数组:mergeRanges 和 mergeContents
  _normalizeMerges(merges) {
    let mergeRanges = [],
      mergeContents = [];
    if (merges) {
      const { ranges, contents } = merges;
      if (ranges) {
        if (ranges instanceof Array) {
          // 双对象形式
          /*
            merges: {
              ranges: ['R1C1:R2C2'],
              contents: ['111'],
            },
          */

          if (ranges.length > 0 && contents.length > 0) {
            if (ranges.length === contents.length) {
              mergeRanges = [].concat(ranges);
              mergeContents = [].concat(contents);
            } else {
              throw new Error(
                `Reference Error: the ranges count doesn't correspond to contents`
              );
            }
          } else {
            console.warn(
              'Merge ranges or contents are empty, please check the "options.merges'
            );
          }
        } else if (ranges instanceof Object) {
          // 类似 el-form 验证功能的 model 和 rules 对象格式
          /* merges: {
              ranges: {
                1: this.mergeRange,
              },
              contents: {
                1: '111',
              },
            }
          */

          Object.entries(ranges).forEach(([key, range]) => {
            const content = contents[key];
            if (content) {
              mergeRanges.push(range);
              mergeContents.push(content);
            } else {
              throw new Error(
                `Reference Error: content for merge range ${range} is empty`
              );
            }
          });
        } else {
          throw new Error(`Type Error: ranges must be an Array or a Object!`);
        }
      } else {
        // 数组形式,数组元素为单个合并区域及内容
        /* merges: [
            {
              range: 'A1:A3',
              content: '表格标题',
            },
          ],
        */

        Object.values(merges).forEach(({ range, content }) => {
          if (range && content) {
            mergeRanges.push(range);
            mergeContents.push(content);
          } else {
            const tag = range ? 'content' : 'range',
              reverseTag = { content: 'range', range: 'content' };

            const reverse = { content: range, range: content };
            throw new Error(
              `Reference Error: ${tag} is empty where ${reverseTag[tag]} 'is' ${reverse[tag]}`
            );
          }
        });
      }
      return { mergeRanges, mergeContents };
    } else {
      throw new Error(`Reference Error: merges are empty`);
    }
  }
  // 合并单元格
  _setMergeConfigs(sheet, mergeConfigs) {
    if (sheet) {
      if (mergeConfigs) {
        sheet['!merges'] = mergeConfigs;
      } else {
        throw new Error(`Reference Error: mergeConfigs can no be empty!`);
      }
    } else {
      throw new Error(`Reference Error: sheet can no be empty!`);
    }
  }
  // 参数为数组;range 格式:R1C1:R2C2 或 A1:B2
  _setupMergeConfigs(mergeRanges) {
    if (mergeRanges.length > 0) {
      const regex = Excel.RegexR1C1;
      const result = mergeRanges[0].match(regex);
      let setupMergeConfig = () => {};
      if (result.length === 1) {
        throw new Error(
          `Formatting Error: please use a pair of range expressions with R1C1 or A1 from start cell to end cell`
        );
      } else if (result.length === 0) {
        setupMergeConfig = Excel.FromA1;
      } else {
        // result.length === 2
        setupMergeConfig = Excel.FromR1C1;
      }
      let mergeConfigs = [];
      Object.values(mergeRanges).forEach((mergeRange) => {
        // 注意这里的开始索引是 0,因为 XLSX 插件内部保存的单元格区域是 js 对象,索引必须从 0 开始
        mergeConfigs.push(setupMergeConfig.call(null, mergeRange, false));
      });
      return mergeConfigs;
    } else {
      throw new Error(`Reference Error: mergeRanges can no be empty!`);
    }
  }

导出 Excel

  writeFile(fileName, extension = 'xlsx') {
    return new Promise((res, rej) => {
      if (this._book.SheetNames.length > 0) {
        if (fileName.indexOf('xls') > -1) {
          rej('Parameter filename should not contain extension');
        } else {
          try {
            XLSX.writeFile(this._book, `${fileName}.${extension}`);
            res(true);
          } catch (err) {
            rej(err);
          }
        }
      } else {
        rej('Can not write workbook with empty sheets');
      }
    });
  }

公共方法和属性

内部公共方法和属性

const CODES = {
  A: 65,
  a: 97,
};

function fromRangeStr(range, isCellFormatValid, cbParseFrom) {
  const parseFrom = (range) => {
    let result = cbParseFrom(range);
    if (result) {
      return result;
    } else {
      // 列序号超范围时 groups 为空,解构会报错;序号前包含前导 0 或其他非法字符也会报错
      throw new Error(
        'Range Error: row or column format invalid or index exceeded!'
      );
    }
  };

  if (range.indexOf(':') > -1) {
    const [s, e] = range.split(':');
    return { s: parseFrom(s), e: parseFrom(e) };
  } else {
    if (isCellFormatValid(range)) {
      throw new Error(
        `Formatting Error: merge ranges should contain a separator ":". You can only ignore it in cell`
      );
    } else {
      return { s: parseFrom(range) };
    }
  }
}
/**
 *
 * @param {对象格式 { s:{ r:1, c:1 }, e: { r:1, c:1 }}} range
 * @param {(r,c): string; 返回 R1C1 或 A1 格式的字符串} cbToCell
 * @returns
 */
function toRangeStr(range, cbToCell) {
  const toCell = (cellRange) => {
    const { r, c } = cellRange;
    return cbToCell(r, c);
  };

  if (isEmpty(range.e)) {
    return toCell(range.s);
  } else {
    return toCell(range.s) + ':' + toCell(range.e);
  }
}

可导出的公共方法和属性

export class Excel {
  // 这里的正则为了匹配尽量长的字符串(代表更大的数字),因此将能匹配到较大结果的规则写在 `|` 左侧,较短的写在右侧
  static RegexRowIndex =
    /104857[0-6]|10485[0-6][0-9]|1048[0-4][0-9]{2}|104[0-7][0-9]{3}|10[0-3][0-9]{4}|[1-9][0-9]{1,5}|[1-9]/;

  // 这里使用了命名捕获组,因此不能添加 g 标志, g 标志会导致分组丢失,不过会返回所有的匹配项;是否使用 g 根据实际需求选择
  static RegexR1C1 =
    /R(?<r>104857[0-6]|10485[0-6][0-9]|1048[0-4][0-9]{2}|104[0-7][0-9]{3}|10[0-3][0-9]{4}|[1-9][0-9]{1,5}|[1-9])C(?<c>1638[0-4]|163[0-7][0-9]|16[0-2][0-9]{2}|1[0-5][0-9]{3}|[1-9][1-9]{1,3}|[1-9])/;

  static RegexColumnChar = /XF[A-D]|XE[A-Z]|[A-W][A-Z]{2}|[A-Z]{1,2}/g;

  // 10 进制列序号与 26 进制互换
  /**
   *
   * @param {1-16384 之间的数字} digit
   * @param {是否启用大写字母;默认启用} capitalized
   * @returns
   */
  static Digit2ColChars(digit, capitalized = true) {
    if (digit > 0 && digit < 16385) {
      let codeList = [];
      const start = CODES[capitalized ? 'A' : 'a'] - 1;
      const divide = (digit, codeList) => {
        let mod = digit % 26;
        if (!mod) mod = 26;
        codeList.push(String.fromCharCode(start + mod));
        digit = (digit - mod) / 26;
        if (digit > 0) divide(digit, codeList);
      };
      divide(digit, codeList);
      return codeList.reduce((result, code) => {
        return `${code}${result}`;
      }, '');
    } else {
      throw new Error(
        `Range Error: please provide valid column index within [1~16384]! Wrong index: ${digit}`
      );
    }
  }
  /**
   *
   * @param {26 进制的字符串} chars
   * @param {是否启用大写字母;默认启用} capitalized
   * @returns
   */
  static ColChars2Digit(chars, capitalized = true) {
    const regex = Excel.RegexColumnChar;
    let result = chars.match(regex);
    if (result && result[0] === chars) {
      // 将字母逐个转为 ASCII 码中的索引
      const codeList = [];
      for (const char of chars) {
        let code = char.charCodeAt(0);
        // 比 A/a 小 1 位,比 Z/z 大 1 位
        let start = CODES[capitalized ? 'A' : 'a'] - 1,
          end = start + 27;
        if (code > start && code < end) {
          codeList.push(code - start);
        } else {
          throw new Error(
            `Range Error: please provide valid char within ${
              capitalized ? 'A-Z' : 'a-z'
            }! Wrong column char: ${char}`
          );
        }
      }
      // 将 ASCII 码索引转为 10 进制
      let digit = 0,
        i = codeList.length - 1;
      for (const code of codeList) {
        digit += 26 ** i-- * code;
      }
      return digit;
    } else {
      throw new Error(
        'Range Error: please provide column chars within [A~XFD]!'
      );
    }
  }

  // 统一格式化为对象数组 [{ r:1, c:1 }]
  static FromA1(range, basedIndex = true) {
    const regexCol = Excel.RegexColumnChar,
      regexRow = Excel.RegexRowIndex;

    const parseFrom = (range) => {
      const colChar = range.match(regexCol)[0];
      const r = range.match(regexRow)[0];
      if (colChar && r && colChar.length + r.length === range.length) {
        const c = Excel.ColChars2Digit(colChar);
        return basedIndex ? { r, c } : { r: r - 1, c: c - 1 };
      }
      return false;
    };

    return fromRangeStr(
      range,
      (range) => range.match(regexCol, regexRow).length > 1,
      parseFrom
    );
  }
  static FromR1C1(range, basedIndex = true) {
    const regex = Excel.RegexR1C1;

    const parseFrom = (range) => {
      const result = range.match(regex);
      if (result) {
        const { r, c } = result.groups;
        if (r && c) {
          return basedIndex ? { r, c } : { r: r - 1, c: c - 1 };
        }
      }
      return false;
    };

    return fromRangeStr(
      range,
      (range) => range.match(regex).groups.length > 1,
      parseFrom
    );
  }

  /**
   * 从对象重新格式化为 A1 或 R1C1
   * @param {参数格式为: { s: {r:1, c:1}, e: {r:2, c:2} }} range
   */
  static ToA1(range) {
    return toRangeStr(range, (r, c) => `${Excel.Digit2ColChars(c)}${r}`);
  }
  static ToR1C1(range) {
    return toRangeStr(range, (r, c) => `R${r}C${c}`);
  }

  // A1 与 R1C1 格式互换
  static A1ToR1C1(range) {
    return Excel.ToR1C1(Excel.FromA1(range));
  }
  static R1C1ToA1(range) {
    return Excel.ToA1(Excel.FromR1C1(range));
  }
}
————————

简介

XLSX 插件默认提供的都是一些函数,或者是对象下的函数,使用起来多少有些不便,因此,将插件的导出二次封装为 Book 类,并将部分功能重新组合,使之更便于使用。
适用版本:XLSX 0.18.5

已封装的功能介绍

  • 新建 WorkBook 对象(单张表): const book = new Book({ data: excelData, sheetName: ‘表1’ });
  • 新建 WorkBook 对象(多张表):const book = new Book([
    { data: this.excelData, sheetName: ‘表1’ },
    { data: this.excelData, sheetName: ‘表2’ },
    ]);

  • 新建 WorkSheet 对象:let sheet = book.createSheet(this.excelData, sheetOptions);
  • 添加 WorkSheet :book.appendSheet(sheet, ‘表2’);
  • 新建并添加 WorkSheet :book.addSheet(‘表3’, this.excelData, sheetOptions);
  • 获取 WorkSheet 对象,提供了两种方式:

    一种与 XLSX 相同,通过 book.Sheets[sheetName] 访问
    另一种是重新封装的 book.getSheets(sheetIdentity, basedIndex),第一个参数可以提供表名字符串或者表的索引,第二个参数指示索引从 0 或是 1 开始,默认从 1 开始

  • 一种与 XLSX 相同,通过 book.Sheets[sheetName] 访问
  • 另一种是重新封装的 book.getSheets(sheetIdentity, basedIndex),第一个参数可以提供表名字符串或者表的索引,第二个参数指示索引从 0 或是 1 开始,默认从 1 开始
  • 自定义表头文案:添加了 headers 属性后,默认忽略原表头(sheetOptions.skipHeader = true);注意此处只是忽略原表头并替换为新表头,并不是删除表头const book = new Book(
    { data: this.excelData, sheetName: ‘表1’ },
    {
    headers: {
    id: ‘编号’,
    age: ‘年龄’,
    gender: ‘性别’,
    },
    }
    );

  • 合并单元格:支持多种形式表示合并区域:对象形式、数组形式,合并区域的内容可以直接传字符串或者符合 XLSX 插件的单元格对象形式 { t, v, l, f s, …}const book = new Book({
    data: this.excelData,
    sheetName: ‘表1’,
    options: {
    // 双对象,对象属性为数组
    // merges: {
    // ranges: [this.mergeRange],
    // contents: [{ v: ‘111’ }],
    // },
    // 双对象,对象属性为对象
    // merges: {
    // ranges: {
    // 1: this.mergeRange,
    // },
    // contents: {
    // 1: { v: ‘111’ },
    // },
    // },
    // 数组,数组元素为单个合并区域
    merges: [
    {
    range: this.mergeRange,
    content: ‘111’,
    // content: { v: ‘111’ },
    },
    ],
    origin: ‘A3’, // 注意该属性还是手动添加;或者可以自行添加一段逻辑,将最高的合并单元格的下一行作为数据的起始行
    },
    });

  • 导出到本地: book.writeFile(‘info’); ;第二个参数为扩展名,默认 ‘xlsx’,可以修改

公共方法:

  • 列序号与字母转换:Excel.Digit2ColChars(digit, capitalized)、 Excel.ColChars2Digit(chars, capitalized)
  • 从 R1C1 或 A1 表示的字符串转换为对象形式 ( { s: { r: 1, c: 1} } ):Excel.FromA1(range, basedIndex)、Excel.FromR1C1(range, basedIndex)
  • 从对象形式转换为 R1C1 或 A1 形式:Excel.ToA1(range)、Excel.ToR1C1(range)’

几个常用的正则规则:

  • 行号有效范围 Excel.RegexRowIndex = /104857[0-6]|10485[0-6][0-9]|1048[0-4][0-9]{2}|104[0-7][0-9]{3}|10[0-3][0-9]{4}|[1-9][0-9]{1,5}|[1-9]/
  • 列字母有效范围:Excel.RegexColumnChar = /XF[A-D]|XE[A-Z]|[A-W][A-Z]{2}|[A-Z]{1,2}/g;
  • R1C1 有效范围:Excel.RegexR1C1 = /R(?104857[0-6]|10485[0-6][0-9]|1048[0-4][0-9]{2}|104[0-7][0-9]{3}|10[0-3][0-9]{4}|[1-9][0-9]{1,5}|[1-9])C(?1638[0-4]|163[0-7][0-9]|16[0-2][0-9]{2}|1[0-5][0-9]{3}|[1-9][1-9]{1,3}|[1-9])/;

data 对象示例:

excelData = [
  {
    id: 1,
    age: 17,
    gender: 'male',
  },
  {
    id: 2,
    age: 20,
    gender: 'male',
  },
  {
    id: 3,
    age: 18,
    gender: 'female',
  },
],

实现

外部依赖

import * as XLSX from 'xlsx';
import { isEmpty } from '../validator/jsValidator'; // 自定义的验证对象是否问空的方法,对象必须带自有属性,才会返回 true;空对象返回 false

const { utils } = XLSX;

基本结构

class Book {
  _book = null; // 将 XLSX 插件生成的 book 对象作为当前 Book 类实例的一个属性,避免部分函数使用时还需要将 book 作为参数传递
  _basedIndex = false; // 为真表示 Sheets 索引从 1 开始

  /**
   *
   * @param {包含 data, sheetName 和 options 的对象,或由对象组成的数组} params
   * @param {所有 WorkSheet 的默认配置,且以各 WorkSheet 的独立配置为优先} defaultOptions
   */
  constructor(params, defaultOptions) {
    this.book = utils.book_new();
    this._initBook(params, defaultOptions);
  }

  get Sheets() {
    return this._book.Sheets;
  }
  ...
}

初始化

表格数据会交由 initBook 方法,创建 WorkSheet 对象并添加到 XLSX 插件生成的 book 对象

  _initBook(params, defaultOptions = {}) {
    // 初始化 WorkBook
    this._basedIndex = defaultOptions.indexBase;
    // 初始化 WorkSheet
    if (params instanceof Array) {
      Object.values(params).forEach((param) => this._initSheet(param));
    } else if (params instanceof Object) {
      this._initSheet(params);
    } else {
      throw new Error('Type Error: params should be a object or an array');
    }
  }

  _initSheet({ data, sheetName, options }) {
    let sheetData = [].concat(data);
    let sheetOptions = options || defaultOptions;
    const headers = options.headers;
    // 自定义表头文本
    if (!isEmpty(headers)) {
      sheetData.unshift(headers);
      sheetOptions.skipHeader = true; // 自定义表头时默认忽略原表头
    }
    const sheet = this.createSheet(sheetData, sheetOptions);
    this.appendSheet(sheet, sheetName);
  }

创建表格、添加表格

  createSheet(data, options = {}) {
    if (data?.length > 0) {
      let sheet = utils.json_to_sheet(data, options);

      const { merges, origin } = options;
      if (merges) {
        // 将 merges 参数统一为两个数组
        const { mergeRanges, mergeContents } = this._normalizeMerges(merges);
        if (origin) {
          // 将字符串表示的合并区域转为对象形式
          const mergeConfigs = this._setupMergeConfigs(mergeRanges);
          this._setMergeConfigs(sheet, mergeConfigs);
          Object.entries(mergeConfigs).forEach(([i, config]) => {
            let { r: sr, c: sc } = config.s,
              { r: er, c: ec } = config.e;
            // 由于 mergeConfigs 中将开始索引设置为 0,此处需要重新加上
            const s = Excel.ToA1({ s: { r: sr + 1, c: sc + 1 } }),
              e = Excel.ToA1({ s: { r: er + 1, c: ec + 1 } });
            const content = mergeContents[i];
            if (content) {
              if (!content.t) {
                this.setSheetContent(sheet, s, { v: content });
              } else {
                this.setSheetContent(sheet, s, content);
              }
            } else {
              throw new Error(
                `Reference Error: content for mergeRange ${s}:${e} is empty!`
              );
            }
          });
        } else {
          throw new Error(
            'Reference Error: options.origin is necessary when using merge feature!'
          );
        }
      }
      return sheet;
    }
    return null;
  }

  appendSheet(sheet, sheetName) {
    if (!sheet) {
      throw new Error('Fail to append, sheet is empty');
    } else {
      let book = this._book;
      utils.book_append_sheet(book, sheet, sheetName);
    }
  }

  // 一步添加新表
  addSheet(sheetName, data, options) {
    const sheet = this.createSheet(data, options);
    this.appendSheet(sheet, sheetName);
  }

获取表格对象

  /**
   * 
   * @param {表的索引} index
   * @param {表的开始索引,默认为 true,表示从 1 开始;不影响内存中的表数组} basedIndex
   * @returns
   */
  getSheetNameByIndex(index, basedIndex = true) {
    const book = this._book;
    if (index < 0 || index > book.SheetNames.length) {
      throw new Error(`Range Error: sheet index exceeded!`);
    } else {
      return book.SheetNames[index - basedIndex];
    }
  }

  /**
   *
   * @param {表的索引或表名的字符串} sheetIdentity
   * @param {同上} basedIndex
   * @returns Sheet 对象
   */
  getSheet(sheetIdentity, basedIndex) {
    switch (typeof sheetIdentity) {
      case 'number':
        sheetIdentity = this.getSheetNameByIndex(sheetIdentity, basedIndex);
      case 'string':
        return this.Sheets[sheetIdentity];
      default:
        throw new Error(
          'Type Error: please provide a WorkSheet index or WorkSheet name string'
        );
    }
  }

设置表格内容

  /**
   *
   * @param {同上} sheetIdentity
   * @param {单元格区域,R1C1 或 A1 表示} range
   * @param {字符串或符合 XLSX 单元格对象的格式} content
   * @param {同上} basedIndex
   */
  setSheetContent(sheetIdentity, range, content, basedIndex) {
    if (sheetIdentity instanceof Object) {
      sheetIdentity[range] = content;
    } else {
      let sheet = this.getSheet(sheetIdentity, basedIndex);
      sheet[range] = content;
    }
  }

合并单元格

  // 将所有形式提供的 merges 参数统一为两个数组:mergeRanges 和 mergeContents
  _normalizeMerges(merges) {
    let mergeRanges = [],
      mergeContents = [];
    if (merges) {
      const { ranges, contents } = merges;
      if (ranges) {
        if (ranges instanceof Array) {
          // 双对象形式
          /*
            merges: {
              ranges: ['R1C1:R2C2'],
              contents: ['111'],
            },
          */

          if (ranges.length > 0 && contents.length > 0) {
            if (ranges.length === contents.length) {
              mergeRanges = [].concat(ranges);
              mergeContents = [].concat(contents);
            } else {
              throw new Error(
                `Reference Error: the ranges count doesn't correspond to contents`
              );
            }
          } else {
            console.warn(
              'Merge ranges or contents are empty, please check the "options.merges'
            );
          }
        } else if (ranges instanceof Object) {
          // 类似 el-form 验证功能的 model 和 rules 对象格式
          /* merges: {
              ranges: {
                1: this.mergeRange,
              },
              contents: {
                1: '111',
              },
            }
          */

          Object.entries(ranges).forEach(([key, range]) => {
            const content = contents[key];
            if (content) {
              mergeRanges.push(range);
              mergeContents.push(content);
            } else {
              throw new Error(
                `Reference Error: content for merge range ${range} is empty`
              );
            }
          });
        } else {
          throw new Error(`Type Error: ranges must be an Array or a Object!`);
        }
      } else {
        // 数组形式,数组元素为单个合并区域及内容
        /* merges: [
            {
              range: 'A1:A3',
              content: '表格标题',
            },
          ],
        */

        Object.values(merges).forEach(({ range, content }) => {
          if (range && content) {
            mergeRanges.push(range);
            mergeContents.push(content);
          } else {
            const tag = range ? 'content' : 'range',
              reverseTag = { content: 'range', range: 'content' };

            const reverse = { content: range, range: content };
            throw new Error(
              `Reference Error: ${tag} is empty where ${reverseTag[tag]} 'is' ${reverse[tag]}`
            );
          }
        });
      }
      return { mergeRanges, mergeContents };
    } else {
      throw new Error(`Reference Error: merges are empty`);
    }
  }
  // 合并单元格
  _setMergeConfigs(sheet, mergeConfigs) {
    if (sheet) {
      if (mergeConfigs) {
        sheet['!merges'] = mergeConfigs;
      } else {
        throw new Error(`Reference Error: mergeConfigs can no be empty!`);
      }
    } else {
      throw new Error(`Reference Error: sheet can no be empty!`);
    }
  }
  // 参数为数组;range 格式:R1C1:R2C2 或 A1:B2
  _setupMergeConfigs(mergeRanges) {
    if (mergeRanges.length > 0) {
      const regex = Excel.RegexR1C1;
      const result = mergeRanges[0].match(regex);
      let setupMergeConfig = () => {};
      if (result.length === 1) {
        throw new Error(
          `Formatting Error: please use a pair of range expressions with R1C1 or A1 from start cell to end cell`
        );
      } else if (result.length === 0) {
        setupMergeConfig = Excel.FromA1;
      } else {
        // result.length === 2
        setupMergeConfig = Excel.FromR1C1;
      }
      let mergeConfigs = [];
      Object.values(mergeRanges).forEach((mergeRange) => {
        // 注意这里的开始索引是 0,因为 XLSX 插件内部保存的单元格区域是 js 对象,索引必须从 0 开始
        mergeConfigs.push(setupMergeConfig.call(null, mergeRange, false));
      });
      return mergeConfigs;
    } else {
      throw new Error(`Reference Error: mergeRanges can no be empty!`);
    }
  }

导出 Excel

  writeFile(fileName, extension = 'xlsx') {
    return new Promise((res, rej) => {
      if (this._book.SheetNames.length > 0) {
        if (fileName.indexOf('xls') > -1) {
          rej('Parameter filename should not contain extension');
        } else {
          try {
            XLSX.writeFile(this._book, `${fileName}.${extension}`);
            res(true);
          } catch (err) {
            rej(err);
          }
        }
      } else {
        rej('Can not write workbook with empty sheets');
      }
    });
  }

公共方法和属性

内部公共方法和属性

const CODES = {
  A: 65,
  a: 97,
};

function fromRangeStr(range, isCellFormatValid, cbParseFrom) {
  const parseFrom = (range) => {
    let result = cbParseFrom(range);
    if (result) {
      return result;
    } else {
      // 列序号超范围时 groups 为空,解构会报错;序号前包含前导 0 或其他非法字符也会报错
      throw new Error(
        'Range Error: row or column format invalid or index exceeded!'
      );
    }
  };

  if (range.indexOf(':') > -1) {
    const [s, e] = range.split(':');
    return { s: parseFrom(s), e: parseFrom(e) };
  } else {
    if (isCellFormatValid(range)) {
      throw new Error(
        `Formatting Error: merge ranges should contain a separator ":". You can only ignore it in cell`
      );
    } else {
      return { s: parseFrom(range) };
    }
  }
}
/**
 *
 * @param {对象格式 { s:{ r:1, c:1 }, e: { r:1, c:1 }}} range
 * @param {(r,c): string; 返回 R1C1 或 A1 格式的字符串} cbToCell
 * @returns
 */
function toRangeStr(range, cbToCell) {
  const toCell = (cellRange) => {
    const { r, c } = cellRange;
    return cbToCell(r, c);
  };

  if (isEmpty(range.e)) {
    return toCell(range.s);
  } else {
    return toCell(range.s) + ':' + toCell(range.e);
  }
}

可导出的公共方法和属性

export class Excel {
  // 这里的正则为了匹配尽量长的字符串(代表更大的数字),因此将能匹配到较大结果的规则写在 `|` 左侧,较短的写在右侧
  static RegexRowIndex =
    /104857[0-6]|10485[0-6][0-9]|1048[0-4][0-9]{2}|104[0-7][0-9]{3}|10[0-3][0-9]{4}|[1-9][0-9]{1,5}|[1-9]/;

  // 这里使用了命名捕获组,因此不能添加 g 标志, g 标志会导致分组丢失,不过会返回所有的匹配项;是否使用 g 根据实际需求选择
  static RegexR1C1 =
    /R(?<r>104857[0-6]|10485[0-6][0-9]|1048[0-4][0-9]{2}|104[0-7][0-9]{3}|10[0-3][0-9]{4}|[1-9][0-9]{1,5}|[1-9])C(?<c>1638[0-4]|163[0-7][0-9]|16[0-2][0-9]{2}|1[0-5][0-9]{3}|[1-9][1-9]{1,3}|[1-9])/;

  static RegexColumnChar = /XF[A-D]|XE[A-Z]|[A-W][A-Z]{2}|[A-Z]{1,2}/g;

  // 10 进制列序号与 26 进制互换
  /**
   *
   * @param {1-16384 之间的数字} digit
   * @param {是否启用大写字母;默认启用} capitalized
   * @returns
   */
  static Digit2ColChars(digit, capitalized = true) {
    if (digit > 0 && digit < 16385) {
      let codeList = [];
      const start = CODES[capitalized ? 'A' : 'a'] - 1;
      const divide = (digit, codeList) => {
        let mod = digit % 26;
        if (!mod) mod = 26;
        codeList.push(String.fromCharCode(start + mod));
        digit = (digit - mod) / 26;
        if (digit > 0) divide(digit, codeList);
      };
      divide(digit, codeList);
      return codeList.reduce((result, code) => {
        return `${code}${result}`;
      }, '');
    } else {
      throw new Error(
        `Range Error: please provide valid column index within [1~16384]! Wrong index: ${digit}`
      );
    }
  }
  /**
   *
   * @param {26 进制的字符串} chars
   * @param {是否启用大写字母;默认启用} capitalized
   * @returns
   */
  static ColChars2Digit(chars, capitalized = true) {
    const regex = Excel.RegexColumnChar;
    let result = chars.match(regex);
    if (result && result[0] === chars) {
      // 将字母逐个转为 ASCII 码中的索引
      const codeList = [];
      for (const char of chars) {
        let code = char.charCodeAt(0);
        // 比 A/a 小 1 位,比 Z/z 大 1 位
        let start = CODES[capitalized ? 'A' : 'a'] - 1,
          end = start + 27;
        if (code > start && code < end) {
          codeList.push(code - start);
        } else {
          throw new Error(
            `Range Error: please provide valid char within ${
              capitalized ? 'A-Z' : 'a-z'
            }! Wrong column char: ${char}`
          );
        }
      }
      // 将 ASCII 码索引转为 10 进制
      let digit = 0,
        i = codeList.length - 1;
      for (const code of codeList) {
        digit += 26 ** i-- * code;
      }
      return digit;
    } else {
      throw new Error(
        'Range Error: please provide column chars within [A~XFD]!'
      );
    }
  }

  // 统一格式化为对象数组 [{ r:1, c:1 }]
  static FromA1(range, basedIndex = true) {
    const regexCol = Excel.RegexColumnChar,
      regexRow = Excel.RegexRowIndex;

    const parseFrom = (range) => {
      const colChar = range.match(regexCol)[0];
      const r = range.match(regexRow)[0];
      if (colChar && r && colChar.length + r.length === range.length) {
        const c = Excel.ColChars2Digit(colChar);
        return basedIndex ? { r, c } : { r: r - 1, c: c - 1 };
      }
      return false;
    };

    return fromRangeStr(
      range,
      (range) => range.match(regexCol, regexRow).length > 1,
      parseFrom
    );
  }
  static FromR1C1(range, basedIndex = true) {
    const regex = Excel.RegexR1C1;

    const parseFrom = (range) => {
      const result = range.match(regex);
      if (result) {
        const { r, c } = result.groups;
        if (r && c) {
          return basedIndex ? { r, c } : { r: r - 1, c: c - 1 };
        }
      }
      return false;
    };

    return fromRangeStr(
      range,
      (range) => range.match(regex).groups.length > 1,
      parseFrom
    );
  }

  /**
   * 从对象重新格式化为 A1 或 R1C1
   * @param {参数格式为: { s: {r:1, c:1}, e: {r:2, c:2} }} range
   */
  static ToA1(range) {
    return toRangeStr(range, (r, c) => `${Excel.Digit2ColChars(c)}${r}`);
  }
  static ToR1C1(range) {
    return toRangeStr(range, (r, c) => `R${r}C${c}`);
  }

  // A1 与 R1C1 格式互换
  static A1ToR1C1(range) {
    return Excel.ToR1C1(Excel.FromA1(range));
  }
  static R1C1ToA1(range) {
    return Excel.ToA1(Excel.FromR1C1(range));
  }
}