micro-app 404 问题排查与修复

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-* 参数里,刷新、前进后退时靠它恢复

记住两个规则,后面会反复用到:

  1. 恢复时,URL 里的 app-* 参数优先,default-page 靠后 。micro-app 初始化时先看 URL 参数里有没有子应用路径,有就直接用,没有才用 <micro-app> 上的 default-page
  2. 子应用是 history 路由,路径里不该出现 #。一旦出现,一定是脏数据,不是合法页面。

三、问题与修复对照

一共 4 个问题。子应用改 1 处(治本),主应用加 3 道防线(兜底)。每条互不影响,可以分开上线。

3.1 根本原因:子应用"补跳"时把带 hash 的完整地址当成了 path

什么问题

子应用的页面是按权限动态注册的。第一次进 /pages/detail 时,这个路由还没注册,守卫要先做三件事:

  1. 拉权限,生成能访问的路由;
  2. router.addRoute() 把它们注册进去;
  3. 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、匹配数量和 参数名,避免泄露敏感信息。

五、怎么验证修好了

  1. 在生产环境从主应用反复进子应用、刷新,看还复不复现。
  2. 触发动态路由补跳时,看 replay-after-async-routes 日志:nextTarget.hash 必须是空的, nextTarget.path 必须是纯业务路径(不带 #)。
  3. 拿同一段(相同 traceId)的 micro-router-before-syncmicro-router-after-sync 对比:如果路径从正常业务路径变成了带 # 的,说明污染发生在 micro-app 路由同步这一环。
  4. 不带 tag 直接进宿主路由,子应用应该落在默认页(如 /home),URL 里的 app-* 参数不应被写成子应用根路径 /
  5. 清洗日志只应该在 app-* 参数真的带 hash 时出现;正常进入不应触发清洗重进。

六、速查表

# 问题 解决办法 改哪里
1 补跳拿 fullPathpath,hash 写进虚拟路由 传结构化字段 { path, query, hash: '' } 子应用守卫
2 深链 tag 带着坏 hash 进 default-page 用之前 split('#', 1)[0] 切掉 主应用容器
3 URL 里 app-* 参数的脏路径被优先恢复 守卫里清洗参数后 replace 重进 主应用守卫
4 空手进入时子应用根路径 / 被同步回来 注册表 + 容器深链优先/空路径回退(:default-page)+ 指令下发解析 主应用注册表
偶发问题没法定位 两端写同一份日志 + traceId 分段 两端

一句话总结:子应用补跳别拿 fullPathpath;主应用在所有会让 micro-app 读取子应用路径的 入口(tag 深链、app-* 参数、无深链的默认页)把 hash 洗掉、把默认页兜住;最后用统一日志 验证真的修好了。

相关推荐
西安栈上月明软件科技有限公司1 小时前
GEO 友好度检测器技术实现(已开源)
前端
汉堡大王95271 小时前
用 Trae Work 自动化任务,6 分钟生成一份前端生态周报
前端·javascript·人工智能
深圳佛手1 小时前
安装DeepSeek Harness npx @deepseek-ai/dsh web 长时间没反应,安装失败,如何解决?
前端
Csvn1 小时前
事件循环与渲染时机:一文吃透宏任务、微任务和 rAF
前端
章鱼小丸子逃跑中1 小时前
【2025最新版】如何将fnm与node.js安装在D盘?【保姆级安装及人性话理解教程】
前端·javascript·npm·node.js
Larcher2 小时前
从 SDD 到可交付:我如何用规范驱动 AI 做出一个 Chrome 翻译插件
前端·后端·架构
suaizai_2 小时前
AI Agent如何懂你:四层能力拆解
java·前端·人工智能
invicinble2 小时前
把握前端项目的核心(vibecoding)
前端
0end13 小时前
AI Agent 学习笔记(三):上下文工程(下)—— KV Cache、提示词设计与 Agent Skills
前端·aigc·ai编程