导航与路由
QORM 应用如何在场景(scene)之间跳转、在跳转时携带数据,以及这些数据相对于应用其余 状态处于什么位置。
场景栈
运行中的应用同一时刻只显示一个场景。当前显示哪个场景是运行时的属性,而非应用定义 的属性——manifest 只声明应用启动时打开的 entry 场景,之后的一切都由 navigate 动作步骤驱动。
运行时维护一个返回栈(back stack),记录你一路走来的场景。向前导航会把当前场景 压栈并显示目标场景;返回导航则把栈顶弹出并回到它。
entry: home
home 栈: []
→ navigate 到 profile 栈: [home] 显示: profile
→ navigate 到 settings 栈: [home, profile] 显示: settings
→ back 栈: [home] 显示: profile
→ back 栈: [] 显示: home
→ back 栈: [] 显示: home (空操作)
对空栈执行返回是空操作,因此在入口场景上按硬件/返回键永远不会让应用走进死胡同。导航到 当前已显示的场景、或导航到未知的场景 id,都会被忽略。
导航
navigate 步骤通过 id 指定目标场景(to),或弹出栈(back: true):
{ "type": "action", "id": "openProfile",
"steps": [ { "type": "navigate", "to": "profile" } ] }
{ "type": "action", "id": "back",
"steps": [ { "type": "navigate", "back": true } ] }
to 本身也可以是绑定表达式——"to": "{{ state.nextScene }}"——因此一个动作就能实现 动态路由。
页面过渡
每次导航都会记录一个方向——向前 navigate 记为 push,返回记为 pop。客户端每帧 读取一次该方向(读取后即清除),以播放对应的页面过渡:向前 push 时新场景从尾侧边缘滑入, pop 时向反方向滑回。方向纯属表现层,绝不影响状态。
导航参数——route.*
一个 navigate 步骤可以携带路由参数:在派发时计算、附着到目标场景上的具名值。目标 场景通过 route.* 命名空间读取它们,与 state.*、viewport.*、t.* 并列。
在 params 下声明(参数名 → 值表达式):
{ "type": "navigate", "to": "profile",
"params": { "userId": "{{ userId }}", "name": "{{ name }}" } }
每个表达式在动作的上下文中求值一次,因此可以读取动作的调用参数(如上例)、state.* 或作用域内的任何东西。求得的带类型的值即成为目标场景的 route。
目标场景用 {{ route.<名字> }} 绑定它们:
{ "type": "text", "text": "{{ route.name }}" }
{ "type": "text", "text": "User id: {{ route.userId }}" }
缺失的键解析为 nil(渲染为空文本),因此没有携带某个参数就到达的场景会优雅降级,而不会 报错。
参数随栈帧走
路由参数是帧局部(frame-local)的:它属于显示该场景的那一个具体栈帧,而不属于场景 id。向前导航时,当前场景连同其当前 route一起压栈;返回时两者一起恢复。因此从详情页 返回,会把上一屏原样放回——参数也一并恢复。
home (route: {}) → openProfile(userId=u-101)
profile (route: {userId:u-101}) → openProfile(userId=u-102) [继续下钻]
profile (route: {userId:u-102}) → back
profile (route: {userId:u-101}) ← 恢复更早那个帧的 route
home (route: {}) ← 再 back 恢复入口的空 route
入口场景从一个空 route 开始({},永不为 nil)。
场景局部 route vs. 全局 state
QORM 有两个截然不同的数据存放处,而导航正是二者边界最关键的地方:
globalState(state.*) | 路由参数(route.*) | |
|---|---|---|
| 作用范围 | 所有场景共享的一个存储 | 仅当前栈帧 |
| 生命周期 | 整个应用会话 | 该帧位于栈上期间 |
| 由谁写入 | state.* 动作步骤、http.* 结果 | navigate 步骤的 params |
| 如何读取 | {{ state.x }} | {{ route.x }} |
| 在哪声明 | qorm.json 的 globalState.schema | 每次导航临时指定 |
全局 state 用于存放跨越单个屏幕、或被多个屏幕共享的数据——登录用户、购物车、缓存 列表、当前主题/语言。路由参数用于存放那些说明这是哪一个实例的小小标识符——某个 profile 屏正在展示的 userId、某个详情屏打开的订单 id。路由参数是 QORM 里函数实参的 类比:它是调用方告诉目标屏该渲染什么的方式,而无需改动其他屏幕都能看见的共享状态。
经验法则:如果返回时应当把它忘掉,它就是路由参数;如果应当持久保留,它就属于全局 state。
URL 路由(已实现)
内存中的场景栈会镜像到浏览器地址栏,于是深链 URL 与浏览器的前进/后退按钮都从同一个 模型里自然导出。URL 用查询串编码当前场景及其路由参数:
/?scene=profile&userId=u-101&name=Ada
│ │ └────┬─────┘
│ │ └── 路由参数 → route.userId、route.name
│ └── 场景 id → "profile" 场景
└── 入口场景就是 "/"
规则如下:
RoutePath—— 运行时把当前场景 + 路由参数渲染成这个路径(入口场景且无参数时为
/;键会排序以保证稳定)。它是"地址栏该显示什么"的唯一真相来源。
- 深链入口 —— 加载
/?scene=<id>&k=v会在首次渲染之前把运行时导航到该场景,于是
页面直接打开到那里,且查询参数绑定到 route.*。未知场景 id 会被忽略,回落到入口场景。
- 地址栏同步 —— 每次导航都会把它的
RoutePath送给客户端(/event响应的
X-Qorm-Route 头,以及 SSE/轮询负载里的 route 字段)。当它变化时客户端 history.pushState,于是 URL 无需刷新就跟随导航。
- 前进 / 后退 —— 浏览器的历史移动会把目标 URL 的场景 + 参数回报给服务端
(POST /navigate,与 /event 一样有人侧 token 校验),由它驱动运行时对齐。回到上一帧 会弹栈(恢复其参数);前进则压栈。
来自 URL 的路由参数值是字符串(查询串是无类型的),所以通过深链到达的场景看到的 route.userId 是 "u-101"。由于路由参数是 QORM 中函数实参的类比,把标识符作为路由参数 传递(而不是塞进全局 state)的应用,可以免费获得可分享、可刷新的深链。
路由守卫
一旦 URL 能指向任意场景,"只有登录用户才能看到这个页面"就不再是导航动作能保证的 事情了——守卫必须长在目的地上。场景在 root 旁边声明自己的前置条件:
{
"type": "scene",
"id": "checkout",
"guard": {
"condition": "{{ state.computed.signedIn }}",
"redirect": "signin",
"params": { "next": "{{ 'checkout' }}" }
},
"root": { "type": "column", "id": "root", "children": [] }
}
condition必填——一个作用于场景作用域(state.*、state.computed.*、t.*、
viewport.*、route.*)的 {{ … }} 表达式。为真即放行。缺少 condition 的守卫 会带着加载期错误被整个丢弃,而不是悄悄放所有人进去。
redirect是守卫不通过时改去的场景 id。它是字面 id,不是绑定。不写它则这次
导航直接被拒绝——你留在原地。拒绝在任何路径上都是 fail-closed 的,入口场景也 不例外:见下面的"无处可去时"。
params在守卫触发时、在你正要离开的那个场景的上下文中求值,并成为目标场景的
{{ route.* }}——这是携带"办完之后回到这里"提示的常用手段。被拒绝的那次导航自己 的参数不会跟着重定向走。
生效范围
守卫在每一条进入路径上都会跑:动作的 navigate 步骤、浏览器的前进/后退、直接 深链进入该场景、back: true 的 navigate,以及首次渲染时的入口场景。因此受保护的 路由无法靠手敲 URL 抵达,而这项检查只写在一处,不必在每个会导航的动作里重复。
它在场景的 onEnter 之前运行,所以拉取私有数据的钩子不会为一个被拒之门外的 访客触发。
关于后退栈的两个细节:
- 从入口场景发生的重定向是替换而不是压栈,因此后退不会回到一个你被拒绝过的
场景。
- 一次导航被拒绝则完全不动后退栈。
无处可去时
守卫可以直接拒绝而不是重定向:它没写 redirect、它的重定向成环,或者链条超过 跳数上限。在一次导航上,这意味着"留在原地",这是安全的——你所在的场景本来就是被 允许的。
但在首次渲染上它不能这么解释,因为"原地"恰恰就是那个被拒绝的场景。于是运行时 会按以下顺序离开:
- 后退栈上最近的、守卫仍然放行的那一帧(被跳过的帧会被丢弃,这样一次后退不会又
走回那次拒绝);
- 若没有,则入口场景;
- 若还是没有——入口场景本身就是被拒绝的那个,且没有历史——会话进入阻断状态:
什么都不渲染,URL 保持为 /。
三种情况下被拒场景的 onEnter 都不会运行,而真正进入的那个场景会运行它自己的。
后退是一次进入,不是撤销
back: true 的 navigate 和其他路径一样要过守卫。那一帧是在你被允许时压入的;等你 回去时权限可能已经没了(令牌过期、同一个动作里先登出),后退绝不能成为唯一没人检查 的那扇门。
如果那一帧的守卫现在会重定向,就跟随它的重定向目标。如果那一帧被直接拒绝,就跳过它 继续往下弹——"后退"表达的是你要离开当前场景的意图,而"你不能进那里"的答案是下一个 你可以进的地方。如果整个栈都进不去,则保持栈不动、留在原地。
守卫会链式生效:重定向的目标自身也受守卫保护。链的上限是 8 跳,且一条重复访问同一 场景的链会拒绝本次导航而不是打转——加载器把指向自身或指向不存在场景的 redirect 报为错误,并对重定向成环给出诊断。
守卫与派生值
守卫会为自己单独重新求一次派生值。这一点对最常见的写法——先登录再导航的动作—— 至关重要:
{ "type": "action", "id": "signIn", "steps": [
{ "type": "state.set", "path": "user", "value": "{{ draftName }}" },
{ "type": "navigate", "to": "checkout" }
] }
派生值在一次派发内本来是冻结的(见表达式),所以读 {{ state.computed.signedIn }} 的守卫会依据登录前的视图判断,把用户当场弹回去。 正是这次私有刷新让上面这两步能正常工作。它不会被发布出去:navigate 之后的 步骤读到的仍是帧内稳定的那份视图,与别处一致。
examples/derived 端到端地演示了这条路径——一个以派生值 signedIn 为守卫条件的 结算场景,重定向到一个读取 {{ route.next }} 的登录场景。