HarmonyOS NavDestination 生命周期为什么会重复触发:aboutToAppear、onShown 和 onHidden 怎么分工
HarmonyOS NavDestination 生命周期为什么会重复触发:aboutToAppear、onShown 和 onHidden 怎么分工

Navigation 页面最容易误解的一点是:页面创建、页面显示、页面隐藏、页面销毁不是同一个阶段。aboutToAppear、onShown、onHidden 这些回调如果分工不清,页面一返回、一切 tab、一 push 新页面,就可能重复请求、重复刷新,甚至把已经隐藏页面的旧请求结果写回 UI。
我一般把 NavDestination 页面拆成三类动作:
- 初始化页面结构和 ViewModel;
- 页面真正显示时刷新轻量状态;
- 页面隐藏时停掉可见期任务,丢弃过期结果。
不要把所有事情都塞进 aboutToAppear,也不要每次 onShown 都重新拉全量数据。生命周期回调不是越多越保险,职责越清楚越稳定。
先看问题怎么发生
很多页面会这样写:
@Component
struct DetailPage {
aboutToAppear() {
this.fetchDetail()
}
build() {
NavDestination() {
DetailContent()
}
.onShown(() => {
this.fetchDetail()
})
}
}
这个写法的问题很直接:创建时请求一次,显示时又请求一次。后面从详情页 push 到评论页,再 pop 回来,onShown 又可能触发。应用切后台再回来,也可能触发可见性相关逻辑。
页面表现通常是:
- 详情接口重复请求;
- 返回页面后列表闪一下;
- 旧请求比新请求晚回来,把新数据覆盖掉;
- 页面已经隐藏,loading 还在改;
- 埋点、曝光、刷新逻辑重复执行。
这些问题不一定每次都复现,因为它跟页面栈、动画、网络速度和用户操作速度有关。越是这种不稳定问题,越要先把生命周期职责拆清楚。
aboutToAppear 适合做什么
aboutToAppear 更适合做“这次组件要被创建了,先把基础对象准备好”的工作。
比如:
aboutToAppear() {
this.viewModel = new DetailViewModel(this.detailId)
this.viewModel.bindRepository(this.repository)
}
它适合做初始化,但不适合无脑放所有网络请求。原因是页面创建和页面可见不是一回事。某些情况下页面创建了,但还没真正展示;某些情况下页面没有销毁,只是重新显示。
如果请求确实只需要一次,可以给它明确标记:
private loadedOnce: boolean = false
private loadOnce() {
if (this.loadedOnce) {
return
}
this.loadedOnce = true
this.fetchDetail()
}
这样代码表达的是“只加载一次”,而不是把希望寄托在某个生命周期只触发一次。
onShown 适合做什么
onShown 更适合处理“页面又被看到了”的事情。
比如:
- 从子页面返回后刷新一个轻量状态;
- 恢复暂停的动画或曝光统计;
- 检查当前页面是否需要重新校验;
- 页面可见时开始监听某些短周期任务。
不要每次显示都全量请求。更稳的写法是把首次加载和后续显示拆开:
NavDestination() {
DetailContent()
}
.onShown(() => {
this.visible = true
if (!this.loadedOnce) {
this.loadedOnce = true
this.fetchDetail('first-show')
return
}
this.refreshLightweightState()
})
.onHidden(() => {
this.visible = false
this.cancelVisibleWork()
})
这样第一次显示负责拿主数据,后面再次显示只做轻量刷新。比如从编辑页回来,只检查收藏状态、评论数量、是否需要刷新局部,而不是整页重新拉一遍。
案例一:重复显示导致重复请求
我用一个本地模型模拟了页面显示、隐藏、再显示的过程。
错误写法里,aboutToAppear 请求一次,每次 onShown 又请求一次:
function createBadPage() {
return {
requests: [],
aboutToAppear() {
this.requests.push('fetch:aboutToAppear')
},
onShown() {
this.requests.push('fetch:onShown')
}
}
}
模拟三次显示后,请求数量会明显偏多:
{
"badDuplicateFetchCase": {
"duplicateFetches": 4,
"unstable": true
}
}
稳定写法里,首次显示拉主数据,后续显示只做轻量刷新:
function createStablePage() {
return {
visible: false,
loadedOnce: false,
requests: [],
aboutToAppear() {
this.requests.push('init:view-model')
},
onShown() {
this.visible = true
if (!this.loadedOnce) {
this.loadedOnce = true
this.requests.push('fetch:first-show')
} else {
this.requests.push('refresh-lightweight')
}
},
onHidden() {
this.visible = false
this.requests.push('cancel-visible-work')
}
}
}
验证结果是:
{
"stableVisibilityCase": {
"fetches": 1,
"lightweightRefreshes": 2,
"stable": true
}
}
这个结果说明:重复显示不是问题,问题是每次显示都做了同一份重活。
案例二:隐藏页面不能接受旧请求结果
第二个问题更隐蔽:页面隐藏后,旧请求晚回来。
比如用户打开详情页,请求 A 发出;马上进入编辑页,详情页隐藏;编辑页返回后请求 B 发出;如果 A 比 B 晚回来,还把结果写回页面,就会出现旧数据覆盖新数据。
可以用一个请求 token 守住边界:
private visible: boolean = false
private activeRequestToken: number = 0
private fetchDetail(reason: string) {
const token = ++this.activeRequestToken
this.repository.fetchDetail(this.detailId).then((result) => {
if (!this.visible || token !== this.activeRequestToken) {
return
}
this.detail = result
})
}
private cancelVisibleWork() {
this.activeRequestToken += 1
}
onHidden 里不一定真的能取消所有请求,但至少可以让旧请求结果失效:
.onHidden(() => {
this.visible = false
this.cancelVisibleWork()
})
本地验证里,隐藏后旧 token 的结果被丢弃,重新显示后的最新结果才被接受:
{
"staleRequestCase": {
"staleAccepted": false,
"latestAccepted": true,
"acceptedResults": [
"latest-result"
],
"stable": true
}
}
这类保护很重要。页面生命周期能告诉你“页面现在不可见了”,但不会自动帮你判断哪个异步结果还有效。这个判断要自己写清楚。
几个回调怎么分工
可以先按这个表处理:
| 阶段 | 适合做什么 | 不适合做什么 |
|---|---|---|
aboutToAppear |
初始化对象、读取入参、创建 ViewModel | 每次都拉全量数据 |
onShown |
首次显示拉主数据、再次显示轻量刷新 | 不加判断地重复请求 |
onHidden |
暂停可见期任务、让旧请求失效 | 继续更新页面 UI |
onWillDisappear |
退出前收尾、保存必要状态 | 做耗时重任务 |
aboutToDisappear |
释放页面级资源 | 再触发新请求 |
这个表不是死规则,但能避免最常见的混乱:创建时做创建的事,可见时做可见的事,隐藏时停掉可见期的事。
可以封装一个页面可见期守卫
如果多个页面都有异步请求,可以抽一个轻量守卫:
class VisibleRequestGuard {
private visible: boolean = false
private token: number = 0
show(): number {
this.visible = true
return ++this.token
}
hide(): void {
this.visible = false
this.token += 1
}
canApply(token: number): boolean {
return this.visible && token === this.token
}
}
页面里使用:
private guard: VisibleRequestGuard = new VisibleRequestGuard()
private requestDetail() {
const token = this.guard.show()
this.repository.fetchDetail(this.detailId).then((result) => {
if (!this.guard.canApply(token)) {
return
}
this.detail = result
})
}
onHidden 里统一失效:
.onHidden(() => {
this.guard.hide()
})
这不是为了把生命周期包装得很复杂,而是让异步结果有一个统一入口。否则每个页面都手写 visible、requestId、loading,迟早有一个页面会漏判断。
返回刷新也不要一刀切
很多页面返回时确实要刷新,但不要默认全量刷新。
可以按修改范围拆:
| 返回来源 | 推荐动作 |
|---|---|
| 从只读子页面返回 | 不刷新或只刷新轻量状态 |
| 从编辑页返回且保存成功 | 刷新当前详情或局部字段 |
| 从评论页返回 | 只刷新评论数或最新一条 |
| 从设置页返回 | 更新全局展示偏好 |
| 从登录页返回 | 重新校验权限和用户态 |
如果每次 onShown 都全量拉主数据,读者看到的就是页面返回后闪烁、滚动位置丢失、接口重复打。真正要做的是让返回来源告诉页面“哪里变了”。
最后留一份检查清单
以后排 NavDestination 生命周期问题,我会按这个顺序看:
- 页面是不是在
aboutToAppear和onShown都请求了同一份数据。 - 首次显示和再次显示有没有分开。
onHidden有没有让旧请求结果失效。- 页面隐藏后还有没有继续改 UI 状态。
- 返回刷新是全量刷新,还是局部刷新。
- loading、曝光、动画、监听器有没有跟可见期绑定。
- 多次 push/pop 后请求数量是否符合预期。
NavDestination 生命周期不是为了让每个回调都写一段逻辑,而是让页面创建、显示、隐藏、销毁的职责分开。创建时初始化,可见时刷新,隐藏时停掉可见期任务,异步结果用 token 守住,这样页面返回和栈切换才不会越写越乱。
更多推荐
所有评论(0)