微前端路由契约:子应用别偷偷接管全局地址栏 📅 2026/7/3 18:27:22 微前端路由契约子应用别偷偷接管全局地址栏微前端项目里路由最容易变成隐形耦合。主应用有自己的路由子应用也有路由。如果子应用随意改全局地址栏、监听全局 history、跳转到未声明路径就会让导航、权限、埋点和刷新恢复全部变复杂。微前端路由要有契约。子应用可以管理自己的内部状态但不能偷偷接管全局地址栏。一、主应用负责全局路由flowchart TD A[Browser URL] -- B[Shell Router] B -- C[App A Mount] B -- D[App B Mount] C -- E[Internal Router]Shell 决定挂载哪个子应用。子应用内部可以有二级路由但必须在分配的 base path 下运行。二、明确 base pathconst appConfig { name: billing, basePath: /billing, entry: https://cdn.example.com/billing/entry.js };子应用所有跳转都应该基于basePath。不要在子应用里硬写/settings这种全局路径。base path 的管理需要一个集中的注册表。主应用加载子应用时从注册表查询合法的 path 范围const appRegistry new Mapstring, AppRegistration(); interface AppRegistration { name: string; basePath: string; internalRoutes: string[]; allowedTransitions: string[]; } function registerApp(app: AppRegistration) { appRegistry.set(app.name, app); } function validateRoute(path: string): string | null { for (const [name, app] of appRegistry) { if (path.startsWith(app.basePath)) return name; } return null; // 未匹配任何应用的路径 }每次子应用请求跳转时主应用的 Shell Router 先做校验。如果目标路径不在任何应用的 base path 范围内拒绝跳转并记录日志function handleSubAppNavigation(from: string, to: string, appName: string) { const app appRegistry.get(appName); if (!app) return console.error(Unknown app: ${appName}); // 内部跳转target 在 base path 下 if (to.startsWith(app.basePath)) { return shellRouter.push(to); } // 跨应用跳转检查是否在 allowedTransitions 中 if (app.allowedTransitions.some(p to.startsWith(p))) { return shellRouter.push(to); } console.warn(App ${appName} attempted unauthorized transition to ${to}); }这种校验可以在出现为什么订单页跳到用户页了这种问题时快速从日志里找到来源。三、跨应用跳转走事件或 API子应用想跳到另一个业务域应该请求 Shell 导航而不是直接改window.location。shell.navigate(/settings/profile);这样 Shell 可以统一做权限检查、埋点、确认弹窗和加载状态。Shell 提供的导航 API 应该包括interface ShellAPI { navigate(to: string, options?: NavigateOptions): void; replace(to: string): void; goBack(): void; onBeforeNavigate(fn: (to: string) boolean | Promiseboolean): void; }onBeforeNavigate是很多团队忽略的重要能力。当用户正在编辑表单时切换到另一个子应用前需要提示是否保存草稿。这个逻辑不应该散落在每个子应用里而是由 Shell 统一提供// Shell 中注册拦截 shell.onBeforeNavigate(async (to) { const currentApp getActiveApp(); if (currentApp?.hasUnsavedChanges()) { const confirmed await showConfirmDialog(你有未保存的更改是否离开); return confirmed; } return true; });子应用通过暴露hasUnsavedChanges方法Shell 在导航前调用它。拦截逻辑集中管理各子应用只需要声明自己是否需要保护。四、刷新恢复要测试微前端路由最容易在刷新时露馅。直接打开深层路径Shell 能否挂载正确子应用子应用能否恢复内部页面route_tests: direct_open_deep_link browser_back_forward permission_denied app_unmount_cleanup只测从首页点击进入是不够的。用户会复制链接也会刷新页面。刷新恢复的常见问题是子应用加载时序。用户访问/billing/invoice/123Shell 先挂载 billing 应用billing 加载后再解析/invoice/123恢复内部页面。这个过程中loading 状态要处理好// Shell 恢复逻辑 async function handleRouteChange(path: string) { const appName matchApp(path); if (!appName) return redirect(/404); // 1. 加载子应用 const app await loadApp(appName); // 2. 挂载到指定容器 app.mount(container, { initialPath: path }); }子应用需要能从initialPath恢复内部状态。内部路由的实现方式history、hash、memory要和 base path 一致// 子应用内部路由初始化 function mount(container: HTMLElement, options: { initialPath: string }) { const router createRouter({ history: createMemoryHistory({ initialEntries: [options.initialPath] }), routes: internalRoutes, }); router.start(); }使用memory history而不是browser history可以防止子应用直接操作地址栏避免和 Shell Router 冲突。还要约定子应用卸载时清理监听器、定时器和全局状态。路由切走后如果子应用还在监听 history 或发送埋点会产生很难查的幽灵行为。export function unmount() { router.dispose(); subscriptions.forEach(fn fn()); }微前端的路由契约不只包括进入也包括离开。卸载干净Shell 才能稳定管理多个应用。五、总结微前端路由要由主应用负责全局地址子应用在 base path 内管理内部路由。跨应用跳转走 Shell API并测试刷新、前进后退、权限和卸载。路由契约清楚微前端才不会从架构解耦变成导航混乱。如果每个子应用都尊重 base path、导航 API 和卸载协议微前端的边界会清爽很多。否则问题会从代码耦合变成运行时耦合。路由还要和权限系统对齐。Shell 在挂载子应用之前应确认用户是否有访问权限子应用内部也要做细粒度权限控制。全局入口和内部按钮都要守边界不能只靠菜单隐藏。route_permission: shell: app level access sub_app: feature level access api: server side enforcement前端路由不是安全边界但它是用户体验边界。权限失败时应该给出清楚的替代路径而不是白屏。微前端越多路由契约越像交通规则。规则明确应用之间才不会互相抢方向盘。