在移动端用户填写多步骤表单时,保持表单页面状态是个比较令人头痛的点。因为在传统的 Web 页面中,浏览器通过 history API 管理页面栈。但在单页应用(SPA)中,由于只有一个 HTML 页面,用户点击返回按钮时往往无法正确回到“上一个状态”,这与原生 App 的体验差异较大。为了解决这个问题,我们在项目里整了个虚拟页面导航。这个功能主要解决了以下问题。
- SPA 页面返回时无法恢复“上一个状态”
- 浏览器默认不支持页面标题自动切换
关键模块
UUID生成
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c: string) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
- 为每个虚拟页面生成唯一标识符(UUID),避免页面 ID 冲突。
- 用于绑定回调函数和页面状态。
全局状态对象:virtualRouterState
const virtualRouterState = {
isDirty: true, // 是否处于初始化阶段(首次加载未完成)
destoryMode: false, // 是否进入销毁模式(回退过程中跳过某些逻辑)
callbackMap: {} as Record<string, any>, // 存储每个虚拟页面对应的回调函数(key 为 UUID)
paddingZero: false, // 是否需要插入 vid=0 的哨兵节点
cacheTitle: null as null | string, // 缓存当前页面的标题,在返回时恢复
};
popstate 监听器
这是整个虚拟页面导航的核心部分,负责监听浏览器“返回”按钮操作,并根据当前 history.state 执行相应动作。
window.addEventListener('popstate', (event: PopStateEvent) => {
const href = window.location.href;
const state = event.state;
// ...
});
详细流程如下:
步骤 1:忽略首次加载
if (virtualRouterState.isDirty) {
return;
}
防止第一次加载就触发 popstate 回调。
步骤 2:处理 paddingZero(插入占位节点)
if (virtualRouterState.paddingZero) {
virtualRouterState.paddingZero = false;
window.history.pushState({ isSentryPage: true, vid: 0 }, href);
window.history.back();
return;
}
当需要插入一个占位节点(vid=0)时,先插入,再继续 back。
步骤 3:判断是否有 state
if (!state) {
return;
}
无状态时不处理。
步骤 4:处理 vid=0 的哨兵节点
if (state.vid === 0) {
window.history.back();
return;
}
遇到 vid=0 的节点,直接 back,跳过它。
步骤 5:处理 isSentryPage(哨兵节点)
if (state.isSentryPage) {
virtualRouterState.destoryMode = true;
window.history.back();
}
进入销毁模式,继续 back。
步骤 6:处理 isFirstPage(初始页面)
if (state.isFirstPage) {
if (virtualRouterState.destoryMode) {
virtualRouterState.destoryMode = false;
} else {
setTimeout(() => {
if (virtualRouterState.cacheTitle) {
document.title = virtualRouterState.cacheTitle;
}
}, 100);
const cb = virtualRouterState.callbackMap[state.name];
cb(false);
}
virtualRouterState.paddingZero = true;
window.history.back();
}
- 如果处于销毁模式,则退出销毁模式。
- 否则执行用户定义的回调函数,并恢复缓存的标题。
- 插入 paddingZero 占位节点,继续 back。
步骤 7:处理 isVirtualPage(虚拟页面)
if (state.isVirtualPage) {
window.history.replaceState({ isSentryPage: true, vid: state.vid - 1 }, href);
if (virtualRouterState.destoryMode) {
window.history.back();
} else {
setTimeout(() => {
if (virtualRouterState.cacheTitle) {
document.title = virtualRouterState.cacheTitle;
}
}, 100);
const cb = virtualRouterState.callbackMap[state.name];
cb(false);
}
}
- 将当前状态替换为哨兵节点,vid 减一。
- 若在销毁模式,继续 back。
- 否则执行回调并恢复标题。
useVirtualPage Hook 实现
export function useVirtualPage(backCallbackFn: (isOpen: boolean) => void, title?: string)
参数说明:
backCallbackFn: 页面打开或关闭时的回调函数。参数为布尔值,true表示打开,false表示关闭。title: 可选参数,设置当前虚拟页面的文档标题。
返回值:
[openVirtualPage, goBack]
openVirtualPage(): 打开一个新的虚拟页面goBack(): 返回上一页(调用window.history.back())
1. useRef 生成页面唯一标识符
const name = useRef<string>(uuidv4());
为每个虚拟页面生成唯一的 UUID,用于后续与 callbackMap 关联。
2. useEffect 注册回调函数
useEffect(() => {
virtualRouterState.callbackMap[name.current] = backCallbackFn;
}, [backCallbackFn]);
每次 backCallbackFn 改变时,更新 callbackMap 中该页面的回调函数。
3. openVirtualPage 函数(打开虚拟页面)
a. 设置新页面标题
setTimeout(() => {
if (title) {
virtualRouterState.cacheTitle = document.title;
document.title = title;
} else {
virtualRouterState.cacheTitle = null;
}
}, 300);
延迟设置标题是为了让 DOM 更新有足够时间渲染旧标题。
b. push 函数 —— 实际压栈逻辑
const push = () => {
const href = window.location.href;
const crtState = window.history.state;
backCallbackFn(true); // 页面打开回调
if (crtState && crtState.isFirstPage) {
throw new Error('页面状态错误, isFirstPage');
}
if (crtState && crtState.isVirtualPage) {
throw new Error('页面状态错误, isSentryPage');
}
if (
!crtState ||
!(crtState.isFirstPage || crtState.isSentryPage || crtState.isVirtualPage)
) {
window.history.pushState({ name: name.current, isFirstPage: true, vid: 1 }, href);
window.history.pushState({ isSentryPage: true, vid: 1 }, href);
return;
}
const vid = crtState.vid + 1;
if (vid === 1) {
window.history.replaceState({ name: name.current, isFirstPage: true, vid }, href);
} else {
window.history.replaceState({ name: name.current, isVirtualPage: true, vid }, href);
}
window.history.pushState({ isSentryPage: true, vid }, window.location.href);
};
-
如果当前没有历史记录或不是虚拟页面状态:
- 插入一个初始页面(isFirstPage)和一个哨兵节点(isSentryPage)
-
如果已有虚拟页面状态:
- 递增
vid - 如果是第一次打开(vid === 1),使用 replaceState 替换初始状态
- 否则标记为 isVirtualPage
- 最后插入一个哨兵节点(isSentryPage),作为下一次 back 的触发点
- 递增
4. 初次加载兼容处理
if (virtualRouterState.isDirty) {
const crtState = window.history.state;
if (crtState && crtState.isSentryPage) {
window.history.go(-1 * (crtState.vid + 1));
}
setTimeout(() => {
virtualRouterState.isDirty = false;
push();
}, 100);
} else {
push();
}
如果初次加载时已经存在哨兵节点,先跳转到正确的起始位置,再进行 push。
错误边界与异常处理
if (crtState && crtState.isFirstPage) {
throw new Error('页面状态错误, isFirstPage');
}
if (crtState && crtState.isVirtualPage) {
throw new Error('页面状态错误, isSentryPage');
}
这两个抛错是为了防止在非预期状态下进行 push 操作,确保页面栈状态一致。
使用场景
function MyComponent() {
const [openPage, goBack] = useVirtualPage((isOpening) => {
if (!isOpening) {
console.log('用户点击了返回');
}
}, '详情页');
return (
<>
<button onClick={openPage}>打开虚拟页面</button>
<button onClick={goBack}>返回</button>
</>
);
}
有点点小坑
在调试过程中发现在极限条件下(测试同学手速超快的情况下),iOS会阻止history.pushState()。
状态流程图
openVirtualPage()
↓
pushState 或 replaceState
↓
修改 document.title
↓
popstate 事件触发(用户点击返回)
↓
根据 history.state 判断当前类型
↓
执行回调函数 backCallbackFn(false)
↓
恢复 document.title(如需)
总结
| 模块 | 功能 |
|---|---|
| UUID 生成 | 保证每个页面唯一标识 |
| popstate 事件 | 拦截浏览器返回按钮,处理不同 state 类型 |
| 虚拟页面栈 | 使用 vid 控制页面层级,isFirstPage / isVirtualPage / isSentryPage 区分状态 |
| 回调函数 | 通过 callbackMap 绑定页面与回调,实现返回时执行业务逻辑 |
| 标题切换 | 缓存旧标题,在返回时恢复 |
| 销毁模式 | 控制是否跳过某些页面的回调 |
| iOS pushState 限制 | 用 replaceState 和哨兵节点优化性能 |
| 初始化处理 | dirty 状态避免首次加载误触发 |
| 异常处理 | 抛出非法状态错误,防止栈混乱 |