Expo Router 路由结构完全指南:从文件约定到 Stacks/Tabs 实战(基于 building-native-ui 技能文档)
【免费下载链接】agentic-awesome-skillsAAS Core is the local, agent-first control plane for complete catalog discovery, agent-owned selection, stack validation, and planning, backed by 2,400+ agentic skills. Includes CLI, local MCP, catalog, plugins, and Workbench.项目地址: https://gitcode.com/gh_mirrors/an/agentic-awesome-skills
导读
本篇以 AAS(agentic-awesome-skills)仓库中 Expo 官方building-native-ui技能的 route-structure.md 为核心骨架,系统讲解 Expo Router 的路由文件约定、动态路由、分组路由、Stacks 与 Tabs 嵌套、Array Routes 多栈共享、布局文件与 404 处理等关键机制。读完本文,你将掌握一套可直接落地的 Expo Router 工程目录组织规范,并能结合本仓库内 SKILL.md、tabs.md、search.md 等配套参考,搭建出目录清晰、URL 简洁、支持多 Tab 独立导航历史的原生应用。
一、文件约定:app目录的黄金法则
Expo Router 以文件系统作为路由声明,所有路由都必须放置在app目录下。以下约定是构建任何 Expo Router 应用的地基,来自 route-structure.md:
- 路由归属:路由文件一律放在
app目录。 - 动态路由:使用
[]表示动态段,例如[id].tsx匹配任意单段路径。 - 命名禁区:路由文件绝不能命名为
(foo).tsx——因为(foo)是分组语法,应改用(foo)/index.tsx。 - URL 分组:使用
(group)形式的目录来简化对外公开的 URL 结构(分组名不会出现在 URL 中)。 - 禁止混放组件:绝不把组件、类型或工具函数放进
app目录,它们应放在components/、utils/等独立目录。这是本技能反复强调的 anti-pattern。 - 纯路由目录:
app目录只应包含路由文件和_layout文件,且每个文件必须默认导出一个组件。 - 根路由兜底:确保应用始终存在匹配
/的路由,否则应用会呈现空白页(该路由可以位于某个分组内)。 - Stack 必用布局:定义导航栈时一律通过
_layout.tsx文件完成。
此外,SKILL.md 的代码风格一节对文件组织提出了补充要求:
- 文件名一律使用 kebab-case,例如
comment-card.tsx; - 重构导航时必须删除旧的路由文件,避免残留路由干扰匹配;
- 文件名中禁止使用特殊字符;
- 在
tsconfig.json中配置路径别名(如@/components/...),重构时优先使用别名而非相对导入。
二、动态路由:方括号声明动态段
动态路由用方括号声明可变路径段,参数名与文件名一一对应:
app/ users/ [id].tsx # 匹配 /users/123、/users/abc [id]/ posts.tsx # 匹配 /users/123/posts[id].tsx可以匹配任意单段值(如123或abc),[id]/posts.tsx则是在动态段之下的子路由,两者可以并存并各司其职。
2.1 Catch-All 路由
当一段路径需要匹配任意层级时,使用[...slug]语法:
app/ docs/ [...slug].tsx # 匹配 /docs/a、/docs/a/b、/docs/a/b/cCatch-All 路由特别适合文档站、博客这类深度不确定的内容结构——无论/docs下嵌套多少级目录,都能被同一份代码接管并渲染。
三、Query 参数与 Pathname 读取
3.1 useLocalSearchParams:读取查询参数
在页面组件内通过useLocalSearchParams读取 URL 查询参数与动态段参数:
import { useLocalSearchParams } from "expo-router"; function Page() { const { id } = useLocalSearchParams<{ id: string }>(); }动态路由参数的命名与文件名严格对应:
[id].tsx→useLocalSearchParams<{ id: string }>()[slug].tsx→useLocalSearchParams<{ slug: string }>()
这意味着动态段和?key=value查询串在 Expo Router 中会被统一合并进 LocalSearchParams,读取方式完全一致。
3.2 usePathname:读取当前路径
需要获取当前完整路径时使用usePathname:
import { usePathname } from "expo-router"; function Component() { const pathname = usePathname(); // e.g. "/users/123" }这在实现“当前页高亮”“面包屑导航”“埋点上报”等场景时非常有用。
四、分组路由 (Group Routes):组织不改变 URL
用圆括号声明分组目录,分组名只用于组织代码,不会出现在 URL 中:
app/ (auth)/ login.tsx # URL: /login register.tsx # URL: /register (main)/ index.tsx # URL: / settings.tsx # URL: /settings分组的三个典型用途:
- 组织相关路由:把登录/注册归入
(auth),把主界面归入(main),让目录结构直接反映业务域; - 应用不同布局:每个分组都可以有自己的
_layout.tsx,从而对整组路由施加不同的导航容器或配置; - 保持 URL 简洁:URL 中不会出现
(auth)、(main)这类实现细节。
五、Stacks 与 Tabs 的嵌套结构:Tab 内的独立导航栈
当应用使用 Tabs 时,header 与页面标题必须设置在嵌套在每个 Tab 内部的 Stack 中。这样每个 Tab 都拥有独立的导航历史和各自的 header;而根布局通常不显示 header。
核心操作要点:
- 在 Tab 布局上把
headerShown设为false; - 使用
(group)路由简化公开 URL; - 结构调整时,可能需要删除或重构已有的路由文件以适配新结构。
标准结构示例:
app/ _layout.tsx — <Tabs /> (home)/ _layout.tsx — <Stack /> index.tsx — <ScrollView /> (settings)/ _layout.tsx — <Stack /> index.tsx — <ScrollView /> (home,settings)/ info.tsx — <ScrollView /> (shared across tabs)这里(home,settings)/info.tsx是跨 Tab 共享的页面——它同时隶属于 home 与 settings 两个分组,因此两个 Tab 都能 push 到它。
5.1 为什么 ScrollView 必须是第一个子组件
route-structure.md 的示例中每个页面都是<ScrollView />,这与 SKILL.md 的行为规范一致:
- 路由属于 Stack 时,其第一个子组件几乎总是带
contentInsetAdjustmentBehavior="automatic"的ScrollView; - 为响应式考虑,根组件一律包在滚动视图中,用
contentInsetAdjustmentBehavior="automatic"取代<SafeAreaView>,以获得更智能的安全区适配; FlatList、SectionList同样应设置contentInsetAdjustmentBehavior="automatic"。
这样从 tab 切换到页面内容时,滚动与安全区行为都保持原生观感。
六、Array Routes:多栈共享屏幕的高级布局
'(index,settings)'形式的 Array Route 用于创建多个 Stack,特别适合需要跨栈共享屏幕的 Tab 应用:
app/ _layout.tsx — <Tabs /> (index,settings)/ _layout.tsx — <Stack /> index.tsx — <ScrollView /> settings.tsx — <ScrollView />它需要一个带显式 anchor 路由的专用布局:
// app/(index,settings)/_layout.tsx import { useMemo } from "react"; import Stack from "expo-router/stack"; export const unstable_settings = { index: { anchor: "index" }, settings: { anchor: "settings" }, }; export default function Layout({ segment }: { segment: string }) { const screen = segment.match(/\((.*)\)/)?.[1]!; const options = useMemo(() => { switch (screen) { case "index": return { headerRight: () => <></> }; default: return {}; } }, [screen]); return ( <Stack> <Stack.Screen name={screen} options={options} /> </Stack> ); }关键机制解读:
Layout接收的segmentprop 就是当前激活的分组名(如(index)),通过segment.match(/\((.*)\)/)?.[1]!提取出index或settings;- 布局根据当前激活的屏幕动态渲染对应的
Stack.Screen,因此两个 Tab 共享同一个 Stack 容器,可自由 push 公共详情页; unstable_settings中为每个入口指定anchor(v4 中取代了initialRouteName),确保每个 Tab 都有明确的初始路由。
6.1 完整的生产级 App 结构示例
结合动态路由、Array Routes 与目录分离,一份完整结构如下:
app/ _layout.tsx — <NativeTabs /> (index,search)/ _layout.tsx — <Stack /> index.tsx — Main list search.tsx — Search view i/[id].tsx — Detail page components/ theme.tsx list.tsx utils/ storage.ts use-search.ts注意components/与utils/与app/平级——这正是“绝不把组件与工具放进 app 目录”约定的落地形态。i/[id].tsx展示了 Array Route 内部同样可以使用动态段来承载详情页。
七、布局文件(Layout Files):每个目录的导航容器
每个目录都可以有一个_layout.tsx,用于包裹该目录下的全部路由:
// app/_layout.tsx import { Stack } from "expo-router/stack"; export default function RootLayout() { return <Stack />; }// app/(tabs)/_layout.tsx import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs"; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <Label>Home</Label> <Icon sf="house.fill" /> </NativeTabs.Trigger> </NativeTabs> ); }7.1 与 NativeTabs 的组合(SDK 54/55)
第二个示例中的NativeTabs来自 tabs.md 推荐的expo-router/unstable-native-tabs。SDK 55+ 的推荐写法是组件化 API:
import { NativeTabs } from "expo-router/unstable-native-tabs"; export default function TabLayout() { return ( <NativeTabs minimizeBehavior="onScrollDown"> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Icon sf="house.fill" md="home" /> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge> </NativeTabs.Trigger> <NativeTabs.Trigger name="(search)" role="search"> <NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label> </NativeTabs.Trigger> </NativeTabs> ); }需要注意的规则(来自 tabs.md):
- 每个 Tab 都必须有一个 Trigger,且
NativeTabs.Trigger的name必须与路由名完全一致(包括圆括号,例如<NativeTabs.Trigger name="(search)">); - NativeTabs 不渲染 header,必须在每个 Tab 内部嵌套 Stack 来提供导航 header;
- Tabs 必须是静态的——运行时动态增删 Tab 会重挂载导航器并丢失状态;
- 搜索类 Tab 建议放在最后,便于与搜索栏结合。
7.2 与 JS Tabs 的差异速查
| JS Tabs | Native Tabs |
|---|---|
<Tabs.Screen> | <NativeTabs.Trigger> |
options={{ title }} | <NativeTabs.Trigger.Label> |
options={{ tabBarIcon }} | <NativeTabs.Trigger.Icon> |
tabBarBadgeoption | <NativeTabs.Trigger.Badge> |
| Props 驱动 API | 组件化 API |
| 内置 header | 需嵌套<Stack>提供 header |
八、路由设置(Route Settings):anchor 与 unstable_settings
通过导出unstable_settings来配置路由行为:
export const unstable_settings = { anchor: "index", };重要变更:在 Expo Router v4 中,initialRouteName被重命名为anchor。在 Array Routes 中,每个入口都可通过unstable_settings独立指定自己的 anchor 路由(见第六节的index: { anchor: "index" }),这是多栈共享布局能够正确初始化的关键。
九、404 处理:+not-found.tsx
创建+not-found.tsx文件处理所有未匹配的路由:
// app/+not-found.tsx import { Link } from "expo-router"; import { View, Text } from "react-native"; export default function NotFound() { return ( <View> <Text>Page not found</Text> <Link href="/">Go home</Link> </View> ); }该文件是 Expo Router 约定俗成的“兜底路由”——任何没有对应文件的 URL 都会落到这里,配合第一节的“根路由兜底”约定,应用永远不会出现空白页或死链。
十、实战要点汇总:把路由结构落到真实页面
10.1 页面标题放在 Stack 而非页面文本
SKILL.md 明确要求:一律使用导航栈标题(Stack title)而不是页面内的自定义文本元素。在 Stack 布局中通过Stack.Screen的 options 设置:
<Stack.Screen options={{ title: "Home" }} />10.2 在 Stack 布局中统一配置 header
结合 Array Route 与useLocalSearchParams的实际需求,一个带搜索与透明 header 的共享 Stack 布局通常写成:
// app/(index,search)/_layout.tsx import { Stack } from "expo-router/stack"; import { colors } from "@/theme/colors"; export default function Layout({ segment }) { const screen = segment.match(/\((.*)\)/)?.[1]!; const titles: Record<string, string> = { index: "Items", search: "Search" }; return ( <Stack screenOptions={{ headerTransparent: true, headerShadowVisible: false, headerLargeTitle: true, headerTitleStyle: { color: colors.label }, headerBackButtonDisplayMode: "minimal", }} > <Stack.Screen name={screen} options={{ title: titles[screen] }} /> <Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} /> </Stack> ); }10.3 搜索页与查询参数的配合
路由结构规划好后,搜索栏可以直接挂在 Stack header 上(详见 search.md):
<Stack.Screen name="index" options={{ headerSearchBarOptions: { placeholder: "Search", onChangeText: (event) => console.log(event.nativeEvent.text), }, }} />若搜索 Tab 使用role="search"的 NativeTabs,搜索栏会与 Tab 栏无缝集成,配合useLocalSearchParams即可实现“列表 → 详情”的完整检索闭环。
10.4 自定义 header 工具栏(iOS,SDK 55+)
对于需要 header 按钮的页面,Stack.Toolbar提供原生 iOS 工具栏(详见 toolbar-and-headers.md),它作为 Stack 的兄弟节点书写在页面组件内:
<> {/* ScrollView 必须是屏幕的第一个子组件 */} <ScrollView style={{ flex: 1 }} contentInsetAdjustmentBehavior="automatic"> {/* Screen content */} </ScrollView> <Stack.Screen.Title large>Folders</Stack.Screen.Title> <Stack.Toolbar placement="right"> <Stack.Toolbar.Button icon="folder.badge.plus" onPress={() => {}} /> <Stack.Toolbar.Button onPress={() => {}}>Edit</Stack.Toolbar.Button> </Stack.Toolbar> </>placement支持"left"(header 左侧)、"right"(header 右侧)与"bottom"(底部工具栏,默认值);注意placement="bottom"只能在屏幕组件内使用,不能写在布局文件中。
十一、常见误区与规避建议
结合 route-structure.md 与 SKILL.md,以下是实践中最容易踩的坑:
- 在
app目录混放组件/工具:这是最严重的 anti-pattern,会导致路由扫描到非页面文件而报错或行为异常;组件进components/,工具进utils/。 (foo).tsx文件命名:圆括号是分组保留语法,单个文件不能直接叫(foo).tsx,请用(foo)/index.tsx。- 忘记根路由:
app下没有匹配/的路由时应用为空白页,可将 index 放入某个分组内解决。 - Trigger name 不匹配:NativeTabs 中
name必须与路由名完全一致(含圆括号),如(search)。 - 重构不删旧路由:移动或重构导航时务必同步删除旧路由文件,否则会被意外匹配。
- JS Tab 与 Native Tab 混用:NativeTabs 不渲染 header,需要 header 就在每个 Tab 内嵌套 Stack,并在 Tab 布局关闭
headerShown。
结语
Expo Router 的app目录即路由,掌握文件约定、动态段、分组与 Array Routes,就等于掌握了整个导航体系的设计语言。本文内容以 route-structure.md 为骨架,并补充了 SKILL.md、tabs.md、search.md、toolbar-and-headers.md 中的配套约定。实际编码时,请以官方文档为准核对版本差异(尤其是 v4 中initialRouteName→anchor的更名,以及 SDK 54/55 之间 NativeTabs API 的差异),并始终遵循“ScrollView 作为首个子组件、Stack title 承载标题、组件与工具隔离在 app 之外”这三条核心纪律,即可构建出结构清晰、体验原生的 Expo 应用。
【免费下载链接】agentic-awesome-skillsAAS Core is the local, agent-first control plane for complete catalog discovery, agent-owned selection, stack validation, and planning, backed by 2,400+ agentic skills. Includes CLI, local MCP, catalog, plugins, and Workbench.项目地址: https://gitcode.com/gh_mirrors/an/agentic-awesome-skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考