虚拟化表格(Virtualized Table)性能优化
文章目录
- 功能介绍
- 一开始的代码
- 领导让我们分析一下
- 开始优化
- 如何监听事件和传参?
- 定位操作栏
- 更加优化
功能介绍
菜鸟最近做的一个功能如下:
后端返回两个很大的数组,例如:数组a 1w条,数组b 2w条,然后要操作b的数据去a里面,然后操作a的去b里面,最后把修改后的数组a和数组b返回给后端!且这个操作,是可以撤销的,用户操作了,但是没保存,是可以直接叉了,就不改后端数据!且数据还是可以搜索的!
一开始的代码
菜鸟一开始其实也考虑到了性能问题,但是当时是测试环境,最多就几十条数据,用 el-table 完全够用,且当时用 Virtualized Table 虚拟化表格 来渲染的时候老是 eslint 报错,所以当时就没管了!
接下来是老的代码
<script setup>
import { Search } from '@element-plus/icons-vue'
import { getExcelApi, saveReportInfoApi } from '@/network/analysisApi'
const props = defineProps({
dialogVisible: {
type: Boolean,
default: false
},
id: {
type: Number,
default: -1
}
})
const emit = defineEmits(['closeEvent'])
// 关闭弹窗
function handleClose() {
emit('closeEvent', false)
}
const dialogBox = ref()
function closeDialog() {
dialogBox.value.resetFields()
}
// 需要返回后端的数据
const subformData = {
fileName: '',
id: props.id,
reportAPath: '',
outputPath: ''
}
// 表数据
let reportA = ref([])
let reportB = ref([])
let oldreportA = []
let oldreportB = []
const loading = ref(true)
// 获取数据
getExcelApi(props.id)
.then((res) => {
console.log(res)
if (res.code == 200) {
subformData.fileName = res.data.fileName
subformData.reportAPath = res.data.reportAPath
subformData.outputPath = res.data.outputPath
reportA.value = res.data.reportA
reportB.value = res.data.reportB
oldreportA = res.data.reportA
oldreportB = res.data.reportB
if (res.data.reportB.length <= 50 && res.data.reportA.length <= 50) {
loading.value = false
} else {
setTimeout(() => {
loading.value = false
}, 5000)
}
} else {
ElMessage({
message: res.message,
type: 'error'
})
}
})
.catch((err) => {
console.log(err)
})
// 搜索
let searchVal = ref('')
const search = () => {
reportA.value = oldreportA.filter(function (i) {
return i.patientName.includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i.patientName.includes(searchVal.value)
})
}
// 表A减数据
const reduceFun = (e) => {
let index = oldreportA.findIndex(
(item) =>
item.id === e.row.id &&
item.barcode === e.row.barcode &&
item.patientName === e.row.patientName &&
item.species === e.row.species
)
let data = oldreportA.splice(index, 1)
oldreportB.splice(0, 0, data[0])
reportA.value = oldreportA.filter(function (i) {
return i.patientName.includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i.patientName.includes(searchVal.value)
})
}
// 表B加数据
const addFun = (e) => {
let index = oldreportB.findIndex(
(item) =>
item.id === e.row.id &&
item.barcode === e.row.barcode &&
item.patientName === e.row.patientName &&
item.species === e.row.species
)
let data = oldreportB.splice(index, 1)
oldreportA.splice(0, 0, data[0])
reportA.value = oldreportA.filter(function (i) {
return i.patientName.includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i.patientName.includes(searchVal.value)
})
}
// 提交表单
const submit = () => {
subformData.reportA = oldreportA
subformData.reportB = oldreportB
saveReportInfoApi(subformData)
.then((res) => {
console.log(res)
if (res.code === 200) {
ElMessage({
message: '提交审核成功!',
type: 'success'
})
handleClose()
} else {
ElMessage({
message: res.message,
type: 'error'
})
}
})
.catch((err) => {
console.log(err)
})
}
// 定义table的表头
const columns = [
{
title: '测序批次',
dataKey: 'batch',
width: 300
},
{
title: 'barcode',
dataKey: 'barcode',
width: 100
},
{
title: '患者姓名',
dataKey: 'patientName',
width: 100
},
{
title: '体系',
dataKey: 'structure',
width: 350
},
{
title: '样本编号',
dataKey: 'sampleNum',
width: 150
},
{
title: '报告编号',
dataKey: 'reportNum',
width: 150
},
{
title: '样本类型',
dataKey: 'sampleType',
width: 150
},
{
title: '提取Reads总数',
dataKey: 'extractReads',
width: 140
},
{
title: '样本比对总reads',
dataKey: 'sampleContrastReads',
width: 150
},
// 内参检出情况
// 分类
{
title: 'Species',
dataKey: 'species',
width: 300
},
{
title: '物种中文名',
dataKey: 'speciesCn',
width: 250
},
{
title: '物种比对Reads数',
dataKey: 'speciesContrastReads',
width: 150
},
// 样本检出靶标数
// 特异靶标数
{
title: '综合可信度',
dataKey: 'credibility',
width: 150
},
{
title: 'DNC的Reads数',
dataKey: 'dncReads',
width: 150
},
{
title: '样本质控总reads',
dataKey: 'qualityReads',
width: 150
},
// DNC的靶标数
{
title: '同批最大reads数',
dataKey: 'maxReads',
width: 150
},
// 同批最高bc
// 同批最高核酸编号
{
title: '物种类别',
dataKey: 'speciesCategory',
width: 150
},
{
title: '定植情况',
dataKey: 'planting',
width: 400
},
{
title: '结果解释',
dataKey: 'resultExplain',
width: 800
},
{
title: '物种所在盘',
dataKey: 'speciesDisk',
width: 250
},
// 表中没有可能要修改
{
title: 'Genus',
dataKey: 'genus',
width: 150
},
{
title: '属名',
dataKey: 'genericName',
width: 150
},
{
title: '危害程度分类',
dataKey: 'harm',
width: 150
},
{
title: '检出数/10000',
dataKey: 'detectionNumber',
width: 150
},
{
title: '单样本Score',
dataKey: 'sampleScore',
width: 150
}
]
</script>
<template>
<div>
<el-dialog
title="结果筛选"
ref="dialogBox"
:modelValue="dialogVisible"
:before-close="handleClose"
@close="closeDialog"
width="90%"
top="30px"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<div style="display: flex; width: 300px">
<el-input v-model="searchVal" placeholder="患者姓名" clearable></el-input>
<el-button style="margin-left: 50px" type="primary" :icon="Search" @click="search"
>搜索</el-button
>
</div>
<hr />
<p>表格A</p>
<div style="height: 300px">
<el-table
v-loading="loading"
element-loading-text="加载中..."
:data="reportA"
style="width: 100%; height: 100%"
>
<template v-for="(item, index) in columns" :key="index">
<el-table-column :prop="item.dataKey" :label="item.title" :width="item.width" />
</template>
<el-table-column fixed="right" label="操作" width="80" center>
<template #default="scope">
<el-button type="primary" size="small" @click="reduceFun(scope)"> - </el-button>
</template>
</el-table-column>
</el-table>
</div>
<hr />
<p>表格B</p>
<div style="height: 300px">
<el-table
v-loading="loading"
element-loading-text="加载中..."
:data="reportB"
style="width: 100%; height: 100%"
>
<template v-for="(item, index) in columns" :key="index">
<el-table-column :prop="item.dataKey" :label="item.title" :width="item.width" />
</template>
<el-table-column fixed="right" label="操作" width="80" center>
<template #default="scope">
<el-button type="primary" size="small" @click="addFun(scope)"> + </el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<div>
<el-button type="primary" @click="submit">提交</el-button>
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
但是这段代码在生产环境中就完全不够看了,生产环境不管是reportA还是reportB都是几千条左右,即使1秒就获取到了后端数据,但是 el-table 加载就要几秒钟,所以菜鸟直接写了一个5秒的定时器,等5秒后差不多渲染完了才把蒙层关闭(有点掩耳盗铃的感觉)!
重点是当菜鸟滚动列表的时候,那叫一个卡顿,且如果进行了移动数据的操作,那又会一卡一卡的,如果加上搜索,卡顿得让人难以想象!
领导让我们分析一下
卡成这样,用户肯定是受不了,所以领导就找我们分析原因!
菜鸟感觉前端数据量有点大,不如:用分页搜索并配合后端一起解决!但是很快被后端否决了,因为很麻烦,例如:
a查了10条,b查了10条,操作了b的一条去了a,那a点击第二页应该就是9-19条,而不是之前的10-20条,b也会变成1-11条(去掉操作的那一条),而不是1-10条了!
每一个操作都要向后端去请求,并告诉后端,数组a增加了哪一个、减少了哪一个、数组b增加了哪一个、减少了哪一个,交给后端去处理分页(并不改数组库)!
显然上面的这个思路得上千万条数据可能会使用的,菜鸟这个还不至于!
所以将思路定为前端性能优化,领导直接发话:这优化不了就该优化菜鸟我了!
开始优化
既然分到自己头上了,那就只能干了!奥里给!
菜鸟想起来了之前的 Virtualized Table 虚拟化表格 ,当时使用确实会报错,但是把那个报错的代码删除,确实反应很快,只是当时没有深究如何解决报错,想着偷懒去了,现在就硬上了!
所以代码改成了这样:
<script setup>
import { Search } from '@element-plus/icons-vue'
import { getExcelApi, saveReportInfoApi } from '@/network/analysisApi'
const props = defineProps({
dialogVisible: {
type: Boolean,
default: false
},
id: {
type: Number,
default: -1
}
})
const emit = defineEmits(['closeEvent'])
// 关闭弹窗
function handleClose() {
emit('closeEvent', false)
}
const dialogBox = ref()
function closeDialog() {
dialogBox.value.resetFields()
}
// 需要返回后端的数据
const subformData = {
fileName: '',
id: props.id,
reportAPath: '',
outputPath: ''
}
// 表数据
let reportA = ref([])
let reportB = ref([])
// 保存表头 --》 操作columns
let reportATitle = []
let reportBTitle = []
const columnsA = ref([])
const columnsB = ref([])
// 用于提交的数据
let oldreportA = []
let oldreportB = []
// 加载
const loading = ref(true)
// 获取数据
getExcelApi(props.id)
.then((res) => {
console.log(res)
if (res.code == 200) {
subformData.fileName = res.data.fileName
subformData.reportAPath = res.data.reportAPath
subformData.outputPath = res.data.outputPath
reportA.value = res.data.reportA
const tempA = reportA.value[0]
for (let i in tempA) {
console.log(i)
if (i == 'id') continue
reportATitle.push(i)
}
for (let i in reportATitle) {
columnsA.value.push({
title: reportATitle[i],
key: reportATitle[i],
dataKey: reportATitle[i],
width: reportATitle[i] === '结果解释' || reportATitle[i] === '描述' ? 1200 : 200
})
}
columnsA.value.push({
key: 'operations',
title: '操作',
cellRenderer: () => (
<>
<ElButton type="primary" size="small">
-
</ElButton>
</>
),
width: 80,
align: 'center'
})
reportB.value = res.data.reportB
const tempB = reportB.value[0]
for (let i in tempB) {
if (i == 'id') continue
reportBTitle.push(i)
}
for (let i in reportBTitle) {
columnsB.value.push({
title: reportBTitle[i],
key: reportBTitle[i],
dataKey: reportBTitle[i],
width: reportBTitle[i] === '结果解释' || reportBTitle[i] === '描述' ? 1200 : 200
})
}
columnsB.value.push({
key: 'operations',
title: '操作',
cellRenderer: () => (
<>
<ElButton type="primary" size="small">
+
</ElButton>
</>
),
width: 80,
align: 'center'
})
oldreportA = res.data.reportA
oldreportB = res.data.reportB
loading.value = false
} else {
ElMessage({
message: res.message,
type: 'error'
})
}
})
.catch((err) => {
console.log(err)
})
// 搜索
let searchVal = ref('')
const search = () => {
reportA.value = oldreportA.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
}
const uniqueItem = ['id', 'Barcode', '患者姓名', 'Species']
// 表A减数据
const reduceFun = (e) => {
console.log(e)
let index = oldreportA.findIndex((item) => {
console.log(item)
let num = 0
for (let i of uniqueItem) {
if (item[i] === e[i]) {
num++
}
}
return num === 4
})
let data = oldreportA.splice(index, 1)
oldreportB.splice(0, 0, data[0])
reportA.value = oldreportA.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
}
// 表B加数据
const addFun = (e) => {
console.log(e)
let index = oldreportB.findIndex((item) => {
console.log(item)
let num = 0
for (let i of uniqueItem) {
if (item[i] === e[i]) {
num++
}
}
return num === 4
})
let data = oldreportB.splice(index, 1)
oldreportA.splice(0, 0, data[0])
reportA.value = oldreportA.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
}
// 提交表单
const submit = () => {
subformData.reportA = oldreportA
subformData.reportB = oldreportB
saveReportInfoApi(subformData)
.then((res) => {
console.log(res)
if (res.code === 200) {
ElMessage({
message: '提交审核成功!',
type: 'success'
})
handleClose()
} else {
ElMessage({
message: res.message,
type: 'error'
})
}
})
.catch((err) => {
console.log(err)
})
}
</script>
<template>
<div>
<el-dialog
title="结果筛选"
ref="dialogBox"
:modelValue="dialogVisible"
:before-close="handleClose"
@close="closeDialog"
width="90%"
top="30px"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<div style="display: flex; width: 300px">
<el-input v-model="searchVal" placeholder="患者姓名" clearable></el-input>
<el-button style="margin-left: 50px" type="primary" :icon="Search" @click="search"
>搜索</el-button
>
</div>
<hr />
<p>表格A</p>
<div style="height: 300px">
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2 :columns="columnsA" :data="reportA" :width="width" :height="height" fixed>
<template #overlay v-if="loading">
<div
class="el-loading-mask"
style="display: flex; align-items: center; justify-content: center"
>
<el-icon class="is-loading">
<i-ep-loading />
</el-icon>
</div>
</template>
</el-table-v2>
</template>
</el-auto-resizer>
</div>
<hr />
<p>表格B</p>
<div style="height: 300px">
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2 :columns="columnsB" :data="reportB" :width="width" :height="height" fixed>
<template #overlay v-if="loading">
<div
class="el-loading-mask"
style="display: flex; align-items: center; justify-content: center"
>
<el-icon class="is-loading">
<i-ep-loading />
</el-icon>
</div>
</template>
</el-table-v2>
</template>
</el-auto-resizer>
</div>
<template #footer>
<div>
<el-button type="primary" @click="submit">提交</el-button>
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
果然不出所料,还是报错:
Parsing error: Unexpected token <
这里菜鸟直接反手 ChatGPT,问了一个:
然后菜鸟就知道了,原来是jsx搞的鬼,知道了原因,解决就很快了,直接反手再来一发 ChatGPT :
然后配置一下 .eslintrc.cjs
配置好了之后发现没有报错,项目可以运行,自信满满点到这个界面,发现还是报错:
问了ChatGPT 发现是要在script
标签上加个lang="jsx"
:
完美解决!
如何监听事件和传参?
这样渲染倒是上去了,但是jsx菜鸟不会呀,咋监听按钮的点击事件?咋传参数?
菜鸟只能一个一个尝试了!菜鸟想着都是 element 应该传值是一样的吧,所以变成了这样!
columnsA.value.push({
key: 'operations',
title: '操作',
cellRenderer: (row) => (
<>
<ElButton type="primary" size="small" @click="addFun(row)">
-
</ElButton>
</>
),
width: 80,
align: 'center'
})
但是直接报错
继续问 GPT:
然后菜鸟写成了这样
columnsA.value.push({
key: 'operations',
title: '操作',
cellRenderer: (row) => (
<>
<ElButton type="primary" size="small" onClick="addFun(row)">
-
</ElButton>
</>
),
width: 80,
align: 'center'
})
发现还是没有用,但是不报错了,果断 GPT:
最后代码成型:
<script lang="jsx" setup>
import { Search } from '@element-plus/icons-vue'
import { getExcelApi, saveReportInfoApi } from '@/network/analysisApi'
const props = defineProps({
dialogVisible: {
type: Boolean,
default: false
},
id: {
type: Number,
default: -1
}
})
const emit = defineEmits(['closeEvent'])
// 关闭弹窗
function handleClose() {
emit('closeEvent', false)
}
const dialogBox = ref()
function closeDialog() {
dialogBox.value.resetFields()
}
// 需要返回后端的数据
const subformData = {
fileName: '',
id: props.id,
reportAPath: '',
outputPath: ''
}
// 表数据
let reportA = ref([])
let reportB = ref([])
// 保存表头 --》 操作columns
let reportATitle = []
let reportBTitle = []
const columnsA = ref([])
const columnsB = ref([])
// 用于提交的数据
let oldreportA = []
let oldreportB = []
// 加载
const loading = ref(true)
// 获取数据
getExcelApi(props.id)
.then((res) => {
console.log(res)
if (res.code == 200) {
subformData.fileName = res.data.fileName
subformData.reportAPath = res.data.reportAPath
subformData.outputPath = res.data.outputPath
reportA.value = res.data.reportA
const tempA = reportA.value[0]
for (let i in tempA) {
console.log(i)
if (i == 'id') continue
reportATitle.push(i)
}
for (let i in reportATitle) {
columnsA.value.push({
title: reportATitle[i],
key: reportATitle[i],
dataKey: reportATitle[i],
width: reportATitle[i] === '结果解释' || reportATitle[i] === '描述' ? 1200 : 200
})
}
columnsA.value.push({
key: 'operations',
title: '操作',
cellRenderer: (row) => (
<>
<ElButton type="primary" size="small" onClick={() => reduceFun(row.rowData)}>
-
</ElButton>
</>
),
width: 80,
align: 'center'
})
reportB.value = res.data.reportB
const tempB = reportB.value[0]
for (let i in tempB) {
if (i == 'id') continue
reportBTitle.push(i)
}
for (let i in reportBTitle) {
columnsB.value.push({
title: reportBTitle[i],
key: reportBTitle[i],
dataKey: reportBTitle[i],
width: reportBTitle[i] === '结果解释' || reportBTitle[i] === '描述' ? 1200 : 200
})
}
columnsB.value.push({
key: 'operations',
title: '操作',
cellRenderer: (row) => (
<>
<ElButton type="primary" size="small" onClick={() => addFun(row.rowData)}>
+
</ElButton>
</>
),
width: 80,
align: 'center'
})
oldreportA = res.data.reportA
oldreportB = res.data.reportB
loading.value = false
} else {
ElMessage({
message: res.message,
type: 'error'
})
}
})
.catch((err) => {
console.log(err)
})
// 搜索
let searchVal = ref('')
const search = () => {
reportA.value = oldreportA.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
}
const uniqueItem = ['id', 'Barcode', '患者姓名', 'Species']
// 表A减数据
const reduceFun = (e) => {
console.log(e)
let index = oldreportA.findIndex((item) => {
console.log(item)
let num = 0
for (let i of uniqueItem) {
if (item[i] === e[i]) {
num++
}
}
return num === 4
})
let data = oldreportA.splice(index, 1)
oldreportB.splice(0, 0, data[0])
reportA.value = oldreportA.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
}
// 表B加数据
const addFun = (e) => {
console.log(e)
let index = oldreportB.findIndex((item) => {
console.log(item)
let num = 0
for (let i of uniqueItem) {
if (item[i] === e[i]) {
num++
}
}
return num === 4
})
let data = oldreportB.splice(index, 1)
oldreportA.splice(0, 0, data[0])
reportA.value = oldreportA.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
reportB.value = oldreportB.filter(function (i) {
return i['患者姓名'].includes(searchVal.value)
})
}
// 提交表单
const submit = () => {
subformData.reportA = oldreportA
subformData.reportB = oldreportB
saveReportInfoApi(subformData)
.then((res) => {
console.log(res)
if (res.code === 200) {
ElMessage({
message: '提交审核成功!',
type: 'success'
})
handleClose()
} else {
ElMessage({
message: res.message,
type: 'error'
})
}
})
.catch((err) => {
console.log(err)
})
}
</script>
<template>
<div>
<el-dialog
title="结果筛选"
ref="dialogBox"
:modelValue="dialogVisible"
:before-close="handleClose"
@close="closeDialog"
width="90%"
top="30px"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<div style="display: flex; width: 300px">
<el-input v-model="searchVal" placeholder="患者姓名" clearable></el-input>
<el-button style="margin-left: 50px" type="primary" :icon="Search" @click="search"
>搜索</el-button
>
</div>
<hr />
<p>表格A</p>
<div style="height: 300px">
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2 :columns="columnsA" :data="reportA" :width="width" :height="height" fixed>
<template #overlay v-if="loading">
<div
class="el-loading-mask"
style="display: flex; align-items: center; justify-content: center"
>
<el-icon class="is-loading">
<i-ep-loading />
</el-icon>
</div>
</template>
</el-table-v2>
</template>
</el-auto-resizer>
</div>
<hr />
<p>表格B</p>
<div style="height: 300px">
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2 :columns="columnsB" :data="reportB" :width="width" :height="height" fixed>
<template #overlay v-if="loading">
<div
class="el-loading-mask"
style="display: flex; align-items: center; justify-content: center"
>
<el-icon class="is-loading">
<i-ep-loading />
</el-icon>
</div>
</template>
</el-table-v2>
</template>
</el-auto-resizer>
</div>
<template #footer>
<div>
<el-button type="primary" @click="submit">提交</el-button>
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
定位操作栏
但是这样了,菜鸟还是不满足,感觉一般这个操作都是要固定起来,而不是在最后一行看不见!
然后菜鸟又踩坑了,发现就是定位不了,最后对比官方网站找到原因,发现是需要从 element plus 里面引入 TableV2FixedDir
简化后的代码 (避免大家看很多,提出来这个部分了):
import { TableV2FixedDir } from 'element-plus'
columnsB.value.push({
key: 'operations',
title: '操作',
cellRenderer: (row) => (
<>
<ElButton type="primary" size="small" onClick={() => addFun(row.rowData)}>
+
</ElButton>
</>
),
width: 80,
align: 'center',
fixed: TableV2FixedDir.RIGHT
})
更加优化
这个是菜鸟沸点底下的大佬给的思路:
所以这里把ref 换成 shallowRef
let reportA = shallowRef([])
let reportB = shallowRef([])
到此性能优化完成,可能对大佬来说真的很简单,但是菜鸟还是感觉有点成就感,毕竟性能提升了50倍以上!