鸿蒙 ArkUI 深水区:Navigation 多级路由,从「拼页面」到「搭应用」的分水岭
写在前面
如果你写 ArkUI 写过五个页面以上,大概率遇到过这个场景:
首页一个商品列表,点商品跳详情页,详情页里又有「查看评价」「同类推荐」两个跳转,评价页里还能点头像跳用户主页......
你一开始的处理方式通常是:onClick 里 router.pushUrl,把 url 拼起来跳。写五个页面还能撑,写到第十个你发现:
- 跳转动画生硬,系统默认那一下硬切毫无设计感
- 路由栈乱了,用户点返回回到的不是上一页而是不知道哪一页
- 传参只能拼 query string,对象传不了,复杂参数要 JSON 序列化
- Tab 切换时,每个 Tab 的路由栈独立不了,用户在 TabA 跳三层,切 TabB 再回来,栈没了
这是「拼页面」和「搭应用」的分水岭。鸿蒙 6.1 给的答案是 Navigation 容器 + NavPathStack 路由栈------声明式路由、可编程栈、原生动画、对象传参,一个不落。
本文就用一个真机可跑 的 demo,把 Navigation 从「听名字一脸懵」讲到「下个项目直接抄」。代码托管在 AtomGit,文末有链接,真机实拍截图作证。
适合人群:写过 ArkUI、被
router.pushUrl折磨过的同学。 不适合人群:还在学@Builder的同学------出门左转看我的上一篇。
一、先讲清楚:Navigation 到底在管什么
一句话:Navigation 是一个路由容器,管着「当前该显示哪个页面」和「页面跳转的栈」。
你之前写 router.pushUrl 是命令式路由------你告诉系统「跳到 url X」。Navigation 是声明式路由------你告诉容器「路由栈顶现在是 name X」,容器自己根据路由表找对应 builder 构造页面显示。
结构长这样:
scss
Navigation(路由栈)
├─ 首页内容(栈底默认页,直接写在 Navigation 里)
└─ 路由表 builder(根据 name 构造目标页)
└─ NavDestination 容器(目标页外壳)
└─ 真正的页面内容
四个核心概念:
| 概念 | 作用 | 一句话理解 | |---|---|---|| | NavPathStack | �路由栈 | 「所有跳转记录的栈,push/pop 都通过它」 | | Navigation | 路由容器 | 「当前显示栈顶页面,首页写在它里」 | | NavDestination | 目标页外壳 | 「每个目标页必须套这个壳,自带标题栏 + 返回」 | | 路由表 builder | name → 页面 | 「根据 name 找对应 builder 构造页面」 |
记住这四个,往下看。
二、动手:一个「首页 → 详情页」的最小路由 demo
2.1 数据模型
typescript
class RouteItem {
name: string = ''
desc: string = ''
color: string = '#007DFF'
RouteItem() {}
set(name: string, desc: string, color: string): RouteItem {
this.name = name
this.desc = desc
this.color = color
return this
}
}
// 显式声明的路由参数对象(ArkTS 不允许裸对象字面量)
class RouteParam {
id: string = ''
title: string = ''
color: string = '#007DFF'
RouteParam() {}
}
两个细节值得说:
- 用
class+ 工厂方法set,不用interface字面量------因为路由参数要在页面间传递,class实例能保持引用稳定 RouteParam必须显式声明成 class,不能{ id: '1', title: '...' }裸对象------这是 ArkTS 的强约束(arkts-no-untyped-obj-literals),比 TypeScript 严,新手必踩
2.2 详情页:NavDestination 容器
typescript
@Component
export struct DetailPage {
@State param: RouteParam = new RouteParam()
private pathStack: NavPathStack = new NavPathStack()
build() {
NavDestination() {
Column({ space: 16 }) {
Stack({ alignContent: Alignment.Center }) {
Text(this.param.title).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#fff')
}
.width('100%').height(120).backgroundColor(this.param.color)
.borderRadius({ bottomLeft: 18, bottomRight: 18 })
Column({ space: 8 }) {
Text('你从首页跳过来了').fontSize(14).fontColor('#888')
Text(`参数 id = ${this.param.id}`).fontSize(18).fontWeight(FontWeight.Bold)
Text(`参数 title = ${this.param.title}`).fontSize(14).fontColor('#555')
}
.width('100%').padding(16).backgroundColor('#fff').borderRadius(10)
Button('返回首页')
.backgroundColor(this.param.color).fontColor('#fff')
.onClick(() => { this.pathStack.pop() })
}
}
.title(this.param.title)
.onReady((context: NavDestinationContext) => {
this.pathStack = context.pathStack
const p = context.pathInfo.param as RouteParam
if (p) { this.param = p }
})
}
}
三个关键点:
① NavDestination 是目标页的「外壳」
每个被 push 进来的目标页,build() 顶层必须套 NavDestination。它自带标题栏、返回按钮、页面生命周期,你只管在里面写页面内容。
② pathStack 从 onReady 取
NavDestination 在 onReady 回调里给你一个 NavDestinationContext,里面有 pathStack(当前挂在的路由栈)和 pathInfo(当前路由项信息,含 param)。你要把它们存到组件属性上,后面 pop 用得到。
③ context.pathInfo.param 取参数
首页 push 时传的 param 对象,在这里通过 context.pathInfo.param 取出来。它是对象引用,不是 query string ------这是 Navigation 比 router.pushUrl 强大的核心一点,任意复杂对象都能传。
2.3 首页:Navigation 容器 + 路由表
typescript
@Entry
@Component
struct Index {
private pathStack: NavPathStack = new NavPathStack()
@State routes: RouteItem[] = []
aboutToAppear(): void {
this.routes = [
new RouteItem().set('商品详情', '传 id 进详情页,展示商品信息', '#007DFF'),
new RouteItem().set('订单跟踪', '传 orderId,展示物流进度', '#FF4D4F'),
new RouteItem().set('用户中心', '传 userId,展示个人信息', '#27AE60')
]
}
// 路由表 builder:Navigation 容器按 name 找对应 builder 构造目标页
@Builder
PageMap(name: string) {
if (name === 'detail') {
DetailPage()
}
}
build() {
Column() {
Text('Navigation 路由 Demo').fontSize(22).fontWeight(FontWeight.Bold).margin({ top: 16 })
Navigation(this.pathStack) {
// 这里是首页内容(栈底默认页)
Column({ space: 10 }) {
Text('首页:选择一条要跳转的路由').fontSize(14).fontColor('#888')
List({ space: 10 }) {
ForEach(this.routes, (route: RouteItem, idx: number) => {
ListItem() {
Row({ space: 12 }) {
Stack({ alignContent: Alignment.Center }) {
Text(`${idx + 1}`).fontSize(18).fontColor('#fff').fontWeight(FontWeight.Bold)
}
.width(40).height(40).backgroundColor(route.color).borderRadius(20)
Column({ space: 4 }) {
Text(route.name).fontSize(16).fontWeight(FontWeight.Bold)
Text(route.desc).fontSize(12).fontColor('#888')
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('›').fontSize(22).fontColor('#bbb')
}
.width('100%').padding(14).backgroundColor('#fff').borderRadius(10)
.onClick(() => {
// pushPath 进栈:name 是路由表 key,param 传给目标页
const p = new RouteParam()
p.id = `${idx + 1}`
p.title = route.name
p.color = route.color
this.pathStack.pushPath({ name: 'detail', param: p } as NavPathInfo)
})
}
}, (route: RouteItem, idx: number) => `${idx}`)
}
}
}
.navDestination(this.PageMap) // 路由表挂在容器的 navDestination 属性上
.title('首页')
}
}
}
三、这段代码的五个关键点
① NavPathStack 是路由栈,所有跳转通过它操作
typescript
private pathStack: NavPathStack = new NavPathStack()
你持有这个栈的引用,push/pop/replace 都通过它。这比 router.pushUrl 命令式调用优雅得多------栈是数据,你能监听栈的变化、能清空、能拿到所有历史。
② pushPath 跳转,不是 push
鸿蒙 6.1 API 23 的真实方法名是 pushPath,不是文档里到处写的 push 。这是新手查文档最容易踩的坑------很多教程是旧 API,方法名是 push,API 23 改成了 pushPath。
typescript
this.pathStack.pushPath({ name: 'detail', param: p } as NavPathInfo)
name是路由表里的 key,容器根据它找 builderparam是传给目标页的对象,任意复杂都行- 整个参数要
as NavPathInfo显式断言,ArkTS 不允许裸对象字面量
③ 路由表 builder 用 if 分发 name
typescript
@Builder
PageMap(name: string) {
if (name === 'detail') {
DetailPage()
}
}
路由表就是一个 @Builder,接收 name,用 if 分发到对应的目标页组件。多个目标页就多个 if 分支。这比写 JSON 路由配置文件直观得多------你看到的就是「name → 哪个页面」。
④ .navDestination(this.PageMap) 挂路由表
路由表 builder 要挂在 Navigation 容器的 navDestination 属性上。这个挂载点容易忘------忘了它,你 push 进栈啥都不发生,容器不知道去哪找目标页。
⑤ pop 返回,不是 router.back
typescript
this.pathStack.pop()
返回用栈的 pop 方法,它会销毁栈顶页面、显示上一个页面,带原生动画。你不用操心动画、不用操心销毁、不用操心状态恢复------栈管到底。
四、真机实拍:首页 → 详情页的跳转长这样
我把这个 demo 装到真机上跑(鸿蒙 6.1.1.125, API 24),下面两张都是真机实拍,没有任何 P 图。
首页:三条路由项,点任意一条跳详情页:

点「商品详情」后,push 到详情页,标题栏带返回,内容区展示传过来的 id 和 title:

重点看第二张图:详情页顶部有原生标题栏(自动加的),内容区正确显示了首页传过来的
参数 id = 1和参数 title = 商品详情------这就是context.pathInfo.param取出来的对象,整个跳转过程对象引用传递,没有 query string 序列化反序列化那些破事。
五、Navigation vs router.pushUrl:啥时候用哪个
新手最容易纠结的问题:既然有 router.pushUrl,还要 Navigation 干啥?
答案一句话:router 是命令式全局路由,Navigation 是声明式容器化路由。
| 维度 | router.pushUrl |
Navigation |
|---|---|---|
| 路由风格 | 命令式(你调函数跳) | 声明式(你改栈,容器自己显示) |
| 传参 | query string 或 JSON 序列化 | 任意对象引用传递 |
| 多 Tab 独立栈 | 不支持,全局一个栈 | 支持,每个 Tab 一个 NavPathStack |
| 跳转动画 | 系统默认硬切 | 原生流畅过渡,可定制 |
| 路由拦截 | 需自己埋点 | 内置 InterceptionCallback |
| 何时用 | 简单几个页面,无独立栈需求 | 多级跳转、Tab 嵌套、要拦截 |
一句话决策:页面超过 5 个,或者要做 Tab + 多级路由,就用 Navigation。
六、常见坑(都是血泪)
| 坑 | 症状 | 解法 |
|---|---|---|
用 push 跳转 |
编译报错「Property 'push' does not exist」 | API 23 改名 pushPath,用新方法 |
路由表忘挂 navDestination |
push 进栈啥都不发生 | .navDestination(this.PageMap) 挂到容器 |
NavDestination 套错位置 |
标题栏不显示,返回按钮不见 | 目标页 build() 顶层必须套 NavDestination |
| 裸对象字面量传参 | 编译报错 arkts-no-untyped-obj-literals |
param 用显式 class 实例,as NavPathInfo 断言 |
context.pathInfo.param 取出来是 unknown |
类型不匹配 | as RouteParam 显式断言,别用 any |
| 多个目标页用同一个 builder | 路由表分发错乱 | 路由表 builder 里用 if (name === 'xxx') 分发 |
七、完整代码仓库
本文所有代码都已托管到 AtomGit,欢迎 clone、提 issue、点 star:
🔗 仓库地址 :atomgit.com/JaneConan/a...
仓库包含:
- 完整的「首页 → 详情页」Navigation 路由 demo 工程
Index.ets首页(Navigation 容器 + 路由表 + 路由列表)DetailPage详情页(NavDestination 外壳 + 对象取参 + pop 返回)RouteParam显式参数 class(ArkTS 强约束示范)- 可直接用 DevEco Studio 打开运行
八、下一步该学什么?
跑通这个 demo 之后,你的 ArkUI 路由就入门了。建议按这个顺序往下:
InterceptionCallback路由拦截:跳转前埋点、登录态校验、参数清洗Navigation嵌套 Tabs :每个 Tab 一个独立NavPathStack,Tab 切换栈独立replacePath替换栈顶:登录页跳主页,替换栈顶不让用户返回登录页NavPathStack持久化 :配合@StorageLink,路由栈跨冷启动保留NavDestination生命周期 :类似 Vue 的onActivated/onDeactivated,做页面可见时刷新数据
写在最后
Navigation 这个东西,本质是把**「页面跳转」从命令式调用变成声明式数据**。你改栈顶数据,容器自己渲染对应页面------这和前端圈 React Router v6 的 loader、Vue Router 的 beforeEach 思路相通,只是鸿蒙用原生容器实现得更顺滑。
一旦你开始用栈思维写路由,你会发现大部分「跳转 + 返回 + 传参 + 动画」的需求,都是栈操作的自然结果。代码量少一半,bug 少九成,设计逮不住你跳转动画生硬。
代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来,亲手 push 一次再 pop 一次感受下栈操作。
跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀
作者:JaneConan 仓库:atomgit.com/JaneConan/a... 协议:Apache-2.0,随便用,别告我