一 背景
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 包
相比较上面代码,将 csvHeader
和 csvData
放入数组中,一个 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 })
}