从 router.pushUrl 迁移到 NavPathStack,是 HarmonyOS NEXT 开发者绕不过去的一道坎。这篇文章从实际项目代码出发,把这套路由架构掰开揉碎了讲清楚。
一、为什么选 NavPathStack,不选 router
先说结论:新项目直接上 NavPathStack,没有犹豫的余地。
原因不是 router 不好用------router 在 API 9 时代确实能干活。但 HarmonyOS NEXT(API 12+)的主推方案是 Navigation 组件 + NavPathStack,router 更像是保留兼容的老方案。两者的核心差异在三个地方:
1. 页面栈隔离
router 是全局单栈。你的整个应用共用一个页面栈,A 模块 push 进去的页面,B 模块 pop 的时候也能弹出来。模块之间互相污染,调试的时候看着栈里的页面列表头疼。
NavPathStack 是 Navigation 绑定的独立栈。一个 Navigation 实例持有自己的 NavPathStack,多个 Navigation 各管各的。这意味着你可以安全地在不同 Tab 里各自维护路由状态,互不干扰。
2. 自定义转场动画
router 的转场动画是系统固定的,你想做个从底部滑入的效果?没门。NavPathStack 配合 NavDestination 支持自定义转场动画,可以通过 customNavContentTransition 回调完全接管页面切换的动画逻辑。共享元素转场(geometryTransition)也只在这套体系下能用。
3. 多 Navigation 实例
这才是真正拉开差距的地方。典型场景:底部 Tab 切换,每个 Tab 内部有独立的页面栈。用 router 的话,切 Tab 再回来,之前的页面栈状态全丢了。用 NavPathStack,每个 Tab 的 Navigation 各自持有栈实例,切换 Tab 不影响路由状态。
所以,如果你的应用有 Tab 结构、有分栏需求、或者需要自定义转场,router 根本不够看。

二、NavPathStack 核心 API 一览
NavPathStack 是这套路由体系的控制器,所有页面跳转、返回、替换都通过它。API 设计思路跟 Android 的 FragmentManager 有点像------栈操作为主,方法语义清晰。
2.1 pushPath
最基础的跳转方式。接收一个 PathInfo 对象,包含目标页面的 name 和可选的 param:
typescript
const info: PathInfo = {
name: 'DetailPage',
param: { from: '首页Push', timestamp: Date.now() }
};
this.navStack.pushPath(info);
PathInfo 的结构很简单:name 是你在路由表或 Builder 里注册的页面名称,param 是传给目标页面的参数。pushPath 还支持第二个参数 LaunchMode,可以指定为 MOVE_TO_TOP_SINGLETON------如果栈里已经存在同名页面,直接把它移到栈顶而不是再压一个新实例。
2.2 pushPathByName
跟 pushPath 功能一致,但参数形式不同。name 和 param 分开传,写法更简洁:
typescript
this.navStack.pushPathByName('DetailPage', {
from: 'PushByName',
timestamp: Date.now(),
id: 42,
data: 'Hello Navigation'
} as NavParam);
两者区别只在于调用形式:pushPath 把 name 和 param 打包成对象,pushPathByName 拆开传。实际效果完全一样,选哪个看你习惯。
2.3 replacePath
替换栈顶页面。不是压栈,是把当前页面弹出去再压新的,栈深度不变:
typescript
const info: PathInfo = {
name: 'DetailPage',
param: { from: 'Replace操作', timestamp: Date.now() }
};
this.navStack.replacePath(info);
典型场景:登录页 → 首页。登录成功后 replace 到首页,用户按返回键不会回到登录页。
2.4 pop
弹出栈顶页面,回到上一个页面:
typescript
this.navStack.pop();
除此之外还有几个精细化的 pop 变体:
popToName('PageA'):一直 pop 到名为 PageA 的页面popToIndex(1):pop 到栈中索引为 1 的页面moveToTop('PageA'):不 pop,把栈里名为 PageA 的页面移到栈顶
这些方法在深层级跳转后快速回退时特别好用,比 router 的 back() 灵活太多。
2.5 clear
清空整个路由栈,回到 Navigation 的首页(NavBar):
typescript
this.navStack.clear();
适合「返回首页」这种操作,不管当前栈有多深,一键清空。
2.6 其他实用方法
| 方法 | 作用 |
|---|---|
size() |
获取栈中页面数量 |
getAllPathName() |
获取栈中所有页面名称列表 |
getParamByIndex(i) |
获取指定索引页面的参数 |
getParamByName('PageA') |
获取指定名称页面的参数 |
getIndexByName('PageA') |
获取指定名称页面的索引 |
removeByName('PageA') |
移除指定名称的页面(不弹栈) |
setInterception({...}) |
设置路由拦截器 |
setInterception 特别值得一提------它相当于全局路由守卫。willShow 回调在每次页面跳转前触发,你可以在里面做登录校验、权限拦截、重定向等逻辑,这是 router 完全不具备的能力。
三、路由表配置:route_map.json
路由表是 NavPathStack 的「电话簿」------它告诉系统,页面名称对应哪个组件文件、用哪个 Builder 函数构建。
3.1 创建路由表文件
在 entry/src/main/resources/base/profile/ 下创建 route_map.json:
json
{
"routerMap": [
{
"name": "DetailPage",
"pageSourceFile": "src/main/ets/pages/DetailPage.ets",
"buildFunction": "DetailPageBuilder"
},
{
"name": "SettingsPage",
"pageSourceFile": "src/main/ets/pages/SettingsPage.ets",
"buildFunction": "SettingsPageBuilder"
}
]
}
每个条目三个字段:
name:页面注册名称,就是pushPath/pushPathByName里传的那个字符串pageSourceFile:组件源文件路径buildFunction:构建函数名,必须是在对应文件中用@Builder导出的函数
3.2 在 module.json5 中注册
光有文件不够,还得告诉系统去哪找。在 entry/src/main/module.json5 的 module 节点下添加:
json5
{
"module": {
"name": "entry",
"type": "entry",
"routerMap": "$profile:route_map"
}
}
$profile:route_map 指向刚才创建的 JSON 文件,$profile 是资源路径的简写,对应 resources/base/profile/ 目录。
3.3 路由表 vs navDestination Builder
这里有两条路可以走:
方式一:路由表(推荐)
配置 route_map.json 后,系统自动根据 name 查找 Builder 函数,不需要手动写 navDestination 属性。跨模块(HSP/HAR)跳转时尤其方便------跳转前不需要 import 目标页面的文件,系统按需动态加载。
方式二:navDestination Builder(自定义路由表)
不配路由表,在 Navigation 组件上设置 .navDestination(this.buildNavDestination),手动在 Builder 函数里根据 name 分发组件:
typescript
@Builder
buildNavDestination(name: string, param: Object) {
if (name === 'DetailPage') {
NavDetailPage({ stackRef: this.navStack, paramInfo: param as NavParam })
} else if (name === 'SettingsPage') {
SettingsPage({ stackRef: this.navStack })
}
}
这种方式灵活但需要手动维护映射关系,页面多了 if-else 链会很长。适合页面数量少、跳转逻辑需要定制(比如根据参数决定渲染哪个组件)的场景。
两种方式可以混用,但同一个 name 不能重复注册。路由表优先级更高------如果路由表里已经注册了某个 name,navDestination Builder 里不会收到这个 name 的回调。
3.4 HSP/HAR 模块的路由表
跨模块跳转是路由表的一大亮点。每个 HSP/HAR 模块都可以有自己的 route_map.json,在各自的 module.json5 / oh-package.json5 中注册。跳转时无需 import 目标模块的代码,系统自动完成动态加载。
这对于大型应用的模块化拆分至关重要------模块之间零耦合,只靠路由表的 name 字符串通信。
四、NavDestination 与 navDestination Builder 模式
每个通过 NavPathStack 管理的子页面,都必须包裹在 NavDestination 容器里。NavDestination 不只是个布局容器,它还提供了页面级的标题栏、菜单栏、工具栏和生命周期回调。
4.1 基本结构
一个完整的 NavDestination 页面长这样:
typescript
@Component
struct NavDetailPage {
@Prop stackRef: NavPathStack = new NavPathStack();
@Prop paramInfo: NavParam = { from: '', timestamp: 0 };
build() {
NavDestination() {
Column() {
// 页面内容
}
}
.title('详情页')
.hideTitleBar(false)
.onShown(() => { /* 页面显示回调 */ })
.onHidden(() => { /* 页面隐藏回调 */ })
}
}
4.2 navDestination Builder 的分发逻辑
在 demo 中,我们用的是手动 Builder 方式(而非路由表),代码如下:
typescript
@Builder
buildNavDestination(name: string, param: Object) {
if (name === 'DetailPage') {
NavDetailPage({ stackRef: this.navStack, paramInfo: param as NavParam })
}
}
这个 Builder 绑定在 Navigation 组件上:
typescript
Navigation(this.navStack) {
// 首页内容
}
.navDestination(this.buildNavDestination)
当 NavPathStack 执行 pushPath 或 pushPathByName 时,系统会调用这个 Builder,传入目标页面的 name 和 param。Builder 内部根据 name 决定实例化哪个组件,再把 param 转成对应的类型传给组件。
这里有个关键细节:param 的类型是 Object ,但你的业务参数是 NavParam 这样的具体接口。必须在 Builder 里手动做类型断言 param as NavParam,否则组件里拿到的就是一坨未类型化的数据。
4.3 NavPathStack 的传递方式
子页面需要拿到 NavPathStack 才能执行 pop、push 等操作。在 demo 中我们通过 @Prop 直接传递:
typescript
@Component
struct NavDetailPage {
@Prop stackRef: NavPathStack = new NavPathStack();
// ...
}
首页创建子页面时传入:
typescript
NavDetailPage({ stackRef: this.navStack, paramInfo: param as NavParam })
这是最直接的方式。还有两种常见做法:
- @Provide / @Consume :在 Navigation 所在组件上
@Provide('navStack') navStack,子页面里@Consume('navStack') navStack,不需要层层传递 - onReady 回调 :在 NavDestination 的
onReady(context: NavDestinationContext)回调里通过context.pathStack获取,这是官方推荐的标准方式
三种方式各有取舍:@Prop 传递最显式但组件签名变长;@Provide/@Consume 写法简洁但有隐式依赖;onReady 是官方方案但回调时序需要留意。
五、参数传递:接口化参数设计
参数传递是路由体系里最容易出 bug 的环节。router 时代的参数就是一坨 JSON,类型安全全靠自觉。NavPathStack 虽然参数类型还是 Object,但配合 TypeScript 接口可以把风险降到最低。
5.1 定义参数接口
在 demo 中,我们定义了两个接口:
typescript
interface NavParam {
from: string;
timestamp: number;
id?: number;
data?: string;
level?: number;
}
interface PathInfo {
name: string;
param: NavParam;
}
NavParam 是业务参数,必填字段(from、timestamp)和可选字段(id、data、level)区分明确。PathInfo 是 pushPath 需要的包装结构。
可选字段用 ? 标记,接收端判断 undefined 来决定是否展示:
typescript
aboutToAppear(): void {
this.paramDisplay = `from: ${this.paramInfo.from}\ntimestamp: ${this.paramInfo.timestamp.toString()}`;
if (this.paramInfo.id !== undefined) {
this.paramDisplay += `\nid: ${this.paramInfo.id.toString()}`;
}
if (this.paramInfo.data !== undefined) {
this.paramDisplay += `\ndata: ${this.paramInfo.data}`;
}
if (this.paramInfo.level !== undefined) {
this.paramDisplay += `\nlevel: ${this.paramInfo.level.toString()}`;
}
}
5.2 两种传参方式对比
pushPath 传参:参数包在 PathInfo 对象里
typescript
const info: PathInfo = { name: 'DetailPage', param: { from: '首页Push', timestamp: Date.now() } };
this.navStack.pushPath(info);
pushPathByName 传参:参数直接作为第二个参数
typescript
this.navStack.pushPathByName('DetailPage', {
from: 'PushByName',
timestamp: Date.now(),
id: 42,
data: 'Hello Navigation'
} as NavParam);
注意区别:pushPath 的 param 是 PathInfo 里的嵌套字段,而 pushPathByName 的 param 是直接传的 NavParam。接收端拿到的数据结构是一致的,都是在 Builder 的 param: Object 参数里,断言成 NavParam 即可。
5.3 参数设计建议
- 每个页面定义自己的参数接口 ,不要用一个万能的大接口。
DetailPageParam、SettingsPageParam各管各的 - 必填字段不加
?,让编译器帮你检查。如果 push 的时候漏了必填字段,IDE 直接报红线 - 复杂对象做序列化保护,NavPathStack 的参数在跨模块传递时会经历序列化,对象里的方法、循环引用都会丢失。参数接口只放纯数据字段
- 用
as断言而不是强行转换 ,param as NavParam是类型断言,运行时不做检查;如果参数结构不对,访问不存在的字段会 undefined。建议在 aboutToAppear 里加 try-catch 兜底,demo 里就是这么做的
六、Navigation 的两种显示模式
Navigation 组件支持三种显示模式:Stack、Split、Auto。Auto 是前两者的自适应切换,本质上还是 Stack 和 Split 两种。
6.1 Stack 模式(单栏)
typescript
Navigation(this.navStack) {
// ...
}
.mode(NavigationMode.Stack)
移动端的标准模式。每次跳转,整个页面被替换。首页(NavBar)不在路由栈中,push 进去的 NavDestination 页面在栈里,pop 到栈空时回到首页。
这是 demo 中使用的模式,也是绝大多数手机应用的默认选择。
6.2 Split 模式(分栏)
typescript
Navigation(this.navStack) {
// ...
}
.mode(NavigationMode.Split)
大屏设备(平板、车机)的分栏模式。左侧固定显示导航栏(NavBar),右侧显示 NavDestination 页面。跳转时只有右侧内容区变化,左侧导航栏不动。
分栏模式适合主从结构的界面:左侧列表,右侧详情。Master-Detail 的经典布局。
6.3 Auto 模式(自适应)
typescript
Navigation(this.navStack) {
// ...
}
.mode(NavigationMode.Auto)
根据 Navigation 组件的宽度自动切换:宽度 >= 600vp 用 Split,小于 600vp 用 Stack。如果你想一套代码同时适配手机和平板,Auto 是最省事的选择。
可以通过 onNavigationModeChange 回调感知模式切换,在回调里做布局适配:
typescript
.onNavigationModeChange((mode: NavigationMode) => {
console.info(`Navigation mode changed to: ${mode}`);
})
6.4 分栏布局的细节控制
Split 模式下还有一些精细化的属性:
navBarWidth:控制左侧导航栏宽度navBarPosition:控制导航栏位置(左侧 / 右侧)hideNavBar:隐藏导航栏(单栏应用常用)
demo 里用了 .hideToolBar(true) 隐藏底部工具栏,配合 Stack 模式,视觉效果更干净。
七、router vs NavPathStack 全面对比
把两个方案的核心差异拉个表,一目了然:
| 对比维度 | router | NavPathStack |
|---|---|---|
| 页面栈管理 | 全局单栈,所有页面共享 | Navigation 内独立栈,多实例互不干扰 |
| 参数传递 | params 字段,松散 Object |
结构化参数,配合接口实现类型安全 |
| 转场动画 | 系统默认,无法自定义 | 完全可自定义,支持共享元素转场 |
| 多实例 | 不支持,全局只有一个栈 | 支持,每个 Navigation 各自独立 |
| 路由拦截 | 无 | setInterception 提供 willShow/didShow 回调 |
| Tab 内路由 | 切 Tab 丢失状态 | 各 Tab 独立栈,状态不丢 |
| 跨模块跳转 | 需硬编码 URL 路径 | 路由表 name 映射,零耦合 |
| 页面生命周期 | onPageShow/onPageHide | NavDestination 的 onShown/onHidden |
| 回退控制 | 只能 back() 退一层 |
pop / popToName / popToIndex / clear |
| 栈信息查询 | getLength() 仅返回长度 |
size / getAllPathName / getParamByName 等 |
| 页面模式 | 仅单栏 | Stack / Split / Auto 三种 |
| 适用场景 | 简单线性跳转 | 复杂导航结构、多 Tab、大屏适配 |
结论很明确:router 能做的事 NavPathStack 都能做,反过来不行。
唯一需要注意的迁移成本:router 的页面是独立的 @Entry 组件,NavPathStack 的子页面是 @Component + NavDestination。迁移不只是换个 API 调用,页面结构也要跟着改。
八、常见坑与解决方案
坑 1:子页面里 new 了 NavPathStack,push 没反应
这是最常见的新手错误。子页面里自己创建了一个 NavPathStack:
typescript
// 错误!
@Component
struct DetailPage {
navStack: NavPathStack = new NavPathStack(); // 这不是 Navigation 绑定的那个栈
build() {
NavDestination() {
Button('跳转').onClick(() => {
this.navStack.pushPathByName('NextPage', null); // 没反应
})
}
}
}
原因:你 new 出来的 NavPathStack 跟 Navigation 绑定的不是同一个对象,push 了但页面不知道。
解决方案:三种方式任选------
- 通过
@Prop/@Provide传递 Navigation 的栈实例(demo 用的方式) - 在 NavDestination 的
onReady回调里获取context.pathStack - 通过 AppStorage 共享(不推荐,多个 Navigation 会冲突)
坑 2:路由表配了但跳转没反应
可能的原因:
module.json5里没注册routerMap: "$profile:route_map"buildFunction命名与文件中导出的@Builder函数名不一致pageSourceFile路径写错(注意是相对于模块根目录的路径)- 同时配了路由表和
navDestination,路由表优先,但路由表注册失败时不会 fallthrough
排查方法 :先在 navDestination Builder 里加日志,确认回调有没有被触发。如果触发不到,检查路由表配置。如果触发了但 name 不对,检查 pushPath 传入的 name 拼写。
坑 3:参数收到后是 undefined
push 的时候传了参数,子页面 aboutToAppear 里拿到的却是 undefined。
可能的原因:
- pushPathByName 传参格式不对------第二个参数直接传业务对象,不要包在
{ param: ... }里 - 跨模块传递时参数经历了序列化,对象里的方法、Symbol、undefined 值字段都会丢失
- Builder 里的类型断言写错了,
param as SomeWrongType
解决方案:参数接口只放纯数据字段(string / number / boolean / 数组 / 普通对象),aboutToAppear 里 try-catch 包裹,对可选字段做 undefined 判断。demo 里的写法就是标准模板。
坑 4:预览器里路由跳转失败
路由表配置的跳转在 DevEco 预览器(Previewer)中可能不生效,也不报错。这是已知的限制------预览器对动态路由的支持不完整。
解决方案:路由跳转逻辑用模拟器或真机测试,预览器只用来调 UI 布局。
坑 5:频繁 push 导致栈溢出
快速连续点击跳转按钮,短时间内 push 了多个相同页面进栈。
解决方案:
- 使用
LaunchMode.MOVE_TO_TOP_SINGLETON,避免同名页面重复入栈 - 在 onClick 里加防抖:跳转后 disable 按钮几百毫秒
- 在路由拦截器
willShow里做频率限制
坑 6:clear() 后首页状态异常
调用 navStack.clear() 清空栈后回到首页,但首页的某些状态不对(比如列表滚动位置重置了)。
原因:clear 会卸载所有 NavDestination,回到 NavBar。如果首页内容在 NavBar 里,且 NavBar 没有做状态保持,组件会重新构建。
解决方案 :首页的关键状态用 @State / @Link / AppStorage 管理,不要依赖组件的生命周期保持状态。
坑 7:NavDestination 生命周期回调时序
onShown / onHidden / onWillAppear / onWillDisappear 的触发时机跟组件的 aboutToAppear / aboutToDisappear 不完全一致。
关键区别:
aboutToAppear:组件创建时触发,只调一次onShown:页面每次显示时触发,可以调多次(比如 pop 回来时再触发一次)
如果需要在页面每次展示时刷新数据,用 onShown 而不是 aboutToAppear。
九、完整代码走读
下面逐段走读 NavigationDemo.ets 的完整实现,把每个设计决策讲清楚。
9.1 接口定义
typescript
interface NavParam {
from: string;
timestamp: number;
id?: number;
data?: string;
level?: number;
}
interface PathInfo {
name: string;
param: NavParam;
}
NavParam 定义了页面间传递的业务参数。from 标识来源页面,timestamp 记录跳转时间,id 和 data 是示例的可选业务字段,level 用于多级跳转时标记栈深度。
PathInfo 是 pushPath 的参数结构,把页面名称和业务参数包装在一起。这个接口名跟系统内置的 PathInfo 重名了------实际项目中建议改个更有辨识度的名字,比如 NavRouteInfo,避免歧义。
9.2 主页面结构
typescript
@Entry
@Component
struct NavigationDemo {
@State currentIndex: number = 0;
@State navStack: NavPathStack = new NavPathStack();
@State logText: string = '';
三个状态变量:navStack 是核心------路由栈控制器,用 @State 修饰确保栈操作后 UI 能刷新。logText 用来记录路由操作日志,方便调试。
9.3 Navigation 容器
typescript
Navigation(this.navStack) {
Column() {
Text('首页 (NavPathStack管理)')
Text(`路由栈深度: ${this.navStack.size().toString()}`)
// ... 操作按钮
}
}
.navDestination(this.buildNavDestination)
.mode(NavigationMode.Stack)
.hideToolBar(true)
Navigation(this.navStack) 把路由栈实例绑定到容器上。花括号里的内容是 NavBar(首页),不在路由栈中。.navDestination(this.buildNavDestination) 设置了子页面的 Builder 分发函数。.mode(NavigationMode.Stack) 指定单栏模式。
this.navStack.size() 实时显示栈深度,这是个很有用的调试手段------开发阶段建议保留,上线前删掉。
9.4 Push 跳转
typescript
Row() {
Text('Push跳转').layoutWeight(1)
Text('→').fontColor('#1a73e8')
}
.onClick(() => {
const info: PathInfo = { name: 'DetailPage', param: { from: '首页Push', timestamp: Date.now() } };
this.navStack.pushPath(info);
this.addLog('pushPath: DetailPage');
})
最简单的跳转------只传必填参数 from 和 timestamp,没有 id、data、level。跳转后栈深度 +1,日志区显示操作记录。
9.5 Push 带参数
typescript
.onClick(() => {
this.navStack.pushPathByName('DetailPage', {
from: 'PushByName',
timestamp: Date.now(),
id: 42,
data: 'Hello Navigation'
} as NavParam);
this.addLog('pushPathByName: DetailPage, id=42');
})
对比 pushPath:参数直接作为第二参数传入,包含了可选字段 id 和 data。接收端通过 if (this.paramInfo.id !== undefined) 判断是否有这些字段,有则展示,无则跳过。这就是接口化参数的灵活性------同一个详情页组件,不同入口传不同参数,页面自适应展示。
9.6 Replace 替换
typescript
.onClick(() => {
const info: PathInfo = { name: 'DetailPage', param: { from: 'Replace操作', timestamp: Date.now() } };
this.navStack.replacePath(info);
this.addLog('replacePath: DetailPage');
})
Replace 把当前页面(首页 NavBar 之上的那个页面)替换掉。栈深度不变,但内容换了。注意:replacePath 替换的是栈顶,如果当前栈是空的(还在首页),replace 之后栈里有一个 DetailPage,pop 就回到首页了。
9.7 Clear 清空
typescript
.onClick(() => {
this.navStack.clear();
this.addLog('clear: 路由栈已清空');
})
一键清空。不管栈里堆了多少层页面,clear 之后全部回到首页。
9.8 navDestination Builder
typescript
@Builder
buildNavDestination(name: string, param: Object) {
if (name === 'DetailPage') {
NavDetailPage({ stackRef: this.navStack, paramInfo: param as NavParam })
}
}
Builder 是页面分发的核心。系统传入 name 和 param,Builder 根据 name 决定实例化哪个组件,同时把 navStack 引用和类型化参数传给组件。
这里只有一个 DetailPage,实际项目中会有一长串 if-else 或 switch-case。如果嫌丑,可以用 Map 注册:
typescript
private pageMap: Record<string, (stack: NavPathStack, param: Object) => void> = {
'DetailPage': (stack, param) => NavDetailPage({ stackRef: stack, paramInfo: param as NavParam }),
'SettingsPage': (stack, param) => SettingsPage({ stackRef: stack }),
};
@Builder
buildNavDestination(name: string, param: Object) {
this.pageMap[name]?.(this.navStack, param);
}
但 ArkTS 的 @Builder 内部限制比较多,Map 方式不一定能编译通过,具体要看 API 版本。稳妥起见还是 if-else。
9.9 详情页组件
typescript
@Component
struct NavDetailPage {
@Prop stackRef: NavPathStack = new NavPathStack();
@Prop paramInfo: NavParam = { from: '', timestamp: 0 };
@State paramDisplay: string = '';
@Prop stackRef 接收父组件传入的 NavPathStack 引用。注意这里用 @Prop 而不是 @Link------因为我们只调用 stackRef 的方法(push / pop / clear),不需要双向同步栈的状态变化。NavPathStack 的方法调用是命令式的,不需要状态绑定。
@Prop paramInfo 接收类型化的业务参数。
9.10 参数解析与展示
typescript
aboutToAppear(): void {
try {
this.paramDisplay = `from: ${this.paramInfo.from}\ntimestamp: ${this.paramInfo.timestamp.toString()}`;
if (this.paramInfo.id !== undefined) {
this.paramDisplay += `\nid: ${this.paramInfo.id.toString()}`;
}
if (this.paramInfo.data !== undefined) {
this.paramDisplay += `\ndata: ${this.paramInfo.data}`;
}
if (this.paramInfo.level !== undefined) {
this.paramDisplay += `\nlevel: ${this.paramInfo.level.toString()}`;
}
} catch (e) {
this.paramDisplay = '参数解析失败';
}
}
try-catch 包裹是必要的防御性编程。如果路由跳转传了格式不对的参数(比如 pushPathByName 的第二个参数传了个字符串而不是 NavParam 对象),直接访问 this.paramInfo.from 会抛异常。catch 里给个兜底提示,比白屏强。
9.11 多级跳转
typescript
Button('Push到下一层')
.onClick(() => {
const info: PathInfo = {
name: 'DetailPage',
param: {
from: '详情页Push',
timestamp: Date.now(),
level: this.stackRef.size()
}
};
this.stackRef.pushPath(info);
})
详情页自己也能 push 同名的 DetailPage。每次 push 进去的 DetailPage 都会创建新实例,栈深度递增。level: this.stackRef.size() 记录了入栈时的栈深度,方便排查多级跳转的问题。
这是个很实用的调试技巧------在多级跳转场景下,level 值能让你快速判断当前页面在栈中的大概位置。
9.12 Pop 和 Clear
typescript
Button('Pop返回')
.onClick(() => { this.stackRef.pop(); })
Button('Clear回首页')
.onClick(() => { this.stackRef.clear(); })
Pop 退一层,Clear 回首页。不管当前在栈的哪一层,clear 一定回到 Navigation 的 NavBar。
9.13 对比表格
demo 的底部放了一个 router vs NavPathStack 的对比表格,用 ArkUI 的 Row + Text 布局实现:
typescript
Row() {
Text('页面管理').fontSize(11).width('30%').fontColor('#666666')
Text('全局栈').fontSize(11).width('35%').textAlign(TextAlign.Center)
Text('Navigation内独立栈').fontSize(11).width('35%').textAlign(TextAlign.Center)
}
虽然简陋,但在真机上展示效果还行。实际项目中这种表格建议用 Canvas 或者 WebView 渲染,ArkUI 原生布局做表格太痛苦了。

十、总结与迁移建议
如果你正在从 router 迁移到 NavPathStack,以下是一份实践验证过的迁移路径:
第一步:搭框架
创建 Navigation 根容器 + NavPathStack 实例,把首页内容放进 NavBar 区域。先不配路由表,用 navDestination Builder 手动分发,跑通基本流程。
第二步:定义参数接口
为每个子页面定义参数接口,必填字段不加 ?。先把接口定义好,后面传参的时候 IDE 会自动检查。
第三步:改造子页面
把原来的 @Entry 页面改成 @Component + NavDestination。删除 onPageShow / onPageHide,换成 onShown / onHidden。把 router.back() 换成 navStack.pop()。
第四步:配路由表(可选)
页面多了之后,把 navDestination Builder 里的 if-else 迁移到 route_map.json。每个页面文件导出 @Builder 函数,路由表自动管理映射。
第五步:加路由拦截
在 setInterception 的 willShow 回调里加登录校验、权限检查等全局逻辑。这是 router 时代做不到的事,值得花时间设计。
NavPathStack 不是银弹,它有自己的学习曲线和踩坑点。但在 HarmonyOS NEXT 的生态下,它是官方主推、API 最全、扩展性最强的路由方案。早迁移早受益,拖得越久重构成本越高。
完整 demo 代码在 entry/src/main/ets/pages/NavigationDemo.ets,可以直接跑起来看效果。建议在模拟器上实际操作一遍,push / pop / replace / clear 各点几下,看栈深度变化和参数传递,比看文章管用。