news 2026/10/30 6:35:09

Macro SplitRouter 离开守卫深度解析:useBeforeLeave 的拦截语义与 onCommit 提交回调契约

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Macro SplitRouter 离开守卫深度解析:useBeforeLeave 的拦截语义与 onCommit 提交回调契约
  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • 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.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

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
关闭 / 移除 panerouter.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。四个阶段的职责分工如下:

  1. guards:运行所有命中的离开守卫,任一返回false即refused;
  2. middleware:对每个 target 的to做重定向或取消决策;
  3. preload:预加载路由数据,受preloadBudgetMs预算约束(默认150ms);
  4. 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。两条并行的命中条件:

  1. 卸载式命中:守卫深度>= fromDepth。fromDepth由firstChangedDepth(routes, from, to)计算;
  2. 查询式命中:options.search中任一命名空间在from与to之间深比较不相等。

firstChangedDepth依赖renderKeys:

return JSON.stringify([ node.definition.mountKey ?? node.definition.id, remountKey ?? null, ]);

见 routes/queries.ts。这行代码解释了 README 里最容易被忽略的一句:

Routes that share amountKeycount 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。拆成四条硬性约束:

  1. 同步、一次性:回调类型是() => void,不接受 Promise,且每个导航只发布一次;
  2. 只在committed时发布:cancelled、unchanged、activated一律丢弃;
  3. 必须在守卫活跃期内注册:守卫阶段结束(signalabort)后注册无效;
  4. 宿主离开检查永远不发布:权限探测无法确认宿主真的跳转了。

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守卫返回falserun.ts
cancelledmiddleware 调用cancel()run.ts
superseded新导航顶掉进行中的导航run.ts
disposedrouter.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 的每一句都翻译成了可执行断言,建议按此顺序阅读:

  1. 时机:publishes once after middleware and the destination and URL commit(L40);
  2. 被拒丢弃:discards every callback when a later guard refuses(L66);
  3. 中间件取消丢弃:L80;
  4. 可恢复错误仍提交:middleware 抛错 L97、preload 拒绝 L138;
  5. 顶替与销毁:L167、L195;
  6. 不改不发布:L215activated、L230unchanged;
  7. 移除 pane 后发布:L243;
  8. 全窗口被拒时丢弃所有 pane 的回调:L257;
  9. 回调抛错隔离:L270;
  10. 回调内销毁后续停止:L292;
  11. 宿主离开不发布: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.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

相关推荐

上一篇:没有框架也能做:用vanilla JS + torph打造专业级文本变形动画
下一篇:从 CVE 到修复:iniscan 如何检测 SOAP WSDL 缓存目录安全隐患

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 6:31:36

应用层协议HTTP与HTTPS

我们看到大部分网址前面都有一个 http 或者 https&#xff0c;它其实是应用层的协议方案名&#xff0c;本文讲的就是其中的 http。协议这种东西&#xff0c;虽然能自己定义&#xff0c;但是已经有大佬定义了一些非常好用的&#xff0c;我们可以直接参考使用。在互联网世界中&am…

作者头像 李华
网站建设 2026/10/30 6:25:29

针对 `EQPBizHelper` 类的并发分析及优化意见

针对 EQPBizHelper 类的并发分析及优化意见 用户提供了 EQPBizHelper 类的完整实现&#xff0c;该类代表一个测试工站&#xff0c;与 PLC 控制器、上位机 UI、MES/EAP 等模块交互。在多工站、多硬件环境下&#xff0c;该类的多个方法会被不同线程调用&#xff08;如硬件消息接收…

作者头像 李华
网站建设 2026/10/30 6:18:20

Pion WebRTC架构深度解析:Go语言WebRTC实现原理完全指南

Pion WebRTC架构深度解析&#xff1a;Go语言WebRTC实现原理完全指南 Pion WebRTC是一个纯Go语言实现的WebRTC API库&#xff0c;为开发者提供了构建实时音视频通信应用的完整解决方案。作为WebRTC技术的Go语言实现&#xff0c;Pion WebRTC让开发者能够在任何支持Go的环境中轻松…

作者头像 李华
网站建设 2026/10/30 6:18:19

Pion WebRTC跨平台开发终极指南:Windows、Linux、macOS全支持

Pion WebRTC跨平台开发终极指南&#xff1a;Windows、Linux、macOS全支持 想要构建跨平台的实时音视频应用吗&#xff1f;Pion WebRTC是一个纯Go语言实现的WebRTC API库&#xff0c;为开发者提供了强大的跨平台支持。无论你是Windows用户、Linux开发者还是macOS爱好者&#xf…

作者头像 李华
网站建设 2026/10/30 6:12:25

在M1 MacOS上构建ztm项目时解决arm64架构的未定义符号问题

在M1 MacOS上构建ztm项目时解决arm64架构的未定义符号问题 【免费下载链接】ztm ZTM&#xff08;零信任网格&#xff09;是一个以隐私为先的开源去中心化端到端加密软件定义网络&#xff0c;基于HTTP/2隧道。体验无限连接&#xff0c;将全球网格化&#xff01; 项目地址: htt…

作者头像 李华