在前端开发中,我们常常需要将网页上的表格数据导出为 Excel 文件以便用户下载或进一步处理。本篇博客将展示如何使用一个自定义的
ExportExcelTool
类并使用xlsx-style
来实现 Vue.js 应用中的表格数据导出功能。
xlsx-style
安装
npm install xlsx-style
在自定义ExportExcelTool.js
中引入xlsx-style
/**
* @author 吴彦祖不想打工
* @date 2024-03-12
* @desciption: 用于嵌套表头的表格导出
*/
// import XLSX from 'xlsx-style'
// xlsx-style 的引入通常会出现各种问题
// 例如: ERROR: Could not resolve "./cptable"
// 具体解决方案可自行百度, 我这里直接将 xlsx-style 在 index.html 引入
// 如果引入失败可参考我的方法
/**
* @description: 边框样式
*/
const borderAll = {
top: {
style: 'thin',
},
bottom: {
style: 'thin'
},
left: {
style: 'thin'
},
right: {
style: 'thin'
}
}
// 完整代码在文章最后面
通常情况下根据你的vue版本或者构建工具的不同可能引入会出现一些问题,例如
ERROR: Could not resolve “./cptable” 等等。。。。总之就很难受热 ㄟ( ▔, ▔ )ㄏ
解决方法可自行百度,也可参考我的方法试下
- 找到下载的xlsx-style包中的
xlsx.full.min.js
文件,复制到public文件目录下
2. 直接在index.html
文件中引入,不需要再通过 import XLSX from 'xlsx-style'
引入,然后直接在需要用到XLSX的地方使用XLSX
即可
<script src="/xlsx.full.min.js"></script>
下面是相关实现
自定义
ExportExcelTool.js
实现,全部代码在文章结尾
主要用于从复杂的嵌套数据结构中提取数据并构建带有层级表头的 Excel 表格。通过传入配置项,如表格数据(tableData)、表头结构(tableHeader)、样式设置等,可以实现常见样式的 Excel 导出功能
类构造函数与选项配置
ExportExcelTool类的构造函数接受一系列选项参数,包括表格数据、表头结构、样式映射等信息。可以自定义这些选项以满足不同场景的需求:
-
tableData
: 需要导出的数据源数组。 必须/** * 例子 */ const data = [{ prop: "date", label: "日期", children: [], }, ...]
-
tableHeader
: 嵌套表头结构,描述了表格的列及其层级关系。 必须/** * 例子 */ const data = [{ date: '2016-05-03', name: '王小虎', province: '上海', city: '普陀区', address: '上海市普陀区金沙江路 1518 弄', zip: 200333 }, ...]
-
headerProps
: 表头属性映射对象。用于从表头结构中提取关键属性。如果你的数据属性值与默认值不同需要自行配置映射关系/** * 默认表头属性映射 * 用于从 tableHeader 对象中提取关键属性(如prop、label等) */ const defaultHeaderProps = { prop: 'prop', label: 'label', children: 'children' }
-
fileName
: 导出文件名,默认为"exportExcel"。默认值可根据需要调整 -
sheetName
: 工作表名称,默认为"Sheet1"。默认值可根据需要调整 -
cellStyle
: 表格基础样式配置。默认值可根据需要调整,其他样式属性可上xlsx-style官网查询/** * @description: 默认单元格样式 */ const defaultCellStyle = { /** * 表头区域样式配置 */ headerStyle: { border: borderAll, font: {name: '宋体', sz: 11, italic: false, underline: false, bold: true}, alignment: {vertical: 'center', horizontal: 'center'}, fill: {fgColor: {rgb: 'FFFFFF'}}, }, /** * 内容样式配置 */ dataStyle: { border: borderAll, font: {name: '宋体', sz: 11, italic: false, underline: false}, alignment: {vertical: 'center', horizontal: 'left', wrapText: true}, fill: {fgColor: {rgb: 'FFFFFF'}}, } } ** * @description: 边框样式 */ const borderAll = { top: { style: 'thin', }, bottom: { style: 'thin' }, left: { style: 'thin' }, right: { style: 'thin' } }
-
columnWidthsArray
: 列宽数组,按顺序设置每一列宽度,默认为空。默认值可根据需要调整例如 {0:200,2:200} 第一列宽度为200,第三列宽度为200 其余列默认
-
columnWidth
: 列宽默认值,默认100。默认值可根据需要调整 -
formatterValue
:数据格式化方法,用于在导出前对单元格内容进行处理。不需要做数据格式化或处理的话,可不传入/** * 例子 * @param value 值 * @param header 表头信息 */ formatterValue(value, header) { if (header.prop === 'date') { return value.replace(/-/g, "/") } return value }
核心方法解析
-
exportData 方法
这是整个类的核心方法,调用其他辅助方法完成数据提取、表头构建、单元格合并等一系列操作,并最终生成可下载的Excel文件。
-
flat 方法
此方法用于将嵌套的列表展平为一维数组。这对于处理具有嵌套子元素的复杂表头结构非常有用。
-
buildHeader 方法
构建Excel表头时,该方法会递归遍历嵌套表头结构,并根据headerProps生成符合Excel要求的一维数组形式的表头。
-
extractData 方法
该方法用于从原始数据中提取并转换成适应于Excel的二维数组形式。它会考虑到表头中的嵌套关系,确保数据正确对应到相应的列。
-
doMerges 方法
在生成工作表后,此方法负责计算需要合并的单元格区域,并生成相应的合并规则。
-
aoa_to_sheet 方法
将二维数组转换为符合xlsx库规范的工作表对象,同时在此过程中添加了行头和数据区域的样式设定。
-
辅助方法
还包含如s2ab方法,用于将字符串转换为ArrayBuffer;openDownloadXLSXDialog方法,用于打开浏览器的下载对话框让用户下载生成的Excel文件。
综上所述,通过自定义的这个类能够有效应对复杂嵌套表头的表格数据导出问题,提高了代码的复用。通过封装
xlsx-style
库的功能并结合自定义的样式、列宽和数据格式化逻辑,可以便捷地实现这一常见业务需求。
下面是简单使用
<script setup lang="ts">
import {reactive} from 'vue';
const state = reactive({
tableData: {
loading: false,
// 后端返回的表头数据
header: [
{
label: '基本信息',
children: [
{prop: 'name', label: '姓名'},
{prop: 'date', label: '入学日期'},
],
},
{
label: '联系方式',
children: [
{
prop: 'phone',
label: '电话'
},
{prop: 'email', label: '邮箱'},
],
},
{
label: '家庭地址',
children: [
{prop: 'province', label: '省份'},
{prop: 'city', label: '城市'},
{prop: 'street', label: '街道'},
{prop: 'zip', label: '邮编'},
],
},
],
// 具体数据
data: [{
date: '2016-09-01',
name: '李小明',
phone: '13800138000',
email: 'lixiaoming@example.com',
province: '上海',
city: '普陀区',
street: '金沙江路 1518 弄',
zip: 200333
}, {
date: '2016-09-02',
name: '张晓红',
phone: '13700137000',
email: 'zhangxiaohong@example.com',
province: '上海',
city: '普陀区',
street: '金沙江路 1518 弄',
zip: 200333
}, {
date: '2016-09-03',
name: '张晓丽',
phone: '13700137000',
email: 'zhangxiaoli@example.com',
province: '上海',
city: '普陀区',
street: '金沙江路 1518 弄',
zip: 200333
},
{
date: '2016-09-03',
name: '坤坤',
phone: '13700137000',
email: 'kunkun@example.com',
province: '上海',
city: '普陀区',
street: '金沙江路 1517 弄',
zip: 200333
}]
}
})
// 引入 ExportExcelTool
import ExportExcelTool from "./exportExcelTool";
// 导出 excel 方法,绑定按钮触发
const exportExcel = () => {
// 创建 exportExcelTool 对象
const exportExcelTool = new ExportExcelTool({
tableData: state.tableData.data, // 必须
tableHeader: state.tableData.header,// 必须
// 下面参数都有默认值,可选择修改
fileName: "学生信息表",
sheetName: "学生信息",
columnWidth: 80,
columnWidthsArray: {2: 150, 3: 200, 6: 200},
headerProps: {
prop: "prop",
label: "label",
children: "children",
},
cellStyle: {
/**
* 表头区域样式配置
*/
headerStyle: {
border: {top: {style: 'thin',}, bottom: {style: 'thin'}, left: {style: 'thin'}, right: {style: 'thin'}},
font: {name: '宋体', sz: 13, italic: false, underline: false, bold: true},
alignment: {vertical: 'center', horizontal: 'center'},
fill: {fgColor: {rgb: '719aad'}},
},
/**
* 内容样式配置
*/
dataStyle: {
border: {top: {style: 'thin',}, bottom: {style: 'thin'}, left: {style: 'thin'}, right: {style: 'thin'}},
font: {name: '宋体', sz: 11, italic: true, underline: false},
alignment: {vertical: 'center', horizontal: 'left', wrapText: true},
fill: {fgColor: {rgb: 'ceffca'}},
}
},
/**
* 数据格式化
* @param value 值
* @param header 表头信息
*/
formatterValue(value, header) {
if (header.prop === 'date') {
return value.replace(/-/g, "/")
}
return value
}
})
// 调用方法导出 excel
exportExcelTool.exportData();
}
</script>
效果如下,引入js文件后,只需要传入表头数据,表格数据,导出文件名,sheet页名,就可实现excel导出,其他表格样式根据需要调整即可
ExportExcelTool.js
全部代码如下
/**
* @author 吴彦祖不想打工
* @date 2024-03-12
* @desciption: 用于嵌套表头的表格导出
*/
// import XLSX from 'xlsx-style'
// xlsx-style 的引入通常会出现各种问题
// 例如: ERROR: Could not resolve "./cptable"
// 具体解决方案可自行百度, 我这里直接将 xlsx-style 在 index.html 引入
/**
* @description: 边框样式
*/
const borderAll = {
top: {
style: 'thin',
},
bottom: {
style: 'thin'
},
left: {
style: 'thin'
},
right: {
style: 'thin'
}
}
/**
* @description: 默认单元格样式
*/
const defaultCellStyle = {
/**
* 表头区域样式配置
*/
headerStyle: {
border: borderAll,
font: {name: '宋体', sz: 11, italic: false, underline: false, bold: true},
alignment: {vertical: 'center', horizontal: 'center'},
fill: {fgColor: {rgb: 'FFFFFF'}},
},
/**
* 内容样式配置
*/
dataStyle: {
border: borderAll,
font: {name: '宋体', sz: 11, italic: false, underline: false},
alignment: {vertical: 'center', horizontal: 'left', wrapText: true},
fill: {fgColor: {rgb: 'FFFFFF'}},
}
}
/**
* 默认表头属性映射
* 用于从 tableHeader 对象中提取关键属性(如prop、label等)
*/
const defaultHeaderProps = {
prop: 'prop',
label: 'label',
children: 'children'
}
export default class ExportExcelTool {
constructor(options) {
options = options ? options : {};
/**
* 表格数据
*/
this.tableData = options.tableData
/**
* 表头结构
*/
this.tableHeader = options.tableHeader;
/**
* 表头结构属性映射
* 用于从 tableHeader 对象中提取关键属性(如prop、label等)
*/
this.headerProps = options.headerProps || defaultHeaderProps
/**
* 导出文件名
*/
this.fileName = options.fileName || 'exportExcel'
/**
* 工作表名称
* @type {string}
*/
this.sheetName = options.sheetName || 'Sheet1'
/**
* 表格基础样式
*/
this.cellStyle = options.cellStyle || defaultCellStyle
/**
* 列宽 数组 按顺序为每一列设置宽度,未设置宽度的列默认为 columnWidth: 100
* 例如 {0:200,2:200} 第一列宽度为200,第三列宽度为200 其余列默认
*/
this.columnWidthsArray = options.columnWidthsArray || {}
/**
* 列宽 默认100
*/
this.columnWidth = options.columnWidth || 100
/**
* 数据格式化方法
* 例如:
* formatterValue(value, header){
* return value
* }
*/
this.formatterValue = options.formatterValue
}
/**
* 导出数据Excel
*/
exportData() {
console.log(this.tableData)
if (!Array.isArray(this.tableHeader) || this.tableHeader.length < 1) {
throw {type: 'error', message: '请选择需要导出的表头!'}
}
if (!Array.isArray(this.tableData) || this.tableData.length < 1) {
throw {type: 'error', message: '请选择需要导出的数据!'}
}
let sheetName = this.sheetName
// excel表头
let excelHeader = this.buildHeader(this.tableHeader, this.headerProps)
// 头部行数,用来固定表头
let headerRows = excelHeader.length
//
let headerList = this.flat(this.tableHeader, this.headerProps)
// 提取数据
let dataList = this.extractData(this.tableData, this.headerProps, headerList)
excelHeader.push(...dataList, [])
// 计算合并
let merges = this.doMerges(excelHeader)
// 生成sheet
let ws = this.aoa_to_sheet(excelHeader, headerRows)
// 单元格合并
ws['!merges'] = merges
// 在生成sheet之后,为每一列设置宽度
ws['!cols'] = [];
const columnWidths = this.columnWidthsArray;
for (let c = 0; c < headerList.length; c++) {
// 如果没有指定宽度,则使用默认宽度
ws['!cols'].push({wpx: columnWidths[c] || this.columnWidth});
}
let workbook = {
SheetNames: [sheetName],
Sheets: {},
}
workbook.Sheets[sheetName] = ws
// excel样式
let wopts = {
bookType: 'xlsx',
bookSST: false,
type: 'binary',
cellStyles: true,
}
let wbout = XLSX.write(workbook, wopts)
let blob = new Blob([this.s2ab(wbout)], {type: 'application/octet-stream'})
this.openDownloadXLSXDialog(blob, this.fileName + '.xlsx')
}
/**
* 构建excel表头
* @param revealList 列表页面展示的表头
* @param headerProps
* @returns {[]} excel表格展示的表头
*/
buildHeader(revealList, headerProps) {
let excelHeader = []
// 构建生成excel表头需要的数据结构
this.getHeader(revealList, headerProps, excelHeader, 0, 0)
let max = Math.max(...excelHeader.map((a) => a.length))
excelHeader.filter((e) => e.length < max).forEach((e) => this.pushRowSpanPlaceHolder(e, max - e.length))
return excelHeader
}
/**
* 生成头部
* @param headers 展示的头部
* @param headerProps 属性映射
* @param excelHeader excel头部
* @param deep 深度
* @param perOffset 前置偏移量
* @returns {number} 后置偏移量
*/
getHeader(headers, headerProps, excelHeader, deep, perOffset) {
let offset = 0
let cur = excelHeader[deep]
if (!cur) {
cur = excelHeader[deep] = []
}
// 填充行合并占位符
this.pushRowSpanPlaceHolder(cur, perOffset - cur.length)
for (let i = 0; i < headers.length; i++) {
let head = headers[i]
cur.push(head[headerProps.label])
if (head.hasOwnProperty(headerProps.children) && Array.isArray(head[headerProps.children]) && head[headerProps.children].length > 0) {
let childOffset = this.getHeader(head[headerProps.children], headerProps , excelHeader, deep + 1, cur.length - 1)
// 填充列合并占位符
this.pushColSpanPlaceHolder(cur, childOffset - 1)
offset += childOffset
} else {
offset++
}
}
return offset
}
/**
* 根据选中的数据和展示的列,生成结果
* @param selectionData
* @param headerProps
* @param headerList
* @returns {*[]}
*/
extractData(selectionData, headerProps, headerList) {
// 导出的结果集
let excelRows = []
// 如果有child集合的话会用到
let dataKeys = new Set(Object.keys(selectionData[0]))
selectionData.some((e) => {
if (e[headerProps.children] && e[headerProps.children].length > 0) {
let childKeys = Object.keys(e[headerProps.children][0])
for (let i = 0; i < childKeys.length; i++) {
dataKeys.delete(childKeys[i])
}
return true
}
})
this.flatData(selectionData, headerProps, (list) => {
excelRows.push(...this.buildExcelRow(dataKeys, headerList, headerProps, list))
})
return excelRows
}
/**
* 根据列和数据生成excel行
* @param mainKeys
* @param headers
* @param headerProps
* @param rawDataList
* @returns {*[]}
*/
buildExcelRow(mainKeys, headers, headerProps, rawDataList) {
// 数据行
let rows = []
for (let i = 0; i < rawDataList.length; i++) {
let cols = []
let rawData = rawDataList[i]
// 提取数据
for (let j = 0; j < headers.length; j++) {
let header = headers[j]
// 父元素键需要行合并
if (rawData['rowSpan'] === 0 && mainKeys.has(header[headerProps.prop])) {
cols.push('!$ROW_SPAN_PLACEHOLDER')
} else {
let value = rawData[header[headerProps.prop]]
// 这里可以格式化你的数据
if (this.formatterValue) {
value = this.formatterValue(value, header)
}
// push进已经格式化了的数据
cols.push(value)
}
}
rows.push(cols)
}
return rows
}
/**
* @description: 需要合并的数据
* @param {*} arr
* @return {*}
*/
doMerges(arr) {
// 要么横向合并 要么纵向合并
let deep = arr.length
let merges = []
for (let y = 0; y < deep; y++) {
// 先处理横向合并
let row = arr[y]
let colSpan = 0
for (let x = 0; x < row.length; x++) {
if (row[x] === '!$COL_SPAN_PLACEHOLDER') {
row[x] = undefined
if (x + 1 === row.length) {
merges.push({s: {r: y, c: x - colSpan - 1}, e: {r: y, c: x}})
}
colSpan++
} else if (colSpan > 0 && x > colSpan) {
merges.push({s: {r: y, c: x - colSpan - 1}, e: {r: y, c: x - 1}})
colSpan = 0
} else {
colSpan = 0
}
}
}
// 再处理纵向合并
let colLength = arr[0].length
for (let x = 0; x < colLength; x++) {
let rowSpan = 0
for (let y = 0; y < deep; y++) {
if (arr[y][x] === '!$ROW_SPAN_PLACEHOLDER') {
arr[y][x] = undefined
if (y + 1 === deep) {
merges.push({s: {r: y - rowSpan, c: x}, e: {r: y, c: x}})
}
rowSpan++
} else if (rowSpan > 0 && y > rowSpan) {
merges.push({s: {r: y - rowSpan - 1, c: x}, e: {r: y - 1, c: x}})
rowSpan = 0
} else {
rowSpan = 0
}
}
}
return merges
}
/**
* 将二维数组转换为一个sheet
* @param data
* @param headerRows
* @returns {{}}
*/
aoa_to_sheet(data, headerRows) {
const ws = {}
const range = {s: {c: 10000000, r: 10000000}, e: {c: 0, r: 0}}
for (let R = 0; R !== data.length; ++R) {
// 处理每个单元格的样式
for (let C = 0; C !== data[R].length; ++C) {
if (range.s.r > R) {
range.s.r = R
}
if (range.s.c > C) {
range.s.c = C
}
if (range.e.r < R) {
range.e.r = R
}
if (range.e.c < C) {
range.e.c = C
}
/// 这里生成cell的时候,使用上面定义的默认样式
const cell = {
v: data[R][C] || ''
}
if (R < headerRows) {
// 头部列表样式
cell.s = this.cellStyle.headerStyle
} else {
// 数据列表样式
cell.s = this.cellStyle.dataStyle
}
const cell_ref = XLSX.utils.encode_cell({c: C, r: R})
if (typeof cell.v === 'number') {
cell.t = 'n'
} else if (typeof cell.v === 'boolean') {
cell.t = 'b'
} else {
cell.t = 's'
}
ws[cell_ref] = cell
}
}
// console.log(ws, range)
if (range.s.c < 10000000) {
ws['!ref'] = XLSX.utils.encode_range(range)
}
return ws
}
/**
* 填充行合并占位符
* @param arr
* @param count
*/
pushRowSpanPlaceHolder(arr, count) {
for (let i = 0; i < count; i++) {
arr.push('!$ROW_SPAN_PLACEHOLDER')
}
}
/**
* 填充列合并占位符
* @param arr
* @param count
*/
pushColSpanPlaceHolder(arr, count) {
for (let i = 0; i < count; i++) {
arr.push('!$COL_SPAN_PLACEHOLDER')
}
}
/**
* @description:铺平数组
* @param {*} list 表格数据
* @param headerProps
* @param {*} eachDataCallBack 构建的行数据
* @return {*}
*/
flatData(list, headerProps, eachDataCallBack) {
let resultList = []
for (let i = 0; i < list.length; i++) {
let data = list[i]
let rawDataList = []
// 每个子元素都和父元素合并成一条数据
if (data[headerProps.children] && data[headerProps.children].length > 0) {
for (let j = 0; j < data[headerProps.children].length; j++) {
delete data[headerProps.children][j].bsm
let copy = Object.assign({}, data, data[headerProps.children][j])
rawDataList.push(copy)
copy['rowSpan'] = j > 0 ? 0 : data[headerProps.children].length
}
} else {
data['rowSpan'] = 1
rawDataList.push(data)
}
resultList.push(...rawDataList)
if (typeof eachDataCallBack === 'function') {
eachDataCallBack(rawDataList)
}
}
return resultList
}
/**
* 将嵌套的列表展平为一维数组。
* @param {Array} revealList - 嵌套的列表,其中每个元素可能包含子元素。
* @param {Object} headerProps - 描述列表项中子元素属性的对象。预期包含一个'children'属性,指明子元素在父元素中的键名。
* @returns {Array} 返回展平后的列表,其中不包含任何嵌套结构。
*/
flat(revealList, headerProps) {
let result = []
revealList.forEach((e) => {
// 检查当前元素是否包含子元素属性
if (e.hasOwnProperty(headerProps.children)) {
// 当前元素确实包含子元素
if (e[headerProps.children]) {
// 子元素非空
if (e[headerProps.children].length > 0) {
// 递归展平子元素,并将结果合并到结果数组中
result.push(...this.flat(e[headerProps.children], headerProps))
} else {
// 子元素为空,将当前元素添加到结果数组
result.push(e)
}
} else {
// 子元素属性存在,但值为空,将当前元素添加到结果数组
result.push(e)
}
} else if (e.hasOwnProperty(headerProps.prop)) {
// 如果元素不包含子元素属性但包含'code'属性,将其添加到结果数组
result.push(e)
}
})
return result
}
s2ab(s) {
let buf = new ArrayBuffer(s.length)
let view = new Uint8Array(buf)
for (let i = 0; i !== s.length; ++i) {
view[i] = s.charCodeAt(i) & 0xff
}
return buf
}
/**
* 打开下载XLSX文件的对话框
* @param {string|Blob} url - 文件的URL或者Blob对象
* @param {string} saveName - 保存文件时的名称,默认为空字符串
*/
openDownloadXLSXDialog(url, saveName) {
if (typeof url == 'object' && url instanceof Blob) {
// 如果url是Blob对象,则创建一个blob URL
url = URL.createObjectURL(url)
}
var aLink = document.createElement('a') // 创建一个链接元素
aLink.href = url // 设置链接地址
aLink.download = saveName || '' // 设置下载时的文件名
var 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) // 触发链接的点击事件,开始下载
}
}