uniapp脚手架编译微信小程序出现白屏问题,狂踩坑得出的结论

47 阅读4分钟

前言

因为公司的小程序项目是使用uniapp脚手架编译的,因为我们在测试阶段和生产阶段基本不会出现,但是用户基数过多的时候就会两遍产生质变,以下内容是我踩过的坑得出的一些结论(使用ai润色了一下)

微信小程序端“白屏”很多时候不是渲染问题,而是以下几类原因导致 JS 逻辑中断或卡死:

  • 路由重定向循环、并发跳转、tabBar 跳转方式错误
  • 请求层抛异常未捕获(Unhandled Rejection),页面逻辑直接断掉
  • 首屏直接落到分包页/鉴权页/深链页,启动竞态导致白屏
  • 小程序退到后台太久,回来状态脏了(token/用户态)继续跑导致异常

本文按排查优先级给一套稳定方案,适配 Vue3 + Pinia

先把白屏变“可见错误”:全局异常捕获(必做)

App.vue(Vue3 写法)

import { onLaunch, onShow } from '@dcloudio/uni-app'

onLaunch(() => {
  // Vue3 没有 Vue.config.errorHandler,这里主要补齐小程序错误与未处理 Promise
  // #ifdef MP-WEIXIN
  wx.onError((msg) => {
    console.error('[wx.onError]', msg)
  })
  wx.onUnhandledRejection((res) => {
    console.error('[wx.onUnhandledRejection]', res)
  })
  // #endif
})

onShow(() => {
  // 可选:记录每次回前台
})
</script>

说明:Vue3 侧的 errorHandler 更建议在 main.ts 用 app.config.errorHandler 做(下面会给)。

main.ts(推荐补全 Vue 错误捕获)

import App from './App.vue'
import { createPinia } from 'pinia'

export function createApp() {
  const app = createSSRApp(App)
  const pinia = createPinia()
  app.use(pinia)

  app.config.errorHandler = (err, instance, info) => {
    console.error('[VueError]', err, info)
  }

  return { app, pinia }
}

(一)排查页面重定向问题:防并发、防循环、统一入口

1.1 常见坑点(白屏高发)

  • tabBar 页面用了 navigateTo(会 fail)
  • A → redirectTo B,B 又 redirectTo A(循环)
  • onLoad/onShow 同时触发登录校验 + 活动跳转(并发跳转)
  • 启动时频繁 reLaunch/redirectTo(竞态)

1.2 路由统一封装(Vue3 直接用函数)

/src/utils/router.ts

const LOCK_KEY = '__router_lock__'
let lastUrl = ''
let lastAt = 0

const TAB_BAR_PAGES = [
  '/pages/index/index',
  // '/pages/home/index',
]

function isTabBar(url: string) {
  const path = url.split('?')[0]
  return TAB_BAR_PAGES.includes(path)
}

export function safeGo(options: { url: string; type?: 'navigateTo' | 'redirectTo' | 'reLaunch' }) {
  const { url, type = 'navigateTo' } = options
  const now = Date.now()

  // 300ms 内重复跳同一地址,直接拦截
  if (now - lastAt < 300 && url === lastUrl) {
    console.warn('[router] blocked duplicate:', url)
    return
  }

  const app = getApp<{ globalData: Record<string, any> }>()
  if (app.globalData[LOCK_KEY]) {
    console.warn('[router] locked, ignore:', url)
    return
  }
  app.globalData[LOCK_KEY] = true

  lastUrl = url
  lastAt = now

  const done = () => {
    app.globalData[LOCK_KEY] = false
  }

  if (isTabBar(url)) {
    uni.switchTab({ url, complete: done })
    return
  }

  uni[type]({
    url,
    complete: done,
    fail: (e) => {
      console.error('[router] fail:', type, url, e)
      done()
      uni.reLaunch({ url: '/pages/index/index' }) // 兜底回首页
    },
  })
}

后面文章里涉及跳转,统一用 safeGo(),你会发现白屏概率会明显下降。

(二)排查 http 抛出异常接收:请求层不要随意 throw,统一兜底结构

2.1 推荐:request 永远 resolve(ok/data/msg/code)

/src/utils/http.ts

const BASE_URL = 'https://api.xxx.com'

export type HttpResult<T = any> = {
  ok: boolean
  data: T | null
  msg: string
  code: number
}

export function request<T = any>(opt: {
  url: string
  method?: UniApp.RequestOptions['method']
  data?: any
  header?: any
  timeout?: number
}): Promise<HttpResult<T>> {
  return new Promise((resolve) => {
    uni.request({
      url: BASE_URL + opt.url,
      method: opt.method || 'GET',
      data: opt.data || {},
      header: opt.header || {},
      timeout: opt.timeout || 15000,
      success: (res) => {
        const status = res.statusCode || 0
        const body: any = res.data || {}

        // 按你们后端协议调整:例如 code === 0 成功
        if (status >= 200 && status < 300) {
          if (body.code === 0) {
            resolve({ ok: true, data: body.data ?? null, msg: body.msg || 'ok', code: body.code })
          } else {
            resolve({ ok: false, data: null, msg: body.msg || '业务异常', code: body.code ?? -2 })
          }
        } else {
          resolve({ ok: false, data: null, msg: `HTTP ${status}`, code: status })
        }
      },
      fail: (err) => {
        console.error('[http.fail]', err)
        resolve({ ok: false, data: null, msg: err.errMsg || '网络异常', code: -1 })
      },
    })
  })
}

2.2 页面调用必须兜底(不要默认成功)

import { request } from '@/utils/http'

const res = await request<{ list: any[] }>({ url: '/list' })
if (!res.ok) {
  uni.showToast({ title: res.msg, icon: 'none' })
  return
}
list.value = res.data?.list || []

这样你几乎不会再遇到“请求失败直接白屏”,因为页面逻辑永远能走到兜底分支。

(三)进入页面一定先进入首页:缓存记录再重定向(首屏稳定策略)

你的目标:无论从哪里打开小程序,先落首页渲染,再二跳到目标页
这能解决:分包未加载、鉴权竞态、启动时多处跳转造成的白屏。

3.1 Pinia 用户态(用于清理等)

/src/stores/user.ts

import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: uni.getStorageSync('token') || '',
    userInfo: uni.getStorageSync('userInfo') || null as any,
  }),
  actions: {
    setToken(t: string) {
      this.token = t
      uni.setStorageSync('token', t)
    },
    setUserInfo(info: any) {
      this.userInfo = info
      uni.setStorageSync('userInfo', info)
    },
    reset() {
      this.token = ''
      this.userInfo = null
      uni.removeStorageSync('token')
      uni.removeStorageSync('userInfo')
    },
  },
})

3.2 App 启动记录目标页,并强制 reLaunch 首页 App.vue

<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'

const EXPIRE_MS = 30 * 60 * 1000 // 30分钟

function buildTargetFromLaunchOptions(): string {
  // #ifdef MP-WEIXIN
  const opts = wx.getLaunchOptionsSync?.() || {}
  if (!opts.path) return ''
  const path = '/' + opts.path
  const query = opts.query || {}
  const qs = Object.keys(query).length
    ? '?' + Object.keys(query).map(k => `${k}=${encodeURIComponent(query[k])}`).join('&')
    : ''
  const full = path + qs
  if (full.startsWith('/pages/index/index')) return ''
  return full
  // #endif
  return ''
}

function clearUserData() {
  const user = useUserStore()
  user.reset()
  uni.removeStorageSync('pending_redirect')
}

onLaunch(() => {
  // 1) 记录目标页
  const target = buildTargetFromLaunchOptions()
  if (target) uni.setStorageSync('pending_redirect', target)

  // 2) 强制先落首页
  uni.reLaunch({ url: '/pages/index/index' })
})

onHide(() => {
  uni.setStorageSync('last_hide_time', Date.now())
})

onShow(() => {
  const last = Number(uni.getStorageSync('last_hide_time') || 0)
  if (!last) return
  const gap = Date.now() - last
  if (gap > EXPIRE_MS) {
    console.warn('[expire] too long:', gap)
    clearUserData()
    uni.reLaunch({ url: '/pages/index/index' })
  }
})
</script>

3.3 首页负责“二跳重定向”(只执行一次) /pages/index/index.vue

<script setup lang="ts">
import { onShow } from '@dcloudio/uni-app'
import { safeGo } from '@/utils/router'

onShow(() => {
  const target = uni.getStorageSync('pending_redirect')
  if (!target) return

  // 先清掉,避免循环
  uni.removeStorageSync('pending_redirect')

  // 延迟一帧,确保首屏先渲染出来
  setTimeout(() => {
    safeGo({ url: target, type: 'redirectTo' })
  }, 0)
})
</script>

这套模式非常“抗白屏”:启动无论多复杂,先给用户一个能渲染的首页,再做业务跳转。

四)退出时长处理:超过时长清除用户数据(Pinia 版)

上面 App.vue 已经实现了“退后台超时清理”。这里补充两个经验点:

4.1 不建议清 uni.clearStorageSync()

会误删配置/引导标记/AB 实验等数据。只清用户相关即可(token、userInfo、pending_redirect)。

4.2 如果要“页面级超时”

对敏感页面可叠加页面级超时(业务更安全),但全局超时依然建议保留。


最后:一套白屏排查优先级(直接照这个顺序查)

  1. 路由:tabBar 跳转方式正确吗?有无重定向循环?有无并发跳转?
  2. 请求:有没有 throw 未 catch?有没有 unhandledRejection?请求失败有没有兜底?
  3. 首屏策略:是否允许启动直接落到深层页?建议“先首页渲染,再二跳”。
  4. 后台超时:用户退后台太久回来,是否需要清理 token/用户态避免脏状态引发白屏?