前言
最近兩家公司都遇到了全選全頁+批量操作的功能場景,即點擊全選所有的時候需要勾選所有數據包括非當前頁的。
方案
如果純前端分頁可以參考 antdv.table,一般主流的組件庫都給封裝好了。
後端分頁一種方案是:
基於組件庫的現有能力,全選所有時設置 pageSize 為無窮大並調用列表接口得到全量數據賦值給 selectedRowKeys 即可。但是這套方案最大的問題在於點擊全選所有時需要等待後端接口返回,這樣的交互延遲是無法忍受的!且全選所有+批量操作兩次請求的服務端資源浪費也是巨大的。
因此基於後端分頁的前提,提出了另一套合理解決方案:
通過 isAll 判斷是否為全選所有,如果是的話配合 excludeKeys、否則配合 includeKeys 的值完成返顯。最後業務中調用批量操作接口的時候還需要傳篩選項 searchParams。
實現
框架為 vue3 + antdv,代碼如下:
CTable.vue
<!-- CTable -->
<template>
<a-table
v-bind="$attrs"
:columns="columns"
>
<template #headerCell="{ column }" v-if="!$slots.headerCell">
<template v-if="column.dataIndex === '_checkbox_'">
<CTableHeaderCheckbox
ref="cTableHeaderCheckboxRef"
:rowKey="rowKey"
:dataSource="dataSource"
:total="total"
v-model:isAll="isAll"
v-model:includeKeys="includeKeys"
v-model:excludeKeys="excludeKeys"
:judgeToggleIsAll="judgeToggleIsAll"
/>
</template>
</template>
<template #bodyCell="{ record, column }" v-if="!$slots.bodyCell">
<template v-if="column.dataIndex === '_checkbox_'">
<CTableBodyCheckbox
:record="record"
:rowKey="rowKey"
:isAll="isAll"
:includeKeys="includeKeys"
:excludeKeys="excludeKeys"
:judgeToggleIsAll="judgeToggleIsAll"
/>
</template>
</template>
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" v-if="name === 'headerCell'">
<CTableHeaderCheckbox
v-if="slotProps.column.dataIndex === '_checkbox_'"
ref="cTableHeaderCheckboxRef"
:rowKey="rowKey"
:dataSource="dataSource"
:total="total"
v-model:isAll="isAll"
v-model:includeKeys="includeKeys"
v-model:excludeKeys="excludeKeys"
:judgeToggleIsAll="judgeToggleIsAll"
/>
</slot>
<slot :name="name" v-bind="slotProps" v-if="name === 'bodyCell'">
<CTableBodyCheckbox
v-if="slotProps.column.dataIndex === '_checkbox_'"
:record="slotProps.record"
:rowKey="rowKey"
:isAll="isAll"
:includeKeys="includeKeys"
:excludeKeys="excludeKeys"
:judgeToggleIsAll="judgeToggleIsAll"
/>
</slot>
<slot :name="name" v-bind="slotProps" v-else></slot>
</template>
</a-table>
</template>
<script lang="ts" setup>
import { Table, TableColumnProps } from 'ant-design-vue';
import CTableHeaderCheckbox from './CTableHeaderCheckbox.vue';
import CTableBodyCheckbox from './CTableBodyCheckbox.vue';
const props = withDefaults(
defineProps<{
columns: TableColumnProps[],
allSelection?: {
onCheckboxChange:(data) => void,
} | null,
}>(),
{
columns: () => [],
allSelection: null,
},
);
const $attrs: any = useAttrs();
const $slots = useSlots();
const cTableHeaderCheckboxRef = ref();
const columns = computed(() => {
if (props.allSelection) {
return [
{
title: '多選',
dataIndex: '_checkbox_',
fixed: 'left',
width: 48,
customHeaderCell: () => ({ class: 'ant-table-checkbox-column' }),
},
...props.columns,
];
}
return props.columns;
});
// 是否全選所有
const isAll = ref(false);
// 未全選所有時勾選數據
const includeKeys = ref<string[]>([]);
// 全選所有時反選數據
const excludeKeys = ref<string[]>([]);
const rowKey = computed(() => $attrs.rowKey || $attrs['row-key']);
const dataSource = computed(() => $attrs.dataSource || $attrs['data-source']);
// 表單數據可能存在disabled不可選擇狀態,此時需要後端返回enabledTotal幫助判斷
const total = computed(() => $attrs.pagination?.enabledTotal || $attrs.pagination?.total || $attrs.enabledTotal || $attrs.total);
// 已勾選總數,幫助業務展示
const checkedTotal = computed(() => (isAll.value ? total.value - excludeKeys.value.length : includeKeys.value.length));
// 當選擇數據發生改變時,需要判斷是否切換全選狀態
const judgeToggleIsAll = () => {
if (isAll.value && excludeKeys.value.length && excludeKeys.value.length === total.value) {
isAll.value = false;
includeKeys.value = [];
excludeKeys.value = [];
}
if (!isAll.value && includeKeys.value.length && includeKeys.value.length === total.value) {
isAll.value = true;
includeKeys.value = [];
excludeKeys.value = [];
}
};
// 當源數據發生改變時,手動重置選擇框狀態
const onResetCheckbox = () => {
cTableHeaderCheckboxRef.value.handleMenu({ key: Table.SELECTION_NONE });
};
// 有任何選擇變化時,同步回傳給父組件
watch(
[isAll, includeKeys, excludeKeys],
() => {
props.allSelection?.onCheckboxChange?.({
isAll: isAll.value,
includeKeys: includeKeys.value,
excludeKeys: excludeKeys.value,
checkedTotal: checkedTotal.value,
});
},
{ deep: true },
);
defineExpose({
onResetCheckbox,
});
</script>
<style lang="less" scoped>
.ant-table-wrapper {
.ant-table {
&-thead {
.ant-table-checkbox-column {
padding-right: 4px;
}
}
}
}
</style>
vue 模版裏需要額外判斷 slots 是否存在 headerCell 和 bodyCell,如果存在的話透傳動態插槽,否則通過具名插槽傳入。CTableHeaderCheckbox 使用了 v-model 而 CTableBodyCheckbox 沒有使用的原因是 CTableBodyCheckbox 裏的操作比較簡單,巧妙的利用了數組 splice 和 push 特性觸發響應式對象更新。
CTableHeaderCheckbox.vue
<!-- CTableHeaderCheckbox -->
<template>
<a-checkbox
:checked="isCurrentChecked"
:indeterminate="isCurrentIndeterminate"
:disabled="isCurrentDisabled"
@change="onCheckboxChange"
/>
<a-dropdown
:disabled="!total"
>
<CIcon
class="ml-2 cursor-pointer"
icon="triangle-down-o"
:size="12"
color="#C9CCD0"
/>
<template #overlay>
<a-menu @click="handleMenu">
<a-menu-item :key="Table.SELECTION_ALL">全選所有</a-menu-item>
<a-menu-item :key="Table.SELECTION_INVERT">反選當頁</a-menu-item>
<a-menu-item :key="Table.SELECTION_NONE">清空所有</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<script lang="ts" setup>
import { Table } from 'ant-design-vue';
const props = withDefaults(
defineProps<{
rowKey: string,
dataSource: any[],
isAll: boolean,
total: number,
includeKeys: string[],
excludeKeys: string[],
judgeToggleIsAll:() => void,
}>(),
{},
);
const emit = defineEmits(['update:isAll', 'update:includeKeys', 'update:excludeKeys']);
const dataSourceKeys = computed(() => props.dataSource.filter((record) => !record.disabled).map((item) => item[props.rowKey]));
const isAll = computed({
get: () => props.isAll,
set: (val) => {
emit('update:isAll', val);
},
});
const includeKeys = computed({
get: () => props.includeKeys,
set: (val) => {
emit('update:includeKeys', val);
},
});
const excludeKeys = computed({
get: () => props.excludeKeys,
set: (val) => {
emit('update:excludeKeys', val);
},
});
const isCurrentChecked = computed(() => {
if (!dataSourceKeys.value.length) return false;
return isAll.value
? !dataSourceKeys.value.some((key) => excludeKeys.value.includes(key))
: dataSourceKeys.value.every((key) => includeKeys.value.includes(key));
});
const isCurrentIndeterminate = computed(() => {
if (!dataSourceKeys.value.length) return false;
if (isAll.value) {
return !dataSourceKeys.value.every((key) => excludeKeys.value.includes(key)) && !isCurrentChecked.value;
} else {
return dataSourceKeys.value.some((key) => includeKeys.value.includes(key)) && !isCurrentChecked.value;
}
});
const isCurrentDisabled = computed(() => !props.total || props.dataSource.every((record) => record.disabled));
const handleMenu = ({ key }) => {
if (key === Table.SELECTION_INVERT) {
// 數學意義的補集
if (isAll.value) {
excludeKeys.value = [
...excludeKeys.value.filter((excludeKey) => !dataSourceKeys.value.includes(excludeKey)),
...dataSourceKeys.value.filter((dataSourceKey) => !excludeKeys.value.includes(dataSourceKey)),
];
} else {
includeKeys.value = [
...includeKeys.value.filter((includeKey) => !dataSourceKeys.value.includes(includeKey)),
...dataSourceKeys.value.filter((dataSourceKey) => !includeKeys.value.includes(dataSourceKey)),
];
}
props.judgeToggleIsAll();
} else {
isAll.value = key === Table.SELECTION_ALL;
includeKeys.value = [];
excludeKeys.value = [];
}
};
const onCheckboxChange = (e) => {
const { checked } = e.target;
if (isAll.value) {
excludeKeys.value = checked
? excludeKeys.value.filter((key) => !dataSourceKeys.value.includes(key))
: Array.from(new Set([...excludeKeys.value, ...dataSourceKeys.value]));
} else {
includeKeys.value = checked
? Array.from(new Set([...includeKeys.value, ...dataSourceKeys.value]))
: includeKeys.value.filter((key) => !dataSourceKeys.value.includes(key));
}
props.judgeToggleIsAll();
};
defineExpose({
handleMenu,
});
</script>
代碼裏可以看到 enabledTotal、record.disabled 等字段,這些都是考慮到列表項中有禁用態的數據做的兼容,disabled 是與後端定義好的保留字段,實際封裝過程中也可以通過傳參 Function(record) => boolean 保持靈活性。
CTableBodyCheckbox.vue
<!-- CTableBodyCheckbox -->
<template>
<a-checkbox
:checked="isAll ? !excludeKeys.includes(record[rowKey]) : includeKeys.includes(record[rowKey])"
:disabled="record.disabled"
@change="onCheckboxChange(record[rowKey])"
/>
</template>
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
record: any,
rowKey: string,
isAll: boolean,
includeKeys: string[],
excludeKeys: string[],
judgeToggleIsAll:() => void,
}>(),
{},
);
const onCheckboxChange = (key) => {
const keys = props.isAll ? props.excludeKeys : props.includeKeys;
const index = keys.indexOf(key);
if (~index) {
keys.splice(index, 1);
} else {
keys.push(key);
}
props.judgeToggleIsAll();
};
</script>
使用
<CTable
ref="cTableRef"
:columns
:dataSource="tableData.dataSource"
:allSelection
@change="onTableChange"
/>
const cTableRef = ref();
const allSelection = reactive({
isAll: false,
includeKeys: [],
excludeKeys: [],
checkedTotal: 0,
onCheckboxChange: ({
isAll,
includeKeys,
excludeKeys,
checkedTotal
}) => {
allSelection.isAll = isAll;
allSelection.includeKeys = includeKeys;
allSelection.excludeKeys = excludeKeys;
allSelection.checkedTotal = checkedTotal;
},
});
const batchDelete = () => {
api({
isAll: allSelection.isAll,
includeList: allSelection.includeKeys,
excludeList: allSelection.excludeKeys,
...searchParams,
}).then(() => {
cTableRef.value.onResetCheckbox();
});
};
結論
如此一來,展示和交互邏輯就全部收攏在前端了,對於交互體驗和服務端負載都是極大的改善。