
一句话:微前端的路由协同要回答三个问题——「路由归谁管(主应用还是子应用)、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 })),
};
}
六、面试延伸
- 「微前端里路由怎么协同?」
先回答「归谁管」:① ⭐ 主应用全托管——子应用「只渲染不管路由」(主应用解析 URL 并把路由信息当 props 传进去),只有一份路由真相,「后退/深链接」天然正确,但子应用不能有独立路由树;② ⭐ 子应用自治——子应用有自己的 router(用 basename 约束到自己的前缀),主应用只按 URL 前缀「挂载/卸载」它,⭐ 大多数微前端用这种;③ 混合(主应用管顶层前缀、子应用管内部嵌套)。关键实现是「让两套 history 保持一份真相」:共享同一个history对象,或者 ⭐ 劫持pushState/replaceState+ 派发自定义事件(因为pushState不触发popstate,这是「主应用感知不到子应用导航」的根源)。 -
「为什么『两套 history』会不同步?」
因为history.pushState()不会触发popstate事件——⭐ 只有「浏览器前进/后退」才会触发popstate。所以:① 子应用内部点击链接 → 子应用 router 调pushState→ URL 变了、子应用 UI 也变了,但主应用完全不知道(它的useLocation不会更新)→ 「菜单高亮/面包屑/埋点」全部滞后;② 用户按后退 → 触发popstate→ 主应用与子应用都收到 → 两边都处理一次 → 可能「主应用卸载子应用、子应用又想渲染自己」的竞争。解法(按推荐度):① ⭐ 共享同一个history对象(最彻底,只有一份真相);② ⭐ 劫持pushState/replaceState+ 用微任务派发自定义路由事件(让所有听众都能感知);③ 子应用「主动上报」(简单但容易漏)。 -
「
pushState为什么不触发popstate?」
这是规范的设计:popstate的语义是「历史上『当前条目』发生了变化」——它由浏览器的前进/后退(以及history.go()/back()/forward())触发。而pushState/replaceState是「脚本主动修改历史」——⛔ 它们不会触发popstate(因为「脚本自己知道它做了什么」)。⭐ 后果:在一个「多个 router 共存」的环境(微前端)里,谁调了pushState,只有它自己知道 → 其它 router 全部失联。这就是「劫持pushState」的必要性——把「脚本主动改历史」这件事也变成「可被监听的事件」。(⚠️ 注意:pushState也不会触发hashchange,即使只改了 hash。) -
「404 归谁显示?」
⭐ 原则是「谁匹配失败谁负责」:URL 分「前缀 + 子应用内部路径」两段——① 前缀不认识 → 主应用 404(并带「返回首页」);② 前缀对了但内部路径不认识 → ⭐ 子应用自己的 404(且应渲染在子应用容器内,保持主应用导航栏可见)。两个必须避免的反模式:① ⭐ 主应用「贪婪兜底」(path: '*'放在前缀路由之前)→ 会把「子应用内部路径」全部吃掉 → 表现为「子应用的深层页面全 404」;② 子应用越权兜底 → 表现为「A 应用渲染了 B 应用的内容」。防御手段:主应用的兜底路由放在所有子应用前缀之后;子应用 router 用 basename 约束自己的范围。 -
「深链接(直接访问子应用的深层页面)怎么保证能工作?」
链路是「主应用按前缀挂载 → 子应用按 basename 解析内部路径」:① 浏览器打开/app-a/detail/123→ 主应用读 URL、前缀匹配成功(/app-a)→ 挂载子应用 A;② 子应用 A 启动,用 basename/app-a解析出内部路径/detail/123→ 渲染详情页。⭐ 关键是「主应用不应该去解析/detail/123」(那是子应用的私事)——它只做「前缀匹配」。三个常见失败点:① 主应用把「不认识的路径」当 404(✅ 判据是「前缀匹配成功就交给子应用」);② 子应用的静态资源用相对路径(以基座域名解析 → 404,✅ 构建时设绝对publicPath);③ 子应用的路由库没配 basename(路径解析错位)。 -
「微前端里
useEffect的依赖数组为什么特别容易写错?」
因为「路由变化」是高频事件,而「子应用的挂载/卸载」应该是低频事件。❌ 如果把location放进挂载 effect 的依赖(useEffect(() => { mount(); return unmount; }, [app, location]))→ ⭐ 每次路由变化都「卸载再挂载」子应用 → 表现为「子应用内部一导航就白屏 / 状态全丢 / 反复请求」。✅ 依赖只放app(只有「切换到另一个应用」才重挂)。那「路由变化」怎么通知子应用?① ⭐ 子应用自己监听(它有自己的 router,且共享同一个history);② 或者主应用把 location 通过「ref / 事件」传过去,让子应用「订阅」而不是「被重挂」。⭐ 这是一个「把高频事件误当成低频事件的依赖」的典型陷阱。 -
「子应用之间怎么跳转?」
⭐ 走「路由桥」(而不是裸pushState):① 子应用 A 里调routeBridge.navigate('/app-b', '/detail/123');② 路由桥「改 URL + 通知所有听众」;③ 主应用重新匹配 → 发现「匹配到的应用变了」 → 卸载 A(⭐ A 必须清理干净:root.unmount()+ 订阅/定时器/第三方实例,见 10.10)、挂载 B。关键点:① 不要在 A 里直接改location.href(会整页刷新、丢主应用状态);② A 的unmount必须彻底(否则会出现「A 的事件监听还在响应」「A 的定时器还在跑」);③ ⭐ 「前缀注册表」要检查冲突(两个应用抢同一个前缀会互相渲染)。 -
「微前端的路由协同,最容易被忽略的三件事?」
① ⭐ 「后退」时的一致——因为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 埋点用路由桥「一处采集」;鉴权在主应用做前缀级守卫(子应用只做更细的)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!