
📖 引言
前七篇我们学习了 ArkTS 语言、声明式 UI、基础组件和状态管理,已经能够构建单个页面的应用了。但真实的应用不可能只有一个页面——首页点进去是列表,列表点进去是详情,还有设置页、个人中心等等。
页面之间怎么跳转?跳转的时候怎么传数据?返回的时候怎么把结果带回来?页面栈是怎么管理的?这些都是路由导航要解决的问题。
你可能会问:不就是 router.pushUrl 吗?有什么好讲的?
如果你只是简单地从 A 跳到 B,那确实很简单。但实际开发中,你会遇到各种各样的问题:
- 跳转参数怎么传?对象类型的参数怎么序列化?
- 返回上一页的时候,怎么通知上一页刷新数据?
- 怎么跳转到指定页面,并清除中间的页面栈?
- 路由守卫怎么做?未登录怎么跳转到登录页?
- 页面切换动画怎么自定义?
这些问题在「民族图鉴」项目中都有实际的应用场景。比如从民族列表页跳转到详情页,要传民族 ID;从详情页返回来,列表页的收藏状态要更新。
本文将以「民族图鉴」项目为载体,从基础的页面跳转,到参数传递,到页面栈管理,再到路由设计模式,系统讲解 HarmonyOS 应用的路由导航。
🎯 学习目标
完成本文后,你将能够:
- ✅ 掌握页面跳转的多种方式与区别(push/replace/back)
- ✅ 熟练掌握页面间参数传递的各种方式
- ✅ 理解页面栈的管理机制
- ✅ 掌握返回传参与结果回调的实现
- ✅ 学会命名路由与路由表的设计
- ✅ 理解路由守卫与权限控制的实现思路
- ✅ 写出结构清晰、易于维护的路由代码
💡 需求分析
为什么需要路由管理?
一个没有路由系统的应用,所有页面都堆在一起,跳转全靠手动管理状态。页面少了还好,页面多了之后:
- 跳转混乱:不知道从哪来,不知道往哪去
- 参数传递麻烦:各种全局变量传来传去
- 返回栈混乱:返回到哪个页面全靠猜
- 权限控制难:每个页面都要判断一遍登录状态
路由系统就是为了解决这些问题的——它统一管理页面的跳转、参数、栈、权限,让页面导航变得清晰可控。
「民族图鉴」的路由场景
「民族图鉴」虽然是个中等规模的应用,但路由场景很丰富:
| 场景 | 说明 | 跳转方式 |
|---|---|---|
| 启动页 → 首页 | 应用启动,Splash 页跳转到首页 | replace(不保留启动页) |
| 首页 → 详情页 | 点击民族卡片,进入详情页 | push(进栈) |
| 列表 → 详情页 | 百科列表点击进入详情 | push(进栈) |
| 个人页 → 收藏页 | 个人中心进入收藏记录 | push(进栈) |
| 个人页 → 设置页 | 个人中心进入设置 | push(进栈) |
| 设置 → 意见反馈 | 设置里进入反馈页 | push(进栈) |
| 详情页 → 返回 | 返回上一页 | back |
让我们从最基础的开始,一步步深入。
🛠️ 核心实现
步骤1:路由基础——页面注册与跳转方式
1.1 页面注册:main_pages.json
在讲跳转之前,先说说页面注册。
HarmonyOS 的路由是静态注册的——所有页面都必须在 main_pages.json 中声明,才能被路由系统找到。
// resources/base/profile/main_pages.json
{
"src": [
"pages/SplashPage",
"pages/Index",
"pages/EthnicDetailPage",
"pages/CollectionPage",
"pages/HistoryPage",
"pages/SettingsPage",
"pages/FeedbackPage"
]
}
为什么要静态注册?
| 原因 | 说明 |
|---|---|
| 安全 | 防止恶意代码随意跳转到内部页面 |
| 性能 | 编译时就能构建路由表,启动更快 |
| 包管理 | 原子化服务按需分发时,知道哪些页面在哪个包里 |
| 可追溯 | 所有页面都在配置文件里,一目了然 |
⚠️ 注意:页面路径必须完全一致,大小写敏感。写错了一个字母,运行时就会报错找不到页面。
1.2 三种跳转方式
HarmonyOS 的路由系统提供了三种核心跳转方式:
| 方式 | API | 页面栈变化 | 适用场景 |
|---|---|---|---|
| 推入 | pushUrl | 新页面入栈 | 正常跳转(列表→详情) |
| 替换 | replaceUrl | 替换当前栈顶 | 启动页→首页(不保留启动页) |
| 返回 | back | 出栈 | 返回上一页 |
页面栈示意图:
pushUrl 的情况:
[A, B] → pushUrl(C) → [A, B, C]
栈里有 A、B,跳转到 C,C 入栈
replaceUrl 的情况:
[A, B] → replaceUrl(C) → [A, C]
栈里有 A、B,用 C 替换掉 B
back 的情况:
[A, B, C] → back() → [A, B]
栈里有 A、B、C,返回后 C 出栈
1.3 pushUrl:最常用的跳转
pushUrl 是最常用的跳转方式——新页面入栈,用户可以返回来。
import router from '@ohos.router';
// 跳转到民族详情页
router.pushUrl({
url: 'pages/EthnicDetailPage',
params: {
ethnicId: '01'
}
});
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
| url | string | 目标页面路径,和 main_pages.json 里的一致 |
| params | Object | 跳转参数,目标页面通过 getParams() 获取 |
1.4 replaceUrl:替换当前页
replaceUrl 会替换掉当前栈顶的页面,用户返回的时候不会回到被替换的那个页面。
// 启动页跳转到首页
// 用 replaceUrl,这样用户按返回键不会回到启动页
router.replaceUrl({
url: 'pages/Index'
});
什么时候用 replaceUrl?
- 启动页 → 首页(启动页用完就扔,不保留)
- 登录页 → 首页(登录完就不用回去了)
- 引导页 → 首页(引导页只看一次)
这些场景的共同点:目标页面之后,不需要再回到当前页面。
1.5 back:返回上一页
back 用于返回上一页,就是栈顶出栈。
import router from '@ohos.router';
// 简单返回
router.back();
// 返回指定页面(返回到栈中的某个页面)
router.back({
url: 'pages/Index'
});
// 返回并传参数
router.back({
url: 'pages/EthnicListPage',
params: {
refreshed: true
}
});
💡 back 也能传参! 很多人不知道,back 的时候也能带参数回去。下一节会详细讲。
1.6 路由底层原理:页面栈、路由表与路由匹配
了解了基础用法后,我们深入一点,看看路由系统的底层是怎么工作的。理解了原理,遇到问题才能快速定位。
1. 页面栈的实现原理
页面栈本质上是一个栈(Stack)数据结构,遵循后进先出(LIFO)的原则。
页面栈的数据结构示意:
┌─────────────────┐
│ 栈顶(当前页) │ ← push 入栈,pop 出栈
├─────────────────┤
│ 详情页 │
├─────────────────┤
│ 列表页 │
├─────────────────┤
│ 首页 │ ← 栈底(根页面)
└─────────────────┘
操作:
- pushUrl(详情页) → 详情页入栈,成为新的栈顶
- back() → 栈顶出栈,上一页成为新的栈顶
- replaceUrl(X) → 栈顶出栈,X 入栈(替换)
栈的关键特性:
- 只能从栈顶操作(入栈、出栈)
- 栈底是根页面,永远不会被弹出(除非应用退出)
- 当前显示的页面永远是栈顶页面
- 栈的深度就是当前打开的页面数量
2. 路由表的构建
HarmonyOS 的路由表是在编译时构建的,不是运行时动态生成的。
编译流程:
main_pages.json
↓
编译器解析所有页面路径
↓
构建静态路由表(Route Table)
↓
打包进应用安装包
↓
运行时直接查表,快速定位页面
为什么要静态构建路由表?
| 优势 | 说明 |
|---|---|
| 性能 | 运行时不需要动态查找,直接查表,跳转更快 |
| 安全 | 所有页面都是已知的,防止恶意跳转 |
| 包管理 | 原子化服务可以按页面分发,知道哪些页面在哪个包 |
| 可分析 | 编译时就能分析页面依赖关系 |
3. 路由匹配过程
当调用 router.pushUrl({ url: 'pages/EthnicDetailPage' }) 时,系统内部做了什么?
路由匹配流程:
1. 解析 URL → 'pages/EthnicDetailPage'
↓
2. 在路由表中查找匹配项
├─ 精确匹配:完全一致的路径
└─ 没有匹配 → 抛出错误(找不到页面)
↓
3. 找到对应页面的组件类
↓
4. 创建页面实例
↓
5. 传递 params 参数
↓
6. 页面入栈,触发生命周期
↓
7. 页面渲染显示
4. 参数传递的底层机制
路由参数是怎么从 A 页面传到 B 页面的?
参数传递机制:
A 页面调用 pushUrl({ params: { id: '01' } })
↓
参数对象被序列化(JSON.stringify)
↓
存入路由系统的参数缓冲区
↓
B 页面创建完成
↓
B 页面调用 getParams()
↓
从缓冲区取出参数,反序列化(JSON.parse)
↓
返回给调用方
为什么参数必须可序列化?
- 参数要跨页面传递,需要经过序列化/反序列化
- 函数、Symbol、循环引用的对象无法序列化
- 所以传参会有限制,这也是为什么推荐传 ID 而不是传大对象
步骤2:参数传递——页面间的数据通信
2.1 push 时传参 + getParams 接收
这是最常用的参数传递方式:跳转的时候把参数带上,目标页面取出来。
发送方(A 页面):
// pages/EthnicListPage.ets
import router from '@ohos.router';
// 跳转到详情页,带上民族 ID
navigateToDetail(ethnicId: string): void {
router.pushUrl({
url: 'pages/EthnicDetailPage',
params: {
ethnicId: ethnicId
}
});
}
接收方(B 页面):
// pages/EthnicDetailPage.ets
import router from '@ohos.router';
@Entry
@Component
struct EthnicDetailPage {
@State ethnic: EthnicGroup | undefined = undefined;
aboutToAppear(): void {
// 获取路由参数
const params = router.getParams() as Record<string, string>;
if (params?.ethnicId) {
// 根据 ID 查询民族信息
this.ethnic = getEthnicById(params.ethnicId);
}
}
build() {
// ...
}
}
注意事项:
- params 是 any 类型:要自己做类型断言,最好定义好接口
- 参数要可序列化:不能传函数、不能传循环引用的对象
- 参数大小有限制:不要传太大的数据(建议几 KB 以内)
- aboutToAppear 中获取:在页面即将出现的时候获取参数
2.2 返回时传参:back + params
很多人不知道,返回的时候也能传参数。比如从详情页返回列表页,告诉列表页"收藏状态变了,刷新一下"。
发送方(详情页,返回时):
// pages/EthnicDetailPage.ets
// 返回到列表页,带上刷新参数
goBackAndRefresh(): void {
router.back({
url: 'pages/EthnicListPage',
params: {
needRefresh: true,
ethnicId: this.ethnic?.id,
isFavorite: this.isFavorite
}
});
}
接收方(列表页):
// pages/EthnicListPage.ets
import router from '@ohos.router';
@Entry
@Component
struct EthnicListPage {
@State favoriteStatusMap: Record<string, boolean> = {};
// 页面每次出现时都会调用
onPageShow(): void {
// 检查有没有返回参数
const params = router.getParams() as Record<string, Object>;
if (params?.needRefresh) {
// 刷新收藏状态
this.refreshFavoriteStatus();
}
}
}
⚠️ 注意:back 传参的时候,目标页面要用
onPageShow来接收,而不是aboutToAppear。因为返回的时候,页面已经创建过了,不会再走 aboutToAppear,但每次显示都会走 onPageShow。
2.3 全局状态:@StorageLink
如果数据需要在很多页面间共享,而且需要持久化,可以用 @StorageLink。
// 所有需要这个状态的页面,都用同一个 key
@StorageLink('current_language') currentLanguage: AppLanguage = AppLanguage.ZH_CN;
一个页面改了,所有页面自动同步,而且重启应用还在。
适用场景:
- 主题设置
- 语言设置
- 用户登录状态
- 全局配置
不适用场景:
- 临时的跳转参数(用完就没用了)
- 大量的数据(Preferences 不适合存大数据)
2.4 Service 单例模式
复杂的业务数据,比如收藏列表、浏览历史,放在 Service 层,用单例模式管理。
// services/StorageService.ets
export class StorageService {
private static instance: StorageService;
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
private favorites: string[] = [];
async toggleFavorite(ethnicId: string): Promise<boolean> {
// ... 切换收藏状态
}
isFavorite(ethnicId: string): boolean {
return this.favorites.includes(ethnicId);
}
}
所有页面都调用同一个 Service 实例,数据是共享的。这是中大型应用最常用的方式。
2.5 路由传参的三种方式深度对比
HarmonyOS 路由系统中,参数传递主要有三种方式:query 方式、params 方式、state 方式(通过全局状态)。每种方式有各自的适用场景。
方式一:params 传参(最常用)
// 发送方
router.pushUrl({
url: 'pages/EthnicDetailPage',
params: {
ethnicId: '01',
source: 'home'
}
});
// 接收方
const params = router.getParams() as Record<string, string>;
const ethnicId = params?.ethnicId;
特点:
- 参数和 URL 分离,不体现在 URL 上
- 支持多种类型(字符串、数字、布尔、对象、数组)
- 数据量较小(建议 < 10KB)
- 需要可序列化
方式二:URL query 传参
// 发送方:把参数拼在 URL 后面
router.pushUrl({
url: 'pages/EthnicDetailPage?ethnicId=01&source=home'
});
// 接收方:需要自己解析 URL
const params = router.getParams() as Record<string, string>;
// query 参数也会被解析到 params 中
const ethnicId = params?.ethnicId;
特点:
- 参数在 URL 上,一目了然
- 只能传递字符串类型
- 适合简单的、少量的参数
- 可以直接分享 URL(类似网页的 query string)
方式三:全局 state 传参
// 方式A:@StorageLink(持久化)
@StorageLink('current_ethnic_id') currentEthnicId: string = '';
// 方式B:Service 单例(内存中)
StorageService.getInstance().setCurrentEthnic(ethnic);
// 方式C:AppStorage(全局状态)
AppStorage.Set('currentEthnic', ethnic);
特点:
- 不通过路由传递,数据存在全局
- 数据量大也没关系
- 可以传任意类型(包括函数,不推荐)
- 多个页面都能访问
三种方式对比表:
| 对比项 | params 传参 | URL query | 全局 state |
|---|---|---|---|
| 数据类型 | 任意可序列化类型 | 只能字符串 | 任意类型 |
| 数据量 | 小(<10KB) | 很小(URL长度限制) | 大 |
| URL可见 | ❌ | ✅ | ❌ |
| 持久化 | ❌ | ❌ | 可选 |
| 多页面共享 | ❌ | ❌ | ✅ |
| 适用场景 | 页面跳转传参 | 简单标记、分享链接 | 复杂业务数据 |
| 「民族图鉴」使用场景 | 详情页传民族ID | 来源页面标记 | 收藏、历史记录 |
「民族图鉴」的选型实践:
| 场景 | 选用方式 | 原因 |
|---|---|---|
| 首页→详情页,传民族ID | params | 数据量小,跳转专用 |
| 详情页→列表页,通知刷新 | back params | 返回时传结果 |
| 收藏状态,多页面共享 | Service 单例 | 业务数据,需要共享 |
| 主题设置,全局生效 | @StorageLink | 需要持久化,全局共享 |
| 搜索关键词,分享链接 | URL query(可选) | 需要体现在URL上 |
2.6 参数传递方式对比总结
| 方式 | 适用场景 | 数据量 | 持久化 | 双向 |
|---|---|---|---|---|
| push params | 页面跳转时传参 | 小(<10KB) | ❌ | 单向(A→B) |
| back params | 返回时传结果 | 小 | ❌ | 单向(B→A) |
| URL query | 简单参数、分享链接 | 很小 | ❌ | 单向 |
| @StorageLink | 全局设置、用户偏好 | 小 | ✅ | 双向(全局共享) |
| Service 单例 | 复杂业务数据 | 中 | 可选(自己实现) | 双向(全局共享) |
选型建议:
- 跳转带个 ID → push params
- 返回带个结果 → back params
- 需要分享链接 → URL query
- 全局设置 + 要持久化 → @StorageLink
- 业务数据 + 很多页面用 → Service 单例
步骤3:页面栈管理
3.1 什么是页面栈?
页面栈是路由系统的核心概念。所有打开的页面,按顺序压入栈中。
栈顶(当前页面)
│
▼
[首页] → [列表页] → [详情页]
▲
│
栈底
特点:
- 新页面从栈顶入栈(push)
- 返回时栈顶出栈(pop)
- 栈顶就是当前显示的页面
- 栈底是应用的根页面
3.2 栈长度与性能
页面栈不是无限的,HarmonyOS 对页面栈深度有限制(通常最多 32 个页面)。超过了可能会出问题。
什么时候栈会很深?
- 列表页 → 详情页 → 列表页 → 详情页 → 列表页…
- 这种循环跳转,栈会越来越深
解决方案:
- 对于循环跳转,考虑用 replaceUrl 而不是 pushUrl
- 或者跳转到指定页面,中间的清掉
3.3 清空栈:跳转到根页面
有时候需要返回到首页,把中间的页面都清掉。比如:
- 退出登录,回到登录页
- 某个操作完成后,回到首页
方法1:多次 back(简单粗暴)
// 不行,不知道有多少层
router.back();
router.back();
router.back();
方法2:back 到指定 URL
// 返回到首页,中间的都出栈
router.back({
url: 'pages/Index'
});
这个方法会一直出栈,直到找到指定 URL 的页面。
💡 注意:如果指定的 URL 不在栈里,会怎么样?会报错。所以要确保目标页面在栈中。
步骤4:路由命名与常量管理
4.1 为什么要管理路由路径?
如果跳转路径都是硬编码的字符串,散落在各个文件里:
// 文件 A 里
router.pushUrl({ url: 'pages/EthnicDetailPage' });
// 文件 B 里
router.pushUrl({ url: 'pages/EthnicDetailPage' });
// 文件 C 里
router.pushUrl({ url: 'pages/ethnicDetailPage' }); // 写错了!
问题来了:
- 容易写错,大小写、拼写错误
- 要改路径的时候,要找所有地方改
- 不知道总共有多少页面,哪些用了哪些
4.2 路由常量管理
最好的方式是统一管理路由路径,定义成常量,要用的时候引用常量。
// common/constants/RouteConstants.ets
/**
* 路由路径常量
* 统一管理所有页面路径,避免硬编码和拼写错误
*/
export class RouteConstants {
// ========== 主流程 ==========
static readonly SPLASH: string = 'pages/SplashPage';
static readonly INDEX: string = 'pages/Index';
// ========== 民族相关 ==========
static readonly ETHNIC_DETAIL: string = 'pages/EthnicDetailPage';
static readonly ETHNIC_LIST: string = 'pages/EthnicListPage';
// ========== 个人中心 ==========
static readonly PROFILE: string = 'pages/ProfilePage';
static readonly COLLECTION: string = 'pages/CollectionPage';
static readonly HISTORY: string = 'pages/HistoryPage';
static readonly SETTINGS: string = 'pages/SettingsPage';
static readonly FEEDBACK: string = 'pages/FeedbackPage';
// ========== 功能页面 ==========
static readonly MAP: string = 'pages/MapPage';
static readonly QUIZ: string = 'pages/QuizPage';
static readonly MUSIC: string = 'pages/MusicPage';
static readonly AI_CHAT: string = 'pages/AIChatPage';
static readonly IMAGE_TOOL: string = 'pages/ImageToolPage';
}
使用方式:
import { RouteConstants } from '../common/constants/RouteConstants';
// 跳转详情页
router.pushUrl({
url: RouteConstants.ETHNIC_DETAIL,
params: { ethnicId: id }
});
// 返回首页
router.back({
url: RouteConstants.INDEX
});
好处:
- 不会拼错(写错了编译报错)
- 改路径只改一个地方
- 所有路由一目了然
- IDE 自动补全
步骤5:路由守卫与权限控制
5.1 什么是路由守卫?
路由守卫就是在跳转之前做检查,满足条件才跳转,不满足就拦截。
常见的场景:
- 未登录 → 跳转到登录页
- 没权限 → 提示无权限
- 页面需要参数 → 参数不对就跳转到错误页
- 埋点统计 → 记录页面访问路径
路由守卫通常有两种:
- beforeEach:跳转前调用,可以拦截跳转
- afterEach:跳转后调用,用于埋点、统计等
5.2 完整的路由守卫实现(beforeEach + afterEach)
ArkUI 目前没有内置的路由守卫 API,但我们可以自己封装一个功能完整的 RouterHelper,支持 beforeEach 和 afterEach。
// utils/RouterHelper.ets
import router from '@ohos.router';
import { RouteConstants } from '../common/constants/RouteConstants';
/**
* 路由守卫回调类型
*/
type BeforeEachGuard = (
from: string,
to: string,
params?: Record<string, Object>
) => boolean | { redirect: string; params?: Record<string, Object> };
type AfterEachHook = (
from: string,
to: string,
params?: Record<string, Object>
) => void;
/**
* 路由工具类
* 封装跳转逻辑,支持 beforeEach/afterEach 守卫
*/
export class RouterHelper {
private static beforeEachGuards: BeforeEachGuard[] = [];
private static afterEachHooks: AfterEachHook[] = [];
/**
* 注册前置守卫
*/
static beforeEach(guard: BeforeEachGuard): void {
this.beforeEachGuards.push(guard);
}
/**
* 注册后置钩子
*/
static afterEach(hook: AfterEachHook): void {
this.afterEachHooks.push(hook);
}
/**
* 跳转页面(带守卫检查)
*/
static push(url: string, params?: Record<string, Object>): void {
const fromUrl = this.getCurrentUrl();
// 1. 执行所有 beforeEach 守卫
const guardResult = this.runBeforeEachGuards(fromUrl, url, params);
if (guardResult === false) {
// 守卫返回 false,拦截跳转
console.info(`[Router] 跳转被拦截: ${fromUrl} → ${url}`);
return;
}
let targetUrl = url;
let targetParams = params;
if (typeof guardResult === 'object' && guardResult.redirect) {
// 守卫返回重定向
targetUrl = guardResult.redirect;
targetParams = guardResult.params || params;
console.info(`[Router] 路由重定向: ${url} → ${targetUrl}`);
}
// 2. 执行跳转
router.pushUrl({ url: targetUrl, params: targetParams });
// 3. 执行 afterEach 钩子
this.runAfterEachHooks(fromUrl, targetUrl, targetParams);
}
/**
* 替换页面(带守卫)
*/
static replace(url: string, params?: Record<string, Object>): void {
const fromUrl = this.getCurrentUrl();
const guardResult = this.runBeforeEachGuards(fromUrl, url, params);
if (guardResult === false) {
return;
}
let targetUrl = url;
let targetParams = params;
if (typeof guardResult === 'object' && guardResult.redirect) {
targetUrl = guardResult.redirect;
targetParams = guardResult.params || params;
}
router.replaceUrl({ url: targetUrl, params: targetParams });
this.runAfterEachHooks(fromUrl, targetUrl, targetParams);
}
/**
* 返回上一页
*/
static back(url?: string, params?: Record<string, Object>): void {
const fromUrl = this.getCurrentUrl();
if (url) {
router.back({ url: url, params: params });
} else {
router.back();
}
// 返回的目标 URL 不太好确定,可以简单处理
// 实际项目中可以维护一个路由栈来精确追踪
setTimeout(() => {
const toUrl = this.getCurrentUrl();
this.runAfterEachHooks(fromUrl, toUrl, params);
}, 100);
}
/**
* 执行所有 beforeEach 守卫
*/
private static runBeforeEachGuards(
from: string,
to: string,
params?: Record<string, Object>
): boolean | { redirect: string; params?: Record<string, Object> } {
for (const guard of this.beforeEachGuards) {
const result = guard(from, to, params);
if (result === false || (typeof result === 'object' && result.redirect)) {
return result;
}
}
return true;
}
/**
* 执行所有 afterEach 钩子
*/
private static runAfterEachHooks(
from: string,
to: string,
params?: Record<string, Object>
): void {
for (const hook of this.afterEachHooks) {
try {
hook(from, to, params);
} catch (e) {
console.error('[Router] afterEach hook error', e);
}
}
}
/**
* 获取当前页面 URL
*/
private static getCurrentUrl(): string {
try {
const state = router.getState();
return state.path;
} catch (e) {
return '';
}
}
}
使用方式:
// 在应用启动时注册守卫(EntryAbility.ets 或 SplashPage)
import { RouterHelper } from '../utils/RouterHelper';
import { RouteConstants } from '../common/constants/RouteConstants';
// 注册登录守卫
RouterHelper.beforeEach((from, to, params) => {
// 需要登录的页面列表
const loginRequiredPages = [
RouteConstants.FAVORITES,
RouteConstants.PROFILE
];
if (loginRequiredPages.includes(to)) {
const isLoggedIn = checkLoginStatus();
if (!isLoggedIn) {
// 未登录,重定向到登录页,并带上原始目标地址
return {
redirect: RouteConstants.LOGIN,
params: { redirectUrl: to }
};
}
}
return true;
});
// 注册页面埋点
RouterHelper.afterEach((from, to, params) => {
// 上报页面访问埋点
reportPageView(from, to);
console.info(`[Router] 页面跳转: ${from} → ${to}`);
});
支持的守卫返回值:
| 返回值 | 含义 | 效果 |
|---|---|---|
true | 允许跳转 | 正常跳转 |
false | 拦截跳转 | 不跳转,停留在当前页 |
{ redirect: 'xxx' } | 重定向 | 跳转到指定页面 |
{ redirect: 'xxx', params: {} } | 重定向带参 | 跳转到指定页面并传参 |
5.3 「民族图鉴」中的权限场景
「民族图鉴」大部分页面是公开的,不需要登录。但有些功能(比如收藏同步、用户资料)可能需要登录:
| 页面 | 是否需要登录 |
|---|---|
| 首页 | 否 |
| 民族列表 | 否 |
| 民族详情 | 否 |
| 个人中心 | 否(游客模式也能看) |
| 收藏记录 | 是(但本地收藏不用) |
| 设置 | 否 |
目前「民族图鉴」是纯本地应用,不需要登录,所以路由守卫用得不多。但如果以后加了云端同步、用户系统,就需要了。
步骤6:命名路由与路由表最佳实践
6.1 为什么要管理路由路径?
如果跳转路径都是硬编码的字符串,散落在各个文件里:
// 文件 A 里
router.pushUrl({ url: 'pages/EthnicDetailPage' });
// 文件 B 里
router.pushUrl({ url: 'pages/EthnicDetailPage' });
// 文件 C 里
router.pushUrl({ url: 'pages/ethnicDetailPage' }); // 写错了!
问题来了:
- 容易写错,大小写、拼写错误
- 要改路径的时候,要找所有地方改
- 不知道总共有多少页面,哪些用了哪些
6.2 路由常量管理最佳实践
最好的方式是统一管理路由路径,定义成常量,要用的时候引用常量。
「民族图鉴」项目的路由常量定义:
// common/constants/RouteConstants.ets
/**
* 路由路径常量
* 统一管理所有页面路径,避免硬编码和拼写错误
*/
export class RouteConstants {
// ========== 启动页 ==========
static readonly SPLASH: string = 'pages/SplashPage';
// ========== 主Tab页面 ==========
static readonly HOME: string = 'pages/HomePage';
static readonly ENCYCLOPEDIA: string = 'pages/EncyclopediaPage';
static readonly MAP: string = 'pages/MapPage';
static readonly QUIZ: string = 'pages/QuizPage';
static readonly PROFILE: string = 'pages/ProfilePage';
// ========== 详情页 ==========
static readonly ETHNIC_DETAIL: string = 'pages/EthnicDetailPage';
// ========== 测验相关 ==========
static readonly QUIZ_PAGE: string = 'pages/QuizPage';
static readonly QUIZ_RESULT: string = 'pages/QuizResultPage';
static readonly WRONG_BOOK: string = 'pages/WrongBookPage';
// ========== 个人中心子页面 ==========
static readonly FAVORITES: string = 'pages/FavoritesPage';
static readonly COLLECTION: string = 'pages/CollectionPage';
static readonly LEARNING_HISTORY: string = 'pages/LearningHistoryPage';
static readonly SETTINGS: string = 'pages/SettingsPage';
static readonly ABOUT: string = 'pages/AboutPage';
static readonly PRIVACY: string = 'pages/PrivacyPage';
static readonly AGREEMENT: string = 'pages/AgreementPage';
static readonly FEEDBACK: string = 'pages/FeedbackPage';
// ========== AI功能 ==========
static readonly SOUL_TEST: string = 'pages/SoulTestPage';
static readonly SOUL_TEST_RESULT: string = 'pages/SoulTestResultPage';
// ========== 搜索 ==========
static readonly SEARCH: string = 'pages/SearchPage';
}
命名规范:
- 全大写下划线分隔
- 按模块分组,加注释分隔
- 名称语义化,一看就知道是什么页面
- 模块内的页面加模块前缀(如 QUIZ_RESULT、SOUL_TEST_RESULT)
使用方式:
import { RouteConstants } from '../common/constants/RouteConstants';
// 跳转详情页
router.pushUrl({
url: RouteConstants.ETHNIC_DETAIL,
params: { ethnicId: id }
});
// 返回首页
router.back({
url: RouteConstants.HOME
});
好处:
- 不会拼错(写错了编译报错)
- 改路径只改一个地方
- 所有路由一目了然
- IDE 自动补全
- 可以快速查找引用
6.3 路由参数常量管理
除了路由路径,参数名也建议统一管理:
// common/constants/RouteConstants.ets
export class RouteParams {
// 民族相关
static readonly ETHNIC_ID: string = 'ethnicId';
static readonly ETHNIC_NAME: string = 'ethnicName';
// 来源页面
static readonly SOURCE_PAGE: string = 'sourcePage';
// 测验相关
static readonly QUIZ_TYPE: string = 'quizType';
static readonly SCORE: string = 'score';
// 登录相关
static readonly REDIRECT_URL: string = 'redirectUrl';
}
使用:
// 传参
router.pushUrl({
url: RouteConstants.ETHNIC_DETAIL,
params: {
[RouteParams.ETHNIC_ID]: '01',
[RouteParams.SOURCE_PAGE]: 'home'
}
});
// 接参
const params = router.getParams() as Record<string, string>;
const ethnicId = params?.[RouteParams.ETHNIC_ID];
这样参数名也不会写错,改起来也方便。
步骤7:「民族图鉴」实战——完整的路由设计与流程图
让我们用「民族图鉴」项目来实践一下,看看一个完整的应用路由是怎么设计的。
7.1 页面清单与层级结构
「民族图鉴」总共有 20+ 个页面,按功能模块划分:
| 模块 | 页面路径 | 页面名 | 层级 | 说明 |
|---|---|---|---|---|
| 启动 | pages/SplashPage | 启动页 | L1 | 品牌 Logo,跳转首页 |
| 主Tab | pages/HomePage | 首页 | L2 | Tab1 - 首页推荐 |
| 主Tab | pages/EncyclopediaPage | 百科页 | L2 | Tab2 - 民族列表 |
| 主Tab | pages/MapPage | 地图页 | L2 | Tab3 - 民族分布地图 |
| 主Tab | pages/QuizPage | 测验页 | L2 | Tab4 - 知识测验 |
| 主Tab | pages/ProfilePage | 个人中心 | L2 | Tab5 - 个人中心 |
| 详情 | pages/EthnicDetailPage | 民族详情 | L3 | 民族详细信息 |
| 搜索 | pages/SearchPage | 搜索页 | L3 | 搜索民族 |
| 测验 | pages/QuizResultPage | 测验结果 | L3 | 测验得分展示 |
| 测验 | pages/WrongBookPage | 错题本 | L3 | 错题记录 |
| 个人 | pages/FavoritesPage | 收藏图鉴 | L3 | 收藏的民族列表 |
| 个人 | pages/CollectionPage | 我的图鉴 | L3 | 已解锁的民族 |
| 个人 | pages/LearningHistoryPage | 学习历史 | L3 | 浏览历史记录 |
| 个人 | pages/SettingsPage | 设置 | L3 | 应用设置 |
| 个人 | pages/AboutPage | 关于我们 | L4 | 应用介绍 |
| 个人 | pages/PrivacyPage | 隐私政策 | L4 | 隐私说明 |
| 个人 | pages/AgreementPage | 用户协议 | L4 | 协议内容 |
| 个人 | pages/FeedbackPage | 意见反馈 | L4 | 用户反馈 |
| AI | pages/SoulTestPage | 灵魂测试 | L3 | 趣味测试 |
| AI | pages/SoulTestResultPage | 测试结果 | L4 | 测试结果展示 |
层级说明:
- L1:启动页(一次性)
- L2:主 Tab 页面(5个,平级切换)
- L3:二级页面(从 Tab 页 push 进入)
- L4:三级页面(从二级页面 push 进入)
7.2 页面跳转流程图
「民族图鉴」页面跳转流程图:
┌──────────────┐
│ SplashPage │ 启动页
└──────┬───────┘
│ replaceUrl
▼
┌──────────────┐
│ HomePage │ 首页 (Tab)
│ Encyclopedia │ 百科 (Tab)
│ Map │ 地图 (Tab)
│ Quiz │ 测验 (Tab)
│ Profile │ 个人 (Tab)
└──────┬───────┘
│
┌──────┴───────────────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│EthnicDetail │ push │ Search │ push
│ Page │◄─────────►│ Page │
└──────┬───────┘ └──────┬───────┘
│ │
│ back │ back
▼ ▼
回到 Tab 页 回到 Tab 页
┌──────────────┐
│ QuizResult │ push
│ Page │◄────── QuizPage
└──────┬───────┘
│
▼
┌──────────────┐
│ WrongBook │ push
│ Page │
└──────────────┘
┌──────────────┐
│ Profile │ (Tab)
└──────┬───────┘
│
┌──────┼───────────────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Favorites Collection History Settings SoulTest
Page Page Page Page Page
│ │ │ │ │
│ │ │ │ ▼
│ │ │ │ SoulTestResult
│ │ │ │ Page
│ │ │ │
│ │ │ ├───────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
About Privacy Agreement Feedback
Page Page Page Page
跳转规则说明:
| 跳转关系 | 跳转方式 | 说明 |
|---|---|---|
| SplashPage → HomePage | replaceUrl | 启动页用完即弃,不保留 |
| Tab 之间切换 | Tab 切换 | 同一页面内的 Tab 切换,不入栈 |
| Tab → 二级页 | pushUrl | 正常入栈,可返回 |
| 二级页 → 三级页 | pushUrl | 继续入栈 |
| 返回上一级 | back | 出栈 |
| 详情页之间 | replaceUrl | 避免栈过深(可选优化) |
7.3 启动页跳转逻辑
// pages/SplashPage.ets
import router from '@ohos.router';
import { RouteConstants } from '../common/constants/RouteConstants';
@Entry
@Component
struct SplashPage {
aboutToAppear(): void {
// 延迟 2 秒跳转首页
// 用 replaceUrl,不保留启动页在栈中
setTimeout(() => {
router.replaceUrl({
url: RouteConstants.HOME
});
}, 2000);
}
build() {
// 启动页 UI...
}
}
为什么用 replaceUrl?
- 启动页是一次性的,用完就没用了
- 用户按返回键不应该回到启动页
- 所以替换掉,栈里不保留
7.4 列表页 → 详情页跳转
// 百科列表页
import router from '@ohos.router';
import { RouteConstants } from '../common/constants/RouteConstants';
import { RouteParams } from '../common/constants/RouteConstants';
// 跳转到详情页
navigateToDetail(ethnicId: string): void {
router.pushUrl({
url: RouteConstants.ETHNIC_DETAIL,
params: {
[RouteParams.ETHNIC_ID]: ethnicId,
[RouteParams.SOURCE_PAGE]: 'encyclopedia'
}
});
}
// 详情页接收参数
aboutToAppear(): void {
const params = router.getParams() as Record<string, string>;
if (params?.[RouteParams.ETHNIC_ID]) {
const ethnicId = params[RouteParams.ETHNIC_ID];
this.loadEthnicDetail(ethnicId);
}
}
7.5 个人中心的二级页面跳转
// ProfilePage.ets 中的跳转逻辑
navigateToFavorites(): void {
router.pushUrl({ url: RouteConstants.FAVORITES });
}
navigateToCollection(): void {
router.pushUrl({ url: RouteConstants.COLLECTION });
}
navigateToHistory(): void {
router.pushUrl({ url: RouteConstants.LEARNING_HISTORY });
}
navigateToSettings(): void {
router.pushUrl({ url: RouteConstants.SETTINGS });
}
这些都是 pushUrl,因为用户需要能返回到个人中心。
⚠️ 常见问题与解决方案
问题1:跳转报错,说找不到页面?
现象:
调用 router.pushUrl({ url: 'pages/XXX' }),运行时报错,说找不到页面。
常见原因:
| 原因 | 概率 | 说明 |
|---|---|---|
| 没在 main_pages.json 注册 | 40% | 最常见,忘了注册页面 |
| 路径写错了 | 30% | 大小写、拼写错误 |
| 层级不对 | 15% | 写了 pages/xxx 但实际路径不对 |
| 文件名和路径不一致 | 10% | 文件叫 MyPage.ets,但路径写的是 MyPage 不一样 |
| 编译缓存 | 5% | 加了页面但编译没更新 |
排查步骤:
第1步:确认 main_pages.json 里有这个页面吗?
↓ 有
第2步:路径和文件名完全一致吗?
- 大小写敏感!pages/MyPage 和 pages/mypage 不一样
- 别多写或少写了 pages/ 前缀
↓ 一致
第3步:文件存在吗?
- 路径对吗?ets/pages/ 下面有这个文件吗?
↓ 存在
第4步:Clean + Rebuild
- 有时候是缓存问题
问题2:getParams() 拿不到参数?
现象:
跳转的时候传了 params,但目标页面 getParams() 拿不到,或者是 undefined。
常见原因:
| 原因 | 说明 |
|---|---|
| 参数名写错了 | 两边的 key 不一样 |
| 接收时机不对 | 用了 aboutToAppear 但应该用 onPageShow |
| back 的时候拿不到 | back 传参要用 onPageShow 接收 |
| 参数类型不匹配 | 传的是数字,当成字符串用 |
| replace 的时候没传 | 用了 replace 但没传 params |
关键:区分 aboutToAppear 和 onPageShow
| 生命周期 | 调用时机 | 能不能拿到 params |
|---|---|---|
| aboutToAppear | 页面首次创建,即将显示 | ✅ 能(第一次 push 的参数) |
| onPageShow | 每次页面显示都调用(包括返回) | ✅ 能(包括 back 带的参数) |
| onPageHide | 每次页面隐藏 | ❌ |
| aboutToDisappear | 页面销毁前 | ❌ |
最佳实践:
- 第一次进入的参数 → aboutToAppear 里取
- 返回时带的参数 → onPageShow 里取
- 不确定就都在 onPageShow 里处理
问题3:页面栈太深了,怎么清理?
现象:
用户来回跳转,页面栈越来越深,担心性能问题或栈溢出。
解决方案:
方案1:用 replaceUrl 代替 pushUrl
对于循环跳转的场景(A→B→A→B…),用 replace 而不是 push:
// 不要这样:会无限入栈
// A → B → A → B → ...
router.pushUrl({ url: 'pageA' });
// 改成这样:替换当前页
router.replaceUrl({ url: 'pageA' });
方案2:back 到指定页面
如果要回到某个页面,中间的都清掉:
// 回到首页,中间的都出栈
router.back({
url: 'pages/Index'
});
方案3:清空栈重新来
如果层级太多,直接跳转到根页面,重新开始:
// 用 replace 多次,或者 back 到最底层
// 视具体情况而定
💡 经验法则:
- 正常的页面跳转(列表→详情)→ push
- 一次性的页面(启动页、登录页)→ replace
- 返回上一页 → back
- 回到指定页 → back({ url: ‘xxx’ })
问题4:路由参数大小有限制吗?
现象:
传了一个很大的对象作为参数,结果出问题了。
答案:有,有限制。
路由参数是通过序列化传递的,不能太大。
建议:
| 数据量 | 传递方式 |
|---|---|
| 小数据(ID、配置项) | 路由 params |
| 中等数据(几十 KB) | Service 单例 / 全局状态 |
| 大数据(图片、大数组) | 存文件/数据库,传路径或 ID |
最佳实践:传 ID,不传对象
// ❌ 不好:传整个对象,又大又容易序列化出错
router.pushUrl({
url: 'pages/Detail',
params: { ethnic: ethnicObject } // 整个对象传过去
});
// ✅ 好:传 ID,目标页面自己去查
router.pushUrl({
url: 'pages/Detail',
params: { ethnicId: ethnicObject.id } // 只传 ID
});
传 ID 的好处:
- 数据量小
- 不会有序列化问题
- 目标页面拿到的是最新数据(不是跳转那一刻的快照)
- 代码更解耦
问题5:怎么实现"返回并刷新"?
现象:
A 页面跳转到 B 页面,B 页面做了一些操作,返回 A 页面的时候,A 页面要刷新数据。
解决方案:
方案1:onPageShow 里刷新(最简单)
// A 页面
onPageShow(): void {
// 每次页面显示都刷新
// 简单粗暴,但可能没必要每次都刷
this.loadData();
}
缺点:每次显示都刷新,包括正常的前进后退,可能浪费性能。
方案2:back 时传参,有需要才刷新
// B 页面:返回时带个标记
router.back({
url: 'pages/PageA',
params: { needRefresh: true }
});
// A 页面:在 onPageShow 里判断
onPageShow(): void {
const params = router.getParams() as Record<string, boolean>;
if (params?.needRefresh) {
this.loadData();
}
}
优点:只有需要的时候才刷新,性能好。
方案3:Service 层共享状态(推荐)
// B 页面修改数据
StorageService.getInstance().toggleFavorite(ethnicId);
// A 页面用 @State + Service
onPageShow(): void {
// 从 Service 获取最新数据
this.favorites = StorageService.getInstance().getFavorites();
}
优点:数据统一在 Service 层,各页面保持一致。
💡 「民族图鉴」用的是方案3:收藏、浏览历史都存在 StorageService 里,页面显示的时候从 Service 取最新数据,简单可靠。
📝 本章小结
核心知识点
本文系统讲解了 HarmonyOS 应用的路由导航:
1. 路由基础
- 静态注册:所有页面必须在 main_pages.json 中声明
- 三种跳转:push(入栈)、replace(替换)、back(出栈)
- 页面栈:栈顶是当前页,入栈出栈管理页面
2. 参数传递
- push params:跳转时传参,目标页用 getParams() 接收
- back params:返回时传结果,用 onPageShow 接收
- @StorageLink:全局共享 + 持久化,适合设置项
- Service 单例:复杂业务数据的共享方式
3. 页面栈管理
- 栈深度限制:不要无限入栈
- back 到指定页面:清除中间页面
- replaceUrl:一次性页面用替换,不留栈
4. 路由最佳实践
- 路由常量统一管理,避免硬编码
- 传 ID 不传大对象
- 一次性页面用 replace
- 返回刷新用 Service 共享状态
5. 路由守卫
- 统一封装路由跳转,做权限检查
- 未登录跳转到登录页
- 可以自己封装 RouterHelper 工具类
最佳实践总结
✅ 路由路径用常量管理
// 定义常量
export class RouteConstants {
static readonly ETHNIC_DETAIL: string = 'pages/EthnicDetailPage';
}
// 使用常量
router.pushUrl({ url: RouteConstants.ETHNIC_DETAIL });
✅ 跳转传 ID,不传大对象
// ✅ 传 ID,目标页面自己查
router.pushUrl({
url: RouteConstants.DETAIL,
params: { id: item.id }
});
✅ 一次性页面用 replaceUrl
// 启动页、登录页、引导页
// 用完就不用回去了,用 replace
router.replaceUrl({ url: RouteConstants.INDEX });
✅ 返回传参用 onPageShow 接收
// 不要在 aboutToAppear 里等返回参数
// 要用 onPageShow
onPageShow(): void {
const params = router.getParams();
// 处理参数
}
✅ 业务数据用 Service 共享
// 多个页面都要用的业务数据
// 放到 Service 层,单例管理
// 每个页面显示的时候从 Service 取最新
StorageService.getInstance().getFavorites();
✅ 页面栈不要太深
正常的 3-5 层没问题
10 层以上要考虑优化
循环跳转用 replace,不要一直 push
下一步预告
在下一篇文章中,我们将:
- 📋 深入学习 List 列表组件的使用与原理
- 🔄 掌握 ForEach 和 LazyForEach 的区别
- 📐 学习 Grid 网格布局
- ⚡ 理解列表性能优化的关键技巧
- 🚀 实现高性能的民族列表页面
🔗 相关链接
- 项目源码: GitCode 仓库
- 路由导航: 官方文档
- router.pushUrl: 官方文档
- 页面路由: 官方指南
- 返回传参: 官方文档
💡 提示:路由导航看起来简单,但细节很多。很多开发者用了很久,还不知道 back 也能传参、不知道 onPageShow 的作用。把这些细节搞清楚了,遇到复杂的页面跳转需求,你才能从容应对。好的路由设计,能让应用的导航逻辑清晰、可维护性强;不好的路由设计,到处都是硬编码的字符串,改都不敢改。从一开始就养成好习惯,比以后重构省事多了。

48

被折叠的 条评论
为什么被折叠?



