做鸿蒙应用开发,绕不开页面路由和组件跳转,最典型的场景就是:用户点了一个列表项,跳进详情页,改完数据再退回来,列表得跟着刷新。这个需求看起来平平无奇,但真从零开始搭过一次路由体系,你会发现里面藏着一整套状态管理和生命周期的问题。我见过不少项目,业务代码写得还行,一到页面跳转就乱了——要么返回的时候数据不刷新,要么页面栈越堆越深,要么跨模块跳转直接白屏。
这篇内容就把页面路由与组件跳转这条线完整捋一遍,从路由方案选型、Navigation落地、参数传递,到生命周期和页面栈管理,把那些官方文档里没细说、但实际开发一定会踩的坑也一并讲清楚。适合刚开始接触鸿蒙开发的朋友,也适合已经在写业务、但想系统梳理路由机制的开发者。看完你至少能明白:页面应该怎么组织、参数应该怎么传、返回刷新应该怎么做、页面栈为什么会越堆越深。
1. 路由方案选型:别急着写代码,先想清楚Router和Navigation
我先说结论:新项目请直接上Navigation,老项目如果还在用Router,可以逐步迁移,但不建议再往Router里加新页面了。原因不是Router不能用,而是它的设计思路和鸿蒙当前推荐的工程化方向已经不太匹配。
1.1 Router和Navigation的定位差异
早期鸿蒙提供的是router模块,风格很接近Web前端的路由:router.pushUrl({ url: 'pages/Detail', params }) 就能跳转。对应的页面是Page级别的,系统管跳转历史,页面之间天然隔离。这个设计轻量、直接,API也简单,适合Demo和单模块的小项目。
Navigation是后面逐渐成熟的路由方案,它把路由做成了ArkUI的容器组件:Navigation容器内部维护一个NavPathStack路径栈,页面本身是以NavDestination组件为单位存在的。也就是说,一个页面不再是一个独立的Page,而是挂载在Navigation容器里的一个NavDestination节点。
我把两者的核心差异整理成一张表,方便对照理解:
| 对比项 | router模块 | Navigation |
|---|---|---|
| API风格 | 命令式url跳转 | 容器 + 路径栈 |
| 页面载体 | Page | NavDestination组件 |
| 跨模块跳转 | 需要明确依赖和url配置 | 系统路由表 + 动态import,天然支持har/hsp |
| 转场动画 | 系统默认,定制能力有限 | 可精确控制转场,能力丰富 |
| 生命周期 | Page级事件 | NavDestination级事件,更贴近组件生命周期 |
| 参数传递 | 序列化传递,类型约束弱 | 参数对象直接传递,支持返回结果回调 |
| 官方定位 | 旧方案,维护保留 | 当前推荐方案,持续演进 |
为什么鸿蒙会有两个方案?说到底还是迭代节奏的问题。早期为了快速铺开生态,router这种简单的API方案能帮助开发者快速上手。但真正组件化、模块化的大型工程里,router的url字符串跳转有几个硬伤:第一,目标页面url写错是编译期发现不了的,运行时才会炸;第二,跨模块跳转要处理依赖,拆包很痛苦;第三,页面和容器之间的转场和动画定制能力弱。
1.2 为什么新项目我建议直接用Navigation
理由其实就三条,每一条都踩在大型工程的痛点上。
第一,组件化和动态加载。现代应用不可能全塞在一个模块里,拆成har、hsp是常态。Navigation配合系统路由表,可以做到主工程不直接依赖子模块的代码,通过路由名加动态import去加载目标页面。这种模式对团队并行开发极其友好,主工程只管壳和路由表,业务模块独立开发独立发版。这是Router做不到的,Router的url跳转在跨har时依赖关系太重,拆包拆到后面会很痛苦。
第二,类型安全和参数模型。Navigation的pushPath是对象化的,path name和param一起传,你可以在编译期约束参数类型。Router那套url加params本质上都是字符串或者JSON,传参出问题的几率高很多。举个实际例子:详情页要一个商品id和一个来源标记,用Navigation你可以定义一个 DetailParams interface,IDE会帮你校验字段;用Router你只能靠运行时判断,一不小心传错了字段名,目标页拿到的就是undefined。
第三,生命周期更完整、可控。NavDestination的生命周期和组件生命周期挂钩,你能在页面每次显示、隐藏的时机做精确处理。这个能力在做“返回刷新”“状态保持”这类需求时特别有用,后面第三章、第四章会详细展开。
如果项目里已经有大量Router代码怎么办?我的建议是不要一次性推倒重来,把新页面统一走Navigation,老页面逐步换。鸿蒙官方对Router是“保留但不再主推”的态度,短期内还能用,但新特性肯定都只给Navigation。
有人可能会说Navigation入门门槛比Router高,因为引入了容器、路径栈、NavDestination这些概念。我的看法是:你把Navigation容器理解成手机浏览器,NavPathStack就是里面一层层叠着的标签页,pushPath是开新页,pop是返回上一页,popToName是退回到某个指定的页。想清楚这个栈模型,再看代码就不懵了。我面试时爱问一个问题:连续跳了五个页面,怎么一次回到第一个?会栈操作的人秒答popToName,不理解栈的人会开始写循环。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Navigation路由实战:从一个能跑的跨页跳转开始
理论说太多没用,先搭一个最小可运行的项目。我这里用ArkTS写Demo,API版本按API 11以上来,你现在用DevEco Studio新版本建工程,默认就是这套。看代码之前你只需要记住一件事:Navigation的所有跳转,本质上都是对NavPathStack这个路径栈的操作。
2.1 最小可运行配置:容器、路径栈、目标页
第一步,在入口页里放Navigation组件,并创建NavPathStack。
typescript复制// HomePage.ets
@Entry
@Component
struct HomePage {
// 创建一个路径栈,所有路由操作都通过它来
private pathStack: NavPathStack = new NavPathStack()
build() {
Navigation(this.pathStack) {
Column({ space: 12 }) {
Button('跳转到详情页')
.onClick(() => {
this.pathStack.pushPathByName('DetailPage', { id: 1001, title: '测试商品' })
})
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack) // 单栈模式,不写默认也是Stack
}
}
第二步,写一个目标页。这里有个关键点:被Navigation路由的页面不是普通的@Entry组件,而是用NavDestination包起来的组件。页面路径名和NavDestination的name要对应上,否则跳转找不到页面。
typescript复制// DetailPage.ets
@Component
export struct DetailPage {
private pathStack: NavPathStack = new NavPathStack()
@Builder
PageMap(name: string) {
NavDestination() {
Text('这是详情页')
}
.title('详情')
.onReady((context: NavDestinationContext) => {
this.pathStack = context.pathStack
})
}
build() {
this.PageMap('DetailPage')
}
}
注意上面这个写法,我在@Builder里定义PageMap,是为了让组件和NavDestination建立关联,并让路径名在构建时确定。实际工程里更规整的做法是每个页面导出自己的@Builder构建函数,由路由表统一注册,这个我们在2.2节讲。
这里要特别提醒一个新手高发问题:很多人第一次写NavDestination会漏掉在onReady里拿pathStack这一步。如果没有在onReady里把context.pathStack存下来,页面内就无法继续做跳转。这个报错不会很显眼,往往是点击页面内的按钮没反应,不仔细看根本想不到是pathStack没拿到。
2.2 系统路由表与跨模块动态加载
Navigation既然是容器路由,那路由名对应哪个页面,由谁来管?答案有两种。一种是在页面入口集中写一个映射函数做注册,另一种是用系统路由表,在module.json5里配置。第二种是工程化推荐方案,因为只有用它才能发挥跨har动态加载的能力。
系统路由表的作用是:把“路由名 -> 页面组件”的关系交给配置文件,并且支持配置动态import。这样主工程不需要import子模块的组件,子模块甚至可以在独立har里维护,真正实现了跨模块解耦。
在模块的module.json5里配置routes:
json5复制{
"module": {
"name": "entry",
"routes": [
{
"name": "DetailPage",
"pageSourceFile": "src/main/ets/pages/DetailPage.ets",
"buildFunction": "DetailPageBuilder",
"data": {
"description": "详情页"
}
}
]
}
}
pageSourceFile指向页面文件,buildFunction指向这个文件里导出的@Builder函数。被路由的页面需要导出这个构建函数:
typescript复制// DetailPage.ets
@Component
export struct DetailPage {
build() {
NavDestination() {
Text('这是详情页')
}
.title('详情')
}
}
@Builder
export function DetailPageBuilder() {
DetailPage()
}
配置完成后,跳转代码和之前一样,基本没变化:
typescript复制this.pathStack.pushPathByName('DetailPage', { id: 1001 })
如果页面在另一个har里,主工程的路由表也能通过路由扩展机制引用,配置时写清楚har包名和页面文件路径即可。这个能力让模块之间彻底解耦,也是我推崇Navigation的核心原因之一。你想想,如果A团队负责商品模块,B团队负责订单模块,两边只要约定好路由名,各自发版互不干扰,这在Router时代几乎不敢想。
2.3 页面返回的姿势:pop、popToName与批量返回
跳过去容易,返回来才是真正考验对栈理解的地方。Navigation的返回API我整理一下:
pop():出栈,相当于返回上一页。pop(result: Object):返回上一页的同时回传一个result对象。popToName(name: string):回到指定名字的页面,中间所有页面出栈。popToIndex(index: number):回退到指定栈下标。moveToTop(name: string):把指定页面移到栈顶,但不清中间页面。
批量返回的经典场景:A页发起一个完整的表单填写流程,依次经过B、C、D三个步骤页,最后用户点“完成”,需要从D直接回到A。用 popToName('A') 一行搞定。如果没有这个API,用循环pop会非常考验对栈状态的控制,容易多弹一层或少弹一层。尤其是中间某个步骤页里又有分支跳转的情况下,循环pop的代码几乎没法维护。
pop的时候带返回值:详情页返回列表页,列表页要根据编辑结果刷新某一行,就可以把修改结果作为result传回来:
typescript复制// 详情页里返回,带上变更标记
this.pathStack.pop({ changed: true, id: 1001 })
接收返回结果的标准姿势是给目标页的NavDestination设置onPop回调:
typescript复制// 列表页的NavDestination
NavDestination() {
// 页面内容
}
.onPop((popInfo: PopInfo) => {
// popInfo.result 就是在目标页pop时带回来的对象
console.info('返回结果:', JSON.stringify(popInfo.result))
if (popInfo.result && (popInfo.result as Record<string, Object>)['changed']) {
// 执行刷新
}
})
这里要注意:onPop是加在发起跳转的那个NavDestination上的。原理是路由栈在pop时会把结果沿着路径栈回传给上一个页面的NavDestination。所以列表页要接收详情页的返回值,就要写在自己页面的NavDestination上,而不是写在详情页里。
提示:写路由相关代码时,我建议把push、pop封装成统一的导航工具类,页面里不直接new NavPathStack,而是统一从工具类获取。这样后面如果要加统计埋点、登录校验、全局跳转拦截,改动成本会小很多。特别是到了三五十个页面的规模,你会深刻体会到这层封装的意义。
3. 参数传递与组件间通信:把数据安全地送到目的地
路由只是把页面“串”起来,真正让页面之间有内容的是参数传递和组件间通信。鸿蒙在状态管理上有不少机制,新手很容易被 @Prop、@Link、@ObjectLink、@Provide、@Consume、AppStorage、EventHub 这些概念绕晕。我按使用场景分三层来讲,每层讲清楚“什么时候用、怎么用、为什么”。
3.1 页面间传参与回传结果
页面间参数传递主要有两种:跳转时带过去的param,和返回时带回来的result。
pushPathByName('DetailPage', { id: 1001, title: '测试商品' }) 的第二个参数就是一个对象。目标页怎么拿?我的做法是在NavDestination的onReady里通过context拿到pathStack,然后用getState获取当前路径信息:
typescript复制@Component
export struct DetailPage {
@State id: number = 0
@State title: string = ''
build() {
NavDestination() {
Text(`id: ${this.id}, title: ${this.title}`)
}
.title('详情')
.onReady((context: NavDestinationContext) => {
const state = context.pathStack.getState() as NavPathState
const param = state.param as Record<string, Object>
this.id = param['id'] as number
this.title = param['title'] as string
this.pathStack = context.pathStack
})
}
}
我个人习惯给每个页面的参数定义一个interface,比如 DetailParams。这样在pushPathByName那边,IDE能给你做属性提示,写错字段名编译期就能发现。我强烈建议给参数建模,别图省事用 any。路由参数是页面之间的接口约定,接口不清晰,后面的问题会像滚雪球一样越滚越大。
关于参数序列化,这里要提醒一句:虽然API叫param,类型上写的是Object,底层在不同场景下可能会做序列化处理。所以参数里尽量放JSON可序列化的数据,不要放class实例、方法引用这些。如果是比较大的对象,建议换思路:先存AppStorage、数据库或者临时文件,把key传过去,目标页再取。我见过有人试图把整个图片对象塞进param,结果目标页拿到的是一串奇怪的序列化错误,这是典型的参数设计不合理。
返回结果result也一样,pop({ changed: true }) 传的是标准对象。接收方通过onPop回调的popInfo.result拿到。这块前面2.3已经给了示例,不再重复。
3.2 父子组件通信:@Prop、@Link和@ObjectLink怎么选
页面跳转解决了,页面内部的组件通信是另一座山。很多开发者在列表页里拆了一堆子组件,结果发现子组件改数据,父组件不刷新,或者反过来。
先建立三个概念:
@Prop:单向同步。父组件把值传给子组件,子组件内部能改自己的副本,但不影响父组件。@Link:双向同步。子组件改了,父组件同步改;父组件改了,子组件也同步改。本质上是引用同一个数据源。@ObjectLink:用于class对象,配对@Observed使用,负责对象属性级别的观察。它和@Link的区别是:@Link把整个数据源同步过来,@ObjectLink观察的是对象内部属性的变化,适合数据结构比较复杂的场景。
给个简单的购物车例子:商品卡片是子组件,它需要修改商品的购买数量,同时父组件要感知总数量变化。那么商品卡片里的数量字段就应该用 @Link 或者 @ObjectLink 管理。
typescript复制@Observed
class CartItem {
id: number = 0
count: number = 1
}
@Component
export struct CartCard {
@ObjectLink item: CartItem
@Link totalCount: number
build() {
Row() {
Text(`商品${this.item.id} x ${this.item.count}`)
Button('-').onClick(() => {
if (this.item.count > 1) {
this.item.count--
this.totalCount--
}
})
Button('+').onClick(() => {
this.item.count++
this.totalCount++
})
}
}
}
父组件里创建CartItem数组,把总数量用 @Link 传下去:
typescript复制@State items: CartItem[] = []
@State totalCount: number = 0
build() {
List() {
ForEach(this.items, (item: CartItem) => {
ListItem() {
CartCard({ item: item, totalCount: $totalCount })
}
}, (item: CartItem) => item.id.toString())
}
}
这里有个细节要留意:@State数组里放class对象,要给class加@Observed,然后在子组件里用@ObjectLink,才能监听到对象内部属性变化。如果子组件用@Prop收这个对象,改属性不会通知父组件,这在很多新手项目里是“改了不刷新”的头号原因。
3.3 跨层级通信:@Provide/@Consume、AppStorage与EventHub的使用边界
页面内部还有第三种场景:一个深层子组件要修改顶层组件的数据,又不方便一层层用@Link传。这时候鸿蒙提供了@Provide/@Consume,作用类似“依赖注入”:父组件@Provide一个变量,任意层级子组件@Consume同一个变量,改任意一方,双方同步。
typescript复制// 顶层组件
@Provide('userName') userName: string = 'default'
// 深层子组件
@Consume('userName') userName: string
页面之间跨路由的共享状态,可以使用AppStorage。AppStorage是全局唯一的键值存储,页面A存一个数据,页面B能直接读到,而且支持 @StorageLink、@StorageProp 做双向或单向绑定。持续性数据再用 PersistentStorage 持久化到本地。
EventHub是更轻量的事件总线,适合“发送一个通知、不关心谁接收”的场景,比如登录状态变化后通知多个页面刷新。
但我必须强调使用边界:能通过路由参数和返回值解决的,就不要引入全局状态;能用@Link和@Provide解决的,就不要引入AppStorage。 全局状态用多了,调试成本呈指数上升:你不知道这个变量到底被谁改了,也不知道什么时候改的。我见过太多“某变量突然被改,全应用状态崩了”的案例,排查到最后都是全局状态滥用。
一个我常用的折中方案:页面之间如果需要共享一个“会话级”数据,我在入口组件里把它作为@State,然后用@Provide往下传;只有真正跨页面、跨模块的全局会话信息,比如登录态、用户信息,才放到AppStorage。
4. 生命周期与页面栈管理:很多怪问题的根源在这
页面跳转涉及一个绕不开的话题:生命周期。为什么返回后列表不刷新?为什么跳转后旧页面还在运行?为什么页面关闭后还收到回调?这些问题全都要回到生命周期和页面栈管理上找答案。
4.1 NavDestination的生命周期和执行顺序
NavDestination的生命周期和普通组件生命周期是重叠的。常见阶段有:
onWillAppear:页面即将显示。onAppear:页面显示完成。onWillShow/onShow:页面可取焦、可交互。onWillHide/onHide:页面被遮挡、不可交互。onWillDisappear/onDisappear:页面即将销毁、已销毁。
与之并行的是ArkUI组件本身的 aboutToAppear、aboutToDisappear。NavDestination每次显示和隐藏,组件并不一定重建,但页面级的onWillAppear和onWillShow每次都会触发。
举个例子说明执行顺序。从页面A push到页面B,A的生命周期大致是:onWillHide -> onHide,B是:onWillAppear -> onAppear -> onWillShow -> onShow。从B返回A,B走onWillDisappear -> onDisappear -> aboutToDisappear,A走onWillShow -> onShow。如果你要在A回到前台时刷新数据,挂A的onWillShow或onShow即可。
这点非常重要,我见过很多人习惯把刷新操作写在 aboutToAppear 里,但aboutToAppear只在组件创建时执行一次。页面被压栈、再弹回来,组件并没有重建,所以你写的刷新逻辑永远不会触发。正确的做法是把“页面可见时刷新”的逻辑放在 onWillShow 或 onShow 里。这个问题其实和前端圈常说的“vue3路由跳转不刷新页面”是同一类困惑,本质都是页面组件实例复用和生命周期时机的问题,理解一次就通了。
4.2 页面栈管理:防止堆叠、泄漏和状态不刷新
页面栈管理最日常的坑有三个。
第一个是连续快速点击导致的重复压栈。用户手速快,连点两下按钮,pushPathByName 被调用两次,栈里就有了两个一模一样的DetailPage,返回的时候返回两次。解决办法是自己维护一个跳转锁:在push之前判断当前栈顶是否已经是目标页面,或者用一个全局的isNavigating标志位,跳转过程中置为true,路由结束后再置为false。我项目里就是这么干的,实测效果很好,这个锁放在导航工具类里,所有页面统一生效。
第二个是全局监听器的泄漏。在页面的 aboutToAppear 里注册了AppStorage或EventHub的监听,如果不记得在 aboutToDisappear 里注销,页面销毁后监听器还活着,回调里又在操作一个已经销毁的页面组件,轻则报警告,重则内存泄漏。
typescript复制aboutToAppear(): void {
this.onUserChange = (user: UserInfo) => {
// 更新页面
}
AppStorage.setOrCreate('userInfo', this.userInfo)
AppStorage.on('userInfo', this.onUserChange)
}
aboutToDisappear(): void {
AppStorage.off('userInfo', this.onUserChange)
}
这里要留意API版本差异,新版本里AppStorage相关API可能被新的状态管理方案覆盖,但旧API在大版本内仍然可用。关键是养成成对注册注销的习惯,我见过太多只注册不注销的代码。
第三个是页面返回后不刷新。页面A通过onWillShow刷新,但onWillShow每次切后台再切回来也会触发,如果你在里面做了网络请求,就会造成无谓的刷新。更精准的方案是结合2.3节的onPop回调:详情页返回时把变更结果带回来,列表页只有收到变更才刷新局部。这也是前面介绍参数回传时要落到实处的场景。
我在这块的经验是:掌握“何时刷新”的判断标准——数据变化点在哪里,就在哪里通知;页面不可见时不要做无用功。能用局部刷新解决的不要整页刷新,能靠onPop精准触发的不要用onShow无脑轮询。
5. 常见问题与排查技巧实录
这一节收集我在实际开发和社区答疑里经常遇到的典型问题,做成速查表,后面再展开几个有代表性的排查过程,希望能帮你少走弯路。
5.1 高频问题速查表
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 跳转后目标页白屏 | NavDestination没有正确包裹,或路由表buildFunction名称与导出不一致 | 检查目标页是否用NavDestination作为根组件,确认buildFunction名称完全匹配 |
| 跳转报“页面未找到” | 路由名拼写错误,或系统路由表未配置该页面 | 核对pushPathByName的name与路由表name一致,检查module.json5是否重新编译 |
| 页面返回后旧状态丢失 | 在aboutToAppear里做初始化,而页面只是从后台恢复 | 把初始化拆成“创建时一次性初始化”和“可见时刷新”两段,分别在aboutToAppear和onWillShow里处理 |
| 列表页从详情页返回不刷新 | 刷新逻辑写在aboutToAppear里,组件没有重建 | 改用onPop回调接收返回值并精准刷新,或在onWillShow中刷新 |
| 连续点击跳了两个页面 | 缺少跳转锁或路由去重 | 在导航工具类里加isNavigating跳转锁,或判断栈顶页面 |
| 页面销毁后还收到事件回调 | 注册了AppStorage/EventHub监听未注销 | 在aboutToDisappear里注销监听 |
| 组件改了数据页面不刷新 | 对象未加@Observed或子组件用错了装饰器 | 检查class是否加了@Observed,子组件是否用@ObjectLink或@Link |
| 参数里带了undefined或函数 | 序列化失败导致目标页参数异常 | 参数只传可序列化数据,复杂对象先转JSON或存全局存储 |
5.2 三个印象深刻的排查案例
案例一:跳转后返回,列表页白屏,但Log里没报错。排查半天发现,列表页的Navigation容器设置了 mode 为 NavigationMode.Split,在返回时窗口宽度判定变了,导致页面重新布局,而列表的懒加载 LazyForEach 在数据源重新绑定后没有触发。最后把List的数据源改成@State修饰并加key生成函数,才稳定。这个案例给我们的教训是:Navigation的窗口模式会影响页面布局,遇到白屏先检查mode和布局适配,别一上来就怀疑代码逻辑。
案例二:跨har跳转在真机上报“页面未找到”,DevEco预览器却正常。排查到最后发现原因很简单:har模块改动了route配置,但主工程没有重新编译har产物,真机装的是旧版har。解决方案是clean项目后重新构建,或者把har作为hsp本地工程依赖,避免产物不一致。这个案例排查了一下午,很折磨人,最后是同事无意中说了句“我这边clean过就好了”,问题瞬间定位。如果你也遇到类似问题,建议先确认har产物更新了没有。
案例三:详情页返回后,列表页刷新了,但刷新期间用户又点了一次列表项,导致参数错乱。原因是刷新是异步的,用户操作了旧数据。解决办法是:列表页从onPop拿到结果后,先把列表变成loading态,数据更新完成后再恢复可点击。别小看这个交互细节,电商类应用这个坑非常常见,用户双击跳转然后又手滑点其他商品,索引错位、数据错乱都是这么来的。
这几个案例背后其实都是一个逻辑:路由不只是“跳过去弹回来”,它还涉及数据同步、异步时序、用户交互的并发。写路由代码时,要把自己放在真实用户场景里考虑,别只盯着“能跳”这个最低标准。
最后分享一个我自己的习惯:在项目里我会把导航动作统一封装,禁止业务代码里直接new NavPathStack,而是通过NavService类来做push、pop、popToName,统一处理跳转锁、埋点、登录态校验。这样做的第一个项目可能觉得多余,但当页面数量超过三五十个、模块拆到三四个har之后,你会庆幸当初做了这层封装。路由是应用的骨骼,骨骼稳了,业务代码才敢放手去写。如果你的项目刚好在选型阶段,不妨按这篇文章的思路先搭一套干净的Navigation骨架,后续会省很多事。
