乐于分享
好东西不私藏

app.js 里那个 App(),为什么只能调一次?

app.js 里那个 App(),为什么只能调一次?

各位产品、运营、还有深夜对着红屏报错「App() must be called…」的攻城狮朋友们,大家好。我是做小程序架构的老工程狮 👋

你有没有遇到过这种场面:测试同事换了个入口场景——比如从分享卡片、扫码、或通知中心再次唤起小程序——结果页面一闪白,控制台多了一行刺眼的提示,逻辑像断了线:登录态没续上、全局数据清零、埋点重复计数。追到最后,根子都在 app.js:要么没注册,要么注册了不止一次,要么把“初始化”当成了“随缘异步”。

微信官方文档把这件事讲得很硬,但很关键:

每个小程序都需要在 app.js 中调用 App() 方法注册小程序实例,绑定生命周期回调、错误监听和页面不存在监听函数等;并且 App() 必须在 app.js 中调用,必须且只能调用一次,不然会出现无法预期的后果。

今天咱们不搞背诵,把这行话拆开:它到底在“注册”什么、生命周期怎么跟用户前台/后台对应、onError/onPageNotFound 怎么兜底、以及我见过最毁稳定性的几种写法怎么改。


一、App() 不是“随便执行个函数”,而是把程序交出去

(1)小程序只有一个“全局实例”,入口不是 main(),是 App()

网页里你可以 N 个 script 标签、N 次 new Vue(),谁先ready谁说了算。

小程序逻辑层是一份 JS 文件从头跑(更像 ServiceWorker),框架要求你在 app.js 里显式声明:

js

// app.jsApp({  onLaunch(options) {},  onShow(options) {},  onHide() {},  onError(msg) {},  globalData: { token''userInfonull }})

这里的 App() 不是帮你“创建对象”,而是注册程序:框架读到 app.js 执行到这一句,才真正把小程序全局实例挂起来,生命周期管线才开始转。

所以它才咬死两条:

  1. 必须在 app.js(不让你把“程序注册”藏到 pages/ 里某个文件、或按需懒加载)

  2. 只能一次(不是“建议单例”,是硬性约束——二次调用直接给你“未定义行为”警告)

(2)为什么“只能一次”不是小题大做

如果允许多次注册,就会变成:

A 页逻辑 require 了别的模块,模块侧又偷偷 App();热重载/分包加载路径再一抖——全局实例被覆盖、生命周期重复绑、globalData 被重置……用户侧表现就是:切后台再回来状态丢了、onLaunch 突然跑两遍、甚至白屏。

所以这条铁律本质是在保护一件事:

全局状态与全局管线,只能有一条,且必须在框架能找到的地方(app.js)钉死。


二、生命周期:不是“页面加载”,是“程序活着”的三段节奏

很多团队把 onLaunch 当“页面 onLoad 的放大版”,结果把接口、埋点、一次性重算全塞进去,导致首屏被拖慢。看清语义,比背时机更重要。

(1)onLaunch:全局只一次,干“一次性基建”

官方定义:小程序初始化完成时触发,全局只触发一次

参数 options里你最该关注的不是“用户是谁”,而是入口场景scene / query / path / referrerInfo——它回答了“这趟小程序是怎么被打开的”(扫码?分享卡?通知?)。

我建议的纪律:

  • ✅ 轻量探测:scene 分发、灰度开关、版本/渠道记录、隐私协议是否已同意

  • ❌ 别在这里写“必须等到接口回来才让首页渲染”的同步锁——你正在拖冷启动

js

App({  onLaunch(options) {    // 正确姿态:记录入口信息,而不是卡住世界    this.globalData.launchScene = options.scene    if (!wx.getStorageSync('PRIVACY_AGREED')) {      // 进阶建议:这里只做“事实记录”,真正弹窗/跳转最好交给首页 onShow 判断    }  },  globalData: { launchScene0token'' }})

(2)前台/后台的真实定义:不是“关了”,是“离开微信前台”

文档给的前后台定义很实用:关闭/Home 键离开微信并不意味着销毁,常见是进入后台;只有在后台太久或资源高才真正销毁重来。

对应到回调就是:

  • onShow:启动 / 从后台回到前台 → 适合做“每次可见都要拉”的轻量刷新

  • onHide:前台切后台 → 适合做“离开前保存/停止定时器/结束不必要轮询”

老工程狮经验:

别在 onShow 里无脑 wx.request刷一大坨不变数据,否则用户每次切回来都白等一轮;先把“是否需要刷新”的条件想清楚(时间戳阈值/脏标记/对比版本号)。


三、onError + onUnhandledRejection:别让线上事故“静默”

(1)onError(msg):脚本错误 / API 调用报错的统一出口

官方说明:小程序发生脚本错误或 API 调用报错时触发,并带上错误信息。

它最适合做的事只有一件:把它变成证据——打本地日志/写到上报队列,而不是弹框吓用户。

js

App({  onError(msg) {    // 1) 本地先留痕(环形队列防爆)    try {      const logs = wx.getStorageSync('_err_q') || []      logs.push({ t: Date.now(), msg: (msg||'').slice(0,800) })      if(logs.length > 20) logs.shift()      wx.setStorageSync('_err_q', logs)    } catch(e) {}    // 2) 异步上报(别用 await 阻塞生命周期)    wx.request({ url'https://log.xxx.com/err', method:'POST', data:{ msg } })  }})

(2)onUnhandledRejection:Promise 的“最后防线”

从基础库 2.10.0 起支持:Promise 拒绝但没人 catch 时,会到这里。

它往往比 onError 更隐蔽:代码没语法炸,但逻辑“悄悄失败”(比如接口封装成 Promise 后忘记 then/catch)。

进阶建议:

这两个钩子定位是“兜底日志/告警”,别在里面再跑一套会抛错的复杂链路,不然日志系统自己就循环了。


四、onPageNotFound:把“404”变成可控的降级,而不是用户一脸懵

官方:小程序要打开的页面不存在时触发(比如活动页下线、路径拼错、动态 url 里 page 写错)。

js

App({  onPageNotFound(res) {    // res.path / res.query 能看到它想去哪    // 如果是 tab 页,要用 switchTab;普通页才 redirectTo    wx.redirectTo({ url'/pages/home/index' })  }})

进阶建议(建设性):

对业务而言,onPageNotFound 的价值是保护转化路径——让用户掉到首页/兜底页,而不是直接卡死白屏。同时把 res.path上报,你才能发现:到底是代码发布的路径改漏了,还是运营分享卡里带了过期页面参数。


五、globalData 与 getApp():能碰,但得定规矩

(1)globalData 是“全局可见储物柜”,不是“随便塞的杂物间”

适合放:登录态 token、脱敏后的 userId、环境开关、launchScene。

不适合放:整页列表数据、表单草稿、UI 状态(这些应在页面/组件 data)。

(2)getApp() 的两条红线(官方也点到为止)

官方提醒过:

  • 不要在 App() 内定义的函数里、或 App 调用前 调用 getApp()

  • 拿到实例后,不要私自调用生命周期函数

人话版:

getApp()用来读全局状态/工具函数就够了;别把它当“跨页通信总线”到处 getApp().globalData.xxx=yyy然后指望别人知道你改了啥。要通信就走事件/状态管理/显式函数,别暗箱。


六、最常见的“毁约”写法(以及怎么收回来)

翻车 1:把 App() 包进条件/异步里

有人写:

js

// ❌ 错误氛围if (someFlag) { App({...}) }// 或setTimeout(() => App({...}), 0)

结果框架在“预期注册期”没见到实例,后续行为就看运行时脸色了。

✅ 收回来:App() 放 app.js 顶层同步调用,内部再按条件走分支。

翻车 2:在 app.js 里引了某个 page 级模块,模块又偷偷依赖 App 已存在

形成“先有鸡还是先有蛋”的脆弱链。

✅ 收回来:app.js 只做全局初始化;页面级逻辑不反向依赖 app.js 的执行时序,依赖只通过 getApp()读 globalData/方法。

翻车 3:onLaunch 里塞重同步任务,导致首屏被拖

表现为:低端机白屏时间明显变长。

✅ 收回来:把“必须刷新的数据”做成可等待令牌/脏标记,首页 onShow 自己决定要不要刷,onLaunch 只负责写标记。


七、结束语

App() 看起来就一行,却是小程序整个生命周期管线的唯一开关:它在 app.js、只能一次、它绑定 onLaunch/onShow/onHide 的节奏、它也给你 onError/onUnhandledRejection/onPageNotFound 的兜底口子。

你守好它的纪律——不二次调、不同步锁死、不暗箱全局写——全局状态就稳,首屏就轻,用户从入口到成交的路径就不容易“莫名其妙断线”。


参考文献

[1] 微信团队. 逻辑层 App Service——注册小程序(每个小程序都需要在 app.js 中调用 App 方法…)[EB/OL]. (更新日期见页面底部版本信息)[2024-06-16 引用确认]. https://developers.weixin.qq.com/miniprogram/dev/framework/app-service/app.html.

[2] 微信团队. App(Object object)(App() 必须在 app.js 中调用,且只能一次;参数:onLaunch/onShow/onHide/onError/onPageNotFound/onUnhandledRejection…)[EB/OL]. (更新日期见页面底部版本信息)[2024-06-16 引用确认]. https://developers.weixin.qq.com/miniprogram/dev/reference/api/App.html.

[3] GB/T 25000.51-2016, 系统与软件工程 系统与软件质量要求和评价(SQuaRE) 第51部分:就绪可用软件产品(RUSP)的质量要求和测试细则 [S]. 北京:中国标准出版社,2017.


💬 互动话题

你们项目里 onError / onPageNotFound 现在有没有接上报?还是说至今还是 console.log躺平版?评论区留一句你见过最“安静爆炸”的线上事故(白屏/状态清零/分享卡打不开),我挑几个典型,顺着 App() 的时序给你画一张「从入口场景→onLaunch→onShow→兜底」的落地 Checklist,照着补齐,入口稳定性基本就锁住了👇