原文链接: https://interview.poetries.top/principle-docs/comprehensive/10-%E5%B0%8F%E7%A8%8B%E5%BA%8F%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5.html

一、双线程模型

渲染线程和逻辑线程

小程序的双线程指的就是渲染线程和逻辑线程,这两个线程分别承担UI的渲染和执行 JavaScript 代码的工作

渲染线程使用 Webview 进行 UI 的渲染呈现。Webview 是一个完整的类浏览器运行环境,本身具备运行 JavaScript 的能力,但是小程序并不是将逻辑脚本放到 Webview 中运行,而是将逻辑层独立为一个与 Webview 平行的线程,使用客户端提供的 JavaScript 引擎运行代码,iOS 的JavaScriptCore、安卓是腾讯 X5 内核提供的 JsCore 环境以及 IDE 工具的 nwjs

并且逻辑线程是一个只能够运行 JavaScript 的沙箱环境,不提供 DOM 操作相关的 API,所以不能直接操作 UI,只能够通过 setData 更新数据的方式异步更新 UI

事件驱动的通信方式

你要注意上图渲染线程和逻辑线程之间的通信方式,与 Vue/React 不同的是,小程序的渲染层与逻辑层之间的通信并不是在两者之间直接传递数据或事件,而是由 Native 作为中间媒介进行转发。

整个过程是典型的事件驱动模式:

  • 渲染层(也可以称为视图层)通过与用户的交互触发特定的事件 event;
  • 然后 event 被传递给逻辑层;
  • 逻辑层继而通过一系列的逻辑处理、数据请求、接口调用等行为将加工好的数据 data 传递给渲染层;
  • 最后渲染层将 data 渲染为可视化的 UI。

总的来说,跟浏览器的线程模型相比,小程序的双线程模型解决了或者说规避了 Web Worker 堪忧的性能同时又实现了与 Web Worker 相同的线程安全,从性能和安全两个角度实现了提升。可以概括地说,双线程模式是受限于浏览器现有的进程和线程管理模式之下,在小程序这一具体场景之内的一种改进的架构方案。

注意:浏览器中Worker 内的 JavaScript 代码不能操作 DOM,可以将其理解为线程安全的

性能方面

  • 在保证功能的前提下尽量使用结构简单的 UI;
  • 尽量降低 JavaScript 逻辑的复杂度;
  • 尽量减少 setData 的调用频次和携带的数据体量。

二、 小程序的用户体系与 OAuth 规范

微信小程序完整的登录流程

整个登录流程中描述了三种角色和六个术语,了解它们的定位和作用,是理解小程序登录流程的基础。

登录流程里的三个角色

客户端在整个登录流程中主要承担两种行为:

  • 作为整个流程的发起者,获取临时登录凭证 code;
  • 作为整个流程的终结者,存储登录态令牌 token。

不过客户端的所有信息和网络请求几乎都是可以被破解或拦截的,所以出于安全的考虑,小程序登录流程中的一些接口被限制不能在客户端中直接调用,而是需要在服务端发起,开发者服务的工作便是处理这些安全敏感的网络请求,体现为上图中使用code 获取 openid 和 session_key的请求,这个请求使用了微信提供的 auth.code2Session 接口。 * 而微信接口服务的工作对于开发者来说是不透明的,你需要做的仅仅是根据接口的规范,组装网络请求发送给它,然后根据返回的接口执行分发逻辑。微信服务器会验证网络请求的合法性,对于合法请求下发密钥 session_key 和用户 openid

登录流程的六个术语

  • code
    • 它是在客户端(即小程序)内通过 wx.login API 获取的,然后通过 HTTP 请求发送给开发者服务器。code 的作用体现在“临时”两字上,它的有效期限仅有 5 分钟,并且仅能够使用一次(即请求一次 auth.code2Session 接口)。
  • appid
    • 每个微信小程序在创建之后(即在微信公众平台注册并初始化完成)便同时生成了一appid,这个 ID 标记了小程序的唯一性,等同于网站的URL(经过备案的)、App 的包名等标记应用唯一性的信息
  • appsecret
    • 它是小程序的密钥,可以在微信公众平台的后台管理系统中获取。appsecret 是非常私密的信息,所以微信在制定小程序登录的流程时,将携带此信息的网络请求限制在只能通过开发者服务器发送给微信接口服务,这样对于客户端来说是不可见的,进而降低了被泄露的可能性。与appid 不同的是,appsecret 可以被重置,但每次重置之后,历史的 appsecret 便会失效,所以请谨慎操作。
  • openid
    • 这里你要注意,很多开发者容易走入一个误区: 误将 openid 理解为用户的唯一 ID。这句话如果放在某个小程序的特定语境下是没有问题的,但是如果放在微信生态的全局角度上是错误的。为什么呢?
    • 微信对于用户 openid 的定义是:微信号在某个应用程序中的唯一 ID。这里的“某个应用程序”指的是小程序、公众号、接入开放平台的应用。微信生态中目前有公众平台和开放平台两种,其中公众平台又细分为小程序和公众号,开放平台可以接入网站、移动应用等。同一个微信号在不同的应用程序中有不同的 openid。
    • 在微信生态下另外有一个标记微信号的唯一 ID:UnionId。这个 ID 跟应用程序无关。所以,可以简单地理解为 UnionId 与 appid 综合加密后的结果,见下图:
    • UnionId 通常用来关联在不同应用程序中各个 openid,比如同一个微信号在小程序和公众号内需要配置同样的权限,仅通过 openid 无法实现,便需要获取此微信号的 UnionId。虽然获取 UnionId 的流程并不在这节课的讨论范围之内,但我相信你在后续工作中一定会遇到处理 UnionId 和 openid 的场景,所以先了解一下没啥坏处
  • session_key
    • session_key 是对用户数据进行加密签名的密钥,微信服务器使用它将用户的数据进行加密和解密。你可以简单地将 session_key 理解为获取用户数据的“绿卡”,登录之后所有涉及访问微信服务器的请求一般都需要带上它,微信服务器会校验 session_key 的合法性。
    • 其实到这一步(即拿到了 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"
          }
      }
* 关联完成之后开发者服务器将 token下发到客户端,客户端保存在本地,后续的所有请求均需要携带此 token,携带的方法并没有既定的规范,可以通过 URL Query、HTTP Body、Header 等,但通常建议通过 Header 传递,这样相对来说更安全一些。

OAuth 2.0 规范中的角色划分

咱们先思考一个问题:小程序登录之后如果需要访问用户的数据(比如昵称、地域、性别等)需要得到谁的授权?是微信?还是用户?

答案是用户。用户的数据虽然存放在微信的服务器之上,但是这些数据的所有权属于用户自己,而不是微信。这里其实引出了 OAuth 2.0 规范中的两个基本概念。

  • Resource Owner:资源所有者,即用户;
  • Resource Server:资源服务器,即微信。

而小程序在获取用户数据中的角色是作为微信平台的第三方应用程序,在 OAuth 2.0 规范中的术语为 Third-party application。

三、自定义组件

自定义组件的资源管理

创建微信小程序自定义组件需要使用 Component 构造器,这是微信小程序结构体系内最小粒度的构造器,外层是 Page 构造器,最外层的是 App 构造器,三者的关系如下图:

    Component({
      behaviors:[],
      properties:{},
      data: {},
      lifetimes: {},
      pageLifetimes: {},
      methods: {}
    });

我们可以对照 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 类似,用于定义组件内部的函数

自定义组件的生命周期

组件间的通信流程

与 Vue/React 不同,小程序没有类似 Vuex 或 Redux 数据流管理模块,所以小程序的自定义组件之间的通信流程采用的是比较原始的事件驱动模式,即子组件通过抛出事件将数据传递给父组件,父组件通过 properties 将数据传递给子组件

假设小程序的某个页面中存在两个组件,两个组件均依赖父组件(Page)的部分属性,这部分属性通过 properties 传递给子组件

当组件 A 需要与组件 B 进行通信时,会抛出一个事件通知父组件 Page,父组件接收到事件之后提取事件携带的信息,然后通过 properties 传递给组件 B。这样便完成了子组件之间的消息传递。

除了事件驱动的通信方式以外,小程序还提供了一种更加简单粗暴的方法:父组件通过selectComponent 方法直接获取某个子组件的实例对象,然后就可以访问这个子组件的任何属性和方法了。随后将这个子组件的某个属性通过 properties传递个另外一个子组件。相较而言,事件驱动的方法更加优雅,在流程上也更加可控,所以通常建议使用事件驱动的通信方式。

四、性能优化

微信 IDE 的小程序评分功能位于调试器-> Audits 面板中

点击“运行”之后,微信 IDE 会对当前的小程序项目进行评测(包括代码层面的检测、通过记录用户交互行为的体验检测)。最终从性能、体验和最佳实践三个维度分别打分以及综合分:

  • 性能评分是通过对页面渲染、网络、JS 脚本等方面的评估综合得来的;
  • 体验评分是从设计和交互等方面的评估而来,由于设计和交互存在一定的主观因素,所以体验的评分权当建议;
  • 最佳实践涉及的方面更宽泛,除了代码编写方面的建议

小程序性能优化的具体维度

微信 IDE 对小程序性能进行评分有以下几个维度

  • 避免过大的 WXML 节点数目
  • 避免执行脚本的耗时过长的情况
  • 避免首屏时间太长的情况
  • 避免渲染界面的耗时过长的情况
  • 对网络请求做必要的缓存以避免多余的请求
  • 所有请求的耗时不应太久
  • 避免 setData 的调用过于频繁
  • 避免 setData 的数据过大
  • 避免短时间内发起太多的图片请求
  • 避免短时间内发起太多的请求

1. 避免过大的 WXML 节点数目

WXML 是基于 HTML 的一种 DSL(Domain Specific Language,领域专属语言),除了原生组件(比如 Camera 相机组件)以外,常规组件最终会被小程序的渲染线程通过 WebView 渲染为 HTML ,所以从性能优化的角度上,HTML 的大部分性能优化方案均适用于 WXML,尽量减少节点数目就是方案之一

节点数目会影响渲染性能,要理解这句话,你要对浏览器的渲染流程有大概了解,来看下面这张图:

  • HTML 是 XML 的变体,在渲染的时候首先会被浏览器内核解析为 DOM 树,这是一种树形结构,然后会解析每个节点标签的类型、属性等要素,最后与 JavaScript 脚本和 CSS 结合起来进而在经过布局和绘制完成整个渲染流程
  • 理论上 HTML 的节点数目和深度是没有限制的,但是从浏览器的渲染流程中不难看出,DOM 树的结构越复杂,渲染的管线就会越慢
  • 降低节点数目对于性能优化的另外一个原因,是与小程序 /Vue/React 这种 MVVM 框架的 DOM更新机制有关。这类框架在更新 UI 时不直接操作 DOM ,而是使用 VDOM( Virtual DOM,虚拟 DOM )技术来实现,VDOM 的高性能来源于高效的 Diff 算法,在内存中对 VDOM 树结构进行对比后提取差异点再映射到真实 DOM 中。

2. 避免执行脚本的耗时过长

执行脚本的耗时过长对于性能的不良影响主要体现在两个时期:

  • 第一是在小程序加载完成后的首次渲染期间;
  • 第二是小程序运行过程中的处理用户交互时期。

JavaScript 脚本对小程序首次渲染的影响与浏览器环境下 <script> 标签对 HTML 渲染的影响类似,虽然小程序中不允许使用 <script> 标签,双线程模型下 JavaScript 脚本也并不会完全阻塞 UI 线程的行为,但是逻辑线程执行 JavaScript 代码时仍旧是单线程的,通过任务队列管理代码的有序执行。如果某一段 JavaScript 代码逻辑占时太长,造成任务队列过长,最终会影响小程序在响应用户交互行为上的长延时或卡顿

3. 避免首屏时间太长

影响首屏时间的因素非常多(比如 DNS 解析耗时、TCP 链接的建立耗时……)对于小程序开发者来说,有些因素是不可控的(比如 DNS 解析),那么在可控的众多因素当中,最核心的两个优化方向是

  • 代码优化;
  • 网络优化。

代码方向的优化措施重点关注这样几点:

  • 降低 WXML 的结构复杂度,比如节点个数和深度;
  • 降低首次渲染的数据规模,首次渲染只包含核心数据,非核心数据的渲染可推迟到首屏渲染完成之后进行;
  • 从设计和交互的角度出发,在实际内容被渲染之前展示友好的 loading 效果。

而网络方向的优化核心是为了降低 RTT( Road-Trip Time,往返时延),也就是微信 IDE 给出的“6.所有请求的耗时不应太多”这条建议。由于小程序的所有资源均托放在微信的服务器,所以不存在 CDN 和 DNS 优化问题,对于开发者来说,降低 RTT 最有效的两个措施是:

  • 减少网络请求所携带的数据体积,这是最直观的网络优化方案;
  • 提高服务器处理网络请求的速度,这一点是对服务端的要求,除了服务端代码本身的性能以外,当用户量上升到一定规模之后,还需要服务器有处理高并发的能力。对于专注于端侧的传统前端和小程序开发者来说,这些知识是相对陌生的,往往需要后端的同学配合完成。这也是云开发相较于传统开发模式的主要优势之一,使用云开发可以让端侧的开发者也能够开发出弹性伸缩、高并发、高 QPS 处理的服务层

4. 避免渲染界面的耗时过长的情况

这是一条综合性能指标,渲染主要包括两个角度:

  • 一是首屏的渲染时间;
  • 二是小程序运行期间的界面更新所需的渲染时间,我们不妨称之为动态渲染。

动态渲染是由 JavaScript 脚本中调用 setData 更新数据所触发,所以优化动态渲染的切入点便一目了然:优化 setData。至于具体的优化方案,便是微信 IDE 给出的两点建议

  • 避免 setData 的调用过于频繁
    • 频繁调用 setData 会造成逻辑线程与渲染线程之间过多的通信,01讲我们提到双线程之间的通行需要借助微信原生平台作转发,中间必然是有一定的性能损耗和时延。除此之外,渲染线程在接收到逻辑线程传递的数据之后,需要进行解析、VDOM 对比、更新 UI 等一套管线流程,在前一条流程执行完结之前,后面的数据只能排队等待执行。所以频繁调用 setData 就会造成队列加长,用户交互行为触发的 UI 更新就会缓慢甚至可能由于计算量太大造成卡顿。
  • 避免 setData 的数据量太大
    • 频繁调用 setData 会造成队列中的任务太多,而如果 setData 的数据量太大,则会造成单个任务的处理耗时加长。与上一条相比,一个是任务数量过多,一个是单个任务过重,两者最终对于性能产生的负面影响是一致的。此外,由于双线程之间需要借助微信原生平台转发,所以 setData 数据量过大也会造成通信时延的加长。

5. 对网络请求做必要的缓存以避免多余的请求

小程序的资源文件托管在微信的服务器,所以小程序开发者不需要关注前端开发领域中对于静态资源的 HTTP 缓存策略,这件事情微信会帮助开发者完成。

这一条建议所指的是在代码层面,将部分重复使用的网络请求结果在代码或 storage 中进行合理缓存以实现复用,对于使用同一个网络请求结果的代码可以直接从缓存中读取,进而减少了不必要的网络请求个数。每次网络请求不论时间长短,均需要用户等待,减少网络请求的个数相当于减少了用户等待时间,提升了用户体验

6. 避免短时间内发起太多的图片请求

  • 这一条与微信 IDE 给出的另一条建议“10.避免短时间内发起太多的请求”的方向是一致的,均是为了解决过多 HTTP 请求造成用户等待时间过长的问题。图片资源相对特殊的一个特点是体积较大,前端领域最早的懒加载方案便是主要针对图片资源,所以图片资源的请求对性能的影响更加直观一些。
  • 目前前端和小程序领域中使用的仍旧是 HTTP 1.1 协议,一个 TCP 链接同时只能处理一个 HTTP 请求,在前一个请求得到服务器的响应之后才会发起第二个请求,如果同一时间的 HTTP 请求太多就会产生排队。
  • 浏览器为了应对这种问题,提供了建立多个 TCP 连接以实现并行发送 HTTP 请求的目的,目前市面上的浏览器最多支持同时建立 4~8 个 TCP 连接。也就是说,最多可以同时处理 4~8 个HTTP 请求。如果同一时刻需要发送的 HTTP 请求数量远大于这个数字,那么还是会产生排队。前面的内容我们重复地提到了“排队”一词,不论是线程间的通信排队、任务队列的排队、还是 HTTP 请求的排队,这些行为都是需要用户等待的,对于用户的切身体验来说,便是响应缓慢甚至卡顿

五、使用 Webpack 提升小程序研发效率

管理第三方 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 ,最后才可以在小程序源码中引入(流程如下图所示)

而且,小程序预构建 npm 模块的过程并不是简单地将原始模块从拷贝 node_modules 目录到miniprogram_npm 目录,而是会将原始模块的所有散列文件打包成一个单 js 文件,然后再将这个 js 文件作为模块入口暴露出去

整个预编译的流程如下:

  • 读取小程序项目的 package.json 文件(位于 miniprogram/package.json)中有哪些依赖(dependencies
  • node_modules 目录内依次寻找这些依赖的原始 npm 模块,读取模块的 package.json 文件,搜寻 main 字段指定的入口 js 文件
  • 分析模块的入口 js 文件引用了哪些子文件
  • 将所有文件打包为一个单 js 文件

你要注意,在执行第四步时,微信 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 目前却并没有这种特性

那么对于习惯了标准 npm 使用方式的前端开发者来说,微信小程序这种 npm 模块的管理和打包方案是很难接受的,单纯从研发效率的角度出发,这个方案也几乎没有可取之处。

所以,业内普遍的做法就是:放弃微信 IDE 的 npm 管理方案,使用前端构建工具打造一套构建体系

一个未经修改的微信小程序源码目录如下图所示:

其中 cloudfunctions 是云函数的根目录,miniprogram 中的文件是小程序本体的源码,包括小程序的业务代码和 npm 模块

使用 Webpack 打造的构建体系通常会另外建立一个与 cloudfunctions 和 miniprogram 平行的目录用于管理源码,然后将 miniprogram 目录作为构建产出目录,如下:

同时禁用微信 IDE 编译相关的功能,把这些工作全部交给 Webpack:

这样一来,在自建的构建体系下,我们不仅可以使用标准的 npm 模块管理方式,同时可以发挥 Webpack 对于研发效率的加持,比如 Tree- Shaking 减小打包文件体积、结合 Babel 使用最新的 ECMAScript 特性、结合 Lint 工具统一代码规范等。这是接下来我们要讨论使用 Webpack 完成的几项具体工作的基础

六、数据监控

数据建模:性能、用户和异常

1. 性能数据

优化性能的目标主要有两个:

  • 减少用户打开小程序(或某个页面)后的等待时间,这部分的性能称为启动性能;
  • 提高用户操作小程序的流畅度,这部分的性能称为运行时性能。

2. 用户数据

用户的数据可以分为两种类型:一是静态数据,包括用户的年龄、性别、地域等信息,这些数据叫“用户画像”;二是动态数据,或者称为用户行为数据,这是一个比较宽泛的概念,可以细分为很多子项,比如:

  • 用户在使用小程序期间的一些交互操作数据,比如点击某个按钮,从页面A切换到页面B;
  • 用户的行为踪迹,比如先点击页面A的某个按钮然后点击另一个按钮最后切换到页面B;
  • 用户在某个页面的停留时长;
  • 用户的留存率;

3. 异常数据

异常数据有三种类型:

  • 端侧的代码异常,比如小程序 JavaScript 脚本的某段逻辑执行报错;
  • 服务异常,不过这类异常情况不仅仅是小程序服务端的问题,也可能是用户设备所在网络环境造成的 HTTP 请求失败;
  • 行为异常,最常见的一种就是爬虫脚本频繁地请求某个服务接口。

性能数据、用户数据和异常数据三者相对独立,而我们统计数据的目的并不是收集这些独立的数据,而是希望将它们综合在一起进行分析,这样才能从多维度、多方面获取数据隐藏的信息。也就是将所有数据通过一定的联系归属到在更上一层的领域内分析

在小程序场景下,把这三种类型数据联系到一起的上层领域就是小程序的每个页面- Page。页面再上一层的领域就是小程序的运行环境(包括用户设备信息和小程序的版本信息)。由此我们可以总结出小程序的数据统计所使用的的数据模型,如下图所示

确定了数据模型,接下来就是制定针对每种数据的采集方案。

采集方案:自动化工具和 API 劫持

1. 性能数据采集

性能数据的采集通常会放在小程序发布前的研发或测试阶段,将其作为自动化测试的一部分。当然这并不是说采集小程序线上的性能数据没价值,而是必要性不足,因为影响线上性能数据的外界因素太多了,用户的网络情况、设备状态等都有可能造成某一时刻(甚至某一时间段之内)的性能数据波动,这种情况下统计的数据大多是没有实际价值的。而在研发或测试阶段往往是在固定的外界环境中进行性能数据的采集,多次抽样取期望值,然后与历史数据进行对比和评估

具体到性能数据的采集方法上,主流的有两种:

  • 截图+图片比对。
    • 在对小程序进行仿真操作的过程中按照一定的频率进行截图,然后使用工具进行图片比对,从而获取到一些性能数据,比如小程序启动耗时、首屏渲染耗时等等。通过这种方法获取到的性能数据有一个特点,数据的精细度与截图的频率和图片比对工具的准确性成正比,实施的成本相对比较高。
  • 使用官方提供的性能 Trace 工具 (opens new window)导出数据。
    • 直接获取到各项性能指标的数值,包括启动耗时、下载耗时、渲染耗时……比第一种方法实施的成本低很多,而且数据精准度更高。但目前只能在 Android 手机上拿到 Trace 工具的数据,iPhone 暂时不支持。

2. 异常数据和用户数据采集

  • 行为异常比如爬虫,在端侧是无法知悉的,防爬防刷是服务器安全保障的一部分,所以行为异常的监控一般都是由服务端承担,你可以把这项工作交给服务端的同事。
  • 服务异常的数据来源有两种,一种是用户网络原因导致的请求失败或超时,一种是服务器本身出了问题。第二种与行为异常同样是属于服务端的职责,而在小程序端侧只能够介入第一种异常数据的采集,在采集方案上与代码异常是一致的。

异常数据的采集也可以称为异常监控,采集到异常本身并不是主要目标,更重要的是能够采集到引起异常的用户行为路径。 比如对于电商小程序典型的购买商品的链路:用户点击了商品详情页的“购买”按钮,首先跳转到“购物车”页面,然后继续点击“下单”跳转到订单页面,最后点击“支付”调起微信支付。这个过程用户一共需要四个步骤:

假如在这条链路中的“购物车”页面出现了异常,我们要采集的并不仅仅是当前页面脚本抛出的异常本身,而是要同时获取到引起异常的前序路径,即“商品页”信息。

用户行为数据的采集同样如此。 我们要获取的并不仅仅是用户点击了哪个按钮,还需要采集到这个按钮所在的页面,如果此页面是由其他页面跳转而来还需要采集前序页面的路径信息

还是以刚才的商品购买链路为例,点击商品页的“购买”按钮会触发跳转购物车,如下:

    Page({
      gotoCart(){
        wx.navigateTo({
          url: 'pages/cart?id=xxx'
        });
      }
    });

然后在购物车页面中获取 URL 中携带的商品 ID:

    Page({
      onLoad(query){
        const { id } = query;
      }
    });

如果使用最原始的代码埋点,需要在两个页面的函数中手动填写埋点代码,如下:

    // 商品页
    Page({
      gotoCart(){
        reportClientLog({
          // ...上报商品页数据
        });
        wx.navigateTo({
          url: 'pages/cart?id=xxx'
        });
      }
    });
    // 购物车页面
    Page({
      onLoad(query){
        reportClientLog({
          // ...上报购物车页面数据
        });
        const { id } = query;
      }
    });

这种方式既费时、费力又难以维护,因为如果在后续迭代中不需要统计某个函数的行为,就要找到这个函数的埋点代码手动删除。所以我们要来解决这样的问题,这里需要用到 ES 6 的一些新特性:Proxy 和 Reflect 。目前小程序运行时还不支持这些特性,你可以借助 Babel 将其转化为 ES 5 语法

用 Proxy 和 Reflect 实现埋点的思路非常简单:代理(也可以称为劫持)小程序的 API ,在调用 API 的同时采集数据。以上述案例中用到的小程序 Page 对象为例,使用 Proxy 和 Reflect 实现 API 代理

    Page = new Proxy(Page, {
      get(target,key,context){
        const originHandler = Reflect.get(target,key,context);
        // 只代理函数
        if(typeof originHandler === 'function'){
          return function(...args){
            reportClientLog({
              // ...上报数据
            });
            originHandler.call(context,...args);
          }.bind(context);
        }
        return originHanlder;
      }
    });

将以上代码封装为一个独立的 JavaScript 文件,假设名称为 report.js ,然后在小程序中引入:

    require('./report.js');
    Page({
      // ...
    })

经过以上改造,每当调用 Page 的 API 时都会上报数据。但是当调用 Page 的任何一个 API 都会上报数据,而大多数情况下只需要统计有限的几个 API ,所以要为 report.js 引入一种白名单机制:只有在名单之内的 API 上报数据。改造的方式也很简单

    export default function report(obj,apilist){
     return new Proxy(obj, {
      get(target,key,context){
        const originHandler = Reflect.get(target,key,context);
        // 只代理列表内的函数
        if(typeof originHandler === 'function' && apiList.includes(key)){
          return function(...args){
            reportClientLog({
              // ...上报数据
            });
            originHandler.call(context,...args);
          }.bind(context);
        }
        return originHanlder;
      }
    }); 
    }

你应该也注意到了,上面这段代码不仅加入了白名单机制,而且还把被代理的对象改成了动态的参数,这样便可以适用于任何对象,比如小程序的 App 和 Page 对象:

    const report = require('./report.js');
    // app.js
    App = report(App, [
      'onShow',
      'onLoad',
      'onLaunch'
    ]);
    App({
      // ...
    });
    // page.js
    Page = report(Page, [
      'onShow',
      'onHide',
      'onLoad',
      'gotoCart'
    ]);
    Page({
      // ...
    });

到目前为止,我们完成了数据采集的实施方案,当然我们肯定会根据现实业务的需求做出调整和改造,比如制定上报数据的格式规范、上报时机、处理离线数据等细节(这些内容与业务有强关联性)

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 文件的实时性,也保证了网站所有静态资源的实时性
  • 跟网站不同的是,小程序的“所有”端侧资源都是静态的
  • 小程序的资源是托管在微信服务器上的,跟网站不同,微信不会在用户每次打开小程序时,从服务器拉取最新的小程序资源,而是尽可能地发挥缓存的优势

当用户打开小程序时,微信客户端会先从缓存中拉取小程序的端侧资源,有的话就展示给用户,没有的话会从微信服务器拉取,这时,拉取的肯定是最新版本,然后放入缓存并展示给用户。

以上就是小程序的端侧资源的管理机制。从这套流程里你会发现一个问题:既然优先使用缓存中的资源,那么当我发布了小程序新版本之后,怎么保证用户尽可能快地更新为新版本呢?这就是我们要讨论的重点:小程序的端侧资源更新机制。

本地没有缓存会触发是最简单的一种时机,除此之外还有两种时机。

  • 未启动时: 指的是小程序处于非活跃状态时(比如处于后台),但是请注意,这种状态是用户已经用过小程序后才会产生的,如果用户从来都没有用过你的小程序,就不存在状态的概念了,因为对于这个用户来说,你的小程序是无状态的。
  • 冷启动时: 小程序被销毁重新打开后会进入冷启动状态

当你在小程序管理后台发布新版本的小程序之后,微信会根据用户设备上小程序的状态实施不同的更新策略

如果小程序处于未启动状态, 微信客户端会在“若干个时机”去检查缓存中的小程序有没有新版本,如果有会默默把新版本资源拉取到本地缓存中

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

从上述内容中,你可以得出一个结论:当你发布一个新版本后,用户并不能“立即”获得更新。

小程序未启动时最慢 24 小时可以覆盖全部用户,或者需要经历两次冷启动,这对一些紧急的版本更新来说太慢了,所以在现实工作中往往要将小程序的更新提速,让用户尽可能快地获取到新版本。具体实施方法是通过小程序的 UpdateManager 对象,在代码里主动检查并应用更新信息。我们对照流程图和代码讲解,来看下面这张图:

    const axios = require('axios')
    const updateManager = wx.getUpdateManager()
    updateManager.onCheckForUpdate(function (res) {
      // 将是否有新版本信息挂载到全局对象上
      this.globalData.hasUpdate = res.hasUpdate
    })
    updateManager.onUpdateReady(function () {
      if(!this.globalData.hasUpdate){
        return
      }
      const { miniProgram } = wx.getAccountInfoSync()
      // 获取当前小程序的版本号
      const currVersion = miniProgram.version
      // 从你的开发者服务器接口中获取是否有紧急版本需要更新
      axios.get(`${<your-url?}?currVersion=${currVersion}`).then(res=>{
        if(res.needUpdate){
          // 紧急版本立即重启小程序应用更新
          updateManager.applyUpdate()
        }
      })
    })
  • 首先在代码中创建一个 UpdateManager 对象,然后添加 onCheckForUpdateonUpdateReady 监听,当微信客户端从微信服务器中获取到小程序的更新信息后会触发 onCheckForUpdate 函数,入参携带 hasUpdate 属性标记是否有新版本未更新。我们将这个信息挂载到全局对象上以便后续使用。
  • 当微信客户端从微信服务器中将最新版本的小程序端侧资源拉取到本地之后,会触发 onUpdateReady函数,此时需要你的开发者服务器提供一个接口,对应上述代码中的 your-url。这个接口的入参是用户当前使用的小程序版本,然后根据这个版本号判断当前用户的小程序版本是否存在严重 Bug 需要更新到最新版本。你需要在小程序的脚本代码中,当 onUpdateReady 函数被触发时调用这个接口,如果需要更新则通过调用 updateManager.applyUpdate() 强制重启小程序应用更新。

上述这套更新机制相比较需要两次冷启动的默认更新机制来说,能够减少一次冷启动的时间,能更快速地令用户获取最新版本的小程序,对于一些修复紧急 Bug 的版本是一种行之有效的方案。当然,我们只展示了端侧的调用流程,在后端发布小程序时,你需要记录每次发布版本的详细信息,包括是否有紧急 Bug 修复,这样才能够为端侧的调用提供数据来源。

后端服务灰度发布策略

后端服务的发布流程中有一个非常重要且通用的策略:灰度发布。所谓的灰度发布简单理解就是将新版本的服务只向一定比例的用户开放,而另一部分用户仍然使用旧版本的服务,然后观察新版本的状态,如果一切正常则慢慢扩大新版本的用户比例,直到全部用户都切入新版本,便完成了灰度发布的全流程。

灰度发布需要提前制定用户请求的转发策略,一般有两种:

  • 按照新旧服务所占用的服务器比例随机转发;
  • 按照用户的 ID 转发。
  • 第一种简单粗暴,比如你有 10 台服务器,其中 2 台部署了新版本的服务,负载均衡器会在接收到用户请求时按照 20% 的概率随机转发到新版本服务器上,剩余的转发到旧版本服务器。
  • 第二种需要进行一定的编码工作,比如 Nginx 配置 Lua 脚本,当接收到用户请求时,从请求中获取到用户的 ID ,在小程序场景下就是用户的 OpenId ,然后匹配转发策略中是否这个 ID 在新版本服务的白名单中,如果是的话便转发到新版本服务,否则转发到旧版本服务。如下图所示:

八、云开发:云原生一体化应用开发平台

云开发其实是一种后端服务,和服务器所扮演的角色类似,都是服务端角色。不过云开发把服务所需要的一些资源(比如计算、存储、消息推送等)封装打包,以方便开发者使用。整体上讲,云开发包括了云函数、云数据库、云存储、云托管等一些基础服务资源,以及云上的各种扩展能力(比如图像处理、客服服务等)。

  • 在调用方式上, 云开发的使用方法和前端开发差不多,它将触手可及的各种资源以接口 SDK 的形式给到开发者。举个例子,如果开发微信小程序,需要存储用户的个人数据以方便应用业务,你可以用云开发的接口把数据存入数据库,这个接口并不是 URL 地址,而是一个函数方法(function),举例如下:

如果你想对这些数据进行一些复杂的处理(比如对数据做分析,生成报表)涉及其他的数据,可以把处理的逻辑放到云开发云函数中进行,而云函数也可以在小程序中用函数方法(function)的形式调用,举例如下:

再深一步,如果你的微信小程序想存储一些文件,也可以直接使用云开发接口,调用上传文件,文件可以同时被小程序端和云函数端获取到,方便应用功能的开发,举例如下:

以上在开发小程序时所用到的数据库、云函数、云存储都是云开发提供的资源:

  • 云函数是独立的计算资源,通过触发执行逻辑运算或者资源处理,最终返回结果;
  • 数据库是遵循 Mongo 协议的非关系型数据库,可以直接通过各种 API 进行调用处理;
  • 云存储是云开发提供的专门的存储空间,有基础 API 进行文件管理。

而这些基础服务资源(数据库、云函数、云存储)都被整合到一套接口调用标准中,根据这套标准以及适用端场景,会产生各种 SDK,分别专注于客户端、云函数端、管理端等进行资源统筹和处理。

阅读全文

Last Updated:
Contributors: guoli