结合公司业务要求封装个表格组件和钩子函数

一 背景

24年下半年接触了公司的几个自研项目,属于后台管理系统。我们是搞内网安全,会收集一些电脑数据,通过前期设置的策略对数据进行清洗汇总。所以经常跟表格表单 打交道。

公司有自己的组件库,但并没有进行维护。出了问题改组件,也不更新到组件库上,导致每个项目一个组件库。使用说明文档也没有,暴露出很多问题。

由此利用了空闲时间对于一些表格操作进行抽离和封装,并应用到了自己的项目中。

如有不足或不对的地方,希望掘友能够指出,一起学习进步。

✍️ 接下来进入正文 。。。

二 问题及改进

问题1:当每天数据量达到几万条,导出一定时间范围的数据时,前端页面会出现崩溃的现象,并且在导出过程中页面无法操作。(在我们公司中导出几十万条数据是很常见的)

问题2 :对于一些特殊的列,无法通过配置对象的形式来实现,需要通过 插槽进行插入。(在我们公司很喜欢用这种方式)


可以优化改进:

改进1:表格组件实现拖拽排序

改进2:获取 Excel 导入的数据

改进3:对于多个 Excel 导出支持打包成 zip

改进4:表格字段支持排序(本地和远程)

改进5 :本地排序需要支持一些比较特殊的排序(如 文件目录的排序

改进6 :由原本的插槽 修改使用JSX的方式

三 功能介绍

3.1 VhTable 组件

  • 支持对表格列进行切换显隐
  • 支持对表格列进行排序(本地和远程)
  • 支持对表格行进行拖拽排序
  • 支持多种形式的表格操作列
  • 支持一些比较特殊的排序(文件目录的排序

3.2 ColumnSelect 组件

  • 普通的下拉选择框,没什么特殊的地方。

3.3 useTable 钩子

  • 导出功能前端支持 10 万条数据的导出,页面不卡死,能够正常操作
  • 读取导入 Excel 数据
  • 支持多个 Excel 导出的同时打包成压缩包

还有其他一些基本的功能函数,这里就不一一赘述。先挑主要的进行说明。

3.4 应用到实际项目

今天就基于上面的这些问题及改进,开发 vhTable ColumnSelect useTable

并应用到自己的项目中,该项目平时是用来记录一些日常日记,一开始该页面的表格也没有进行封装抽离。刚好借此机会,对该页面的表格进行抽离。

技术栈:vue 3.2.8 element-plus 2.7.6

四 VhTable

组件:表格列操作列分页器

功能:字段排序拖拽排序

4.1 表格列

原本在公司是使用vue2 ,对于一些比较特殊的列都是采用插槽进行实现。

而这里我并不采用插槽的形式进行实现。

因为 vue3 是可以支持 JSX组件,这就可以实现每一列都可以写成一个对象,便于进行维护。

VhTable 关键代码

js 复制代码
<template v-for="(column, i) in props.columnList" :key="i">
    <el-table-column v-if="props.showColumn.includes(column.prop)" :prop="column.prop">
      <template #header>
        <component v-if="column.component && column.component.enable" :is="renderLabel(column)" />
        <span v-else>
          {{
            typeof column.label === 'function'
              ? column.label(column)
              : column.label
          }}
        </span>
      </template>
      <template #default="scope">
        <component v-if="column.component && column.component.enable" :is="renderValue(column, scope)" />
        <span v-else>
          {{
            typeof column.value === 'function'
              ? column.value(scope.row, column.prop, scope.$index)
              : scope.row[column.prop]
          }}
        </span>
      </template>
    </el-table-column>
</template>
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
  showColumn: {
    type: Array,
    default: () => []
  },
  columnList: {
    type: Array,
    default: () => []
  }
})

const renderLabel = (column) => {
  const { component = {} } = column
  const label = component.label ? component.label : ''
  if (typeof label === 'function') {
    const component = label(column)
    return typeof component === 'string'
      ? renderCustomComponent(component, 'cell')
      : component
  }
  return renderCustomComponent(label, 'cell')
}


const renderValue = (column, scope) => {
  const { component = {} } = column
  const value = component.value
  if (typeof value === 'function') {
    const component = value(scope.row, column.prop, scope.$index, column)
    return typeof component === 'string'
      ? renderCustomComponent(component, 'cell')
      : component
  }
  return value
    ? renderCustomComponent(value, 'cell')
    : renderCustomComponent(scope.row[column.prop], 'cell')
}


const renderCustomComponent = (text, customClass = '') => {
  return <span>{text}</span>
}
</script>

基本使用

1.已比较特殊的 日记属性 列来举例进行使用:

js 复制代码
<template>
    <vhTable :data="tableData" :showColumn="showColumn" :columnList="columnList"  />
</template>
<script setup>
import { ref } from 'vue'

const showProp = ref([
  'index',
  'selection',
  'title',
  'content',
  'createdAt',
  'images',
  'auth',
  'title_private',
  'private',
  'site'
])

const columnList = ref([
  {
    label: '日记属性',
    component: {
      enable: true,
      showEdit: false,
      label: (column) => {
        return (
          <TableHeaderByEdit
            column={column}
            onChange={() => { column.component.showEdit = !column.component.showEdit }}
          />
        )
      },
      value: (row, prop, i, column) => {
        return (
          <AttrColumn
            val={row.private}
            edit={column.component.showEdit}
            publicAtr={false}
            onChange={() => { row.private = !row.private; handleEdit(row) }}
          />
        )
      }
    },
    prop: 'private',
    minWidth: '100px'
  }
])
</script>

下面这两部分则属于业务组件,跟表格组件没有关系,表格只负责渲染

js 复制代码
// TableHeaderByEdit
<template>
  <div class="tableHeader">
    <span>{{ typeof column.label === 'function' ? column.label() : column.label }}</span>
    <el-icon @click="changeEdit">
      <Edit />
    </el-icon>
  </div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const emits = defineEmits(['change'])
defineProps({
  column: {
    type: Object,
    default: () => ({})
  }
})

const changeEdit = () => {
  emits('change')
}
</script>
<style lang="scss" scoped>
.tableHeader {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
</style>
js 复制代码
// AttrColumn
<template>
  <div class="attr-column">
    <el-tag :type="val === publicAtr ? 'success' : 'info'">{{ val === publicAtr ? '公开' : '私有' }}</el-tag>
    <el-switch v-if="edit" style="transform: scale(0.8)" v-model="switchVal" @change=handleAtrChange />
  </div>
</template>
<script setup>
import { defineProps, defineEmits, watch, ref } from 'vue'
const emits = defineEmits(['change'])
const props = defineProps({
  val: {
    type: Boolean,
    default: false
  },
  edit: {
    type: Boolean,
    default: false
  },
  publicAtr: {
    type: Boolean,
    default: true
  }
})

const switchVal = ref(false)
watch(() => props.val, (val) => {
  switchVal.value = val === props.publicAtr
}, { immediate: true })

const handleAtrChange = (state) => {
  emits('change', state)
}
</script>
<style lang="scss" scoped>
.attr-column {
  display: flex;
  align-items: center;
  justify-content: end;
}
</style>

2.对于一些比较常规的列在使用上也不会特别的复杂:

js 复制代码
const columnList = ref([
    {
        label: '内容',
        showOverflowTooltip: true,
        prop: 'content',
        minWidth: '400px'
    }
])

4.2 操作列

VhTable 关键代码

js 复制代码
 <el-table-column v-if="props.operation && props.operation.length != 0" :label="defaultConfig.operationLabel">
    <template #default="scope">
      <template v-for="(oper, i) in operation" :key="i">
        <template v-if="!oper.isConfirm">
          <el-button v-if="
            typeof oper.hidden === 'function'
              ? !oper.hidden(scope.row, scope.$index)
              : true
          " :disabled="typeof oper.disabled === 'function'
            ? oper.disabled(scope.row, scope.$index)
            : false
            " :size="defaultConfig.operationSize" :type="oper.type" :icon="oper.icon"
            @click="handleOperEvent(oper, scope.row, scope.$index)">{{
              typeof oper.label === 'function'
                ? oper.label(scope.row, scope.$index)
                : oper.label
            }}</el-button>
        </template>
        <template v-else>
          <el-popconfirm confirm-button-text="是" cancel-button-text="否" title="确定删除?"
            @confirm="handleOperEvent(oper, scope.row, scope.$index)">
            <template #reference>
              <el-button v-if="
                typeof oper.hidden === 'function'
                  ? !oper.hidden(scope.row, scope.$index)
                  : true
              " :disabled="typeof oper.disabled === 'function'
                ? oper.disabled(scope.row, scope.$index)
                : false
                " :size="defaultConfig.operationSize" :type="oper.type" :icon="oper.icon">{{
                  typeof oper.label === 'function'
                    ? oper.label(scope.row, scope.$index)
                    : oper.label
                }}</el-button>
            </template>
          </el-popconfirm>
        </template>
      </template>
    </template>
  </el-table-column>

type 为 button

形式一 形式二 形式三
js 复制代码
// 形式一
const operation = ref([
  {
    event: (row, index) => onSelect(row, index),
    type: 'primary',
    icon: 'View'
  },
  {
    event: (row, index) => onEdit(row, index),
    type: 'icon',
    icon: 'Edit'
  },
  {
    event: (row, index) => onDelect(row, index),
    type: 'danger',
    icon: 'Delete'
  }
])

// 形式二
const operation = ref([
  {
    event: (row, index) => onSelect(row, index),
    type: 'primary',
    label: '查看'
  },
  {
    event: (row, index) => onEdit(row, index),
    label: '编辑'
  },
  {
    event: (row, index) => onDelect(row, index),
    type: 'danger',
    label: '删除'
  }
])

// 形式三
const operation = ref([
  {
    event: (row, index) => onSelect(row, index),
    type: 'primary',
    label: '查看',
    icon: 'View'
  },
  {
    event: (row, index) => onEdit(row, index),
    label: '编辑',
    icon: 'Edit'
  },
  {
    event: (row, index) => onDelect(row, index),
    type: 'danger',
    label: '删除',
    icon: 'Delete'
  }
])

type 为 text

上面的基础上将 type 修改为 text 即可。

形式一 形式二 形式三

除了还增加两条方法用于控制该行数据的显示/隐藏 可操作/不可操作

js 复制代码
{
    event: (row, index) => onSelect(row, index),
    type: 'primary',
    icon: 'View',
    hidden: (row, index) => {
      return index === 1
    }
}
js 复制代码
  {
    event: (row, index) => onSelect(row, index),
    type: 'primary',
    icon: 'View',
    disabled: (row, index) => {
      return index === 1
    }
  }

对于一些操作也需要进行 二次确定 ,这里也能够支持

js 复制代码
{
    event: (row, index) => onDelect(row, index),
    type: 'danger',
    icon: 'Delete',
    isConfirm: true
}

4.3 分页器

这里没什么好讲的。。

VhTable 关键代码

js 复制代码
<template>
    <el-pagination 
         v-if="props.hasPagination" 
         v-model:current-page="pageNum" 
         v-model:page-size="pageSize"
         :total="props.pagination.total" 
         :size="defaultConfig.paginationSize" 
         :page-size="props.pagination.pageSizes"
         :background="props.pagination.background" 
         :layout="props.pagination.layout"
         @change="handlePaginationChange" 
    />
</template>
<script setup>
import { defineEmits } from 'vue'
const emits = defineEmits([
  'paginationChange'
])

const pageNum = ref(0)
const pageSize = ref(10)

watch(
  () => props.pagination,
  (pagination) => {
    if (props.hasPagination) {
      pageNum.value = pagination.pageNum
      pageSize.value = pagination.pageSize
    }
  },
  {
    immediate: true,
    deep: true
  }
)

const handleSelectionChange = (selection) => {
  emits('selectionChange', selection)
}
</script>

基本使用

js 复制代码
<template>
    <vhTable 
      :data="tableData" 
      :pagination="pagination" 
      :hasPagination="pagination.enable" 
      @paginationChange="getTableData" 
    />
</template>
<script setup>
const pagination = ref({
  enable: true,
  layout: 'total, sizes, prev, pager, next, jumper',
  background: true,
  total: 0,
  pageNum: 1,
  pageSize: 50,
  pageSizes: [10, 20, 50, 100]
})
const { getTableData } = useTable()
</script>

4.4 字段排序

1.本地排序

原本的使用 el-table 进行自定义排序需要如下配置:

js 复制代码
<template>
    <el-table :data="tableData" style="width: 100%">
      <el-table-column prop="date" label="Date" sortable />
      <el-table-column prop="name" label="Name" sortable :sort-method="nameSort" />
      <el-table-column prop="address" label="Address" />
    </el-table>
</template>
<script setup>
import { ref } from 'vue'

const tableData = ref([
  {
    date: '2016-05-03',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles'
  },
  {
    date: '2016-05-02',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles'
  },
  {
    date: '2016-05-04',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles'
  },
  {
    date: '2016-05-01',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles'
  }
])

const nameSort = (a, b) => {
  return a.name - b.name
}
</script>

可以发现在 nameSort 方法 中是没法拿到当前的是降序还是升序。对于一些特殊的排序(文件目录排序)则是需要拿到排序状态。因此这里做了一些小处理。

要想拿到当前字段 order 值是 ascending 还是 descending

可以通过 sort-change 事件 当前字段 order 值。

VhTable 关键代码

关键点将 sortable 配置成 custom

js 复制代码
<template>
<el-table ref="tableRef" :data="tableData" @sort-change="handleSortChange" :default-sort="props.defaultSort">
    <template v-for="(column, i) in props.columnList" :key="i">
       <el-table-column 
         v-if="props.showColumn.includes(column.prop)" 
         :sortable="column.sortable ? 'custom' : false"
         :prop="column.prop"
       />
    </template>
</el-table>
</template>
<script setup>
import { ref, defineEmits } from 'vue'

const tableRef = ref(null)
const order = ref('ascending')
const orderProp = ref('')
const tableData = ref([])

const emits = defineEmits([
  'sortChange'
])

// 自身默认排序方法
const defaultSortMethod = (a, b, prop) => {
  const sortType = columnMap.value[prop].sortType || 'string'

  if (sortType === 'number') {
    return a[prop] - b[prop]
  }

  return a[prop].localeCompare(b[prop])
}

const handleSortChange = ({ column, prop, order: ord }) => {
  let sortData = []

  if (ord == null) { // 经过测试 ord 偶尔会为空
    const ord = order.value === 'ascending' ? 'descending' : 'ascending'
    order.value = ord
  }
  
  order.value = ord
  orderProp.value = prop
  
  // *不管是否为远程排序,都往外抛
  emits('sortChange', { column, prop, order: order.value })
  
  const sortMethod = columnMap.value[prop].sortMethod
  
  if (typeof sortMethod !== 'function') {
    sortData = tableData.value.sort((a, b) => {
      return ord === 'ascending'
        ? defaultSortMethod(a, b, prop)
        : defaultSortMethod(b, a, prop)
    })
  } else {
    sortData = tableData.value.sort((a, b) => {
      // 将 order 抛出
      return sortMethod(a, b, order.value)
    })
  }

  tableData.value = sortData
}
</script>

这里有个不知道算不算问题(产品经理提的):

如果数据存在分页,前一页使用某个字段在前端点击降序排序(本地排序),点击下一页时表格没有按照该字段进行排序。

这里先算是个问题,vhTable 这里就做了这样的处理:

js 复制代码
watch(
  () => props.data,
  () => {
    tableData.value = [...props.data]
    if (
      orderProp.value &&
      order.value &&
      !remoteSortColumn.value.includes(orderProp.value)
    ) {
      nextTick(() => {
        // 再触发一次本地排序
        tableRef.value && tableRef.value.sort(orderProp.value, order.value)
      })
    }
  },
  {
    immediate: true
  }
)

2.远程排序

可以通过监听 @sortChange事件

js 复制代码
<template>
   <VhTable @sortChange="handleSortChange" />
</template>
<script setup>
const handleSortChange = ({ prop, order }) => {
  const item = column.value.find((item) => item.prop === prop)
  if (item && item.remote) {
    query.value.prop = prop
    query.value.order = order
    getTableData({
      pageSize: pagination.value.pageSize,
      pageNum: pagination.value.pageNum
    })
  } else {
    delete query.value.prop
    delete query.value.order
  }
}
</script>

4.5 拖拽排序

这里是借助了第三方插件sortablejs 实现的拖拽功能。

vhTable 关键代码

js 复制代码
<template>
  <el-table ref="tableRef" :data="tableData"></el-table>
</template>
<script setup>
import { ref, onMounted, defineEmits } from 'vue'
import Sortable from 'sortablejs'

const tableData = ref([])
const tableRef = ref(null)

const emits = defineEmits([
  'sortRowChange'
])

const initSortable = () => {
  const el = tableRef.value.$el.querySelectorAll('.el-table__body > tbody')[0]
  Sortable.create(el, {
    // 拖拽时类名
    ghostClass: 'sortable-host',
    // 拖拽结束的回调方法
    onEnd(event) {
      const { newIndex, oldIndex } = event
      emits('sortRowChange', { newIndex, oldIndex, data: tableData.value[oldIndex] })
    }
  })
}

onMounted(() => {
  initSortable()
})
</script>
<style lang="scss" scoped>
::v-deep tr.sortable-ghost td {
  opacity: 0.6;
  background-color: var(--el-sortable-bgcolor) !important;
}
</style>

使用

配置 issortable 和 监听 sortRowChange 事件

js 复制代码
<template>
  <vhTable :data="tableData" :issortable="true" @sortRowChange="sortRowChange"/>
</template>
<script setup>
const sortRowChange = ({ newIndex, oldIndex, data }) => {
  console.log(newIndex, oldIndex, data)
  // todo 更新数据
}
</script>

五 useTable

5.1 前端导出大数据

这里使用的是web worker 来实现大数据的导出,将生成 Excel 文件的操作交由子进程去处理,不在主进程避免页面出现卡死现象。

由于之前没有接触过 web worker ,这里遇到很多坑 (具体可以查看我另外一篇文章,# 🍀前端可以不用依赖后端实现导出大数据了)。

在 vue 中需要下载第三方插件worker-loader,并且在 vue-config.js 需要添加以下配置:

js 复制代码
config.module
    .rule('worker')
    .test(/\.worker\.js$/)
    .use('worker-loader')
    .loader('worker-loader')
    .options({})
    .end()  

子进程

js 复制代码
// export.worker.js
import Excel from 'exceljs'

self.onmessage = async function(e) {
  const { csvData, csvHeader } = e.data

  const workbook = new Excel.Workbook()
  const worksheet = workbook.addWorksheet('My Sheet')

  worksheet.columns = csvHeader
  csvData.forEach(row => worksheet.addRow(row))

  // 生成 Excel 文件的 Buffer
  const excelBuffer = await workbook.xlsx.writeBuffer()

  const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })

  // 发回给到主进程
  self.postMessage({ chunk: blob })
}

主进程

js 复制代码
import ExportWorker from './export.worker.js'
import { saveAs } from 'file-saver'

/**
 * 导出数据为 XLSX(通过 web Worker)
 * @param {Object} csvHeader XLSX 头
 * @param {Array} csvData 数据
 * @param {String} filename 文件名
 */
const exportDataToXLSXByWorker = (csvHeader, csvData, filename) => {
    const worker = new ExportWorker()

    const keys = csvHeader.map(item => item.key)
    csvData = csvData.map(row => {
      return keys.reduce((acc, prev) => {
        acc[prev] = typeof row[prev] === 'object' ? JSON.stringify(row[prev]) : row[prev]
        return acc
      }, {})
    })

    worker.postMessage({
      csvData: csvData,
      csvHeader: csvHeader
    })

    worker.onmessage = async(e) => {
      const { chunk: blog } = e.data
      saveAs(blog, filename)
    }
}

5.2 Excel 压缩成 Zip 包

相比较上面代码,将 csvHeadercsvData 放入数组中,一个 Excel 对应一个数组,遍历二维数组,生成 Excel。并压缩到 zip 下载即可。

js 复制代码
const exportDataToXLSXByWorker = (excelData, filename, iszip) => {
    const worker = new ExportWorker()

    // * 判断 csvData 中的值是否存在对象,需要序列化处理
    excelData = excelData.map((item) => {
      const { csvHeader, csvData } = item
      const keys = csvHeader.map(item => item.key)
      item.csvData = csvData.map(row => {
        return keys.reduce((acc, prev) => {
          acc[prev] = typeof row[prev] === 'object' ? JSON.stringify(row[prev]) : row[prev]
          return acc
        }, {})
      })
      return item
    })

    worker.postMessage({
      excel: excelData,
      iszip
    })

    worker.onmessage = async(e) => {
      const { chunk: blog, iszip } = e.data
      if (iszip) {
        saveAs(blog, `${filename}.zip`)
      } else {
        saveAs(blog, filename)
      }
    }
}
js 复制代码
import Excel from 'exceljs'
import JSZip from 'jszip'
self.onmessage = async function(e) {
  const iszip = e.data.iszip
  let data = e.data.excel || []
  if (!iszip) data = [data[0]]
  const excelBufferList = []
  await Promise.all(data.map(async (item, i) => {
    const workbook = new Excel.Workbook()
    const worksheet = workbook.addWorksheet('My Sheet')
    const { csvData, csvHeader, fileName = 'file' } = item

    worksheet.columns = csvHeader

    // 添加数据行
    csvData.forEach(row => worksheet.addRow(row))

    // 生成 Buffer
    const buffer = await workbook.xlsx.writeBuffer()
    excelBufferList.push({ fileName, buffer })
  }))

  let blobContent = ''
  if (iszip) {
    const zip = new JSZip()
    excelBufferList.forEach((item) => {
      const { fileName, buffer } = item
      zip.file(`${fileName}.xlsx`, buffer)
    })
    blobContent = await zip.generateAsync({ type: 'blob' })
  } else {
    const buffer = excelBufferList[0].buffer
    blobContent = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
  }

  self.postMessage({ chunk: blobContent, iszip })
}

使用

js 复制代码
<script setup>
const { exportSelectedData, exportAllData } = useTable()

// 导出选中
exportSelectedData({
  fileName = 'exportSelectedData', // 文件名
  isOpenWorker = false, // 是否开启 worker
  isAllColumn = false, // 是否导出所有字段
  limit = 1000, // 一个 Excel 最多存放多少条数据
  iszip = false, // 是否开启压缩包
  type = 'excel' // 类型,目前仅允许 excel
})

// 导出全部
exportAllData(); // 参数跟上述一致
</script>

注意 经过测试,如果一个 Excel 文件有 10 万条数据,有 30个Excel 文件,页面也会出现卡死,严重的情况下,页面会直接崩溃掉。

经排查:是由于下面这部分代码,需要将生成的Excel数据不断存放到 eccelBufferList 变量中,导致 eccelBufferList 内存泄漏。最后再一次性放到 zip 包中。

这里算是个缺陷,但经过测试,一个 Excel 文件有 10 万条数据,相比较没有开启 web worker,页面不会出现卡死(用户无法操作)或崩溃现象。

5.3 读取导入的 Excel 数据

这里封装了 upload-excel 组件 ,即能手动选择文件上传,还支持拖拽上传。

js 复制代码
<template>
  <div class="upload-excel">
    <div class="btn-upload">
      <el-button :loading="loading" type="primary" @click="handleUpload">
        {{ $t('msg.uploadExcel.upload') }}
      </el-button>
    </div>

    <input ref="excelUploadInput" class="excel-upload-input" type="file" accept=".xlsx, .xls" @change="handleChange"/>

    <div class="drop" @drop.stop.prevent="handleDrop" @dragover.stop.prevent="handleDragover" @dragenter.stop.prevent="handleDragover">
      <el-icon><UploadFilled /></el-icon>
      <span>{{ $t('msg.uploadExcel.drop') }}</span>
    </div>
  </div>
</template>

<script setup>
import XLSX from 'xlsx'
import { ref, defineProps } from 'vue'
import { getHeaderRow, isExcel } from './utils'
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const loading = ref(false)

const props = defineProps({
  // 上传前回调
  beforeUpload: Function,
  // 成功回调
  onSuccess: Function
})

// 触发点击上传
const excelUploadInput = ref(null)
const handleUpload = () => {
  excelUploadInput.value.click()
  // 触发 hangleChange
}
const handleChange = (e) => {
  const files = e.target.files
  const rawFile = files[0]
  if (!rawFile) return
  upload(rawFile)
}
// 拖拽上传
// 当元素或选中的文本在可释放目标上被释放时触发
const handleDrop = (e) => {
  // 上传中
  if (loading.value) return
  const files = e.dataTransfer.files
  if (files.length !== 1) {
    ElMessage.error('必须有一个文件')
    return
  }
  const rawFile = files[0]
  if (!isExcel(rawFile)) {
    ElMessage.error('文件必须时.xlsx, .xls, .csv格式')
    return
  }
  upload(rawFile)
}
// 当元素或选中的文本被拖到一个可释放目标上时触发
const handleDragover = (e) => {
  e.dataTransfer.dropEffect = 'copy'
}

// 触发上传事件
const upload = rawFile => {
  excelUploadInput.value.value = null
  // 上传前回调
  if (!props.beforeUpload) {
    readerData(rawFile)
    return
  }
  // 如果指定了上传前回调,那么只有返回 true 才会执行后续操作
  const before = props.beforeUpload(rawFile)
  if (before) {
    readerData(rawFile)
  }
}
// 读取文件(异步)
const readerData = (rawFile) => {
  loading.value = true
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    // 该事件在读取操作完成时触发
    reader.onload = e => {
      // 1. 获取解析到的数据
      const data = e.target.result
      // 2. 利用 XLSX 对数据进行解析
      const workbook = XLSX.read(data, { type: 'array' })
      // 3. 获取第一张表格(工作簿)名称
      const firstSheetName = workbook.SheetNames[0]
      // 4. 只读取 Sheet1(第一张表格)的数据
      const worksheet = workbook.Sheets[firstSheetName]
      // 5. 解析数据表头
      const header = getHeaderRow(worksheet)
      // 6. 解析数据体
      const results = XLSX.utils.sheet_to_json(worksheet)
      // 7. 传入解析之后的数据
      generateData({ header, results })
      // 8. loading 处理
      loading.value = false
      // 9. 异步完成
      resolve()
    }
    // 启动读取指定的 Blob 或 File 内容
    reader.readAsArrayBuffer(rawFile)
  })
}
// 根据导入内容,生成数据
const generateData = excelData => {
  props.onSuccess && props.onSuccess(excelData)
}
</script>

<style lang="scss" scoped>
.upload-excel {
  display: flex;
  justify-content: center;
  .excel-upload-input {
    display: none;
    z-index: -9999;
  }
  .btn-upload,
  .drop {
    border: 1px dashed #bbb;
    width: 350px;
    height: 160px;
    text-align: center;
    line-height: 160px;
  }
  .drop {
    line-height: 60px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    color: #bbb;
    i {
      font-size: 60px;
      display: block;
      margin: 0 auto;
    }
  }
}
</style>

工具函数解析 excel

js 复制代码
import XLSX from 'xlsx'

/**
 * 获取表头(通用方式)
 */
export const getHeaderRow = sheet => {
  const headers = []
  const range = XLSX.utils.decode_range(sheet['!ref'])
  let C
  const R = range.s.r
  /* start in the first row */
  for (C = range.s.c; C <= range.e.c; ++C) {
    /* walk every column in the range */
    const cell = sheet[XLSX.utils.encode_cell({ c: C, r: R })]
    /* find the cell in the first row */
    let hdr = 'UNKNOWN ' + C // <-- replace with your desired default
    if (cell && cell.t) hdr = XLSX.utils.format_cell(cell)
    headers.push(hdr)
  }
  return headers
}

export const isExcel = file => {
  return /\.(xlsx|xls|csv)$/.test(file.name)
}

使用

js 复制代码
<template>
  <upload-excel :onSuccess="onSuccess"></upload-excel>
</template>
<script setup>
const importdata = reactive([])
const onSuccess = ({ header, results }) => {
  showImportData.value = false
  ElMessage.success({
    type: 'success',
    message: i18n.t('msg.excel.importSuccess')
  })
  showImportData.value = true
  importdata = results
}
</script>

六 ColumnSelect

这里也没什么好讲的。。

基本使用

js 复制代码
<template>
    <column-select
      :showProp="showColumn" 
      :columnList="columnList"
      @columnChange="columnChange" 
    />
</template>
<script setup>
import { ref } from 'vue'

const showColumn = ref([
  'index',
  'selection',
  'title',
  'content',
  'createdAt',
  'images',
  'auth',
  'title_private',
  'private',
  'site'
])

const columnList = ref([]) // 将表格配置
</script>

七 完整使用案例

将该组件已经应用到了我另外一个项目上,大家可以去下载下来查看。如使用上有什么问题,或出现什么 bug 也希望大家指出来。

八 源码

ColumnSelect 组件

js 复制代码
<template>
  <el-dropdown :hide-on-click="false" @command="handleselectColumn">
    <el-icon class="el-icon--right" size="20">
      <Menu />
    </el-icon>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item :command="item.prop" v-for="(item, i) in props.columnList" :key="i">
          <span style="padding-right: 20px">{{ renderLabel(item) }}</span>
          <el-icon v-if="props.showProp.includes(item.prop)">
            <Check />
          </el-icon>
          <span v-else style="width: 14px; height: 14px"></span>
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>
<script setup>
import { defineEmits, defineProps, watch, ref } from 'vue'
const props = defineProps({
  showProp: {
    type: Array,
    default: () => []
  },
  columnList: {
    type: Array,
    default: () => []
  }
})
const emits = defineEmits(['columnChange'])

const columnMap = ref({})
watch(
  () => props.columnList,
  () => {
    columnMap.value = props.columnList.reduce((acc, cur) => {
      acc[cur.prop] = {
        ...cur
      }
      return acc
    }, {})
  },
  { immediate: true }
)

const handleselectColumn = (prop) => {
  const list = props.showProp
  if (!list.includes(prop)) {
    list.push(prop)
  } else {
    const i = list.findIndex((item) => item === prop)
    list.splice(i, 1)
  }

  let propList = []
  Object.keys(columnMap.value).forEach((key) => {
    if (list.includes(key)) {
      propList.push(key)
    }
  })

  propList = [...new Set([...props.showProp, ...propList])]
  emits('columnChange', propList)
}

const renderLabel = (column) => {
  if (typeof column.label === 'function') {
    const component = column.label(column)
    return typeof component === 'string' ? component : ''
  }

  return column.label
}
</script>
<style lang="scss" scoped>
::v-deep .el-dropdown-menu__item {
  justify-content: space-between;
}
</style>

vhTable 组件

js 复制代码
<template>
  <div class="table-container">
    <el-table ref="tableRef" v-loading="props.load" :data="tableData"
      :height="props.height ? props.height : defaultConfig.tableHeight" :stripe="defaultConfig.stripe"
      :border="defaultConfig.border" @sort-change="handleSortChange" @selection-change="handleSelectionChange"
      :default-sort="props.defaultSort">

      <!-- selection -->
      <el-table-column v-if="props.showColumn.includes('selection')" :fixed="defaultConfig.selectionFixed"
        type="selection" />

      <!-- index -->
      <el-table-column v-if="props.showColumn.includes('index')" :width="defaultConfig.indexWidth"
        :label="defaultConfig.indexLabel" :fixed="defaultConfig.indexFixed" :index="props.indexMethod" type="index" />

      <!-- 其他列 -->
      <template v-for="(column, i) in props.columnList" :key="i">
        <el-table-column v-if="props.showColumn.includes(column.prop)" :min-width="column.minWidth ? column.minWidth : defaultConfig.columnMinWidth
          " :showOverflowTooltip="column.showOverflowTooltip" :sortable="column.sortable ? 'custom' : false"
          :fixed="column.fixed" :align="column.align" :prop="column.prop">
          <template #header>
            <component v-if="column.component && column.component.enable" :is="renderLabel(column)" />
            <span v-else>
              {{
                typeof column.label === 'function'
                  ? column.label(column)
                  : column.label
              }}
            </span>
          </template>
          <template #default="scope">
            <component v-if="column.component && column.component.enable" :is="renderValue(column, scope)" />
            <span v-else>
              {{
                typeof column.value === 'function'
                  ? column.value(scope.row, column.prop, scope.$index)
                  : scope.row[column.prop]
              }}
            </span>
          </template>
        </el-table-column>
      </template>

      <!-- 操作列 -->
      <el-table-column v-if="props.operation && props.operation.length != 0" :label="defaultConfig.operationLabel"
        :fixed="defaultConfig.operationFixed" :width="defaultConfig.operationWidth">
        <template #default="scope">
          <template v-for="(oper, i) in operation" :key="i">
            <template v-if="!oper.isConfirm">
              <el-button v-if="
                typeof oper.hidden === 'function'
                  ? !oper.hidden(scope.row, scope.$index)
                  : true
              " :disabled="typeof oper.disabled === 'function'
                ? oper.disabled(scope.row, scope.$index)
                : false
                " :size="defaultConfig.operationSize" :type="oper.type" :icon="oper.icon"
                @click="handleOperEvent(oper, scope.row, scope.$index)">{{
                  typeof oper.label === 'function'
                    ? oper.label(scope.row, scope.$index)
                    : oper.label
                }}</el-button>
            </template>
            <template v-else>
              <el-popconfirm confirm-button-text="是" cancel-button-text="否" title="确定删除?"
                @confirm="handleOperEvent(oper, scope.row, scope.$index)">
                <template #reference>
                  <el-button v-if="
                    typeof oper.hidden === 'function'
                      ? !oper.hidden(scope.row, scope.$index)
                      : true
                  " :disabled="typeof oper.disabled === 'function'
                    ? oper.disabled(scope.row, scope.$index)
                    : false
                    " :size="defaultConfig.operationSize" :type="oper.type" :icon="oper.icon">{{
                      typeof oper.label === 'function'
                        ? oper.label(scope.row, scope.$index)
                        : oper.label
                    }}</el-button>
                </template>
              </el-popconfirm>
            </template>
          </template>
        </template>
      </el-table-column>

    </el-table>
  </div>

  <!-- 分页器 -->
  <div class="pagination-container">
    <el-pagination v-if="props.hasPagination" v-model:current-page="pageNum" v-model:page-size="pageSize"
      :total="props.pagination.total" :size="defaultConfig.paginationSize" :page-size="props.pagination.pageSizes"
      :background="props.pagination.background" :layout="props.pagination.layout" @change="handlePaginationChange" />
  </div>
</template>
<script setup>
import {
  defineEmits,
  defineProps,
  ref,
  reactive,
  watch,
  nextTick,
  onMounted
} from 'vue'
import Sortable from 'sortablejs'
const props = defineProps({
  load: {
    type: Boolean,
    default: () => false
  },
  showColumn: {
    type: Array,
    default: () => []
  },
  columnList: {
    type: Array,
    default: () => []
  },
  indexMethod: {
    type: Function,
    default: () => { }
  },
  operation: {
    type: Array,
    default: () => []
  },
  height: {
    type: Number,
    default: 0
  },
  data: {
    type: Array,
    default: () => []
  },
  hasPagination: {
    type: Boolean,
    default: () => false
  },
  pagination: {
    type: Object,
    default: () => { }
  },
  defaultSort: {
    type: Object,
    default: () => { }
  },
  issortable: {
    type: Boolean,
    default: false
  }
})

const emits = defineEmits([
  'paginationChange',
  'selectionChange',
  'selectable',
  'sortChange',
  'sortRowChange'
])

const defaultConfig = reactive({
  stripe: true,
  border: true,
  tableHeight: 500,
  selectionFixed: 'left',
  radioFixed: 'left',
  radioWidth: 55,
  indexFixed: 'left',
  indexLabel: '#',
  indexWidth: 55,
  columnMinWidth: 180,
  operationLabel: '操作',
  operationWidth: 200,
  operationFixed: 'right',
  operationSize: 'small',
  paginationSize: 'small'
})

const tableRef = ref(null)
const order = ref('ascending')
const orderProp = ref('')
const remoteSortColumn = ref([])
const tableData = ref([])
const columnMap = ref({})
const pageNum = ref(0)
const pageSize = ref(10)

watch(
  () => props.defaultSort,
  () => {
    if (props.defaultSort) {
      const { prop, order: ord } = props.defaultSort
      order.value = ord
      orderProp.value = prop
    }
  },
  {
    immediate: true
  }
)

watch(
  () => props.data,
  () => {
    tableData.value = [...props.data]
    if (
      orderProp.value &&
      order.value &&
      !remoteSortColumn.value.includes(orderProp.value)
    ) {
      nextTick(() => {
        tableRef.value && tableRef.value.sort(orderProp.value, order.value)
      })
    }
  },
  {
    immediate: true
  }
)

watch(
  () => props.columnList,
  (column) => {
    columnMap.value = column.reduce((acc, cur) => {
      acc[cur.prop] = {
        ...cur
      }
      return acc
    }, {})
    remoteSortColumn.value = column.reduce((acc, cur) => {
      if (cur.remote) {
        acc.push(cur.prop)
      }
      return acc
    }, [])
  },
  {
    immediate: true
  }
)

watch(
  () => props.pagination,
  (pagination) => {
    if (props.hasPagination) {
      pageNum.value = pagination.pageNum
      pageSize.value = pagination.pageSize
    }
  },
  {
    immediate: true,
    deep: true
  }
)

const renderLabel = (column) => {
  const { component = {} } = column
  const label = component.label ? component.label : ''
  if (typeof label === 'function') {
    const component = label(column)
    return typeof component === 'string'
      ? renderCustomComponent(component, 'cell')
      : component
  }
  return renderCustomComponent(label, 'cell')
}

const renderValue = (column, scope) => {
  const { component = {} } = column
  const value = component.value
  if (typeof value === 'function') {
    const component = value(scope.row, column.prop, scope.$index, column)
    return typeof component === 'string'
      ? renderCustomComponent(component, 'cell')
      : component
  }
  return value
    ? renderCustomComponent(value, 'cell')
    : renderCustomComponent(scope.row[column.prop], 'cell')
}

const renderCustomComponent = (text, customClass = '') => {
  return <span>{text}</span>
}

const defaultSortMethod = (a, b, prop) => {
  const sortType = columnMap.value[prop].sortType || 'string'

  if (sortType === 'number') {
    return a[prop] - b[prop]
  }

  return a[prop].localeCompare(b[prop])
}

const handleSortChange = ({ column, prop, order: ord }) => {
  let sortData = []

  if (ord == null) {
    const ord = order.value === 'ascending' ? 'descending' : 'ascending'
    order.value = ord
  }

  order.value = ord
  orderProp.value = prop

  // *不管是否为远程排序,都往外抛
  emits('sortChange', { column, prop, order: order.value })

  const sortMethod = columnMap.value[prop].sortMethod
  if (typeof sortMethod !== 'function') {
    sortData = tableData.value.sort((a, b) => {
      return ord === 'ascending'
        ? defaultSortMethod(a, b, prop)
        : defaultSortMethod(b, a, prop)
    })
  } else {
    sortData = tableData.value.sort((a, b) => {
      return sortMethod(a, b, order.value)
    })
  }

  tableData.value = sortData
}

const handlePaginationChange = () => {
  emits('paginationChange', {
    pageSize: pageSize.value,
    pageNum: pageNum.value
  })
}

const handleSelectionChange = (selection) => {
  emits('selectionChange', selection)
}

const handleOperEvent = (oper, row, i) => {
  if (typeof oper.event === 'function') {
    oper.event(row, i)
  }
}

const initSortable = () => {
  // 配置拖拽元素
  const el = tableRef.value.$el.querySelectorAll('.el-table__body > tbody')[0]
  Sortable.create(el, {
    // 拖拽时类名
    ghostClass: 'sortable-ghost',
    // 拖拽结束的回调方法
    onEnd(event) {
      const { newIndex, oldIndex } = event
      emits('sortRowChange', { newIndex, oldIndex, data: tableData.value[oldIndex] })
    }
  })
}

onMounted(() => {
  if (props.issortable) {
    initSortable(tableRef)
  }
})
</script>
<style lang="scss" scoped>
.pagination-container {
  display: flex;
  justify-content: end;
  margin-top: 12px;
}

::v-deep tr.sortable-ghost td {
  opacity: 0.6;
  background-color: var(--el-sortable-bgcolor) !important;
}
</style>

useTable 钩子

js 复制代码
import { watch, ref, toRaw } from 'vue'
import Excel from 'exceljs'
import { saveAs } from 'file-saver'
import JSZip from 'jszip'
import ExportWorker from './export.worker.js'

function splitArray(arr = [], limit) {
  const result = []
  for (let i = 0; i < arr.length; i += limit) {
    result.push(arr.slice(i, i + limit))
  }
  return result
}

export const useTable = ({
  // 默认显示的字段
  defaultColumn,
  // 所有字段
  column,
  // 请求数据方法
  fetchApi,
  // 是否有分页器
  hasPagination,
  // 保存 showColumnKey
  showColumnKey
}) => {
  const selectionRows = ref([])
  const showColumn = ref([])
  const columnList = ref([])
  const tableData = ref([])
  const total = ref(0)
  const pageNum = ref(0)
  const pageSize = ref(0)
  const loading = ref(false)
  const uploading = ref(false)
  const storageKey = ref(showColumnKey)

  // *监听 defaultColumn 是否发生变化,避免异步修改 defaultColumn 导致监听不到
  watch(
    () => defaultColumn.value,
    (val) => {
      showColumn.value = toRaw(val)
      if (storageKey.value) localStorage.setItem(storageKey.value, val)
    },
    {
      immediate: true,
      deep: true
    }
  )

  // *监听 column 是否发生变化,避免异步修改 column 导致监听不到
  watch(
    () => column.value,
    (val) => {
      columnList.value = toRaw(val)
    },
    {
      immediate: true,
      deep: true
    }
  )

  /**
   * 获取表格数据
   * @param {Object} params 上层传递的请求参数
   */
  const getTableData = async (params = {}) => {
    loading.value = true
    const {
      data,
      pageNum: num,
      pageSize: size,
      total: sum
    } = await fetchApi(params)

    tableData.value = data
    if (hasPagination) {
      pageNum.value = num
      pageSize.value = size
      total.value = sum
    }
    loading.value = false
  }

  /**
   * 索引方法(配合 vhTable 或 elTable 进行使用)
   * @param {Number} index 下标
   * @returns 当前行处理后的下标
   */
  const indexMethod = (index) => {
    if (!hasPagination) return index + 1
    const pageIndex = pageNum.value - 1 < 0 ? 0 : pageNum.value - 1
    return index + 1 + pageIndex * pageSize.value
  }

  /**
   * 选中行(配合 vhTable 或 elTable 进行使用)
   * @param {Object} rows 被选中的行
   */
  const onSelectionChange = (rows) => {
    selectionRows.value = toRaw(rows)
  }

  /**
   * 选中展示的列(配合 ColumnSelect 进行使用)
   * @param {Array} propList 被展示的列
   */
  const columnChange = (propList) => {
    showColumn.value = propList
  }

  /**
   * 根据 ColumnList 配置将数据进行转换
   * @param {Array || Object} rowList 数据(必填)
   * @param {Array} columnMap 配置信息
   * @returns 经过转换的数据
   */
  const transitionRow = (rowList, columnMap) => {
    if (!Array.isArray(rowList) && typeof rowList === 'object') rowList = [rowList]

    if (!columnMap) columnMap = getColumnMapConfig(false)

    const data = rowList.map(row => {
      const exportItem = Object.keys(columnMap).reduce((acc, cur) => {
        const customValueFunc = columnMap[cur].value
        acc[cur] = row[cur]

        if (typeof customValueFunc === 'function') acc[cur] = customValueFunc(row, cur)
        return acc
      }, {})
      return exportItem
    })

    return data
  }

  /**
   * 获取导出的列配置信息
   * @param {Boolean} isAllColumn 是否为所有列
   * @returns 列 Map
   */
  const getColumnMapConfig = (isAllColumn = false) => {
    const columnMap = columnList.value.filter(column => isAllColumn ? true : showColumn.value.includes(column.prop)).reduce((acc, cur) => {
      acc[cur.prop] = cur
      return acc
    }, {})

    return columnMap
  }

  /**
   * 生成符合要求的 Excel 数据
   * @param {Array} data 数据
   * @param {Array} isAllColumn 是否需要导出所有字段
   * @returns 符合 Excel 要求的数据
   */
  const generateExcelData = (data, isAllColumn) => {
    const columnMap = getColumnMapConfig(isAllColumn)

    const csvHeader = Object.keys(columnMap).map(key => {
      return {
        width: 30,
        key: key,
        header: typeof columnMap[key].label === 'function' ? columnMap[key].label(columnMap[key]) : columnMap[key].label
      }
    })

    const csvData = transitionRow(data, columnMap)

    return {
      csvData,
      csvHeader
    }
  }

  const exportDataByType = (config) => {
    const { excelData, fileName, isOpenWorker, iszip } = config

    // TODO 需要考虑一下 type

    if (isOpenWorker) {
      exportDataToXLSXByWorker(excelData, fileName, iszip)
    } else {
      exportDataToXLSX(excelData, fileName, iszip)
    }
  }

  /**
   * 导出所有数据
   * @param {String} fileName 文件名
   * @param {Array} data 数据
   * @param {Boolean} isOpenWorker 是否开启子进程
   */
  const exportAllData = async (
    {
      fileName = 'exportAllData',
      isOpenWorker = false,
      isAllColumn = false,
      limit = 1000,
      iszip = false,
      type = 'excel'
    }
  ) => {
    const {
      data
    } = await fetchApi({
      pageSize: total.value,
      pageNum: 0
    })

    if (data.length === 0) return

    const excelData = []
    if (iszip) { // 拆分数据
      const splitArr = splitArray(data, limit)
      splitArr.forEach((item, i) => {
        const { csvHeader, csvData } = generateExcelData(item, isAllColumn)
        excelData.push({
          csvHeader,
          csvData,
          fileName: `${fileName}_${i + 1}`
        })
      })
    } else {
      const { csvHeader, csvData } = generateExcelData(data, isAllColumn)
      excelData.push({
        csvHeader,
        csvData,
        fileName: `${fileName}`
      })
    }

    // 导出数据
    exportDataByType({
      type,
      excelData,
      isOpenWorker,
      fileName,
      iszip
    })
  }

  /**
   * 导出选中数据
   * @param {String} filename 文件名
   * @param {Boolean} isOpenWorker 是否开启子进程
   */
  const exportSelectedData = (
  {
    fileName = 'exportSelectedData',
    isOpenWorker = false,
    isAllColumn = false,
    limit = 1000,
    iszip = false,
    type = 'excel'
  }
  ) => {
    if (selectionRows.value.length === 0) return

    const excelData = []
    if (iszip) {
      const splitArr = splitArray(selectionRows.value, limit)
      splitArr.forEach((item, i) => {
        const { csvHeader, csvData } = generateExcelData(item, isAllColumn)
        excelData.push({
          csvHeader,
          csvData,
          fileName: `${fileName}_${i + 1}`
        })
      })
    } else {
      const { csvHeader, csvData } = generateExcelData(selectionRows.value, isAllColumn)
      excelData.push({
        csvHeader,
        csvData,
        fileName
      })
    }

    // 导出数据
    exportDataByType({
      type,
      excelData,
      iszip,
      isOpenWorker,
      fileName
    })
  }

  /**
   * 导出数据为 XLSX
   * @param {Object} csvHeader XLSX 头
   * @param {Array} csvData 数据
   * @param {String} filename 文件名
   */
  const exportDataToXLSX = async (excelData, fileName, iszip) => {
    if (!iszip) excelData = [excelData[0]]

    const excelBufferList = []
    await Promise.all(excelData.map(async (item, i) => {
      const workbook = new Excel.Workbook()
      const worksheet = workbook.addWorksheet('My Sheet')
      const { csvData, csvHeader, fileName = 'file' } = item

      worksheet.columns = csvHeader

      // 添加数据行
      csvData.forEach(row => worksheet.addRow(row))

      // 生成 Buffer
      const buffer = await workbook.xlsx.writeBuffer()
      excelBufferList.push({ fileName, buffer })
    }))

    if (!iszip) {
      const buffer = excelBufferList[0].buffer
      saveAs(new Blob([buffer]), `${fileName}.xlsx`)
    } else {
      const zip = new JSZip()
      excelBufferList.forEach((item) => {
        const { fileName = 'file', buffer } = item
        zip.file(`${fileName}.xlsx`, buffer)
      })
      const blobContent = await zip.generateAsync({ type: 'blob' })
      saveAs(blobContent, `${fileName}.zip`)
    }
  }

  /**
   * 导出数据为 XLSX(通过 web Worker)
   * @param {Object} csvHeader XLSX 头
   * @param {Array} csvData 数据
   * @param {String} filename 文件名
   */
  const exportDataToXLSXByWorker = (excelData, filename, iszip) => {
    const worker = new ExportWorker()

    // * 判断 csvData 中的值是否存在对象,需要序列化处理
    excelData = excelData.map((item) => {
      const { csvHeader, csvData } = item
      const keys = csvHeader.map(item => item.key)
      item.csvData = csvData.map(row => {
        return keys.reduce((acc, prev) => {
          acc[prev] = typeof row[prev] === 'object' ? JSON.stringify(row[prev]) : row[prev]
          return acc
        }, {})
      })
      return item
    })

    worker.postMessage({
      excel: excelData,
      iszip
    })

    worker.onmessage = async(e) => {
      const { chunk: blog, iszip } = e.data
      if (iszip) {
        saveAs(blog, `${filename}.zip`)
      } else {
        saveAs(blog, filename)
      }
    }
  }

  /**
   *  删除 localStorage 的 showProps
   * @param {String} key storage key 值
   */
  const delShowColumnStorage = (key = storageKey.value) => {
    localStorage.removeItem(key)
  }

  return {
    uploading,
    loading,
    tableData,
    showColumn,
    columnList,
    selectionRows,
    getTableData,
    indexMethod,
    exportAllData,
    exportSelectedData,
    onSelectionChange,
    delShowColumnStorage,
    columnChange,
    transitionRow,
    exportDataByType
  }
}

export.worker

js 复制代码
import Excel from 'exceljs'
import JSZip from 'jszip'
self.onmessage = async function(e) {
  const iszip = e.data.iszip
  let data = e.data.excel || []
  if (!iszip) data = [data[0]]
  const excelBufferList = []
  await Promise.all(data.map(async (item, i) => {
    const workbook = new Excel.Workbook()
    const worksheet = workbook.addWorksheet('My Sheet')
    const { csvData, csvHeader, fileName = 'file' } = item

    worksheet.columns = csvHeader

    // 添加数据行
    csvData.forEach(row => worksheet.addRow(row))

    // 生成 Buffer
    const buffer = await workbook.xlsx.writeBuffer()
    excelBufferList.push({ fileName, buffer })
  }))

  let blobContent = ''
  if (iszip) {
    const zip = new JSZip()
    excelBufferList.forEach((item) => {
      const { fileName, buffer } = item
      zip.file(`${fileName}.xlsx`, buffer)
    })
    blobContent = await zip.generateAsync({ type: 'blob' })
  } else {
    const buffer = excelBufferList[0].buffer
    blobContent = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
  }

  self.postMessage({ chunk: blobContent, iszip })
}
相关推荐
墨渊君13 分钟前
React Native 跨平台组件库实践: GlueStack UI 上手指南
前端
晓得迷路了21 分钟前
栗子前端技术周刊第 84 期 - Vite v7.0 beta、Vitest 3.2、Astro 5.9...
前端·javascript·vite
独立开阀者_FwtCoder24 分钟前
最全301/302重定向指南:从SEO到实战,一篇就够了
前端·javascript·vue.js
Moment33 分钟前
给大家推荐一个超好用的 Marsview 低代码平台 🤩🤩🤩
前端·javascript·github
小满zs37 分钟前
Zustand 第三章(状态简化)
前端·react.js
普宁彭于晏39 分钟前
元素水平垂直居中的方法
前端·css·笔记·css3
Tianyanxiao42 分钟前
华为×小鹏战略合作:破局智能驾驶深水区的商业逻辑深度解析
大数据·人工智能·经验分享·华为·金融·数据分析
恋猫de小郭1 小时前
为什么跨平台框架可以适配鸿蒙,它们的技术原理是什么?
android·前端·flutter
云浪1 小时前
元素变形记:CSS 缩放函数全指南
前端·css
明似水1 小时前
用 Melos 解决 Flutter Monorepo 的依赖冲突:一个真实案例
前端·javascript·flutter