微前端路由契约:子应用别偷偷接管全局地址栏
微前端项目里,路由最容易变成隐形耦合。主应用有自己的路由,子应用也有路由。如果子应用随意改全局地址栏、监听全局 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 path
const appConfig = { name: 'billing', basePath: '/billing', entry: 'https://cdn.example.com/billing/entry.js' };子应用所有跳转都应该基于basePath。不要在子应用里硬写/settings这种全局路径。
base path 的管理需要一个集中的注册表。主应用加载子应用时,从注册表查询合法的 path 范围:
const appRegistry = new Map<string, 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 | Promise<boolean>): 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/123,Shell 先挂载 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前端路由不是安全边界,但它是用户体验边界。权限失败时,应该给出清楚的替代路径,而不是白屏。
微前端越多,路由契约越像交通规则。规则明确,应用之间才不会互相抢方向盘。