小程序冷启动登录态等待方案:用 Promise 替代全局回调
关键词:微信小程序、uni-app、Vue3、token、静默登录、Promise、请求鉴权
以前写过一次回调版,现在让gpt5.5生成的一个Promise版
方案目标
小程序冷启动时,App.vue 中的静默登录通常是异步执行的。
如果页面进入后立刻调用需要登录态的接口,就可能出现页面接口先执行、登录接口还没完成的情况。解决这个问题的核心不是"在哪里调登录",而是要把登录任务变成一个可等待的全局任务。
最终希望页面侧可以这样写:
ts
const authReady = await ensureRequestAuthReady()
if (!authReady) {
return
}
await requestWithToken()
也就是:需要登录态的业务接口,在调用前先等待登录完成。
核心思路
实现上分成三层:
- 保存当前正在执行的静默登录 Promise。
- 封装
ensureMiniProgramSilentLogin,保证静默登录只会并发执行一次。 - 封装
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。
它需要保证三个行为:
- 已经有登录任务时,直接复用。
- 当前会话已有 token 和 userId 时,直接返回。
- 没有登录态时,发起新的静默登录。
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
调用鉴权接口
为什么不直接在请求拦截器里等待登录?
可以做,但不建议一上来就这么做。
请求拦截器是全局入口,所有接口都会经过它。如果在拦截器里强制等待登录,可能会带来几个问题:
- 公开接口也被迫等待登录。
- 启动阶段接口和登录接口本身容易互相影响。
- 上传、支付、第三方接口等特殊请求可能被误伤。
- 排查接口慢时,很难判断是接口慢还是拦截器在等待登录。
更清晰的边界是:
txt
请求拦截器:当前有 token 就带上 token
业务页面:明确需要登录态时,主动 await ensureRequestAuthReady
这样登录等待只发生在真正需要登录态的业务动作上。
回调方案和 Promise 方案的关系
两种方案的目标相同,都是处理登录接口和业务接口之间的异步时序。
| 对比项 | 回调方案 | Promise 方案 |
|---|---|---|
| 基本思想 | 登录完成后执行回调 | 调接口前等待登录完成 |
| 多页面同时等待 | 需要维护回调队列 | 多个地方 await 同一个 Promise |
| 错误处理 | 需要额外设计失败回调 | 可以返回 null 或 try/catch |
| 代码流程 | 容易被拆成多段 | 更接近同步写法 |
| 适合项目 | 简单原生小程序 | Vue3、TypeScript、复杂业务流程 |
可以把 Promise 方案理解为回调方案的升级版。
它没有改变问题的本质,只是让"等登录完成"这件事变得更可组合、更好维护。
小结
小程序冷启动时,页面接口早于登录完成是很常见的时序问题。
稳定处理这类问题,可以抓住三个点:
- 登录任务保存成全局 Promise。
- 多个页面复用同一个登录 Promise。
- 鉴权接口调用前统一等待 token 和 userId。
最终页面代码只需要关注业务本身:
ts
const authReady = await ensureRequestAuthReady()
if (!authReady) {
return
}
await requestWithToken()
这样既保留了小程序回调方案的核心思想,也让代码更适合现代前端项目继续扩展。