- 后端
- 前端
- 协同办公
- 即时通讯
- 企业应用
- CRM
- AI Agent
- 人工智能
【免费下载链接】macro
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
Macro 前端的分屏路由SplitRouter允许同一页面里并列多个 pane,每个 pane 拥有独立的导航栈与路由深度。在这样的模型里,"离开一个页面"不再是单一事件,而可能是切走、关闭 pane、只改 Query、或者整个窗口跳转。本文围绕useBeforeLeave与context.onCommit两个 API,讲清守卫的注册与命中规则、导航四阶段状态机,以及提交后副作用的精确生命周期契约,并给出可直接复用的实战写法。
本文的技术主体是apps/web/src/lib/split-router/README.md中定义的守卫语义,所有实现细节均可在仓库源码与测试中找到对应证据。
一、为什么普通路由守卫不够用
传统路由的beforeLeave只处理"从路由 A 到路由 B"。但 SplitRouter 的导航模型是Navigation对象,它描述的是一次对多个 pane 的变更:
export type Navigation = { cause: NavigationCause; targets: readonly PaneTarget[]; /** Panes being removed; their leave guards must allow it too. */ removed?: readonly PaneId[]; externalSearch?: string; revert?: () => MaybePromise<void>; apply(entries: readonly Entry[]): NavigationResult; };见 router/types.ts。由此派生出四种"离开"场景:
| 场景 | 触发方式 | context.to |
|---|---|---|
| 同 pane 内跳转 | navigatePane(pane, '/mail') | 新的Entry |
| 关闭 / 移除 pane | router.remove(pane)、close(pane) | undefined |
| 面板级守卫被查询 | PANE_LEVEL深度 | undefined(移除)或新Entry |
| 宿主离开路由树 | 浏览器跳到应用外部 | undefined |
另外还有一种细粒度场景:视图根本没有卸载,只是查询参数?sort=name变成了?sort=date。默认情况下这不算"离开",但如果视图里保存了未保存草稿,它同样需要拦截。这正是LeaveGuardOptions.search存在的原因。
二、导航四阶段状态机:守卫在什么位置跑
SplitRouter把每次导航建模为一个纯状态机,生命周期写死在注释里:
Lifecycle: idle → guards → middleware → preload → idle见 router/run.ts。四个阶段的职责分工如下:
- guards:运行所有命中的离开守卫,任一返回
false即refused; - middleware:对每个 target 的
to做重定向或取消决策; - preload:预加载路由数据,受
preloadBudgetMs预算约束(默认150ms); - commit:调用
navigation.apply(entries)写入 pane 与 URL,随后发布onCommit回调。
关键点在于:onCommit回调不是守卫阶段执行,也不是组件卸载时执行,而是在apply成功返回committed之后同步执行。这决定了它和 Solid 的onCleanup有本质区别。
状态机的取消语义同样清晰(anyPhase分支):任何阶段收到新的navigate事件,都会把当前导航以superseded理由取消;收到failed则取消。
三、useBeforeLeave 的注册语义
3.1 Hook 本体
export function useBeforeLeave( guard: LeaveGuard, options?: LeaveGuardOptions ): void { const router = useSplitRouter(); const scope = usePaneContext(); const pane = scope.pane(); const viewDepth = scope.depth() - 1; onCleanup(router.registerGuard(pane, viewDepth, guard, options)); }见 solid/use-before-leave.ts。三个细节值得注意:
- pane 来自
PaneScope上下文,所以同一个组件在不同 pane 里注册的守卫彼此独立; viewDepth = scope.depth() - 1,把"outlet 深度"换算成"视图深度",用于判断新导航是否真的让这个视图消失了;onCleanup只做注销:注释明确写着cleanup is not a commit。组件卸载时不会自动保存任何东西,这一点在第四节会展开。
3.2 守卫的命中规则
注册只是入表,是否被询问由select决定:
const relevant = (item: RegisteredGuard) => { if (item.pane !== pane) return false; if (fromDepth !== undefined && item.depth >= fromDepth) return true; return ( !!context?.to && !!item.options.search?.some( (namespace) => !deepEqual( context.from.location.search?.[namespace], context.to?.location.search?.[namespace] ) ) ); };见 router/leave-guards.ts。两条并行的命中条件:
- 卸载式命中:守卫深度
>= fromDepth。fromDepth由firstChangedDepth(routes, from, to)计算; - 查询式命中:
options.search中任一命名空间在from与to之间深比较不相等。
firstChangedDepth依赖renderKeys:
return JSON.stringify([ node.definition.mountKey ?? node.definition.id, remountKey ?? null, ]);见 routes/queries.ts。这行代码解释了 README 里最容易被忽略的一句:
Routes that share a
mountKeycount as one route: moving between them keeps the view mounted and does not leave it.
两个路由声明同一个mountKey时,它们的渲染键前缀相同,firstChangedDepth返回更深的差异位置(或undefined),于是浅层守卫不会被询问,视图保持挂载、参数原地更新。测试文件里用workspace与workspace/:itemId共用mountKey: 'workspace'验证了这一点,counts.workspace在来回切换时始终为1,见 tests/solid.test.tsx 与 tests/solid.test.tsx。
3.3 PANE_LEVEL:面板级守卫
/** * Where guards from a pane's chrome sit: above its first route, as inside a * `<PaneScope>`. Only removing the pane or leaving the router asks them. */ export const PANE_LEVEL = -1;见 router/leave-guards.ts。用PANE_LEVEL注册的守卫(例如 pane 外壳里的"关闭前确认")不会因为内部路由跳转而触发,只有在 pane 被移除或整个路由树被离开时才会被问到。移除路径由removalChecks构造上下文,此时to缺省为undefined:
const context = { pane, from, cause: navigation.cause, signal, onCommit: onCommit({ navigation, signal }), };见 router/phases.ts。
3.4 守卫的返回值与容错
LeaveGuard的类型是MaybePromise<boolean>,同步与异步都支持。执行器runChecks顺序运行,直到某个守卫拒绝:
export function runChecks(checks: readonly Check[]): MaybePromise<boolean> { for (const [position, check] of checks.entries()) { const verdict = settleMaybe(check, allowOnError); if (isPromise(verdict)) { return resumeChecks(verdict, checks, position + 1); } if (!verdict) return false; } return true; }见 router/leave-guards.ts。两个行为需要记住:
- 守卫抛错等于放行:
allowOnError打印Split router leave guard failed并返回true。守卫不应该成为导航的故障点; - 短路:第一个
false立即终止,后续守卫根本不会执行。这也是onCommit需要"按导航归属"而非"按守卫归属"的原因之一。
四、onCommit:为"提交之后"而生的副作用
4.1 契约原文拆解
README 的表述非常精确,逐句都能在源码里定位:
export type LeaveGuardContext = { pane: PaneId; from: Entry; to?: Entry; cause: NavigationCause; signal: AbortSignal; /** * Runs a synchronous callback once after this router navigation commits. * Cancelled, unchanged, and owner-activation results discard the callback. * Register while this guard is active. Host-leave checks never run callbacks. */ onCommit(callback: () => void): void; };见 router/types.ts。拆成四条硬性约束:
- 同步、一次性:回调类型是
() => void,不接受 Promise,且每个导航只发布一次; - 只在
committed时发布:cancelled、unchanged、activated一律丢弃; - 必须在守卫活跃期内注册:守卫阶段结束(
signalabort)后注册无效; - 宿主离开检查永远不发布:权限探测无法确认宿主真的跳转了。
4.2 为什么不能在 onCleanup 里做
README 里的这段话是整篇文档的动机所在:
Capture outgoing state while the guard runs; the callback runs after the new panes and URL are applied, when the outgoing component can already be disposed.
也就是说,onCleanup触发时机有两种糟糕的可能:
- 导航被拒绝时,组件并不会卸载,但如果你在守卫里直接写了存储,脏数据已经落盘;
- 导航成功但视图被销毁时,组件已经 dispose,此时再读取内部状态可能拿到失效句柄。
onCommit的解法是把捕获和发布拆开:守卫运行时同步抓取快照(此时组件一定还活着、数据一定还是旧的),把"写入快照"这个动作打包成闭包交给onCommit,由 runner 在apply之后统一发布。
4.3 仓库内的真实用法
useViewRestoration是这条契约的生产级范例:
useBeforeLeave(({ from, onCommit }) => { const publish = retention.remember(from); if (publish) onCommit(publish); return true; });见 view-restoration.ts。注意两点:
retention.remember(from)在守卫阶段完成快照捕获,并返回一个"发布函数";- 只有拿到发布函数才注册
onCommit,因此导航被拒时什么都不会发生。
同一目录的说明文档把这个设计意图写得很直白:Solid cleanup only unregisters the hook; it never saves state.见 split-layout/README.md。
五、回调的生命周期:从注册到发布的完整链路
5.1 注册:绑定到 Navigation,而不是守卫
onCommit在阶段作用域里被构造:
const onCommit = ({ navigation, signal }: RunContext) => (callback: () => void) => { if (!signal.aborted) registerCommit(navigation, callback); };见 router/phases.ts。signal来自perform()创建的AbortController,它的 cleanup 会在离开该阶段时 abort:
return () => controller.abort();见 router/phases.ts。这行 cleanup 就是"守卫阶段结束后注册会被忽略"的实现——signal.aborted为真,registerCommit根本不执行。
registerCommit进一步把回调压入以Navigation对象为键的WeakMap:
const onCommit: RegisterCommitCallback = (navigation, callback) => { settlers.get(navigation)?.callbacks.push(callback); };见 router/runner.ts。因此 README 说:
Callbacks belong to the navigation, not the guard phase or the mounted component.
一次导航可能横跨多个 pane、命中多个守卫,但它们共享同一个回调数组,发布顺序就是注册顺序。
5.2 发布:apply 之后、状态可见
const commit = (navigation: Navigation, entries: readonly Entry[]): NavigationResult => { committing += 1; try { const result = navigation.apply(entries); const callbacks = settlers.get(navigation)?.callbacks.splice(0) ?? []; if (result.status === 'committed') { for (const callback of callbacks) { if (disposed) break; try { callback(); } catch (error) { console.error('Split router commit callback failed', error); } } } return result; } catch (error) { console.error('Split router could not commit a navigation', error); return CANCELLED_RESULT; } finally { committing -= 1; release(); } };见 router/runner.ts。这段不到三十行的代码几乎承载了全部契约,逐个读出行为:
splice(0)在判断状态之前执行,所以非committed结果会丢弃而非留存;- 回调在
apply返回后执行,此时panes.commit()与url.write()都已完成,新 URL 与新的路由匹配可读; try/catch包住单个回调,抛错只打印Split router commit callback failed,不改变导航结果,也不阻止后续回调;if (disposed) break支持"回调里把 router 销毁"这种极端情况,剩余回调被跳过;apply自身抛错时整体退化为CANCELLED_RESULT,回调同样被丢弃。
5.3 丢弃的五条路径
README 列举了Refusal, cancellation, supersession, disposal, and failed application,对应到源码:
| 丢弃原因 | 触发点 | 关键代码 |
|---|---|---|
refused | 守卫返回false | run.ts |
cancelled | middleware 调用cancel() | run.ts |
superseded | 新导航顶掉进行中的导航 | run.ts |
disposed | router.dispose() | runner.ts |
failed | 某阶段抛错 / 拒绝 | phases.ts |
取消路径统一走settle(navigation, CANCELLED_RESULT),settlers.delete(navigation)之后回调数组随之不可达。
5.4 两种"不发布"的特殊结果
unchanged:isUnchanged在启动 run 之前就返回,守卫根本没有执行,自然没有回调:const isUnchanged = (pane: PaneId, from: Entry, entry: Entry) => { const handsOverProps = entry.props !== undefined && entry.props !== from.props; const sameDestination = !handsOverProps && sameVisit(from, entry); return sameDestination && !runner.isRunning(pane); };见 router/pane-navigation.ts。
activated:目标内容已被另一个 pane 或预览持有,claims.applyUnlessHeld返回activated,commit里的if (result.status === 'committed')不成立,回调被丢弃。README 的措辞是activation of another content owner do not publish callbacks。
六、失败恢复:什么情况下导航提交了但中途出过错
README 特别澄清了一类容易误判的场景:
Preload and middleware errors that the router recovers from still allow commit callbacks when navigation commits.
middleware 抛错被runMiddleware兜底,打印Split router middleware failed; continuing navigation并回退到原to:
return settleMaybe( () => runEntry(run, request.to), (error) => { if (isAbortError(error, request.signal)) throw error; console.error('Split router middleware failed; continuing navigation', error); return request.to; } );见 router/middleware.ts。注意isAbortError会重新抛出——被取消的导航不算"可恢复错误"。
preload 失败甚至不会冒泡:preloadLocation内部用try/catch包住同步抛错,并对异步结果使用Promise.allSettled,最后withinBudget只做超时竞速:
export async function withinBudget(work: Promise<unknown>, ms: number): Promise<void> { let timer: ReturnType<typeof setTimeout> | undefined; const deadline = new Promise<void>((resolve) => { timer = setTimeout(resolve, ms); }); try { await Promise.race([work, deadline]); } finally { clearTimeout(timer); } }见 router/preload.ts。这两条都有对应测试断言"仍发布一次",见 tests/commit-callbacks.test.ts。
七、宿主离开:为什么 onCommit 是 no-op
当用户点击应用外部链接或浏览器跳转时,Solid Router 会先征求权限。此时canLeave构造的上下文刻意把一个空函数塞进去:
const context = { pane, from, cause: 'external' as const, signal: UNCANCELLABLE, // The host only asks permission; the router cannot observe its commit. onCommit: () => {}, };见 router/url-navigation.ts。理由写在注释里:宿主是否真的完成跳转,路由器无从观察。如果在这里发布回调,一旦宿主中止跳转,副作用就会凭空发生。同理,此路径的signal使用UNCANCELLABLE,因为这是纯查询、没有阶段可退出。
测试对此有明确断言:宿主离开时守卫被调用一次但回调不发布,随后一次真实导航才发布一次,见 tests/commit-callbacks.test.ts。
八、实战写法
8.1 基础:拦截普通离开
import { useBeforeLeave } from '@app/lib/split-router'; function DraftEditor() { useBeforeLeave(({ from, to, cause, signal }) => { if (!hasUnsavedChanges()) return true; // 支持异步确认;signal 会在导航被顶掉时 abort return confirmDiscard().then((ok) => ok); }); }signal直接来自perform()的阶段控制器,可用于取消网络请求。若返回的 Promise 在 abort 后 resolve,report()会因context.signal.aborted而不派发事件,见 phases.ts。
8.2 保护查询参数变化(视图不卸载)
function FilteredList() { useBeforeLeave(() => !dirty(), { search: ['list'] }); }配合路由声明中的search: ['list'],当?list.sort变化时该守卫会被命中;而sort未变化的导航不会触发它。测试用例正是这个模式,见 tests/solid.test.tsx 与 tests/solid.test.tsx。
search比较使用fast-deep-equal,因此数组或多值参数也按结构比较,而不是字符串比较。
8.3 高级:捕获 + 提交发布
function useSnapshotOnLeave(store: SnapshotStore) { useBeforeLeave(({ from, onCommit }) => { // 此刻组件仍存活,from 是即将离开的旧 Entry const snapshot = captureNow(from); const publish = store.stage(snapshot); // 只有导航真正提交才会写入 onCommit(publish); return true; }); }这段与useViewRestoration的结构完全一致。若要观察"提交后世界",回调体内可以安全读取router.entry(pane)与history.read()——测试断言此时history.read().path === '/mail'且路由 id 已是mail,见 tests/commit-callbacks.test.ts。
8.4 面板级:关闭前确认
import { PANE_LEVEL } from '@app/lib/split-router'; router.registerGuard(pane, PANE_LEVEL, ({ to }) => { // to === undefined 表示 pane 将被移除 return !hasRunningJob() || confirmStop(); });在<PaneScope>内使用useBeforeLeave注册的是视图深度守卫;需要"整个 pane 关闭才问"的语义时,走PANE_LEVEL。
九、常见陷阱清单
| 陷阱 | 现象 | 正解 |
|---|---|---|
| 在守卫里直接写存储 | 导航被拒绝也已写入 | 改为onCommit(publish)暂存 |
在onCleanup里保存状态 | 卸载才存,且无法区分成功/失败 | cleanup 只用于注销;状态走onCommit |
异步守卫里await后再注册 | 注册被静默忽略 | 在守卫的同步首帧内注册 |
期望mountKey相同仍触发守卫 | 视图未卸载,浅层守卫不被问 | 使用options.search或显式PANE_LEVEL |
| 认为回调抛错会回滚导航 | 导航照常提交 | 回调内自行 try/catch,日志键为Split router commit callback failed |
在rewriteCurrent/removeEntries上期待回调 | 走commitNow,跳过守卫 | 这两个方法不经过 guards 阶段 |
最后一条可从源码确认:rewriteCurrent直接调用runner.commitNow(visitNavigation(...)),而commitNow的注释是Applies without guards or middleware.,见 router/pane-navigation.ts 与 router/runner.ts。
十、测试即规范
tests/commit-callbacks.test.ts事实上把 README 的每一句都翻译成了可执行断言,建议按此顺序阅读:
- 时机:
publishes once after middleware and the destination and URL commit(L40); - 被拒丢弃:
discards every callback when a later guard refuses(L66); - 中间件取消丢弃:L80;
- 可恢复错误仍提交:middleware 抛错 L97、preload 拒绝 L138;
- 顶替与销毁:L167、L195;
- 不改不发布:L215
activated、L230unchanged; - 移除 pane 后发布:L243;
- 全窗口被拒时丢弃所有 pane 的回调:L257;
- 回调抛错隔离:L270;
- 回调内销毁后续停止:L292;
- 宿主离开不发布:L306。
这十一个用例覆盖了 README 全文的所有断言,也是二次开发时最可靠的回归基线。运行方式可参考仓库根目录的justfile与 apps/web/package.json 中的测试脚本。
十一、延伸阅读
- 守卫实现:router/leave-guards.ts
- 阶段编排与
onCommit注入:router/phases.ts - 发布语义与状态机执行:router/runner.ts
- 状态机定义:router/run.ts
- Hook 绑定:solid/use-before-leave.ts
- 深度与
mountKey计算:routes/queries.ts - 宿主离开与 URL 导航:router/url-navigation.ts
- 公开导出面:index.ts
- 生产用例:split-layout/view-restoration.ts
- 后端
- 前端
- 协同办公
- 即时通讯
- 企业应用
- CRM
- AI Agent
- 人工智能
【免费下载链接】macro
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
相关推荐
Vue Router 导航守卫深度解析:全面掌握路由拦截机制
Vue Router 导航守卫深度解析:全面掌握路由拦截机制 前言 在现代前端开发中,路由管理是单页应用 SPA 的核心功能之一。Vue Router 作为 V
路由前端Web框架deepseek-harness 的 landlock-run CLI 契约:调用语法、退出码与隔离语义全解析
deepseek harness 的 landlock run CLI 契约:调用语法、退出码与隔离语义全解析 landlock run 是 deepseek
人工智能AI AgentAgent 框架DeepSeekGHelper 完整指南:华硕笔记本性能控制、风扇曲线与独显直连一窗搞定
GHelper 完整指南:华硕笔记本性能控制、风扇曲线与独显直连一窗搞定 GHelper 是一款面向华硕笔记本的轻量性能控制工具,用来替代臃肿的 Armoury
桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考