为了改善移动端多步骤表单的体验,咱整了个虚拟页面

188 阅读5分钟

在移动端用户填写多步骤表单时,保持表单页面状态是个比较令人头痛的点。因为在传统的 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);
};
  1. 如果当前没有历史记录或不是虚拟页面状态:

    • 插入一个初始页面(isFirstPage)和一个哨兵节点(isSentryPage)
  2. 如果已有虚拟页面状态:

    • 递增 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 状态避免首次加载误触发
异常处理抛出非法状态错误,防止栈混乱