我最早在 HarmonyOS 上做页面跳转时,第一反应是找router.pushUrl,因为这种写法最接近“给一个地址,打开一个页面”的直觉。但页面数量一多,我开始意识到路由设计并不是“能跳过去”就完事。页面栈怎么控制、参数怎么传、从详情页返回后列表要不要刷新,这些细节比选哪个 API 重要得多。这也是这篇文章想解决的:HarmonyOS 里的路由跳转到底该怎么设计,Router 和 Navigation 到底该选谁。
看完官方文档你会发现,Router 和 Navigation 都能实现页面跳转。Router 的入口在@kit.ArkUI的 router 模块里,Navigation 则是 ArkUI 提供的一个导航容器组件,两者并不是同一个维度上的东西。很多开发者的真实困惑是:文档说 A 能用、B 也能用,为什么社区里越来越多声音说新项目要用 Navigation?这篇文章会从实际项目出发,把两套方案的设计逻辑、典型写法、维护成本和踩坑点一次讲完,帮你做出适合自己团队的选择。
我做应用型产品开发,最近两年的主力端就是 HarmonyOS。下面这些内容不会停留在 API 层面,更多是放在“怎么搭一套能维护两三年的页面导航架构”这个尺度上。如果你正在从 0 搭 HarmonyOS 应用,或者考虑把老项目迁移到新版导航架构,这篇内容应该有参考价值。
1. 先给结论:为什么新项目我更推荐 Navigation
1.1 一句话版本
先放结论,后面再展开。
新项目,直接用 Navigation。老项目如果只是加几个页面,用 Router 不会有致命问题;但如果老项目正在重构,或者页面层次越来越深,越早迁到 Navigation 越从容。
为什么我敢这么直接?因为当前 HarmonyOS 的演进方向已经很明显了。官方文档里 router 模块仍然存在,但新的导航能力、新的页面管理特性基本都围绕 Navigation 展开。从“未来演进”的角度看,Navigation 是更值得押注的方向。
1.2 两套方案出现的背景
要理解两套方案,得先看它们各自的背景。
早期 HarmonyOS 版本里,页面之间的跳转用得最多的是 Router。这个模型非常直观:先在系统层面维护一份页面清单,然后通过一个 URL 字符串告诉系统“我要打开哪个页面”。对新手来说,几乎不用理解什么栈和容器,照着pushUrl的签名写就能跑通。
Navigation 是后来逐渐成熟的方案。它不是简单的 API 替换,而是把导航能力真正下沉到 ArkUI 组件体系里。Navigation 组件负责承载一个页面栈,NavPathStack负责管理这个栈,目标页面用NavDestination包裹。跳转的本质变成了“往我自己的栈里压一个页面项”,整个栈生命周期都暴露给开发者。
这两种模型的核心差异在于:Router 是“全局路由表 + 系统统一出栈入栈”,Navigation 是“页面容器 + 开发者可控栈”。前者简单直接,后者灵活复杂。到了 HarmonyOS 面向复杂应用演进的时候,Router 的模型就开始吃力了,Navigation 顺势成了更主流的选型。
2. Router 的定位与适用场景
2.1 Router 的推荐写法
先看一段最常规的 Router 跳转,以当前主流的@kit.ArkUI导入为例。场景是首页列表进入商品详情页,需要携带商品 id 和来源。
import { router } from '@kit.ArkUI'; router.pushUrl({ url: 'pages/DetailPage', params: { id: 1001, from: 'home' } });目标页面里这样取参数:
const params = router.getParams() as Record<string, Object>; const id = params.id as number; const from = params.from as string;返回时直接调用:
router.back();这套写法本身没什么问题。它在系统内部维护的是一个全局栈,pushUrl入栈,back出栈,页面生命周期由系统统一调度。如果一个应用只有几个页面,这个模式是够用的。
不过这里有一个细节,很多人初学时会忽略:params在传递过程中会经历序列化,这意味着它并不适合承载复杂对象。传一个Date对象过去,接收端拿到的可能已经不是原来的Date,而是一个时间戳字符串或别的什么。传Map、Set、自定义 class 实例也是如此。常规做法是只传叶子数据,比如时间戳:
params: { createAt: Date.now() }接收后再自行new Date(createAt)还原。这是注意点,不是大坑,但提前知道能省不少调试时间。
2.2 Router 的核心短板
Router 真正的短板,不是“能不能跳”,而是页面多了以后的可维护性。
第一,类型不安全。url是字符串,params是Record<string, Object>。页面名改了、参数名改了,编译期不会报错,运行期可能直接取到 undefined。对于一个几十个页面的项目来说,这等于在路由层面埋了大量隐性地雷。
第二,路由表容易膨胀。页面数量越多,需要集中维护的页面配置就越杂乱。团队多人协作时,经常出现改了一个页面路径,忘了另一个地方还在旧路径跳转的情况。
第三,栈操作能力弱。Router 提供的能力核心就是pushUrl、replaceUrl、back这一套。但真实业务里有大量“回到某个指定页面并刷新”“把中间某个页面移除”“清空整个栈再进入首页”的需求。Router 不是不能做,而是要做就得自己维护额外状态,很别扭。
第四,页面状态刷新逻辑容易散落。Router 模式下,页面从后台返回前台的刷新,主要依赖onPageShow这种页面生命周期。页面简单的时候没问题,但一旦页面之间互相影响,你会发现onPageShow里要处理的判断条件越来越多,最终变成一团乱麻。
2.3 适合 Router 的具体场景
说了一堆短板,但不代表 Router 该被丢弃。我实际开发中的经验是,下面这几类场景用 Router 完全没问题:
- 页面数量很少,大概 10 个以内,且后续几乎不扩展。
- 单链路流程,比如“启动页 -> 首页 -> 详情页”,没有太多页面组合关系。
- 快速原型、Demo 演示,给领导或客户看效果,不需要考虑长期维护。
- 团队刚刚接触 HarmonyOS,先通过 Router 理解页面生命周期,再逐步上 Navigation。
一旦发现页面数量超过 20 个,或者出现了底部 Tab、二级详情、下单流程回退等复杂结构,就该认真考虑 Navigation 了。
3. Navigation 的设计思路与核心能力
3.1 从组件角度理解 Navigation
Navigation 和 Router 最大的不同,在于它不再是一个全局跳转 API,而是一个页面容器组件。
打个比方:Router 是你告诉前台“我要去 302 房间”,由全局系统帮你开门;Navigation 是你自己手里握着一串钥匙,想把哪个房间打开、把哪扇门关上,完全由自己决定。每个 Navigation 组件都会绑定一个NavPathStack,页面切换就是对栈的压入和弹出操作。
先看最基本的用法。首页作为入口页:
import { Navigation, NavPathStack } from '@kit.ArkUI'; @Entry @Component struct Index { private pageStack: NavPathStack = new NavPathStack(); @Builder pageMap(name: string, param: unknown) { if (name === 'product_detail') { ProductDetailPage({ id: (param as ProductParam).id, from: (param as ProductParam).from }); } } build() { Navigation(this.pageStack) { Button('打开商品详情') .onClick(() => { this.pageStack.pushPath({ name: 'product_detail', param: { id: 1001, from: 'home' } }); }) } .navDestination(this.pageMap) } }目标页面:
@Component export struct ProductDetailPage { id: number = 0; from: string = ''; build() { NavDestination() { Text(`商品ID:${this.id}`) } .title('商品详情') .onShown(() => { console.info('detail shown'); }) } }这里有几个关键点需要理解:
Navigation(this.pageStack)把导航容器和具体的栈绑定。.navDestination(this.pageMap)是页面映射表,pageMap根据 name 返回对应的页面组件。- 目标页面必须用
NavDestination作为根节点,否则页面不会正确进入导航层级。 - 页面和页面之间的跳转关系由 Navigation 自己管理,不再依赖集中式的路由表。
这意味着,页面跳转从“改全局配置 + 写 URL”变成了“定义栈 + 映射组件 + 压栈”。前期多了一点理解成本,但后续扩展的灵活性明显更强。
3.2 栈操作能力是核心优势
NavPathStack最重要的价值,是提供了一整套可编程的栈操作能力。我在项目里常用的大概有这些:
pushPath:压入一个新页面,最常规的跳转。replacePath:替换当前页面,常用于“登录页跳首页”这类场景。pop:返回上一页,等价于系统返回。popToName:返回到指定 name 的页面,并把中间的页面弹出去。removeByIndexes:按索引移除某个或某几个页面,适合清理流程中的中间页。clear:清空整个页面栈,常用于回到根并重置状态。
有了这些操作,“回到第 N 层页面”“下单完成后清掉中间页”这类需求就不再是绕来绕去的 hack,而是直接调用 API 就能实现的设计。
3.3 与页面生命周期结合
Navigation 模式下的页面生命周期,和 Router 模式不太一样。Router 依赖的onPageShow在 Navigation 里不是主角,更常用的是NavDestination上提供的onShown、onHidden回调。
什么意思?当你从详情页返回列表页时,列表页如果是一个NavDestination,它的onShown就会触发。利用这个时机刷新数据,比在全局页面生命周期里猜“现在到底该不该刷新”要精准得多。
NavDestination() { List({ space: 12 }) { // 列表内容 } } .onShown(() => { this.loadList(); })不过要注意,onShown每次页面可见都会触发,如果每次都重新请求接口,用户快速来回切换时会看到频繁 loading。一个比较实用的做法是加“过期时间”标记,比如 30 秒内不重新拉取,超过 30 秒才真正请求。这个是我实际维护列表页时常用的优化手段。
4. 从真实场景看两种方案落地差异
4.1 一个业务场景:列表进详情再下单
只看 API 很难体会差异,我用一个相对完整的业务场景来对比。场景是这样:首页是商品列表,点击某个商品进入商品详情页,详情页里可以继续进入下单确认页。用户从详情页返回时,列表页需要刷新最新数据;用户到达下单确认页后,如果确认完成,希望直接回到一个干净的状态,而不是一层层返回。
这个场景里包含了列表页、详情页、下单确认页三个节点,涉及普通跳转、返回刷新、清理中间页三个核心诉求。用它来对比 Router 和 Navigation 最有说服力。
4.2 Router 版本怎么落
用 Router 实现这个场景时,典型的代码路径是这样的:
- 首页通过
router.pushUrl跳到pages/DetailPage。 - 详情页通过
router.pushUrl跳到pages/OrderPage。 - 列表刷新依赖首页的
onPageShow,因为从详情页返回首页时会触发。 - 如果下单完成后不希望回到详情页,可以用
router.replaceUrl把下单确认页替换成“支付结果页”。
看起来都能实现,但问题在于:返回逻辑和页面业务耦合得很深。你要时刻记住当前页面栈里有哪些页面,然后决定该用back、replaceUrl还是pushUrl。一旦页面层级加深,维护成本会指数级上升。
4.3 Navigation 版本怎么落
同样场景,用 Navigation 时的结构更清晰:
- 首页
pageStack.pushPath到product_detail。 - 详情页
pageStack.pushPath到order_confirm。 - 列表刷新绑定在首页
NavDestination的onShown上,返回时自然触发。 - 下单确认完成时,可以
replacePath成pay_success,或者直接popToName('product_list')回到列表并刷新。
popToName这种“指哪打哪”的能力,是 Router 模式下很难优雅做到的事。流程无论走到多深,只要目标页面还在栈里,就能一次返回到位。
4.4 一张表看清各自定位
我把两个方案的核心差异整理成一张表,方便快速对照。
| 对比维度 | Router | Navigation |
|---|---|---|
| 页面注册 | 依赖集中页面配置,路由关系靠 URL 维护 | 页面映射在 Navigation 内部管理,系统级配置负担更轻 |
| 参数传递 | params 序列化,类型不安全 | 直接传对象,可定义类型,类型可控 |
| 页面栈能力 | push/replace/back 为主,栈操作有限 | push/replace/pop/popToName/clear 等栈能力丰富 |
| 返回刷新 | 主要靠 onPageShow,作用范围偏全局 | 可用 NavDestination 的 onShown/onHidden,粒度更细 |
| 复杂流程 | 需要自己维护很多额外状态 | 页面栈本身就是状态,设计上更自然 |
| 新项目推荐度 | 简单场景可用,不作为长期推荐 | 当前和未来的主流方向 |
这张表不是在说 Router 一无是处,而是想说明两套方案在“可维护性”上的差异。页面只有三四个时,差异很小;页面二十个以上,差异会被放大得非常明显。选型要看你项目的未来,而不仅仅是眼前的功能。
5. 踩坑清单和排查方法
5.1 Navigation 页面白屏
我第一次切 Navigation 的时候,遇到过最诡异的问题就是白屏。pushPath执行后页面栈确实变了,但新页面内容一片空白。
排查到最后,原因很简单:目标页面没有用NavDestination作为根组件。Navigation 需要靠NavDestination来识别这是一个导航目标页,如果目标页面直接写Column或Stack,内容不会进入导航层级,自然就白屏了。
还有一个类似场景:pageMap里的 name 和pushPath传的 name 不一致。比如跳转时写product_detail,映射里写productDetail,匹配不上,页面也不会渲染。遇到白屏时,优先检查这两处。
5.2 返回后列表不刷新
从详情页返回列表页,列表还是旧数据。这个问题在我从 Router 迁移到 Navigation 时反复出现,根因是惯性思维。
Router 模式下,页面返回时onPageShow会触发,大家习惯在onPageShow里刷新数据。但 Navigation 模式下,列表页根节点是NavDestination,需要绑定onShown才能真正感知“页面重新可见”。
NavDestination() { List({ space: 12 }) { } } .onShown(() => { this.loadList(); })如果你发现返回后数据不刷新,先检查是不是还在沿用 Router 的onPageShow,而不是 Navigation 的onShown。
5.3 Tabs 切换页面状态丢失
底部 Tab 是另一个容易翻车的点。很多人会自然地把整个首页在一个 Navigation 里做,结果切换 Tab 后,部分页面状态被重置。
原因是:多个 Tab 共用同一个页面栈时,切换过程可能涉及栈的清理或重建,状态自然保不住。一个相对成熟的方案是:每个 Tab 的内容区使用独立的 Navigation,各自管理自己的NavPathStack。这样 Tab 和 Tab 之间的页面栈互不干扰,业务上也更清晰。
这种结构需要一开始就设计好,后期再拆的成本会比较高。所以如果你的应用确定有底部 Tab,导航骨架必须提前规划。
5.4 参数里的 Date 和 Map 悄悄变形
这个问题在 Router 和 Navigation 里都存在,只要参数经过序列化和反序列化,复杂类型就可能变形。
我在一个项目里传过Date对象,下一页拿到的时区不对;传过Map,下一页发现变成普通对象。这类问题最隐蔽,因为控制台不报错,只是运行结果不对。处理办法也很简单:
- 时间类数据传时间戳,不要传
Date实例。 - 集合类数据先转数组,再作为参数传递。
- 自定义 class 实例不要直接传,传一个普通 DTO 结构。
路由参数的定位应该是“轻量标识”,而不是“数据搬运工”。这个原则不仅能避开序列化问题,还能让代码更简洁。
5.5 Router 和 Navigation 混用的返回栈问题
迁移过程中最容易遇到的就是混用。一部分页面用router.pushUrl,另一部分用pageStack.pushPath,用着用着发现返回逻辑完全乱掉。
原因是两套导航体系各自维护各自的栈,系统并不知道 Navigation 页面栈里当前有哪些页面。从 Navigation 页面调router.pushUrl跳到 Router 页面,再按返回,可能直接退出了应用或者回到一个意想不到的页面。
所以一个工程里,尽量统一导航体系。迁移期如果不得不混用,最好把跳转入口收敛到一个服务里,避免各页面直接调底层 API。下面一节我会专门讲这个过渡方案。
6. 从 Router 迁到 Navigation 的过渡方案
6.1 用一个 NavService 统一跳转入口
老项目迁 Navigation,最忌讳的是改到一半发现页面之间互相撕裂。一个可行的做法是:先抽象一个导航服务 NavService,所有业务页面只调用它,不直接碰 Router 或 NavPathStack。
import { router } from '@kit.ArkUI'; import { NavPathStack } from '@kit.ArkUI'; type RouteParam = { name: string; param?: unknown; }; export class NavService { private static stack?: NavPathStack; static bind(stack: NavPathStack) { NavService.stack = stack; } static push(route: RouteParam) { if (NavService.stack) { NavService.stack.pushPath({ name: route.name, param: route.param }); } else { router.pushUrl({ url: route.name, params: route.param as object }); } } static back() { if (NavService.stack) { NavService.stack.pop(); } else { router.back(); } } }入口页创建 Navigation 时执行绑定:
NavService.bind(this.pageStack);这样做的核心价值是:业务页面不关心底层是 Router 还是 Navigation,只关心NavService.push和NavService.back。后续 Navigation 覆盖到位后,把 Router 分支删掉即可,业务代码完全不用动。
6.2 迁移顺序:从叶子页面开始
NavService 只是过渡方案,真正切换 Navigation 还是要有阶段计划。我的经验是:不要一次性大爆炸式重建,按“叶子页 -> 中间页 -> 容器页”的顺序来。
意思是先迁流程末端页面,比如详情页、支付成功页、结果页。这些页面被业务引用比较分散,单独迁风险最低。迁完叶子页,再迁中间承接页,最后把首页改成 Navigation 容器。
每次迁移一个页面后,要重点回归两个点:返回栈是否正常,参数传递是否一致。混用期容易出现“从 Navigation 页面跳到 Router 页面后返回异常”,所以每一轮迁移后都要跑一遍完整链路,不要等到全部迁完再统一测试。
6.3 到底按什么标准选型
发展到这一步,你一定想知道一个相对明确的判断标准。我按自己做项目的经验,给一个可以“抄作业”的清单:
- 页面少于 10 个,后续业务也不复杂:Router 完全够用,不用折腾。
- 页面在 10 到 20 个之间,相互关联不深:两套都能用,我更倾向 Navigation,因为传参类型更安全。
- 有底部 Tab、多级详情、复杂订单流程:必须 Navigation,没有悬念。
- 团队刚开始接触 ArkUI:可以先让团队用 Router 快速跑通需求,但架构上必须预留 NavService 这一层,方便后续切换。
- 老项目要大改版:直接切 Navigation,别继续给 Router 打补丁。
选型的核心不是“哪个 API 更高级”,而是“项目未来到底会长多大”。这一点想清楚,Router 和 Navigation 的选择就不会再摇摆。
7. 路由设计时容易被忽略的几件小事
7.1 路由只传轻量标识,不要传整块大数据
很多新手喜欢把列表页已经拿到的整个对象塞进路由参数,比如把整个商品对象传进详情页。这个做法有两个问题:
一是序列化成本高,复杂对象容易变形。 二是业务耦合变重。如果详情页只依赖 id,那数据可以从缓存或数据层拉取;可一旦依赖整个对象,后续数据源变化,路由参数这里也要跟着改。
正确做法是只传轻量标识:
this.pageStack.pushPath({ name: 'product_detail', param: { id: product.id, from: 'home' } });详情页拿到 id 后自行加载。这个原则对 Router 同样适用,只是 Navigation 在类型安全上更友好,更容易帮助团队养成好习惯。
7.2 给路由起业务名,而不是组件名
Navigation 的 name 不要求等于页面组件类名,这一点很容易被忽略。把它定义为“业务路径”而不是“页面类名”,后续收益很大。
比如详情页的组件叫ProductDetailPage,路由名可以叫product_detail。将来组件改名、重构,路由逻辑不会受影响。如果后续要接统一跳转入口,业务名也更方便对外暴露和映射。
为了避免字符串魔法值到处出现,建议集中定义常量:
export const RouteName = { ProductList: 'product_list', ProductDetail: 'product_detail', OrderConfirm: 'order_confirm' } as const;跳转时引用RouteName.ProductDetail,拼写错误在编译期就能发现。
7.3 提前想清楚“终点页面回到哪”
业务里有一类需求很常见:用户从 A 页面进入 B 页面,再进入 C 页面,在 C 完成某个操作后,希望回到的不是上一页 B,而是 A 或某个指定页面。
这种返回链路,最忌在代码里硬编码。比较优雅的做法是:在进入流程时,把“回退到哪”作为参数传入,让最后一个页面知道自己的终点在哪里。
this.pageStack.pushPath({ name: 'order_confirm', param: { orderId: 'xxx', backTo: RouteName.ProductList } });完成操作后,确认目标页面还在栈里,再执行popToName回到希望的位置。这样整个流程看起来就像一张清晰的地图,而不是一条被写死的回退线。
我在实际项目中默认用 Navigation 搭骨架,但也不会把 Router 当作必须删掉的代码。两套方案并存期间,我用 NavService 屏蔽底层。踩过几次坑之后最大的体会是:路由跳转的表层问题是“用哪个 API”,底层问题其实是“页面之间的依赖怎么治理”。如果让我给一个最实用的建议,那就是在新项目的第 0 天就引入一个统一跳转入口,无论底层选什么,后续调整都不会伤筋动骨。希望这些经验能帮你少踩几个我踩过的坑。