PAYATHON 2026

支付宝扫码进入自研小程序,第二次打开出现白屏

支付老李

结论

出现“第一次正常、退出后第二次扫码白屏”,通常不是扫码功能失效,而是小程序再次被唤起时复用了上一次的运行实例,业务代码却只在首次启动或页面首次加载时执行初始化。

常见原因包括:

  • 初始化逻辑只写在 App.onLaunch 或 Page.onLoad 中,没有处理 App.onShow、Page.onShow。
  • onHide、onUnload 清除了数据、定时器或监听,二次进入时没有恢复。
  • 二维码参数、支付单号或临时令牌只能使用一次。第二次请求失败后,页面又没有错误提示。
  • 全局变量、登录状态、请求锁或加载状态没有复位。
  • 二次扫码进入了不同路径,或者启动参数解析失败。
  • 页面出现 JavaScript 异常、接口失败或路由失败,而界面完全依赖条件渲染,最终表现为白屏。

排查时,先用真机日志确认第二次扫码触发了哪些生命周期、收到了什么启动参数,并找到出现的第一个异常,再根据结果修复。

为什么第二次扫码不一定会重新启动

用户退出小程序后,进程不一定会立刻销毁。短时间内再次扫码时,支付宝可能只是将已有实例从后台切回前台。

生命周期可能是:

  • 第一次进入:App.onLaunch → App.onShow → Page.onLoad → Page.onShow
  • 退出后再次扫码:App.onShow → 已存在页面的 Page.onShow

第二次进入时,App.onLaunch 和当前页面的 Page.onLoad 未必会再次执行。如果扫码参数读取、订单查询或页面初始化只放在这两个生命周期中,页面就可能继续使用旧状态,或者拿到空数据。

推荐的处理方式

1. 在 App.onShow 中接收每次进入参数

首次启动和从后台返回时,都要保存最新的启动参数,不能只依赖 onLaunch。

App({
  globalData: {
    enterOptions: null,
    enterVersion: 0
  },

  onLaunch(options) {
    this.updateEnterOptions(options);
  },

  onShow(options) {
    this.updateEnterOptions(options);
  },

  updateEnterOptions(options) {
    this.globalData.enterOptions = options || {};
    this.globalData.enterVersion += 1;

    console.info('[app] enter options:', options);
  }
});

实际参数结构由二维码配置方式和支付宝提供的启动参数决定。不要默认业务参数一定在某个固定字段中,应以真机日志和当前接入方式为准。

2. 在页面 onShow 中重新校验并初始化

扫码业务通常需要在 onShow 中处理“本次进入”,同时避免 onLoad 和 onShow 重复发起请求。

Page({
  data: {
    loading: true,
    errorMessage: '',
    order: null
  },

  _handledEnterVersion: -1,
  _requesting: false,

  onShow() {
    const app = getApp();
    const version = app.globalData.enterVersion;

    if (this._handledEnterVersion === version) {
      return;
    }

    this._handledEnterVersion = version;
    this.initialize(app.globalData.enterOptions);
  },

  async initialize(options) {
    if (this._requesting) {
      return;
    }

    this._requesting = true;
    this.setData({
      loading: true,
      errorMessage: '',
      order: null
    });

    try {
      const params = this.parseEnterParams(options);

      if (!params.orderId) {
        throw new Error('缺少 orderId');
      }

      const order = await this.queryOrder(params.orderId);

      this.setData({
        order,
        loading: false
      });
    } catch (error) {
      console.error('[page] initialize failed:', error);

      this.setData({
        loading: false,
        errorMessage: error.message || '页面加载失败,请重新扫码'
      });
    } finally {
      this._requesting = false;
    }
  },

  parseEnterParams(options) {
    const query = options && options.query ? options.query : {};

    return {
      orderId: query.orderId || ''
    };
  },

  queryOrder(orderId) {
    return new Promise((resolve, reject) => {
      my.request({
        url: 'https://example.com/api/orders/detail',
        method: 'GET',
        data: { orderId },
        success: (response) => {
          if (response.status === 200 && response.data) {
            resolve(response.data);
          } else {
            reject(new Error('订单查询失败'));
          }
        },
        fail: (error) => {
          reject(error);
        }
      });
    });
  }
});

其中的接口地址、响应结构和启动参数字段仅用于示例,需要替换成实际业务定义。

3. 页面必须提供加载和错误状态

如果页面使用 a:if="{{order}}" 之类的条件渲染,接口失败后 order 一直为空,页面就不会显示任何内容。加载中、加载失败和正常内容都应该有明确的界面状态。

<view class="page">
  <view a:if="{{loading}}" class="status">
    正在加载…
  </view>

  <view a:elif="{{errorMessage}}" class="status error">
    {{errorMessage}}
  </view>

  <view a:else class="content">
    <text>订单号:{{order.orderId}}</text>
    <text>金额:{{order.amount}}</text>
  </view>
</view>

即使接口发生异常,也应向用户显示错误信息,而不是留下白屏。

按顺序排查

检查生命周期

先在以下位置打印带时间的日志:

App({
  onLaunch(options) {
    console.info('[lifecycle] App.onLaunch', Date.now(), options);
  },

  onShow(options) {
    console.info('[lifecycle] App.onShow', Date.now(), options);
  },

  onHide() {
    console.info('[lifecycle] App.onHide', Date.now());
  }
});
Page({
  onLoad(query) {
    console.info('[lifecycle] Page.onLoad', Date.now(), query);
  },

  onShow() {
    console.info('[lifecycle] Page.onShow', Date.now());
  },

  onHide() {
    console.info('[lifecycle] Page.onHide', Date.now());
  },

  onUnload() {
    console.info('[lifecycle] Page.onUnload', Date.now());
  }
});

如果第二次扫码只触发 App.onShow 和 Page.onShow,就说明二次初始化不能依赖首次生命周期。

对比两次启动参数

需要逐项比较:

  • 进入路径是否相同。
  • query 是否为空。
  • 参数是否经过 URL 编码。
  • 二维码是否包含一次性 token。
  • 第二次进入时是否仍停留在上一次的页面,而没有进入二维码指定的目标页面。
  • 业务参数是否被全局旧数据覆盖。

日志中最好直接输出完整对象,不要只打印拼接后的字符串,否则容易漏掉嵌套字段。

检查控制台第一个异常

白屏通常只是结果,原因可能是:

  • 读取了 undefined 的属性。
  • JSON.parse 收到空字符串或非法内容。
  • decodeURIComponent 处理了不合法的编码。
  • 请求失败后,代码仍继续读取响应数据。
  • setData 使用了错误的数据结构。
  • 自定义组件在二次显示时抛出异常。
  • 路由目标不存在或参数过长。

先处理第二次进入后出现的第一个异常。后面的报错往往是它引发的连锁反应。

检查请求锁和加载状态

下面的判断很容易让二次进入跳过请求:

if (app.globalData.hasInitialized) {
  return;
}

如果首次进入后 hasInitialized 已被设为 true,退出时页面数据又被清空,第二次进入就会跳过初始化,最终显示空页面。

请求锁也要在成功和失败后都释放:

async loadData() {
  if (this._requesting) {
    return;
  }

  this._requesting = true;

  try {
    await this.fetchData();
  } catch (error) {
    console.error(error);
  } finally {
    this._requesting = false;
  }
}

还要确认失败分支会把 loading 恢复为 false。

检查二维码参数是否只能使用一次

扫码支付等场景经常使用一次性订单号、授权码或临时 token。第一次扫码时,即使用户没有点击按钮,页面初始化接口也可能已经完成“领取”“锁定”或“核验”,从而改变服务器状态。

再次使用同一二维码时,服务端可能返回:

  • token 已使用;
  • token 已过期;
  • 订单已锁定;
  • 会话状态不匹配;
  • 不允许重复进入。

前端需要识别这些响应并显示清楚的提示,不能继续按照成功数据渲染。

if (response.data.code === 'TOKEN_USED') {
  this.setData({
    loading: false,
    errorMessage: '二维码已使用,请重新获取二维码'
  });
  return;
}

同一个二维码能否重复进入,应由业务规则决定。如果业务允许重复扫码,服务端查询接口就应保持幂等,不能因为用户打开页面就消耗凭证。

检查退出时的清理逻辑

检查 App.onHide、Page.onHide 和 Page.onUnload 是否执行了以下操作:

  • 清空全局扫码参数;
  • 清除用户或订单状态;
  • 停止定时器后没有重新创建;
  • 移除事件监听后没有重新注册;
  • 中止请求后没有重置请求状态;
  • 将页面状态设为空;
  • 关闭 WebSocket 后没有重新连接。

清理和恢复必须成对处理。例如:

Page({
  onShow() {
    this.startPolling();
  },

  onHide() {
    this.stopPolling();
  },

  startPolling() {
    this.stopPolling();

    this._timer = setInterval(() => {
      this.refreshOrder();
    }, 3000);
  },

  stopPolling() {
    if (this._timer) {
      clearInterval(this._timer);
      this._timer = null;
    }
  }
});

不建议直接依赖强制重载

重新跳转页面可以用来验证问题是否与页面状态复用有关,但不适合作为首选修复方案。频繁调用 my.reLaunch 可能遮住生命周期、状态管理或接口幂等方面的问题,也可能引起跳转循环。

如果确实需要重新进入指定页面,应先检查当前路径和参数,并增加防重复标记:

const app = getApp();

if (!app.globalData.relaunching) {
  app.globalData.relaunching = true;

  my.reLaunch({
    url: '/pages/pay/index?orderId=123',
    complete: () => {
      app.globalData.relaunching = false;
    }
  });
}

项目能否使用 my.reLaunch,以及目标页面是否可以作为启动页面,需要根据当前支付宝小程序的运行环境和路由配置确认。

最终修复原则

扫码进入类小程序应将每次进入都当作新的业务会话:

  • 在 App.onShow 中获取并保存最新进入参数。
  • 在目标页的 Page.onShow 中判断是否需要重新初始化。
  • 不要用永久性的 hasInitialized 阻止后续进入。
  • 请求锁、加载状态和定时器都要能够正确复位。
  • 服务端接口需要明确处理二维码重复使用和过期情况。
  • 遇到异常或空数据时,页面应显示可见的错误信息。
  • 修复后使用真机连续扫码测试,覆盖短时间返回、长时间停留后台、重复使用二维码和网络失败等情况。

如果第二次扫码时生命周期正常,启动参数也与第一次一致,就应继续检查首次页面请求是否已经消耗二维码凭证,以及服务端对第二次请求返回了什么。

备注:内容仅供参考。