ArkTS Navigation 路由实战:从 Router 迁移到 NavPathStack,打造企业级路由体系


为什么要抛弃 @ohos/router?

在 HarmonyOS NEXT(API 12+)之前,大多数开发者都用 @ohos/router 跳转页面:

typescript 复制代码
// 旧写法 ------ 字符串路由,无类型安全
router.push({ url: 'pages/Detail', params: { id: 123 } });
router.back();

这种方式有几个典型痛点:

问题 具体表现
无类型安全 url 字符串写错编译不报错,运行才崩
参数传递脆弱 参数通过 router.getParams() 取,容易 undefined
无法拦截跳转 无法做登录守卫、权限校验
多端适配困难 手机/平板/折叠屏布局难统一

从 API 9 开始,华为官方已将 Navigation + NavDestination 定为标准路由方案,API 12 后 @ohos/router 进入软性废弃阶段。本文就带你从旧方案完整迁移到新方案,并构建一套带登录守卫的企业级路由体系。


一、Navigation 核心架构

Navigation 的设计思路是「单 Activity + 多 Fragment」------整个应用只有一个 Navigation 根容器,所有页面通过 NavPathStack 管理入栈/出栈:

复制代码
EntryPage (Navigation)
├── 首页 (NavDestination: name="Home")
├── 详情页 (NavDestination: name="Detail")
├── 设置页 (NavDestination: name="Settings")
└── 登录页 (NavDestination: name="Login")

三个核心组件:

  • Navigation:根视图容器,承载 TitleBar + 内容区
  • NavDestination :每个「页面」的容器,替代旧的 @Entry @Component
  • NavPathStack:路由栈管理器,push/pop/replace 都由它来

2.1 创建全局路由栈

推荐用 AppStorage 存放全局路由栈,方便跨模块访问:

typescript 复制代码
// src/main/ets/common/router/AppRouter.ets
import { NavPathStack } from '@kit.ArkUI';

// 全局单例路由栈
export const appRouter = new NavPathStack();

// 注册到 AppStorage,供全局使用
AppStorage.setOrCreate('appRouter', appRouter);
typescript 复制代码
// src/main/ets/pages/EntryPage.ets
import { appRouter } from '../common/router/AppRouter';
import { routeMap } from '../common/router/RouteMap';

@Entry
@Component
struct EntryPage {
  // 从 AppStorage 获取全局路由栈
  @StorageLink('appRouter') navStack: NavPathStack = appRouter;

  build() {
    Navigation(this.navStack) {
      // 这里是首页内容(默认展示)
      HomeContent()
    }
    .navDestination(routeMap)  // 绑定路由表
    .mode(NavigationMode.Stack) // 手机用 Stack,平板可换 Auto
    .title('首页')
    .hideTitleBar(false)
  }
}

2.3 定义路由表(类型安全的关键)

typescript 复制代码
// src/main/ets/common/router/RouteMap.ets
import { NavDestinationBuilder } from '@kit.ArkUI';

// 路由名称枚举,杜绝字符串魔法值
export enum RouteName {
  HOME = 'Home',
  DETAIL = 'Detail',
  SETTINGS = 'Settings',
  LOGIN = 'Login',
  PROFILE = 'Profile',
}

// 路由表:name -> 组件构建函数
@Builder
export function routeMap(name: string, param: ESObject) {
  if (name === RouteName.DETAIL) {
    DetailPage({ itemId: param?.id as number })
  } else if (name === RouteName.SETTINGS) {
    SettingsPage()
  } else if (name === RouteName.LOGIN) {
    LoginPage()
  } else if (name === RouteName.PROFILE) {
    ProfilePage({ userId: param?.userId as string })
  }
}

💡 推荐做法 :将路由表的 @Builder 函数集中放在一个文件,所有页面组件在这里统一注册,避免循环依赖。


三、页面跳转实战

3.1 基础跳转:push / pop

typescript 复制代码
// src/main/ets/pages/HomePage.ets
import { appRouter } from '../common/router/AppRouter';
import { RouteName } from '../common/router/RouteMap';

@Component
struct HomeContent {
  build() {
    Column({ space: 16 }) {
      Text('首页').fontSize(24).fontWeight(FontWeight.Bold)

      // 跳转详情页,携带参数
      Button('查看商品详情')
        .onClick(() => {
          appRouter.pushPath({
            name: RouteName.DETAIL,
            param: { id: 10086, title: 'ArkTS实战课程' }
          });
        })

      // 跳转设置页(不携带参数)
      Button('去设置')
        .onClick(() => {
          appRouter.pushPathByName(RouteName.SETTINGS, null);
        })
    }
    .padding(20)
    .width('100%')
  }
}

3.2 目标页接收参数

typescript 复制代码
// src/main/ets/pages/DetailPage.ets
interface DetailParam {
  id: number;
  title: string;
}

@Component
struct DetailPage {
  // 方式一:通过组件属性接收(路由表传入)
  @Prop itemId: number = 0;

  // 方式二:通过 NavDestination 的 onReady 回调获取
  @State title: string = '';
  @State price: number = 0;

  build() {
    NavDestination() {
      Column() {
        Text(`商品ID: ${this.itemId}`)
        Text(`标题: ${this.title}`)
        
        Button('返回上一页')
          .onClick(() => {
            appRouter.pop();
          })

        Button('返回并传参给上一页')
          .onClick(() => {
            // pop 时携带结果,类似 Android 的 startActivityForResult
            appRouter.pop({ result: 'user_confirmed', itemId: this.itemId });
          })
      }
      .padding(20)
    }
    .title('商品详情')
    .onReady((context: NavDestinationContext) => {
      // 在 onReady 中安全获取路由参数
      const param = context.pathInfo.param as DetailParam;
      this.title = param?.title ?? '';
    })
  }
}

3.3 接收 pop 返回值

typescript 复制代码
// 在 HomeContent 中,等待 Detail 页返回结果
Button('查看并等待结果')
  .onClick(async () => {
    const result = await appRouter.pushPathByName(RouteName.DETAIL, { id: 1 }, true);
    // result 就是 detail 页 pop() 时传入的对象
    if (result && result.result === 'user_confirmed') {
      console.log('用户确认了商品:', result.itemId);
    }
  })

四、登录守卫:路由拦截器

这是企业级应用的必备能力------未登录用户访问受保护页面时,自动跳转到登录页,登录成功后回到原来想去的页面。

4.1 实现路由拦截器

typescript 复制代码
// src/main/ets/common/router/AuthGuard.ets
import { appRouter } from './AppRouter';
import { RouteName } from './RouteMap';

// 需要登录才能访问的页面
const PROTECTED_ROUTES: string[] = [
  RouteName.PROFILE,
  RouteName.SETTINGS,
  // ...其他受保护路由
];

export function setupAuthGuard() {
  appRouter.setInterception({
    willShow: (from: NavDestinationContext | NavBar, to: NavDestinationContext | NavBar,
                operation: NavigationOperation, animated: boolean) => {

      // 只拦截 NavDestination(页面跳转),跳过 NavBar(顶部导航)
      if (typeof to === 'string') {
        return;
      }

      const toName = (to as NavDestinationContext).pathInfo.name;

      // 判断目标页是否需要登录
      if (PROTECTED_ROUTES.includes(toName)) {
        // 检查登录状态(从 AppStorage 或 Preferences 取)
        const isLoggedIn = AppStorage.get<boolean>('isLoggedIn') ?? false;

        if (!isLoggedIn) {
          // 用户未登录,重定向到登录页
          // 同时记录原始目标,登录后可以跳回来
          AppStorage.setOrCreate('redirectAfterLogin', toName);

          // 替换当前跳转目标为登录页
          appRouter.pushPathByName(RouteName.LOGIN, null, false);

          // 返回 false 取消原跳转
          return;
        }
      }
    }
  });
}

4.2 在 EntryPage 中注册拦截器

typescript 复制代码
// EntryPage.ets
import { setupAuthGuard } from '../common/router/AuthGuard';

@Entry
@Component
struct EntryPage {
  @StorageLink('appRouter') navStack: NavPathStack = appRouter;

  aboutToAppear() {
    // 应用启动时注册路由拦截器
    setupAuthGuard();
  }

  build() {
    Navigation(this.navStack) {
      HomeContent()
    }
    .navDestination(routeMap)
    .mode(NavigationMode.Stack)
  }
}

4.3 登录成功后跳回原页面

typescript 复制代码
// LoginPage.ets 登录成功回调
function onLoginSuccess() {
  // 保存登录状态
  AppStorage.setOrCreate('isLoggedIn', true);

  // 取出登录前的目标页
  const redirect = AppStorage.get<string>('redirectAfterLogin');
  AppStorage.delete('redirectAfterLogin');

  if (redirect) {
    // 替换登录页,跳到原目标
    appRouter.replacePath({ name: redirect, param: null });
  } else {
    // 没有重定向目标,直接返回上一页
    appRouter.pop();
  }
}

实际 App 经常是底部 Tab + 每个 Tab 内部独立导航的架构,这时需要嵌套 NavPathStack

typescript 复制代码
// src/main/ets/pages/MainTabPage.ets

// 每个 Tab 拥有独立的路由栈
const homeStack = new NavPathStack();
const shopStack = new NavPathStack();
const mineStack = new NavPathStack();

@Entry
@Component
struct MainTabPage {
  @State currentTab: number = 0;

  build() {
    Tabs({ barPosition: BarPosition.End }) {
      // Tab 1: 首页(独立导航栈)
      TabContent() {
        Navigation(homeStack) {
          HomeContent()
        }
        .navDestination(homeRouteMap)
        .hideTitleBar(true)
      }
      .tabBar(BottomTabBar({ icon: $r('app.media.ic_home'), label: '首页', index: 0 }))

      // Tab 2: 商城(独立导航栈)
      TabContent() {
        Navigation(shopStack) {
          ShopContent()
        }
        .navDestination(shopRouteMap)
        .hideTitleBar(true)
      }
      .tabBar(BottomTabBar({ icon: $r('app.media.ic_shop'), label: '商城', index: 1 }))

      // Tab 3: 我的(独立导航栈)
      TabContent() {
        Navigation(mineStack) {
          MineContent()
        }
        .navDestination(mineRouteMap)
        .hideTitleBar(true)
      }
      .tabBar(BottomTabBar({ icon: $r('app.media.ic_mine'), label: '我的', index: 2 }))
    }
    .barHeight(60)
    .onChange((index: number) => {
      this.currentTab = index;
    })
  }
}

⚠️ 踩坑提醒 :嵌套 Navigation 时,每个 Tab 的 NavPathStack 必须是独立实例 ,不能共用全局的 appRouter,否则 Tab 切换时路由栈会互相干扰。


六、旧 Router 迁移速查表

如果你的项目已有大量 @ohos/router 代码,参考下表快速迁移:

旧写法(@ohos/router) 新写法(Navigation) 说明
router.push({ url: 'pages/X' }) navStack.pushPathByName('X', null) 基础跳转
router.push({ url: 'pages/X', params: {id: 1} }) navStack.pushPath({ name: 'X', param: {id: 1} }) 携带参数
router.back() navStack.pop() 返回上一页
router.back({ url: 'pages/Home' }) navStack.popToName('Home') 返回到指定页
router.clear() navStack.clear() 清空路由栈
router.getLength() navStack.size() 获取栈深度
router.getParams() context.pathInfo.param(onReady 中) 获取传入参数
router.replace({ url: 'pages/X' }) navStack.replacePath({ name: 'X', param: null }) 替换当前页

七、完整项目结构

复制代码
src/main/ets/
├── common/
│   └── router/
│       ├── AppRouter.ets        # 全局路由栈单例
│       ├── RouteMap.ets         # 路由表 + 路由名称枚举
│       └── AuthGuard.ets        # 路由拦截器(登录守卫)
├── pages/
│   ├── EntryPage.ets            # 根页面(Navigation 挂载点)
│   ├── MainTabPage.ets          # 底部 Tab 页(含嵌套 Navigation)
│   ├── HomePage.ets             # 首页内容
│   ├── DetailPage.ets           # 详情页(NavDestination)
│   ├── LoginPage.ets            # 登录页(NavDestination)
│   ├── SettingsPage.ets         # 设置页(NavDestination)
│   └── ProfilePage.ets         # 个人中心(NavDestination,受保护)
└── entryability/
    └── EntryAbility.ets         # 应用入口

八、踩坑总结

问题 原因 解决方案
navStack 为 undefined 导致崩溃 aboutToAppear 之前访问了路由栈 @StorageLink 确保响应式绑定,或在 onReady 后操作
pop 返回值拿不到 pushPath 第三个参数 animated 传了 false 异步等返回值时必须 pushPathByName(name, param, true)
嵌套 Tab 路由互相干扰 所有 Tab 共用了同一个 NavPathStack 每个 Tab 创建独立的 NavPathStack 实例
拦截器只触发一次 onReady 之后才调用 setInterception 在 EntryPage 的 aboutToAppear 中提前注册
NavDestination 页面白屏 路由表里没有注册该 name 检查 routeMap 函数中是否有对应的 if 分支
Router 和 Navigation 混用报错 部分旧页面还用 @Entry,被 Navigation 管理后冲突 迁移时统一去掉子页面的 @Entry 装饰器,只保留根页面的 @Entry

总结

Navigation + NavPathStack 是 HarmonyOS NEXT 路由的最终答案:

  • 类型安全:RouteName 枚举替代魔法字符串
  • 参数可靠:onReady 中统一取参,不再 undefined
  • 拦截能力:登录守卫、权限控制一行注册
  • 多端适配NavigationMode.Auto 自动判断手机/平板布局
  • 嵌套友好:Tab 内独立路由栈,互不干扰

如果你的项目还在用 @ohos/router,建议在下一个迭代中开始迁移,拖得越久历史包袱越重。

相关推荐
ONEDAY14 小时前
HarmonyOS 多 Product 构建实践:一套代码生成多个产物
harmonyos
TT_Close17 小时前
别劝退了!5秒搞定 Flutter 鸿蒙 FVM 起跑线
flutter·harmonyos·visual studio code
TrisighT18 小时前
ArkTS 列表滚动时为什么会闪现旧数据?我扒了 LazyForEach 的复用逻辑
harmonyos·arkts·arkui
MonkeyKing18 小时前
鸿蒙ArkTS深度剖析:ArkTS与TS/JS核心差异、静态强类型实战优势
typescript·harmonyos
TrisighT18 小时前
Electron鸿蒙PC上写日志文件,我被权限和路径坑了两次
electron·harmonyos
TrisighT2 天前
一个下午搞定 ArkTS 折叠面板?结果我从两点写到晚上九点
harmonyos·arkts·arkui
花椒技术4 天前
HJPusher / HJPlayer SDK 实践:我们为什么把直播推播链路拆成一套可复用能力
设计模式·harmonyos·直播
一维Ace5 天前
HarmonyOS ArkTS 按钮组件全解:Button、Toggle 状态交互实战
harmonyos
anyup6 天前
来简单聊聊鸿蒙开发,万元奖金的事~
前端·华为·harmonyos
Georgewu6 天前
【无测试机别害怕】华为云鸿蒙云手机南:从零到联调全流程详解
harmonyos