1. 检索功能为什么总在真实项目里翻车
做过管理后台的人都有一个共识:列表页好写,检索难做。刚入行那会儿,我接手一个后台系统,需求文档上写着"支持按名称模糊查询",我三下五除二写了个like %keyword%就交差了。结果上线第二天,运营同事跑过来问:"为什么我搜'张三'搜不到,搜'张 三'(中间带空格)也搜不到?为什么搜出来的结果顺序乱七八糟?为什么翻到第三页数据就重复了?"
那一刻我才明白,检索功能从来不是"一个输入框加一个按钮"这么简单。它背后牵扯的是查询条件的动态组合、分页与排序的稳定性、前后端参数的传递约定、以及数据库索引的合理使用。这四个问题任何一个没处理好,用户就会觉得"这个系统不好用",而这类体验问题往往比功能缺失更致命。
这篇内容就是围绕SpringBoot + ElementUI 实现检索功能这个主题,把我这些年踩过的坑、总结的套路完整梳理一遍。核心会讲清楚几件事:后端如何用 SpringBoot 优雅地接收和组装动态查询条件,前端如何用 ElementUI 的表单组件构建检索区并管理查询状态,前后端之间的分页参数怎么约定才不容易出错,以及那些文档里不会写、但实际开发中一定会遇到的细节问题。
适合谁看?如果你正在做或者即将做一个带检索功能的后台管理系统,无论你是刚接触 SpringBoot 的新手,还是写过几个项目但对检索这块始终没形成系统方法的老手,这篇内容都能给你一些可以直接抄作业的思路。我不会只给你一段代码,而是会把"为什么这么设计"讲透,这样你换个业务场景也能自己推导出方案。
先说一个反直觉的结论:检索功能的核心难点不在查询本身,而在"状态管理"。前端要管理用户输入的检索条件状态,后端要管理接收到的参数状态,数据库要管理查询结果的分页状态。这三个状态只要有一个对不上,功能就会出问题。下面我们逐个拆解。
2. 后端查询条件的动态组装:从一堆 if 到一套机制
2.1 为什么不能用一个方法写死所有查询条件
最原始的做法是这样的:Controller 里接收一堆参数,然后 Service 里写一长串 if 判断,哪个参数不为空就拼哪个条件。这种写法在只有两三个查询条件时还能忍,一旦条件增加到七八个,代码就会变成一坨谁都不想维护的"面条"。
我见过一个项目,检索条件有十二个,Service 方法写了三百多行,全是if (StringUtils.isNotBlank(name)) { wrapper.like("name", name); }这样的重复代码。后来要加一个"按创建时间范围查询",负责的同事改了整整一个下午,还改出了 bug——因为他在复制粘贴时把某个字段名写错了。
问题的本质是:查询条件的组装是一个"可变逻辑",不应该和业务逻辑混在一起。正确的做法是把查询条件的构建抽象出来,让每个条件独立、可组合、可测试。
2.2 用查询对象(Query Object)承载检索参数
我的习惯是定义一个专门的查询对象,把所有可能的检索参数都放进去。这样做的好处是:参数有明确的类型和默认值,Controller 接收时不用写一堆@RequestParam,后续扩展也方便。
public class UserQuery { private String name; private Integer status; private String phone; private LocalDate startDate; private LocalDate endDate; private Integer pageNum = 1; private Integer pageSize = 10; private String sortField; private String sortOrder; // getter/setter 省略 }这里有几个细节值得说。第一,pageNum和pageSize给了默认值,这样前端即使不传也不会导致分页异常。第二,sortField和sortOrder单独拎出来,是为了支持前端表格的列排序。第三,日期用LocalDate而不是String,让 Spring 的类型转换机制帮我们做格式校验,传个乱七八糟的字符串直接报错,比在业务代码里手动 parse 要干净得多。
注意:查询对象里的字段类型要和数据库字段类型尽量对齐。我见过有人把状态字段定义成 String,结果前端传 "1" 后端也认,传 "abc" 后端也认,最后在 SQL 里报类型转换错误,排查半天。
2.3 条件组装的核心:把"是否拼接"的判断收敛到一处
有了查询对象之后,组装条件就有章可循了。我一般会在 Service 层写一个私有方法专门负责构建查询条件,用 MyBatis-Plus 的LambdaQueryWrapper或者 JPA 的Specification都可以,思路是一样的。
private LambdaQueryWrapper<User> buildWrapper(UserQuery query) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getName()), User::getName, query.getName()); wrapper.eq(query.getStatus() != null, User::getStatus, query.getStatus()); wrapper.like(StringUtils.hasText(query.getPhone()), User::getPhone, query.getPhone()); wrapper.ge(query.getStartDate() != null, User::getCreateTime, query.getStartDate()); wrapper.le(query.getEndDate() != null, User::getCreateTime, query.getEndDate()); return wrapper; }这种写法的精髓在于:每个条件都是"条件成立才拼接"。like方法的第一个参数是一个布尔值,只有为 true 时才会把后面的条件加进去。这样一行代码就是一个条件,既简洁又不容易出错。
对比一下传统写法,你需要写if (条件) { wrapper.like(...); },五个条件就是五个 if 块,代码行数是这种写法的三倍,而且容易漏掉某个条件的判断。这种"条件表达式 + 链式调用"的模式,是我认为在检索功能里最值得推广的写法。
2.4 模糊查询的边界:什么时候该用 like,什么时候不该用
很多人一提到检索就无脑上like %keyword%,但这在数据量大的时候是性能杀手。因为前置百分号会导致索引失效,数据库只能全表扫描。
我的经验是分场景处理:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 精确匹配(如手机号、订单号) | eq | 能走索引,速度快 |
| 前缀匹配(如姓名、标题) | like keyword% | 能走索引,符合用户习惯 |
| 任意位置匹配(如备注、描述) | like %keyword% | 无法走索引,需限制数据量 |
| 多字段联合检索 | 全文索引或搜索引擎 | 数据库 like 扛不住 |
如果业务确实需要任意位置匹配,又对性能有要求,那就要考虑引入专门的全文检索方案,或者至少给查询加上时间范围限制,避免全表扫描。这个决策要在需求评审阶段就提出来,而不是等上线后用户抱怨慢才补救。
3. ElementUI 检索区的构建与状态管理
3.1 检索表单的布局:别让用户在一堆输入框里迷路
ElementUI 的el-form配合el-row和el-col是构建检索区的标准组合。但布局这件事,很多人做得不够讲究。我见过一个检索区,八个输入框挤在一行,每个框窄得只能显示三四个字,用户根本看不清自己输了什么。
我的做法是:根据条件数量决定列数。条件少于四个,一行放两到三个;条件多于四个,一行放三个或四个,超过一行的部分用"展开/收起"控制。这样既保证了可读性,又不会让检索区占据太多垂直空间。
<el-form :model="queryParams" inline class="search-form"> <el-row :gutter="16"> <el-col :span="6"> <el-form-item label="姓名"> <el-input v-model="queryParams.name" placeholder="请输入姓名" clearable /> </el-form-item> </el-col> <el-col :span="6"> <el-form-item label="状态"> <el-select v-model="queryParams.status" placeholder="请选择" clearable> <el-option label="启用" :value="1" /> <el-option label="禁用" :value="0" /> </el-select> </el-form-item> </el-col> <!-- 更多条件 --> </el-row> </el-form>这里有个细节:clearable属性一定要加。用户输入后想清空,如果没有这个属性,只能一个个字符删,体验很差。另外placeholder要写清楚,是"请输入"还是"请选择",让用户知道这个字段的交互方式。
3.2 查询参数对象与重置逻辑
前端也需要一个查询参数对象,和後端的查询对象对应。这个对象的管理有几个容易出问题的地方。
第一个问题是重置。用户点了"重置"按钮,你希望所有条件回到初始状态。但如果你直接写this.queryParams = {},可能会把分页参数也清掉,导致后续查询异常。正确的做法是保留分页和排序参数,只重置检索条件。
resetQuery() { this.queryParams = { name: '', status: null, phone: '', startDate: null, endDate: null, pageNum: 1, pageSize: this.queryParams.pageSize, sortField: this.queryParams.sortField, sortOrder: this.queryParams.sortOrder }; this.handleQuery(); }第二个问题是查询时重置页码。用户翻到第五页,然后改了检索条件点查询,这时候应该从第一页开始查,而不是继续查第五页。这个逻辑很容易被忽略,导致用户看到"空数据"还以为系统坏了。
handleQuery() { this.queryParams.pageNum = 1; this.fetchData(); }3.3 表格列排序与后端排序的联动
ElementUI 的el-table支持列排序,但默认是前端排序,只对当前页数据有效。如果要做真正的全局排序,需要开启sortable="custom",然后在sort-change事件里把排序字段传给后端。
<el-table :data="tableData" @sort-change="handleSortChange"> <el-table-column prop="createTime" label="创建时间" sortable="custom" /> </el-table>handleSortChange({ prop, order }) { this.queryParams.sortField = prop; this.queryParams.sortOrder = order === 'ascending' ? 'asc' : 'desc'; this.fetchData(); }后端接收到排序参数后,要做一个白名单校验。因为排序字段是直接拼接到 SQL 里的,如果不校验,用户传个恶意字段名就可能出问题。我的做法是维护一个允许排序的字段列表,不在列表里的直接忽略。
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("createTime", "name", "status"); private void applySort(LambdaQueryWrapper<User> wrapper, UserQuery query) { if (StringUtils.hasText(query.getSortField()) && ALLOWED_SORT_FIELDS.contains(query.getSortField())) { boolean isAsc = "asc".equalsIgnoreCase(query.getSortOrder()); wrapper.orderBy(true, isAsc, columnToFunction(query.getSortField())); } else { wrapper.orderByDesc(User::getCreateTime); } }提示:默认排序一定要设置。如果用户没有指定排序,数据库返回的顺序是不确定的,分页时可能出现数据重复或遗漏。用一个稳定的字段(如创建时间或主键)做默认排序,能避免很多诡异问题。
4. 分页参数的传递与前后端约定
4.1 分页参数的命名统一
前后端联调时,分页参数命名不一致是最常见的低级错误。前端传page和size,后端接收pageNum和pageSize,结果就是永远查第一页。这种问题排查起来不复杂,但很浪费时间。
我的建议是:在项目启动阶段就定好一套命名规范,写进接口文档。我一般用pageNum和pageSize,因为语义清晰,不容易和page(可能指页码也可能指页面对象)混淆。如果团队已经有约定,就跟着约定走,关键是统一。
4.2 分页返回结构的标准化
后端返回的分页数据,结构也要统一。我习惯用这样的格式:
{ "code": 200, "message": "success", "data": { "list": [...], "total": 100, "pageNum": 1, "pageSize": 10 } }list是当前页数据,total是总记录数,后面两个是回显用的。前端拿到total后设置给el-pagination的total属性,分页组件就能正确显示总页数。
这里有个坑:total 的查询和 list 的查询要使用相同的条件。我见过有人 list 查询带了所有条件,total 查询却漏了一个,导致分页显示的总数和实际数据对不上。用 MyBatis-Plus 的话,page方法会自动处理 count 查询,条件是一致的,但如果手写 count SQL 就要特别注意。
4.3 深分页问题的处理
当数据量很大时,limit 100000, 10这样的深分页查询会非常慢,因为数据库要扫描前十万条记录再丢弃。这个问题在检索功能里尤其明显,因为用户可能翻很多页。
解决方案有几种。最简单的是限制最大页码,比如只允许查前一百页,超出提示用户用更精确的条件筛选。更彻底的是用游标分页,记录上一页最后一条记录的 ID,下一页从这条记录之后开始查。但游标分页不支持跳页,适合无限滚动的场景,不一定适合所有后台系统。
我的实际经验是:后台管理系统的数据量通常不会大到需要游标分页,限制最大页码加上合理的索引就够用了。如果确实遇到性能问题,优先考虑的是优化查询条件,而不是分页方式。
5. 那些文档不会告诉你的实战细节
5.1 空字符串和 null 的区别处理
前端传过来的空字符串""和后端接收到的null,在检索逻辑里应该被同等对待——都表示"用户没有输入这个条件"。但实际开发中,这两者经常被区别对待,导致查询结果不符合预期。
比如用户清空了姓名输入框,前端可能传name="",后端如果只判断name != null,就会拼接where name like '%%',虽然结果和不过滤一样,但多了一个无意义的条件,影响查询计划。所以判断条件要用StringUtils.hasText()而不是简单的 null 判断,它能同时处理 null、空字符串和纯空格。
5.2 日期范围查询的边界问题
日期范围查询有个经典的坑:用户选择"2024-01-01 到 2024-01-31",如果数据库存的是datetime类型,那么2024-01-31 10:00:00这条记录不会被查出来,因为2024-01-31 10:00:00 > 2024-01-31 00:00:00。
解决办法有两种。一种是把结束日期加一天,查询条件变成< 2024-02-01。另一种是在前端把结束日期补全为2024-01-31 23:59:59。我倾向于第一种,在后端处理,因为前端处理容易被绕过,而且时分秒的边界值容易写错。
if (query.getEndDate() != null) { wrapper.lt(User::getCreateTime, query.getEndDate().plusDays(1)); }5.3 检索条件的回显与 URL 同步
用户检索后刷新页面,检索条件应该还在,这是基本体验。实现方式是把检索条件同步到 URL 的 query 参数里,刷新时从 URL 读取并回填。
// 查询时同步到 URL this.$router.replace({ query: { ...this.queryParams } }); // 初始化时从 URL 读取 created() { const { query } = this.$route; if (Object.keys(query).length) { this.queryParams = { ...this.queryParams, ...query }; } this.fetchData(); }这样做还有个额外好处:用户可以把带检索条件的 URL 分享给同事,对方打开就能看到同样的筛选结果。这个功能在协作场景下很实用,但很多系统都没做。
5.4 防抖与请求竞态
检索输入框如果做成"输入即查询",一定要加防抖,否则用户每敲一个字符就发一次请求,既浪费资源又可能导致结果错乱。
import { debounce } from 'lodash'; watch: { 'queryParams.name': debounce(function() { this.handleQuery(); }, 500) }但防抖解决不了请求竞态问题:用户快速输入"abc",可能"ab"的请求比"abc"的请求后返回,导致最终显示的是"ab"的结果。解决办法是给每个请求打上标记,只处理最新请求的响应。
async fetchData() { const requestId = ++this.currentRequestId; const res = await api.getUserList(this.queryParams); if (requestId !== this.currentRequestId) return; this.tableData = res.data.list; }这个细节在数据量小、网络好的时候不容易暴露,但一旦网络波动或者数据量大,就会出现"搜索结果和输入对不上"的诡异现象。加上这个判断,成本很低,收益很大。
6. 从能用到好用:检索功能的进阶优化
6.1 查询条件的持久化
用户经常需要重复使用同一套检索条件,比如每天早上都要查"昨天创建且状态为待处理"的订单。如果每次都要重新选一遍,体验就很差。
我的做法是在前端用 localStorage 记住用户最近一次使用的检索条件,下次打开页面时自动回填。但要注意区分"用户主动重置"和"页面刷新",前者应该清空记忆,后者应该恢复记忆。
// 查询时保存 localStorage.setItem('userQueryCache', JSON.stringify(this.queryParams)); // 重置时清除 localStorage.removeItem('userQueryCache');这个功能实现起来不复杂,但对高频使用的用户来说,能省下不少重复操作的时间。
6.2 检索结果的导出
检索之后往往跟着导出需求。导出的关键点是:导出应该使用和当前列表相同的检索条件,而不是导出全部数据。我见过有系统导出按钮直接查全表,用户筛选了半天,导出来却是所有数据,非常让人恼火。
实现上,导出接口接收和列表接口相同的查询参数,但不分页,或者分页但循环取所有页。数据量大时建议用异步导出,生成文件后通知用户下载,避免请求超时。
6.3 慢查询的监控与优化
检索功能上线后,要关注慢查询日志。如果某个检索条件经常导致慢查询,就要针对性优化。常见的优化手段包括:给查询字段加索引、限制模糊查询的使用范围、对大数据量的表做归档或分表。
我一般会在开发阶段就用真实数据量测试检索性能,而不是等上线后被动救火。测试时重点关注:不带条件的查询、最常用的检索条件组合、以及数据量最大时的分页查询。这三个场景覆盖了大部分性能问题。
6.4 检索条件的组合逻辑
当检索条件很多时,用户可能希望支持"或"的逻辑,比如"姓名包含张三 或者 手机号包含 138"。这种需求用简单的链式 wrapper 就不好实现了,需要用到嵌套条件。
wrapper.and(w -> w.like(User::getName, keyword) .or() .like(User::getPhone, keyword));这种嵌套逻辑在 MyBatis-Plus 里用 lambda 表达式写起来还算清晰,但要注意括号的层级,写错了会导致条件优先级错误。我的建议是:如果检索逻辑复杂到需要多层嵌套,考虑把条件拆分成多个查询再合并,或者引入更专业的查询构建工具,不要硬写。
检索功能看起来简单,但要做好做细,涉及的细节非常多。从后端的条件组装、分页处理,到前端的表单管理、状态同步,再到性能优化和体验打磨,每一环都有讲究。我在实际项目中的体会是:把检索功能当成一个独立的小系统来设计,而不是列表页的附属品,这样想问题会更全面,做出来的东西也更经得起使用。