前端Excel导出实用方案(完整源码,可直接应用)
目录
前言:
技术选型:
主要功能点:
核心代码:
完整代码:
开发文档
前言:
在前后端分离开发为主流的时代,很多时候,excel导出已不再由后端主导,而是把导出的操作移交到了前端。本文在全局导出组件封装上,保持了高度的扩展性,无论大家用的是element组件库还是antd vue的组件库或者其他的组件库,都容易进行更换。
技术选型:
vue + antd vue + sheetjs
前端导出excel导出,需借助第三方插件,目前两款导出最为主流。
一款是sheetjs,优点支持多种excel格式,但是官方文档全是英文
SheetJS Community Edition | SheetJS Community Edition
一款是exceljs,优点是中文文档很全,缺点是导出格式受限,仅支持部分格式
https://github.com/exceljs/exceljs/blob/master/README_zh.md
因公司业务需要,用户需支持多种excel的格式,所以本文笔者主要针对sheetjs进行封装调用。
主要功能点:
- 自定义dom
- 拆分成多张表导出(默认超过1万条数据自动拆分)
- 自定义过滤函数
- 各种标题自定义
- 数据排序
- 支持大数据量导出
核心代码:
// 文件名称
const filename = fileName;
//Excel第一个sheet的名称
const ws_name = sheetName;
// 创建sheet
const ws = XLSX.utils.aoa_to_sheet([this.tableTitle]);
//添加数据
XLSX.utils.sheet_add_json(ws, apiData, {
skipHeader: true,
origin:origin
});
// 创建wokbook
const wb = XLSX.utils.book_new();
// 将数据添加到工作薄
XLSX.utils.book_append_sheet(wb, ws, ws_name);
// 导出文件
XLSX.writeFile(wb, filename);
完整代码:
安装sheetjs
npm i xlsx
全局导出组件代码:
ExportExcelComponent.vue
<template>
<div id="excel-export">
<slot name="custom" v-if="isCustom"></slot>
<a-button ghost type="primary" @click="startExport" v-else>
导出excel
</a-button>
<a-modal
v-if="visible"
v-model="visible"
:title="modelTitle"
:maskClosable="false"
:closable="false"
>
<template #footer>
<a-button
type="primary"
ghost
v-if="isAbnormal"
:loading="btnLoading"
@click="startExport"
>
重新导出
</a-button>
<a-button
type="primary"
ghost
v-if="isAbnormal"
:loading="btnLoading"
@click="getTableData"
>
继续导出
</a-button>
<a-button :loading="btnLoading" @click="handleClose"> 关闭 </a-button>
</template>
<a-progress
:percent="percent"
:status="progressStatus"
class="progress"
/>
</a-modal>
</div>
</template>
<script>
import * as XLSX from "xlsx";
export default {
props: {
//自定义过滤函数
filterFunction: {
type: Function,
default: null,
},
//sheet名
ws_name: {
type: String,
default: "Sheet",
},
//导出的excel的表名
filename: {
type: String,
default: "Excel" + new Date().getTime(),
},
//拆分成每个表多少条数据,需要搭配isSplit属性一起使用
multiFileSize: {
type: Number,
default: 10e3,
},
//模态框标题
modelTitle: {
type: String,
default: "导出excel",
},
//是否自定义dom,如果采用插槽,需要开启该属性,否则dom为默认button
isCustom: {
type: Boolean,
default: false,
},
// 导出的数据表的表头
tableTitleData: {
type: Array,
required: true,
default: () => [],
},
//请求数据的api函数
asyncDataApi: {
type: Function,
default: () => {},
},
//请求参数
listQuery: {
type: Object,
default: () => ({}),
},
},
data() {
return {
ws: null,
isAbnormal: false,
btnLoading: false,
progressStatus: "active",
visible: false,
percent: 0,
tableData: [],
currentPage: 1,
multiFileNum: 0,
};
},
computed: {
// 导出的数据表的表头
tableTitle() {
return this.tableTitleData.map((item) => {
return item.title;
});
},
//导出数据表的表头的code
tableCode() {
return this.tableTitleData.map((item) => {
return item.code;
});
},
},
watch: {
percent: {
handler(newVal) {
if (newVal > 100) {
this.progressStatus = "success";
setTimeout(() => {
this.handleClose();
}, 500);
}
},
},
},
methods: {
//按照指定的title顺序映射排序数组对象
sortData(data, tit_code) {
const newData = [];
data.forEach((item) => {
const newObj = {};
tit_code.forEach((v) => {
newObj[v] = item[v] || "";
});
newData.push(newObj);
});
return newData;
},
handleClose() {
console.log("close");
this.resetExport();
this.visible = false;
},
resetExport() {
this.percent = 0;
this.progressStatus = "active";
this.isAbnormal = false;
this.tableData = [];
this.currentPage = 1;
this.multiFileNum = 0;
this.ws = XLSX.utils.aoa_to_sheet([this.tableTitle]);
},
//获取进度条百分比
getPersent(res) {
const persent_num =
((res.paginator.currentPage * res.paginator.size) /
res.paginator.total) *
100;
this.percent = parseInt(persent_num) - 1;
},
//异常处理
handleAbnormal() {
this.btnLoading = false;
this.progressStatus = "exception";
this.isAbnormal = true;
},
async startExport() {
if (!this.asyncDataApi) {
return new Promise(new Error("asyncDataApi is required"));
}
this.resetExport();
await this.getTableData();
},
//请求导出的数据和标题
async getTableData() {
this.visible = true;
this.btnLoading = true;
this.isAbnormal = false;
try {
const res = await this.asyncDataApi({
...this.listQuery,
page: this.currentPage,
});
if (res.code !== 200) {
this.handleAbnormal();
this.$message.error(res.message || this.t("requestException"));
return;
}
let apiData = res.data;
apiData = this.sortData(apiData, this.tableCode);
if (this.filterFunction) {
apiData = this.filterFunction(apiData);
}
apiData = apiData.map((item) => Object.values(item));
this.addSheetData(apiData, res);
this.currentPage = res.paginator.currentPage + 1;
console.log("res", res);
this.getPersent(res);
const isSplit =
res.paginator.currentPage * res.paginator.size >=
this.multiFileSize * (this.multiFileNum + 1);
if (isSplit) {
this.splitExport();
}
if (res.paginator.currentPage < res.paginator.page) {
this.getTableData();
return;
}
//当数据不满足拆分数量时触发
this.hadnleOneExport(res);
this.percent += 2;
this.btnLoading = false;
this.$message.success("导出成功");
} catch (error) {
console.log(error);
this.$message.error("网络错误,请稍后再试");
this.handleAbnormal();
}
},
//当数据不满足拆分数量时触发
hadnleOneExport(res) {
if (
this.multiFileNum &&
res.paginator.total > this.multiFileNum * this.multiFileSize
) {
this.multiFileNum += 1;
this.exportExcel(
this.filename + this.multiFileNum + ".xlsx",
this.ws_name + this.multiFileNum
);
} else if (!this.multiFileNum) {
this.exportExcel(this.filename + ".xlsx", this.ws_name);
}
},
//拆分成多个excel导出
splitExport() {
this.multiFileNum += 1;
this.exportExcel(
this.filename + this.multiFileNum + ".xlsx",
this.ws_name + this.multiFileNum
);
//重置表格
this.ws = XLSX.utils.aoa_to_sheet([this.tableTitle]);
},
addSheetData(apiData, res) {
//添加数据到表格 origin为每次添加数据从第几行开始
XLSX.utils.sheet_add_json(this.ws, apiData, {
skipHeader: true,
origin:
(this.currentPage - 1) * res.paginator.size -
this.multiFileSize * this.multiFileNum +
1,
});
},
//导出所有数据到一个excel
exportExcel(fileName, sheetName) {
// 文件名称
const filename = fileName;
//Excel第一个sheet的名称
const ws_name = sheetName;
// 创建wokbook
const wb = XLSX.utils.book_new();
// 将数据添加到工作薄
XLSX.utils.book_append_sheet(wb, this.ws, ws_name);
// 导出文件
XLSX.writeFile(wb, filename);
},
},
};
</script>
调用示例:
App.vue
<template>
<div>
<h1>测试表格导出</h1>
<div>
<ExportExcelComponent
:tableTitleData="title"
:asyncDataApi="asyncDataApi"
:isCustom="isCustom"
:listQuery="listQuery"
ref="export"
:filterFunction="handleDateFilter"
>
<template #custom>
<!-- <a-button type="primary" @click="handleClick">导出excel</a-button> -->
<a-dropdown-button>
Dropdown
<a-menu slot="overlay" @click="handleMenuClick">
<a-menu-item key="1">
<a-icon type="user" />1st menu item
</a-menu-item>
<a-menu-item key="2">
<a-icon type="user" />2nd menu item
</a-menu-item>
<a-menu-item key="3">
<a-icon type="user" />3rd item
</a-menu-item>
</a-menu>
</a-dropdown-button>
</template>
</ExportExcelComponent>
</div>
</div>
</template>
<script>
import ExportExcelComponent from "./ExportExcelComponent/ExportExcelComponent.vue";
import { asyncDataApi } from "./request";
import dayjs from "dayjs";
export default {
data() {
return {
listQuery: {
name: "yyy",
age: 18,
},
isCustom: true,
asyncDataApi: null,
title: [
{ code: "id", title: "序号" },
{ code: "hobby", title: "爱好" },
{ code: "name", title: "姓名" },
{ code: "age", title: "年龄" },
// { code: "hobby", title: "爱好" },
{ code: "sex", title: "性别" },
{ code: "address", title: "地址" },
{ code: "birthday", title: "生日" },
{ code: "createTime", title: "创建时间" },
{ code: "updateTime", title: "更新时间" },
{ code: "remark", title: "备注" },
{ code: "status", title: "状态" },
],
};
},
methods: {
handleDateFilter(data) {
const res = data.reduce((pre, cur) => {
for (let i in cur) {
if (i === "createTime") {
cur[i] = dayjs(cur[i] * 1000).format("YYYY-MM-DD HH:mm:ss");
}
}
pre.push(cur);
return pre;
}, []);
return res;
},
async handleMenuClick(val) {
// const titleNewData = [];
// for (let i = 1; i < 500; i++) {
// this.title.forEach((item) => {
// titleNewData.push({ code: item.code + i, title: item.title + i });
// });
// }
// this.title = titleNewData;
console.log("点击了导出excel", val);
await (this.asyncDataApi = asyncDataApi);
this.$refs.export.startExport();
},
// async handleClick() {
// console.log("点击了导出excel");
// await (this.asyncDataApi = asyncDataApi);
// this.$refs.export.startExport();
// },
},
components: {
ExportExcelComponent,
},
};
</script>
mock数据:
request.js
const asyncDataApi = (listquery) => {
console.log("params", listquery);
// 模拟异步请求接口
return new Promise((resolve, reject) => {
setTimeout(() => {
const data = [];
for (let i = listquery.page * 100; i < (listquery.page + 1) * 100; i++) {
const obj = {
id: i - 99,
name: "姓名" + i,
age: 20 + i,
hobby:
"赵客缦胡缨,吴钩霜雪明。银鞍照白马,飒沓如流星。十步杀一人,千里不留行。事了拂衣去,深藏身与名。闲过信陵饮,脱剑膝前横。将炙啖朱亥,持觞劝侯嬴。" +
i,
sex: "男" + i,
birthday: "2020-01-01",
createTime: "1701155392",
updateTime: "2020-01-01",
remark: "备注" + i,
status: "1" + i,
};
// let newObj = {};
// for (var a = 1; a < 500; a++) {
// for (let k in obj) {
// newObj[k + a] = obj[k];
// }
// }
// data.push(newObj);
data.push(obj);
}
resolve({
data,
code: 200,
msg: "请求成功",
paginator: {
page: 1000,
size: 100,
total: 100000,
currentPage: listquery.page,
},
});
}, 100);
});
};
export { asyncDataApi };
开发文档
调用方式:
如果不采用自定义dom的话,直接点击默认的按钮可直接导出表格数据; 如果采用自定义dom的话,通过ref实例调用子组件内的startExport方法,执行导出操作
<template>
<ExportExcelComponent
...
:isCustom = "true"
:asyncDataApi="asyncDataApi"
:tableTitleData="titles"
ref="export"
>
<a-button type="primary" @click="handleClick">导出excel</a-button>
</ExportExcelComponent>
</template>
<script>
import { asyncDataApi } from '@/api/member'
export default{
data(){
return:{
titles:[]
asyncDataApi,
}
}
methods:{
handleClick(){
this.$refs.export.startExport();
}
}
}
</script>
API
属性如下
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
listQuery | 请求参数 | Object | {} |
asyncDataApi | 请求数据的api函数 | Function | 必传 |
tableTitleData | 导出的数据表的表头 | Array | 必传 |
isCustom | 是否自定义dom,如果采用插槽,需要开启该属性,否则dom为默认button;可以传递 v-slot:custom 来自定义 dom。 | Boolean | false |
modelTitle | 模态框标题 | String | "导出excel" |
multiFileSize | 拆分成每个表多少条数据,需要搭配isSplit属性一起使用 | Number | 10e3 |
filename | 导出的excel的表名 | String | "Excel" + new Date().getTime() |
ws_name | sheet名 | String | "Sheet" |
filterFunction | 自定义过滤函数;可在业务层处理数据格式,如时间格式化等 | Function(data) | null |
FAQ
filterFunction怎么使用
<template>
<ExportExcelComponent
...
:isCustom = "true"
:asyncDataApi="asyncDataApi"
:tableTitleData="titles"
ref="export"
:filterFunction="handleDateFilter"
>
<a-button type="primary" @click="handleClick">导出excel</a-button>
</ExportExcelComponent>
</template>
<script>
import { asyncDataApi } from '@/api/member'
export default{
data(){
return:{
titles:[]
asyncDataApi,
}
}
methods:{
handleDateFilter(data) {
const res = data.reduce((pre, cur) => {
for (let i in cur) {
if (i === "createTime") {
cur[i] = dayjs(cur[i] * 1000).format("YYYY-MM-DD HH:mm:ss");
}
}
pre.push(cur);
return pre;
}, []);
return res;
},
handleClick(){
this.$refs.export.startExport();
}
}
}
</script>
导出表格数据为空是什么情况?
因为导出的表格数据的顺序和标题的顺序并不一定是一致的,所以在组件内部做了映射排序,一定要确保传入的标题数据在调用导出接口之前执行。如果传递的标题有误,在进行映射的时候,这时标题和表格数据并不匹配,那么就会出现数据映射为空的情况
Promise Error:"asyncDataApi is required"
当传递给组件的后端api需要在点击dom后赋值再传递的时候,一定要确保在导入后端api之后再调用组件内的导出方法,否则因为后端api还没传递过去就调用,然后抛错或者导出异常
正确示例:
async handleClick() {
await (this.asyncDataApi = asyncDataApi);
this.$refs.export.getTableData();
},
后端导出表格api数据返回格式
因该组件为全局组件,方便以后复用,需与后端协商规定好数据导出的格式。以下为笔者的公司,与后端同事协商的数据格式。大家可根据自己公司需要,更改以上源码中后端返回值字段。
//后端返回数据结构
{
"status": true,
"data": [
{...},
{...},
],
"paginator": {
"currentPage": 1,
"total": 200,
"size": 20,
"page": 10
}
}