第一次接手小程序项目的时候,我是拿写 Vue 的那套心智去套它的,结果很快就在 setData 上撞墙了。长列表一滚就掉帧,第一反应是「模板写得太重」,改了半天结构没什么用,排查了一下午才发现症结在逻辑线程往渲染线程塞的数据量上。小程序看着像个功能受限的 Web 容器,可它的运行架构、登录体系、构建方式、发版机制其实是另外一套东西,照搬 Web 经验大概率会踩坑。这篇是我把小程序从底层模型一路梳理到云开发之后留下的笔记,八个主题相互独立,可以当手册来翻。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 双线程模型到底在解决什么问题,为什么
setData是绕不开的性能瓶颈 - 小程序登录流程里的三个角色、六个术语,以及它和 OAuth 2.0 规范的对应关系
- 自定义组件的构造器、生命周期,还有组件间通信的两条路子
- 微信开发者工具 Audits 面板给出的十条性能建议,逐条讲清背后的原理
- 为什么大家最后都放弃了「构建 npm」,改用 Webpack 自建构建体系
- 性能、用户、异常三类数据怎么建模,怎么用 Proxy 劫持 API 做低侵入埋点
- 小程序发了新版本,用户为什么拿不到,
UpdateManager怎么给更新提速 - 云开发的云函数、云数据库、云存储各自负责哪一块
先说一句时效性上的话。这些笔记整理于 2021 年,小程序基础库、开发者工具的面板位置、公众平台后台的菜单层级这几年都调过好几轮,文中提到的入口路径和版本号只反映当时的情况。原文我一个字没删,但真要动手的时候,请以你手上那版开发者工具和微信官方文档为准,别拿这篇当 API 手册用。架构层面的东西倒是稳定的,双线程、登录时序、更新时机这些结论到现在依然成立。
一、双线程模型
这一节是理解小程序其它所有特性的地基。为什么不能操作 DOM、为什么 setData 这么贵、为什么组件通信要绕一圈,答案全在这个模型里。
渲染线程和逻辑线程
小程序的双线程指的就是渲染线程和逻辑线程,这两个线程分别承担 UI 的渲染和执行 JavaScript 代码的工作
下面这张图是小程序运行时的整体结构,左边是负责画界面的渲染层,右边是跑业务代码的逻辑层,中间隔着一层 Native。

图里最该留意的是「两个方框中间那道墙」。它不是画着好看的,两侧是真的跑在不同线程、不同 JS 运行环境里。
渲染线程使用 Webview 进行 UI 的渲染呈现。Webview 是一个完整的类浏览器运行环境,本身具备运行 JavaScript 的能力,但是小程序并不是将逻辑脚本放到 Webview 中运行,而是将逻辑层独立为一个与 Webview 平行的线程,使用客户端提供的 JavaScript 引擎运行代码,iOS 的JavaScriptCore、安卓是腾讯 X5 内核提供的 JsCore 环境以及 IDE 工具的 nwjs
并且逻辑线程是一个只能够运行 JavaScript 的沙箱环境,不提供 DOM 操作相关的 API,所以不能直接操作 UI,只能够通过 setData 更新数据的方式异步更新 UI
这里有个坑很多人一开始都会踩。你在小程序里写 document.querySelector 会直接报 document is not defined,不是微信故意阉割,而是逻辑线程压根就不在 Webview 里,它手上没有 document 这个对象。同理,window、localStorage、XMLHttpRequest 这些全局也都不存在,需要什么能力就用 wx. 开头的对应 API 去换。这也是为什么很多 npm 包搬到小程序里跑不起来,它们默认自己活在浏览器里。
顺着上面聊,那些依赖 DOM 尺寸的逻辑怎么办呢?小程序给了 wx.createSelectorQuery() 这类异步查询接口,你拿不到节点对象本身,只能拿到一份节点信息的快照。注意「异步」两个字,跨线程通信没法同步返回,这一点在写滚动加载、吸顶这类交互时会明显感觉到别扭。
事件驱动的通信方式
你要注意上图渲染线程和逻辑线程之间的通信方式,与 Vue/React 不同的是,小程序的渲染层与逻辑层之间的通信并不是在两者之间直接传递数据或事件,而是由 Native 作为中间媒介进行转发。
整个过程是典型的事件驱动模式:
- 渲染层(也可以称为视图层)通过与用户的交互触发特定的事件 event;
- 然后 event 被传递给逻辑层;
- 逻辑层继而通过一系列的逻辑处理、数据请求、接口调用等行为将加工好的数据 data 传递给渲染层;
- 最后渲染层将 data 渲染为可视化的 UI。
Vue 的响应式改一个 this.count,同一个线程内 patch 一下就完事了。小程序不行,setData 的数据要先序列化成字符串,交给 Native 转发,渲染层再反序列化,然后才轮到 diff 和更新。一次数据更新至少跨了两次线程边界,中间还夹着两次 JSON 序列化。
所以你传什么进去、传多大,直接决定了这条链路有多慢。
跟浏览器的线程模型相比,小程序的双线程模型解决了或者说规避了
Web Worker堪忧的性能同时又实现了与Web Worker相同的线程安全,从性能和安全两个角度实现了提升。可以概括地说,双线程模式是受限于浏览器现有的进程和线程管理模式之下,在小程序这一具体场景之内的一种改进的架构方案。
注意:浏览器中Worker 内的 JavaScript 代码不能操作 DOM,可以将其理解为线程安全的
微信为什么要这么设计?除了性能,安全是另一个更硬的理由。小程序跑在微信这个超级 App 里,如果开发者的脚本能直接摸到渲染层的 DOM,那就意味着能改页面结构、能注入任意节点,微信的审核形同虚设。把逻辑层关进一个没有 DOM 的沙箱,微信就可以在 Native 这一层完整地管控每一次数据流转。
回到我们要解决的问题上,这套模型对日常开发的直接影响就是下面这几条。
性能方面
- 在保证功能的前提下尽量使用结构简单的 UI;
- 尽量降低 JavaScript 逻辑的复杂度;
- 尽量减少 setData 的调用频次和携带的数据体量。
这三条读着像正确的废话,落到代码上其实很具体。第一条对应的是别把整个列表塞进一个 wx:for 里还嵌五层 view;第二条对应的是别在 onPageScroll 这种高频回调里做重计算;第三条最容易出问题,很多人 setData 的时候图省事直接把整个 list 甩过去,其实小程序支持路径式的局部更新,改第 3 条的标题写成 this.setData({ 'list[3].title': '新标题' }) 就行,传输量能小掉一两个数量级。第四节还会把这些点展开讲。
二、 小程序的用户体系与 OAuth 规范
登录是小程序里最容易写出安全漏洞的一块。我见过不少项目图省事,直接在小程序端调 auth.code2Session 拿 openid,appsecret 就那么明晃晃地写在前端代码里。小程序包是可以被解出来的,这么写等于把家门钥匙贴在门上。要避开这类问题,得先把整条链路上每一步谁做、为什么必须他做搞清楚。
微信小程序完整的登录流程
下面这张时序图是官方文档里那张的复刻,从 wx.login 开始,到开发者服务器下发 token 结束,中间每一根箭头都有存在的理由。

整个登录流程中描述了三种角色和六个术语,了解它们的定位和作用,是理解小程序登录流程的基础。
把这张图拆开看,参与方一共三个,如下图所示。

登录流程里的三个角色
客户端在整个登录流程中主要承担两种行为:
- 作为整个流程的发起者,获取临时登录凭证 code;
- 作为整个流程的终结者,存储登录态令牌 token。
- 不过客户端的所有信息和网络请求几乎都是可以被破解或拦截的,所以出于安全的考虑,小程序登录流程中的一些接口被限制不能在客户端中直接调用,而是需要在服务端发起,开发者服务的工作便是处理这些安全敏感的网络请求,体现为上图中使用code 获取 openid 和 session_key的请求,这个请求使用了微信提供的 auth.code2Session 接口。
- 而微信接口服务的工作对于开发者来说是不透明的,你需要做的仅仅是根据接口的规范,组装网络请求发送给它,然后根据返回的接口执行分发逻辑。微信服务器会验证网络请求的合法性,对于合法请求下发密钥 session_key 和用户 openid
登录流程的六个术语
这六个词分别是 code、appid、appsecret、openid、session_key 和 token。它们不是并列关系,前三个是输入,中间两个是微信返回的凭据,最后一个是你自己造的。理清这条链,登录流程基本就通了。
code
- 它是在客户端(即小程序)内通过 wx.login API 获取的,然后通过 HTTP 请求发送给开发者服务器。code 的作用体现在「临时」两字上,它的有效期限仅有 5 分钟,并且仅能够使用一次(即请求一次 auth.code2Session 接口)。
- 为什么要设计一个用完即弃的中间凭证?因为 code 本身不含任何敏感信息,就算在传输途中被截获,攻击者没有 appsecret 也换不出
session_key。它的角色就是一张只对开发者服务器有意义的兑换券。这也解释了一类常见的线上问题,网络抖动时前端做了自动重试,同一个 code 被送去换了两次,第二次微信直接返回 code 已被使用的错误,用户就卡在登录页出不来了。具体的 errcode 请查官方文档的错误码表,别照抄我这里的描述去写判断。正确的做法是重试之前重新调一次wx.login拿新的 code,而不是复用旧的。
appid
- 每个微信小程序在创建之后(即在微信公众平台注册并初始化完成)便同时生成了一appid,这个 ID 标记了小程序的唯一性,等同于网站的URL(经过备案的)、App 的包名等标记应用唯一性的信息
appsecret
- 它是小程序的密钥,可以在微信公众平台的后台管理系统中获取。appsecret 是非常私密的信息,所以微信在制定小程序登录的流程时,将携带此信息的网络请求限制在只能通过开发者服务器发送给微信接口服务,这样对于客户端来说是不可见的,进而降低了被泄露的可能性。与 appid 不同的是,appsecret 可以被重置,但每次重置之后,历史的 appsecret 便会失效,所以请谨慎操作。
- 补一句实践上的建议,appsecret 别写进代码仓库,哪怕是私有仓库。走环境变量或者服务端的配置中心,泄露之后重置的代价是所有在途请求全部失效,线上会直接抖一下。至于后台里具体在哪个菜单能看到它,这些年公众平台改过版,按当前后台的实际布局找就行。
openid
- 这里你要注意,很多开发者容易走入一个误区,误将 openid 理解为用户的唯一 ID。这句话如果放在某个小程序的特定语境下是没有问题的,但是如果放在微信生态的全局角度上是错误的。为什么呢?
- 微信对于用户 openid 的定义是,微信号在某个应用程序中的唯一 ID。这里的「某个应用程序」指的是小程序、公众号、接入开放平台的应用。微信生态中目前有公众平台和开放平台两种,其中公众平台又细分为小程序和公众号,开放平台可以接入网站、移动应用等。同一个微信号在不同的应用程序中有不同的 openid。
- 在微信生态下另外有一个标记微信号的唯一 ID 叫 UnionId。这个 ID 跟应用程序无关。所以,可以简单地理解为 UnionId 与 appid 综合加密后的结果,见下图:

- 图里那条横向的连线就是关键。同一个微信号,在小程序、公众号、开放平台应用里各有一个 openid,它们互不相同,但都挂在同一个 UnionId 下面。
UnionId 通常用来关联在不同应用程序中各个 openid,比如同一个微信号在小程序和公众号内需要配置同样的权限,仅通过 openid 无法实现,便需要获取此微信号的 UnionId。虽然获取 UnionId 的流程并不在这节课的讨论范围之内,但我相信你在后续工作中一定会遇到处理 UnionId 和 openid 的场景,所以先了解一下没啥坏处- 什么时候会需要它?只要你的业务不止一个微信端,这事就躲不掉。比如用户在公众号里买了会员,打开小程序希望是同一个账号,这时候只能靠 UnionId 去关联。所以数据库设计的时候,我建议一开始就把
union_id字段留出来,哪怕现在只有一个小程序,后面补迁移比一开始加一列痛苦多了。另外提醒一句,UnionId 不是无条件下发的,需要小程序和公众号绑定在同一个微信开放平台账号下,具体的绑定条件以官方文档为准。
session_key
- session_key 是对用户数据进行加密签名的密钥,微信服务器使用它将用户的数据进行加密和解密。你可以简单地将 session_key 理解为获取用户数据的「绿卡」,登录之后所有涉及访问微信服务器的请求一般都需要带上它,微信服务器会校验 session_key 的合法性。
- 这里有个容易被忽略的点,session_key 是会过期的,而且过期时机不由你控制。用户长时间不用小程序、或者在其它端触发了新的登录,都可能让它失效。所以解密用户数据失败的时候,别急着怀疑加密算法写错了,先调
wx.checkSession看看会话还在不在,不在就重新走一遍wx.login。 - 其实到这一步(即拿到了 openid 和 session_key)已经完成了小程序的登录流程,但对于一个应用程序来说,用户进行登录操作应该是「一劳永逸」的,即登录过一次之后在一定时间之内的后续操作都不需要再次登录,用技术语言描述就是应该保存用户的登录态。这个时候就需要用到接下来的一个术语 token。
token
- 登录态是个逻辑词汇,token 可以理解为登录态的具象化、数据化。在小程序的登录流程图中,你可以看出,token 是由开发者服务器创建的一个字符串,而且需要跟 openid 和 session_key 相关联。其实这里并不是强制关联 openid,因为 openid 并不算是私密信息,可以放心地下发到客户端(即小程序)。但是 session_key 是非常私密的信息,一旦泄露有很大的安全隐患,所以强烈建议不要把它下发到客户端。
- 在获取到 openid 和 session_key 之后,开发者服务器创建一个 token,然后与 openid 和 session_key 进行关联,具体的方法根据服务器编程语言的不同有多种实现方案。咱们以 JavaScript 语言作为示例,可以创建一个对象,对象的 key 是 token 的值,value 是一个包含 openid 和 session_key 的对象,如下:
{
"token_1": {
"openid": "获取到的openid 1",
"session_key": "获取到的session_key 1"
},
"token_2": {
"openid": "获取到的openid 2",
"session_key": "获取到的session_key 2"
}
}- 上面这个结构只是用来说明映射关系,真跑起来千万别用一个内存对象来存。进程一重启映射全丢,多实例部署时各存各的,用户在 A 机器登录、请求打到 B 机器就是 401。生产环境这份映射一般放 Redis,顺手把过期时间也设上,登录态自然就有了有效期。
- 关联完成之后开发者服务器将 token 下发到客户端,客户端保存在本地,后续的所有请求均需要携带此 token,携带的方法并没有既定的规范,可以通过 URL Query、HTTP Body、Header 等,但通常建议通过 Header 传递,这样相对来说更安全一些。
- 为什么 Header 更安全?URL Query 会被各种访问日志、代理、埋点系统原样记录下来,token 就这么散得到处都是。放 Header 至少不会进 access log 的 URL 字段。
OAuth 2.0 规范中的角色划分
咱们先思考一个问题:小程序登录之后如果需要访问用户的数据(比如昵称、地域、性别等)需要得到谁的授权?是微信?还是用户?
答案是用户。用户的数据虽然存放在微信的服务器之上,但是这些数据的所有权属于用户自己,而不是微信。这里其实引出了 OAuth 2.0 规范中的两个基本概念。
Resource Owner:资源所有者,即用户;Resource Server:资源服务器,即微信。
而小程序在获取用户数据中的角色是作为微信平台的第三方应用程序,在 OAuth 2.0 规范中的术语为 Third-party application。
把这三个角色和前面的流程图对一下就清楚了。微信是 Resource Server,用户是 Resource Owner,你的小程序是 Third-party application,而 code 扮演的正是 OAuth 2.0 授权码模式里的 Authorization Code,session_key 相当于 Access Token 的角色。整套流程不是微信自己拍脑袋发明的,而是授权码模式在小程序场景下的一次具体实现。
理解了这层对应关系,有个实践上的好处。当你以后接入其它开放平台,不管是支付宝小程序还是 GitHub OAuth,看到「先拿 code 再换 token」的套路会觉得很熟悉,因为它们本来就是一回事。
也正因为授权的对象是用户不是微信,所以获取头像昵称这类接口一直在收紧,从早期的静默授权,到必须用户点按钮触发,再到后来的头像昵称填写能力。这块的具体接口这些年变化很大,用之前请以官方文档当前的说明为准,我这里就不列具体 API 名了,免得误导。
三、自定义组件
页面写到第三个的时候,你就会开始想把重复的那部分抽出来。小程序的自定义组件跟 Vue 组件长得像,但生命周期、通信方式、样式隔离规则都是自己一套,照着 Vue 的直觉写,很容易在样式和事件上翻车。
自定义组件的资源管理
创建微信小程序自定义组件需要使用 Component 构造器,这是微信小程序结构体系内最小粒度的构造器,外层是 Page 构造器,最外层的是 App 构造器,三者的关系如下图:
下面这张图把 App、Page、Component 三层构造器的包含关系画了出来,注意它是一层套一层的,Component 在最里面。

理解这个层级有个实际用处。App 是全局单例,整个小程序生命周期内只有一个;Page 随着路由栈进出而创建销毁;Component 挂在 Page 上,跟着宿主页面走。所以全局状态放 getApp().globalData,页面级状态放 Page 的 data,组件私有状态放 Component 的 data,这条界线一开始划清楚,后面维护会轻松很多。
Component 构造器接收的配置对象长这样。
Component({ |
我们可以对照 Vue 和 React 讲解 Component 构造器的几个属性,这样更容易理解:
behaviors类似于 Vue 和 React 中的mixins,用于定义多个组件之间的共享逻辑,可以包含一组 properties、data、lifetimes 和 methods 的定义;properties类似于 Vue 和 React 中的 props ,用于接收外层(父组件)传入的数据;- data 类似于 Vue 中的 data 以及 React 中的 state ,用于描述组件的私用数据(状态);
- lifetimes 用于定义组件自身的生命周期函数,这种写法是从小程序基础库 2.2.3 版本引入的,原本的写法与 Vue 和 React 类似,都是直接挂载到组件的一级属性上
- pageLifetimes 是微信小程序自定义组件独创的一套逻辑,用于监听此组件所在页面的生命周期。一般用于在页面特定生命周期时改变组件的状态,比如在页面展示时(show)把组件的状态设置为 A,在页面隐藏时(hide)设置为 B;
- methods 与 Vue 的 methods 类似,用于定义组件内部的函数
pageLifetimes 这个设计我一直觉得挺聪明。想象一个倒计时组件,页面被切到后台的时候你希望它把定时器停掉,回到前台再续上。在 Vue 里你得让父组件监听 visibilitychange 然后往下传,小程序直接给组件开了个口子,组件自己就能感知宿主页面的 show 和 hide,不用父组件配合。写埋点组件、轮播组件、地图组件的时候,这个口子省了很多胶水代码。
behaviors 也值得多说一句。它跟 Vue 2 的 mixin 一样,好处是复用逻辑,坏处也一样,来源不清晰。一个组件引了三个 behavior,某个 data 字段到底是哪来的,只能一个个翻。我的习惯是 behavior 里的字段和方法统一加前缀,比如列表相关的都叫 _listXxx,冲突和溯源的问题会小很多。
自定义组件的生命周期
组件自身的生命周期和它感知到的页面生命周期,两条线并行,关系如下图。

attached 是最常用的那个,相当于组件挂载完成,初始化请求一般放这里。ready 要等到视图层布局完成,需要 createSelectorQuery 量节点尺寸的话必须等到这个时机,放在 attached 里量出来的多半是 0。detached 记得清定时器和事件监听,这是组件类内存泄漏最常见的来源。
组件间的通信流程
与 Vue/React 不同,小程序没有类似 Vuex 或 Redux 数据流管理模块,所以小程序的自定义组件之间的通信流程采用的是比较原始的事件驱动模式,即子组件通过抛出事件将数据传递给父组件,父组件通过 properties 将数据传递给子组件
假设小程序的某个页面中存在两个组件,两个组件均依赖父组件(Page)的部分属性,这部分属性通过 properties 传递给子组件
下图画的就是这种「兄弟组件靠父页面中转」的数据流向,注意所有箭头都要经过中间的 Page 节点。

当组件 A 需要与组件 B 进行通信时,会抛出一个事件通知父组件 Page,父组件接收到事件之后提取事件携带的信息,然后通过 properties 传递给组件 B。这样便完成了子组件之间的消息传递。
抛事件用的是 this.triggerEvent('eventName', detail),父页面在 WXML 上用 bind:eventName 接。跟 Vue 的 $emit 几乎一模一样,迁移过来没什么心智负担。
除了事件驱动的通信方式以外,小程序还提供了一种更加简单粗暴的方法,父组件通过 selectComponent 方法直接获取某个子组件的实例对象,然后就可以访问这个子组件的任何属性和方法了。随后将这个子组件的某个属性通过 properties 传递给另外一个子组件。相较而言,事件驱动的方法更加优雅,在流程上也更加可控,所以通常建议使用事件驱动的通信方式。
不是说 selectComponent 不能用,而是它把子组件的内部实现直接暴露给了父组件,等于绕开了 properties 这道契约。子组件改个内部方法名,父组件就跟着炸,而且编译期没有任何提示。我自己的用法是只在两种场景下动它,一是调用子组件暴露的命令式方法比如 弹窗.open(),二是需要拿子组件里 canvas 之类的原生实例。数据流还是老老实实走事件和 properties。
组件写多了自然会想更进一步,把一整块能力打包给别的团队用,那就是插件的范畴了。插件的项目结构、审核类目和调用限制跟普通组件差别不小,我在小程序插件总结 从创建到发布的完整流程里单独写过,这里不展开。
四、性能优化
性能优化最怕的就是凭感觉调。好在微信开发者工具里内置了一个评分工具,能把「感觉有点卡」翻译成十条具体的、可以逐条排查的问题。
微信 IDE 的小程序评分功能位于
调试器-> Audits 面板中
面板打开长这样,跑完会给出一份带分数和建议的报告。

点击「运行」之后,微信 IDE 会对当前的小程序项目进行评测(包括代码层面的检测、通过记录用户交互行为的体验检测)。最终从性能、体验和最佳实践三个维度分别打分以及综合分:
- 性能评分是通过对页面渲染、网络、JS 脚本等方面的评估综合得来的;
- 体验评分是从设计和交互等方面的评估而来,由于设计和交互存在一定的主观因素,所以体验的评分权当建议;
- 最佳实践涉及的方面更宽泛,除了代码编写方面的建议
小程序性能优化的具体维度
微信 IDE 对小程序性能进行评分有以下几个维度
- 避免过大的 WXML 节点数目
- 避免执行脚本的耗时过长的情况
- 避免首屏时间太长的情况
- 避免渲染界面的耗时过长的情况
- 对网络请求做必要的缓存以避免多余的请求
- 所有请求的耗时不应太久
- 避免 setData 的调用过于频繁
- 避免 setData 的数据过大
- 避免短时间内发起太多的图片请求
- 避免短时间内发起太多的请求
这十条看着零散,其实归成三类就清楚了。前四条讲的是渲染和脚本,中间三条讲的是数据通信,最后三条讲的是网络请求。下面挑几条讲透背后的原因,知道了为什么,你就不用死记这个清单。
顺带说一句,Audits 面板的位置和检测项这些年调整过,你现在打开可能叫「体验评分」或者归到别的入口下面了,具体以你手上那版开发者工具为准。检测逻辑背后的原理没变,这才是值得记的部分。
1. 避免过大的 WXML 节点数目
WXML 是基于 HTML 的一种 DSL(Domain Specific Language,领域专属语言),除了原生组件(比如 Camera 相机组件)以外,常规组件最终会被小程序的渲染线程通过 WebView 渲染为 HTML ,所以
从性能优化的角度上,HTML 的大部分性能优化方案均适用于 WXML,尽量减少节点数目就是方案之一
节点数目会影响渲染性能,要理解这句话,你要对浏览器的渲染流程有大概了解,来看下面这张图:
这是一条标准的浏览器渲染流水线,从解析 HTML 到最终绘制,中间每一环的耗时都跟节点数正相关。

- HTML 是 XML 的变体,在渲染的时候首先会被浏览器内核解析为 DOM 树,这是一种树形结构,然后会解析每个节点标签的类型、属性等要素,最后与 JavaScript 脚本和 CSS 结合起来进而在经过布局和绘制完成整个渲染流程
- 理论上 HTML 的节点数目和深度是没有限制的,但是从浏览器的渲染流程中可以看出来,DOM 树的结构越复杂,渲染的管线就会越慢
- 降低节点数目对于性能优化的另外一个原因,是与小程序 /Vue/React 这种 MVVM 框架的 DOM更新机制有关。这类框架在更新 UI 时不直接操作 DOM ,而是使用 VDOM( Virtual DOM,虚拟 DOM )技术来实现,VDOM 的高性能来源于高效的 Diff 算法,在内存中对 VDOM 树结构进行对比后提取差异点再映射到真实 DOM 中。
落到实操上,最常见的节点浪费来自两个地方。一是为了套样式无脑加 view 嵌套,四五层下来一个卡片就是十几个节点,列表一渲染就翻几百倍。二是长列表全量渲染,用户只看得到一屏,你把两千条全画出来了。前者用 flex 布局能压掉不少层级,后者的解法是虚拟列表或者分页加载,小程序也提供了 recycle-view 这类回收复用的方案可以参考。
2. 避免执行脚本的耗时过长
执行脚本的耗时过长对于性能的不良影响主要体现在两个时期:
- 第一是在小程序加载完成后的首次渲染期间;
- 第二是小程序运行过程中的处理用户交互时期。
JavaScript 脚本对小程序首次渲染的影响与浏览器环境下
<script>标签对 HTML 渲染的影响类似,虽然小程序中不允许使用<script>标签,双线程模型下 JavaScript 脚本也并不会完全阻塞 UI 线程的行为,但是逻辑线程执行 JavaScript 代码时仍旧是单线程的,通过任务队列管理代码的有序执行。如果某一段 JavaScript 代码逻辑占时太长,造成任务队列过长,最终会影响小程序在响应用户交互行为上的长延时或卡顿
这里有个跟浏览器不一样的地方值得留意。在浏览器里,一段死循环会把页面整个冻住,连滚动都不行。小程序因为双线程隔离,逻辑层卡死的时候界面本身还能滚、动画还在动,看着像没事,但你点任何按钮都没反应。这种「假活」状态排查起来比彻底卡死更费劲,因为第一直觉会以为是事件绑定写错了。遇到点击没反应先去看逻辑层有没有长任务,这条经验能省不少时间。
3. 避免首屏时间太长
影响首屏时间的因素非常多(比如 DNS 解析耗时、TCP 链接的建立耗时……)对于小程序开发者来说,有些因素是不可控的(比如 DNS 解析),那么在可控的众多因素当中,
最核心的两个优化方向是:
- 代码优化;
- 网络优化。
代码方向的优化措施重点关注这样几点:
- 降低 WXML 的结构复杂度,比如节点个数和深度;
- 降低首次渲染的数据规模,首次渲染只包含核心数据,非核心数据的渲染可推迟到首屏渲染完成之后进行;
- 从设计和交互的角度出发,在实际内容被渲染之前展示友好的 loading 效果。
补一条原文没展开的。小程序还有个 Web 里没有的首屏杀手,就是主包体积。用户点开小程序要先下载代码包,包越大白屏越久。分包加载是这里性价比最高的手段,把首页之外的业务拆成子包,主包只留启动必需的部分。再配合分包预下载,用户在首页停留的这几秒里把下一个可能进入的子包悄悄拉下来,体感上就没有二次等待了。这块的配置字段以官方文档为准,我就不默写了。
而网络方向的优化核心是为了降低 RTT( Round-Trip Time,往返时延),也就是微信 IDE 给出的「6.所有请求的耗时不应太多」这条建议。由于小程序的所有资源均托放在微信的服务器,所以不存在 CDN 和 DNS 优化问题,对于开发者来说,降低 RTT 最有效的两个措施是:
- 减少网络请求所携带的数据体积,这是最直观的网络优化方案;
- 提高服务器处理网络请求的速度,这一点是对服务端的要求,除了服务端代码本身的性能以外,当用户量上升到一定规模之后,还需要服务器有处理高并发的能力。对于专注于端侧的传统前端和小程序开发者来说,这些知识是相对陌生的,往往需要后端的同学配合完成。这也是云开发相较于传统开发模式的主要优势之一,
使用云开发可以让端侧的开发者也能够开发出弹性伸缩、高并发、高 QPS 处理的服务层
4. 避免渲染界面的耗时过长的情况
这是一条综合性能指标,渲染主要包括两个角度:
- 一是首屏的渲染时间;
- 二是小程序运行期间的界面更新所需的渲染时间,我们不妨称之为动态渲染。
动态渲染是由 JavaScript 脚本中调用 setData 更新数据所触发,所以优化动态渲染的切入点便一目了然:优化 setData。至于具体的优化方案,便是微信 IDE 给出的
两点建议:
- 避免 setData 的调用过于频繁。
- 频繁调用 setData 会造成逻辑线程与渲染线程之间过多的通信,01讲我们提到双线程之间的通行需要借助微信原生平台作转发,中间必然是有一定的性能损耗和时延。除此之外,渲染线程在接收到逻辑线程传递的数据之后,需要进行解析、VDOM 对比、更新 UI 等一套管线流程,在前一条流程执行完结之前,后面的数据只能排队等待执行。所以频繁调用 setData 就会造成队列加长,用户交互行为触发的 UI 更新就会缓慢甚至可能由于计算量太大造成卡顿。
- 避免 setData 的数据量太大。
- 频繁调用 setData 会造成队列中的任务太多,而如果 setData 的数据量太大,则会造成单个任务的处理耗时加长。与上一条相比,一个是任务数量过多,一个是单个任务过重,两者最终对于性能产生的负面影响是一致的。此外,由于双线程之间需要借助微信原生平台转发,所以 setData 数据量过大也会造成通信时延的加长。
这两条落到代码上有三个很实在的做法。第一,滚动、输入这类高频场景把多次 setData 合并成一次,攒够一批再提交,节流函数在这里比什么优化都管用。第二,用数据路径做定向更新,改数组里某一项就写 'list[3].checked',别整个数组重设。第三,也是最容易被忽略的,只有会出现在 WXML 里的数据才放 data,其余的挂在 this 上就行。我见过把整个接口返回的响应体直接 setData 进去的写法,里面九成字段模板根本没用到,白白跨线程搬运了一大坨 JSON。
一句话,setData 不是赋值,它是一次跨线程的数据传输。
5. 对网络请求做必要的缓存以避免多余的请求
小程序的资源文件托管在微信的服务器,所以小程序开发者不需要关注前端开发领域中对于静态资源的 HTTP 缓存策略,这件事情微信会帮助开发者完成。
这一条建议所指的是在代码层面,将部分重复使用的网络请求结果在代码或 storage 中进行合理缓存以实现复用,对于使用同一个网络请求结果的代码可以直接从缓存中读取,进而减少了不必要的网络请求个数。每次网络请求不论时间长短,均需要用户等待,减少网络请求的个数相当于减少了用户等待时间,提升了用户体验
6. 避免短时间内发起太多的图片请求
- 这一条与微信 IDE 给出的另一条建议「10.避免短时间内发起太多的请求」的方向是一致的,均是为了解决过多 HTTP 请求造成用户等待时间过长的问题。图片资源相对特殊的一个特点是体积较大,前端领域最早的懒加载方案便是主要针对图片资源,所以图片资源的请求对性能的影响更加直观一些。
- 目前前端和小程序领域中
使用的仍旧是 HTTP 1.1 协议,一个 TCP 链接同时只能处理一个 HTTP 请求,在前一个请求得到服务器的响应之后才会发起第二个请求,如果同一时间的 HTTP 请求太多就会产生排队。 - 浏览器为了应对这种问题,提供了建立多个 TCP 连接以实现并行发送 HTTP 请求的目的,目前市面上的浏览器最多支持同时建立
4~8 个 TCP 连接。也就是说,最多可以同时处理4~8 个HTTP 请求。如果同一时刻需要发送的 HTTP 请求数量远大于这个数字,那么还是会产生排队。前面的内容我们重复地提到了「排队」一词,不论是线程间的通信排队、任务队列的排队、还是 HTTP 请求的排队,这些行为都是需要用户等待的,对于用户的切身体验来说,便是响应缓慢甚至卡顿
补两点当年没写进去的。小程序对 wx.request 的并发数本身就有上限,超出的请求会被排队,这个上限是运行时限制,跟浏览器的 TCP 连接数不是一回事,具体数值以官方文档为准。另外,图片这块微信提供了 lazy-load 属性和 mode 裁剪,配合 CDN 的图片处理参数按屏幕宽度取图,比在前端做懒加载省事得多。列表页封面图统一走缩略图规格,首屏请求的字节数能降一大截。
这里还有个例外要提。像 camera、live-player、map 这些原生组件不走 WebView 渲染,是由客户端直接绘制在页面上层的,所以它们不受上面这些 WXML 渲染优化的影响,但会带来层级和滚动上的麻烦。我在小程序直播总结 live-pusher与live-player接入实践里写过原生组件的层级坑,做音视频的可以顺手看看。
五、使用 Webpack 提升小程序研发效率
这一节是全文里工程味最重的一块。如果你是从 Vue 或 React 项目转过来的,第一次打开小程序工程八成会愣一下,没有 webpack.config.js,没有 vite.config.ts,构建这件事被微信开发者工具包办了。方便是方便,代价是你几乎失去了所有控制权。
管理第三方 npm 模块
微信小程序的早期版本不支持使用第三方 npm 包,在基础库 2.2.1 版本才开始支持。但是微信小程序使用 npm 模块的方式与 Node.js 的并不完全相同,虽然同样可以用包管理工具(npm/yarn)安装 npm 模块,但是在小程序源码中引入(require)npm 模块的路径并不是node_modules,而是 miniprogram_npm 目录。
开发者在使用 npm 模块之前必须使用微信 IDE 菜单栏中的「工具」-「构建 npm」,将原始的 npm 模块(即 node_modules 目录中的模块)进行一次预构建,预构建的产出目录便是 miniprogram_npm ,最后才可以在小程序源码中引入(流程如下图所示)
下图是这条链路的示意,node_modules 在左边,中间是开发者工具的构建动作,右边产出 miniprogram_npm。

多说一句,这个菜单项的位置在不同版本的开发者工具里挪过窝,找不到的话在工具菜单里翻一下,或者直接搜「构建 npm」。功能本身一直都在。
而且,小程序预构建 npm 模块的过程并不是简单地将原始模块从 node_modules 目录拷贝到 miniprogram_npm 目录,而是会将原始模块的所有散列文件打包成一个单 js 文件,然后再将这个 js 文件作为模块入口暴露出去
整个预编译的流程如下:
- 读取小程序项目的
package.json文件(位于miniprogram/package.json)中有哪些依赖(dependencies) - 在
node_modules目录内依次寻找这些依赖的原始 npm 模块,读取模块的package.json文件,搜寻main字段指定的入口 js 文件 - 分析模块的入口 js 文件引用了哪些子文件
- 将所有文件打包为一个单 js 文件
四个步骤画成图是下面这样,读 package.json、找 main、分析依赖、打包成单文件。

你要注意,在执行第四步时,微信 IDE 并不会将原始 npm 模块所依赖的其他 npm 模块一并打包。 比如,现实工作中的网络请求模块 axios ,这个基础模块可能被某个 npm 模块(假设为模块 A)依赖,如下
import axios from 'axios'; |
那么微信 IDE 在编译 A 的时候并不会将 axios 的代码一起打包为单 js 文件,而是会保留代码中对于 axios 模块的引用。同时会根据依赖关系寻找 axios 模块的 package.json 文件,然后执行上述的 2~3 步骤,也就是把 axios 模块也编译为单 js 文件
最终的效果就是 miniprogram_npm 目录中存在模块 A 和模块 axios 两个子目录。这样就存在一个很严重的问题: 通常我们的代码中只使用了第三方 npm 模块的一个或几个 API 而不是全部,微信 IDE 方式却始终会把 npm 模块内的全部代码进行打包,最终造成的后果是代码体积增大
又因为小程序对于代码体积有严格的限制(写这篇时主包上限是 2M),使用微信 IDE 打包后很可能会超过上限。虽然在前端开发领域内,一些构建工具(比如 Webpack)会通过 Tree Shaking 机制在打包过程中将没有用到的代码片段舍弃,减少打包后的文件体积,但微信 IDE 目前却并没有这种特性
这个体积上限后来放宽过,分包之后总包的额度也涨了,具体数字请查当前的官方文档,别按我这个 2M 去做容量规划。不过结论不变,包体积始终是硬约束,早点建立体积意识总没错。
举个最典型的例子。你只想用 lodash 里的一个 debounce,import { debounce } from 'lodash',微信的构建 npm 会老老实实把整个 lodash 打进去。Webpack 配上 Tree Shaking,或者干脆写成 lodash.debounce 这种单函数包,进包的就只有那几十行。差距是数量级的。
那么对于习惯了标准 npm 使用方式的前端开发者来说,微信小程序这种 npm 模块的管理和打包方案是很难接受的,单纯从研发效率的角度出发,这个方案也几乎没有可取之处。
所以,业内普遍的做法就是放弃微信 IDE 的 npm 管理方案,使用前端构建工具打造一套构建体系
一个未经修改的微信小程序源码目录如下图所示:

其中 cloudfunctions 是云函数的根目录,miniprogram 中的文件是小程序本体的源码,包括小程序的业务代码和 npm 模块
改造的核心思路只有一句,把 miniprogram 从「你写代码的地方」降级成「构建产物的地方」。
使用 Webpack 打造的构建体系通常会另外建立一个与
cloudfunctions 和 miniprogram平行的目录用于管理源码,然后将 miniprogram 目录作为构建产出目录,如下:
改造后的目录多了一层源码目录,miniprogram 变成 dist,如下图。

这么分层还有个附带的好处,miniprogram 既然是产物就可以直接进 .gitignore,仓库里只留源码,再也不会出现两个人各自「构建 npm」之后产物冲突、review 时几千行 diff 的情况。
同时禁用微信 IDE 编译相关的功能,把这些工作全部交给 Webpack:
对应的开关在项目设置里,把 ES6 转 ES5、增强编译、样式补全这些勾掉,如下图。

为什么一定要关掉?因为两套编译会打架。Babel 已经按你的 browserslist 降过一遍级了,开发者工具再按它自己的规则处理一次,轻则产物膨胀,重则出现只在真机上复现的诡异报错,排查起来毫无头绪。规则很简单,编译这件事只能有一个负责人。
这样一来,在自建的构建体系下,我们不仅可以使用标准的 npm 模块管理方式,同时可以发挥 Webpack 对于研发效率的加持,比如 Tree-Shaking 减小打包文件体积、结合 Babel 使用最新的 ECMAScript 特性、结合 Lint 工具统一代码规范等。这是接下来我们要讨论使用 Webpack 完成的几项具体工作的基础
这套方案的代价也得说清楚。自建构建体系意味着 WXML、WXSS、JSON 这些文件的拷贝和监听都得自己配 loader 和 plugin,热更新体验多半不如原生工具顺手,团队里还得有人长期维护这套配置。如果你的小程序就是几个页面的展示型应用,老老实实用官方的构建 npm 完全够用,别为了工程化而工程化。真正值得上这套的是业务复杂、要跟主站共用组件库和工具函数、并且有 CI 流程的项目。
另外补一句时效性。现在也有不少团队直接用 Taro、uni-app 这类跨端框架,它们自带完整的构建链路,把上面这些问题在框架层面就解决了。用什么取决于你是不是真的需要跨端,我这里讲的是原生小程序工程怎么自己搭。
六、数据监控
小程序上线之后,你会发现自己突然变瞎了。用户反馈说「打不开」,你既不知道他卡在哪一步,也不知道有多少人遇到了同样的问题。想恢复视力,就得先想清楚要采什么数据,再想怎么采。
数据建模,性能、用户和异常
1. 性能数据
优化性能的目标主要有两个:
- 减少用户打开小程序(或某个页面)后的等待时间,这部分的性能称为启动性能;
- 提高用户操作小程序的流畅度,这部分的性能称为运行时性能。
启动性能和运行时性能各自还能往下拆几层,如下图。

这两类性能对应的优化手段完全不同。启动性能拼的是包体积、分包策略和首屏请求,运行时性能拼的是 setData 频率和渲染复杂度。上报的时候也要分开打标,混在一起看平均值,什么结论都得不出来。
2. 用户数据
用户的数据可以分为两种类型,一是静态数据,包括用户的年龄、性别、地域等信息,这些数据叫「用户画像」;二是动态数据,或者称为用户行为数据,这是一个比较宽泛的概念,可以细分为很多子项,比如:
- 用户在使用小程序期间的一些交互操作数据,比如点击某个按钮,从页面A切换到页面B;
- 用户的行为踪迹,比如先点击页面A的某个按钮然后点击另一个按钮最后切换到页面B;
- 用户在某个页面的停留时长;
- 用户的留存率;
静态画像和动态行为的关系如下图。

这里必须插一句合规的事。用户画像相关的字段现在受隐私协议和个人信息保护相关法规约束,采集前要在小程序里明示并获得授权,能不采的字段就别采。这跟技术方案无关,是能不能上线的问题。
3. 异常数据
异常数据有三种类型:
- 端侧的代码异常,比如小程序 JavaScript 脚本的某段逻辑执行报错;
- 服务异常,不过这类异常情况不仅仅是小程序服务端的问题,也可能是用户设备所在网络环境造成的 HTTP 请求失败;
- 行为异常,最常见的一种就是爬虫脚本频繁地请求某个服务接口。
三类异常的归属和责任方如下图,注意它们并不都由前端负责。

性能数据、用户数据和异常数据三者相对独立,而我们统计数据的目的并不是收集这些独立的数据,而是希望将它们综合在一起进行分析,这样才能从多维度、多方面获取数据隐藏的信息。也就是将所有数据通过一定的联系归属到在更上一层的领域内分析
在小程序场景下,把这三种类型数据联系到一起的上层领域就是小程序的每个页面 Page。页面再上一层的领域就是小程序的运行环境(包括用户设备信息和小程序的版本信息)。由此我们可以总结出小程序的数据统计所使用的数据模型,如下图所示

这张图我建议多看两眼,它是整套监控方案的骨架。为什么要用 Page 来串?因为线上排查问题时你问的第一个问题永远是「哪个页面出的事」,而不是「哪条 JS 报了错」。同理,最外层挂运行环境,是因为很多问题只在特定机型或特定基础库版本上复现,没有这层维度,你会在一堆报错里找不到规律。上报字段里把小程序版本、基础库版本、机型这几项带上,成本极低,回报极高。
确定了数据模型,接下来就是制定针对每种数据的采集方案。
采集方案,自动化工具和 API 劫持
1. 性能数据采集
性能数据的采集通常会放在小程序发布前的研发或测试阶段,将其作为自动化测试的一部分。当然这并不是说采集小程序线上的性能数据没价值,而是必要性不足,因为影响线上性能数据的外界因素太多了,用户的网络情况、设备状态等都有可能造成某一时刻(甚至某一时间段之内)的性能数据波动,这种情况下统计的数据大多是没有实际价值的。而在研发或测试阶段往往是在固定的外界环境中进行性能数据的采集,多次抽样取期望值,然后与历史数据进行对比和评估
具体到性能数据的采集方法上,主流的有两种:
- 截图+图片比对。
- 在对小程序进行仿真操作的过程中按照一定的频率进行截图,然后使用工具进行图片比对,从而获取到一些性能数据,比如小程序启动耗时、首屏渲染耗时等等。通过这种方法获取到的性能数据有一个特点,数据的精细度与截图的频率和图片比对工具的准确性成正比,实施的成本相对比较高。
- 使用官方提供的性能 Trace 工具导出数据。
- 直接获取到各项性能指标的数值,包括启动耗时、下载耗时、渲染耗时……比第一种方法实施的成本低很多,而且数据精准度更高。但目前只能在 Android 手机上拿到 Trace 工具的数据,iPhone 暂时不支持。
2. 异常数据和用户数据采集
- 行为异常比如爬虫,在端侧是无法知悉的,防爬防刷是服务器安全保障的一部分,所以行为异常的监控一般都是由服务端承担,你可以把这项工作交给服务端的同事。
- 服务异常的数据来源有两种,一种是用户网络原因导致的请求失败或超时,一种是服务器本身出了问题。第二种与行为异常同样是属于服务端的职责,而在小程序端侧只能够介入第一种异常数据的采集,在采集方案上与代码异常是一致的。
异常数据的采集也可以称为异常监控,采集到异常本身并不是主要目标,更重要的是能够采集到引起异常的用户行为路径。 比如对于电商小程序典型的购买商品的链路,用户点击了商品详情页的「购买」按钮,首先跳转到「购物车」页面,然后继续点击「下单」跳转到订单页面,最后点击「支付」调起微信支付。这个过程用户一共需要四个步骤:

假如在这条链路中的「购物车」页面出现了异常,我们要采集的并不仅仅是当前页面脚本抛出的异常本身,而是要同时获取到引起异常的前序路径,即「商品页」信息。
这一点是很多自建监控做不好的原因。光有一条 Cannot read property 'id' of undefined,你连它是从哪个入口进来的都不知道,更别说复现。有了前序路径,问题往往一眼就清楚了,比如从活动页进来的没带商品 ID,从首页进来的带了。
用户行为数据的采集同样如此。 我们要获取的并不仅仅是用户点击了哪个按钮,还需要采集到这个按钮所在的页面,如果此页面是由其他页面跳转而来还需要采集前序页面的路径信息
还是以刚才的商品购买链路为例,点击商品页的「购买」按钮会触发跳转购物车,如下:
Page({ |
然后在购物车页面中获取 URL 中携带的商品 ID:
Page({ |
如果使用最原始的代码埋点,需要在两个页面的函数中手动填写埋点代码,如下:
// 商品页 |
这种方式既费时、费力又难以维护,因为如果在后续迭代中不需要统计某个函数的行为,就要找到这个函数的埋点代码手动删除。所以我们要来解决这样的问题,这里需要用到 ES 6 的一些新特性
Proxy 和 Reflect。目前小程序运行时还不支持这些特性,你可以借助 Babel 将其转化为 ES 5 语法
这里补一个当年的坑。Proxy 是没法被 Babel 完整降级的,它依赖引擎的底层能力,babel-polyfill 也补不出来,只能靠 Proxy 本身在运行环境里存在。所以这套方案能不能用,取决于你的目标基础库版本和机型覆盖,而不是取决于 Babel 配置。现在的基础库对 Proxy 的支持已经好很多了,但兜底逻辑还是建议写上,检测不到 Proxy 就退回到手动埋点,别让埋点代码把主流程搞崩。
用 Proxy 和 Reflect 实现埋点的思路非常简单,代理(也可以称为劫持)小程序的 API ,在调用 API 的同时采集数据。以上述案例中用到的小程序 Page 对象为例,使用 Proxy 和 Reflect 实现 API 代理
Page = new Proxy(Page, { |
这段代码的核心是 get 陷阱。每次读取被代理对象上的属性,都会先走一遍这个函数,如果读到的是函数就包一层,在原函数执行前先把数据报上去,然后再原样调用。原始的业务代码一行都不用改,这就是低侵入的意思。
不过实话实说,上面这个写法直接拿去跑是不生效的。Page 本身是个构造函数,你把它代理了,get 陷阱拦的是「读 Page 的属性」,而实际发生的是「调用 Page(options)」,两者不是一回事。真要落地,通常是代理传给 Page 的那个配置对象,或者在 Page 外面包一层函数。思路是完全一致的,只是拦截的位置要往里挪一层,大意如下。
const rawPage = Page; |
注意这里用了 originHandler.apply(this, args) 而不是 .call(context, ...),因为生命周期函数执行时的 this 应该是小程序注入的页面实例,context 拿到的是 Proxy 自己,绑错了 this.setData 就找不到了。这是我当时调这段代码卡最久的地方。
将以上代码封装为一个独立的 JavaScript 文件,假设名称为 report.js ,然后在小程序中引入:
require('./report.js'); |
经过以上改造,每当调用 Page 的 API 时都会上报数据。但是当调用 Page 的任何一个 API 都会上报数据,而大多数情况下只需要统计有限的几个 API ,所以要为
report.js 引入一种白名单机制,只有在名单之内的 API 上报数据。改造的方式也很简单
export default function report(obj, apiList) { |
白名单不只是为了少上报几条数据。没有它的话,模板里每一个 bindtap 绑定的方法、每一次框架内部读属性都会触发上报,量大到服务端根本接不住,而且噪声淹没信号,你反倒什么都看不出来。埋点这事,采得准比采得多重要。
你应该也注意到了,上面这段代码不仅加入了白名单机制,而且还把被代理的对象改成了动态的参数,这样便可以适用于任何对象,比如小程序的 App 和 Page 对象:
const report = require('./report.js'); |
到目前为止,我们完成了数据采集的实施方案,当然我们肯定会根据现实业务的需求做出调整和改造,比如制定上报数据的格式规范、上报时机、处理离线数据等细节(这些内容与业务有强关联性)
上报时机这块我多说两句,它比采集本身更容易出问题。每触发一次就发一个请求,前面性能那节讲的「避免短时间内发起太多请求」就全白说了。常规做法是在内存里攒一个队列,达到条数阈值或者时间阈值再批量发一次,同时在页面 onHide 和小程序 onHide 的时候强制 flush 一把,不然用户直接退出,最后那批数据就丢了。离线场景可以先写 wx.setStorage,下次启动时补发,但要给存储量设上限,别把用户的本地存储撑爆。
3. 采集到所需数据之后,然后就是根据这些数据做分析、决策了
- 性能数据能够帮助技术研发人员发现影响应用程序性能的不良因素,然后进行专项优化。
- 异常数据主要的作用是监控线上环境存在的问题,然后根据问题影响面的大小制定告警策略,比如当监控到影响功能逻辑的严重脚本错误,后台监控服务会通过邮件、短信、电话的方式通知责任人督促尽快解决
告警这块给个经验,阈值一定要按比例而不是按绝对数量来定。用户量涨十倍,报错数自然也涨十倍,用绝对值做阈值的告警很快就会天天响,然后所有人都开始无视它,告警就死了。
整体的数据监控体系可以简化为下面这张图:

图的右半边(存储、分析、告警)通常不是前端一个人能扛下来的,但左半边的采集和上报规范必须由前端定,因为只有你清楚哪个字段是从哪来的、什么时候可能为空。
这一节的几个结论
- 数据不仅仅对产品和运营有价值,对于研发同样意义非凡,你需要明确这一点,在以后的工作中将数据重视起来;
- 性能的评估通常作为自动化测试的一部分,而异常监控则是针对生产环境的。作为一名研发,你需要时刻关注这两种数据,并且有针对性地进行改善;
- 采集小程序的异常数据和用户数据可以通过
劫持小程序 SDK 的 API ,这样能够减轻代码埋点的工作量,并且降低后续维护的成本。
再补一句现实情况。如果你的团队规模不大,这一整套自建监控的投入产出比其实不高,微信官方的小程序数据助手、以及一些成熟的前端监控服务都能覆盖大部分需求。自建的价值在于你能拿到原始数据、能按自己的业务维度切分。要不要自建,看的是你有没有那些第三方满足不了的分析诉求。
七、小程序的更新策略
这一节可能是全文里最容易被低估的。很多人发完新版本,看到后台显示「已发布」就以为完事了,结果客服那边还在收到旧版本的问题反馈,一头雾水。原因不在你的代码,在小程序的缓存和更新机制上。
小程序的资源可以笼统地分为前端和后端资源,前端资源也可以被称为端侧资源(包括脚本、样式文件等),后端资源指的是小程序的一些服务接口。
端侧更新策略
- 网站的前端资源可以分为动态资源和静态资源, 静态的资源包括 js、css、图片等文件,为了提高性能通常会将这些文件尽量缓存到本地。动态的资源只有 HTML 文件
- 网站的HTML 文件最初是由服务端通过模板引擎渲染出来的,比如 freemarker、smarty 等,现在仍然有很多网站使用这种方式,不过更流行的是用 React/Vue SSR 以及 SPA 的静态 HTML。
- 虽然在 SPA 架构中,HTML 文件与 js 文件、css文件一样作为静态资源部署,但跟 js 和 css 不同的是,我们并不会让浏览器缓存 HTML 文件,而是通过服务器配置将 HTML 文件的 HTTP 请求的 Cache-Control Header 设置为 no-cache 。这是为了保证用户每次打开网站都会得到最新版的 HTML 文件,而其他静态资源都要通过 HTML 文件才会被引入,这保证了HTML 文件的实时性,也保证了网站所有静态资源的实时性
- 跟网站不同的是,小程序的「所有」端侧资源都是静态的
- 小程序的资源是托管在微信服务器上的,跟网站不同,微信不会在用户每次打开小程序时,从服务器拉取最新的小程序资源,而是尽可能地发挥缓存的优势
这就是问题的根源。网站有个 no-cache 的 HTML 当锚点,你改了代码用户刷新就能拿到;小程序没有这个锚点,所有东西都躺在缓存里。

当用户打开小程序时,微信客户端会先从缓存中拉取小程序的端侧资源,有的话就展示给用户,没有的话会从微信服务器拉取,这时,拉取的肯定是最新版本,然后放入缓存并展示给用户。
以上就是小程序的端侧资源的管理机制。从这套流程里你会发现一个问题:既然优先使用缓存中的资源,那么当我发布了小程序新版本之后,怎么保证用户尽可能快地更新为新版本呢?这就是我们要讨论的重点:小程序的端侧资源更新机制。
本地没有缓存会触发是最简单的一种时机,除此之外还有两种时机。
- 未启动时: 指的是小程序处于非活跃状态时(比如处于后台),但是请注意,这种状态是用户已经用过小程序后才会产生的,如果用户从来都没有用过你的小程序,就不存在状态的概念了,因为对于这个用户来说,你的小程序是无状态的。
- 冷启动时: 小程序被销毁重新打开后会进入冷启动状态
当你在小程序管理后台发布新版本的小程序之后,微信会根据用户设备上小程序的状态实施不同的更新策略
如果小程序处于未启动状态, 微信客户端会在「若干个时机」去检查缓存中的小程序有没有新版本,如果有会默默把新版本资源拉取到本地缓存中
未启动状态下的静默更新流程如下图,整个过程用户完全无感知。

如果小程序处于冷启动状态,微信客户端会主动检查是否有新版本,同时会向用户展示缓存中的旧版本。有新版本的话会默默地拉取到本地,然后在用户再次触发小程序冷启动时展示给用户。也就是说,需要两次冷启动才能将最新版本的小程序展示给用户。整个流程如下图所示:

「两次冷启动」这四个字建议记牢。测试同学说「我明明清了缓存还是旧的」,八成就是卡在第一次冷启动只下载不生效这一步,让他退出去彻底杀掉再进一次就好了。这个不是 bug,是设计如此,因为在启动过程中直接替换代码包会让用户看到界面突变。
从上述内容中,你可以得出一个结论,
当你发布一个新版本后,用户并不能「立即」获得更新。
小程序未启动时最慢 24 小时可以覆盖全部用户,或者需要经历两次冷启动,这对一些紧急的版本更新来说太慢了,所以在现实工作中往往要将小程序的更新提速,让用户尽可能快地获取到新版本。具体实施方法是通过小程序的 UpdateManager 对象,在代码里主动检查并应用更新信息。我们对照流程图和代码讲解,来看下面这张图:
下图是加了主动更新逻辑之后的时序,关键在于 onUpdateReady 之后我们插入了一次自己的判断。

const axios = require('axios') |
原文这段代码里有两处直接抄会踩的地方,我改了一下。一是回调里用的 this.globalData,普通函数的 this 并不指向 App 实例,得用 getApp() 拿;二是接口地址原本写成了一个残缺的占位符,这里换成 YOUR_SERVER_URL 常量,你自己替换成真实地址就行。另外 axios 在小程序里跑需要配一个基于 wx.request 的适配器,不能直接用,这点也提一句。
- 首先在代码中创建一个
UpdateManager对象,然后添加onCheckForUpdate和onUpdateReady监听,当微信客户端从微信服务器中获取到小程序的更新信息后会触发onCheckForUpdate函数,入参携带hasUpdate属性标记是否有新版本未更新。我们将这个信息挂载到全局对象上以便后续使用。 - 当微信客户端从微信服务器中将最新版本的小程序端侧资源拉取到本地之后,会触发
onUpdateReady函数,此时需要你的开发者服务器提供一个接口,对应上述代码中的 your-url。这个接口的入参是用户当前使用的小程序版本,然后根据这个版本号判断当前用户的小程序版本是否存在严重 Bug 需要更新到最新版本。你需要在小程序的脚本代码中,当onUpdateReady函数被触发时调用这个接口,如果需要更新则通过调用updateManager.applyUpdate()强制重启小程序应用更新。
上述这套更新机制相比较需要两次冷启动的默认更新机制来说,能够减少一次冷启动的时间,能更快速地令用户获取最新版本的小程序,对于一些修复紧急 Bug 的版本是一种行之有效的方案。当然,我们只展示了端侧的调用流程,在后端发布小程序时,你需要记录每次发布版本的详细信息,包括是否有紧急 Bug 修复,这样才能够为端侧的调用提供数据来源。
这里有个体验上的坑要提醒。applyUpdate() 会立刻重启小程序,用户正在填的表单、正在看的页面全部丢失。所以这个接口不能无脑调,我的建议是把服务端的紧急标记分成两档,真正的致命 bug 才走强制重启,一般更新只弹个提示让用户自己选时机。另外 onUpdateFailed 也记得挂上,下载失败时至少能上报一条日志,不然这条链路对你来说就是黑箱。
上面提到的「最慢 24 小时」和更新触发时机,是微信当时公开的行为描述,后续版本有没有调整我没有逐版验证过,具体请以官方的运行机制文档为准。这套主动更新的写法本身一直是有效的。
后端服务灰度发布策略
后端服务的发布流程中有一个非常重要且通用的策略:灰度发布。所谓的灰度发布简单理解就是将新版本的服务只向一定比例的用户开放,而另一部分用户仍然使用旧版本的服务,然后观察新版本的状态,如果一切正常则慢慢扩大新版本的用户比例,直到全部用户都切入新版本,便完成了灰度发布的全流程。
灰度发布需要提前制定用户请求的转发策略,一般有两种:
- 按照新旧服务所占用的服务器比例随机转发;
- 按照用户的 ID 转发。
- 第一种简单粗暴,比如你有 10 台服务器,其中 2 台部署了新版本的服务,负载均衡器会在接收到用户请求时按照 20% 的概率随机转发到新版本服务器上,剩余的转发到旧版本服务器。
- 第二种需要进行一定的编码工作,比如 Nginx 配置 Lua 脚本,当接收到用户请求时,从请求中获取到用户的 ID ,在小程序场景下就是用户的 OpenId ,然后匹配转发策略中是否这个 ID 在新版本服务的白名单中,如果是的话便转发到新版本服务,否则转发到旧版本服务。如下图所示:
按 ID 转发的链路如下图,负载均衡器在中间做判断。

两种策略的取舍很清楚。按比例随机胜在零成本,缺点是同一个用户这次进新版本、下次进旧版本,行为不一致,做过灰度的都知道这有多难排查。按 ID 转发能保证同一个用户始终落在同一侧,代价是要写路由逻辑、要维护白名单。
小程序这个场景我更倾向按 ID。原因是小程序天然有 openid 这么一个稳定的用户标识,实现成本比 Web 端低,而且端侧代码是全量下发的,如果接口行为在两个版本间来回跳,前端的兼容逻辑会写得非常难看。
还有个常被忽略的点,端侧灰度和服务端灰度得对齐。小程序新版本调的是新接口,可你的服务端还有一半机器跑旧代码,用户就会随机遇到 404。稳妥的顺序是服务端先全量上线并向下兼容,端侧再发版,这个顺序反了就是线上事故。
八、云开发,云原生一体化应用开发平台
- 云开发其实是一种后端服务,和服务器所扮演的角色类似,都是服务端角色。不过云开发把服务所需要的一些资源(比如计算、存储、消息推送等)封装打包,以方便开发者使用。整体上讲,云开发包括了云函数、云数据库、云存储、云托管等一些基础服务资源,以及云上的各种扩展能力(比如图像处理、客服服务等)。
- 在调用方式上, 云开发的使用方法和前端开发差不多,它将触手可及的各种资源以接口 SDK 的形式给到开发者。举个例子,如果开发微信小程序,需要存储用户的个人数据以方便应用业务,你可以用云开发的接口把数据存入数据库,这个接口并不是 URL 地址,而是一个函数方法(function),举例如下:
下面这段就是往云数据库写一条记录的写法,注意它长得完全不像一次网络请求。

对比一下传统做法。同样是存一条用户数据,以前你要写接口文档、起 Node 服务、配数据库连接、加鉴权中间件、部署上线、配域名和 HTTPS 证书。云开发把这一整串压缩成了一次函数调用,鉴权那部分微信直接帮你带上了 openid。对小团队和个人项目来说,这个提效是实打实的。
如果你想对这些数据进行一些复杂的处理(比如对数据做分析,生成报表)涉及其他的数据,可以把处理的逻辑放到云开发云函数中进行,而云函数也可以在小程序中用函数方法(function)的形式调用,举例如下:
调用云函数的写法如下图,wx.cloud.callFunction 传函数名和参数就完事了。

这里有个安全上的要点。云数据库虽然能在小程序端直接读写,但权限控制是靠集合的权限设置来做的,配置不当就等于把数据库暴露在客户端。凡是涉及金额、库存、订单状态这类不能被伪造的操作,一律走云函数,因为云函数跑在服务端环境里,客户端改不了它的逻辑。这条界线一开始就要划清楚。
再深一步,如果你的微信小程序想存储一些文件,也可以直接使用云开发接口,调用上传文件,文件可以同时被小程序端和云函数端获取到,方便应用功能的开发,举例如下:
上传文件的接口同样是函数式的,如下图。

云存储返回的是一个 cloud:// 开头的 fileID,而不是 HTTP URL。它可以直接丢给 <image> 组件用,也可以调接口换成带签名的临时链接给外部访问。这个设计省掉了自己搭对象存储和配 CDN 的活,代价是文件跟这个云环境绑死了,以后想迁出来要写脚本批量导。
以上在开发小程序时所用到的数据库、云函数、云存储都是云开发提供的资源:
- 云函数是独立的计算资源,通过触发执行逻辑运算或者资源处理,最终返回结果;
- 数据库是遵循 Mongo 协议的非关系型数据库,可以直接通过各种 API 进行调用处理;
- 云存储是云开发提供的专门的存储空间,有基础 API 进行文件管理。
而这些基础服务资源(数据库、云函数、云存储)都被整合到一套接口调用标准中,根据这套标准以及适用端场景,会产生各种 SDK,分别专注于客户端、云函数端、管理端等进行资源统筹和处理。
最后说说这套东西该不该用。云开发省掉的是运维成本,换来的是平台绑定。你的数据结构、调用方式、权限模型全都长在微信的体系里,哪天业务要出微信生态,迁移的工作量不小。所以我的判断是,工具类、内部系统、MVP 验证阶段的项目,云开发是最优解,能把时间全花在业务上;而如果这个小程序只是你多端布局中的一端,后面还要出 App 和 H5,那不如老老实实自建服务端,让小程序和其它端共用同一套 API。
顺带提一句时效性,云开发这几年一直在扩能力,云托管、云调用这些后来的东西这篇没写到,具体能力清单请看官方文档。基础的三件套(云函数、云数据库、云存储)到现在还是主力,理解了它们,其余的能力都是这三样的延伸。
总结
回头看这八个主题,它们其实是被同一条线串起来的。
双线程模型是那个源头。因为逻辑层和渲染层分家,所以你摸不到 DOM,所以数据要靠 setData 跨线程搬运,所以性能优化的重心永远落在通信次数和数据量上,而不是像 Web 那样死磕重排重绘。搞懂了这一点,Audits 面板那十条建议你不用背也能自己推出来。
登录那块的价值在于把 appsecret 关在服务端。真正要记住的只有一句,凡是能伪造的操作都不能只在客户端做判断,code 换 session_key 这一步必须走服务器。这跟 OAuth 2.0 授权码模式是同一个道理,学会一次,接别的开放平台都能复用。
工程化那两节讲的是同一件事的两面。自建 Webpack 构建体系解决的是「写得爽不爽」,数据监控解决的是「上线之后看不看得见」。这两块都不是必需品,团队小、项目简单的时候,官方工具加第三方监控完全够用,等你真的被体积上限卡住、被线上问题问懵的时候再上,不算晚。
更新策略是最容易吃亏的一节。「发布了不等于用户拿到了」,两次冷启动这个机制不知道的话,紧急修复的版本可能要等一天才铺开。UpdateManager 是解药,但别滥用 applyUpdate,用户正在填的表单没了比晚一天更新更让人恼火。
至于云开发,它是个取舍问题而不是技术问题。省运维还是保迁移自由,看你项目的生命周期规划。
最后再啰嗦一句,这篇写于 2021 年,里面的面板位置、菜单名称、体积上限这些都可能已经变了,动手前请对一遍官方文档。但双线程、登录时序、缓存更新这些架构层的结论,到今天依然站得住。