陌上人如玉
公子世无双

6.14 微前端下的路由协同

6.14 微前端下的路由协同

一句话:微前端的路由协同要回答三个问题——「路由归谁管(主应用还是子应用)、URL 前缀怎么分(basename)、导航怎么不整页刷新」;而最容易被忽略的是「404 归属」「深链接」和「子应用内部路由与主应用 history 的一致」。

📌 本篇是「路由视角」;「微前端的沙箱 / 样式隔离 / 依赖共享」见 10.10 微前端落地。

一、核心问题:路由归谁管

⭐⭐⭐ 三种路由管理模式(先选清楚,否则后面全是坑):

① 【主应用全托管】——子应用「只渲染,不管路由」
   主应用解析 URL → 把「路由信息」当 props 传给子应用 →
   子应用自己**不创建 router**(它只根据 props 渲染)
   ✅ 最简单:只有一份路由真相(主应用)→ 「后退/前进/深链接」天然正确
   ⚠️ 代价:子应用「不能有自己独立的路由树」;
     而且「子应用内部的多级页面」要由主应用知道(耦合变强)
   ⭐ 适合:「子应用就是一块 UI」(如「一个数据看板组件」「一个表单页」)

② 【子应用自治】——子应用有自己完整的 router(用 basename)
   主应用按 URL 前缀「挂载/卸载」子应用;
   子应用**自己创建 router**,但把 basename 设为「自己的前缀」(如 `/app-a`)
   ✅ 子应用可以有自己的路由树(多级页面、嵌套布局)
   ✅ 主应用只需要「知道前缀」
   ⚠️ 代价:⭐ **两套 history 要靠同一个 `history` 对象或靠「URL 同步」保持一致**
     (否则「后退」会出现「子应用没反应」或「退两次」)
   ⭐ 适合:**大多数微前端场景**(尤其是「老系统渐进迁移」)

③ 【混合】——主应用管「顶层」(哪个子应用),子应用管「内部」
   最常见的形态:主应用负责「前缀路由 + 应用切换」,
   子应用负责「前缀内部的嵌套路由」
   ⭐ 实际上「②」本来就是混合(主应用管前缀、子应用管内部)
⭐⭐⭐ 「两套 history 不一致」是怎么发生的(这是最常见的 bug):

   场景:主应用用 React Router(BrowserRouter),子应用也用 React Router。

   ① 子应用内部点击链接 → 子应用的 router 调 `history.pushState('/app-a/detail')`
      → ⭐ URL 变了,**子应用的 UI 也变了**(正确)
      ⚠️ 但「主应用的 router」是通过 `popstate` 感知 URL 的 →
         `pushState` **不会触发 `popstate`** → ⭐ **主应用不知道 URL 变了**
      → 如果主应用的「菜单高亮/面包屑」依赖 `useLocation()` → **不会更新**

   ② 用户按「后退」→ 触发 `popstate`
      → ⭐ 主应用与子应用**都**收到 → **两边都处理一次**
      → 可能出现「主应用把子应用卸载了,子应用又想渲染自己」的竞争

   ✅ 解法(三条,按推荐度):
     ① ⭐ **共享同一个 history 对象**(子应用的 router 用主应用创建的那个)
        —— 这是最彻底的(只有一份真相)
     ② ⭐ **劫持 pushState/replaceState + 派发自定义事件**(见 10.10 的实现)
        —— 让「所有听众」(包括主应用)都能感知路由变化
     ③ **主应用『订阅』子应用的路由变化**(子应用主动上报)
        —— 简单但容易漏(子应用必须记得上报)

二、basename 与「深链接」

// ✅ 子应用「自治」时的 basename 注入(React Router 为例)
// 主应用通过 props 告诉子应用「你的前缀是什么」
export async function mount({ container, props }: {
  container: HTMLElement;
  props: {
    /** ⭐⭐ 子应用的 basename(由主应用决定,如 '/app-a') */
    basePath: string;
    /** 主应用共享的 history(⭐ 推荐传,避免两套 history) */
    history?: History;
    /** 主应用注入的「通知路由变化」的能力 */
    onRouteChange?: (path: string) => void;
  };
}) {
  const root = createRoot(container);

  root.render(
    <App
      // ⭐ 传给子应用的 router
      routerProps={{
        basename: props.basePath,
        // ⭐⭐ 优先用「主应用共享的 history」(只有一份真相)
        history: props.history,
      }}
      onRouteChange={props.onRouteChange}
    />
  );

  return root;
}
// ✅ 子应用的 router 创建(⭐ 关键:basename + 共享 history + 上报变化)
import { createBrowserRouter, RouterProvider, useLocation } from 'react-router-dom';

function App({ routerProps, onRouteChange }: {
  routerProps: { basename: string; history?: History };
  onRouteChange?: (path: string) => void;
}) {
  const router = useMemo(
    () =>
      createBrowserRouter(
        [
          { path: '/', element: <Home /> },
          { path: '/detail/:id', element: <Detail /> },
          { path: '*', element: <SubAppNotFound /> },     // ⭐ 子应用内部的 404
        ],
        {
          // ⭐⭐⭐ 两个关键参数:
          basename: routerProps.basename,                 // 「我的 URL 前缀」
          // ⚠️ React Router v6 的 createBrowserRouter「不接受外部 history 对象」——
          //    要共享 history,需要:
          //    ① 用 `createMemoryRouter` + 手动双向同步(见下)
          //    ② 或者「不用 RouterProvider,用 <BrowserRouter window={fakeWindow}>」
          //    ③ ⭐ 或者最简单:让「子应用也用 BrowserRouter」但「保证前缀一致」
          //       —— 此时 subapp 依赖「全局 history」,天然共享(因为它就是 window.history)
        }
      ),
    [routerProps.basename]
  );

  return (
    <>
      {/* ⭐ 上报路由变化(供主应用更新菜单高亮/面包屑/埋点) */}
      <RouteChangeReporter onRouteChange={onRouteChange} />
      <RouterProvider router={router} />
    </>
  );
}

function RouteChangeReporter({ onRouteChange }: { onRouteChange?: (path: string) => void }) {
  const location = useLocation();
  useEffect(() => {
    // ⭐ 上报「完整 pathname(含 basename)」
    onRouteChange?.(location.pathname);
  }, [location.pathname, onRouteChange]);
  return null;
}
⭐⭐⭐ 「深链接」(直接访问 `/app-a/detail/123`)必须能工作:

   【链路】
     浏览器直接打开 `/app-a/detail/123`
     → ① 主应用启动 → 读 URL → 匹配到「前缀 /app-a」→ 挂载子应用 A
     → ② 子应用 A 启动 → 用 basename `/app-a` 解析出「内部路径 `/detail/123`」
     → ③ 渲染详情页
   ⭐ 关键:**主应用必须「先挂载子应用,再让子应用自己解析内部路由」**——
     主应用**不应该**去解析 `/detail/123`(那是子应用的私事)。

   ⚠️ 三个常见的失败点:
     ① **主应用把「不认识的路径」当成 404**(因为它只认识前缀)
        ✅ 判据:「**前缀匹配成功就交给子应用**」,子应用内部再决定 404
     ② **子应用的 assets 路径错**(相对路径以「基座域名」解析)
        ✅ 子应用构建时设「绝对 publicPath」(见 10.10)
     ③ **子应用的路由库「不认识 basename」**(路径解析错位)
        ✅ 每个路由库都要正确配 basename

三、404 归属与「导航边界」

⭐⭐⭐ 「404 归谁显示」是一个必须明确的设计决策:

   【层次】URL 分三段:`/前缀` + `/子应用内部路径` + `/参数`
     · 主应用负责:**前缀匹配**(前缀不认识 → 主应用 404)
     · 子应用负责:**内部路径匹配**(前缀对但内部路径错 → 子应用 404)

   ⭐ 推荐做法:「**谁匹配失败谁负责**」——
     · 主应用:「没有任何子应用的前缀匹配」→ 主应用 404 页(带「返回首页」)
     · 子应用:「前缀对了,但内部路由不认识」→ 子应用自己的 404
       ⭐ 且这个 404 **应该显示在子应用的容器内**(保持主应用的导航栏可见)

   ⚠️ 反例(常见):
     · 主应用「贪婪兜底」`path: '*'` → 把「子应用内部路径」也吃掉 →
       子应用永远收不到那些路径 → 表现为「子应用的深层页面全 404」
     · 子应用「越权兜底」→ 把「别的应用的前缀」也吃进来 →
       表现为「A 应用渲染了 B 应用的内容」
   ✅ 防御:主应用的兜底路由放在「所有子应用前缀之后」;
     子应用的 router 用「自己的 basename」约束范围。
// ✅ 主应用侧:前缀路由表 + 兜底 404(⭐ 顺序很重要)
const CHILD_APPS = [
  { name: 'app-a', prefix: '/app-a', load: () => import('./apps/appA') },
  { name: 'app-b', prefix: '/app-b', load: () => import('./apps/appB') },
];

function MainRouter() {
  return (
    <BrowserRouter>
      <AppShell>
        <Routes>
          <Route path="/" element={<Home />} />
          {/* ⭐ 每个子应用一个「前缀通配」路由 */}
          {CHILD_APPS.map((app) => (
            <Route
              key={app.name}
              // ⭐⭐ `/*` 表示「前缀之后的所有路径都交给子应用」
              path={`${app.prefix}/*`}
              element={<MicroAppHost app={app} />}
            />
          ))}
          {/* ⭐ 兜底(放在最后):前缀都不认识 → 主应用 404 */}
          <Route path="*" element={<MainNotFound />} />
        </Routes>
      </AppShell>
    </BrowserRouter>
  );
}

/** ⭐ 子应用宿主:负责「加载 + 挂载 + 卸载 + 传递路由信息」 */
function MicroAppHost({ app }: { app: typeof CHILD_APPS[number] }) {
  const location = useLocation();
  const containerRef = useRef<HTMLDivElement | null>(null);
  const instanceRef = useRef<{ unmount: () => void } | null>(null);

  // ⭐ 挂载/卸载(生命周期见 10.10)
  useEffect(() => {
    let cancelled = false;
    (async () => {
      const mod = await app.load();
      if (cancelled || !containerRef.current) return;

      const instance = await mod.mount({
        container: containerRef.current,
        props: {
          // ⭐⭐⭐ 核心:把「前缀」告诉子应用(它据此设 basename)
          basePath: app.prefix,
          // ⭐ 把「当前完整路径」传过去(避免子应用「自己读 window.location」时的时序问题)
          initialPath: location.pathname,
          // ⭐ 让子应用能「通知主应用」路由变化(用于菜单高亮/埋点)
          onRouteChange: (path: string) => {
            // ⚠️ 这里「不要」再 pushState(子应用已经 push 了)
            //    只需要「更新主应用的 UI 状态」
            window.dispatchEvent(new CustomEvent('micro:routeChange', { detail: { app: app.name, path } }));
          },
        },
      });
      instanceRef.current = instance;
    })();

    return () => {
      cancelled = true;
      instanceRef.current?.unmount();
      instanceRef.current = null;
    };
    // ⚠️ 依赖「只放 app」——不要放 location,否则「每次路由变化都重挂子应用」(会白屏/丢状态)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [app]);

  return <div ref={containerRef} data-micro-app={app.name} />;
}
⭐⭐⭐ `MicroAppHost` 里最容易被写错的一处:**依赖数组**

   ❌ `useEffect(() => { mount(); return unmount; }, [app, location])`
      → ⭐ **每次路由变化都「卸载再挂载」子应用** →
        表现为「子应用内部一导航就白屏 / 状态全丢 / 反复请求」
      ⭐ 这是微前端里最常见的严重 bug 之一。

   ✅ `useEffect(..., [app])`
      → 只在「切换到另一个应用」时重挂。
   ⭐ 那「路由变化」怎么让子应用知道?
     · 方案 A:**子应用自己监听**(它有自己的 router,且共享同一个 history)→ ⭐ 推荐
     · 方案 B:主应用把 `location` 通过「一个 ref / 事件」传过去(不放进依赖)
       —— 子应用「订阅」它(而不是「被重新挂载」)

四、完整实现:一个「路由桥」(230 行)

// route-bridge.ts —— 主应用 ↔ 子应用 的路由协同层
// 覆盖:共享 history / 前缀解析 / 子应用 basename 注入 / 404 归属 / 后退一致

/** ============ 1. 前缀解析(决定「哪个子应用」+「它内部的路由是什么」) ============ */
export interface AppRouteMatch {
  app: { name: string; prefix: string };
  /** ⭐ 子应用「内部」的路径(已去掉前缀,带前导 `/`) */
  innerPath: string;
  /** 完整路径(含前缀) */
  fullPath: string;
  search: string;
  hash: string;
}

export function matchAppRoute(
  apps: Array<{ name: string; prefix: string }>,
  location: { pathname: string; search: string; hash: string }
): AppRouteMatch | null {
  // ⭐ 按「前缀长度」降序匹配(避免 `/app` 抢走 `/app-a` 的路由)
  const sorted = [...apps].sort((a, b) => b.prefix.length - a.prefix.length);

  for (const app of sorted) {
    const { pathname } = location;
    // ⭐ 前缀匹配必须「边界正确」:
    //    `/app-a` 应匹配 `/app-a`、`/app-a/`、`/app-a/detail`
    //    但**不该**匹配 `/app-abc`
    const isExact = pathname === app.prefix || pathname === `${app.prefix}/`;
    const isPrefix = pathname.startsWith(`${app.prefix}/`);
    if (!isExact && !isPrefix) continue;

    const innerPath = isExact ? '/' : pathname.slice(app.prefix.length);
    return { app, innerPath, fullPath: pathname, search: location.search, hash: location.hash };
  }

  return null;     // ⭐ 没有任何前缀匹配 → 主应用负责 404
}
/** ============ 2. history 劫持(让「所有听众」都能感知路由变化) ============ */
type RouteListener = (location: { pathname: string; search: string; hash: string }) => void;

export function createRouteBridge() {
  const listeners = new Set<RouteListener>();
  let installed = false;

  /** ⭐ 当前 location(规范化成一个纯对象) */
  const getLocation = () => ({
    pathname: window.location.pathname,
    search: window.location.search,
    hash: window.location.hash,
  });

  function notify() {
    const loc = getLocation();
    listeners.forEach((fn) => fn(loc));
  }

  function install() {
    if (installed) return;
    installed = true;

    // ⭐⭐ 劫持 pushState/replaceState
    //    因为「pushState 不会触发 popstate」——这是「两套 router 不同步」的根源
    const rawPush = history.pushState.bind(history);
    const rawReplace = history.replaceState.bind(history);

    history.pushState = function (...args) {
      const ret = rawPush(...args);
      // ⭐ 用「微任务」派发(让「同步调用的调用方」先拿到返回值)
      queueMicrotask(notify);
      return ret;
    };
    history.replaceState = function (...args) {
      const ret = rawReplace(...args);
      queueMicrotask(notify);
      return ret;
    };

    // ⭐ 浏览器前进/后退会触发 popstate
    window.addEventListener('popstate', notify);

    // ⚠️ 注意:这里「不回复原」——应用生命周期内一直劫持
    //    如果模块被热替换(HMR),可能重复劫持 → 用 installed 标志防重
  }

  return {
    install,
    getLocation,

    /** ⭐ 订阅(返回 unsubscribe) */
    subscribe(fn: RouteListener): () => void {
      install();
      listeners.add(fn);
      // ⭐ 订阅时「立刻推一次当前值」(避免「订阅前的变化错过」)
      fn(getLocation());
      return () => { listeners.delete(fn); };
    },

    /** ⭐ 以「子应用前缀」为基准做一次导航(会走原生 history) */
    navigate(prefix: string, innerPath: string, opts: { replace?: boolean } = {}) {
      const url = `${prefix}${innerPath === '/' ? '' : innerPath}` || '/';
      if (opts.replace) history.replaceState(null, '', url);
      else history.pushState(null, '', url);
    },
  };
}

export const routeBridge = createRouteBridge();
/** ============ 3. React 集成:一个 Hook 拿「当前匹配到的子应用 + 内部路径」 ============ */
import { useSyncExternalStore } from 'react';

export function useMatchedAppRoute(apps: Array<{ name: string; prefix: string }>) {
  // ⭐ 用 useSyncExternalStore 订阅路由(与 store 的模式一致,见 11.13)
  const location = useSyncExternalStore(
    routeBridge.subscribe,
    routeBridge.getLocation,
    () => ({ pathname: '/', search: '', hash: '' })     // SSR 快照
  );

  // ⭐ 解析(纯函数,可单测)
  return { location, match: matchAppRoute(apps, location) };
}
/** ============ 4. 子应用内部的适配(React Router 为例) ============ */
/**
 * ⭐ 子应用侧:把「自己的 router」与「主应用的 route-bridge」接起来
 *    —— 目标是「无论谁发起导航,两边都一致」
 */
export function useSubAppRouterSync(basename: string) {
  const navigate = useNavigate();
  const location = useLocation();

  // ⭐ ① 子应用「感知到 URL 变化」(可能是主应用发起的,如「菜单点击」)
  useEffect(() => {
    // 主应用「点击菜单」→ pushState('/app-a/xxx') → route-bridge 通知
    // ⭐ 子应用的 router **本来就监听同一个 window.history** →
    //    它会自己响应 popstate;但 **pushState 不触发 popstate** →
    //    所以这里需要「手动同步一次」
    const unsubscribe = routeBridge.subscribe((loc) => {
      const inner = loc.pathname.startsWith(basename)
        ? loc.pathname.slice(basename.length) || '/'
        : '/';
      // ⭐ 只有当「子应用内部路径」与「当前 location」不一致时才导航(避免循环)
      if (inner !== location.pathname) {
        void navigate(inner, { replace: true });
      }
    });
    return unsubscribe;
  }, [basename, navigate, location.pathname]);

  // ⭐ ② 子应用「自己导航」时,同样走原生 history(route-bridge 会通知主应用)
  //    ⭐ 所以这里不需要额外做事(子应用的 router 已经 pushState 了)
}
⭐⭐⭐ 这个「路由桥」解决的四个问题(对照微前端的真实痛点):

① 【pushState 不触发 popstate → 两套 router 不同步】
   ⭐ 劫持 `pushState`/`replaceState` + 用「微任务」派发自定义通知 →
     **所有听众**(主应用、子应用、埋点、面包屑)都能感知路由变化。
   ⚠️ 用「微任务」而不是「同步派发」:
     如果是同步派发,`history.pushState` 的调用方「还没拿到返回值」
     就进入了回调 → 可能在回调里读到「还没更新的状态」。

② 【前缀匹配的边界正确性】
   ❌ `pathname.startsWith('/app')` 会把 `/app-abc` 也匹配进来
   ✅ 判据是「**等于前缀** 或 **前缀 + `/` 开头**」
   ⭐ 并且「按前缀长度降序匹配」(否则 `/app` 会抢走 `/app-a`)

③ 【404 的归属清晰】
   · 前缀不认识 → 主应用 404(`matchAppRoute` 返回 null)
   · 前缀对但内部路径错 → 子应用自己的 404(在子应用容器内显示)
   ⭐ 这需要「主应用**不要**贪婪兜底」+「子应用 router 用 basename 约束范围」。

④ 【「深链接」与「后退一致」】
   · 深链接:`matchAppRoute` 用「完整 URL」判断前缀 →
     主应用挂载正确的子应用 → 子应用用自己的 basename 解析内部路径 ✓
   · 后退:`popstate` 天然触发 → 主应用与子应用都收到 →
     ⚠️ 但要注意「**别让主应用把子应用卸载了**」(依赖数组别放 location,见上文)。

五、本篇特有的坑

// ① 主应用用 `path: '*'` 贪婪兜底(把子应用的内部路径也吃掉)
<Route path="*" element={<NotFound />} />                  // ⚠️ 放在前面 → 子应用永远收不到
// ✅ 子应用前缀路由在前、兜底在最后

// ② 前缀匹配用「startsWith」但没做边界(`/app` 匹配了 `/app-abc`)
pathname.startsWith(prefix)                                // ⚠️
// ✅ pathname === prefix || pathname.startsWith(prefix + '/')

// ③ 前缀匹配没排序(`/app` 抢走 `/app-a`)
apps.find((a) => pathname.startsWith(a.prefix))            // ❌
// ✅ 按「前缀长度」降序

// ④ 子应用没设 basename(内部路由解析错位)
<BrowserRouter>                                            // ⚠️ 没有 basename
// ✅ <BrowserRouter basename="/app-a">

// ⑤ 子应用用「相对路径的 assets」(以基座域名解析 → 404)
// ✅ 构建时设绝对 publicPath(见 10.10)

// ⑥ 主应用 useEffect 依赖里放了 `location`(每次路由变化重挂子应用)⭐
useEffect(() => { mount(); return unmount; }, [app, location]);   // ❌ 白屏/状态丢失
// ✅ 只依赖「app」(切换应用才重挂)

// ⑦ 子应用内部导航「整页刷新」(丢了主应用状态)
location.href = '/app-a/detail';                            // ❌
// ✅ 用 history.pushState(或子应用自己的 router)

// ⑧ 「后退」时主应用把子应用卸载了(子应用「闪一下」)
// ⚠️ 通常是因为「匹配逻辑」在 popstate 时短暂返回 null
// ✅ 匹配逻辑要「稳定」;⭐ 或用「上一次匹配的应用」做「过渡保留」

// ⑨ 「菜单高亮」与「子应用内部路由」不一致(菜单还是上一个)
// ✅ 子应用主动上报(`onRouteChange`)或主应用订阅 route-bridge

// ⑩ 「面包屑」由主应用渲染但内容来自子应用(信息不完整)
// ✅ 两种做法:
//    · 子应用上报「面包屑数据」(主应用只负责渲染)
//    · 面包屑「渲染在子应用容器内」(子应用自己负责)

// ⑪ 子应用的「404」显示在「整页」(把主应用导航栏也替换了)
// ✅ 子应用 404 应该渲染在「子应用容器内」

// ⑫ 「面包屑/标题」在「子应用加载完成前」是错的(先用主应用的兜底)
// ✅ 让主应用有「兜底标题」(如「应用 A」),子应用加载完再更新

// ⑬ 多个子应用「都想响应同一个 URL」→ 竞争渲染
// ✅ 「前缀唯一」(一个前缀只属于一个应用);⭐ 建「前缀注册表」并检查冲突

// ⑭ 「子应用内部的 hash 路由」与「主应用的 hash」冲突
// ⚠️ 用 HashRouter 的子应用与「主应用的 hash 锚点」会打架
// ✅ 微前端里尽量用「history 路由」(不要用 hash 路由)

// ⑮ 「预取/懒加载」时不知道「用户接下来要去哪个子应用」
// ✅ 用「路由桥」预测(如「菜单 hover」时预加载对应子应用的 chunk)

// ⑯ 「子应用之间的跳转」(A 应用里点链接跳到 B 应用)
// ⚠️ 如果直接 pushState('/app-b/...') → 主应用需要「感知并切换子应用」
// ✅ 走「路由桥」(它会通知主应用重新匹配)→ 主应用卸载 A、挂载 B
//    ⚠️ 注意「A 卸载时清理干净」(见 10.10 的 unmount 三件事)

// ⑰ 「子应用自己维护一份路由表」但主应用不知道(无法做「菜单」)
// ✅ 子应用「上报路由元信息」(路径 → 标题/图标)或「主应用集中维护」

// ⑱ 「鉴权」在子应用里做(每个子应用各写一套)
// ⭐ 重复 + 不一致
// ✅ 鉴权在主应用(`beforeLoad` 风格的前缀守卫);子应用只做「更细的」权限

// ⑲ 「子应用的路由变化」没有触发「埋点上报」(PV 统计漏了)
// ✅ 用 route-bridge 统一上报(⭐ 一处搞定所有子应用)

// ⑳ 「HMR/热更新」时重复劫持 history(pushState 被包了多层)
// ✅ 用「模块级标志」防重(本实现的 `installed`)
// ⑯ 的完整处理(子应用之间跳转)
// 子应用 A 里:
function LinkToAppB() {
  return (
    <button
      onClick={() => {
        // ⭐ 走「路由桥」而不是裸 pushState ——
        //    它会让「主应用」感知到「前缀变了」→ 切换子应用
        routeBridge.navigate('/app-b', '/detail/123');
      }}
    >
      去应用 B
    </button>
  );
}

// 主应用侧:订阅路由变化 → 重新匹配 → 切换子应用
function MicroAppSwitcher() {
  const { match } = useMatchedAppRoute(CHILD_APPS);

  // ⭐ 当「匹配到的应用」变化时:
  //    · 旧应用 unmount(⭐ 必须清理干净:unmount React root + 订阅 + 定时器)
  //    · 新应用 mount
  //    这正是「MicroAppHost 的 useEffect 依赖 app」所做的事
  if (!match) return <MainNotFound />;      // ⭐ 无匹配 → 主应用 404
  return <MicroAppHost app={match.app} />;
}
// ⑬ 的完整处理(前缀注册表 + 冲突检查)
export function createPrefixRegistry() {
  const map = new Map<string, string>();     // prefix → appName

  return {
    register(prefix: string, appName: string) {
      // ⭐ ① 完全重复
      if (map.has(prefix)) {
        throw new Error(`前缀冲突:${prefix} 已被 ${map.get(prefix)} 占用`);
      }
      // ⭐② 「前缀包含」冲突(`/app` 与 `/app-a`)——⚠️ 允许但要有意识
      for (const [existing, owner] of map) {
        if (prefix.startsWith(`${existing}/`) || existing.startsWith(`${prefix}/`)) {
          console.warn(
            `[route-bridge] 前缀「${prefix}」与「${existing}」存在包含关系(${appName} vs ${owner})——` +
            `请确认匹配顺序(按长度降序)符合预期`
          );
        }
      }
      map.set(prefix, appName);
    },
    list: () => [...map.entries()].map(([prefix, appName]) => ({ prefix, appName })),
  };
}

六、面试延伸

  1. 「微前端里路由怎么协同?」
    先回答「归谁管」:① ⭐ 主应用全托管——子应用「只渲染不管路由」(主应用解析 URL 并把路由信息当 props 传进去),只有一份路由真相,「后退/深链接」天然正确,但子应用不能有独立路由树;② ⭐ 子应用自治——子应用有自己的 router(用 basename 约束到自己的前缀),主应用只按 URL 前缀「挂载/卸载」它,⭐ 大多数微前端用这种;③ 混合(主应用管顶层前缀、子应用管内部嵌套)。关键实现是「让两套 history 保持一份真相」:共享同一个 history 对象,或者 ⭐ 劫持 pushState/replaceState + 派发自定义事件(因为 pushState 不触发 popstate,这是「主应用感知不到子应用导航」的根源)。

  2. 「为什么『两套 history』会不同步?」
    因为 history.pushState() 不会触发 popstate 事件——⭐ 只有「浏览器前进/后退」才会触发 popstate。所以:① 子应用内部点击链接 → 子应用 router 调 pushState → URL 变了、子应用 UI 也变了,但主应用完全不知道(它的 useLocation 不会更新)→ 「菜单高亮/面包屑/埋点」全部滞后;② 用户按后退 → 触发 popstate → 主应用与子应用都收到 → 两边都处理一次 → 可能「主应用卸载子应用、子应用又想渲染自己」的竞争。解法(按推荐度):① ⭐ 共享同一个 history 对象(最彻底,只有一份真相);② ⭐ 劫持 pushState/replaceState + 用微任务派发自定义路由事件(让所有听众都能感知);③ 子应用「主动上报」(简单但容易漏)。

  3. 「pushState 为什么不触发 popstate?」
    这是规范的设计:popstate 的语义是「历史上『当前条目』发生了变化」——它由浏览器的前进/后退(以及 history.go()/back()/forward())触发。而 pushState/replaceState 是「脚本主动修改历史」——⛔ 它们不会触发 popstate(因为「脚本自己知道它做了什么」)。⭐ 后果:在一个「多个 router 共存」的环境(微前端)里,谁调了 pushState,只有它自己知道 → 其它 router 全部失联。这就是「劫持 pushState」的必要性——把「脚本主动改历史」这件事也变成「可被监听的事件」。(⚠️ 注意:pushState 也不会触发 hashchange,即使只改了 hash。)

  4. 「404 归谁显示?」
    ⭐ 原则是「谁匹配失败谁负责」:URL 分「前缀 + 子应用内部路径」两段——① 前缀不认识 → 主应用 404(并带「返回首页」);② 前缀对了但内部路径不认识 → ⭐ 子应用自己的 404(且应渲染在子应用容器内,保持主应用导航栏可见)。两个必须避免的反模式:① ⭐ 主应用「贪婪兜底」(path: '*' 放在前缀路由之前)→ 会把「子应用内部路径」全部吃掉 → 表现为「子应用的深层页面全 404」;② 子应用越权兜底 → 表现为「A 应用渲染了 B 应用的内容」。防御手段:主应用的兜底路由放在所有子应用前缀之后;子应用 router 用 basename 约束自己的范围。

  5. 「深链接(直接访问子应用的深层页面)怎么保证能工作?」
    链路是「主应用按前缀挂载 → 子应用按 basename 解析内部路径」:① 浏览器打开 /app-a/detail/123 → 主应用读 URL、前缀匹配成功(/app-a)→ 挂载子应用 A;② 子应用 A 启动,用 basename /app-a 解析出内部路径 /detail/123 → 渲染详情页。⭐ 关键是「主应用不应该去解析 /detail/123」(那是子应用的私事)——它只做「前缀匹配」。三个常见失败点:① 主应用把「不认识的路径」当 404(✅ 判据是「前缀匹配成功就交给子应用」);② 子应用的静态资源用相对路径(以基座域名解析 → 404,✅ 构建时设绝对 publicPath);③ 子应用的路由库没配 basename(路径解析错位)。

  6. 「微前端里 useEffect 的依赖数组为什么特别容易写错?」
    因为「路由变化」是高频事件,而「子应用的挂载/卸载」应该是低频事件。❌ 如果把 location 放进挂载 effect 的依赖(useEffect(() => { mount(); return unmount; }, [app, location]))→ ⭐ 每次路由变化都「卸载再挂载」子应用 → 表现为「子应用内部一导航就白屏 / 状态全丢 / 反复请求」。✅ 依赖只放 app(只有「切换到另一个应用」才重挂)。那「路由变化」怎么通知子应用?① ⭐ 子应用自己监听(它有自己的 router,且共享同一个 history);② 或者主应用把 location 通过「ref / 事件」传过去,让子应用「订阅」而不是「被重挂」。⭐ 这是一个「把高频事件误当成低频事件的依赖」的典型陷阱。

  7. 「子应用之间怎么跳转?」
    ⭐ 走「路由桥」(而不是裸 pushState):① 子应用 A 里调 routeBridge.navigate('/app-b', '/detail/123');② 路由桥「改 URL + 通知所有听众」;③ 主应用重新匹配 → 发现「匹配到的应用变了」 → 卸载 A(⭐ A 必须清理干净:root.unmount() + 订阅/定时器/第三方实例,见 10.10)、挂载 B。关键点:① 不要在 A 里直接改 location.href(会整页刷新、丢主应用状态);② A 的 unmount 必须彻底(否则会出现「A 的事件监听还在响应」「A 的定时器还在跑」);③ ⭐ 「前缀注册表」要检查冲突(两个应用抢同一个前缀会互相渲染)。

  8. 「微前端的路由协同,最容易被忽略的三件事?」
    ① ⭐ 「后退」时的一致——因为 popstate 会「被所有听众收到」,容易「主应用卸载了子应用」或「处理两次」,需要「匹配逻辑稳定」+「依赖数组正确」;② ⭐ 「面包屑 / 页面标题 / 埋点 PV」——这些「跨应用共享的信息」需要统一的采集点(⭐ 用路由桥做「一处上报」),否则「子应用的 PV 全丢了」;③ ⭐ 「鉴权」的一致——每个子应用各写一套鉴权 → 重复且不一致,正确做法是「主应用做前缀级守卫(未登录/无权限在前缀层拦住),子应用只做更细的权限」(安全校验仍在后端,见 6.12)。另外一个常被忽略的:子应用内部的 404 与「主应用的兜底路由」的边界。

一句话速记

微前端路由协同先定「归谁管」:主应用全托管(一份真相,最简)/ ⭐ 子应用自治(自带 router + basename,最常用)/ 混合;⭐ 最核心的坑是「两套 history 不同步」——因为 pushState/replaceState 不触发 popstate(只有浏览器前进后退才触发),所以「子应用内部导航,主应用感知不到」,✅ 解法是「共享同一个 history 对象」或 ⭐ 「劫持 pushState/replaceState + 用微任务派发自定义路由事件」(让所有听众都能感知);前缀匹配要边界正确(pathname === prefix || pathname.startsWith(prefix + '/'),⭐ 并按前缀长度降序,否则 /app 抢走 /app-a);⭐ 404 归属「谁匹配失败谁负责」(前缀不认识 → 主应用 404;前缀对但内部路径错 → 子应用 404 且渲染在子应用容器内),⚠️ 主应用不能贪婪兜底(否则吃掉子应用深层页面);深链接靠「主应用按前缀挂载 → 子应用按 basename 解析内部路径」(主应用不该解析子应用的内部路径);⭐ useEffect 依赖数组只放 app(❌ 放 location 会导致「每次路由变化重挂子应用」→ 白屏/状态丢失),「路由变化」让子应用自己监听或订阅;子应用间跳转走路由桥(并保证卸载彻底 + 前缀不冲突);面包屑/标题/PV 埋点用路由桥「一处采集」;鉴权在主应用做前缀级守卫(子应用只做更细的)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.14 微前端下的路由协同

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫