中式美食的菜谱详情页一开始看起来很简单:点一张菜谱卡片,进详情页,看完再返回。真正做起来以后才发现,麻烦不在详情页本身,而在“从哪里进来”和“返回后该恢复成什么样”。
比如在搜索页里筛了“番茄”,列表已经滑到中间,然后点进“番茄炒蛋”。看完返回时,如果页面直接回到列表顶部,关键词也没了,刚才找菜的过程就被打断了。这个问题不是什么大功能,但很影响实际使用,因为每次返回都像重新开始找。
问题不是 pop 一下这么简单
我一开始容易把返回想简单:详情页在栈顶,返回就是pop。这个想法只在“首页进详情页”时还凑合,一旦入口多起来就不够了。
中式美食里同一个菜谱可能从这些地方进详情:
| 入口 | 进入前页面里有什么状态 | 返回时最怕丢什么 |
|---|---|---|
| 搜索页 | 关键词、搜索结果、滚动位置 | 关键词清空、列表回到顶部 |
| 分类页 | 菜系、难度、排序方式 | 分类条件重置 |
| 收藏页 | 收藏分组、当前位置 | 返回后跳回默认收藏列表 |
| 首页推荐 | 推荐来源、卡片位置 | 不知道从哪张推荐卡进来的 |
| 购物清单 | 食材来源、勾选状态 | 返回后清单统计对不上 |
所以详情页不能只拿一个dishId。dishId只能说明“打开哪道菜”,不能说明“从哪里来、返回后怎么恢复”。
我把返回信息拆成三类
这次我会把信息拆成三类,不再都塞进页面临时变量里。
| 信息 | 放哪里 | 为什么 |
|---|---|---|
| 菜谱 id | 路由参数 | 详情页必须知道打开哪道菜 |
| 来源入口 | 路由参数 | 返回时要知道回搜索、收藏还是推荐 |
| 列表现场 | 上一个页面自己的状态里 | 关键词、排序、滚动位置属于列表页,不属于详情页 |
这个边界很重要。详情页负责展示菜谱,不应该替搜索页保存关键词,也不应该替收藏页保存分组。详情页只负责把“我是从哪里进来的”带着走,返回时让来源页自己恢复现场。
一个比较稳的路由上下文可以这样写:
typeDetailSource='search'|'category'|'favorite'|'recommend'|'shopping'interfaceDishDetailRoute{dishId:stringsource:DetailSource keyword?:stringcategoryId?:stringsortKey?:stringscrollAnchor?:string}这里的scrollAnchor不一定非得是像素位置。实际项目里我更愿意用菜谱 id 当锚点,比如当前列表中最后点开的那张卡片 id。像素位置容易被图片加载、卡片高度变化影响,业务 id 更稳定。
进入详情页时,把现场带过去
从搜索页进入详情时,不要只传菜谱 id。
functionopenDishFromSearch(dishId:string):void{constroute:DishDetailRoute={dishId,source:'search',keyword:this.keyword,sortKey:this.sortKey,scrollAnchor:dishId}this.pathStack.pushPath({name:'DishDetail',param:route})}这段代码解决的是“返回后不知道恢复哪一份列表现场”的问题。搜索页点进去,就带搜索页的现场;收藏页点进去,就带收藏页的现场。不要让详情页自己猜。
如果入口很多,可以把构造路由参数的逻辑收起来,不要每个页面都手写一遍。
functionbuildDetailRoute(input:DishDetailRoute):DishDetailRoute{return{dishId:input.dishId,source:input.source,keyword:input.keyword?.trim(),categoryId:input.categoryId,sortKey:input.sortKey,scrollAnchor:input.scrollAnchor??input.dishId}}这样做的好处是后面要加新入口时,不会每个页面都漏一个字段。
返回时不要让详情页接管列表
详情页最容易写错的地方,是在返回按钮里直接改列表状态。这样短期能跑,后面会越来越乱。
我更倾向于让详情页只做两件事:
- 读出自己收到的路由参数。
- 返回时把结果交还给来源页。
functionbackFromDetail(route:DishDetailRoute):void{this.pathStack.pop()this.routeContextStore.markPendingRestore(route)}真正恢复关键词、分类、排序、滚动位置的动作,应该在来源页里做。
functionrestoreSearchPage(route:DishDetailRoute):void{if(route.source!=='search')returnthis.keyword=route.keyword??''this.sortKey=route.sortKey??'default'this.reloadList()this.pendingAnchor=route.scrollAnchor}这里有个细节:先恢复筛选条件,再刷新列表,最后滚动到锚点。顺序反了就容易出问题。比如先滚动,再刷新列表,滚动位置会被新列表覆盖;先清空关键词,再恢复来源,列表会闪一下默认结果。
滚动位置别直接绑死像素
菜谱列表里有图片,有些图片加载得慢,卡片高度会变化。如果直接保存scrollY = 860这种像素值,返回时可能会错位。
中式美食这种卡片列表,更适合保存“上次点的是哪道菜”。
functionrestoreScrollAfterListReady(items:RecipeCardModel[]):void{if(!this.pendingAnchor)returnconstindex=items.findIndex((item)=>item.id===this.pendingAnchor)if(index<0)returnthis.listScroller.scrollToIndex(index)this.pendingAnchor=undefined}这样做不一定百分百还原到原来的像素,但能把人带回刚才那张菜谱附近。对找菜这个场景来说,这比硬记像素稳。
这次踩坑后,我会重点看这几个地方
| 检查点 | 为什么要看 |
|---|---|
详情页是否只认识dishId | 只认识 id,返回现场大概率会丢 |
| 来源页有没有保存自己的筛选状态 | 不保存就只能回默认列表 |
| 返回时是否先刷新再滚动 | 顺序错了,滚动会被刷新覆盖 |
| 列表锚点是不是业务 id | 用 index 或像素,筛选排序后容易错 |
| 新入口有没有补全 route 字段 | 收藏、推荐、购物清单入口最容易漏 |
这类问题不适合靠一句“返回时保存状态”带过去。要把入口、路由、列表状态、滚动锚点都拆出来,不然后面新增收藏入口、推荐入口、购物清单入口时,还会继续出同样的问题。
改完后解决了什么
这次拆完以后,页面逻辑会清楚很多:
- 详情页只管展示菜谱,不再乱动搜索页和收藏页的状态。
- 搜索页、分类页、收藏页各自恢复自己的现场,不互相抢工作。
- 返回后能回到刚才那一段列表,不用重新输入关键词再找一次。
- 新增入口时,只要补
source和上下文,不需要重写详情页。
对中式美食这种多入口菜谱应用来说,这个拆法比“哪里坏了补哪里”稳。后面再加最近浏览、厨房笔记、视频拆菜入口,也能沿用同一套路由上下文。
以后怎么避免再踩
我后面会按一个简单规则处理详情页:只要一个页面能从多个地方进入,就不能只传业务 id。
至少要问四个问题:
- 这个详情页从哪些入口进来?
- 每个入口返回后要恢复什么状态?
- 哪些状态属于来源页,哪些状态属于详情页?
- 返回时有没有稳定锚点,而不是靠 index 或像素硬凑?
如果这四个问题没答清楚,先不要急着写跳转代码。跳转写得越早,后面补状态越难看。
这次 NavPathStack 的重点不是 API 有多复杂,而是边界要清楚:路由负责把上下文带过去,来源页负责恢复现场,详情页不要越权。边界清了,返回体验自然就稳了。