简介:这份 Vue 组合查询条件源码面向具备一定 Vue 基础的前端开发者,聚焦多字段查询条件的组合与联动场景,帮助理解组件化拆分、状态管理与请求触发的完整链路。压缩包共 32 个文件,约 139KB,以 14 个 js 脚本、4 个 vue 单文件组件、5 个 xml 配置及 json、html 等为主,涵盖入口配置、路由、状态仓库与视图模块,结构紧凑便于通读。源码涉及 Vue CLI 脚手架、计算属性与侦听器、Vuex 状态管理、axios 请求以及 Vue Router 路由等知识点,可对照组件与函数逐一解析查询参数如何动态生成并驱动视图更新。目前已有 1277 人学习下载,适合作为查询表单类页面的参考实现,用于梳理目录组织、状态流转与表单绑定思路。
1. 组合查询条件为什么总在第三个字段开始失控
做后台管理系统的人大概率都遇到过这种场景:列表页一开始只有「名称」和「状态」两个筛选框,代码写得干净利落,一个reactive对象加一个getList()就完事。等到产品经理陆续加了时间范围、所属分类、创建人、标签多选、金额区间之后,同一个查询对象里开始混进undefined、空字符串、空数组、null,请求参数越传越长,后端一会儿说「空数组别传」,一会儿说「时间要拆成两个字段」,前端这边watch越写越多,改一个条件触发三次请求。这就是组合查询条件失控的典型现场。
这篇要讲的是 Vue 组合查询条件的源码级实现思路——不是某个具体库的源码,而是把「多个查询字段如何组织、如何序列化、如何驱动请求、如何重置」这套逻辑拆开,落到可复用的组合式函数和配置表上。适合正在写中后台列表页、被筛选条件反复折磨的前端,也适合想把团队里散落的查询逻辑收敛成一套约定的技术负责人。读完你能拿到一套能直接抄进项目的结构,以及几个我踩过的坑。
2. 把查询条件抽象成配置表:字段、类型、默认值三件套
组合查询的核心矛盾在于:字段是动态的,但请求参数的形状是固定的。如果每个字段都手写一个ref,字段一多就变成意大利面;如果全塞进一个大对象,又很难知道每个字段该用什么控件、默认值是什么、空值怎么处理。我的做法是先建一张配置表,把「字段名、控件类型、默认值、序列化方式」这四件事声明出来,剩下的交给一个统一的组合式函数去跑。
2.1 为什么用配置表而不是一堆 ref
先看反面写法。很多项目的查询区是这样堆出来的:
// 反例:字段一多就失控 const name = ref('') const status = ref('') const startTime = ref('') const endTime = ref('') const categoryIds = ref([]) // ...还有十几个 function reset() { name.value = '' status.value = '' startTime.value = '' endTime.value = '' categoryIds.value = [] // ...继续抄 }这种写法的问题不在「能不能跑」,而在于三处会持续产生维护成本。第一,重置逻辑和字段声明是两份平行列表,加字段时漏改reset是高频事故。第二,空值判断散落在请求函数里,if (name.value)这种判断写十几遍。第三,字段和 UI 控件的对应关系只存在于模板里,想做一个「根据权限动态显示筛选字段」的需求时无从下手。
配置表把这三件事收敛到一处。字段声明即重置依据,字段声明即空值判断依据,字段声明即 UI 渲染依据。代价是前期多写一点类型定义,收益是后面加字段只改一行。
2.2 配置表的结构设计
我一般会把配置写成数组,每项描述一个查询字段。下面是一个可直接用的结构:
// querySchema.js // 每个字段描述:key 对应请求参数名,type 决定控件和序列化方式 export const querySchema = [ { key: 'keyword', label: '关键词', type: 'input', defaultValue: '', // 空字符串代表未填写 trim: true // 提交前是否去空格 }, { key: 'status', label: '状态', type: 'select', defaultValue: null, // null 代表未选择,区别于「全部」这种有效值 options: [ { label: '启用', value: 1 }, { label: '停用', value: 0 } ] }, { key: 'createdRange', label: '创建时间', type: 'daterange', defaultValue: [], // 时间范围在提交时要拆成两个字段,用 serialize 声明 serialize: (val) => ({ createdStart: val?.[0] || undefined, createdEnd: val?.[1] || undefined }) }, { key: 'categoryIds', label: '分类', type: 'checkbox', defaultValue: [], // 空数组不参与请求,避免后端收到 categoryIds=[] serialize: (val) => (val.length ? { categoryIds: val } : {}) } ]这里有几个设计决策值得说清楚。defaultValue用null而不是''表示「未选择」,是为了和「全部」这种有效选项区分开——有些后端会把空字符串当成有效筛选值,用null更安全。serialize返回对象而不是直接返回值,是因为一个字段可能映射成多个请求参数(时间范围拆两个),也可能映射成零个参数(空数组不传)。这种「字段到参数的映射」是组合查询里最容易被忽略的一层,很多 bug 就出在这里。
2.3 用组合式函数驱动配置表
有了配置表,接下来写一个useQuery把状态、重置、序列化三件事包起来:
// useQuery.js import { reactive, computed } from 'vue' export function useQuery(schema) { // 用 reactive 承载所有字段值,key 来自 schema const form = reactive( Object.fromEntries(schema.map((f) => [f.key, f.defaultValue])) ) // 重置:直接按 schema 回填默认值,不手写字段列表 function reset() { schema.forEach((f) => { // 数组和对象要重新赋值,避免引用共享 form[f.key] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue }) } // 序列化:把 form 转成请求参数,跳过空值 const params = computed(() => { const result = {} schema.forEach((f) => { const val = form[f.key] // 空值判断:null、undefined、空字符串、空数组都跳过 const isEmpty = val === null || val === undefined || val === '' || (Array.isArray(val) && val.length === 0) if (isEmpty) return if (f.serialize) { Object.assign(result, f.serialize(val)) } else { result[f.key] = f.trim && typeof val === 'string' ? val.trim() : val } }) return result }) return { form, reset, params } }params用computed而不是普通函数,好处是模板里可以直接绑定,也方便在watch里监听它的变化触发请求。注意reset里对数组做了浅拷贝,如果直接form[f.key] = f.defaultValue,多个实例会共享同一个数组引用,改一个影响另一个,这个坑我在两个项目里都见过。
2.4 在组件里接上请求
最后一步是把params接到列表请求上。常见做法是监听params变化,加防抖后请求:
// ListPage.vue 的 script setup 片段 import { watch } from 'vue' import { useQuery } from './useQuery' import { querySchema } from './querySchema' const { form, reset, params } = useQuery(querySchema) let timer = null watch( params, (val) => { // 防抖:连续改多个条件时只发一次请求 clearTimeout(timer) timer = setTimeout(() => { fetchList({ ...val, page: 1 }) }, 300) }, { deep: true } ) function handleReset() { reset() // reset 后 params 变化会自动触发 watch,无需手动请求 }这里有个细节:watch的deep必须开,因为params是computed返回的对象,内部字段变化需要深度监听。防抖时间 300ms 是个经验值,太短会在快速输入时发多次请求,太长会让用户觉得卡。如果筛选区有「查询」按钮而不是实时查询,那就不需要watch,直接在按钮点击时读params.value即可,这种模式反而更可控,我个人更推荐在字段超过五个时用按钮模式。
3. 空值、数组、时间范围:序列化层的三个硬骨头
配置表和useQuery搭好之后,真正容易翻车的是序列化层。请求参数不是简单地把表单值搬过去,中间要处理空值语义、数组格式、时间格式三件事,每一件都有不止一种合理做法,选错了就是联调时来回扯皮。
3.1 空值到底传不传:三种策略的取舍
空值处理有三种常见策略,各有适用场景:
| 策略 | 做法 | 适用场景 | 风险 |
|---|---|---|---|
| 不传 | 空值字段从参数里删掉 | 后端用「参数缺失」表示不过滤 | 后端若把缺失当默认值会出错 |
| 传 null | 空值字段传null | 后端明确支持 null 语义 | JSON 序列化后 null 可能被丢弃 |
| 传空串 | 空值字段传'' | 后端用空串表示不过滤 | 数字、数组字段传空串会类型错误 |
我一般默认用「不传」,因为这是最不容易产生歧义的策略——参数里没有这个字段,后端就不该拿它做过滤。但要注意一个例外:如果后端接口用PUT语义或者做的是「全量更新」,缺失字段可能被当成「清空」,这时候必须传null。判断依据是看接口是查询还是更新,查询用不传,更新用显式 null。
在useQuery里,这个策略体现在params的isEmpty判断上。如果你需要改成传 null,把if (isEmpty) return改成if (isEmpty) { result[f.key] = null; return }即可,改动点集中在一处,这也是配置表方案的好处。
3.2 数组参数的两种格式与后端约定
数组字段(多选分类、多选标签)在请求里有两种常见格式:重复键?ids=1&ids=2和逗号拼接?ids=1,2。前者是 HTTP 标准做法,后者是国内后端更常见的做法。前端不该猜,应该在配置表里显式声明:
{ key: 'tagIds', label: '标签', type: 'checkbox', defaultValue: [], // 显式声明数组序列化格式,避免联调时扯皮 serialize: (val) => { if (!val.length) return {} // 逗号拼接:适合后端用 String.split(',') 解析 return { tagIds: val.join(',') } // 若后端要重复键,改成:return { tagIds: val } // 具体用哪种由 axios 的 paramsSerializer 决定 } }这里有个容易忽略的点:如果选重复键格式,axios 默认的paramsSerializer对数组的处理在不同版本里行为不一致,有的会序列化成tagIds[]=1&tagIds[]=2,多出一对中括号。稳妥做法是在 axios 实例上显式配置序列化器,或者干脆统一用逗号拼接,把解析责任交给后端。我倾向于后者,因为前端可控,后端split一下成本极低。
3.3 时间范围的格式与时区陷阱
时间范围是组合查询里 bug 密度最高的字段。三个问题必须提前定:传什么格式(时间戳还是字符串)、传几个字段(一个范围还是两个端点)、时区怎么处理。
格式上,我建议统一传YYYY-MM-DD HH:mm:ss字符串,因为可读、可调试,后端大多数框架能直接解析。时间戳虽然省事,但排查问题时对着1717000000发呆的体验很差。端点数量上,拆成start和end两个字段比传一个数组更通用,因为很多后端框架对数组参数的绑定支持不好。
时区是最隐蔽的坑。前端日期选择器返回的Date对象带本地时区,toISOString()会转成 UTC,如果你的用户在东八区,选「2024-06-01 00:00:00」,toISOString()出来是2024-05-31T16:00:00Z,后端按 UTC 解析就少了一天。解决办法是格式化时用本地时间拼接,不要走toISOString():
// 本地时间格式化,避免 toISOString 的时区偏移 function formatLocal(date) { const pad = (n) => String(n).padStart(2, '0') return ( `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` ) } // 在 serialize 里使用 serialize: (val) => { if (!val || val.length !== 2) return {} return { createdStart: formatLocal(new Date(val[0])), createdEnd: formatLocal(new Date(val[1])) } }如果后端明确要求 UTC,那就反过来,用toISOString()并在文档里写清楚。关键是「前后端对同一个时间字符串的解释必须一致」,这件事必须在联调前对齐,不能靠猜。
4. 避坑与排查:组合查询里最费时间的五个问题
这一章记录的是我在实际项目里反复遇到的五个问题,每个都按「现象 → 原因 → 解决」写,方便你对照排查。
4.1 改一个条件触发多次请求
现象:在输入框里打字,每敲一个字符就发一次请求,网络面板里请求排队。
原因:watch监听params时没有防抖,且params是computed,每次form变化都会重新计算并触发监听。
解决:在watch回调里加防抖(前面代码里的setTimeout方案),或者改用「查询按钮」模式,只在点击时读一次params.value。如果坚持实时查询,防抖时间不要低于 200ms,否则用户快速输入时仍会发多次。
4.2 重置后请求参数里还残留旧值
现象:点了重置按钮,表单看起来清空了,但请求里还带着上一次的筛选条件。
原因:reset只重置了form,但请求函数里可能缓存了上一次的params,或者watch的防抖定时器还没执行,旧请求先发出去了。
解决:reset之后要清掉待执行的防抖定时器,并立即用新的params发一次请求。另外检查请求函数是否直接引用了form而不是params,直接引用form会绕过空值过滤。
4.3 数组字段传了空数组导致后端报错
现象:多选字段一个都没选,请求里出现categoryIds=或categoryIds=[],后端解析失败。
原因:serialize里没有对空数组做判断,直接join(',')得到空字符串,或者直接传了空数组。
解决:在serialize开头加if (!val.length) return {},让空数组字段完全不参与请求。这个判断要写在每个数组字段的serialize里,或者抽成一个serializeArray工具函数统一处理。
4.4 时间范围跨时区后查询结果对不上
现象:用户选「今天」,查出来的数据包含昨天或缺少今天的数据。
原因:前端用toISOString()格式化,把本地时间转成了 UTC,后端按 UTC 或按本地时间解析,两边差了几个小时。
解决:统一用本地时间格式化函数(见 3.3),并在接口文档里明确时间字段的时区约定。如果后端存的是 UTC,前端格式化后再手动加时区偏移,或者让后端在接收时按约定时区解析。
4.5 动态显示筛选字段后,隐藏字段的值仍被提交
现象:根据权限隐藏了「金额区间」字段,但请求里仍然带着金额参数,导致无权限用户也能按金额筛选。
原因:隐藏字段只是模板里v-if掉了,form里的值还在,params计算时照样带上。
解决:在配置表里加一个visible字段,params计算时跳过visible === false的字段。或者在权限变化时调用reset清掉隐藏字段的值。前者更彻底,因为params是唯一的请求出口,在出口处过滤最安全。
5. 进阶:把查询条件同步到 URL,让刷新和分享都不丢状态
基础方案跑通之后,有一个体验问题会浮现出来:用户筛选了半天,刷新页面全没了;想把当前筛选结果发给同事,只能截图。把查询条件同步到 URL query 上,能同时解决刷新丢失和分享两个问题,代价是要处理 URL 和form的双向同步。
5.1 同步策略:谁驱动谁
同步有两种方向:form变化时写 URL,URL 变化时读form。如果两个方向都无条件执行,会形成死循环。我的做法是让 URL 作为唯一数据源,form只是它的投影:
// useQuerySync.js import { watch } from 'vue' import { useRoute, useRouter } from 'vue-router' export function useQuerySync(form, schema) { const route = useRoute() const router = useRouter() // 初始化:从 URL 读一次,回填 form function initFromUrl() { schema.forEach((f) => { const raw = route.query[f.key] if (raw === undefined) return // 数组字段从逗号字符串还原 if (Array.isArray(f.defaultValue)) { form[f.key] = raw ? String(raw).split(',') : [] } else { form[f.key] = raw } }) } // form 变化时写 URL,用 replace 避免污染浏览器历史 watch( form, () => { const query = {} schema.forEach((f) => { const val = form[f.key] const isEmpty = val === null || val === '' || (Array.isArray(val) && !val.length) if (!isEmpty) { query[f.key] = Array.isArray(val) ? val.join(',') : String(val) } }) router.replace({ query }) }, { deep: true } ) return { initFromUrl } }关键点是router.replace而不是router.push,否则用户每改一个筛选条件就多一条历史记录,点返回键要按十几次才能退出页面。initFromUrl只在组件挂载时调一次,之后 URL 由form单向驱动,避免循环。
5.2 类型还原:URL 里全是字符串
URL query 的值永远是字符串,回填form时要还原成原始类型。数字字段要Number(),布尔字段要判断'true'/'false',数组字段要split(',')。这一步如果偷懒,会出现「状态筛选选了 1,回填后变成字符串 '1',和后端约定的数字类型对不上」这种问题。
稳妥做法是在配置表里加一个parse函数,和serialize对称:
{ key: 'status', type: 'select', defaultValue: null, // 从 URL 还原时的类型转换 parse: (raw) => (raw === undefined ? null : Number(raw)), serialize: (val) => (val === null ? {} : { status: val }) }parse和serialize成对出现,一个负责 URL 到 form,一个负责 form 到请求参数,职责清晰。配置表因此变成「字段的完整生命周期描述」,加字段时把这两个函数一起写了,后面就不会有类型问题。
5.3 一个验证习惯
我现在的习惯是:任何组合查询页面写完,先手动构造几个 URL 直接粘贴到地址栏,看页面能不能正确回填并发出预期请求。比如?status=1&createdRange=2024-06-01,2024-06-30&tagIds=3,5,如果页面能正确显示筛选状态并查出对应数据,说明parse和serialize都写对了。这个习惯帮我提前发现了至少三次类型还原的 bug,比等到测试提工单再查快得多。
组合查询这件事,难点从来不在单个字段怎么绑,而在于字段多起来之后,空值、类型、同步这三层有没有统一的出口。把配置表当唯一声明源,把params当唯一请求出口,把 URL 当唯一状态源,三个「唯一」立住,字段加到二十个也不会乱。希望帮到你。
本文还有配套的精品资源,点击获取