micro-app 404 问题排查与修复:Vue2 hash 主应用 + Vue3 history 子应用
场景:主应用 Vue 2(hash 路由),子应用 Vue 3(history 路由),用京东 micro-app 接入。 症状:从主应用进子应用时,偶尔 跳到主应用的
/404,而且出问题时地址里子应用路径 末尾会多出一个#/。 本文按"什么问题 → 怎么修"逐条讲,示例代码都是通用写法,可直接套用。
一、问题现象
从主应用菜单进子应用,偶尔跳到主应用 /404。出问题时,主应用地址长这样:
text
http://example.com/#/child?app-child=/cdn/child-app/pages/detail#/
注意 app-child 这个参数的值,末尾多了 #/。接着主应用就把要跳的页面算成了 /, / 没有对应路由,于是进 /404。
二、先搞清楚:这套架构里有三套路由
| 路由 | 谁的 | 干什么用 |
|---|---|---|
#/child |
主应用(hash) | 主应用自己的页面,/child 这页放着 <micro-app> 标签 |
/pages/detail |
子应用(history) | 子应用自己的业务页面 |
app-child=... 参数 |
micro-app | micro-app 把子应用当前页面记在主应用地址的 app-* 参数里,刷新、前进后退时靠它恢复 |
记住两个规则,后面会反复用到:
- 恢复时,URL 里的
app-*参数优先,default-page靠后 。micro-app 初始化时先看 URL 参数里有没有子应用路径,有就直接用,没有才用<micro-app>上的default-page。 - 子应用是 history 路由,路径里不该出现
#。一旦出现,一定是脏数据,不是合法页面。
三、问题与修复对照
一共 4 个问题。子应用改 1 处(治本),主应用加 3 道防线(兜底)。每条互不影响,可以分开上线。
3.1 根本原因:子应用"补跳"时把带 hash 的完整地址当成了 path
什么问题
子应用的页面是按权限动态注册的。第一次进 /pages/detail 时,这个路由还没注册,守卫要先做三件事:
- 拉权限,生成能访问的路由;
router.addRoute()把它们注册进去;next({ ..., replace: true })再跳一次目标地址(下称"补跳")。
第 3 步不能省:导航开始时 vue-router 已经按旧路由表匹配完了,中途 addRoute() 加的路由 不会生效,必须再跳一次才能命中新注册的页面。
坏就坏在旧代码直接 next(to.fullPath)。fullPath 是"完整地址"------把 path、query、hash 拼在一起的字符串。一旦里面有 hash,/pages/detail#/ 这种字符串就被当成 path 传给了 vue-router 和 micro-app:
text
子应用守卫 micro-app 主应用 (hash 模式)
────────── ───────── ─────────────────
next('/pages/detail#/') ───► 虚拟路由被写脏 ───► URL 追加参数:
(旧实现) app-child=/cdn/child-app/ #/child?app-child=/cdn/.../detail#/
pages/detail#/
同步回写 URL 和 地址里出现第二个 '#',
history.state 主应用解析时把要跳的页面
算成 '/',无匹配 ───► /404
两个要点:
- 主应用的
/404只是这条链的最后一步,不是原因本身; /cdn/child-app是子应用静态资源的部署前缀,是正常路径,后面所有清洗都要保留它, 不能当成垃圾一起删掉。
怎么修
补跳时传结构化字段,不用 fullPath;在微前端环境里把 hash 直接清空:
js
// child-app/src/router/guard.js ------ Vue 3 + vue-router 4
import router from './index';
import { fetchUserPermission } from '../api';
// micro-app 注入的环境标识,子应用里用它判断"我是不是跑在微前端里"
const IN_MICRO_APP = Boolean(window.__MICRO_APP_ENVIRONMENT__);
let asyncRoutesReady = false;
router.beforeEach(async (to, from, next) => {
const token = getToken();
if (!token) {
return next({ path: '/login', query: { redirect: to.fullPath } });
}
if (!asyncRoutesReady) {
const permission = await fetchUserPermission();
buildRoutesByPermission(permission).forEach((route) => router.addRoute(route));
asyncRoutesReady = true;
// 修复:补跳只传 path / query / hash 三个字段,绝不用 to.fullPath。
// 微前端环境下 hash 一律清空,别把主应用的 hash 带进虚拟路由。
next({
path: to.path,
query: to.query,
hash: IN_MICRO_APP ? '' : to.hash,
replace: true,
});
return;
}
next();
});
3.2 深链入口:坏 hash 藏在 tag 参数里,下次初始化又被用一遍
什么问题
主应用跳子应用时,会把目标页面放在加密的深链参数(下文叫 tag)里带给容器组件。线上发现: 登录回跳这类场景会解析 URL 里的 app-child 再重新写进 tag。如果这时虚拟路由已经带上了 #/404,坏 hash 就跟着 tag 又进了一次子应用初始化------相当于脏数据自我复制。
怎么修
做 default-page 之前把 hash 切掉,其他部分(部署前缀、query)原样保留:
js
// main-app/src/utils/micro-route.js ------ 通用工具,无框架依赖
// 切掉微应用路径里的 hash;部署前缀和 query 原样保留
export function normalizeMicroAppPath(path = '') {
return typeof path === 'string' ? path.split('#', 1)[0] : '';
}
// 有深链用深链,深链无效或为空就用默认页(默认页也会顺手清洗一遍)
export function resolveMicroAppDefaultPage(path = '', fallbackPath = '') {
return normalizeMicroAppPath(path || fallbackPath);
}
效果:
text
/cdn/child-app/pages/detail?room_id=1#/404 ───► /cdn/child-app/pages/detail?room_id=1
3.3 恢复优先级:URL 里的 app-* 参数比 default-page 先被读取
什么问题
规则 1 说过:micro-app 先用 URL 里的 app-* 参数恢复子应用路径。所以就算 URL 没有 tag, 下面这种地址照样出问题:
text
#/child?app-child=/cdn/child-app/pages/detail#/404
3.2 那道入口过滤拦不住它------脏路径已经在 URL 参数里了,轮不到 default-page 出场。
怎么修
在主应用全局守卫的最开头、<micro-app> 渲染之前,把所有 app-* 参数里的 hash 洗掉, 然后 replace 重进一次:
js
// main-app/src/utils/micro-route.js(续)
// 清洗 query 里所有 app-* 参数的 hash;没变化就原样返回,避免多余的重进
export function normalizeMicroAppRouteQuery(query = {}) {
let hasChange = false;
const normalized = {};
Object.keys(query).forEach((key) => {
const value = query[key];
if (!key.startsWith('app-') || typeof value !== 'string') {
normalized[key] = value;
return;
}
const clean = normalizeMicroAppPath(value);
hasChange ||= clean !== value;
normalized[key] = clean;
});
return hasChange ? normalized : query;
}
js
// main-app/src/router/index.js ------ Vue 2 + vue-router 3,放在守卫最顶部
import { normalizeMicroAppRouteQuery } from '../utils/micro-route';
router.beforeEach(async (to, from, next) => {
// 清洗 app-* 参数并 replace 重进,必须赶在 <micro-app> 渲染之前
const normalizedQuery = normalizeMicroAppRouteQuery(to.query || {});
if (normalizedQuery !== to.query) {
next({ path: to.path, query: normalizedQuery, hash: to.hash, replace: true });
return;
}
// ......原有的 登录校验 / 动态权限路由注册与补跳 逻辑......
});
3.4 空手进入:没有深链时,子应用的根路径 / 被同步回主应用
什么问题
不带深链直接进子应用时,如果没有任何兜底,default-page 是空的,首次挂载会把子应用的 根路径 / 同步回主应用地址。/ 也不是有效业务页面,是另一个污染源。
怎么修
建一张子应用注册表,写清每个子应用的默认页,再做两层兜底:
js
// main-app/src/micro/apps.js ------ 子应用注册表
export const MICRO_APPS = [
{
name: 'app-child', // <micro-app name>
entry: '//cdn.example.com/child/', // 子应用部署地址
base: '/cdn/child-app', // 部署前缀(清洗时保留)
mainRoute: 'ChildApp', // 主应用宿主路由 name
defaultPage: '/home', // 没有深链时的默认页
},
];
export const getMicroAppDefaultPage = (name) =>
MICRO_APPS.find((app) => app.name === name)?.defaultPage || '';
第 1 层:容器组件有深链用深链,没有就用默认页
vue
<!-- main-app/src/views/ChildAppHost.vue ------ Vue 2 -->
<template>
<micro-app :name="appName" :url="url" :default-page="initialRoute" />
</template>
<script>
import { MICRO_APPS, getMicroAppDefaultPage } from '@/micro/apps';
import { resolveMicroAppDefaultPage } from '@/utils/micro-route';
export default {
data() {
return {
appName: 'app-child',
initialRoute: '', // 见 created
};
},
computed: {
appConfig() {
return MICRO_APPS.find((app) => app.name === this.appName);
},
url() {
return this.appConfig.entry;
},
},
created() {
// 深链参数 tag(示例用明文 JSON,实际项目可以加密)
const tag = this.$route.query.tag ? JSON.parse(this.$route.query.tag) : {};
const { path, params } = tag;
// resolveMicroAppDefaultPage 会先切掉 hash(3.2 的工具),
// 深链为空或无效时退回注册表默认页
this.initialRoute = joinQuery(
resolveMicroAppDefaultPage(path, getMicroAppDefaultPage(this.appName)),
params
);
},
};
</script>
第 2 层:下发跳转指令前,把空路径换成默认页
js
// main-app/src/micro/navigate.js
import microApp from '@micro-zoe/micro-app';
import router from '@/router';
import { getMicroAppDefaultPage } from './apps';
// path 为空就用默认页,别给已激活的子应用发一条空路由指令
function resolveMicroAppPath(appName, path) {
return typeof path === 'string' && path ? path : getMicroAppDefaultPage(appName);
}
export async function navigateToMicroApp({ appName, path, params }) {
const targetPath = resolveMicroAppPath(appName, path);
if (microApp.getActiveApps().includes(appName)) {
// 已激活:发数据让子应用自己跳
microApp.setData(appName, { 'router-change': { path: targetPath, params } });
} else {
// 未激活:走宿主路由,带上 tag 深链
await router.push({
name: 'ChildApp',
query: { tag: JSON.stringify({ path: targetPath, params }) },
});
}
}
四、问题偶发、没法回看 → 两端写同一份路由日志
什么问题
上面这些 404 都是偶发的,没有日志就没法知道脏数据是哪一环混进来的。
怎么修
主应用和子应用把日志写进同一个存储键,每次挂载算一段:
js
// shared/debug-log.js ------ 两端同一份;存储用 localforage / IndexedDB 都行
const STORE_KEY = 'micro-route-sync'; // 两端同键,导出后能对上时间线
const MAX_ENTRIES = 50;
let traceId = '';
let cache = [];
export function startTrace(source) {
// 每次挂载生成新 traceId,这一段里的日志都带同一个 id
traceId = `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
logRouteSync(source, 'sync-session-start', {});
return traceId;
}
export function logRouteSync(source, stage, route = {}, extra = {}) {
cache.push({
ts: Date.now(),
source, // 'main' | 'child'
traceId,
stage,
route: {
path: route.path || '',
hash: route.hash || '',
matchedCount: route.matched?.length || 0,
queryKeys: Object.keys(route.query || {}), // 只记参数名,不记值
},
...extra,
});
if (cache.length > MAX_ENTRIES) cache.shift();
localforage.setItem(STORE_KEY, cache);
}
主应用启动时给 micro-app 的路由同步加上边界打点:
js
// main-app/src/micro/route-debug.js
import microApp from '@micro-zoe/micro-app';
import { logRouteSync } from 'shared/debug-log';
export function registerMicroRouteDebug(router) {
microApp.router.beforeEach((to, from, appName) => {
logRouteSync('main', 'micro-router-before-sync', router.currentRoute, { appName });
});
microApp.router.afterEach((to, from, appName) => {
logRouteSync('main', 'micro-router-after-sync', router.currentRoute, { appName });
});
}
子应用在守卫的关键节点打点(节选):
js
// child-app/src/router/guard.js(节选)
import { logRouteSync } from 'shared/debug-log';
router.beforeEach(async (to, from, next) => {
logRouteSync('child', 'before-each', to, { fromPath: from.path });
if (!asyncRoutesReady) {
const permission = await fetchUserPermission();
buildRoutesByPermission(permission).forEach((route) => router.addRoute(route));
asyncRoutesReady = true;
const nextTarget = {
path: to.path,
query: to.query,
hash: IN_MICRO_APP ? '' : to.hash,
replace: true,
};
logRouteSync('child', 'replay-after-async-routes', to, {
nextTarget: { path: nextTarget.path, hash: nextTarget.hash }, // 验证 hash 是不是空的
});
next(nextTarget);
return;
}
logRouteSync('child', 'after-each', to);
next();
});
注意:日志里不写 token、参数值、完整路由对象、权限明细,只写路径、hash、匹配数量和 参数名,避免泄露敏感信息。
五、怎么验证修好了
- 在生产环境从主应用反复进子应用、刷新,看还复不复现。
- 触发动态路由补跳时,看
replay-after-async-routes日志:nextTarget.hash必须是空的,nextTarget.path必须是纯业务路径(不带#)。 - 拿同一段(相同
traceId)的micro-router-before-sync和micro-router-after-sync对比:如果路径从正常业务路径变成了带#的,说明污染发生在 micro-app 路由同步这一环。 - 不带
tag直接进宿主路由,子应用应该落在默认页(如/home),URL 里的app-*参数不应被写成子应用根路径/。 - 清洗日志只应该在
app-*参数真的带 hash 时出现;正常进入不应触发清洗重进。
六、速查表
| # | 问题 | 解决办法 | 改哪里 |
|---|---|---|---|
| 1 | 补跳拿 fullPath 当 path,hash 写进虚拟路由 |
传结构化字段 { path, query, hash: '' } |
子应用守卫 |
| 2 | 深链 tag 带着坏 hash 进 default-page |
用之前 split('#', 1)[0] 切掉 |
主应用容器 |
| 3 | URL 里 app-* 参数的脏路径被优先恢复 |
守卫里清洗参数后 replace 重进 |
主应用守卫 |
| 4 | 空手进入时子应用根路径 / 被同步回来 |
注册表 + 容器深链优先/空路径回退(:default-page)+ 指令下发解析 |
主应用注册表 |
| ★ | 偶发问题没法定位 | 两端写同一份日志 + traceId 分段 |
两端 |
一句话总结:子应用补跳别拿 fullPath 当 path;主应用在所有会让 micro-app 读取子应用路径的 入口(tag 深链、app-* 参数、无深链的默认页)把 hash 洗掉、把默认页兜住;最后用统一日志 验证真的修好了。