uniApp 小程序 vue3 app.vue静默登录其他页面等待登录完成方式二

小程序冷启动登录态等待方案:用 Promise 替代全局回调

关键词:微信小程序、uni-app、Vue3、token、静默登录、Promise、请求鉴权

以前写过一次回调版,现在让gpt5.5生成的一个Promise版

juejin.cn/post/737129...

方案目标

小程序冷启动时,App.vue 中的静默登录通常是异步执行的。

如果页面进入后立刻调用需要登录态的接口,就可能出现页面接口先执行、登录接口还没完成的情况。解决这个问题的核心不是"在哪里调登录",而是要把登录任务变成一个可等待的全局任务。

最终希望页面侧可以这样写:

ts 复制代码
const authReady = await ensureRequestAuthReady()

if (!authReady) {
  return
}

await requestWithToken()

也就是:需要登录态的业务接口,在调用前先等待登录完成。

核心思路

实现上分成三层:

  1. 保存当前正在执行的静默登录 Promise。
  2. 封装 ensureMiniProgramSilentLogin,保证静默登录只会并发执行一次。
  3. 封装 ensureRequestAuthReady,业务接口调用前统一等待 token 和 userId。

这套方案和以前小程序里常见的 tokenReadyCallback 思想一致,都是让业务请求等待登录完成。

区别是:

txt 复制代码
回调方案:登录完成后通知页面继续执行
Promise 方案:页面自己 await 登录完成

Promise 写法更适合 Vue3 / TypeScript 项目,因为代码流程更线性,也更容易处理并发和失败状态。

第一步:维护静默登录状态

先准备一个 silentAuth.ts,用于维护当前会话内的登录状态和登录 Promise。

ts 复制代码
export interface MiniProgramSilentAuthState {
  /**
   * 静默登录接口返回的 token。
   *
   * 注意:这里建议只存在内存中,不写入本地缓存。
   * 这样小程序关闭后再次打开,会重新执行静默登录,
   * 不会直接复用上一次启动时留下的 token。
   */
  token: string

  /**
   * 本次静默登录完成时间。
   *
   * 这个字段不是必须的,但保留下来方便后续扩展,
   * 比如需要判断当前会话内 token 已经使用了多久。
   */
  loginTime: number
}

/**
 * 当前会话内的静默登录状态。
 *
 * 使用模块级变量保存,意味着它只在当前小程序运行期间有效。
 * 小程序进程被销毁后,这个变量也会自然丢失。
 */
let silentAuthState: MiniProgramSilentAuthState | null = null

/**
 * 当前正在执行的静默登录任务。
 *
 * 这是整个方案的关键:
 * 多个页面同时需要登录态时,不应该各自发起登录请求,
 * 而是应该等待同一个 Promise。
 */
let silentAuthReadyPromise: Promise<MiniProgramSilentAuthState | null> | null = null

/**
 * 归一化静默登录状态。
 *
 * 这个函数负责把外部传入的数据收敛成统一结构。
 * 如果没有 token,就认为当前登录态不可用,返回 null。
 */
export function normalizeSilentAuthState(input?: Partial<MiniProgramSilentAuthState> | null) {
  const token = typeof input?.token === 'string' ? input.token : ''

  if (!token) {
    return null
  }

  return {
    token,
    loginTime: typeof input?.loginTime === 'number' ? input.loginTime : Date.now(),
  } satisfies MiniProgramSilentAuthState
}

/**
 * 获取当前会话内的静默登录状态。
 *
 * 注意这里不读本地缓存,只读内存变量。
 * 这样可以避免小程序重新打开时误用上一次启动的 token。
 */
export function getSilentAuthState() {
  return normalizeSilentAuthState(silentAuthState)
}

/**
 * 写入当前会话内的静默登录状态。
 *
 * 如果传入的是空值或无效 token,则直接清空当前内存态。
 */
export function setSilentAuthState(input?: Partial<MiniProgramSilentAuthState> | null) {
  const state = normalizeSilentAuthState(input)

  if (!state) {
    clearSilentAuthState()
    return null
  }

  silentAuthState = state
  return state
}

/**
 * 清空当前会话内的静默登录状态。
 *
 * 常见调用时机:
 * 1. token 明确失效。
 * 2. 用户退出登录。
 * 3. 需要强制重新静默登录。
 */
export function clearSilentAuthState() {
  silentAuthState = null
}

/**
 * 获取当前静默登录 token。
 *
 * 请求拦截器或业务鉴权工具可以通过它读取当前会话内 token。
 */
export function getSilentAuthToken() {
  return getSilentAuthState()?.token || ''
}

/**
 * 设置当前正在执行的静默登录 Promise。
 *
 * 发起静默登录时写入,登录结束后清空。
 * 其他页面可以通过 waitForSilentAuthReady 等待它完成。
 */
export function setSilentAuthReadyPromise(
  promise: Promise<MiniProgramSilentAuthState | null> | null,
) {
  silentAuthReadyPromise = promise
}

/**
 * 获取当前正在执行的静默登录 Promise。
 *
 * ensureMiniProgramSilentLogin 会用它判断是否已有登录任务在执行。
 */
export function getSilentAuthReadyPromise() {
  return silentAuthReadyPromise
}

/**
 * 等待静默登录完成。
 *
 * 如果当前有登录任务,就等待这个任务。
 * 如果当前没有登录任务,就直接返回当前内存中的登录状态。
 */
export function waitForSilentAuthReady() {
  return silentAuthReadyPromise || Promise.resolve(getSilentAuthState())
}

这一步的重点是 silentAuthReadyPromise

它相当于 Promise 版本的全局回调队列:所有页面都可以等待它,但不会互相覆盖。

第二步:封装静默登录入口

接下来封装 ensureMiniProgramSilentLogin

它需要保证三个行为:

  1. 已经有登录任务时,直接复用。
  2. 当前会话已有 token 和 userId 时,直接返回。
  3. 没有登录态时,发起新的静默登录。
ts 复制代码
/**
 * 确保微信小程序环境下已完成静默登录。
 *
 * 这个方法用于解决"小程序冷启动时,页面接口先于登录接口执行"的问题。
 * 它会把当前正在执行的静默登录任务保存成全局 Promise,让多个页面可以等待同一个登录任务。
 *
 * 执行逻辑:
 * 1. 非微信小程序环境直接返回 null。
 * 2. 如果已经有静默登录任务在执行,直接等待这一个任务,不重复发起登录。
 * 3. 如果当前会话已经有 token 和 userId,且没有强制刷新要求,直接复用当前登录态。
 * 4. 如果没有可用登录态,则调用 wx.login 获取 code,再请求后端静默登录接口。
 * 5. 登录成功后,把 token 写入当前会话内存,并把用户信息写入 userStore。
 * 6. 登录结束后清空全局 Promise,保证下一次需要时可以重新发起登录。
 *
 * @param query 启动参数或页面参数,可用于透传活动码、邀请人 ID 等渠道信息。
 * @param options 静默登录配置。
 * @param options.force 是否强制重新执行静默登录;默认 false。
 * @returns 静默登录状态;登录失败或非微信小程序环境时返回 null。
 */
export async function ensureMiniProgramSilentLogin(
  query?: Record<string, any> | null,
  options: { force?: boolean } = {},
) {
  /**
   * 非微信小程序环境不执行微信静默登录。
   *
   * 这一步可以根据项目实际情况调整。
   * 如果 H5、App 端也有自己的登录初始化逻辑,可以在这里做平台分支。
   */
  if (!isMpWeixin) {
    return null
  }

  const { force = false } = options
  const userStore = useUserStore()

  /**
   * 如果已经有静默登录任务正在执行,直接等待这个任务。
   *
   * 这样可以避免多个页面同时触发静默登录,导致重复请求 wx.login
   * 或重复请求后端 silentLogin 接口。
   */
  const pendingTask = getSilentAuthReadyPromise()
  if (pendingTask) {
    const readyState = await pendingTask

    /**
     * 如果登录成功,并且项目还需要额外拉取用户资料,
     * 可以在这里继续补齐 userStore。
     */
    if (readyState?.token) {
      await syncUserProfile(userStore)
    }

    return readyState
  }

  /**
   * 如果不是强制登录,并且当前会话内已经有 token 和 userId,
   * 说明登录态已经准备好了,可以直接返回。
   */
  const currentState = getSilentAuthState()
  if (!force && currentState?.token && userStore.getCurrentUserId()) {
    await syncUserProfile(userStore)
    return currentState
  }

  /**
   * 创建真正的静默登录任务。
   *
   * 注意这里先把 Promise 保存起来,再返回给调用方。
   * 后续其他页面如果也需要等待登录,就可以复用同一个 Promise。
   */
  const silentLoginTask = (async () => {
    try {
      /**
       * 第一步:调用 wx.login 获取 code。
       *
       * code 是一次性的,后端会用它换取 openid/session 等信息。
       */
      const wxLoginRes = await getWxCode()
      if (!wxLoginRes.code) {
        throw new Error('微信静默登录未获取到 code')
      }

      /**
       * 第二步:组装静默登录参数。
       *
       * query 可以携带 inviterId、campaignCode 等渠道参数。
       * 这些参数一般来自分享链接或小程序码 scene。
       */
      const requestPayload = {
        code: wxLoginRes.code,
        ...normalizeSilentLoginQuery(query),
      }

      /**
       * 第三步:请求后端静默登录接口。
       *
       * 后端返回 token 和用户基础信息。
       */
      const result = await silentLogin(requestPayload)
      const nextState = {
        token: result?.token || result?.access_token || result?.accessToken,
        loginTime: Date.now(),
      }

      if (!nextState.token) {
        throw new Error('微信静默登录未返回有效 token')
      }

      /**
       * 第四步:同步用户信息和 token。
       *
       * token 写入当前会话内存,用户信息写入 userStore。
       */
      userStore.setMiniProgramUserInfo(result?.user, 'silent')
      setSilentAuthState(nextState)

      /**
       * 如果项目需要再拉一次用户扩展资料,可以在这里执行。
       * 这一步不是所有项目都需要。
       */
      await syncUserProfile(userStore, {
        expectedUserId: result?.user?.id == null ? '' : String(result.user.id),
      })

      return nextState
    }
    catch (error) {
      /**
       * 登录失败时返回 null。
       *
       * 业务侧可以通过 ensureRequestAuthReady 的返回值判断是否继续调用接口。
       */
      console.error('微信小程序静默登录失败', error)
      return null
    }
    finally {
      /**
       * 无论成功还是失败,都要清空当前登录 Promise。
       *
       * 否则下一次调用会一直拿到旧 Promise,无法重新发起登录。
       */
      setSilentAuthReadyPromise(null)
    }
  })()

  /**
   * 把当前登录任务注册为全局可等待 Promise。
   *
   * 这是解决冷启动竞态的关键。
   */
  setSilentAuthReadyPromise(silentLoginTask)

  return silentLoginTask
}

这个函数的重点是"并发复用"。

多个地方同时调用 ensureMiniProgramSilentLogin 时,只会有第一个地方真正发起登录请求,后面的地方都在等待同一个 Promise。

第三步:封装请求鉴权就绪函数

页面真正需要的通常不是"静默登录结果",而是"我现在能不能调需要登录的接口"。

所以可以再封装一层 ensureRequestAuthReady

ts 复制代码
interface EnsureRequestAuthReadyOptions {
  /**
   * 静默登录需要透传的参数。
   *
   * 例如分享人 ID、活动码、来源渠道参数等。
   * 如果页面没有额外参数,可以不传。
   */
  query?: Record<string, any> | null

  /**
   * 是否强制重新执行静默登录。
   *
   * 默认不强制。
   * 一般只有 token 明确异常、用户切换身份时才需要设置为 true。
   */
  forceSilentLogin?: boolean
}

/**
 * 获取当前请求应使用的认证 token。
 *
 * 优先使用正式登录 token。
 * 如果用户没有正式登录,则回退到当前会话内的静默登录 token。
 */
export function getRequestAuthToken() {
  const tokenStore = useTokenStore()
  return tokenStore.updateNowTime().validToken || getSilentAuthToken()
}

/**
 * 确保当前页面已经具备可用的登录态。
 *
 * 返回值说明:
 * 1. 返回 { token, userId }:说明可以继续调用鉴权接口。
 * 2. 返回 null:说明登录态仍不可用,业务侧应该跳过当前请求或引导登录。
 */
export async function ensureRequestAuthReady(options: EnsureRequestAuthReadyOptions = {}) {
  const userStore = useUserStore()

  /**
   * 第一步:先等待 App.vue 中已经发起的静默登录任务。
   *
   * 小程序冷启动时,页面 onLoad 可能比静默登录接口更早执行到业务请求。
   * 这里先等全局 Promise,可以避免页面重复发起登录。
   */
  await waitForSilentAuthReady()

  /**
   * 第二步:等待之后,立即检查 token 和 userId。
   *
   * token 用于请求头 Authorization。
   * userId 通常用于接口参数,比如领取权益、创建订单等。
   */
  let token = getRequestAuthToken()
  let userId = userStore.getCurrentUserId()
  if (token && userId) {
    return { token, userId }
  }

  /**
   * 第三步:兜底补一次静默登录。
   *
   * 这一步是为了处理极端时序:
   * 页面执行到这里时,App.vue 可能还没来得及注册 silentAuthReadyPromise。
   * 此时 waitForSilentAuthReady 会直接返回空状态,所以需要主动补登录。
   */
  await ensureMiniProgramSilentLogin(options.query, {
    force: options.forceSilentLogin,
  })

  /**
   * 第四步:登录后再次检查 token 和 userId。
   *
   * 不要默认认为 ensureMiniProgramSilentLogin 一定成功。
   * 网络失败、后端异常、微信 code 获取失败都可能导致登录态仍不可用。
   */
  token = getRequestAuthToken()
  userId = userStore.getCurrentUserId()
  if (!token || !userId) {
    return null
  }

  return { token, userId }
}

这样以后页面里只要调用鉴权接口,都可以复用它。

第四步:在 App.vue 中启动静默登录

App.vue 仍然负责在小程序启动时发起静默登录。

ts 复制代码
onLaunch((options) => {
  /**
   * 从启动参数中解析渠道参数。
   *
   * 例如:
   * 1. 分享链接里的 inviterId。
   * 2. 小程序码 scene 里的 inviterId。
   * 3. 其他业务需要透传到登录接口的参数。
   */
  const silentLoginQuery = resolveLaunchSilentLoginQuery(options?.query)

  /**
   * 启动静默登录,但不阻塞 App 启动。
   *
   * ensureMiniProgramSilentLogin 内部会把 Promise 注册到全局,
   * 页面如果需要登录态,可以通过 ensureRequestAuthReady 等待它。
   */
  void ensureMiniProgramSilentLogin(silentLoginQuery, {
    force: true,
  })
})

这里不需要在 App.vue 里阻塞所有页面。

页面需要登录态时自己等待,不需要登录态的页面可以正常渲染。

第五步:页面调用鉴权接口前等待

以一个虚构的"领取启动权益"接口为例。

这个接口需要 userId,请求头也需要 token。所以调用前必须等待登录态准备好。

ts 复制代码
async function claimStartupReward(activityId?: string | number) {
  /**
   * 如果页面没有拿到活动 ID,就不需要调用领取接口。
   *
   * 这里用 activityId 做虚构示例:
   * 实际项目中可以替换成 couponId、taskId、resourceId 等任何业务参数。
   */
  if (activityId == null || activityId === '') {
    console.log('未获取到活动ID,跳过领取启动权益')
    return
  }

  /**
   * 领取启动权益是鉴权接口。
   *
   * 这里必须先等待 App.vue 中的静默登录完成,
   * 否则冷启动扫码时可能还没有 token,接口会出现 401 或业务失败。
   */
  const authReady = await ensureRequestAuthReady()

  /**
   * 如果仍然拿不到 userId,说明当前登录态不可用。
   *
   * 这里不要继续调用领取接口,否则只会制造一次必然失败的请求。
   * 具体业务可以选择跳过、提示用户、或引导登录。
   */
  if (!authReady?.userId) {
    console.warn('未获取到用户ID,暂不领取启动权益', { activityId })
    return
  }

  /**
   * 登录态准备好后,再调用需要鉴权的业务接口。
   *
   * token 会由请求拦截器自动写入 Authorization 请求头。
   */
  await claimReward({
    userId: authReady.userId,
    activityId,
  })
}

页面最终的执行顺序会变成:

txt 复制代码
页面开始执行
需要调用鉴权接口
等待 App.vue 发起的静默登录 Promise
检查 token 和 userId
如果没有,主动补一次静默登录
再次检查 token 和 userId
调用鉴权接口

为什么不直接在请求拦截器里等待登录?

可以做,但不建议一上来就这么做。

请求拦截器是全局入口,所有接口都会经过它。如果在拦截器里强制等待登录,可能会带来几个问题:

  1. 公开接口也被迫等待登录。
  2. 启动阶段接口和登录接口本身容易互相影响。
  3. 上传、支付、第三方接口等特殊请求可能被误伤。
  4. 排查接口慢时,很难判断是接口慢还是拦截器在等待登录。

更清晰的边界是:

txt 复制代码
请求拦截器:当前有 token 就带上 token
业务页面:明确需要登录态时,主动 await ensureRequestAuthReady

这样登录等待只发生在真正需要登录态的业务动作上。

回调方案和 Promise 方案的关系

两种方案的目标相同,都是处理登录接口和业务接口之间的异步时序。

对比项 回调方案 Promise 方案
基本思想 登录完成后执行回调 调接口前等待登录完成
多页面同时等待 需要维护回调队列 多个地方 await 同一个 Promise
错误处理 需要额外设计失败回调 可以返回 null 或 try/catch
代码流程 容易被拆成多段 更接近同步写法
适合项目 简单原生小程序 Vue3、TypeScript、复杂业务流程

可以把 Promise 方案理解为回调方案的升级版。

它没有改变问题的本质,只是让"等登录完成"这件事变得更可组合、更好维护。

小结

小程序冷启动时,页面接口早于登录完成是很常见的时序问题。

稳定处理这类问题,可以抓住三个点:

  1. 登录任务保存成全局 Promise。
  2. 多个页面复用同一个登录 Promise。
  3. 鉴权接口调用前统一等待 token 和 userId。

最终页面代码只需要关注业务本身:

ts 复制代码
const authReady = await ensureRequestAuthReady()

if (!authReady) {
  return
}

await requestWithToken()

这样既保留了小程序回调方案的核心思想,也让代码更适合现代前端项目继续扩展。

相关推荐
该用户已不存在1 小时前
用 Claude Code Agents 与 CI/CD 搭建自动化研发团队(Part 3)
后端·ai编程·claude
CoCo的编程之路1 小时前
2026 前端效能飞跃:深度解析智能助手的页面构建最大化方案
前端·人工智能·ai编程·智能编程助手·文心快码baiducomate
JavaAgent架构师2 小时前
前端AI工程化(一):AI通信协议深度解析
前端·人工智能
林恒smileZAZ2 小时前
前端如何让图片、视频、pdf等文件在浏览器直接下载而非预览
前端·pdf
孙6903422 小时前
electron播放本地任意格式的视频
前端·javascript
小小小小宇2 小时前
设计稿转代码:如何将生成代码与内部组件库关联
前端
七牛云行业应用2 小时前
别每个 AI 工具单独配了!MCP 一次搭建,Claude、Cursor、TRAE 全能用
前端
_xaboy2 小时前
FormCreate 设计器 v6.3 正式发布:AI 表单助理3.0登场!
前端·vue.js·低代码·开源·表单设计器
胡志辉2 小时前
邮件中点击“加载图片”,你的IP地址已经被泄漏
前端·后端·安全