本文是「前端工程现场」系列的第 8 篇。
下面使用一个便于理解的 Vue 3 与 Pinia 后台项目作为假设场景。代码、路由和目录均为简化示例,不对应具体线上项目。
设想一个订单后台有两个页面:一个是订单列表页面,一个是订单导出页面。
两个页面都需要使用订单状态、日期范围和关键词。为了避免重复定义查询条件,日常开发中,我们把它们统一放进了 Pinia Store 里面:
export const useOrderStore = defineStore('order', {
state: () => ({
keyword: '',
status: 'all',
dateRange: [],
page: 1
})
})
订单列表修改筛选条件后,导出页面会直接读到同一组值。这个效果最初看起来很方便,页面之间既不用传参数,也不用重复维护查询状态,但是问题会出现在后续的使用中。
举个具体的例子,开发者在订单列表中搜索退款,进入某条订单详情,再返回列表,页面仍然保留原筛选条件,这看起来似乎是符合我们预期的。用户接着进入订单导出页,却发现导出表单也自动带上了退款这个字段。另一个开发者为了修复导出页,在页面挂载时调用 reset(),结果从导出页返回订单列表后,列表筛选也被清空了。
同一份状态同时服务多个页面后,任何页面的读取、修改和重置都会影响其他调用方。问题已经不在某个输入框是否正确绑定,而在状态的共享范围和生命周期没有被明确区分。
本文准备分析三件事:哪些状态适合留在页面,哪些状态应该写进 URL,哪些数据才值得进入全局 Store,以及调整后如何验证页面跳转、刷新和返回行为。
一、全局 Store 延长了筛选状态的生命周期
订单列表原本通过 Store 读取筛选条件:
const orderStore = useOrderStore()
const query = computed(() => ({
keyword: orderStore.keyword,
status: orderStore.status,
page: orderStore.page
}))
这段代码位于页面请求参数组装阶段。它让接口查询直接依赖全局 Store 中的值。
在普通单页应用运行期间,组件卸载不会自动销毁 Pinia Store。用户离开订单列表后,Store 仍然存在,再次进入页面时,页面会继续读取之前的筛选条件。
页面状态的实际流程变成:
进入订单列表
→ 修改 Store
→ 离开页面
→ Store 继续存在
→ 再次进入时读取旧值
这实现了返回列表时保留筛选条件,但也把状态生命周期从当前页面存在期间延长到了当前应用运行期间。
假设项目没有使用持久化插件,浏览器整页刷新后,内存中的 Store 通常会重新创建,筛选条件也随之丢失。若项目额外使用了本地存储或持久化插件,刷新后的表现会改变,排查时需要以实际插件配置为准。
两种生命周期可以这样比较:
| 状态存放位置 | 页面切换后 | 整页刷新后 | 默认共享范围 |
|---|---|---|---|
| 页面局部状态 | 页面卸载时结束 | 丢失 | 当前页面实例 |
| Pinia Store | 当前应用内继续存在 | 默认重新创建 | 使用该 Store 的页面 |
| URL 查询参数 | 地址仍保留时继续存在 | 保留 | 当前 URL |
| 本地存储 | 继续存在 | 保留 | 同源页面,具体取决于读取方式 |
把筛选条件放进 Store,改变的不只是代码位置,还改变了状态什么时候结束、哪些页面能修改它。
如果项目确实需要进入详情后返回列表仍保留筛选这个功能,全局 Store 可以实现这个效果。但当导出页、统计页和列表页共用同一组字段时,状态开始同时承担多个页面的需求,重置规则也会变得难以约定。
二、多个页面共享同一状态后,重置动作会互相影响
假设 Store 提供统一重置方法:
export const useOrderStore = defineStore('order', {
state: () => ({
keyword: '',
status: 'all',
page: 1
}),
actions: {
resetQuery() {
this.keyword = ''
this.status = 'all'
this.page = 1
}
}
})
导出页面为了避免继承列表筛选,在进入时调用:
onMounted(() => {
orderStore.resetQuery()
})
这段代码位于导出页面初始化阶段。它确实能让导出表单从默认条件开始,但清理的是整个订单 Store,而不是只属于导出页的状态。
如果订单列表仍然依赖同一份 Store,返回列表时会发现原筛选已经被清空。
修改前后的影响范围如下:
| 操作 | 开发者原本想影响的页面 | 实际影响范围 |
|---|---|---|
| 列表页修改关键词 | 订单列表 | 所有读取该字段的页面 |
| 导出页重置条件 | 订单导出 | 列表、导出及其他调用方 |
| 列表页切换页码 | 订单列表 | 共享同一 page 的页面 |
| 详情页更新订单状态 | 当前订单详情 | 可能触发列表和统计重新读取 |
这类问题容易被误判为重置时机不对。开发者可能尝试把 resetQuery() 从 onMounted 移到 onUnmounted,或者在路由守卫中判断来源页面。
这些调整能够改变某一次跳转结果,却没有缩小状态的共享范围。只要多个页面继续读写同一份字段,一个页面的重置就必须考虑其他调用方。
排查时可以先搜索 Store 字段的读写位置:
grep -R "orderStore.keyword" src
grep -R "resetQuery" src
这一步用来确认影响范围,利用这个搜索结果能帮助判断一个字段究竟只服务订单列表,还是已经被导出、统计和详情页面共同使用。
如果一个筛选字段只有列表页使用,把它放进全局 Store 并没有带来实际共享收益,却增加了生命周期和重置规则。
三、页面状态、URL 状态和全局状态分别解决什么问题
状态放在哪里,可以先看它需要被谁读取,以及需要保留多久。
订单列表中的状态可以拆成三类:
当前展开了哪一行
关键词、状态、页码
当前用户、租户和权限
它们虽然都会影响页面,使用范围却不同。
| 状态示例 | 推荐位置 | 原因 |
|---|---|---|
| 当前展开行、弹窗开关 | 页面局部状态 | 只服务当前页面实例 |
| 关键词、状态、页码 | URL 查询参数 | 与当前列表地址相关,适合刷新和分享 |
| 当前用户、租户、权限 | 全局 Store | 多个业务模块长期共享 |
| 临时输入内容 | 页面局部状态 | 未提交前不需要暴露给其他页面 |
| 跨步骤表单草稿 | 根据流程决定 | 可能需要模块 Store 或持久化 |
这个划分没有要求所有筛选条件都必须使用一种状态。举个例子,内部后台如果不需要刷新恢复、分享链接或浏览器前进后退,页面局部状态可能已经足够。
URL 查询参数适合表达当前页面正在查看什么。例如:
/orders?keyword=refund&status=paid&page=2
地址能够记录当前列表条件。用户刷新页面、复制链接或使用浏览器返回时,页面可以从 URL 恢复查询。
全局 Store 更适合存放多个模块都需要、并且生命周期覆盖整个应用的数据,例如:
当前登录用户
当前租户
权限集合
主题设置
全局通知
判断状态位置时,可以使用下面三个问题:
- 离开当前页面后,这个状态是否还需要存在;
- 刷新或复制链接后,是否需要恢复同一结果;
- 其他业务模块是否应该直接修改它。
如果只服务当前页面,优先考虑页面局部状态;如果它描述当前页面地址,考虑 URL;如果多个模块确实需要共享,再进入全局 Store。
四、把列表筛选迁回 URL 后,查询流程如何变化
假设项目决定把订单列表的关键词、状态和页码放进路由查询参数。
页面读取参数时,可以先集中完成解析:
const route = useRoute()
const query = computed(() => ({
keyword: String(route.query.keyword ?? ''),
status: String(route.query.status ?? 'all'),
page: Number(route.query.page ?? 1)
}))
这段代码位于页面初始化和请求参数组装阶段。接口查询现在由当前 URL 决定,不再依赖全局 Store 中可能被其他页面修改的值。
用户提交筛选时,页面更新路由:
const router = useRouter()
function applyFilter(keyword: string, status: string) {
router.push({
query: {
...route.query,
keyword: keyword || undefined,
status: status === 'all' ? undefined : status,
page: 1
}
})
}
这段代码改变的是筛选提交阶段,页面不再直接修改全局状态,而是生成新的 URL。
页码也可以通过同一方式更新:
function changePage(page: number) {
router.push({
query: {
...route.query,
page
}
})
}
请求监听当前查询条件:
watch(
query,
() => loadOrders(query.value),
{ immediate: true }
)
修改后的流程为:
用户修改筛选
→ 更新 URL
→ 页面读取查询参数
→ 组装接口参数
→ 请求订单数据
修改前后可以这样比较:
| 环节 | 使用全局 Store | 使用 URL 查询参数 |
|---|---|---|
| 筛选提交 | 修改 Store 字段 | 更新路由查询参数 |
| 页面刷新 | 默认丢失内存状态 | 从 URL 恢复 |
| 返回列表 | 依赖 Store 是否被重置 | 浏览器历史保留对应地址 |
| 分享链接 | 无法表达当前筛选 | 地址包含当前条件 |
| 导出页隔离 | 容易读取同一 Store | 使用自己的路由和页面状态 |
URL 方案也有代价。
查询参数需要解析和校验,数组、日期范围和复杂对象不适合直接无规则地写入地址。字段改名时,还要考虑旧链接是否继续兼容。频繁更新 URL 也会产生浏览器历史记录,需要根据交互选择 push 或 replace。
例如输入框每敲一个字符都调用 push,用户按返回键时可能需要经过大量中间地址。更合适的做法是输入阶段保留页面局部值,点击查询或防抖结束后再更新 URL,或者使用 replace 更新当前历史记录。
五、全局 Store 只保留真正跨模块共享的数据
筛选条件迁移后,订单 Store 不必完全删除。它可以继续保存订单模块中确实需要跨页面共享的数据。
例如多个订单页面都依赖当前选择的店铺:
export const useOrderStore = defineStore('order', {
state: () => ({
currentShopId: ''
}),
actions: {
selectShop(shopId: string) {
this.currentShopId = shopId
}
}
})
如果店铺选择会同时影响订单列表、订单详情和订单统计,把它放进模块 Store 有明确的共享理由。
但仍需确认它与全局状态的关系,如果当前店铺只属于订单模块,可以放在订单 Store。如果整个系统的商品、库存和报表都依赖同一店铺,可能需要上移到应用级 Store。
Store 不是只能存绝对全局的数据,它也可以服务一个业务模块。判断重点在于调用范围是否清楚,重置时机是否能被明确说明。
调整后,可以形成下面的状态分布:
| 状态 | 新位置 | 生命周期 |
|---|---|---|
| 筛选输入草稿 | 页面局部状态 | 当前页面实例 |
| 已提交关键词、状态、页码 | URL 查询参数 | 当前地址和历史记录 |
| 当前订单模块店铺 | 订单 Store | 当前应用运行期间 |
| 登录用户和权限 | 应用级 Store | 当前登录会话 |
| 导出表单内容 | 导出页面局部状态 | 当前导出页面 |
这样的划分减少了页面之间无意共享筛选条件的情况,也让每种状态的清理方式更明确:
局部状态:页面卸载时结束
URL 状态:通过路由变化更新
模块 Store:业务切换或退出时重置
应用 Store:退出登录或应用重建时重置
它仍然需要团队约定。开发者如果为了少写几个参数,又把页面筛选重新写进 Store,原来的共享问题会逐渐回来。
六、迁移后应该怎样验证页面行为
状态位置调整后,类型检查和构建通过只能说明代码能够编译。页面跳转和浏览器历史行为仍然需要单独验证。
可以准备一组与本次改动直接相关的检查:
| 验证操作 | 预期结果 |
|---|---|
| 设置筛选后刷新页面 | 筛选条件和列表结果能够恢复 |
| 从列表进入详情再返回 | 返回原来的筛选和页码 |
| 从列表进入导出页 | 导出页不自动继承列表条件 |
| 在导出页重置表单 | 不影响订单列表地址 |
| 复制筛选后的 URL 打开 | 得到相同的查询条件 |
| 输入非法页码 | 页面回退到可接受的默认值 |
路由参数需要处理无效输入。例如:
function parsePage(value: unknown) {
const page = Number(value)
return Number.isInteger(page) && page > 0
? page
: 1
}
这段代码位于 URL 参数解析阶段。它避免 /orders?page=abc 直接把 NaN 传入接口。
它只能处理页码格式,不能校验服务端是否存在对应页数。列表总页数变化后,当前页码超出范围,仍然需要接口结果或页面逻辑进一步处理。
如果项目已有测试基础,可以对参数解析和路由更新函数编写单元测试。如果是页面返回、刷新和浏览器历史则更适合通过组件测试或端到端测试覆盖。
迁移提交最好与新业务需求分开。例如:
提交 A:筛选状态从 Store 迁移到 URL
提交 B:增加退款状态筛选
第一笔提交主要验证状态来源变化,第二笔提交再检查新字段,混在同一个提交里,出现问题时很难判断是迁移还是业务逻辑导致。
七、当前方案还需要哪些边界约束
把筛选条件移出全局 Store 后,页面之间的状态干扰会减少,但这套方案没有覆盖所有状态问题。
第一,URL 不适合保存敏感数据。令牌、个人隐私和服务端密钥不能放进查询参数,浏览器历史、日志和分享链接都可能暴露这些内容。
第二,复杂表单不一定适合全部写进 URL。大量嵌套字段、临时上传内容和未提交草稿,需要结合页面状态、模块 Store 或持久化方案处理。
第三,Store 中的数据仍然需要明确重置时机。用户退出登录、切换租户或切换店铺时,如果旧状态继续存在,仍然可能影响后续页面。
第四,页面局部状态也可能产生重复代码。多个列表都需要解析页码、同步筛选和重置查询时,可以提取组合函数,但组合函数应服务同一种流程,不要重新创建一个包含所有业务字段的超级查询 Store。
项目可以建立一份简单约定:
| 判断问题 | 优先考虑的位置 |
|---|---|
| 只影响当前组件吗 | 组件局部状态 |
| 描述当前页面内容吗 | URL 查询参数 |
| 同一业务多个页面共享吗 | 模块 Store |
| 整个应用都需要吗 | 应用级 Store |
| 刷新后需要恢复吗 | URL 或明确的持久化方案 |
这张表只能作为讨论起点。状态大小、更新频率、权限范围和页面交互都会影响最终选择。
八、小结
这次问题表面上是订单列表和导出页面互相修改了筛选条件。原因在于关键词、状态和页码被放进同一份全局 Store,页面卸载后状态仍然存在,任何调用方的修改和重置都会影响其他页面。
调整后的方案把临时输入留在页面,把已经提交的列表条件写进 URL,只让确实跨页面共享的数据继续留在 Store。它改变了筛选查询的输入来源,也让刷新、返回和分享链接有了更明确的行为。
这套处理仍然需要参数解析、旧链接兼容、Store 重置和测试维护。URL 也不适合保存敏感信息或复杂草稿。状态拆分减少的是无意共享,并不会替项目自动决定每个变量应该放在哪里。
下次准备把一个字段放进全局 Store 时,可以先确认它需要被哪些页面读取、离开页面后是否仍应存在、刷新后是否需要恢复。这个判断通常比“以后可能会用到,所以先放全局”更容易控制维护范围。下一篇会继续讨论首屏变慢后的排查顺序,重点放在资源体积、请求链路和渲染阶段分别怎样影响用户看到页面的时间。