鸿蒙新特性 | 页面路由——router 怎么跳怎么传参

📅 2026/7/21 23:27:09
鸿蒙新特性 | 页面路由——router 怎么跳怎么传参
一、我们想解决什么问题1.1 生活场景引入先来打个比方帮你建立一个对路由的直观印象。你现在住在一个公寓楼里有一天你想从自己家去楼下超市买点东西。你有两条路可以走一条是坐电梯直接到一楼出门另一条是爬楼梯绕一大圈再出去。当然正常人都会选坐电梯——更快、更舒服、更符合直觉。路由Router在 App 里扮演的角色就相当于这个电梯你告诉它我要去超市它就自动帮你规划好路线带你到达目的地。你不需要关心电梯是怎么升降的、楼层是怎么切换的你只需要说我要去哪就够了。1.2 开发中的具体痛点现在把镜头拉到实际的 App 开发场景。你在做一个 GitHub 客户端 App用户的使用路径大概是这样的打开 App看到仓库列表首页点击某个仓库名字跳转到仓库详情页在详情页里点Issues标签看到这个仓库的 Issue 列表点某一条 Issue跳转到 Issue 详情页在 Issue 详情页里点评论看到所有评论这个过程中发生了多少次页面跳转粗略数一下至少 4 次。而每一次跳转都面临同样的两个核心问题问题一怎么跳用 push 还是 replace要不要带动画用户按返回键应该退到哪里问题二带什么从列表页到详情页要带哪些数据仓库名字当然要带star 数要不要作者头像要不要这些数据怎么传、怎么接、传错了怎么办如果你没把路由设计好上面这些问题就会像地雷一样遍布你的代码。今天发现传参丢了明天发现页面栈乱套了后天发现按返回键直接退出了 App——这些问题单个看起来都不大但累积起来会让你的 App 体验变得非常糟糕。1.3 常见的错误思路我见过一些新手在处理页面跳转时的几种错误思路在这里列出来帮你避坑第一种什么数据都用 AppStorage 全局存着。比如把仓库信息存到 AppStorage 里跳转页面时什么都不带等到了详情页再从 AppStorage 里取。听起来也能工作但问题在于AppStorage 是全局的你的 App 里可能有几十个页面都在往 AppStorage 里写数据时间久了你就不知道哪个页面改了哪个数据而且 AppStorage 里的数据不会随着页面销毁而清理容易造成内存泄漏。第二种用 URL 参数拼接字符串。比如把参数拼成pages/Detail?id123nametest这样的字符串传过去然后在详情页再自己解析。这不是不行但对于复杂对象比如一个包含几十个字段的仓库对象URL 参数根本兜不住而且解析的时候还容易出错。第三种分不清 Page 和 UIAbility。明明只是同一个应用内两个页面之间的跳转非要用 startAbility 去启动一个新的 Ability结果发现数据传递特别麻烦生命周期也乱了。这就是对 HarmonyOS 应用架构理解不到位导致的。看完这三个错误思路你应该对什么不该做有了一些感觉。那么正确的做法是什么呢让我们继续往下看。二、数据模型设计2.1 为什么先设计数据模型做任何功能之前我强烈建议你先把数据结构定义清楚。这就像盖房子之前要先画图纸——你得知道你要盖的是一栋什么样的楼有几层每层干什么用房间里放什么家具。这些都定了施工的时候才不容易返工。写代码也是一样的道理。如果你不提前定义好数据模型一边写一边临时决定这个字段叫什么、那个字段什么类型最后代码就会变得非常散乱改一个地方要同时改好几个文件还容易漏掉。2.2 仓库详情页的数据结构对于我们的 GitHub 客户端仓库详情页需要展示的数据大概有这些// 传递给仓库详情页的数据结构exportinterfaceRepositoryDetailParams{// 仓库全名带所有者前缀例如 openharmony/docsrepoFullName:string;// 仓库描述一句话介绍这个仓库是干嘛的description:string;// star 数量反映项目热度stargazersCount:number;// fork 数量反映项目被二次开发的程度forksCount:number;// 主要编程语言language:string;// 作者头像 URLownerAvatarUrl:string;// 最后更新时间updatedAt:string;// 仓库是否私有可选isPrivate?:boolean;// Issues 总数可选issuesCount?:number;}这个 interface 设计出来之后列表页和详情页就有了一个共同的契约列表页知道我要把哪些数据给出去详情页知道我要从哪个通道把数据接进来。两边对上了参数传递就不会出问题。2.3 为什么要用 interface 而不是直接传对象你可能会问我直接在 pushUrl 的时候把对象塞进去不行吗为什么要先定义 interface好问题。直接塞对象当然能跑但有两个隐患第一个是隐式依赖。你在列表页写了一个对象过了两周你想在详情页访问这个对象的某个字段结果发现列表页当初根本就没传这个字段代码就崩了。如果你用了 interfaceTypeScript 编译器在编译阶段就能告诉你你定义了这个字段但没传或者你访问了这个字段但 interface 里没有。第二个是重构成本高。假设你要给所有页面传递的数据加一个新字段没有 interface 的话你得在整个项目里搜索所有相关的 pushUrl 调用手动加字段。有了 interface你只需要在一个地方改然后让 TypeScript 告诉你哪些地方需要同步更新。三、核心设计决策3.1 HarmonyOS 路由方案全景图HarmonyOS 提供了多种页面导航手段但它们并不是同一个东西理解它们的层次关系很重要。我来给你梳理一下第一层Page 内导航——在一个页面内部切换视图比如 Tab 之间切换、展开折叠面板等。这类需求一般用 ArkUI 内置的组件如 Tabs、Navigation就能搞定不需要 router 介入。第二层同一 UIAbility 内的 Page 跳转——这是最常见的情况比如从首页跳到详情页、从设置页跳到账号管理页。这类需求用router.pushUrl或router.replaceUrl来处理是最轻量、最简单的方式。第三层跨 UIAbility 跳转——比如从你的 App 跳到另一个 App 的某个页面或者在同一个 App 的不同模块UIAbility之间跳转。这类需求要用startAbility涉及 Want 对象复杂度更高。3.2 pushUrl vs replaceUrl 详细对比这是最容易选错的点我把它掰开了揉碎了讲。想象一下你在看一本书你在第 50 页停下来夹了书签然后翻到了第 80 页。这个时候你面前的书是第 80 页但第 50 页的内容并没有消失它就压在下面你随时可以翻回去。pushUrl就相当于这个动作——新页面叠在旧页面的上面。现在换个场景你把第 50 页撕掉了然后把第 80 页粘到原来的位置。你面前还是一本书但这本书只有 80 页了第 50 页彻底没了。replaceUrl就相当于这个动作——新页面把旧页面吃掉了。特性pushUrlreplaceUrl页面栈变化压栈栈深度 1替换栈深度不变返回行为按返回键回到上一页按返回键回到上上一页适用场景正常有来有回的导航流程终结点如登录→主页用户体验符合直觉不会迷路需谨慎用防止用户回不去性能几乎无差别几乎无差别什么时候用 pushUrl几乎所有正常的页面导航都用它。用户点进去看详情看完了按返回键回来——这是天经地义的用户行为。什么时候用 replaceUrl场景很有限但很关键。比如用户注册完成后跳到主页此时如果用户按返回键不应该让他回到注册页因为注册流程已经结束了再比如从列表页点了某个操作后进入编辑页编辑完成后跳回列表页并刷新——这种情况下用 replace 把编辑页替换掉列表页可以防止用户按返回键又回到编辑页的尴尬。3.3 为什么优先选 router 而不是 startAbility我用一个生活场景来解释这个区别。router 就像是你们公司内部各部门之间的电话转接——你从销售部打电话到财务部只要知道对方的分机号就能接通非常方便而且通话记录都在公司内部系统里出了问题好查。startAbility 更像是你从自己公司打电话到另一家公司——你得知道对方的公司名称、对方公司的具体部门而且两家公司之间的通话规则不一样有时候还需要对方公司那边确认才能接通复杂多了。所以在大多数情况下只要是在同一个 App 内部跳转优先用 router。只有在真正需要跨 App 或者需要某个独立能力模块的时候才考虑 startAbility。四、完整代码实现4.1 列表页发起跳转// RepositoryList.ets - 仓库列表页importrouterfromohos.router;importtype{RepositoryDetailParams}from../model/RepositoryModel;EntryComponentstruct RepositoryList{StaterepositoryList:RepositoryDetailParams[][];aboutToAppear(){// 模拟加载仓库数据this.repositoryList[{repoFullName:openharmony/docs,description:OpenHarmony 开发者文档,stargazersCount:2356,forksCount:892,language:TypeScript,ownerAvatarUrl:https://avatars.githubusercontent.com/u/xxx1,updatedAt:2024-12-01},{repoFullName:openharmony/kernel_linux,description:Linux 内核适配层,stargazersCount:3847,forksCount:1102,language:C,ownerAvatarUrl:https://avatars.githubusercontent.com/u/xxx2,updatedAt:2024-11-28}];}build(){Column(){// 顶部标题栏Row(){Text( 搜索仓库).fontSize(20).fontWeight(FontWeight.Bold)}.width(100%).padding(16)// 仓库列表List(){ForEach(this.repositoryList,(repo:RepositoryDetailParams){ListItem(){this.RepoItem(repo)}.onClick((){// 关键pushUrl 的 params 字段就是你要传递的数据router.pushUrl({url:pages/RepositoryDetail,params:repo});})},(repo:RepositoryDetailParams)repo.repoFullName)}}.width(100%).height(100%).backgroundColor(#F5F5F5)}BuilderRepoItem(repo:RepositoryDetailParams){Column(){Text(repo.repoFullName).fontSize(18).fontWeight(FontWeight.Bold).fontColor(#333)Text(repo.description).fontSize(14).fontColor(#666).margin({top:4})Row(){Text(⭐${repo.stargazersCount}).fontSize(12).fontColor(#888)Text(${repo.forksCount}).fontSize(12).fontColor(#888)Text(${repo.language}).fontSize(12).fontColor(#888)}.margin({top:8})}.width(100%).padding(16).backgroundColor(#FFF).borderRadius(8)}}4.2 详情页接收参数// RepositoryDetail.ets - 仓库详情页importrouterfromohos.router;importtype{RepositoryDetailParams}from../model/RepositoryModel;EntryComponentstruct RepositoryDetail{// 从 router 获取参数推荐方式Stateparams:RepositoryDetailParamsrouter.getParams()asRepositoryDetailParams;// 如果你更习惯在生命周期里初始化也可以这样做StaterepoFullName:string;Statedescription:string;StatestargazersCount:number0;StateforksCount:number0;Statelanguage:string;StateownerAvatarUrl:string;StateupdatedAt:string;aboutToAppear(){// 接收跳转时传来的参数constnavParamsrouter.getParams();if(navParams){constpnavParamsasRepositoryDetailParams;this.repoFullNamep.repoFullName;this.descriptionp.description;this.stargazersCountp.stargazersCount;this.forksCountp.forksCount;this.languagep.language;this.ownerAvatarUrlp.ownerAvatarUrl;this.updatedAtp.updatedAt;}}build(){Column(){// 返回导航栏Row(){Button(← 返回).onClick(()router.back())}.width(100%).padding(12)Divider()// 详情内容区Column(){Text(this.repoFullName).fontSize(26).fontWeight(FontWeight.Bold).fontColor(#1A1A1A)Text(this.description).fontSize(16).fontColor(#555).margin({top:12})Divider().margin({top:16,bottom:16})Row(){Column(){Text(${this.stargazersCount}).fontSize(22).fontWeight(FontWeight.Bold)Text(Stars).fontSize(12).fontColor(#888)}.layoutWeight(1)Column(){Text(${this.forksCount}).fontSize(22).fontWeight(FontWeight.Bold)Text(Forks).fontSize(12).fontColor(#888)}.layoutWeight(1)Column(){Text(this.language).fontSize(16).fontWeight(FontWeight.Bold)Text(Language).fontSize(12).fontColor(#888)}.layoutWeight(1)}.width(100%).padding({vertical:16})Text(最后更新:${this.updatedAt}).fontSize(13).fontColor(#999)Row(){Button(Watch).backgroundColor(#E8E8E8).fontColor(#333)Button(Star).backgroundColor(#F0A500).fontColor(#FFF)}.margin({top:20}).width(80%)}.width(100%).padding(16)}.width(100%).height(100%).backgroundColor(#FFF)}}4.3 路由配置module.json5 中声明页面{ module: { pages: $profile:main_pages, // 指向页面路由配置文件 } }对应的main_pages.json{ src: [ pages/RepositoryList, pages/RepositoryDetail ] }五、深度技术原理5.1 页面栈到底是个什么东西前面我好几次提到了页面栈这个词但可能你还没有一个很清晰的概念。让我来把这个概念好好解释一下。栈Stack是一种数据结构特点是后进先出Last In First Out。你可以把它理解为一个叠起来的盘子——最后放上去的盘子最先被拿下来。在 HarmonyOS 的 router 里这个盘子就是页面。当用户打开 App 时栈里只有一个页面首页。用户点了某个列表项pushUrl把详情页放到栈顶。用户再点进去看 Issue 列表pushUrl把 Issue 列表页放到栈顶。此时栈的结构是从栈底到栈顶[首页] → [详情页] → [Issue列表页]用户按返回键router.back()把栈顶的 Issue 列表页弹出去露出下面的详情页。再按一次返回到首页。这就是页面栈的工作原理。理解了这一点你就知道pushUrl 会增加栈深度栈越来越深返回的时候要按很多次。replaceUrl 不增加栈深度新页面直接覆盖旧页面。栈是有容量限制的如果无限 push栈会满。所以设计 App 时要合理规划导航层级。5.2 参数传递的底层机制你可能会好奇pushUrl的时候把一个对象塞进 params 参数里这个对象是怎么到达目标页面的中间经历了什么实际上HarmonyOS 框架在底层做了这些事情发送端当你调用router.pushUrl({ url, params })时框架会把 params 对象序列化成一个内部的数据结构然后和目标页面的 url 一起存入路由管理器的队列里。序列化就是把你写的 JavaScript/TypeScript 对象转换成一种可以存储和传输的格式这个过程对你是透明的。传输过程这个序列化的数据和路由指令一起被发往 HarmonyOS 的路由服务Router Service。这个服务是系统级别的它负责协调所有应用内的页面跳转。接收端目标页面被加载后框架会把序列化数据反序列化重新变回 JavaScript 对象然后注入到目标页面的上下文中。你调用router.getParams()时框架就从这个上下文里把数据取出来还给你。整个过程你完全不需要写任何序列化、反序列化的代码——框架全包了。这就像你寄快递快递公司帮你打包、运输、派送你只需要把东西交给快递员就行不需要自己开车送。5.3 router.back 的进阶用法router.back()不仅仅能把用户带回上一页它还有两个非常实用的参数// 带参数返回上一页router.back({url:pages/RepositoryList,// 可选指定返回到哪个页面params:{refreshed:true}// 可选带回一些数据给上一个页面});这个功能在什么场景下有用呢比如你从列表页跳到编辑页编辑完成后保存并返回到列表页同时希望列表页自动刷新显示最新数据。这种时候你就可以用router.back()把一个refreshed: true带回列表页列表页在onPageShow生命周期里检查这个参数发现是 true 就去刷新数据。5.4 UIAbility 与 Page 的关系这是 HarmonyOS 区别于普通前端开发的一个重要概念。在 Android 开发里有 Activity在 iOS 开发里有 ViewControllerUIAbility 就是 HarmonyOS 里的这个能力单元。一个 UIAbility 就像一个车间里面可以有多台机器Page。不同车间之间是相互独立的有各自的进程入口和生命周期同一个车间里的机器之间用 router 跳转就像在一个工厂内部走动一样方便。为什么要区分这个因为有些场景确实需要不同的 UIAbility。比如你的 App 有一个分享功能你想让用户在任意页面都能触发分享——这时候分享功能最好做成一个独立的 UIAbility因为它不属于任何一个具体的页面而是一个横跨整个 App 的能力。再比如你做一个 App 有主应用和便携卡片两个入口这两个入口的业务逻辑完全不同就可以分别用两个 UIAbility 来承载。六、常见问题解答Q1router.pushUrl 跳转后目标页面收不到参数怎么办这是新人最容易遇到的问题之一。按顺序检查以下三点第一步打开浏览器 DevTools 的日志面板或者用hilog打印日志确认 pushUrl 调用时 params 确实有值。如果 push 时 params 本身就是空的那传不过去很正常。第二步确认目标页面的 url 和你在 push 时写的 url 完全一致包括大小写。HarmonyOS 的路由是严格匹配的pages/RepositoryDetail和pages/repositoryDetail是两个不同的路由。第三步确认目标页面确实在main_pages.json里注册了。如果页面没有注册HarmonyOS 会直接报路由未找到的错误。第四步如果以上都没问题检查 params 里传的对象字段名和你定义的 interface 是否完全一致。TypeScript 在编译时可能不报错如果你用了as断言但运行时拿到的是 undefined。Q2连续多次 pushUrl 后页面栈越来越深返回时要按很多次有没有优雅的解决方案有两个思路。一个是从产品设计上优化不要设计太深的导航层级一般推荐不超过 3 层。如果你的 App 有超过 3 层的页面那就要考虑是不是某些页面可以改成 modal 弹窗或者底部抽屉而不是单独开一个页面。另一个是从技术手段上处理如果确实需要深度导航可以在某些节点用replaceUrl替换掉中间的一些页面。比如从 Issue 列表到 Issue 详情再到某条评论详情如果用户从评论详情返回后不需要回到 Issue 详情因为那个 Issue 已经被记住了可以用 replace 把评论详情替换掉 Issue 详情。Q3router.back() 能不能带回数据给上一个页面可以但有条件。router.back()支持两个可选参数url和params。你可以在返回时通过 params 把数据带回上一个页面。上一个页面需要在onPageShow生命周期里通过router.getParams()接收这些数据。不过要注意如果用户按的是系统返回键不是你自己放的返回按钮这种 back 是没有机会注入参数的。所以如果你需要带参数回来建议用明确的按钮触发 back而不是依赖系统返回键。Q4replaceUrl 和 pushUrl 在性能上有差别吗几乎没有。两者调用的都是 HarmonyOS 路由框架的底层能力开销相当。真正影响 App 性能的是页面的渲染速度和接口请求的快慢。选 push 还是 replace应该完全基于你的业务逻辑和用户交互体验来决定而不是考虑性能。Q5跳转到一个没有在路由表里注册的页面会怎样HarmonyOS 会抛出异常App 会崩溃。所以每次新增页面时除了写代码还要记得同步更新main_pages.json路由表。可以把这个作为一个规范写进你们的项目 README 里新增页面 写代码 注册路由。Q6AppStorage 和 router params 传参怎么选核心区别在于生命周期和作用范围router params临时性、单次传递只在这一次跳转中有效目标页面关闭后数据就消失了。AppStorage全局共享整个 App 生命周期内都存在任何页面随时可以访问。我的建议是跳转时需要用到的数据用 router params这样职责清晰、生命周期明确全局共享的数据如用户登录状态、主题配置用 AppStorage。两者各司其职不要混用。七、运行效果下面是用 ASCII 字符画展示的 App 运行效果展示从仓库列表到仓库详情的完整跳转流程↓ [ 点击 openharmony/docs ] ↓八、扩展方向学完了基础路由你已经可以在项目里正常做页面跳转和参数传递了。但路由的世界远不止于此以下几个方向值得你继续探索8.1 路由守卫与登录拦截在企业级应用里大多数页面都需要用户先登录才能访问。传统的做法是在每个页面的aboutToAppear里写一段检查登录状态的逻辑——如果没有登录就跳转到登录页。这种做法的问题是代码重复而且容易遗漏。更好的方案是封装一个路由守卫Route Guard。你在路由层面统一做拦截每次跳转之前先检查用户登录状态状态过期就自动重定向到登录页登录成功后再跳回用户原本想去的页面。这就好比公司大门装了门禁不管你进哪栋楼先刷一次卡就行不用每栋楼门口再刷一次。8.2 路由参数验证当前我们的实现里列表页给详情页传什么详情页就接收什么没有任何校验。生产环境里这是个隐患——万一哪天后端接口改了返回的数据结构变了你的 App 可能就会在某个页面崩溃。建议在详情页的aboutToAppear里加一层参数校验用 TypeScript 的类型守卫或者一个简单的 validator 函数检查 params 里每个必填字段是否存在、类型是否正确。如果校验不通过至少给用户一个友好的提示比如数据加载失败请重试而不是让 App 直接白屏崩溃。8.3 集中式路由配置随着 App 规模扩大你的main_pages.json里可能会注册几十个页面每个页面的 url 字符串散落在代码的各个角落。有一天你想改一个页面的路径比如把pages/RepositoryDetail改成pages/Repo/Detail你就要在整个项目里搜索所有用到这个路径的地方手动改。解决这个问题的思路很简单把所有的页面路径定义到一个常量文件里用命名常量代替字符串字面量。比如// routes/RouteNames.tsexportconstRouteNames{REPOSITORY_LIST:pages/RepositoryList,REPOSITORY_DETAIL:pages/RepositoryDetail,ISSUE_LIST:pages/IssueList,ISSUE_DETAIL:pages/IssueDetail,}asconst;// 跳转时router.pushUrl({url:RouteNames.REPOSITORY_DETAIL,params:repo});以后要改路径只需要在RouteNames.ts里改一处就行了。8.4 页面转场动画HarmonyOS 的 router 支持自定义页面切换的动画效果。如果你觉得默认的滑动动画太朴素可以自定义滑入方向、速度曲线、持续时间、背景模糊等。比如你想做一个从底部弹出的分享面板就可以用 modal 配合自定义动画来实现而不是用 router 跳转——因为分享面板本质上是当前页面的一个叠加视图不是独立的页面。再比如你想让从列表到详情的转场更有电影感可以用 hero 动画Hero Animation列表里的那张缩略图在跳转过程中飞到详情页的大图位置上让用户有一种视觉连续性体验非常丝滑。8.5 深度链接Deep Link如果你的 App 支持被外部链接唤醒比如用户在浏览器里点击一个 GitHub 仓库链接你的 App 就能直接打开并跳转到对应的仓库详情页——这就是深度链接。实现深度链接需要配置 App 的module.json5中的skills字段声明你的 App 响应哪些 URL 协议。当 App 被外部链接拉起时会触发UIAbility的onNewWant生命周期回调你可以在那里解析链接参数然后跳转到对应页面。这个功能做起来比普通路由复杂一些但用户一旦用上就会觉得非常爽——点击链接直接进 App比先复制链接、再切 App、再粘贴搜索这个流程快了不止一点点。8.6 路由与状态管理的协同当 App 页面多了之后状态管理会变成一个头疼的问题。比如你在详情页 star 了一个仓库然后返回到列表页——列表页怎么知道 star 状态变了需要重新拉接口刷新吗这里就涉及到路由和状态管理的协同设计了。你可以用 AppStorage 或 GlobalState 统一管理用户是否已 star 这个仓库详情页和列表页都读同一个数据源。在router.back()的时候带上一个标志位告诉列表页数据有变化需要刷新。用Link或Watch装饰器监听状态变化自动触发 UI 更新。路由只是导航工具真正的体验取决于它和状态管理的配合程度。