核心问题
底部 Tab 页面内部如果需要跳转到一个全屏页面(不显示底部 Tab 栏),该如何组织导航结构?
常见方案有两种:
- 每个 Tab 内部嵌套独立的 Stack Navigator --- 子页面在 Tab 内部压栈,通过
headerShown: false或tabBarStyle: {display: 'none'}隐藏底部栏 - 子页面提升到与 Tab 同级的根栈 --- Tab 页本身不嵌套 Stack,需要全屏的页面直接注册在 Root Stack 中
本项目采用的是 方案二。
一个"简单"的需求引发的架构选择
产品说:「点这个入口,进一个详情页,底部 Tab 不要了。」
听起来很简单,但这个需求直接决定了整个导航骨架的组织方式。如果处理不好,后续每加一个全屏页面都要写隐藏/显示底部栏的逻辑,维护成本会越来越高。
导航层级结构
css
RootStack (NativeStackNavigator)
│
├── Auth ← 登录页(条件渲染)
│
├── Main ← 底部 Tab 容器(BottomTabNavigator)
│ ├── Tab A ← 纯页面组件,无嵌套 Stack
│ ├── Tab B
│ ├── Tab C
│ └── Tab D
│
├── /ModuleA/SubPage1 ← 与 Main 平级的全屏子页
├── /ModuleA/SubPage2
├── /ModuleB/SubPage1
└── ...
Main 作为 BottomTabNavigator,其内部的四个 Tab 页都是 裸页面组件 ,不包裹任何 Stack Navigator。所有需要全屏展示(隐藏底部 Tab)的子页面,统一注册在 Root Stack 中,与 Main 平级。
为什么这样设计
1. 底部 Tab 自动隐藏,零代码
React Navigation 中,BottomTabNavigator 只对其直接子 Screen 生效。子页面提升到 Root Stack 后,它们不属于 Tab Navigator 的管辖范围,底部 Tab 栏天然不会出现 ------ 无需任何额外的隐藏逻辑。
2. Tab 页保持轻量
Tab 页面只负责展示入口内容(列表、宫格、个人信息等),不承担路由管理职责。每个 Tab 对应的 component 就是一个纯函数组件,没有嵌套 Navigator 的复杂度。
3. 子页面统一管理,新增零侵入
所有子页面集中在一处注册(rootStackScreenRegistry.tsx),按模块分组后合并为扁平数组。新增子页面只需两步:声明类型 + 注册组件,不需要改动任何 Tab 页面代码。
子页面注册机制
类型声明
在 types.ts 中,每个模块有独立的参数类型定义,最终通过交叉类型合并到 RootStackParamList:
ts
type RootStackParamList = {
Auth: undefined;
Main: undefined;
} & HomeRootParamList &
ModuleARootParamList &
ModuleBRootParamList &
MeRootParamList;
每个子页面的路由名使用路径风格(如 /Home/FoodCertificate),参数统一为 {id?: string} | undefined,列表页忽略 id,详情页按需读取。
组件注册
rootStackScreenRegistry.tsx 按模块分组维护注册表:
ts
const SCREENS_MODULE_A: RootStackScreenEntry[] = [
{
name: '/ModuleA/SubPage1',
component: require('@/screens/ModuleA/SubPage1').default,
options: { title: '页面标题' },
},
// ...
];
export const ROOT_STACK_SCREEN_REGISTRY = [
...SCREENS_MODULE_A,
...SCREENS_MODULE_B,
// ...
];
Root Stack 创建时遍历该数组,逐个注册为 Stack.Screen:
tsx
<Stack.Screen name="Main" component={MainTabs} />
{ROOT_STACK_SCREEN_REGISTRY.map(({name, component, options}) => (
<Stack.Screen key={name} name={name} component={component} options={options} />
))}
Tab 页如何跳转子页面
由于子页面注册在 Root Stack 中,Tab 页面的 navigation.navigate() 可以直接跳转到它们 ------ React Navigation 会沿导航树向上查找匹配的 Navigator。
ts
// 在 Tab A 页面中
navigation.navigate('/ModuleA/SubPage1', { id: '123' });
类型系统通过 CompositeNavigationProp 保证 Tab 页面既能访问本 Tab 的路由,也能跳转 Root 级子页:
ts
type RootTabScreenProps<RouteName extends RootTabName> = {
navigation: CompositeNavigationProp<
BottomTabNavigationProp<RootTabParamList, RouteName>,
NavigationProp<RootStackParamList>
>;
};
这意味着在任何 Tab 页面中,IDE 都能自动补全所有可跳转的子页面路由名和参数,写错一个字母编译就会报错。
子页面的顶栏配置
所有子页面共享一套统一的顶栏样式,通过工厂函数生成:
ts
function defaultSubStackScreenOptions({ navigation }) {
return {
headerStyle: { backgroundColor: '#171933' },
headerTintColor: '#fff',
headerTitleStyle: { color: '#fff' },
headerTitleAlign: 'center',
...(navigation.canGoBack() ? {
headerBackVisible: false,
headerLeft: () => <CustomBackButton onPress={() => navigation.goBack()} />,
} : {}),
};
}
Main 和 Auth 页面单独设置 headerShown: false,其余子页面统一应用此配置。一处修改,全局生效。
平台双实现
鸿蒙平台因 react-native-screens 未原生化,Root Stack 降级为 JS Stack(@react-navigation/stack),Android/iOS 使用 Native Stack(@react-navigation/native-stack)。两套实现共享同一份注册表和配置,通过 .harmony.tsx 后缀由 Metro 按平台解析,业务代码无感知。
嵌套 Stack 的例外情况
唯一的例外是某个子页面内部嵌套了独立的 Stack.Navigator,承载多个内部子页面。这种情况下,嵌套的 Stack 作为 Root Stack 的一个 Screen 整体注册,内部路由对外不可见。
适用场景:某个子页面自身有一组内部导航(如多步表单、详情内的二级页面),且这些内部页面不需要被其他模块直接跳转。
小结
| 设计点 | 做法 |
|---|---|
| 子页面归属 | 提升到 Root Stack,与 Main 平级 |
| 底部 Tab 隐藏 | 天然生效,无需额外处理 |
| Tab 页职责 | 纯展示组件,不嵌套 Navigator |
| 子页面注册 | 集中在 registry 文件,按模块分组 |
| 路由类型 | 路径风格命名 + 交叉类型合并 |
| 跳转方式 | navigate 沿导航树自动向上查找 |
| 顶栏样式 | 统一配置函数,Main/Auth 单独处理 |
| 平台差异 | .harmony.tsx 后缀,共享注册表 |