一个会员内容页,跳转前要先调接口确认当前用户有没有对应权益。逻辑很自然:在 setInterception 的 willShow 里 await checkPermission(sku),有权限就放行,没权限就 pop 掉目标页、换成登录页或购买页。本地联调时接口很快,看起来完全正常。上线之后弱网一出现,用户已经进了目标页,半秒后一个弹窗才追上来问“要不要开通会员”;或者页面闪了一下又被拽回首页,返回栈里多出一条不该存在的记录。

你以为是 willShow 的拦截没生效,其实它生效了。它执行了,await 也返回了,pop 和 push 也调了。问题在于 willShow 的拦截窗口在它同步返回的那一刻就关闭了。你 await 的这段时间里,导航动作并没有停下来等你,目标页已经入栈、已经渲染,你后续的 pop 是在“栈已经变了”之后做的一次纠正,不是一次拦截。用户看到的闪屏和错位,就是纠正动作留下的痕迹。

willShow 是同步钩子,不是异步守卫

setInterception 的 willShow 签名是同步的,它接收 from 和 to 两个上下文,返回 void 或者一个布尔值。它的设计意图是让你在路由动作即将发生、但还没有真正提交的那个瞬间,做一次快速的、同步的判断:登录态在不在,目标页在不在白名单里,参数有没有明显缺失。你在这个窗口里修改栈,比如 pop 掉目标页再 push 一个登录页,框架会把它当作这次导航的一部分来处理。

但“同步”两个字是硬约束。你在 willShow 里写 await,函数在遇到 await 的那一刻就返回了一个 Promise,willShow 本身已经执行完毕。导航框架拿到的是一个 Promise,它没有理由、也没有机制去等这个 Promise 兑现之后再提交导航。导航继续走,目标页继续入栈,你的异步逻辑在另一个微任务里跑完,回来对着一个已经提交的路由栈做 pop 和 push。这不是拦截,是事后修正。

官方资料里对这个边界的描述很直白:willShow 适合登录校验、权限拦截这类同步判断,但它不是返回事件拦截,尤其不适合在 willShow 里做“弹窗异步确认”这种操作,因为“弹窗异步确认的时序在这里非常难控制,很容易出现先 pop、后弹窗”。把“权限校验”和“异步弹窗确认”都塞进 willShow,等于要求一个同步钩子去承担一个异步守卫的职责,它做不到。

typescript

// 看起来在拦截,实际上导航已经放行
this.stack.setInterception({
  willShow: (from, to) => {
    // 错误示范:await 让 willShow 提前返回,导航不等人
    checkPermission(to?.pathInfo?.name as string).then((granted) => {
      if (!granted) {
        // 此时目标页已经入栈,这里的 pop 是事后纠正
        this.stack.pop();
        this.stack.pushPathByName('Login', undefined);
      }
    });
    // willShow 同步返回了,导航继续
  }
});

checkPermission 的 then 回调在导航提交之后才执行,this.stack.pop() 撤掉的是一个已经显示过的页面。用户看到的闪屏、返回栈错位、偶发的“弹了两次”都来自这里。willShow 不替你等 Promise,它只等你同步返回的那一行。

栈已经变了,再“拦截”就是补票

还有一个比异步更根本的时序问题,评论区里有人点破过:setInterception 是在栈已经改变之后才调用的。willShow 触发的时刻,路由栈的内部状态可能已经推进到了“目标页已加入”的阶段,你拿到 to 上下文的时候,目标页已经不是“即将要入栈”,而是“已经在了”。

这意味着在 willShow 里做 pop 和 push,本质上是在一个已经提交的栈上做二次编辑。如果你 pop 掉目标页再 push 登录页,返回栈的历史里可能留下一次“目标页曾经存在过”的痕迹;如果你只 pop 不 push,用户会看到页面被弹回去;如果你先 push 再 pop,顺序稍微写反,栈里就会多出一条不该存在的记录。官方的技术支持规格里对这种行为有专门的描述:在 pop 场景下做页面跳转后的拦截,实际上是将当前页面出栈后,再 push 一个新的页面。你看到的“页面闪一下”不是渲染 bug,是栈被编辑了两次。

willShow 的正确用法是在同步窗口内决定“放不放行”,而不是“放行之后再拽回来”。权限校验必须同步完成,或者把校验结果提前缓存在内存里,willShow 只读缓存,不做网络请求。如果校验结果不能提前拿到,就不应该把拦截逻辑放在 willShow 里,而应该把“进入页面”和“校验”拆成两个步骤:先让一个轻量的占位页入栈,在占位页的 aboutToAppear 里做异步校验,校验通过之后 replacePath 成真实内容页,不通过则 replacePath 成登录页或购买页。

typescript

this.stack.setInterception({
  willShow: (from, to) => {
    const target = to?.pathInfo?.name as string;
    // 同步读缓存,不做 await
    if (requiresAuth(target) && !AuthCache.isLoggedIn()) {
      // 同步决定不放行,用栈操作把目标替换成登录页
      this.stack.pop();
      this.stack.pushPathByName('Login', { redirect: target });
    }
  }
});

AuthCache 在应用启动或者上一次登录成功时就已经写好了同步结果。willShow 只做一次内存读,不发起任何异步调用。如果 AuthCache 里没有可用状态,兜底策略不是在这里 await,而是先放行到一个“校验中”的占位页,把异步成本挪到页面内部去消化。

弹窗确认和 willShow 是两件事,onBackPressed 才是草稿的归属

权限拦截和“返回前确认”经常被混为一谈,因为两者都表现为“用户要走了,先拦一下”。但它们的触发场景和所属的钩子完全不同。willShow 拦截的是路由跳转动作,包括 pushPath、replacePath、pop 引起的页面切换;NavDestination 的 onBackPressed 拦截的是当前页面的返回事件,包括系统返回键、左上角返回按钮、侧滑手势。

表单草稿确认的场景,正确的钩子是 onBackPressed,不是 willShow。在 onBackPressed 里弹一个 promptAction.showDialog,根据用户的异步选择返回 true 或 false,框架会等你这个 Promise 兑现之后再决定要不要真正 pop。这是 onBackPressed 的契约,它接受异步返回,因为它的语义就是“当前页面在决定要不要把自己交出去”。

typescript

NavDestination() {
  // 表单内容
}
.onBackPressed(async () => {
  if (!this.hasModified) {
    return false;   // 无修改,正常返回
  }
  const res = await promptAction.showDialog({
    title: '提示',
    message: '草稿尚未保存,确定要离开吗?',
    buttons: [{ text: '取消' }, { text: '放弃草稿' }]
  });
  return res.index !== 1;   // 选“取消”返回 true,吃掉返回事件
});

onBackPressed 返回 true 表示应用接管了这次返回,不执行默认 pop;返回 false 则交给 Navigation 默认逻辑出栈。await showDialog 在这个上下文里是安全的,因为 onBackPressed 的返回值本身就是一个可以等待的 Promise,框架会消费它。同样的 await 放在 willShow 里就是时序事故,放在 onBackPressed 里就是标准写法。钩子选错了,异步本身就是问题;钩子选对了,异步是它支持的能力。

不要在 onBackPressed 里直接调 pathStack.pop(),容易和框架原生的返回逻辑冲突,优先靠返回布尔值控制。如果你确实需要在用户确认放弃之后执行额外的栈操作,先 return false 让默认 pop 发生,再在页面销毁的收尾逻辑里处理,而不是在返回回调里手动 pop 一个正在被框架处理的页面。

setInterception 的 name 只读,重定向要在发起侧做

还有一个和异步无关、但会把“拦截成功”变成“跳错页”的细节:setInterception 上下文里的 context.name 是只读的,你不能在 willShow 里把目标页的名字从 MemberOnly 改成 Login 然后指望导航按新名字走。willShow 拿到的是一份已经确定的路由信息,你可以决定“不执行这次跳转”,但你不能“修改这次跳转的目的地”。

需要重定向的场景,正确的做法是在发起跳转的那一侧做判断,或者在 willShow 里用 pop + pushPathByName 显式地构造新的导航,而不是试图修改 context 的字段。显式构造的代价就是前面说的“栈被编辑两次”,所以要尽量把重定向的逻辑前置到调用方:点击事件里先做同步判断,决定 push 哪一个页面,setInterception 只作为最后一道兜底,而不是主流程。

排查顺序:先看钩子选对没有,再看同步窗口里有没有 await

下次路由拦截“有时候生效有时候不生效”的时候,按这个顺序走。

第一步,确认你用的是哪个钩子。权限校验和跳转放行用 willShow,返回确认和草稿弹窗用 NavDestination.onBackPressed。用 willShow 做弹窗确认,异步时序必然错位;用 onBackPressed 做跨页面的权限拦截,它根本管不到 push 动作。

第二步,如果用的是 willShow,检查回调里有没有 await、.then、setTimeout,或者任何不是同步完成的调用。有的话,把异步逻辑挪到占位页的 aboutToAppear 里,willShow 只做同步读缓存和同步放行判断。

第三步,如果拦截之后页面闪了一下、返回栈不对,检查 willShow 里是不是做了 pop 之后又 push。willShow 触发时栈可能已经变了,二次编辑栈必然留下痕迹。能前置到调用方的重定向,不要留到 willShow 里做。

第四步,如果用了 onBackPressed 但弹窗没出来,确认弹窗的层级没有被 Navigation 页面盖住,以及 showDialog 的返回值有没有被正确消费。onBackPressed 的 Promise 如果没有被 await 或返回,框架拿不到你的决定,会按默认逻辑处理。

setInterception 的 willShow 是一个同步窗口,它的价值在于“在导航提交之前,用一行同步判断决定放不放行”。你把它当成异步守卫来用,它不会报错,它只是不再守门。异步校验的结果该放在哪里等,草稿确认该挂在哪个钩子上,栈的改变该在发起侧还是拦截侧完成——这三件事分清楚了,拦截才会从“有时候生效”变成“每一次都在正确的时间点生效”。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐