Navigation页面跳转教学博客

写给刚从哔站教程毕业、发现 router.pushUrl 被划删除线的新人。本文基于 HarmonyOS 官方文档整理,所有写法均适用于 API 12+,不会教已经弃用的东西。


一、先回答你最大的疑问:为什么要换?

你在哔站学的 router.pushUrl,DevEco 里已经标了删除线。这不是 DevEco 抽风,是官方真的不推荐了。

旧版 router 的问题:

  • 每个页面都是独立 @Entry,互相之间没有父子关系,像一栋楼里每间房都锁着门
  • 传参只能塞 JSON 字符串,类型安全全靠自觉
  • 页面栈管理不透明,跨模块跳转得硬编码路径
  • 没有拦截机制,做不了"未登录就跳到登录页"这种事

新版 Navigation 的优势:

  • 所有子页面都在一个 Navigation 容器里,共享同一个路由栈,页面间通信像在同一间办公室传纸条
  • 参数可以传强类型对象
  • 支持路由拦截、返回回调、精准跳转等高级操作
  • 官方推荐,长期维护,新项目请直接用

一句话总结:router 是翻盖手机,Navigation 是智能手机。翻盖能打电话,但你不会想用它刷短视频。


二、核心概念先搞清,后面代码一看就懂

在写代码之前,你需要认识四个"角色"。我把它们拟人化,方便记忆。

NavPathStack 是一个路由栈,你可以把它想象成一个数组,记录了页面跳转的历史。

复制代码
初始状态:[](空的,显示首页)
跳到登录页:[LoginPage]
再跳到详情页:[LoginPage, DetailPage]
按返回:[LoginPage](DetailPage 出栈了)
再按返回:[](回到首页)

所有跳转、返回、替换操作,都是对这个栈的增删改。它是整个路由体系的核心,所有页面必须操作同一个实例。

Navigation 是一个容器组件,相当于整栋大楼。它把 NavPathStack 绑进来,然后根据栈的变化自动切换显示哪个"房间"(子页面)。

typescript 复制代码
Navigation(this.pageStack) {
  // 这里放首页内容(大楼的一楼大厅)
}

子页面的根组件必须是 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(),那就是新雇了一个大堂经理,他手里没有之前的登记记录,什么都干不了------这是白屏的第二大原因。


假设你有一个空项目,需要配置三个地方 + 写四种文件。

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 发送参数

pushPathparam 字段就是用来传参的:

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 的执行顺序是先建后拆

  1. 新页面 aboutToAppear(先建好新房)
  2. 旧页面 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 一行代码搞定。

记住三句话就够了:

  1. 传参靠 param,收参靠 onReady
  2. 返回带数据靠 pop(result) + onPop 回调
  3. 不想让用户返回就用 replacePath

祝你早日告别 router,拥抱 Navigation。

相关推荐
ZZZMMM.zip5 小时前
竞品分析框架-基于鸿蒙的AI竞品分析应用开发实践
人工智能·华为·harmonyos·鸿蒙系统
ldsweet6 小时前
《HarmonyOS技术精讲-Basic Services Kit》账号管理:应用内账号创建与管理
华为·harmonyos
shaodong11238 小时前
HarmonyOS新特性-沉浸光感在叠叠消小游戏中的落地实践
harmonyos·鸿蒙
xd1855785558 小时前
歌词创作工坊-基于鸿蒙的AI歌词创作应用开发实践
人工智能·华为·harmonyos·鸿蒙
এ慕ོ冬℘゜8 小时前
前端实战:Textarea 实时字数计数输入器(带字数限制+UI优化完整源码)
前端·ui
子非鱼94279 小时前
07-Flutter 鸿蒙实战 07:故事集列表与缓存设计
学习·flutter·缓存·华为·harmonyos
程序员黑豆9 小时前
从零开始:创建第一个鸿蒙应用程序
前端·harmonyos
千逐6811 小时前
HarmonyOS 实战 | 弹窗/提示框怎么做——Alert、Dialog、Toast 详解
华为·harmonyos·鸿蒙
子非鱼942711 小时前
08-Flutter 鸿蒙实战 08:故事详情页,编辑、删除、重新生成与底部弹层
flutter·华为·harmonyos