前端Excel导出实用方案(完整源码,可直接应用)

2023-12-14 23:40:20

目录

前言:

技术选型:

主要功能点:

核心代码:

完整代码:

开发文档


前言:

在前后端分离开发为主流的时代,很多时候,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"
  >
    <template #custom>
        <a-button type="primary" @click="handleClick">导出excel</a-button>
    </template>
  </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。Booleanfalse
modelTitle模态框标题String"导出excel"

multiFileSize

拆分成每个表多少条数据,需要搭配isSplit属性一起使用Number10e3
filename导出的excel的表名String"Excel" + new Date().getTime()
ws_namesheet名String"Sheet"
filterFunction自定义过滤函数;可在业务层处理数据格式,如时间格式化等Function(data)null

FAQ

filterFunction怎么使用

<template>
  <ExportExcelComponent
    ...
    :isCustom = "true"
    :asyncDataApi="asyncDataApi"
    :tableTitleData="titles"
    ref="export"
 ? ?:filterFunction="handleDateFilter"
  >
    <template #custom>
        <a-button type="primary" @click="handleClick">导出excel</a-button>
    </template>
  </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
 ?  }
}

文章来源:https://blog.csdn.net/yxlyttyxlytt/article/details/134790639
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。