1. 项目概述
在Vue.js项目中使用Element UI表格组件时,我们经常需要实现自定义筛选功能。Element UI虽然提供了内置的筛选功能,但在实际业务场景中,这些预设功能往往无法满足复杂的业务需求。比如需要实现日期范围筛选、多条件组合筛选或者带有复杂交互的筛选弹窗时,就需要我们手动实现自定义筛选逻辑。
这次我要分享的是如何在Element Table中完全手动实现自定义筛选功能,包括表头筛选弹窗的构建、筛选逻辑的实现以及与表格数据的交互。不同于简单的配置式筛选,这种手动实现方式可以给我们带来更大的灵活性和控制权。
2. 核心需求解析
2.1 为什么需要手动实现自定义筛选
Element UI的表格组件虽然提供了filter-method属性来实现自定义筛选逻辑,但在以下场景中仍然存在局限性:
- 复杂的筛选UI需求:当需要日期范围选择器、多级联动选择等复杂筛选界面时
- 表头自定义布局:需要在表头添加图标、按钮等交互元素来触发筛选
- 筛选状态管理:需要更精细地控制筛选状态的保存和恢复
- 性能优化:对于大数据量的表格,需要实现防抖、异步筛选等优化
2.2 技术选型分析
实现方案主要基于以下技术栈:
- Vue 3:使用Composition API编写组件逻辑
- Element Plus:作为UI组件库,提供Table和Popover等基础组件
- TypeScript:为代码提供类型支持,提高可维护性
选择这种方案的主要考虑是:
- Element Plus的Table组件提供了完善的API和扩展点
- Popover组件非常适合实现筛选弹窗的显示/隐藏控制
- Composition API使得筛选逻辑可以很好地封装和复用
3. 实现步骤详解
3.1 基础表格结构搭建
首先我们需要创建一个基本的表格结构,这里使用Element Plus的el-table组件:
vue复制<template>
<el-table
ref="tableRef"
row-key="date"
:data="tableData"
style="width: 100%"
>
<el-table-column
prop="date"
label="Date"
width="180"
column-key="date"
>
<template #header>
<FilterPopover
:column="{ key: 'date', label: 'Date' }"
@confirm="handleDateFilterConfirm"
@clear="handleDateFilterClear"
/>
</template>
</el-table-column>
<!-- 其他列定义 -->
</el-table>
</template>
关键点说明:
- 使用
ref="tableRef"获取表格实例,后续用于调用筛选方法 row-key指定行的唯一标识,对于筛选后的数据操作很重要- 在表头插槽(
#header)中插入我们的自定义筛选组件
3.2 筛选弹窗组件实现
创建FilterPopover.vue组件来实现筛选弹窗:
vue复制<template>
<el-popover
placement="bottom"
:visible="visible"
@show="visible = true"
@hide="visible = false"
:width="popoverWidth"
>
<template #reference>
<span class="filter-trigger">
{{ column.label }}
<el-icon @click="open">
<Filter />
</el-icon>
</span>
</template>
<div class="filter-content">
<!-- 日期范围选择 -->
<div v-if="column.key === 'date'">
<div>从</div>
<el-date-picker v-model="dates.start" type="date" placeholder="开始日期" />
<div>到</div>
<el-date-picker v-model="dates.end" type="date" placeholder="结束日期" />
</div>
<!-- 文本输入筛选 -->
<div v-if="column.key === 'tag'">
<el-input v-model="inputValue" placeholder="输入筛选内容"></el-input>
</div>
</div>
<div class="filter-actions">
<el-button size="small" @click="clear">清空</el-button>
<el-button size="small" type="primary" @click="save">确认</el-button>
</div>
</el-popover>
</template>
3.3 筛选逻辑实现
在父组件中实现筛选逻辑:
typescript复制const handleDateFilterConfirm = (filterValue: {start: Date, end: Date}) => {
filteredData.value = tableData.filter(item => {
const itemDate = new Date(item.date);
return (!filterValue.start || itemDate >= filterValue.start) &&
(!filterValue.end || itemDate <= filterValue.end);
});
};
const handleTagFilterConfirm = (keyword: string) => {
filteredData.value = tableData.filter(item =>
item.tag.toLowerCase().includes(keyword.toLowerCase())
);
};
const clearFilter = () => {
filteredData.value = [...tableData];
tableRef.value?.clearFilter();
};
3.4 筛选状态管理
为了更好的用户体验,我们需要管理筛选状态:
typescript复制// 当前筛选状态
const filterState = reactive({
date: {
start: null as Date | null,
end: null as Date | null
},
tag: ''
});
// 应用所有筛选条件
const applyAllFilters = () => {
let result = [...tableData];
// 日期筛选
if (filterState.date.start || filterState.date.end) {
result = result.filter(item => {
const itemDate = new Date(item.date);
return (!filterState.date.start || itemDate >= filterState.date.start) &&
(!filterState.date.end || itemDate <= filterState.date.end);
});
}
// 标签筛选
if (filterState.tag) {
result = result.filter(item =>
item.tag.toLowerCase().includes(filterState.tag.toLowerCase())
);
}
filteredData.value = result;
};
4. 高级功能实现
4.1 多列联合筛选
实际业务中经常需要多列联合筛选,我们可以这样实现:
typescript复制const combinedFilter = () => {
filteredData.value = tableData.filter(item => {
// 日期筛选
const datePass = !filterState.date.start || new Date(item.date) >= filterState.date.start;
const datePass2 = !filterState.date.end || new Date(item.date) <= filterState.date.end;
// 标签筛选
const tagPass = !filterState.tag ||
item.tag.toLowerCase().includes(filterState.tag.toLowerCase());
return datePass && datePass2 && tagPass;
});
};
4.2 筛选历史记录
实现筛选历史记录功能可以提升用户体验:
typescript复制const filterHistory = ref<Array<{
type: string;
value: any;
time: Date;
}>>([]);
const saveToHistory = (type: string, value: any) => {
filterHistory.value.unshift({
type,
value,
time: new Date()
});
// 限制历史记录数量
if (filterHistory.value.length > 5) {
filterHistory.value.pop();
}
};
4.3 性能优化技巧
对于大数据量表格,筛选性能优化很重要:
- 防抖处理:
typescript复制import { debounce } from 'lodash-es';
const debouncedFilter = debounce(() => {
applyAllFilters();
}, 300);
- 虚拟滚动:
vue复制<el-table
:data="filteredData"
style="width: 100%"
height="500"
:row-height="50"
:virtual-scroll-options="{ height: 500 }"
>
- Web Worker:将繁重的筛选逻辑放到Web Worker中执行
5. 完整代码实现
5.1 主组件完整代码
vue复制<template>
<div class="custom-filter-container">
<div class="filter-actions">
<el-button @click="resetDateFilter">重置日期筛选</el-button>
<el-button @click="clearAllFilters">重置所有筛选</el-button>
</div>
<el-table
ref="tableRef"
row-key="date"
:data="filteredData"
style="width: 100%"
>
<el-table-column prop="date" label="Date" width="180" column-key="date">
<template #header>
<FilterPopover
:column="{ key: 'date', label: 'Date' }"
@confirm="handleDateFilterConfirm"
@clear="handleDateFilterClear"
/>
</template>
</el-table-column>
<el-table-column prop="name" label="Name" width="180" />
<el-table-column prop="address" label="Address" />
<el-table-column prop="tag" label="Tag" width="120">
<template #header>
<FilterPopover
:column="{ key: 'tag', label: 'Tag' }"
@confirm="handleTagFilterConfirm"
@clear="handleTagFilterClear"
/>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
import type { TableInstance } from 'element-plus';
import FilterPopover from './FilterPopover.vue';
interface User {
date: string;
name: string;
address: string;
tag: string;
}
const tableRef = ref<TableInstance>();
const tableData: User[] = [
{
date: '2016-05-03',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles',
tag: 'Home',
},
// 更多数据...
];
const filteredData = ref([...tableData]);
const filterState = reactive({
date: { start: null as Date | null, end: null as Date | null },
tag: ''
});
const handleDateFilterConfirm = (value: {start: Date, end: Date}) => {
filterState.date = value;
applyAllFilters();
};
const handleTagFilterConfirm = (value: string) => {
filterState.tag = value;
applyAllFilters();
};
const resetDateFilter = () => {
filterState.date = { start: null, end: null };
applyAllFilters();
};
const clearAllFilters = () => {
filterState.date = { start: null, end: null };
filterState.tag = '';
applyAllFilters();
};
const applyAllFilters = () => {
let result = [...tableData];
// 日期筛选
if (filterState.date.start || filterState.date.end) {
result = result.filter(item => {
const itemDate = new Date(item.date);
return (!filterState.date.start || itemDate >= filterState.date.start) &&
(!filterState.date.end || itemDate <= filterState.date.end);
});
}
// 标签筛选
if (filterState.tag) {
result = result.filter(item =>
item.tag.toLowerCase().includes(filterState.tag.toLowerCase())
);
}
filteredData.value = result;
};
</script>
<style scoped>
.custom-filter-container {
padding: 20px;
}
.filter-actions {
margin-bottom: 15px;
}
</style>
5.2 筛选弹窗组件完整代码
vue复制<template>
<el-popover
placement="bottom"
:visible="visible"
@show="visible = true"
@hide="visible = false"
:width="popoverWidth"
>
<template #reference>
<span class="filter-trigger">
{{ column.label }}
<el-icon class="filter-icon" @click.stop="open">
<Filter />
</el-icon>
</span>
</template>
<div class="filter-content">
<!-- 日期范围选择 -->
<div v-if="column.key === 'date'" class="date-filter">
<div class="date-label">从</div>
<el-date-picker
v-model="dates.start"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
/>
<div class="date-label">到</div>
<el-date-picker
v-model="dates.end"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
</div>
<!-- 文本输入筛选 -->
<div v-if="column.key === 'tag'" class="text-filter">
<el-input
v-model="inputValue"
placeholder="输入标签关键词"
clearable
/>
</div>
</div>
<div class="filter-actions">
<el-button size="small" @click="clear">清空</el-button>
<el-button size="small" type="primary" @click="save">确认</el-button>
</div>
</el-popover>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Filter } from '@element-plus/icons-vue';
const props = defineProps<{
column: {
key: string;
label: string;
}
}>();
const emit = defineEmits(['confirm', 'clear']);
const visible = ref(false);
const inputValue = ref('');
const popoverWidth = ref(300);
const dates = reactive({
start: null as string | null,
end: null as string | null
});
const open = () => {
visible.value = true;
};
const clear = () => {
if (props.column.key === 'date') {
dates.start = null;
dates.end = null;
} else {
inputValue.value = '';
}
emit('clear');
visible.value = false;
};
const save = () => {
visible.value = false;
if (props.column.key === 'date') {
emit('confirm', {
start: dates.start ? new Date(dates.start) : null,
end: dates.end ? new Date(dates.end) : null
});
} else {
emit('confirm', inputValue.value);
}
};
// 根据不同类型设置弹窗宽度
watch(() => props.column.key, (key) => {
popoverWidth.value = key === 'date' ? 400 : 300;
}, { immediate: true });
</script>
<style scoped>
.filter-trigger {
display: flex;
align-items: center;
cursor: pointer;
}
.filter-icon {
margin-left: 5px;
color: #409eff;
}
.date-filter {
display: flex;
align-items: center;
gap: 8px;
}
.date-label {
color: #606266;
}
.text-filter {
padding: 5px 0;
}
.filter-actions {
margin-top: 10px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
6. 常见问题与解决方案
6.1 筛选图标点击无效
问题现象:点击筛选图标没有弹出筛选窗口
原因分析:通常是因为事件冒泡被阻止
解决方案:
vue复制<el-icon @click.stop="open">
<Filter />
</el-icon>
添加.stop修饰符阻止事件冒泡
6.2 筛选后表格滚动位置不正确
问题现象:筛选数据后表格滚动到了顶部
原因分析:Element Table在数据更新后会重置滚动位置
解决方案:
typescript复制const applyAllFilters = () => {
const scrollTop = tableRef.value?.$el.querySelector('.el-table__body-wrapper')?.scrollTop || 0;
// 执行筛选逻辑...
filteredData.value = result;
nextTick(() => {
tableRef.value?.$el.querySelector('.el-table__body-wrapper').scrollTop = scrollTop;
});
};
6.3 大数据量筛选性能问题
问题现象:数据量较大时筛选卡顿
优化方案:
- 使用Web Worker进行后台筛选
- 实现虚拟滚动只渲染可见区域
- 添加防抖处理频繁的筛选操作
- 对数据进行分页处理
6.4 筛选状态持久化
需求场景:页面刷新后保留筛选条件
实现方案:
typescript复制// 保存筛选状态
const saveFilterState = () => {
localStorage.setItem('tableFilterState', JSON.stringify(filterState));
};
// 恢复筛选状态
const restoreFilterState = () => {
const saved = localStorage.getItem('tableFilterState');
if (saved) {
Object.assign(filterState, JSON.parse(saved));
applyAllFilters();
}
};
// 组件挂载时恢复
onMounted(() => {
restoreFilterState();
});
// 筛选条件变化时保存
watch(filterState, () => {
saveFilterState();
}, { deep: true });
7. 扩展思路与进阶技巧
7.1 动态筛选条件
根据业务需求动态生成筛选条件:
typescript复制const dynamicFilters = reactive<Record<string, any>>({});
const addDynamicFilter = (key: string, filterFn: (item: any) => boolean) => {
dynamicFilters[key] = filterFn;
applyAllFilters();
};
const removeDynamicFilter = (key: string) => {
delete dynamicFilters[key];
applyAllFilters();
};
const applyAllFilters = () => {
let result = [...tableData];
// 应用所有动态筛选条件
Object.values(dynamicFilters).forEach(filterFn => {
result = result.filter(filterFn);
});
filteredData.value = result;
};
7.2 服务端筛选集成
对于大数据量场景,可以集成服务端筛选:
typescript复制const fetchFilteredData = async (params: {
dateRange?: [Date, Date];
tag?: string;
page: number;
pageSize: number;
}) => {
loading.value = true;
try {
const res = await api.get('/data/filter', { params });
filteredData.value = res.data.list;
total.value = res.data.total;
} finally {
loading.value = false;
}
};
7.3 筛选条件组合保存
实现筛选条件的保存和复用:
typescript复制const savedFilterPresets = ref<Array<{
name: string;
state: typeof filterState;
}>>([]);
const saveCurrentFilter = (name: string) => {
savedFilterPresets.value.push({
name,
state: JSON.parse(JSON.stringify(filterState))
});
};
const applySavedFilter = (index: number) => {
const preset = savedFilterPresets.value[index];
Object.assign(filterState, preset.state);
applyAllFilters();
};
7.4 筛选结果统计
在筛选后显示统计信息:
vue复制<template>
<div class="filter-stats">
共 {{ filteredData.length }} 条数据(总共 {{ tableData.length }} 条)
<el-tag v-if="filterState.date.start || filterState.date.end">
日期范围: {{ filterState.date.start }} 至 {{ filterState.date.end }}
</el-tag>
<el-tag v-if="filterState.tag">
标签包含: {{ filterState.tag }}
</el-tag>
</div>
</template>
8. 性能优化实践
8.1 虚拟滚动实现
对于大数据量表格,实现虚拟滚动:
vue复制<el-table
:data="filteredData"
style="width: 100%"
height="500"
:row-height="50"
:virtual-scroll-options="{
height: 500,
keepAlive: true
}"
>
8.2 筛选缓存策略
缓存筛选结果避免重复计算:
typescript复制const filterCache = new Map<string, User[]>();
const getCacheKey = () => {
return JSON.stringify(filterState);
};
const applyAllFilters = () => {
const cacheKey = getCacheKey();
if (filterCache.has(cacheKey)) {
filteredData.value = filterCache.get(cacheKey)!;
return;
}
// 执行筛选计算...
filteredData.value = result;
// 缓存结果
filterCache.set(cacheKey, result);
};
8.3 分片筛选计算
将大数据量分片处理避免UI阻塞:
typescript复制const chunkedFilter = async (data: User[], chunkSize = 1000) => {
const result: User[] = [];
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
const filteredChunk = chunk.filter(item => {
// 筛选逻辑...
});
result.push(...filteredChunk);
// 每处理完一个分片就更新UI
filteredData.value = [...result];
await new Promise(resolve => setTimeout(resolve, 0));
}
return result;
};
8.4 Web Worker 后台筛选
将繁重的筛选逻辑放到Web Worker中:
typescript复制// worker.js
self.onmessage = function(e) {
const { data, filterState } = e.data;
const result = data.filter(item => {
// 筛选逻辑...
});
self.postMessage(result);
};
// 主线程
const worker = new Worker('./worker.js');
worker.onmessage = function(e) {
filteredData.value = e.data;
};
const applyAllFilters = () => {
worker.postMessage({
data: tableData,
filterState
});
};
9. 测试与调试技巧
9.1 筛选功能单元测试
使用Vitest编写单元测试:
typescript复制import { describe, it, expect } from 'vitest';
import { filterByDate, filterByTag } from './tableFilters';
describe('表格筛选功能', () => {
const testData = [
{ date: '2023-01-01', tag: 'home' },
{ date: '2023-01-15', tag: 'work' },
{ date: '2023-02-01', tag: 'home' }
];
it('应该正确筛选日期范围', () => {
const result = filterByDate(testData, {
start: new Date('2023-01-10'),
end: new Date('2023-01-20')
});
expect(result).toEqual([
{ date: '2023-01-15', tag: 'work' }
]);
});
it('应该正确筛选标签', () => {
const result = filterByTag(testData, 'home');
expect(result).toHaveLength(2);
});
});
9.2 筛选组件E2E测试
使用Cypress进行端到端测试:
javascript复制describe('表格筛选功能', () => {
it('应该能够通过日期筛选数据', () => {
cy.visit('/');
cy.get('.date-filter-icon').click();
cy.get('.start-date input').type('2023-01-10');
cy.get('.end-date input').type('2023-01-20');
cy.contains('确认').click();
cy.get('.el-table__row').should('have.length', 1);
});
});
9.3 性能测试与监控
使用Performance API监控筛选性能:
typescript复制const applyAllFilters = () => {
const startTime = performance.now();
// 执行筛选逻辑...
const duration = performance.now() - startTime;
if (duration > 100) {
console.warn(`筛选耗时 ${duration.toFixed(2)}ms,考虑优化`);
}
};
9.4 筛选边界条件测试
测试各种边界条件:
typescript复制// 测试空数据
expect(filterByDate([], { start, end })).toEqual([]);
// 测试无效日期
expect(filterByDate(testData, {
start: new Date('invalid'),
end: new Date('2023-01-20')
})).toEqual([]);
// 测试大小写不敏感的标签筛选
expect(filterByTag(testData, 'HOME')).toEqual([
{ date: '2023-01-01', tag: 'home' },
{ date: '2023-02-01', tag: 'home' }
]);
10. 项目总结与经验分享
在实际项目中实现Element Table的自定义筛选功能时,有几个关键点值得特别注意:
-
筛选状态管理:一定要设计好筛选状态的数据结构,确保能够完整保存所有筛选条件。我推荐使用一个集中式的reactive对象来管理所有筛选状态。
-
性能考量:对于前端筛选,数据量超过5000条时就可能出现性能问题。这时候可以考虑以下几种优化方案:
- 实现虚拟滚动减少DOM节点
- 添加防抖处理频繁的筛选操作
- 将大数据量分片处理
- 考虑改用服务端筛选
-
用户体验细节:
- 筛选后保持表格滚动位置不变
- 在表头显示当前筛选状态(如显示筛选图标高亮)
- 提供快捷的筛选条件清除功能
- 对于复杂的筛选条件,考虑添加保存预设功能
-
代码组织建议:
- 将筛选逻辑抽离为独立的工具函数,便于测试和复用
- 对于复杂的筛选弹窗,可以考虑使用动态组件按需加载
- 使用TypeScript严格定义筛选条件的类型,避免运行时错误
-
测试覆盖:
- 单元测试覆盖所有筛选工具函数
- E2E测试覆盖主要筛选场景
- 性能测试确保大数据量下的可用性
在实际项目中,我遇到过因为筛选条件复杂导致的性能问题,最终通过将部分筛选逻辑移到Web Worker中解决。也遇到过筛选状态管理混乱的问题,通过重构为集中式状态管理得到了改善。这些经验告诉我,即使是看似简单的表格筛选功能,也需要仔细设计和不断优化。