《互联网产品的客户端架构·端形态与体验基础》6.2 页面栈与返回语义

返回键的行为不是 UI 细节,而是页面栈的公开契约。本节把 push、replace、back 三种操作与 Tab 切换、冷启动深链放进同一个 TypeScript 页面栈,说明为什么登录页不该留在历史里、深链进入后返回键该去哪,并复盘一次把用户困在登录页的线上事故。

返回键为什么总是不听话

「回声」上线后收到三类关于返回键的反馈:从分享链接打开订单详情,按返回直接退出了应用;在订单 Tab 里逛了三层,切到消息 Tab 再切回来,历史全没了;登录完成后按返回,又回到了登录页。三个现象看起来分散,根因却是同一个:页面栈被当成了“当前页面”的附属变量,而不是一个有明确契约的数据结构。页面栈至少要回答四个问题:新页面是压栈还是替换、返回时栈顶怎么变、切换 Tab 时另一个 Tab 的历史怎么办、从深链冷启动时栈底放什么。这四个问题不写清楚,测试就只能靠手点,回归就只能靠运气。

push、replace 与 back 的契约

操作语义典型场景对返回键的影响
push压入新页面列表进入详情返回回到上一页
replace替换栈顶登录后回跳、重定向返回跳过被替换页
back弹出栈顶返回键、手势返回栈底页面不弹出
noop不改变栈重复点击同一入口返回位置不变

最容易出错的是 replace。登录页、启动页、一次性引导页都不应该留在历史里:用户完成登录后按返回,期望回到登录前的页面,而不是再看一遍登录表单。反过来,从订单列表进入详情必须用 push,否则返回会直接跳回首页,用户丢失了列表位置。

back 在栈底的行为需要产品决策。Web 端可以回到站内首页,原生端可以退到后台,小程序可以提示“再按一次退出”。页面栈本身只返回 noop,把“退出还是留在原地”交给调用方,这样同一份栈逻辑可以服务不同端。

可运行的页面栈

下面的实现把“每个 Tab 一个独立栈”作为默认模型。它不依赖 History API,所有操作返回结构化的结果,调用方可以据此做埋点、动画或日志。

// file: src/router/page-stack.ts
// tier: L1
// verify: node --test test/router/page-stack.test.ts

export interface RouteTarget {
  name: string;
  params?: Record<string, string>;
}

export interface StackEntry {
  id: number;
  route: RouteTarget;
  tab: string;
}

export interface StackOptions {
  /** 每个 Tab 的根页面;返回栈见底时停在这里 */
  home: RouteTarget;
  /** 单个 Tab 的最大深度,默认 12 */
  maxDepth?: number;
}

export type NavKind = "push" | "replace" | "back" | "noop";

export interface NavResult {
  kind: NavKind;
  /** 操作完成后的栈顶 */
  entry: StackEntry | null;
  /** 本次操作移除的条目,按移除顺序排列 */
  removed: StackEntry[];
}

export interface DeepLinkOptions {
  /** 冷启动进入:先铺好 home,再把目标压上去 */
  coldStart?: boolean;
}

/** 比较两个目标是否是同一页面(参数键顺序无关)。 */
export function sameRoute(a: RouteTarget, b: RouteTarget): boolean {
  if (a.name !== b.name) return false;
  const left = a.params ?? {};
  const right = b.params ?? {};
  const leftKeys = Object.keys(left).sort();
  const rightKeys = Object.keys(right).sort();
  if (leftKeys.length !== rightKeys.length) return false;
  return leftKeys.every((key, i) => key === rightKeys[i] && left[key] === right[key]);
}

function cloneTarget(route: RouteTarget): RouteTarget {
  return { name: route.name, params: { ...(route.params ?? {}) } };
}

/**
 * 带 Tab 的页面栈。每个 Tab 拥有独立的后进先出栈,
 * 切换 Tab 只换当前栈,不清空另一个 Tab 的历史。
 */
export class TabbedPageStack {
  private readonly stacks = new Map<string, StackEntry[]>();
  private readonly home: RouteTarget;
  private readonly maxDepth: number;
  private activeTab = "main";
  private nextId = 1;

  constructor(options: StackOptions) {
    this.home = cloneTarget(options.home);
    this.maxDepth = options.maxDepth ?? 12;
    if (this.maxDepth < 1) throw new Error("maxDepth 至少为 1");
    this.stacks.set(this.activeTab, [this.makeEntry(this.home, this.activeTab)]);
  }

  get tab(): string {
    return this.activeTab;
  }

  get current(): StackEntry | null {
    const stack = this.stackOf(this.activeTab);
    return stack[stack.length - 1] ?? null;
  }

  get depth(): number {
    return this.stackOf(this.activeTab).length;
  }

  get canGoBack(): boolean {
    return this.depth > 1;
  }

  /** 返回副本,避免调用方直接改动内部数组。 */
  entries(tab: string = this.activeTab): StackEntry[] {
    return this.stackOf(tab).map((entry) => ({
      ...entry,
      route: cloneTarget(entry.route),
    }));
  }

  push(route: RouteTarget, tab: string = this.activeTab): NavResult {
    const stack = this.stackOf(tab);
    const top = stack[stack.length - 1];
    if (top && sameRoute(top.route, route)) {
      return { kind: "noop", entry: top, removed: [] };
    }
    const removed: StackEntry[] = [];
    while (stack.length >= this.maxDepth && stack.length > 1) {
      removed.push(stack.splice(1, 1)[0] as StackEntry);
    }
    const entry = this.makeEntry(route, tab);
    stack.push(entry);
    return { kind: "push", entry, removed };
  }

  /** 替换栈顶:用于登录后回跳、列表到详情的重定向等不应留下历史的场景。 */
  replace(route: RouteTarget, tab: string = this.activeTab): NavResult {
    const stack = this.stackOf(tab);
    const previous = stack.pop();
    const entry = this.makeEntry(route, tab);
    stack.push(entry);
    return {
      kind: "replace",
      entry,
      removed: previous ? [previous] : [],
    };
  }

  /** 返回上一页;已经在根页面时返回 noop,是否退出应用由调用方决定。 */
  back(tab: string = this.activeTab): NavResult {
    const stack = this.stackOf(tab);
    if (stack.length <= 1) {
      return { kind: "noop", entry: stack[0] ?? null, removed: [] };
    }
    const removed = stack.pop() as StackEntry;
    return {
      kind: "back",
      entry: stack[stack.length - 1] ?? null,
      removed: [removed],
    };
  }

  /** 连续返回到指定页面;目标不在栈里时不做任何改动。 */
  backTo(name: string, tab: string = this.activeTab): NavResult {
    const stack = this.stackOf(tab);
    const index = stack.map((entry) => entry.route.name).lastIndexOf(name);
    if (index === -1 || index === stack.length - 1) {
      return { kind: "noop", entry: stack[stack.length - 1] ?? null, removed: [] };
    }
    const removed = stack.splice(index + 1);
    return {
      kind: "back",
      entry: stack[stack.length - 1] ?? null,
      removed: removed.reverse(),
    };
  }

  /** 切换 Tab:已有历史原样恢复,没有历史则从该 Tab 的 home 开始。 */
  switchTab(tab: string): NavResult {
    if (!this.stacks.has(tab)) {
      this.stacks.set(tab, [this.makeEntry(this.home, tab)]);
    }
    this.activeTab = tab;
    return { kind: "noop", entry: this.current, removed: [] };
  }

  /**
   * 深链入口。冷启动时先铺 home 再压目标,保证返回键有去处;
   * 应用已在前台时按普通 push 处理。
   */
  openFromDeepLink(route: RouteTarget, options: DeepLinkOptions = {}): NavResult {
    if (options.coldStart) {
      const stack = [this.makeEntry(this.home, this.activeTab)];
      this.stacks.set(this.activeTab, stack);
      if (sameRoute(this.home, route)) {
        return { kind: "noop", entry: stack[0] ?? null, removed: [] };
      }
    }
    return this.push(route);
  }

  /** 退出登录等场景:把当前 Tab 重置为单一目标。 */
  reset(route: RouteTarget, tab: string = this.activeTab): NavResult {
    const removed = this.entries(tab);
    const entry = this.makeEntry(route, tab);
    this.stacks.set(tab, [entry]);
    return { kind: "replace", entry, removed };
  }

  private stackOf(tab: string): StackEntry[] {
    const existing = this.stacks.get(tab);
    if (existing) return existing;
    const seeded = [this.makeEntry(this.home, tab)];
    this.stacks.set(tab, seeded);
    return seeded;
  }

  private makeEntry(route: RouteTarget, tab: string): StackEntry {
    return {
      id: this.nextId++,
      route: cloneTarget(route),
      tab,
    };
  }
}

几个设计点值得说明。第一,home 在每个 Tab 初始化时就被压入栈底,back 永远不会把它弹出;这样“返回退出应用”不会在栈逻辑里发生,必须由调用方显式决定。第二,push 会折叠连续重复的同一页面,防止用户连点入口造成三层相同详情;参数不同则视为不同页面,例如 id=1 与 id=2 应该各自保留。第三,超过 maxDepth 时丢弃最旧的非根页面,而不是拒绝新页面;移动端更怕“点不动”,不怕历史短一点。

replace 保留栈深度,但替换条目本身。它适合登录成功后的回跳:登录页被目标页替换,返回键直接回到登录前的来源页。backTo 用于“完成按钮”一次回到某个已知页面,例如下单成功后回到订单列表,而不是逐层弹。

Tab 切换与深链进入

Tab 是页面栈里最容易被忽略的维度。很多实现只维护一个全局栈,切 Tab 时清空,理由是“不同 Tab 的业务无关”。但用户的理解不是这样:他在订单 Tab 里看到第三层,切去消息 Tab 回一条消息,再切回来时,期望还在第三层。实现上只需要把栈按 Tab 分开存放,切换时换一个引用,成本很低。

深链进入则需要区分冷启动与热启动。冷启动时应用没有历史,如果直接把目标页作为唯一栈底,返回键就没有去处;正确做法是先铺 home,再把目标页压上去。热启动时应用已有页面,深链通常按普通 push 处理,用户返回时回到进入深链前的页面。这个差异必须显式建模,否则“分享链接打开后返回退出应用”的问题会反复出现。

测试与真实输出

测试覆盖 push/back、重复折叠、replace、最大深度、Tab 保留、冷启动与热启动深链、backTo 与 reset。完整测试如下:

// file: test/router/page-stack.test.ts
// tier: L1
// verify: node --test test/router/page-stack.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import { sameRoute, TabbedPageStack } from "../../src/router/page-stack.ts";

const fresh = (maxDepth = 12) => new TabbedPageStack({ home: { name: "home" }, maxDepth });
const names = (stack: TabbedPageStack) => stack.entries().map((entry) => entry.route.name);

test("push 后进先出,根页面再返回不会弹出,重复页面折叠为 noop", () => {
  const stack = fresh();
  stack.push({ name: "feed" });
  stack.push({ name: "detail", params: { id: "42" } });
  assert.deepEqual(names(stack), ["home", "feed", "detail"]);
  assert.equal(stack.push({ name: "detail", params: { id: "42" } }).kind, "noop");
  assert.equal(stack.back().entry?.route.name, "feed");
  assert.equal(stack.back().entry?.route.name, "home");
  assert.equal(stack.back().kind, "noop");
  assert.equal(stack.canGoBack, false);
});

test("replace 不增加深度,返回仍回到上一页", () => {
  const stack = fresh();
  stack.push({ name: "feed" });
  assert.equal(stack.replace({ name: "login" }).kind, "replace");
  assert.equal(stack.depth, 2);
  assert.equal(stack.back().entry?.route.name, "home");
});

test("超过最大深度时丢弃最旧的非根页面并保留 home", () => {
  const stack = fresh(3);
  stack.push({ name: "a" });
  stack.push({ name: "b" });
  const result = stack.push({ name: "c" });
  assert.deepEqual(result.removed.map((entry) => entry.route.name), ["a"]);
  assert.deepEqual(names(stack), ["home", "b", "c"]);
});

test("Tab 切换保留各自页面栈,返回互不影响", () => {
  const stack = fresh();
  stack.push({ name: "feed" });
  stack.push({ name: "detail", params: { id: "7" } });
  stack.switchTab("orders");
  stack.push({ name: "order", params: { id: "9" } });
  assert.equal(stack.tab, "orders");
  assert.equal(stack.back().entry?.route.name, "home");
  stack.switchTab("main");
  assert.equal(stack.current?.route.name, "detail");
  assert.equal(stack.depth, 3);
});

test("从未访问过的 Tab 从 home 开始", () => {
  const stack = fresh();
  stack.switchTab("orders");
  assert.equal(stack.depth, 1);
  assert.equal(stack.current?.route.name, "home");
  assert.equal(stack.current?.tab, "orders");
});

test("冷启动深链先铺 home,热启动深链直接压栈,home 不重复", () => {
  const cold = fresh();
  cold.openFromDeepLink({ name: "detail", params: { id: "99" } }, { coldStart: true });
  assert.deepEqual(names(cold), ["home", "detail"]);
  assert.equal(cold.back().entry?.route.name, "home");

  const warm = fresh();
  warm.push({ name: "feed" });
  warm.openFromDeepLink({ name: "detail", params: { id: "99" } });
  assert.deepEqual(names(warm), ["home", "feed", "detail"]);

  const root = fresh();
  assert.equal(root.openFromDeepLink({ name: "home" }, { coldStart: true }).kind, "noop");
  assert.equal(root.depth, 1);
});

test("backTo 一次弹出中间页面,目标不存在时不改动;reset 清空当前 Tab", () => {
  const stack = fresh();
  stack.push({ name: "feed" });
  stack.push({ name: "detail" });
  stack.push({ name: "comments" });
  const result = stack.backTo("feed");
  assert.deepEqual(result.removed.map((entry) => entry.route.name), ["comments", "detail"]);
  assert.equal(stack.current?.route.name, "feed");
  assert.equal(stack.backTo("orders").kind, "noop");
  const reset = stack.reset({ name: "home" });
  assert.equal(reset.removed.length, 2);
  assert.equal(stack.depth, 1);
});

test("sameRoute 忽略参数键顺序,但比较参数值", () => {
  assert.equal(
    sameRoute({ name: "detail", params: { id: "1", tab: "info" } }, { name: "detail", params: { tab: "info", id: "1" } }),
    true,
  );
  assert.equal(sameRoute({ name: "detail", params: { id: "1" } }, { name: "detail", params: { id: "2" } }), false);
});

在实验工程根目录执行:

node --test test/router/page-stack.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ push 后进先出,根页面再返回不会弹出,重复页面折叠为 noop (1.049459ms)
✔ replace 不增加深度,返回仍回到上一页 (0.102084ms)
✔ 超过最大深度时丢弃最旧的非根页面并保留 home (0.126917ms)
✔ Tab 切换保留各自页面栈,返回互不影响 (0.127625ms)
✔ 从未访问过的 Tab 从 home 开始 (0.071125ms)
✔ 冷启动深链先铺 home,热启动深链直接压栈,home 不重复 (0.125625ms)
✔ backTo 一次弹出中间页面,目标不存在时不改动;reset 清空当前 Tab (0.129375ms)
✔ sameRoute 忽略参数键顺序,但比较参数值 (0.067458ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 117.028666

一次把用户困在登录页的事故

某次版本把登录成功后的跳转写成了 push:登录页留在栈底,目标页压在上面。用户点返回,回到登录页;再点返回,退出应用。更隐蔽的问题是,登录页在返回时不会重新校验会话,用户看到的是一个已经登录却仍然显示“请登录”的页面。客服收到的描述是“登录完一直让我登录”,开发复现时却因为每次都用新会话而看不到。根因是把“导航”与“历史”混为一谈。登录成功不是一个新页面,而是一次状态迁移:它应该替换掉登录页,而不是在登录页之上再叠一层。修复只改了一个调用点,从 push 改成 replace,同时在页面栈上加了断言:任何 requiresAuth 页面都不允许把登录页留在它的下方。后者不是运行时兜底,而是测试里的结构性检查。这个事故说明,返回语义不能靠“记得用哪个方法”来保证。把操作命名、默认行为和约束写进页面栈,才能让错误在代码评审和测试里暴露,而不是等客服转述。

小结

页面栈是返回键的契约,不是当前页面的附属变量。push 负责建立历史,replace 负责抹掉不该留下的历史,back 负责消费历史;Tab 切换要保留各自的历史,深链冷启动要先铺好栈底。把这四件事分开建模,返回键才会变得可预测。

下一节把 URL 与页面栈接起来:一条深链如何编码、冷启动时按什么顺序恢复、登录与权限在哪里拦截、失效链接落到哪里。

阅读导航:上一节:6.1 URL 即状态 · 下一节:6.3 深链与状态恢复 。

常见错误与边界

  • 只维护一个全局页面栈。切换 Tab 时要么清空历史,要么把另一个 Tab 的页面混进来。
  • 登录成功用 push。登录页留在历史里,返回键会把用户送回表单。
  • 深链冷启动直接把目标页当栈底。返回键没有去处,用户只能退出应用。
  • 栈底返回直接退出应用。退出是产品决策,不该藏在栈实现里。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查