告别 router.pushUrl:鸿蒙 Navigation 页面跳转,从入门到传参与替换,一篇讲透
写给刚从哔站教程毕业、发现
router.pushUrl被划删除线的新人。本文基于 HarmonyOS 官方文档整理,所有写法均适用于 API 12+,不会教已经弃用的东西。
一、先回答你最大的疑问:为什么要换?
你在哔站学的 router.pushUrl,DevEco 里已经标了删除线。这不是 DevEco 抽风,是官方真的不推荐了。
旧版 router 的问题:
- 每个页面都是独立
@Entry,互相之间没有父子关系,像一栋楼里每间房都锁着门 - 传参只能塞 JSON 字符串,类型安全全靠自觉
- 页面栈管理不透明,跨模块跳转得硬编码路径
- 没有拦截机制,做不了"未登录就跳到登录页"这种事
新版 Navigation 的优势:
- 所有子页面都在一个 Navigation 容器里,共享同一个路由栈,页面间通信像在同一间办公室传纸条
- 参数可以传强类型对象
- 支持路由拦截、返回回调、精准跳转等高级操作
- 官方推荐,长期维护,新项目请直接用
一句话总结:router 是翻盖手机,Navigation 是智能手机。翻盖能打电话,但你不会想用它刷短视频。
二、核心概念先搞清,后面代码一看就懂
在写代码之前,你需要认识四个"角色"。我把它们拟人化,方便记忆。
2.1 NavPathStack ------ 大堂经理
NavPathStack 是一个路由栈,你可以把它想象成一个数组,记录了页面跳转的历史。
初始状态:[](空的,显示首页)
跳到登录页:[LoginPage]
再跳到详情页:[LoginPage, DetailPage]
按返回:[LoginPage](DetailPage 出栈了)
再按返回:[](回到首页)
所有跳转、返回、替换操作,都是对这个栈的增删改。它是整个路由体系的核心,所有页面必须操作同一个实例。
2.2 Navigation ------ 大楼本身
Navigation 是一个容器组件,相当于整栋大楼。它把 NavPathStack 绑进来,然后根据栈的变化自动切换显示哪个"房间"(子页面)。
typescript
Navigation(this.pageStack) {
// 这里放首页内容(大楼的一楼大厅)
}
2.3 NavDestination ------ 每个房间
子页面的根组件必须是 NavDestination,不是 Column、不是 Row、不是 RelativeContainer。它是房间的"门牌号",Navigation 框架靠它来识别和管理子页面。
typescript
NavDestination() {
Column() {
Text('我是房间里的内容')
}
}
.title('登录页') // 门牌上写的名字
如果不用 NavDestination,页面内容就不会被渲染------这就是白屏的第一大原因。
2.4 @Provide / @Consume ------ 大堂经理的对讲机
前面说了,所有页面必须操作同一个 NavPathStack 实例。那子页面怎么拿到它?
答案是:入口页用 @Provide 提供,子页面用 @Consume 消费。
Index(入口页)
└── @Provide('pageStack') pageStack: NavPathStack = new NavPathStack()
│ "我有一个叫 pageStack 的大堂经理,谁需要自己来领"
│
└── Navigation(this.pageStack) { ... }
│
└── LoginPage(子页面)
└── @Consume('pageStack') pageStack: NavPathStack
"我要用名叫 pageStack 的那个大堂经理"
↑ 自动匹配到 Index 提供的那个,是同一个实例
如果子页面自己 new NavPathStack(),那就是新雇了一个大堂经理,他手里没有之前的登记记录,什么都干不了------这是白屏的第二大原因。
三、搭建一个完整的 Navigation 项目(从零开始)
假设你有一个空项目,需要配置三个地方 + 写四种文件。
3.1 配置文件一:main_pages.json ------ 入口页面清单
位置:resources/base/profile/main_pages.json
json
{
"src": [
"pages/Index"
]
}
只放 @Entry 标记的入口页面。 用 Navigation 时,这里只有一个 Index,其他子页面不用写进来。
新人常犯的错:把所有页面都塞进 src。那是 router 的做法。Navigation 的子页面由路由表管理,不归 main_pages.json 管。
3.2 配置文件二:router_map.json ------ 子页面路由表
位置:resources/base/profile/router_map.json
json
{
"routerMap": [
{
"name": "LoginPage",
"pageSourceFile": "src/main/ets/pages/LoginPage.ets",
"buildFunction": "LoginPageBuilder"
},
{
"name": "DetailPage",
"pageSourceFile": "src/main/ets/pages/DetailPage.ets",
"buildFunction": "DetailPageBuilder"
}
]
}
三个字段:
| 字段 | 含义 | 要点 |
|---|---|---|
name |
跳转时用的名字 | 代码里 pushPath({ name: 'xxx' }) 必须和这个一致 |
pageSourceFile |
页面文件路径 | 让系统能找到这个文件 |
buildFunction |
构建函数名 | 必须和代码里 @Builder export function xxxBuilder() 的函数名一致 |
3.3 配置文件三:module.json5 ------ 桥接两个配置
位置:entry/src/main/module.json5
json5
{
"module": {
"pages": "$profile:main_pages", // → 找 main_pages.json
"routerMap": "$profile:router_map" // → 找 router_map.json
}
}
$profile:xxx 的意思就是"去 resources/base/profile/ 下找 xxx.json"。
如果你忘了加 routerMap 这一行,跳转就是白屏。
3.4 三个配置文件的关系
应用启动
│
├─ 系统读 module.json5
│ ├─ pages → main_pages.json → 入口页面 "pages/Index"
│ └─ routerMap → router_map.json → 注册了 LoginPage、DetailPage
│
├─ 加载 Index 页面(唯一 @Entry)
│ └─ Index 的 build() 创建 Navigation + NavPathStack
│
└─ 用户点击跳转
└─ pushPath({ name: 'LoginPage' })
└─ 去 router_map.json 查 name='LoginPage'
└─ 找到 → 调用 LoginPageBuilder() → 显示 LoginPage
四、写代码:入口页 + 子页面
4.1 入口页 Index.ets
typescript
@Entry
@Component
struct Index {
// 用 @Provide 提供 pageStack,子页面通过 @Consume 获取
@Provide('pageStack') pageStack: NavPathStack = new NavPathStack()
build() {
Navigation(this.pageStack) {
Column() {
Text('首页')
.fontSize(60)
Button('跳转到登录页')
.onClick(() => {
this.pageStack.pushPath({ name: 'LoginPage' })
})
Button('跳转到详情页')
.onClick(() => {
this.pageStack.pushPath({ name: 'DetailPage' })
})
}
}
.hideTitleBar(true) // 隐藏自带的标题栏,否则顶部有空白
}
}
4.2 子页面 LoginPage.ets
typescript
@Component
struct LoginPage {
// 用 @Consume 获取入口页提供的 pageStack,不要自己 new!
@Consume('pageStack') pageStack: NavPathStack
build() {
// 根组件必须是 NavDestination
NavDestination() {
Column() {
Text('登录页')
.fontSize(80)
Button('返回')
.onClick(() => {
this.pageStack.pop()
})
}
}
.title('登录页')
}
}
// 导出 @Builder 函数,函数名和 router_map.json 的 buildFunction 一致
@Builder
export function LoginPageBuilder() {
LoginPage()
}
子页面和入口页的区别:
| 入口页 | 子页面 | |
|---|---|---|
| 装饰器 | @Entry + @Component |
@Component(没有 @Entry) |
| 根组件 | Navigation |
NavDestination |
| NavPathStack | @Provide 提供 |
@Consume 消费 |
| 导出函数 | 不需要 | 必须 @Builder export |
五、传参:父页面 → 子页面
5.1 发送参数
pushPath 的 param 字段就是用来传参的:
typescript
// Index.ets
Button('查看商品详情')
.onClick(() => {
this.pageStack.pushPath({
name: 'DetailPage',
param: { id: 42, title: '鸿蒙手机' } // 传一个对象过去
})
})
也可以用 pushPathByName,更简洁:
typescript
this.pageStack.pushPathByName('DetailPage', { id: 42, title: '鸿蒙手机' })
两种写法效果一样,区别只是参数格式不同:
| 方法 | 写法 | 适合场景 |
|---|---|---|
pushPath |
pushPath({ name, param, onPop }) |
需要传参 + 需要返回回调 |
pushPathByName |
pushPathByName(name, param) |
只需传参,代码更短 |
5.2 接收参数(推荐用 onReady)
typescript
// DetailPage.ets
@Component
struct DetailPage {
@Consume('pageStack') pageStack: NavPathStack
@State productId: number = 0
@State productTitle: string = ''
build() {
NavDestination() {
Column() {
Text(`商品ID: ${this.productId}`)
Text(`商品名: ${this.productTitle}`)
Button('返回').onClick(() => { this.pageStack.pop() })
}
}
.title('详情页')
.onReady((context: NavDestinationContext) => {
// context.pathInfo.param 就是发送方传过来的参数
let param = context.pathInfo.param as Record<string, Object>
this.productId = param['id'] as number
this.productTitle = param['title'] as string
})
}
}
onReady 是 NavDestination 专属的回调,在页面即将构建时触发,参数此时一定已经准备好了。官方推荐用这种方式收参。
5.3 完整传参流程
Index(发送方) DetailPage(接收方)
│ │
│ pushPath({ │
│ name: 'DetailPage', │
│ param: { id: 42, title: '鸿蒙手机' }
│ }) ─────────────────────────► │
│ │ onReady 触发
│ │ context.pathInfo.param = { id: 42, ... }
│ │ this.productId = 42
│ │ 页面渲染: "商品ID: 42"
六、返回时带数据:子页面 → 父页面
6.1 场景
你在详情页选了一个商品规格,返回首页时想把选择结果带回去。
6.2 实现方式:onPop 回调 + pop(result)
父页面 ------ 注册 onPop 回调:
typescript
// Index.ets
@State selectedSpec: string = '未选择'
Button('选择规格')
.onClick(() => {
this.pageStack.pushPath({
name: 'DetailPage',
param: { id: 42 },
// onPop:子页面返回时触发,popInfo.result 就是子页面带回的数据
onPop: (popInfo: PopInfo) => {
this.selectedSpec = popInfo.result as string
}
})
})
Text(`当前选择: ${this.selectedSpec}`)
子页面 ------ pop 时传 result:
typescript
// DetailPage.ets
Button('确认选择,返回首页')
.onClick(() => {
// pop 的参数就是返回给父页面的数据
// 这个值会触发父页面注册的 onPop 回调
this.pageStack.pop('红色 256GB')
})
6.3 完整返回数据流程
Index(父页面) DetailPage(子页面)
│ │
│ pushPath({ │
│ name: 'DetailPage', │
│ onPop: (popInfo) => { │
│ this.selectedSpec = │
│ popInfo.result │
│ } │
│ }) │
│ │
│ 用户点击"确认选择"
│ │
│ ◄──────────── │ pop('红色 256GB')
│ │
│ onPop 被触发 │ DetailPage 出栈
│ selectedSpec = '红色 256GB' │
│ Text 更新显示 │
踩坑提醒 :子页面 pop() 如果不传参数,onPop 回调不会触发 。必须写 pop(返回值) 才行。
七、替换:replacePath
7.1 pushPath vs replacePath
这是很多人搞混的地方,看图就明白了:
pushPath(入栈):
操作前: [Index, LoginPage]
操作后: [Index, LoginPage, DetailPage] ← 新页面入栈,旧页面还在
用户按返回 → 回到 LoginPage ✅
replacePath(替换栈顶):
操作前: [Index, LoginPage]
操作后: [Index, HomePage] ← HomePage 替换了 LoginPage
用户按返回 → 回到 Index,LoginPage 已经销毁了 ❌ 回不去
7.2 典型场景:登录页 → 首页
用户登录成功后,不应该再按返回键回到登录页。这时用 replacePath:
typescript
// LoginPage.ets
Button('登录')
.onClick(() => {
// 替换当前页面(登录页被销毁,用户无法返回)
this.pageStack.replacePath({
name: 'HomePage',
param: { username: '张三' }
})
})
简写版:
typescript
this.pageStack.replacePathByName('HomePage', { username: '张三' })
7.3 对比总结
| pushPath | replacePath | |
|---|---|---|
| 栈操作 | 入栈,深度 +1 | 替换栈顶,深度不变 |
| 原页面 | 保留,可返回 | 销毁,不可返回 |
| 典型场景 | 查看详情、打开设置 | 登录→首页、引导页→主页 |
7.4 生命周期顺序
replacePath 的执行顺序是先建后拆:
- 新页面 aboutToAppear(先建好新房)
- 旧页面 aboutToDisappear(再拆旧房)
所以不要在旧页面的 aboutToDisappear 里初始化新页面需要的数据------那时新页面已经构建完了,来不及了。应该在调用 replacePath 之前就准备好数据。
八、白屏排查清单
这是每个鸿蒙新人都会遇到的坑,我按频率排序:
| 排名 | 原因 | 解决 |
|---|---|---|
| 1 | module.json5 忘了加 routerMap |
加上 "routerMap": "$profile:router_map" |
| 2 | 子页面根组件不是 NavDestination | 把 Column/Row 换成 NavDestination |
| 3 | 子页面自己 new NavPathStack() |
改用 @Consume 获取父页面的实例 |
| 4 | router_map.json 里 name/路径/函数名写错 | 逐个核对拼写 |
| 5 | 用预览器调试(预览器不支持 Navigation 跳转) | 换模拟器或真机 |
第 1 条是最常见的。 很多人代码哪哪都没问题,找半天最后发现就是 module.json5 里少了那一行 routerMap。请把它刻在脑子里。
九、方法速查表
跳转(入栈)
| 方法 | 说明 | 示例 |
|---|---|---|
pushPath({ name, param, onPop }) |
跳转 + 传参 + 返回回调 | this.pageStack.pushPath({ name: 'DetailPage', param: { id: 1 } }) |
pushPathByName(name, param) |
简写跳转 | this.pageStack.pushPathByName('DetailPage', { id: 1 }) |
返回(出栈)
| 方法 | 说明 | 示例 |
|---|---|---|
pop(result?) |
返回上一页,可带结果 | this.pageStack.pop('选中红色') |
popToName(name) |
返回到指定名称的页面(中间的都出栈) | this.pageStack.popToName('Index') |
popToIndex(index) |
返回到指定索引的页面 | this.pageStack.popToIndex(0) |
clear() |
清空所有子页面,回到首页 | this.pageStack.clear() |
替换
| 方法 | 说明 | 示例 |
|---|---|---|
replacePath({ name, param }) |
替换当前页 + 传参 | this.pageStack.replacePath({ name: 'HomePage' }) |
replacePathByName(name, param) |
简写替换 | this.pageStack.replacePathByName('HomePage') |
十、新增页面的标准流程
以后你要加一个新页面,只需三步,不需要改 main_pages.json:
第一步:创建页面文件 (如 AboutPage.ets)
typescript
@Component
struct AboutPage {
@Consume('pageStack') pageStack: NavPathStack
build() {
NavDestination() {
Column() {
Text('关于页面')
Button('返回').onClick(() => { this.pageStack.pop() })
}
}
.title('关于')
}
}
@Builder
export function AboutPageBuilder() {
AboutPage()
}
第二步:在 router_map.json 注册
json
{
"name": "AboutPage",
"pageSourceFile": "src/main/ets/pages/AboutPage.ets",
"buildFunction": "AboutPageBuilder"
}
第三步:在任意页面跳转
typescript
this.pageStack.pushPath({ name: 'AboutPage' })
完事。不需要加 @Entry,不需要改 main_pages.json,不需要向系统注册任何东西。
结语
Navigation 体系初学时配置确实比 router 麻烦------要建路由表、要写 @Builder 导出函数、要 @Provide/@Consume 传栈。但这些都是"一次性成本",项目一旦跑起来,后续加页面只需要三步,而且传参、拦截、回调这些 router 做不到的事,Navigation 一行代码搞定。
记住三句话就够了:
- 传参靠
param,收参靠onReady - 返回带数据靠
pop(result)+onPop回调 - 不想让用户返回就用
replacePath
祝你早日告别 router,拥抱 Navigation。