返回键为什么总是不听话
「回声」上线后收到三类关于返回键的反馈:从分享链接打开订单详情,按返回直接退出了应用;在订单 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。登录页留在历史里,返回键会把用户送回表单。
- 深链冷启动直接把目标页当栈底。返回键没有去处,用户只能退出应用。
- 栈底返回直接退出应用。退出是产品决策,不该藏在栈实现里。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。