QORMQORM v0.8.4 docs 开始使用 EN

导航与路由

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.jsonglobalState.schema每次导航临时指定

全局 state 用于存放跨越单个屏幕、或被多个屏幕共享的数据——登录用户、购物车、缓存 列表、当前主题/语言。路由参数用于存放那些说明这是哪一个实例的小小标识符——某个 profile 屏正在展示的 userId、某个详情屏打开的订单 id。路由参数是 QORM 里函数实参的 类比:它是调用方告诉目标屏该渲染什么的方式,而无需改动其他屏幕都能看见的共享状态。

经验法则:如果返回时应当把它忘掉,它就是路由参数;如果应当持久保留,它就属于全局 state。

URL 路由(已实现)

内存中的场景栈会镜像到浏览器地址栏,于是深链 URL 与浏览器的前进/后退按钮都从同一个 模型里自然导出。URL 用查询串编码当前场景及其路由参数:

/?scene=profile&userId=u-101&name=Ada
   │       │        └────┬─────┘
   │       │             └── 路由参数  → route.userId、route.name
   │       └── 场景 id                  → "profile" 场景
   └── 入口场景就是 "/"

规则如下:

/;键会排序以保证稳定)。它是"地址栏该显示什么"的唯一真相来源。

页面直接打开到那里,且查询参数绑定到 route.*。未知场景 id 会被忽略,回落到入口场景。

X-Qorm-Route 头,以及 SSE/轮询负载里的 route 字段)。当它变化时客户端 history.pushState,于是 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": [] }
}

viewport.*route.*)的 {{ … }} 表达式。为真即放行。缺少 condition 的守卫 会带着加载期错误被整个丢弃,而不是悄悄放所有人进去。

导航直接被拒绝——你留在原地。拒绝在任何路径上都是 fail-closed 的,入口场景也 不例外:见下面的"无处可去时"。

{{ route.* }}——这是携带"办完之后回到这里"提示的常用手段。被拒绝的那次导航自己 的参数不会跟着重定向走。

生效范围

守卫在每一条进入路径上都会跑:动作的 navigate 步骤、浏览器的前进/后退、直接 深链进入该场景、back: truenavigate,以及首次渲染时的入口场景。因此受保护的 路由无法靠手敲 URL 抵达,而这项检查只写在一处,不必在每个会导航的动作里重复。

它在场景的 onEnter 之前运行,所以拉取私有数据的钩子不会为一个被拒之门外的 访客触发。

关于后退栈的两个细节:

场景。

无处可去时

守卫可以直接拒绝而不是重定向:它没写 redirect、它的重定向成环,或者链条超过 跳数上限。在一次导航上,这意味着"留在原地",这是安全的——你所在的场景本来就是被 允许的。

但在首次渲染上它不能这么解释,因为"原地"恰恰就是那个被拒绝的场景。于是运行时 会按以下顺序离开:

  1. 后退栈上最近的、守卫仍然放行的那一帧(被跳过的帧会被丢弃,这样一次后退不会又

走回那次拒绝);

  1. 若没有,则入口场景;
  2. 若还是没有——入口场景本身就是被拒绝的那个,且没有历史——会话进入阻断状态:

什么都不渲染,URL 保持为 /

三种情况下被拒场景的 onEnter 都不会运行,而真正进入的那个场景会运行它自己的。

后退是一次进入,不是撤销

back: truenavigate 和其他路径一样要过守卫。那一帧是在你被允许时压入的;等你 回去时权限可能已经没了(令牌过期、同一个动作里先登出),后退绝不能成为唯一没人检查 的那扇门。

如果那一帧的守卫现在会重定向,就跟随它的重定向目标。如果那一帧被直接拒绝,就跳过它 继续往下弹——"后退"表达的是你要离开当前场景的意图,而"你不能进那里"的答案是下一个 你可以进的地方。如果整个栈都进不去,则保持栈不动、留在原地。

守卫会链式生效:重定向的目标自身也受守卫保护。链的上限是 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 }} 的登录场景。