返回文章

避免 iframe 内部跳转污染宿主页面的浏览器历史记录

通过在 PC iframe 场景切换到 Vue Router 内存历史,隔离内嵌页面与宿主页面的浏览器历史栈,并适配返回逻辑、路由位置与页面切换动画。

在把 H5 页面嵌入 PC 端 iframe 后,一个很容易被忽略的问题是:内嵌页面的路由跳转会写入浏览器历史栈,进而污染外层页面的前进后退行为。

例如,PC 页面打开一个 iframe,iframe 内从首页跳到列表,再跳到详情。用户点击浏览器返回时,浏览器可能先回到 iframe 的列表页,而不是回到 PC 页面真正想返回的位置。更麻烦的是,iframe 内的“返回”按钮和宿主页面的“返回”按钮开始共享同一套历史记录,两个页面的导航职责混在了一起。

这类问题的关键不在于再增加一个返回按钮判断,而在于先隔离两套导航历史:普通 H5 使用浏览器历史,PC iframe 使用 Vue Router 自己维护的内存历史。

普通 H5 与 PC iframe 使用不同历史实现

Vue Router 4 提供了两种适合这个场景的 history 实现:

运行场景 history 实现 历史记录归属
普通 H5 createWebHistory 当前浏览器页面
PC iframe createMemoryHistory iframe 内的 Vue Router

普通 H5 仍然需要浏览器地址栏、刷新和系统返回都能工作,所以继续使用 createWebHistory。PC iframe 则不应该把每次内部跳转同步给宿主浏览器,因此使用 createMemoryHistory,让 router.push()router.go() 只操作内存中的路由队列。

路由创建可以集中封装成一个工厂函数:

const routerBase = import.meta.env.VITE_PUBLIC_PATH || '/platform-h5/';

const isPcIframe = () =>
  window.parent !== window &&
  new URLSearchParams(window.location.search).get('platform') === 'PC';

const createRouterHistory = () => {
  if (!isPcIframe()) {
    return createWebHistory(routerBase);
  }

  return createMemoryHistory(routerBase);
};

这样做的好处是,差异只存在于 history 的创建阶段,路由表、导航守卫、页面缓存和页面标题逻辑都可以继续复用。

为什么内存历史还要恢复首次地址

createMemoryHistory() 不会自动读取浏览器当前 URL。它的初始位置是一个空的起始位置,如果直接创建后等待应用启动,刷新详情页时,Vue Router 可能从根路径重新开始解析,而不是从浏览器当前地址进入详情页。

因此,创建内存 history 后,需要把当前页面地址放入它的初始位置:

const getInitialMemoryRoute = () => {
  const normalizedBase = routerBase.replace(/\/$/, '');
  const routePath = window.location.pathname.startsWith(normalizedBase)
    ? window.location.pathname.slice(normalizedBase.length) || '/'
    : window.location.pathname;

  return `${routePath}${window.location.search}${window.location.hash}`;
};

const history = createMemoryHistory(routerBase);
history.replace(getInitialMemoryRoute());
return history;

这里有两个细节:

  1. 部署目录可能是 /platform-h5/,路由匹配需要先移除这个 base,否则会把部署前缀当成业务路由的一部分。
  2. searchhash 不能丢失。像 platform=PC、业务 id 或锚点信息,都可能参与页面初始化。

返回逻辑不能再只看 history.state.back

普通 Web History 会在浏览器 history state 中维护 back 等信息,因此原有代码常用下面的方式判断是否已经位于栈底:

if (!router.options.history.state.back) {
  closeWebView();
} else {
  router.go(-1);
}

createMemoryHistory()state 并不提供 Web History 的 back 字段。PC iframe 如果继续走这段判断,就会被错误当成“没有上一页”,从而调用关闭 WebView 的原生接口。

配套修改应该把运行环境判断放在前面:

const isPcIframe =
  globalStatusStore.platformName === 'PC' && window.parent !== window;

if (!isPcIframe && !router.options.history.state.back) {
  platformApi.closeWebView({ callback: () => {} });
} else {
  router.go(-1);
}

最终的行为变成:

场景 有上一页 没有上一页
普通 H5 router.go(-1) 关闭 WebView
PC iframe router.go(-1) 停留在内存历史首项,由宿主页面负责关闭 iframe

这里的“PC iframe 栈底不关闭 WebView”是有意设计:iframe 本身通常不是一个应该主动关闭宿主 WebView 的页面,关闭动作应由 PC 容器决定。

需要同步检查的隐藏耦合

切换 history 后,不能只搜索 router.go(-1)。凡是直接读取浏览器历史状态的逻辑,都可能与内存 history 不兼容。

例如页面切换动画可能通过下面的代码判断是否发生了回退:

const isBack = fromRoutePosition > window.history.state.position;

在 PC iframe 中,Vue Router 的位置已经保存在内存队列里,而不是 window.history.state.position。这类代码需要改为使用 Vue Router 导航信息、在应用层维护自己的位置计数,或者针对 memory history 单独处理。否则路由虽然不再污染外层浏览器,动画判断仍可能失效,甚至在 window.history.state 为空时抛出异常。

这套方案的边界

这不是把所有 iframe 都切成内存路由,而是用明确的运行条件区分场景。platform=PC 的约定必须由宿主页面稳定传入,并且 iframe 检测不能只依赖 platform,否则普通页面带上同名参数时也可能错误切换 history。

另外,内存 history 只解决导航记录隔离,不会自动解决宿主页面和 iframe 之间的通信。若 PC 容器需要感知 iframe 当前路由,应通过 postMessage 或现有桥接协议显式同步,而不是重新共享浏览器 history。

总结

问题的根因是两套页面共享了同一份浏览器历史,而不是某个返回按钮少写了一个条件。

一个可维护的解决方案应当分三步:

  1. 普通 H5 使用 createWebHistory,PC iframe 使用 createMemoryHistory
  2. 创建内存 history 后恢复首次加载的完整路由地址。
  3. 返回逻辑和动画逻辑都不要假设 history 一定拥有 Web History 的 state.backstate.position

这样,iframe 内部仍然保留完整的 Vue Router 导航体验,同时不会再把内部页面跳转泄漏到外层浏览器的历史栈中。


DLC:历史隔离成功了,返回动画却反了

上面的方案落地后,iframe 内部跳转已经不会再污染宿主页面,但很快又暴露出第二个问题:同一条返回路径,在普通 H5 中使用右滑动画,在 PC iframe 中却可能使用左滑动画,甚至完全没有进入特殊路由的动画判断。

路由本身确实回到了上一页,因此问题很容易被误判为 goBack() 少设置了一次动画方向。一个直觉式修复是在 PC iframe 分支中直接执行:

if (isPcIframe()) {
  router.go(-1);
  return;
}

这只能保证“返回动作发生”,不能保证返回后的动画仍然走原来的判断链。它还把 PC iframe 和普通 H5 拆成了两套返回流程:以后显式动画、iOS 点击标记或其他导航副作用发生变化时,两边很容易再次漂移。

真正的断点仍然是 history.state.position

页面布局原本通过浏览器历史位置判断当前导航是不是回退:

const isBack = fromRoutePosition > window.history.state.position;
fromRoutePosition = window.history.state.position;

这段判断位于动画决策的最前面,后面才会调用 special-routes.ts 中的 isPushAnimation()isBackAnimation()。切换到 createMemoryHistory() 后,iframe 内的 push 和 go 不再更新 window.history.state.position。结果可能有两种:

  1. 浏览器位置始终不变,代码无法识别真实回退。
  2. window.history.state 为空,监听器直接抛错,后面的特殊路由判断根本没有执行机会。

所以,只修改返回按钮仍然修错了层级。既然动画依赖的是“当前路由在栈中的位置”,这个位置就应该由 history 层统一提供。

给内存 history 补上可观察的位置

Vue Router 没有公开 createMemoryHistory() 内部队列的 position,可以在创建 history 时同步维护当前位置和有效栈长度:

let memoryHistoryPosition = 0;
let memoryHistoryLength = 1;

export const getRouterHistoryPosition = () =>
  isPcIframe()
    ? memoryHistoryPosition
    : window.history.state?.position ?? 0;

const history = createMemoryHistory(routerBase);
history.replace(getInitialMemoryRoute());

const push = history.push.bind(history);
const go = history.go.bind(history);

history.push = (to, data) => {
  memoryHistoryPosition += 1;
  memoryHistoryLength = memoryHistoryPosition + 1;
  push(to, data);
};

history.go = (delta, triggerListeners) => {
  memoryHistoryPosition = Math.max(
    0,
    Math.min(memoryHistoryPosition + delta, memoryHistoryLength - 1),
  );
  go(delta, triggerListeners);
};

这里不能只维护一个不断加减的数字,还要记录有效栈长度。用户回退后再次 push 时,原来的 forward 记录会被截断;将长度更新为 memoryHistoryPosition + 1,才能和内存 history 的真实行为保持一致。go() 也需要限制上下界,避免在栈底继续返回后出现负数位置。

普通 H5 仍然读取浏览器维护的 state.position,PC iframe 则读取应用维护的内存位置。动画监听不再关心当前使用哪一种 history:

let fromRoutePosition = getRouterHistoryPosition();

watch(router.currentRoute, (to, from) => {
  const currentRoutePosition = getRouterHistoryPosition();
  const isBack = fromRoutePosition > currentRoutePosition;
  fromRoutePosition = currentRoutePosition;

  // 后续继续执行原有动画判断
});

保持的是整条动画优先级,而不是强制调用特殊路由表

原有动画逻辑通常不只有 special-routes.ts,而是存在明确的优先级:

  1. 调用方显式指定的动画方向。
  2. 根据 history position 确认的真实回退。
  3. special-routes.ts 中配置的特殊前进、返回关系。
  4. 根据路由层级深度推断的默认方向。

真实回退一旦在第 2 步被识别,就会和普通 H5 一样直接使用右滑动画,不需要再查询特殊路由表。special-routes.ts 仍然负责无法单靠 position 或路径深度表达的路由关系。这里要恢复的是整条判断链和原有优先级,而不是让每一次返回都强制经过路由映射。

最后,goBack() 也可以重新合并为一条流程。PC iframe 只跳过“关闭原生 WebView”的栈底判断,实际回退继续和 H5 共用同一个出口:

if (!isPcIframe() && !window.history.state?.back) {
  platformApi.closeWebView({ callback: () => {} });
} else {
  if (animationDirection) {
    globalStatusStore.setAnimationDirection(animationDirection);
  }
  router.go(-1);
}

这次补丁带来的结论是:替换路由 history 不只是更换存储介质,还必须迁移所有依赖该介质衍生状态的逻辑。 返回按钮、页面动画、导航守卫和缓存策略看似属于不同模块,只要它们读取过 window.history.state,就都处在这次迁移的影响范围内。