
中式美食的菜谱详情页一开始只有一个入口:用户在首页点一道菜,进入详情页,看完再返回首页。这个阶段用 pop() 基本够用。问题是项目往后做,详情页入口会越来越多:搜索结果页点进来、收藏页点进来、推荐卡片点进来、分享链接点进来。入口一多,返回就不能再写死。用户从搜索结果进详情页,返回时应该回到刚才的搜索词和筛选状态;从收藏页进来,取消收藏后还要回到收藏列表;如果是分享链接直接打开,栈里可能根本没有上一级页面。
我这次处理的核心不是"怎么跳到详情页",而是让详情页知道自己从哪来、带着什么上下文来、返回时该回到哪里。
环境和边界
| 项目 | 说明 |
|---|---|
| 应用 | 中式美食 |
| 技术栈 | HarmonyOS / ArkTS / ArkUI |
| 页面模型 | Stage 模型下的 ArkUI 页面 |
| 关键能力 | Navigation、NavPathStack、页面参数、详情页状态恢复 |
| 主要场景 | 首页、搜索结果、收藏列表、推荐卡片、分享链接进入菜谱详情 |
这类问题不能只靠页面里写几个 if。如果详情页自己判断来源、自己查数据、自己决定返回路径,后面每增加一个入口,详情页都会被改一遍。我的拆法是:入口页负责构造来源参数,详情页只消费 recipeId 和展示状态,返回逻辑集中放到一个控制器里。
普通 pop 为什么不够
| 进入方式 | 用户期待 | 只写 pop() 的风险 |
|---|---|---|
| 首页菜谱卡片 | 返回首页,保持首页列表状态 | 一般问题不大 |
| 搜索结果 | 返回同一个搜索词、分类和滚动位置 | 搜索词丢失,回到默认列表 |
| 收藏列表 | 返回收藏页,取消收藏后列表刷新 | 可能回到首页,收藏状态也不同步 |
| 推荐卡片 | 回到推荐区域,不打断浏览节奏 | 栈层级不稳定,返回位置不明确 |
| 分享链接 | 没有上级时回到首页或详情承接页 | 栈为空时 pop() 没效果,页面停住 |
我踩到的第一个坑就是把返回写成"详情页返回首页"。首页入口看不出问题,搜索入口一测就露馅:用户搜"红烧",点进红烧肉详情页,再返回,搜索词和列表位置都没了。
先定义入口上下文
详情页需要的数据可以分成两类。
| 类型 | 放什么 | 不放什么 |
|---|---|---|
| 路由上下文 | 来源、菜谱 id、搜索词、分类、列表位置 | 整份菜谱对象、复杂 UI 状态 |
| 详情页状态 | 菜谱内容、收藏状态、浏览记录 | 上一个页面的布局细节 |
我倾向于只把轻量参数放进路由栈。整份菜谱对象不要塞进 NavPathStack,否则数据更新、收藏状态变化、分享打开这些场景都会变麻烦。
ts
type DetailEntry = 'home' | 'search' | 'favorite' | 'recommend' | 'share'
interface RecipeDetailRouteMeta {
from: DetailEntry
recipeId: string
keyword?: string
categoryId?: string
scrollIndex?: number
}
function normalizeDetailMeta(raw: Partial<RecipeDetailRouteMeta>): RecipeDetailRouteMeta {
return {
from: raw.from ?? 'home',
recipeId: String(raw.recipeId ?? ''),
keyword: raw.keyword,
categoryId: raw.categoryId,
scrollIndex: raw.scrollIndex ?? 0
}
}
这里最重要的是 recipeId 和 from。recipeId 用来重新查详情数据,from 用来决定返回路径。keyword、categoryId、scrollIndex 是搜索页和列表页恢复状态用的,不是每个入口都要带。
入口页只负责把话说清楚
入口页不要把返回规则写进详情页,也不要直接把整份菜谱塞过去。它只需要说明:我从哪里来,带哪道菜过去,必要时补上列表上下文。
ts
function openDetailFromHome(stack: NavPathStack, recipeId: string): void {
stack.pushPath({
name: 'RecipeDetailPage',
param: {
from: 'home',
recipeId
} as RecipeDetailRouteMeta
})
}
function openDetailFromSearch(
stack: NavPathStack,
recipeId: string,
searchState: SearchResultState
): void {
stack.pushPath({
name: 'RecipeDetailPage',
param: {
from: 'search',
recipeId,
keyword: searchState.keyword,
categoryId: searchState.categoryId,
scrollIndex: searchState.firstVisibleIndex
} as RecipeDetailRouteMeta
})
}
function openDetailFromFavorite(
stack: NavPathStack,
recipeId: string,
listIndex: number
): void {
stack.pushPath({
name: 'RecipeDetailPage',
param: {
from: 'favorite',
recipeId,
scrollIndex: listIndex
} as RecipeDetailRouteMeta
})
}
这段代码看着普通,但它把边界定住了:入口页不关心详情页怎么查菜谱,详情页也不用猜自己从哪来。以后加"购物清单页进入详情"或者"最近浏览进入详情",继续扩展 DetailEntry 就行。
详情页不要背上整个路由规则
详情页最容易越写越乱。它本来应该做三件事:解析参数、加载菜谱、展示状态。返回到哪里,不应该散落在各个按钮事件里。
ts
interface RecipeDetailState {
loading: boolean
recipe?: RecipeDetail
isFavorite: boolean
errorMessage?: string
}
class RecipeDetailViewModel {
private state: RecipeDetailState = {
loading: true,
isFavorite: false
}
constructor(
private recipeRepo: RecipeRepository,
private favoriteRepo: FavoriteRepository,
private viewedRepo: ViewedRecipeRepository
) {}
async load(meta: RecipeDetailRouteMeta): Promise<void> {
if (!meta.recipeId) {
this.state = {
loading: false,
isFavorite: false,
errorMessage: '菜谱不存在'
}
return
}
const recipe = await this.recipeRepo.findById(meta.recipeId)
if (!recipe) {
this.state = {
loading: false,
isFavorite: false,
errorMessage: '没有找到这道菜'
}
return
}
const isFavorite = await this.favoriteRepo.exists(meta.recipeId)
await this.viewedRepo.markViewed(meta.recipeId, Date.now())
this.state = {
loading: false,
recipe,
isFavorite
}
}
getState(): RecipeDetailState {
return this.state
}
}
这里我没有让 ViewModel 处理"回搜索页"这件事。ViewModel 只管详情数据,返回交给路由控制器。这样写的好处是收藏、浏览记录、菜谱详情这些业务状态不会和页面跳转绑死。
返回逻辑集中处理
返回控制器做的事情很简单:有明确来源时按来源恢复;没有明确来源时看栈里有没有上一页;栈不完整时给一个稳定兜底。
ts
class RecipeDetailBackController {
constructor(private stack: NavPathStack) {}
back(meta: RecipeDetailRouteMeta): void {
if (meta.from === 'search') {
this.stack.replacePath({
name: 'SearchResultPage',
param: {
keyword: meta.keyword ?? '',
categoryId: meta.categoryId ?? '',
scrollIndex: meta.scrollIndex ?? 0
}
})
return
}
if (meta.from === 'favorite') {
this.stack.replacePath({
name: 'FavoritePage',
param: {
scrollIndex: meta.scrollIndex ?? 0,
refresh: true
}
})
return
}
if (meta.from === 'share') {
this.stack.replacePath({ name: 'HomePage' })
return
}
if (this.stack.size() > 1) {
this.stack.pop()
return
}
this.stack.replacePath({ name: 'HomePage' })
}
}
这里不用迷信 pop()。pop() 适合栈稳定的场景,搜索恢复、收藏刷新、分享兜底这些都不是单纯弹出一层能解决的。该 replacePath 的地方就明确替换,不要把状态恢复寄托在"上一个页面刚好还在"。
页面接入保持薄一点
页面层只做组装。它从路由参数里拿 meta,交给 ViewModel 加载,返回按钮交给 BackController。
ts
@Entry
@Component
struct RecipeDetailPage {
@State private state: RecipeDetailState = {
loading: true,
isFavorite: false
}
private meta: RecipeDetailRouteMeta = normalizeDetailMeta({})
private viewModel = new RecipeDetailViewModel(recipeRepo, favoriteRepo, viewedRepo)
private backController = new RecipeDetailBackController(pathStack)
aboutToAppear(): void {
this.meta = normalizeDetailMeta(readCurrentPathParam())
this.viewModel.load(this.meta).then(() => {
this.state = this.viewModel.getState()
})
}
build() {
Column() {
DetailTopBar({
title: this.state.recipe?.name ?? '菜谱详情',
onBack: () => this.backController.back(this.meta)
})
if (this.state.loading) {
LoadingView()
} else if (this.state.errorMessage) {
ErrorView({ message: this.state.errorMessage })
} else {
RecipeDetailContent({
recipe: this.state.recipe,
isFavorite: this.state.isFavorite
})
}
}
}
}
实际项目里 pathStack、Repository 实例和参数读取可以按自己的工程组织放到 AppShell、页面容器或依赖注入里。关键不是固定某个文件名,而是别让详情页变成"什么都知道"的大页面。
我会重点测这几条路径
| 测试路径 | 操作 | 期望结果 |
|---|---|---|
| 首页进入 | 首页点菜谱,进入详情,再返回 | 回到首页列表 |
| 搜索进入 | 搜"红烧",点菜谱,返回 | 搜索词、分类和列表位置保留 |
| 收藏进入 | 收藏页点菜谱,取消收藏,返回 | 收藏页刷新,位置尽量不乱跳 |
| 推荐进入 | 推荐卡片进详情,返回 | 回到推荐区域附近 |
| 分享进入 | 外部链接打开详情,返回 | 栈为空时回到首页 |
| 异常 id | 传入空 id 或不存在的 id | 显示错误态,不白屏 |
我比较在意搜索和收藏这两条,因为它们最容易暴露"路由栈只是跳转,状态没有恢复"的问题。首页入口太简单,不能只拿首页入口当验收。
这次拆完后的收益
NavPathStack 不是只用来把页面压进去。对中式美食这种后面还会继续加搜索、收藏、购物清单、最近浏览、推荐排序的应用来说,路由栈最好一开始就把来源和上下文说清楚。
这次拆完以后,详情页少背了很多规则:它不需要知道搜索页怎么恢复,也不需要知道收藏页怎么刷新。入口页负责讲清楚来路,ViewModel 负责拿数据,BackController 负责回去。后面入口再增加,改的是路由上下文和返回控制器,不是到处找按钮事件。
如果你也在 HarmonyOS 里做多入口详情页,我建议先别急着写页面跳转,先把"从哪来、带什么、回哪去、栈为空怎么办"这四件事列出来。这个表列清楚以后,NavPathStack 的代码反而不会复杂。