Files
conti-docs/04-routing.md
T

105 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04. 路由方案
## 决策
使用 **[go_router](https://pub.dev/packages/go_router)**`^17.5.0`2026-08 快照,Flutter 官方维护),声明式路由 + 嵌套 `ShellRoute`,不使用 `Navigator 1.0` 命令式 push/pop 作为主路由方式。
## 依赖
```yaml
dependencies:
go_router: ^17.5.0
```
## 路由注册规则
- 每个 `feature_*` 包在自己的 `feature_xxx.dart`(对外唯一导出文件)里暴露一个 `List<RouteBase> buildXxxRoutes()` 函数,只声明属于自己的路由,不感知其他 feature。
- `core_router` 包负责把所有 feature 的路由函数聚合成最终的 `GoRouter` 实例,是唯一知道"全部路由长什么样"的地方。
- 路径命名统一用 `kebab-case`,前缀按业务域分组,例如 `/store/:storeId``/payment/confirm`
- 底部导航等常驻 UI 用 `ShellRoute`/`StatefulShellRoute` 包裹对应的 feature 路由,不在每个页面里重复搭一遍导航栏。
- 登录态校验统一在 `core_router` 聚合层用 `redirect` 实现,不在每个页面里各自判断 token 是否过期。
- 跨 feature 跳转只能传**可序列化参数**(path 参数、query 参数,或可序列化的 `extra`),不允许把一个 feature 内部的 Dart 类实例通过 `extra` 传给另一个 feature——这是 [01-project-structure.md](./01-project-structure.md) "Feature 间通信" 规则在路由层的具体落地。
## 参考链接
- [go_router 官方文档](https://pub.dev/packages/go_router)
- [go_router | Dart package](https://pub.dev/packages/go_router)
## 附录:go_router 是什么,日常怎么用
给还没接触过声明式路由的同学看的入门说明。
### 要解决的问题
`Navigator 1.0` 的命令式写法(`Navigator.push(context, MaterialPageRoute(...))`)在页面不多的时候很直观,但规模上来后有几个明显问题:
1. **深链接(deep link/ Web URL 支持差**:命令式 push 本质是"从当前页面跳到下一个页面",很难直接根据一个 URL 字符串恢复出正确的页面栈——比如从推送通知直接打开"门店详情页",命令式写法需要手动拼一串 `push` 调用重建整个栈。
2. **没有统一的登录拦截点**:每个需要登录态的页面都要自己在 `initState` 里判断要不要跳转到登录页,逻辑散落在各处。
3. **底部导航这种"多个 tab 各自维护自己的页面栈"的场景很难优雅表达**
**go_router** 是 Flutter 官方团队维护的声明式路由方案:路由表是一份**声明式配置**(一棵 `GoRoute` 树),当前 URL 决定当前应该显示什么页面栈,而不是"一步步 push 出来的"。因为路由是声明式的、和 URL 强绑定,深链接、Web 浏览器前进/后退、登录拦截都能用同一套机制解决。
### 核心概念
1. **`GoRoute`**:一条路由规则,`path` 是路径模板(支持 `:id` 这种参数),`builder`/`pageBuilder` 返回对应页面。
2. **`ShellRoute` / `StatefulShellRoute`**:包一层常驻 UI(比如带底部导航栏的外壳),内部嵌套的子路由切换时,外壳本身不重建;`StatefulShellRoute` 还能让每个 tab 各自保留自己的页面栈(切 tab 不丢失之前的浏览位置)。
3. **`GoRouterState`**:在 `builder` 里能拿到当前路由的 path 参数(`state.pathParameters`)、query 参数(`state.uri.queryParameters`)、`extra` 对象。
4. **`redirect`**:每次路由变化前会先跑一遍 `redirect` 回调,返回非空字符串就强制跳转——这是实现"未登录访问需要登录的页面 → 自动跳登录页"的地方。
5. **`context.go()` / `context.push()`**`go` 是替换当前路由(浏览器前进后退语义),`push` 是在当前栈上叠加一层(可以 `pop` 回去)——日常最容易混淆的两个 API,选错会导致返回键行为不符合预期。
### 使用示例(底部导航 + 门店详情页 + 登录拦截)
```dart
// packages/core_router/lib/src/app_router.dart
final goRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
return GoRouter(
initialLocation: '/store',
redirect: (context, state) {
final loggingIn = state.matchedLocation == '/login';
if (!authState.isLoggedIn && !loggingIn) return '/login';
if (authState.isLoggedIn && loggingIn) return '/store';
return null; // 不需要重定向
},
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
MainShell(navigationShell: navigationShell),
branches: [
StatefulShellBranch(routes: buildStoreRoutes()),
StatefulShellBranch(routes: buildPaymentRoutes()),
],
),
],
);
});
```
```dart
// packages/feature_store/lib/feature_store.dart
List<RouteBase> buildStoreRoutes() => [
GoRoute(
path: '/store',
builder: (context, state) => const StoreListPage(),
routes: [
GoRoute(
path: ':storeId', // 完整路径 /store/:storeId
builder: (context, state) {
final storeId = state.pathParameters['storeId']!;
return StoreDetailPage(storeId: storeId);
},
),
],
),
];
```
```dart
// 从任意页面跳转到门店详情
context.push('/store/${store.id}');
```
`buildStoreRoutes()` 只在 `feature_store` 包内声明,`app_router.dart` 里只 import 这个函数、不 import `feature_store` 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。