HarmonyOS 6.1 Navigation + NavPathStack 路由架构设计

从 router.pushUrl 迁移到 NavPathStack,是 HarmonyOS NEXT 开发者绕不过去的一道坎。这篇文章从实际项目代码出发,把这套路由架构掰开揉碎了讲清楚。

先说结论:新项目直接上 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.json5module 节点下添加:

json5 复制代码
{
  "module": {
    "name": "entry",
    "type": "entry",
    "routerMap": "$profile:route_map"
  }
}

$profile:route_map 指向刚才创建的 JSON 文件,$profile 是资源路径的简写,对应 resources/base/profile/ 目录。

这里有两条路可以走:

方式一:路由表(推荐)

配置 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 字符串通信。

每个通过 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(() => { /* 页面隐藏回调 */ })
  }
}

在 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 执行 pushPathpushPathByName 时,系统会调用这个 Builder,传入目标页面的 name 和 param。Builder 内部根据 name 决定实例化哪个组件,再把 param 转成对应的类型传给组件。

这里有个关键细节:param 的类型是 Object ,但你的业务参数是 NavParam 这样的具体接口。必须在 Builder 里手动做类型断言 param as NavParam,否则组件里拿到的就是一坨未类型化的数据。

子页面需要拿到 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 参数设计建议

  1. 每个页面定义自己的参数接口 ,不要用一个万能的大接口。DetailPageParamSettingsPageParam 各管各的
  2. 必填字段不加 ?,让编译器帮你检查。如果 push 的时候漏了必填字段,IDE 直接报红线
  3. 复杂对象做序列化保护,NavPathStack 的参数在跨模块传递时会经历序列化,对象里的方法、循环引用都会丢失。参数接口只放纯数据字段
  4. 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 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 调用,页面结构也要跟着改。

八、常见坑与解决方案

这是最常见的新手错误。子页面里自己创建了一个 NavPathStack:

typescript 复制代码
// 错误!
@Component
struct DetailPage {
  navStack: NavPathStack = new NavPathStack(); // 这不是 Navigation 绑定的那个栈

  build() {
    NavDestination() {
      Button('跳转').onClick(() => {
        this.navStack.pushPathByName('NextPage', null); // 没反应
      })
    }
  }
}

原因:你 new 出来的 NavPathStack 跟 Navigation 绑定的不是同一个对象,push 了但页面不知道。

解决方案:三种方式任选------

  1. 通过 @Prop / @Provide 传递 Navigation 的栈实例(demo 用的方式)
  2. 在 NavDestination 的 onReady 回调里获取 context.pathStack
  3. 通过 AppStorage 共享(不推荐,多个 Navigation 会冲突)

坑 2:路由表配了但跳转没反应

可能的原因:

  1. module.json5 里没注册 routerMap: "$profile:route_map"
  2. buildFunction 命名与文件中导出的 @Builder 函数名不一致
  3. pageSourceFile 路径写错(注意是相对于模块根目录的路径)
  4. 同时配了路由表和 navDestination,路由表优先,但路由表注册失败时不会 fallthrough

排查方法 :先在 navDestination Builder 里加日志,确认回调有没有被触发。如果触发不到,检查路由表配置。如果触发了但 name 不对,检查 pushPath 传入的 name 拼写。

坑 3:参数收到后是 undefined

push 的时候传了参数,子页面 aboutToAppear 里拿到的却是 undefined。

可能的原因:

  1. pushPathByName 传参格式不对------第二个参数直接传业务对象,不要包在 { param: ... }
  2. 跨模块传递时参数经历了序列化,对象里的方法、Symbol、undefined 值字段都会丢失
  3. Builder 里的类型断言写错了,param as SomeWrongType

解决方案:参数接口只放纯数据字段(string / number / boolean / 数组 / 普通对象),aboutToAppear 里 try-catch 包裹,对可选字段做 undefined 判断。demo 里的写法就是标准模板。

坑 4:预览器里路由跳转失败

路由表配置的跳转在 DevEco 预览器(Previewer)中可能不生效,也不报错。这是已知的限制------预览器对动态路由的支持不完整。

解决方案:路由跳转逻辑用模拟器或真机测试,预览器只用来调 UI 布局。

坑 5:频繁 push 导致栈溢出

快速连续点击跳转按钮,短时间内 push 了多个相同页面进栈。

解决方案

  1. 使用 LaunchMode.MOVE_TO_TOP_SINGLETON,避免同名页面重复入栈
  2. 在 onClick 里加防抖:跳转后 disable 按钮几百毫秒
  3. 在路由拦截器 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 记录跳转时间,iddata 是示例的可选业务字段,level 用于多级跳转时标记栈深度。

PathInfopushPath 的参数结构,把页面名称和业务参数包装在一起。这个接口名跟系统内置的 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 用来记录路由操作日志,方便调试。

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');
})

最简单的跳转------只传必填参数 fromtimestamp,没有 iddatalevel。跳转后栈深度 +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:参数直接作为第二参数传入,包含了可选字段 iddata。接收端通过 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 之后全部回到首页。

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 函数,路由表自动管理映射。

第五步:加路由拦截

setInterceptionwillShow 回调里加登录校验、权限检查等全局逻辑。这是 router 时代做不到的事,值得花时间设计。


NavPathStack 不是银弹,它有自己的学习曲线和踩坑点。但在 HarmonyOS NEXT 的生态下,它是官方主推、API 最全、扩展性最强的路由方案。早迁移早受益,拖得越久重构成本越高。

完整 demo 代码在 entry/src/main/ets/pages/NavigationDemo.ets,可以直接跑起来看效果。建议在模拟器上实际操作一遍,push / pop / replace / clear 各点几下,看栈深度变化和参数传递,比看文章管用。