目录
前言:
技术选型:
主要功能点:
核心代码:
完整代码:
开发文档
前言:
在前后端分离开发为主流的时代,很多时候,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-modalv-if="visible"v-model="visible":title="modelTitle":maskClosable="false":closable="false"><template #footer><a-buttontype="primary"ghostv-if="isAbnormal":loading="btnLoading"@click="startExport">重新导出</a-button><a-buttontype="primary"ghostv-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为默认buttonisCustom: {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;});},//导出数据表的表头的codetableCode() {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,});},//导出所有数据到一个excelexportExcel(fileName, sheetName) {// 文件名称const filename = fileName;//Excel第一个sheet的名称const ws_name = sheetName;// 创建wokbookconst 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}
}