最近接到个报表的需求,需要展示合并单元格的表格,并且还要支持导出excel。粗略一看,不就两个功能嘛,但是在实现的过程中发现其实要做的还是挺多的,所以在这里记录分享一下。

一、合并单元格的实现

合并单元格这个功能的实现用的是antd的table组件。下面是antd-table组件行列合并功能的使用介绍:

表格支持行/列合并,使用 render 里的单元格属性 colSpan 或者 rowSpan 设值为 0 时,设置的表格不会渲染。

光看这句话好像还不太好理解,看下官方例子的代码就好理解多了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
title: 'Name',
dataIndex: 'name',
render: (text, row, index) => {
const obj = {
children: text,
props: {},
};
if (index === 2) {
obj.props.rowSpan = 2;
}
// These two are merged into above cell
if (index === 3) {
obj.props.rowSpan = 0;
}
return obj;
},
},

我来解读一下上面这一项的意思:当 index === 2,把这一行的 rowSpan 设置为2,也就是第三条数据的时候,这一个单元格需要占两格,那么对应的它后面的一行,也就是 index === 3 这一行就只能占0格了,也就是需要把index === 3时候的 rowSpan设置为0

了解了antd-table组件怎么设置单元格合并,下面就可以开始实现了。无非就是计算一下每一列里,每一项出现的次数,然后再设置下 rowSpan 的值就好了。但是在开始计算之前,还需要做一些准备工作:

  • 后端在返回数据的时候是通过 树形结构 返回的,而渲染表格用到的数据是 数组 的形式,所以我需要手动先转化一遍数据;
  • 其次还要对数据按照表格每一列来排一次序,至于为什么排序,后面再说

1. 处理原始数据

后端返回的数据是这样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const mockData = [{
children: [{
children: [{
children: [],
rate: 0.3333333333333333,
name: '需求1',
cost: 3,
id: 5,
projectId: 1,
projectName: 'test1',
assigner: '张三',
}],
name: '迭代一',
rate: 0.75,
cost: 9,
id: 2,
}],
name: '项目一',
cost: 12,
id: 1,
}];

上面的数据层级有三级,表示的含义就是表格至少会有三列,而且前三列的数据是需要做合并单元格操作的。树形结构数据首先就想到了用递归的方式,因此我需要通过递归来把这种类型的数据给拍平成数组,并且拿到每一级的信息。下面是我拍平之后的结果(firstColName、secondColName、thirdColName 就是表格的列标题,也可以是表格每一列的dataIndex字段,这里为了省事,我直接拿列标题来用了)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const flatMockData = [
{
firstColName: {
name: '项目一',
cost: 12,
id: 1,
children: //...
},
secondColName: {
name: '迭代一',
rate: 0.75,
cost: 9,
id: 2,
children: //...
},
thirdColName: {
rate: 0.3333333333333333,
name: '需求一',
cost: 3,
id: 5,
projectId: 1,
projectName: 'test1',
assigner: '张三',
children: //...,
}
},
]

要让 树形结构每一级表格的每一列 对应起来,原始数据肯定是不够的,还需要额外一个参数来表示每一级的深度,这样我就能把每一级的数据和表格列名对应起来。

1.1 树形结构数据加上层级参数

这一步很好实现,一个递归就好了,直接上代码。

1
2
3
4
5
6
7
8
9
10
function addDeepsToTreeData(
data: ITreeDataItem[],
depsNum: number = 0,
): ITreeDataDepsItem[] {
return data.map(item => ({
...item,
__deps: depsNum, // 表示层级的参数
children: addDeepsToTreeData(item.children, depsNum + 1),
}));
}

1.2 拍平树形结构数据

因为之前我们已经给树形结构的每一层都加了表示层级的参数,这样做就是为了在拍平数据这一步的时候能很快和表格的列名对应起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const colNameList = ['firstColName', 'secondColName', 'thirdColName'];

function getFlatData(data: ITreeDepsDataItem[], colNameList: string[]) {
let array: { [k: string]: ITreeDepsDataItem }[] = [];
function convert(
data: ITreeDepsDataItem[],
parentItem: { [k: string]: any } = {}, // 上一级的信息
) {
_.forEach(data, item => {
if (item.children && item.children.length !== 0) {
if (parentItem) {
// 如果自己的children不为空,并且有上几级信息
// 就加上自己这一级的信息,继续往下传递
convert(item.children, {
...parentItem,
[colNameList[item.__deps]]: item,
});
} else {
// 如果自己的children不为空 ,但是没有上几级的信息没有上一级信息
// 就把自己这一级的信息传递下去
convert(item.children, { [colNameList[item.__deps]]: item });
}
} else {
// children为空说明走到最后一级了
// 这时,表格一行里所有列的数据都获取到了,就push到临时数组里
array.push({ ...parentItem,[colNameList[item.__deps]]: item, });
}
});
}
convert(data);
return array;
};

1.3 排序拍平之后的数组

比如最后的表格有三列需要合并单元格,每一行就要根据这三列综合来排序;最后的表格有n列需要合并单元格,那么每一行就要根据这n列综合来排序,这样做是为了保证合并的都是重复出现的单元格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 获取单个条件的排序函数
type BooleanFn<T> = (x: T, y: T) => boolean;
function getSort<T>(fn: BooleanFn<T>) {
return function(a: T, b: T) {
let ret = 0;
if (fn.call(this, a, b)) {
ret = -1;
} else if (fn.call(this, b, a)) {
ret = 1;
}
return ret;
};
}

// 获取多个条件的排序函数
type NumberFn<T> = (x: T, y: T) => number;
function getMutipSort<T>(arr: NumberFn<T>[]) {
return function(a: T, b: T) {
let tmp, i = 0;
do {
tmp = arr[i++](a, b);
} while (tmp == 0 && i < arr.length);
return tmp;
};
}

// 根据多个条件排序数据
function getSortableData(flatData: { [k: string]: ITreeDepsDataItem }[],colNameList: string[]) {
const sortableData = _.cloneDeep(flatData);
const sortArr = colNameList.map(item =>
getSort<{ [k: string]: ITreeDepsDataItem }>((a, b) => {
if (a[item] && b[item]) {
return a[item].name.toUpperCase() < b[item].name.toUpperCase();
}
return false;
}),
);
sortableData.sort(getMutipSort(sortArr));
return sortableData;
}

到这里,数据就已经处理好了。下面就是计算每一行下面每一个单元格出现的次数

2. 计算每一行里每个单元格出现的次数

循环排好序的数据,计算一行里所有单元格的重复次数(以 ${name}_${id} 作为数据的唯一标识,只用 id 做唯一标识也可以),并把结果存在以 这一行里第一个单元格数据的唯一标识 作为key的对象中。这句话可能有点不好理解,所以我用下面的数据来解释下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 假设这是已经排好序的数据
const sortableData = [{
firstColName: { name: '项目一', id: 1, cost: 12, children: [] },
secondColName: { name: '迭代一', id: 2, rate: 0.75, cost: 9, children: [] },
thirdColName: { name: '需求一', id: 5, rate: 0.25, cost: 3, projectId: 1, projectName: '项目一', assigner: '张三', children: [], }
},{
firstColName: { name: '项目一', id: 1, cost: 12, children: [] },
secondColName: { name: '迭代一', id: 2, rate: 0.75, cost: 9, children: [] },
thirdColName: { name: '需求二', id: 6, rate: 0.25, cost: 3, projectId: 1, projectName: '项目一', assigner: '李四', children: [], }
}];

// 计算之后的重复次数就是这样
// 这个重复出现的次数就是后面 render 函数返回的 rowSpan 的值。
const cellRepetitions = {
项目一_1: {
项目一_1: 2,
迭代一_2: 2,
需求一_5: 1,
需求二_6: 1,
}
}

下面是计算重复次数的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 获取每一组数据里重复出现的数据的次数
type IObjNumber = { [k: string]: number };
function getCellRepetitions(sortableData:{ [k: string]: ITreeDepsDataItem }[], colNameList: string[]) {
// 表格第一列的列名
const parentColName = colNameList[0];
const cellRepetitions: {
[k: string]: IObjNumber | number;
} = {};

// 循环排好序的数据,item代表每一行
_.forEach(sortableData, item => {
const parentColVal = `${item[parentColName].name}_${item[parentColName].id}`;
if (!cellRepetitions[parentColVal]) {
cellRepetitions[parentColVal] = {};
}
// 循环列名,item[col]代表这一行里每一个单元格
_.forEach(colNameList, col => {
if (item[col]) {
const colValue = `${item[col].name}_${item[col].id}`;
cellRepetitions[`__dot_${col}`] = 0; //后面在render的时候会用到的标志位
if ((cellRepetitions[parentColVal] as IObjNumber)[colValue]) {
(cellRepetitions[parentColVal] as IObjNumber)[colValue]++;
} else {
(cellRepetitions[parentColVal] as IObjNumber)[colValue] = 1;
}
}
});
});
return cellRepetitions;
}

3. render函数

计算好了重复次数之后,下面就可以写列的 render 函数了。

1
2
3
4
5
6
7
8
9
10
// 以下是使用自己封装的 renderContent 的例子
// renderContent 函数接收三个参数
// 第一个参数是 antd-table 原始render函数的所有参数
// 第二个参数是列名
// 第三个参数为可选参数,不传的话默认返回name字段的值,传了的话就可以自定义返回内容
{
name: 'colName',
dataIndex: 'colName',
render: (...rest) => renderContent(rest, 'colName'),
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 获取表格每项的render函数
function getRenderContent(cellRepetitions: { [k: string]: IObjNumber | number; }, colNameList: string[]) {
const parentColName = colNameList[0];
// 这里需要实时修改前面留下的 __dot标志位,所以先拷贝一份数据
const countObj = _.cloneDeep(cellRepetitions);
const renderContent: IRenderContent = (
rest: any,
name: string,
userRenderText?: IRenderText, // 自定义表格返回什么格式的数据
) => {
const [v, row, index] = rest;
const textForRender = userRenderText ? userRenderText(row) : v.name;
const obj: {
children: string;
props: { rowSpan?: number };
} = {
children: textForRender,
props: {},
};
// 有 v 说明是需要合并的列
if (v) {
const value = `${v.name}_${v.id}`;
const parentColVal = `${row[parentColName].name}_${row[parentColName].id}`;
if (countObj[parentColVal]) {
/**
* index表示第几行的数据
* 当 行数 等于 这一列的标志位 的时候,说明这一个单元格是需要被显示的
* 所以把这一个单元格的 rowspan 设置为它的重复次数
*
* 并且这时更新标志位的数值,加上当前单元格的重复次数
* 后面当 行数 小于 这一列标志位 的时候,说明这一个单元格是需要被隐藏的,
* 这时把 rowSpan 设置为 0
*/
if (index === countObj[`__dot_${name}`]) {
obj.props.rowSpan = (countObj[parentColVal] as IObjNumber)[value];
(countObj[`__dot_${name}`] as number) += (countObj[
parentColVal
] as IObjNumber)[value];
} else if (index < countObj[`__dot_${name}`]) {
obj.props.rowSpan = 0;
}
}
}
return obj;
};
return renderContent;
}

以上,终于把表格合并单元格功能给完成了。下面就可以愉快的开始想怎么解决导出excel了。

二、 导出excel功能的实现

导出excel功能使用的是 SheetJS 这个库。

并且参考了这位大佬的文章 http://blog.haoji.me/js-excel.html

遇到的坑点就是,js-xlsx 默认不支持设置样式。但是经过一番查找,发现了一个叫 xlsx-style 的库,使用这个库就可以给导出的excel设置样式。

通过dom节点导出

具体的使用方式如下:

  1. 下载 js-xlsx/dist/xlsx.full.min.js 到项目中;
  2. 下载 xlsx-style/dist/xlsx.full.min.js 到项目中;
  3. 修改 xlsx-style/dist/xlsx.full.min.js 中的 XLSX 变量 为XLSX_STYLE,因为两个文件都是默认设置全局变量 XLSX,而我们只需要在最后导出的时候使用 xlsx-style 提供的方法,而其他工具方法还是使用 js-xlsx 中的;
  4. 在项目的index.html中引入这两个文件;
  5. js-xlsx 支持从dom节点导出excel,所以方便起见直接用dom节点导出。

    1
    2
    const dom = document.querySelector('.ant-table-body');
    const sheets = XLSX.utils.table_to_book(dom).Sheets.Sheet1;
  6. 给 sheet 设置样式,下面给出我的设置作为参考,更多设置请自行参考 xlsx-style#cell-styles

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    function setSheetStyle(sheet: ISheet, colNameList:string[]) {
    // 设置列宽
    sheet['!cols'] = colNameList.map(item => ({ wpx: 200 }));
    Object.keys(sheet).forEach(key => {
    if (typeof sheet[key] === 'object') {
    // 第一行是标题行
    if (/^[A-Z]+1$/.test(key)) {
    sheet[key].s = {
    font: {
    sz: 18, // 字体大小为18px
    bold: true,// 加粗
    },
    alignment: {
    vertical: 'center', // 垂直居中
    wrapText: true, // 自动换行
    },
    };
    } else {
    sheet[key].s = {
    alignment: {
    vertical: 'center',
    wrapText: true,
    },
    };
    }
    }
    });
    }
  7. 在 sheet2blob 这个方法中 调用 XLSX_STYLE.write 方法来创建 workbook,上面一步设置的样式才会生效。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    function sheet2blob(sheet: ISheet, name?: string) {
    const sheetName = name || 'sheet1';
    const workbook: {
    SheetNames: string[];
    Sheets: { [k: string]: any }; //多个sheet
    } = {
    SheetNames: [sheetName],
    Sheets: {},
    };
    workbook.Sheets[sheetName] = sheet;
    // 生成excel的配置项
    var wopts = {
    bookType: 'xlsx', // 要生成的文件类型
    bookSST: false, // 是否生成Shared String Table,官方解释是,如果开启生成速度会下降,但在低版本IOS设备上有更好的兼容性
    type: 'binary',
    };
    // 这里调用 XLSX_STYLE 的 write 方法
    const wbout = XLSX_STYLE.write(workbook, wopts);
    const blob = new Blob([s2ab(wbout)], { type: 'application/octet-stream' });
    // 字符串转ArrayBuffer
    function s2ab(s: any) {
    const buf = new ArrayBuffer(s.length);
    const view = new Uint8Array(buf);
    for (let i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xff;
    return buf;
    }
    return blob;
    }
  8. 导出excel文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    /**
    * 通用的打开下载对话框方法,没有测试过具体兼容性
    * @param url 下载地址,也可以是一个blob对象,必选
    * @param saveName 保存文件名,可选
    */
    function openDownloadDialog(url: string | Blob, saveName?: string) {
    if (typeof url == 'object' && url instanceof Blob) {
    url = URL.createObjectURL(url); // 创建blob地址
    }
    let aLink = document.createElement('a');
    aLink.href = url;
    aLink.download = saveName || ''; // HTML5新增的属性,指定保存文件名,可以不要后缀,注意,file:///模式下不会生效
    let event;
    if (window.MouseEvent) {
    event = new MouseEvent('click');
    } else {
    event = document.createEvent('MouseEvents');
    event.initMouseEvent(
    'click',
    true,
    false,
    window,
    0,
    0,
    0,
    0,
    0,
    false,
    false,
    false,
    false,
    0,
    null,
    );
    }
    aLink.dispatchEvent(event);
    }

    function downloadExcelWithDom(dom: HTMLTableElement, saveName?: string) {
    const fileName = `${saveName}.xlsx` || '导出.xlsx';
    const sheet = XLSX.utils.table_to_book(dom).Sheets.Sheet1;
    setSheetStyle(sheet);
    openDownloadDialog(sheet2blob(sheet), fileName);
    }

以上就完成了导出excel功能。由于兼容性问题,有些浏览器上可能无法通过dom节点导出,这时候就只能自己设置数据来导出了。下面也讲讲怎么通过自己设置数据来导出。

通过数据导出

  1. 使用 XLSX.utils.aoa_to_sheet 方法来生成sheet,这个方法可以将一个二维数组转成sheet格式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    function getSheetData(sortableData:{ [k: string]: ITreeDepsDataItem }[], colNameList: string[]) {
    let aoa: string[][] = [];
    aoa.push(colNameList);
    sortableData.forEach(item => {
    aoa.push(
    // 这里只是简单的把单元格的内容设置为name属性
    // 如果需要自定义单元格内容的话,可以使用 table 组件里设置好的 columns
    colNameList.map(colName => {
    if (item[colName]) {
    return item[colName].name;
    } else {
    const lastColName = colNameList[Object.keys(item).length - 1];
    return (item[lastColName] as any)[colName] || '-';
    }
    }),
    );
    });
    return XLSX.utils.aoa_to_sheet(aoa);
    }
  2. 循环表格的列跟行,计算需要合并的单元格,核心逻辑和和前面说的 renderContent 方法是相同的。单元格的合并需要设置 sheet[!merges],格式如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 表示从 第0行第0列 到 第0行第2列 的单元格合并
    // 也就是第0行前三个单元格合并
    sheet[!merges] = [
    {
    // 表示start=
    s:{
    r: 0, // 表示row
    c: 0, // 表示col的
    },
    // 表示end
    e:{
    r: 0,
    c: 2
    }
    }
    ]

    下面是获取每一行需要合并的s和e

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    _.forEach(colNameList, (item, colIndex) => {
    _.forEach(sortableData, (row, rowIndex) => {
    // getRepeatNum 方法同 renderContent,repeat就相当于rowSpan
    const repeat = getRepeatNum(row[item], row, rowIndex, item);
    // 只对 repeat 大于1 的单元格处理
    // 并且这里列是固定的,只有行会发生合并,所以会简单一点
    if (repeat > 1) {
    sheetMerges.push({
    s: {
    r: rowIndex + 1, // 多了标题栏 所以要加 1
    c: colIndex,
    },
    e: {
    r: rowIndex + repeat - 1 + 1, // 多了标题栏 所以要加 1
    c: colIndex,
    },
    });
    }
    });
    });
  3. 最后 设置样式 和 导出 这两步和上面通过dom节点导出一样,不再赘述。