https://mp.weixin.qq.com/ 微信公众平台 https://developers.weixin.qq.com/doc/ https://developers.weixin.qq.com/miniprogram/dev/framework/quickstart/

https://developers.weixin.qq.com/miniprogram/dev/framework/ https://developers.weixin.qq.com/ebook?action=get_post_info&docid=000ee2c29d4f805b0086a37a254c0a 《小程序开发指南》

1. 第一个主要配置文件 app.json

{
  "pages": [
    "pages/index/index",
    "pages/logs/logs",
    "pages/home/home",
    "pages/opportunity/opportunity"
  ],
  "window": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "OA", // 小程序最上方的标题
    "navigationBarBackgroundColor": "#ffffff"
  },
  "tabBar": {
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页"
      },
      {
        "pagePath": "pages/opportunity/opportunity",
        "text": "订单"
      },
      {
        "pagePath": "pages/home/home",
        "text": "我的"
      }
    ]
  },
  "style": "v2",
  "componentFramework": "glass-easel",
  "lazyCodeLoading": "requiredComponents"
}

2. 小程序介绍与开发环境

3. 小程序代码组成

3.1. WXML 模板

WXML 全称是 WeiXin Markup Language,是小程序框架设计的一套标签语言,结合小程序的基础组件、事件系统,可以构建出页面的结构。

打开开发工具的编辑器,在根目录下找到 app.json 文件,双击打开,在 “pages/index/index” 最下面一行 “pages/check/index” 保存文件。模拟器刷新后,读者可以在编辑器中找到 pages/check/index.wxml 文件。

WXML 文件后缀名是 .wxml ,打开 pages/check/index.wxml 文件,有过 HTML 的开发经验的读者应该会很熟悉这种代码的书写方式,简单的 WXML语句在语法上同 HTML 非常相似。

  • 标签必须是严格闭合。
  • 标签可以拥有属性,属性提供了有关的 WXML元素更多信息。属性总是定义在开始标签中,除了一些特殊的属性外,其余属性的格式都是key=“value” 的方式成对出现。需要注意的是,WXML中的属性是大小写敏感的。

3.1.1. 数据绑定

用户界面呈现会因为当前时刻数据不同而有所不同,或者是因为用户的操作发生动态改变,这就要求程序的运行过程中,要有动态的去改变渲染界面的能力。在 Web 开发中,开发者使用 JavaScript 通过Dom 接口来完成界面的实时更新。在小程序中,使用 WXML 语言所提供的数据绑定功能,来完成此项功能。

<!--pages/check/index.wxml-->
<text>当前时间:{{time}}</text>

WXML 通过 {{变量名}} 来绑定 WXML 文件和对应的 JavaScript 文件中的 data 对象属性。

// pages/check/index.ts
Page({
  /**
   * 页面的初始数据
   */
  data: {
    time: (new Date()).toString()
  },
})

属性值也可以动态的去改变,有所不同的是,属性值必须被包裹在双引号中,注意大小写。

<text data-test="{{test}}"> hello world</text>

3.1.2. 逻辑语法

通过 {{ 变量名 }} 语法可以使得 WXML 拥有动态渲染的能力,除此外还可以在 {{ }} 内进行简单的逻辑运算。

<!-- 根据 a 的值是否等于 10 在页面输出不同的内容 -->
<text>{{ a === 10? "变量 a 等于10": "变量 a 不等于10"}}</text>
 
<!--
{ a: 1,  b: 2, c: 3 }
-->
<view> {{a + b}} + {{c}} + d </view>
 
<!--
{ name: 'world' }
-->
<view>{{"hello " + name}}</view>
 
<!-- 常量 -->
<text>{{[1,2,3]}}</text>
<text>{{"hello world"}}</text>

3.1.3. 条件逻辑

WXML 中,使用 wx:if=“{{condition}}” 来判断是否需要渲染该代码块:

<view wx:if="{{condition}}"> True </view>

使用 wx:elif 和 wx:else 来添加一个 else 块:

<view wx:if="{{length > 5}}"> 1 </view>
<view wx:elif="{{length > 2}}"> 2 </view>
<view wx:else> 3 </view>

因为 wx:if 是一个控制属性,需要将它添加到一个标签上。如果要一次性判断多个组件标签,可以使用一个 <block/> 标签将多个组件包装起来,并在上边使用 wx:if 控制属性。

<block wx:if="{{true}}">
  <view> view1 </view>
  <view> view2 </view>
</block>

3.1.4. 列表渲染

在组件上使用 wx:for 控制属性绑定一个数组,即可使用数组中各项的数据重复渲染该组件。默认数组的当前项的下标变量名默认为 index,数组当前项的变量名默认为 item

代码清单2-13 列表渲染示例

<!-- array 是一个数组 -->
<view wx:for="{{array}}">
  {{index}}: {{item.message}}
</view>
 
<!-- 对应的脚本文件
Page({
  data: {
    array: [{
      message: 'foo',
    }, {
      message: 'bar'
    }]
  }
})
-->

使用 wx:for-item 指定数组当前元素的变量名,使用 wx:for-index 指定数组当前下标的变量名:

<view wx:for="{{array}}" wx:for-index="idx" wx:for-item="itemName">
  {{idx}}: {{itemName.message}}
</view>

类似 block wx:if ,也可以将 wx:for 用在 <block/> 标签上,以渲染一个包含多节点的结构块。例如:

<block wx:for="{{[1, 2, 3]}}">
  <view> {{index}}: </view>
  <view> {{item}} </view>
</block>

如果列表中项目的位置会动态改变或者有新的项目添加到列表中,并且希望列表中的项目保持自己的特征和状态(如 <input/> 中的输入内容, <switch/> 的选中状态),需要使用 wx:key 来指定列表中项目的唯一的标识符。

wx:key 的值以两种形式提供:

  1. 字符串,代表在 for 循环的 array 中 item 的某个 property,该 property 的值需要是列表中唯一的字符串或数字,且不能动态改变。

  2. 保留关键字 this 代表在 for 循环中的 item 本身,这种表示需要 item 本身是一个唯一的字符串或者数字,如:

当数据改变触发渲染层重新渲染的时候,会校正带有 key 的组件,框架会确保他们被重新排序,而不是重新创建,以确保使组件保持自身的状态,并且提高列表渲染时的效率。

代码清单2-14 使用 wx:key 示例(WXML)

<switch wx:for="{{objectArray}}" wx:key="unique" > {{item.id}} </switch>
<button bindtap="switch"> Switch </button>
<button bindtap="addToFront"> Add to the front </button>
 
 
<switch wx:for="{{numberArray}}" wx:key="*this" > {{item}} </switch>
<button bindtap="addNumberToFront"> Add Number to the front </button>

代码清单2-15 使用 wx:key 示例(JavaScript)

Page({
  data: {
    objectArray: [
      {id: 5, unique: 'unique_5'},
      {id: 4, unique: 'unique_4'},
      {id: 3, unique: 'unique_3'},
      {id: 2, unique: 'unique_2'},
      {id: 1, unique: 'unique_1'},
      {id: 0, unique: 'unique_0'},
    ],
    numberArray: [1, 2, 3, 4]
  },
  switch: function(e) {
    const length = this.data.objectArray.length
    for (let i = 0; i < length; ++i) {
      const x = Math.floor(Math.random() * length)
      const y = Math.floor(Math.random() * length)
      const temp = this.data.objectArray[x]
      this.data.objectArray[x] = this.data.objectArray[y]
      this.data.objectArray[y] = temp
    }
    this.setData({
      objectArray: this.data.objectArray
    })
  },
  addToFront: function(e) {
    const length = this.data.objectArray.length
    this.data.objectArray = [{id: length, unique: 'unique_' + length}].concat(this.data.objectArray)
    this.setData({
      objectArray: this.data.objectArray
    })
  },
  addNumberToFront: function(e){
    this.data.numberArray = [ this.data.numberArray.length + 1 ].concat(this.data.numberArray)
    this.setData({
      numberArray: this.data.numberArray
    })
  }
})

3.1.5. 模板

WXML提供模板(template),可以在模板中定义代码片段,然后在不同的地方调用。使用 name 属性,作为模板的名字。然后在 <template/> 内定义代码片段,如:

代码清单2-16 定义模板

<template name="msgItem">
  <view>
    <text> {{index}}: {{msg}} </text>
    <text> Time: {{time}} </text>
  </view>
</template>

使用 is 属性,声明需要的使用的模板,然后将模板所需要的 data 传入,如代码2-17所示。

代码清单2-17 模板使用示例

<!--
item: {
  index: 0,
  msg: 'this is a template',
  time: '2016-06-18'
}
-->
 
 
<template name="msgItem">
  <view>
    <text> {{index}}: {{msg}} </text>
    <text> Time: {{time}} </text>
  </view>
</template>
 
 
<template is="msgItem" data="{{...item}}"/>
 
<!-- 输出
0: this is a template Time: 2016-06-18
-->

is可以动态决定具体需要渲染哪个模板,如代码2-18所示。

代码清单2-18 动态使用模板

<template name="odd">
  <view> odd </view>
</template>
 
<template name="even">
  <view> even </view>
</template>
 
<block wx:for="{{[1, 2, 3, 4, 5]}}">
  <template is="{{item % 2 == 0 ? 'even' : 'odd'}}"/>
</block>
 
<!-- 输出
odd
even
odd
even
odd
-->

3.1.6. 引用

WXML 提供两种文件引用方式import和include。 import 可以在该文件中使用目标文件定义的 template,如: 在 item.wxml 中定义了一个叫 item的 template :

<!-- item.wxml -->
<template name="item">
  <text>{{text}}</text>
</template>

index.wxml中引用了 item.wxml,就可以使用 item模板:

<import src="item.wxml"/>
 
<template is="item" data="{{text: 'forbar'}}"/>

需要注意的是 import 有作用域的概念,即只会 import 目标文件中定义的 template,而不会 import 目标文件中 import 的 template,简言之就是 import 不具有递归的特性。

例如:C 引用 B,B 引用A,在C中可以使用B定义的 template,在B中可以使用A定义的 template ,但是C不能使用A定义的template ,如代码2-19、代码2-20、代码2-21所示。

代码清单2-19 模板 A

<!-- A.wxml -->
<template name="A">
  <text> A template </text>
</template>

代码清单2-20 模板 B

<!-- B.wxml -->
<import src="a.wxml"/>
 
<template name="B">
  <text> B template </text>
</template>

代码清单2-21 模板 C

<!-- C.wxml -->
<import src="b.wxml"/>
 
<template is="A"/>  <!-- 这里将会触发一个警告,因为 b 中并没有定义模板 A -->
 
<template is="B"/>

include 可以将目标文件中除了 <template/> <wxs/> 外的整个代码引入,相当于是拷贝到 include 位置,如代码2-22、代码2-23、代码2-24所示。

代码清单2-22 index.wxml

<!-- index.wxml -->
<include src="header.wxml"/>
 
<view> body </view>
 
<include src="footer.wxml"/>

代码清单2-23 header.wxml

<!-- header.wxml -->
<view> header </view>

代码清单2-24 footer.wxml

<!-- footer.wxml -->
<view> footer </view>

3.1.7. 共同属性

所有wxml 标签都支持的属性称之为共同属性,如表2-1所示。 表2-1 共同属性

属性名类型描述注解
idString组件的唯一标识整个页面唯一
classString组件的样式类在对应的 WXSS 中定义的样式类
styleString组件的内联样式可以动态设置的内联样式
hiddenBoolean组件是否显示所有组件默认显示
data-*Any自定义属性组件上触发的事件时,会发送给事件处理函数
bind/catchEventHandler组件的事件

3.2. WXSS 样式

WXSS(WeiXin Style Sheets)是一套用于小程序的样式语言,用于描述WXML的组件样式,也就是视觉上的效果。

WXSS与Web开发中的CSS类似。为了更适合小程序开发,WXSS对CSS做了一些补充以及修改。

3.2.1. 文件组成

图2-7 文件组成

图2-8 app.json

项目公共样式:根目录中的app.wxss为项目公共样式,它会被注入到小程序的每个页面。页面样式:与app.json注册过的页面同名且位置同级的WXSS文件。比如图2-8 app.json注册了pages/rpx/index页面,那pages/rpx/index.wxss为页面pages/rpx/index.wxml的样式。

其它样式:其它样式可以被项目公共样式和页面样式引用,引用方法查看本章中的2.3.3小节。

在小程序开发中,开发者不需要像Web开发那样去优化样式文件的请求数量,只需要考虑代码的组织即可。样式文件最终会被编译优化,具体的编译原理我们留在后面章节再做介绍。

3.2.2. 尺寸单位

在WXSS中,引入了rpx(responsive pixel)尺寸单位。引用新尺寸单位的目的是,适配不同宽度的屏幕,开发起来更简单。

如图2-9所示,同一个元素,在不同宽度的屏幕下,如果使用px为尺寸单位,有可能造成页面留白过多。

图2-9 使用px尺寸单位,iPhone5与iPad视觉对比

修改为rpx尺寸单位,效果如图2-10所示。

图2-10 使用rpx尺寸单位, iPhone5与iPad视觉对比

小程序编译后,rpx会做一次px换算。换算是以375个物理像素为基准,也就是在一个宽度为375物理像素的屏幕下,1rpx = 1px。

举个例子:iPhone6屏幕宽度为375px,共750个物理像素,那么1rpx = 375 / 750 px = 0.5px。

图2-11 常用机型rpx尺寸换算表

3.2.3. WXSS引用

在CSS中,开发者可以这样引用另一个样式文件:@import url('./test_0.css')

这种方法在请求上不会把test_0.css合并到index.css中,也就是请求index.css的时候,会多一个test_0.css的请求。

图2-12 css import网络请求

在小程序中,我们依然可以实现样式的引用,样式引用是这样写:

@import './test_0.wxss'

由于WXSS最终会被编译打包到目标文件中,用户只需要下载一次,在使用过程中不会因为样式的引用而产生多余的文件请求。

3.2.4. 内联样式

WXSS内联样式与Web开发一致:

<!--index.wxml-->
 
<!--内联样式-->
<view style="color: red; font-size: 48rpx"></view>

小程序支持动态更新内联样式:

<!--index.wxml-->
 
<!--可动态变化的内联样式-->
<!--
{
  eleColor: 'red',
  eleFontsize: '48rpx'
}
-->
<view style="color: {{eleColor}}; font-size: {{eleFontsize}}"></view>

3.2.5. 选择器

目前支持的选择器如表2-2所示。

表2-2 小程序WXSS支持的选择器

类型选择器样例样例描述
类选择器.class.intro选择所有拥有 class=“intro” 的组件
id选择器#id#firstname选择拥有 id=“firstname” 的组件
元素选择器elementview checkbox选择所有文档的 view 组件和所有的 checkbox 组件
伪元素选择器::afterview::after在 view 组件后边插入内容
伪元素选择器::beforeview::before在 view 组件前边插入内容

WXSS优先级与CSS类似,权重如图2-13所示。

图2-13 WXSS选择器优先级

权重越高越优先。在优先级相同的情况下,后设置的样式优先级高于先设置的样式。

代码清单2-25 WXSS 选择器优先级权重

view{ // 权重为 1
  color: blue
}
 
.ele{ // 权重为 10
  color: red
}
 
#ele{ // 权重为 100
  color: pink
}
 
view#ele{ // 权重为 1 + 100 = 101,优先级最高,元素颜色为orange
  color: orange
}
 
view.ele{ // 权重为 1 + 10 = 11
  color: green
}

3.2.6. 官方样式库

为了减轻开发者样式开发的工作量,我们提供了WeUI.wxss基础样式库。

WeUI是一套与微信原生视觉体验一致的基础样式库,由微信官方设计团队为微信内网页和微信小程序量身设计,令用户的使用感知更加统一。包含button、cell、dialog、progress、toast、article、actionsheet、icon等各式原生。

图2-14 WeUI.wxss示例列表

3.3. JavaScript 脚本

小程序的主要开发语言是 JavaScript ,开发者使用 JavaScript 来开发业务逻辑以及调用小程序的 API 来完成业务需求。

3.3.1. ECMAScript

在大部分开发者看来,ECMAScript和JavaScript表达的是同一种含义,但是严格的说,两者的意义是不同的。ECMAScript是一种由Ecma国际通过ECMA-262标准化的脚本程序设计语言, JavaScript 是 ECMAScript 的一种实现。理解 JavaScript 是 ECMAScript 一种实现后,可以帮助开发者理解小程序中的 JavaScript同浏览器中的 JavaScript 以及 NodeJS 中的 JavaScript 是不相同的。

ECMA-262 规定了 ECMAScript 语言的几个重要组成部分:

  1. 语法
  2. 类型
  3. 语句
  4. 关键字
  5. 操作符
  6. 对象

浏览器中JavaScript 构成如下图:

图2-15 浏览器中的 JavaScript

浏览器中的JavaScript 是由 ECMAScript 和 BOM(浏览器对象模型)以及 DOM(文档对象模型)组成的,Web前端开发者会很熟悉这两个对象模型,它使得开发者可以去操作浏览器的一些表现,比如修改URL、修改页面呈现、记录数据等等。

NodeJS中JavaScript 构成如下图:

图2-16 NodeJS中的 JavaScript

NodeJS中的JavaScript 是由 ECMAScript 和 NPM以及Native模块组成,NodeJS的开发者会非常熟悉 NPM 的包管理系统,通过各种拓展包来快速的实现一些功能,同时通过使用一些原生的模块例如 FS、HTTP、OS等等来拥有一些语言本身所不具有的能力。

那么,同开发者所熟悉的这两个环境是不同的,小程序中 JavaScript 构成如图2-17所示。

图2-17 小程序中的 JavaScript

小程序中的 JavaScript 是由ECMAScript 以及小程序框架和小程序 API 来实现的。同浏览器中的JavaScript 相比没有 BOM 以及 DOM 对象,所以类似 JQuery、Zepto这种浏览器类库是无法在小程序中运行起来的,同样的缺少 Native 模块和NPM包管理的机制,小程序中无法加载原生库,也无法直接使用大部分的 NPM 包。

3.3.2. 小程序的执行环境

明白了小程序中的 JavaScript 同浏览器以及NodeJS有所不同后,开发者还需要注意到另外一个问题,不同的平台的小程序的脚本执行环境也是有所区别的。

小程序目前可以运行在三大平台:

  1. iOS平台,包括iOS9、iOS10、iOS11
  2. Android平台
  3. 小程序IDE

这种区别主要是体现三大平台实现的 ECMAScript 的标准有所不同。截止到当前一共有七个版本的ECMAScript 标准,目前开发者大部分使用的是 ECMAScript 5 和 ECMAScript 6 的标准,但是在小程序中, iOS9和iOS10 所使用的运行环境并没有完全的兼容到 ECMAScript 6 标准,一些 ECMAScript 6 中规定的语法和关键字是没有的或者同标准是有所不同的,例如:

  1. 箭头函数
  2. let const
  3. 模板字符串

所以一些开发者会发现有些代码在旧的手机操作系统上出现一些语法错误。为了帮助开发者解决这类问题,小程序IDE提供语法转码工具帮助开发者,将 ECMAScript 6代码转为 ECMAScript 5代码,从而在所有的环境都能得到很好的执行。

开发者需要在项目设置中,勾选 ES6 转 ES5 开启此功能。

图2-18 勾选 ES6 转 ES5

3.3.3. 模块化

浏览器中,所有 JavaScript 是在运行在同一个作用域下的,定义的参数或者方法可以被后续加载的脚本访问或者改写。同浏览器不同,小程序中可以将任何一个JavaScript 文件作为一个模块,通过module.exports 或者 exports 对外暴露接口。

请看是一个简单模块示例,B.js 引用模块A,并使用A暴露的multiplyBy2方法完成一个变量乘以 2 的操作。

代码清单2-26 模块示例

// moduleA.js
module.exports = function( value ){
  return value * 2;
}

代码清单2-27 引用模块A

// B.js

// 在B.js中引用模块A
var multiplyBy2 = require('./moduleA')
var result = multiplyBy2(4)

代码清单2-28 在需要使用这些模块的文件中,使用 require(path) 将公共代码引入

var common = require('common.js')
Page({
  helloMINA: function() {
    common.sayHello('MINA')
  },
  goodbyeMINA: function() {
    common.sayGoodbye('MINA')
  }
})

3.3.4. 脚本的执行顺序

浏览器中,脚本严格按照加载的顺序执行,如代码2-29所示。

代码清单2-29 浏览器中的脚本

<html>
<head>
  <!-- a.js
  console.log('a.js')
   -->
  <script src ="a.js"></script>
  <script>
    console.log('inline script')
  </script>

  <!-- b.js
  console.log('b.js')
   -->
  <script src ="b.js"></script>
</head>
</html>

以上代码的输出是:

a.js

inline script

b.js

而在小程序中的脚本执行顺序有所不同。小程序的执行的入口文件是 app.js 。并且会根据其中 require 的模块顺序决定文件的运行顺序,代码2-30是一个 app.js 示例。

代码清单2-30 app.js

/* a.js
console.log('a.js')
*/
var a = require('./a.js')
console.log('app.js')

/* b.js
console.log('b.js')
*/
var b = require('./b.js')

以上代码的输出顺序是:

a.js

app.js

b.js

当 app.js 执行结束后,小程序会按照开发者在 app.json 中定义的 pages 的顺序,逐一执行。如代码2-31所示。

代码清单2-31 app.json 文件

{
  "pages": [
    "pages/index/index",
    "pages/log/log",
    "pages/result/result"
  ],
  "window": {}
}

代码清单2-32 app.js文件

// app.js
console.log('app.js')

代码清单2-33 pages/index/index.js 文件

// pages/index/index
console.log('pages/index/index')

代码清单2-34 page/log/log.js 文件

// pages/log/log
console.log('pages/log/log')

代码清单2-35 page/result/result.js 文件

// pages/result/result
console.log('pages/result/result')

以上文件执行后输出的结果如下:

app.js

pages/index/index

pages/log/log

pages/result/result

3.3.5. 作用域

同浏览器中运行的脚本文件有所不同,小程序的脚本的作用域同 NodeJS 更为相似。

在文件中声明的变量和函数只在该文件中有效,不同的文件中可以声明相同名字的变量和函数,不会互相影响,如代码2-36、代码2-37所示。

代码清单2-36 在脚本 a.js 中定义局部变量

// a.js
// 定义局部变量
var localValue = 'a'

代码清单2-37 在脚本 b.js 中无法访问 a.js 定义的变量

// b.js
// 定义局部变量
console.log(localValue) // 触发一个错误 b.js中无法访问 a.js 中定义的变量

当需要使用全局变量的时,通过使用全局函数 getApp() 获取全局的实例,并设置相关属性值,来达到设置全局变量的目的,如代码2-38、代码2-39所示。

代码清单2-38 在脚本 a.js 中设置全局变量

// a.js
// 获取全局变量
var global = getApp()
global.globalValue = 'globalValue'

代码清单2-39 在脚本 b.js 中访问 a.js 定义的全局变量

// b.js
// 访问全局变量
var global = getApp()
console.log(global.globalValue) // 输出 globalValue

需要注意的是,上述示例只有在 a.js 比 b.js 先执行才有效,当需要保证全局的数据可以在任何文件中安全的被使用到,那么可以在 App() 中进行设置,如代码2-40、代码2-41、代码2-42所示。

代码清单2-40 定义全局变量

// app.js
App({
  globalData: 1
})

代码清单2-41 获取以及修改 global 变量的方法

// a.js
// 局部变量
var localValue = 'a'

// 获取 global 变量
var app = getApp()

// 修改 global 变量
app.globalData++  // 执行后 globalData 数值为 2

代码清单2-42 获取 global 变量

// b.js
// 定义另外的局部变量,并不会影响 a.js 中文件变量
var localValue = 'b'

// 如果先执行了 a.js 这里的输出应该是 2
console.log(getApp().globalData)

4. 理解小程序宿主环境

那么这些文件在微信客户端是怎么协同工作的呢?在本章中我们会介绍微信客户端给小程序所提供的宿主环境,下文我们把这个概念简称为宿主或者宿主环境。
小程序可以调用宿主环境提供的微信客户端的能力,这就使得小程序比普通网页拥有更多的能力。你也会了解到,你的小程序会运行在不同版本的宿主环境下,因此针对各个版本的宿主环境做程序上的兼容也是在所难免的。

4.1. 渲染层和逻辑层

小程序的运行环境分成渲染层和逻辑层,第2章提到过 WXML 模板和 WXSS 样式工作在渲染层,JS 脚本工作在逻辑层。小程序的渲染层和逻辑层分离是经过很多考虑得出来的模型,在第6章我们会详细阐述这个模型背后的原理以及产生的问题。在本章我们会先介绍这个模型的基本工作方式。

4.1.1. 渲染“Hello World”页面

我们看看小程序是如何把脚本里边的数据渲染在界面上的。
WXML模板使用 view 标签,其子节点用 {{ }} 的语法绑定一个 msg 的变量,如代码清单3-1所示。
代码清单3-1 渲染“Hello World”WXML代码

<view>{{ msg }}</view>

在 JS 脚本使用 this.setData 方法把 msg 字段设置成 “Hello World” ,如代码清单3-2所示。
代码清单3-2 渲染“Hello World”JS脚本

Page({
  onLoad: function () {
    this.setData({ msg: 'Hello World' })
  }
})

从这个例子我们可以看到3个点:
1.渲染层和数据相关。
2.逻辑层负责产生、处理数据。
3.逻辑层通过 Page 实例的 setData 方法传递数据到渲染层。
关于第1点,涉及了“数据驱动”的概念,我们会在3.1.3节详细讨论,我们现在先看看第3点涉及的“通信模型”。

4.1.2. 通信模型

小程序的渲染层和逻辑层分别由2个线程管理:渲染层的界面使用了WebView 进行渲染;逻辑层采用JsCore线程运行JS脚本。一个小程序存在多个界面,所以渲染层存在多个WebView线程,这两个线程的通信会经由微信客户端(下文中也会采用Native来代指微信客户端)做中转,逻辑层发送网络请求也经由Native转发,小程序的通信模型如图3-1所示。

图3-1 渲染层和逻辑层通信模型

4.1.3. 数据驱动

在开发UI界面过程中,程序需要维护很多变量状态,同时要操作对应的UI元素。随着界面越来越复杂,我们需要维护很多变量状态,同时要处理很多界面上的交互事件,整个程序变得越来越复杂。通常界面视图和变量状态是相关联的,如果有某种“方法”可以让状态和视图绑定在一起(状态变更时,视图也能自动变更),那我们就可以省去手动修改视图的工作。
这个方法就是“数据驱动”,下边我们来介绍一下小程序的数据驱动基本原理。
WXML结构实际上等价于一棵Dom树,通过一个JS对象也可以来表达Dom树的结构,如图3-2所示。

图3-2 WXML结构和JS对象均可以表示一棵Dom树

WXML可以先转成JS对象,然后再渲染出真正的Dom树,回到“Hello World”那个例子,我们可以看到转换的过程如图3-3所示。

图3-3 WXML结构转JS对象,再转Dom树

通过setData把msg数据从“Hello World”变成“Goodbye”,产生的JS对象对应的节点就会发生变化,此时可以对比前后两个JS对象得到变化的部分,然后把这个差异应用到原来的Dom树上,从而达到更新UI的目的,这就是“数据驱动”的原理,如图3-4所示。

图3-4 状态更新的时候,通过对比前后JS对象变化,进而改变视图层的Dom树

4.1.4. 双线程下的界面渲染

小程序的逻辑层和渲染层是分开的两个线程。在渲染层,宿主环境会把WXML转化成对应的JS对象,在逻辑层发生数据变更的时候,我们需要通过宿主环境提供的setData方法把数据从逻辑层传递到渲染层,再经过对比前后差异,把差异应用在原来的Dom树上,渲染出正确的UI界面,如图3-5所示。

图3-5 逻辑层传递数据到渲染层

4.2. 程序与页面

从逻辑组成来说,一个小程序是由多个“页面”组成的“程序”。这里要区别一下“小程序”和“程序”的概念,往往我们需要在“程序”启动或者退出的时候存储数据或者在“页面”显示或者隐藏的时候做一些逻辑处理,了解程序和页面的概念以及它们的生命周期是非常重要的。

4.2.1. 程序

“小程序”指的是产品层面的程序,而“程序”指的是代码层面的程序实例,为了避免误解,下文采用App来代替代码层面的“程序”概念。

4.2.1.1. 程序构造器App()

宿主环境提供了 App() 构造器用来注册一个程序App,需要留意的是App() 构造器必须写在项目根目录的app.js里,App实例是单例对象,在其他JS脚本中可以使用宿主环境提供的 getApp() 来获取程序实例。
代码清单3-3 getApp() 获取App实例

// other.js
var appInstance = getApp()

App() 的调用方式如代码清单3-4所示,App构造器接受一个Object参数,参数说明如表3-1所示,其中onLaunch / onShow / onHide 三个回调是App实例的生命周期函数,我们会在后文展开;onError我们暂时不在本章展开,我们会在第8章里详细讨论;App的其他参数我们也放在后文进行展开。
代码清单3-4 App构造器

App({
  onLaunch: function(options) {},
  onShow: function(options) {},
  onHide: function() {},
  onError: function(msg) {},
  globalData: 'I am global data'
})

表3-1 App构造器的参数

参数属性类型描述
onLaunchFunction当小程序初始化完成时,会触发 onLaunch(全局只触发一次)
onShowFunction当小程序启动,或从后台进入前台显示,会触发 onShow
onHideFunction当小程序从前台进入后台,会触发 onHide
onErrorFunction当小程序发生脚本错误,或者 API 调用失败时,会触发 onError 并带上错误信息
其他字段任意可以添加任意的函数或数据到 Object 参数中,在App实例回调用 this 可以访问

4.2.1.2. 程序的生命周期和打开场景

初次进入小程序的时候,微信客户端初始化好宿主环境,同时从网络下载或者从本地缓存中拿到小程序的代码包,把它注入到宿主环境,初始化完毕后,微信客户端就会给App实例派发onLaunch事件,App构造器参数所定义的onLaunch方法会被调用。
进入小程序之后,用户可以点击右上角的关闭,或者按手机设备的Home键离开小程序,此时小程序并没有被直接销毁,我们把这种情况称为“小程序进入后台状态”,App构造器参数所定义的onHide方法会被调用。
当再次回到微信或者再次打开小程序时,微信客户端会把“后台”的小程序唤醒,我们把这种情况称为“小程序进入前台状态”,App构造器参数所定义的onShow方法会被调用。
我们可以看到,App的生命周期是由微信客户端根据用户操作主动触发的。为了避免程序上的混乱,我们不应该从其他代码里主动调用App实例的生命周期函数。
在微信客户端中打开小程序有很多途径:从群聊会话里打开,从小程序列表中打开,通过微信扫一扫二维码打开,从另外一个小程序打开当前小程序等,针对不同途径的打开方式,小程序有时需要做不同的业务处理,所以微信客户端会把打开方式带给onLaunch和onShow的调用参数options,示例代码以及详细参数如代码清单3-5和表3-2所示。需要留意小程序的宿主环境在迭代更新过程会增加不少打开场景,因此要获取最新的场景值说明请查看官方文档:https://mp.weixin.qq.com/debug/wxadoc/dev/framework/app-service/app.html

代码清单3-5 onLaunch和onShow带参数

App({
  onLaunch: function(options) { console.log(options) },
  onShow: function(options) { console.log(options) }
})

表3-2 onLaunch,onShow参数

字段类型描述
pathString打开小程序的页面路径
queryObject打开小程序的页面参数query
sceneNumber打开小程序的场景值,详细场景值请参考小程序官方文档
shareTicketStringshareTicket,详见小程序官方文档
referrerInfoObject当场景为由从另一个小程序或公众号或App打开时,返回此字段
referrerInfo.appIdString来源小程序或公众号或App的 appId,详见下方说明
referrerInfo.extraDataObject来源小程序传过来的数据,scene=1037或1038时支持

表3-3 以下场景支持返回 referrerInfo.appId

场景值场景appId信息含义
1020公众号 profile页相关小程序列表 返回来源公众号 appId
1035公众号自定义菜单返回来源公众号 appId
1036App 分享消息卡片返回来源应用 appId
1037小程序打开小程序返回来源小程序 appId
1038从另一个小程序返回返回来源小程序 appId
1043公众号模板消息返回来源公众号 appId

4.2.1.3. 小程序全局数据

我们在 3.1.2节说到小程序的JS脚本是运行在JsCore的线程里,小程序的每个页面各自有一个WebView线程进行渲染,所以小程序切换页面时,小程序逻辑层的JS脚本运行上下文依旧在同一个JsCore线程中。
在上文中说道App实例是单例的,因此不同页面直接可以通过App实例下的属性来共享数据。App构造器可以传递其他参数作为全局属性以达到全局共享数据的目的。
代码清单3-6 小程序全局共享数据

// app.js
App({
  globalData: 'I am global data' // 全局共享数据
})
// 其他页面脚本other.js
var appInstance = getApp()
console.log(appInstance.globalData) // 输出: I am global data

与此同时,我们要特别留意一点,所有页面的脚本逻辑都跑在同一个JsCore线程,页面使用setTimeout或者setInterval的定时器,然后跳转到其他页面时,这些定时器并没有被清除,需要开发者自己在页面离开的时候进行清理。

4.2.2. 页面

一个小程序可以有很多页面,每个页面承载不同的功能,页面之间可以互相跳转。为了叙述简洁,我们之后讨论所涉及的“页面”概念特指“小程序页面”。

4.2.2.1. 文件构成和路径

一个页面是分三部分组成:界面、配置和逻辑。界面由WXML文件和WXSS文件来负责描述,配置由JSON文件进行描述,页面逻辑则是由JS脚本文件负责。一个页面的文件需要放置在同一个目录下,其中WXML文件和JS文件是必须存在的,JSON和WXSS文件是可选的。
页面路径需要在小程序代码根目录app.json中的pages字段声明,否则这个页面不会被注册到宿主环境中。例如两个页面的文件的相对路径分别为 pages/index/page. 和 pages/other/other. (_表示wxml/wxss/json/js四个文件),在app.json的pages字段的代码路径需要去除._后缀,如代码清单3-7所示,默认pages字段的第一个页面路径为小程序的首页。
代码清单3-7 app.json声明页面路径

{
  "pages":[
    "pages/index/page", // 第一项默认为首页
    "pages/other/other"
  ]
}

为了叙述方便,下文使用page.wxml / page.wxss / page.json / page.js 来分别代表特定页面的4个文件。

4.2.2.2. 页面构造器Page()

宿主环境提供了 Page() 构造器用来注册一个小程序页面,Page()在页面脚本page.js中调用,Page() 的调用方式如代码清单3-8所示。Page构造器接受一个Object参数,参数说明如表3-4所示,其中data属性是当前页面WXML模板中可以用来做数据绑定的初始数据,我们会在后文展开讨论;onLoad / onReady / onShow / onHide /onUnload 5个回调是Page实例的生命周期函数,我们在后文展开;onPullDownRefresh / onReachBottom / onShareAppMessage / onPageScroll 4个回调是页面的用户行为,我们也会在后文展开。
代码清单3-8 Page构造器

Page({
  data: { text: "This is page data." },
  onLoad: function(options) { },
  onReady: function() { },
  onShow: function() { },
  onHide: function() { },
  onUnload: function() { },
  onPullDownRefresh: function() { },
  onReachBottom: function() { },
  onShareAppMessage: function () { },
  onPageScroll: function() { }
})

表3-4 Page构造器的参数

参数属性类型描述
dataObject页面的初始数据
onLoadFunction生命周期函数—监听页面加载,触发时机早于onShow和onReady
onReadyFunction生命周期函数—监听页面初次渲染完成
onShowFunction生命周期函数—监听页面显示,触发事件早于onReady
onHideFunction生命周期函数—监听页面隐藏
onUnloadFunction生命周期函数—监听页面卸载
onPullDownRefreshFunction页面相关事件处理函数—监听用户下拉动作
onReachBottomFunction页面上拉触底事件的处理函数
onShareAppMessageFunction用户点击右上角转发
onPageScrollFunction页面滚动触发事件的处理函数
其他Any可以添加任意的函数或数据,在Page实例的其他函数中用 this 可以访问

4.2.2.3. 页面的生命周期和打开参数

页面初次加载的时候,微信客户端就会给Page实例派发onLoad事件,Page构造器参数所定义的onLoad方法会被调用,onLoad在页面没被销毁之前只会触发1次,在onLoad的回调中,可以获取当前页面所调用的打开参数option,关于打开参数我们放在这一节的最后再展开阐述。
页面显示之后,Page构造器参数所定义的onShow方法会被调用,一般从别的页面返回到当前页面时,当前页的onShow方法都会被调用。
在页面初次渲染完成时,Page构造器参数所定义的onReady方法会被调用,onReady在页面没被销毁前只会触发1次,onReady触发时,表示页面已经准备妥当,在逻辑层就可以和视图层进行交互了。
以上三个事件触发的时机是onLoad早于 onShow,onShow早于onReady。
页面不可见时,Page构造器参数所定义的onHide方法会被调用,这种情况会在使用wx.navigateTo切换到其他页面、底部tab切换时触发。
当前页面使用wx.redirectTo或wx.navigateBack返回到其他页时,当前页面会被微信客户端销毁回收,此时Page构造器参数所定义的onUnload方法会被调用。
我们可以看到,Page的生命周期是由微信客户端根据用户操作主动触发的。为了避免程序上的混乱,我们不应该在其他代码中主动调用Page实例的生命周期函数。
最后我们说一下页面的打开参数query,让我们来设想这样一个场景,我们实现一个购物商城的小程序,我们需要完成一个商品列表页和商品详情页,点击商品列表页的商品就可以跳转到该商品的详情页,当然我们不可能为每个商品单独去实现它的详情页。我们只需要实现一个商品详情页的pages/detail/detail._(代表WXML/WXSS/JS/JSON文件)_即可,在列表页打开商品详情页时把商品的id传递过来,详情页通过刚刚说的onLoad回调的参数option就可以拿到商品id,从而绘制出对应的商品,代码如代码清单3-9所示。

代码清单3-9 页面的打开参数Page构造器

// pages/list/list.js
// 列表页使用navigateTo跳转到详情页
wx.navigateTo({ url: 'pages/detail/detail?id=1&other=abc' })

// pages/detail/detail.js
Page({
  onLoad: function(option) {
        console.log(option.id)
        console.log(option.other)
  }
})

小程序把页面的打开路径定义成页面URL,其组成格式和网页的URL类似,在页面路径后使用英文 ? 分隔path和query部分,query部分的多个参数使用 & 进行分隔,参数的名字和值使用 key=value 的形式声明。在页面Page构造器里onLoad的option可以拿到当前页面的打开参数,其类型是一个Object,其键值对与页面URL上query键值对一一对应。和网页URL一样,页面URL上的value如果涉及特殊字符(例如:&字符、?字符、中文字符等,详情参考URI的RFC3986说明 ),需要采用UrlEncode后再拼接到页面URL上。

4.2.2.4. 页面的数据

3.1.4节讨论了小程序界面渲染的基本原理,我们知道小程序的页面结构由WXML进行描述,WXML可以通过数据绑定的语法绑定从逻辑层传递过来的数据字段,这里所说的数据其实就是来自于页面Page构造器的data字段,data参数是页面第一次渲染时从逻辑层传递到渲染层的数据。

代码清单3-10 Page构造器的data参数

<!-- page.wxml -->
<view>{{text}}</view>
<view>{{array[0].msg}}</view>

// page.js
Page({
  data: {
    text: 'init data',
    array: [{msg: '1'}, {msg: '2'}]
  }
})

宿主环境所提供的Page实例的原型中有setData函数,我们可以在Page实例下的方法调用this.setData把数据传递给渲染层,从而达到更新界面的目的。由于小程序的渲染层和逻辑层分别在两个线程中运行,所以setData传递数据实际是一个异步的过程,所以setData的第二个参数是一个callback回调,在这次setData对界面渲染完毕后触发。
setData其一般调用格式是 setData(data, callback),其中data是由多个key: value构成的Object对象。
代码清单3-11 使用setData更新渲染层数据

// page.js
Page({
  onLoad: function(){
    this.setData({
      text: 'change data'
    }, function(){
      // 在这次setData对界面渲染完毕后触发
    })
  }
})

实际在开发的时候,页面的data数据会涉及相当多的字段,你并不需要每次都将整个data字段重新设置一遍,你只需要把改变的值进行设置即可,宿主环境会自动把新改动的字段合并到渲染层对应的字段中,如下代码所示。data中的key还可以非常灵活,以数据路径的形式给出,例如 this.setData({“d[0]”: 100}); this.setData({“d[1].text”: ‘Goodbye’}); 我们只要保持一个原则就可以提高小程序的渲染性能:每次只设置需要改变的最小单位数据。
代码清单3-12 使用setData更新渲染层数据

// page.js
Page({
  data: {
    a: 1, b: 2, c: 3,
    d: [1, {text: 'Hello'}, 3, 4]
  }
  onLoad: function(){
       // a需要变化时,只需要setData设置a字段即可
    this.setData({a : 2})
  }
})

此外需要注意以下3点:

  1. 直接修改 Page实例的this.data 而不调用 this.setData 是无法改变页面的状态的,还会造成数据不一致。
  2. 由于setData是需要两个线程的一些通信消耗,为了提高性能,每次设置的数据不应超过1024kB。
  3. 不要把data中的任意一项的value设为undefined,否则可能会有引起一些不可预料的bug。

4.2.2.5. 页面的用户行为

小程序宿主环境提供了四个和页面相关的用户行为回调:

  1. 下拉刷新 onPullDownRefresh
    监听用户下拉刷新事件,需要在app.json的window选项中或页面配置page.json中设置enablePullDownRefresh为true。当处理完数据刷新后,wx.stopPullDownRefresh可以停止当前页面的下拉刷新。

  2. 上拉触底 onReachBottom
    监听用户上拉触底事件。可以在app.json的window选项中或页面配置page.json中设置触发距离onReachBottomDistance。在触发距离内滑动期间,本事件只会被触发一次。

  3. 页面滚动 onPageScroll
    监听用户滑动页面事件,参数为 Object,包含 scrollTop 字段,表示页面在垂直方向已滚动的距离(单位px)。

  4. 用户转发 onShareAppMessage
    只有定义了此事件处理函数,右上角菜单才会显示“转发”按钮,在用户点击转发按钮的时候会调用,此事件需要return一个Object,包含title和path两个字段,用于自定义转发内容,如代码清单3-13所示。

    代码清单3-13 使用onShareAppMessage自定义转发字段

    // page.js
    Page({
    onShareAppMessage: function () {
     return {
       title: '自定义转发标题',
       path: '/page/user?id=123'
     }
    }
    })
    

4.2.2.6. 页面跳转和路由

一个小程序拥有多个页面,我们可以通过wx.navigateTo推入一个新的页面,如图3-6所示,在首页使用2次wx.navigateTo后,页面层级会有三层,我们把这样的一个页面层级称为页面栈。

图3-6 使用2次wx.navigateTo后的页面栈

后续为了表述方便,我们采用这样的方式进行描述页面栈:[ pageA, pageB, pageC ],其中pageA在最底下,pageC在最顶上,也就是用户所看到的界面,需要注意在本书编写的时候,小程序宿主环境限制了这个页面栈的最大层级为10层 ,也就是当页面栈到达10层之后就没有办法再推入新的页面了。我们下面来通过上边这个页面栈描述以下几个和导航相关的API。
使用 wx.navigateTo({ url: ‘pageD’ }) 可以往当前页面栈多推入一个 pageD,此时页面栈变成 [ pageA, pageB, pageC, pageD ]。
使用 wx.navigateBack() 可以退出当前页面栈的最顶上页面,此时页面栈变成 [ pageA, pageB, pageC ]。
使用wx.redirectTo({ url: ‘pageE’ }) 是替换当前页变成pageE,此时页面栈变成 [ pageA, pageB, pageE ],当页面栈到达10层没法再新增的时候,往往就是使用redirectTo这个API进行页面跳转。
小程序提供了原生的Tabbar支持,我们可以在app.json声明tabBar字段来定义Tabbar页(注:更多详细参数见Tabbar官方文档 )。

代码清单3-14 app.json定义小程序底部tab

{
  "tabBar": {
    "list": [
      { "text": "Tab1", "pagePath": "pageA" },
      { "text": "Tab1", "pagePath": "pageF" },
      { "text": "Tab1", "pagePath": "pageG" }
    ]
  }
}

我们可以在刚刚的例子所在的页面栈中使用wx.switchTab({ url: ‘pageF’ }),此时原来的页面栈会被清空(除了已经声明为Tabbar页pageA外其他页面会被销毁),然后会切到pageF所在的tab页面,页面栈变成 [ pageF ],此时点击Tab1切回到pageA时,pageA不会再触发onLoad,因为pageA没有被销毁。
补充一下,wx.navigateTo和wx.redirectTo只能打开非TabBar页面,wx.switchTab只能打开Tabbar页面。
我们还可以使用 wx. reLaunch({ url: ‘pageH’ }) 重启小程序,并且打开pageH,此时页面栈为 [ pageH ]。表3-5罗列了详细的页面路由触发方式及页面生命周期函数的对应关系。

表3-5 页面路由触发方式及页面生命周期函数的对应关系

路由方式触发时机路由前页面生命周期路由后页面生命周期
初始化小程序打开的第一个页面onLoad, onShow
打开新页面 调用API wx.navigateToonHideonLoad, onShow
页面重定向 调用API wx.redirectToonUnloadonLoad, onShow
页面返回 调用API wx.navigateBackonUnloadonShow
Tab切换 调用 API wx.switchTab请参考表3-6请参考表3-6
重启动调用 API wx.reLaunchonUnloadonLoad, onShow

Tab 切换对应的生命周期(以 A、B 页面为 Tabbar 页面,C 是从 A 页面打开的页面,D 页面是从 C 页面打开的页面为例)如表3-6所示,注意Tabbar页面初始化之后不会被销毁。

表3-6 页面路由触发方式及页面生命周期函数的对应关系

当前页面路由后页面触发的生命周期(按顺序)
AA
ABA.onHide(), B.onLoad(), B.onShow()
AB(再次打开)A.onHide(), B.onShow()
CAC.onUnload(), A.onShow()
CBC.onUnload(), B.onLoad(), B.onShow()
DBD.onUnload(), C.onUnload(), B.onLoad(), B.onShow()
D(从转发进入)AD.onUnload(), A.onLoad(), A.onShow()
D(从转发进入)BD.onUnload(), B.onLoad(), B.onShow()

4.3. 组件

一个小程序页面可以分解成多个部分组成,组件就是小程序页面的基本组成单元。为了让开发者可以快速进行开发,小程序的宿主环境提供了一系列基础组件。
组件是在WXML模板文件声明中使用的,WXML的语法和HTML语法相似,小程序使用标签名来引用一个组件,通常包含开始标签和结束标签,该标签的属性用来描述该组件。
代码清单3-15 在WXML使用组件示例

<!-- page.wxml -->
<image mode="scaleToFill" src="img.png"></image>

需要注意,所有组件名和属性都是小写,多个单词会以英文横杠 ”-” 进行连接。
对于一些容器组件,其内容可以声明在其开始标签和结束标签之间。
代码清单3-16 容器组件嵌套其他组件

<!-- page.wxml -->
<view>
  <image mode="scaleToFill" src="img.png"></image>
  <view>
    <view>1</view>
    <view>2</view>
    <view>3</view>
  </view>
</view>

所有组件都拥有表3-7列举的属性,主要涉及样式和事件绑定,我们不在此处再做额外的说明,详细可以了解2.3节以及3.5节的相关内容。

表3-7 组件共有属性

属性名类型描述其他说明
idString组件的唯一标示保持整个页面唯一
classString组件的样式类在对应的WXSS中定义的样式类
styleString组件的内联样式可以通过数据绑定进行动态设置的内联样式
hiddenBoolean组件是否显示所有组件默认显示
data-*Any自定义属性组件上触发的事件时,会发送给事件处理函数
bind / catchEventHandler事件详情见3.5节

组件都拥有各自自定义的属性,可以对该组件的功能或者样式进行修饰,以image图片组件为例,其属性列表如表3-8所示。

表3-8 Image图片组件属性

属性名类型默认值描述
srcString图片资源地址
modeString’scaleToFill’图片裁剪、缩放的模式
lazy-loadBooleanfalse图片懒加载。只针对page与scroll-view下的image有效 1.5.0
binderrorHandleEvent当错误发生时触发事件,事件对象event.detail = {errMsg: ‘something wrong’}
bindloadHandleEvent当图片载入完毕时触发事件,事件对象event.detail = {height:‘图片高度px’, width:‘图片宽度px’}

为了不占篇幅,我们不在本书展开所有组件的属性说明,请在使用时前往官方文档进行查阅相关组件说明 https://mp.weixin.qq.com/debug/wxadoc/dev/component/

4.4. API

宿主环境提供了丰富的API,可以很方便调起微信提供的能力。在前文的代码示例中可以看到,wx.navigateTo可以保留当前页面,然后跳转到新的页面。这里的wx对象实际上就是小程序的宿主环境所提供的全局对象,几乎所有小程序的API都挂载在wx对象底下(除了Page/App等特殊的构造器),所以本书谈到API概念时,通常指的是wx对象底下的方法。
小程序提供的API按照功能主要分为几大类:网络、媒体、文件、数据缓存、位置、设备、界面、界面节点信息还有一些特殊的开放接口,我们介绍一下API一般调用的约定:

  1. wx.on* 开头的 API 是监听某个事件发生的API接口,接受一个 Callback 函数作为参数。当该事件触发时,会调用 Callback 函数。

  2. 如未特殊约定,多数 API 接口为异步接口 ,都接受一个Object作为参数。

  3. API的Object参数一般由success、fail、complete三个回调来接收接口调用结果,示例代码如代码清单3-17所示,详细说明如表3-9所示。

  4. wx.get* 开头的API是获取宿主环境数据的接口。

  5. wx.set* 开头的API是写入数据到宿主环境的接口。

    代码清单3-17 通过wx.request发起网络请求

    wx.request({
    url: 'test.php',
    data: {},
    header: { 'content-type': 'application/json' },
    success: function(res) {
     // 收到https服务成功后返回
     console.log(res.data)
    },
    fail: function() {
     // 发生网络错误等情况触发
    },
    complete: function() {
     // 成功或者失败后触发
    }
    })
    

代表3-9 API接口回调说明

参数名字类型必填描述
successFunction接口调用成功的回调函数
failFunction接口调用失败的回调函数
completeFunction接口调用结束的回调函数(调用成功、失败都会执行)

还有需要注意到API调用大多都是异步的,其次,有部分API会拉起微信的原生界面,此时会触发Page的onHide方法,当用户从原生界面返回到小程序时,会触发Page的onShow方法。
API的数量非常多,而且随着宿主环境的迭代更新会持续新增API,在这里我们并不想一一展开叙述每一个API的含义,开发者只要了解一般调用API的技巧,再通过官方API文档 https://mp.weixin.qq.com/debug/wxadoc/dev/api/了解到对应的API参数细节即可。在第四章中我们还会通过各种应用场景来展示很多API的用法。

4.5. 事件

4.5.1. 什么是事件

UI界面的程序需要和用户互动,例如用户可能会点击你界面上某个按钮,又或者长按某个区域,这类反馈应该通知给开发者的逻辑层,需要将对应的处理状态呈现给用户。
有些时候程序上的“行为反馈”不一定是用户主动触发的,例如我们在视频video播放的过程中,播放进度是会一直变化的,这种反馈也应该通知给开发者做相应的逻辑处理。
在小程序里边,我们把这种“用户在渲染层的行为反馈”以及“组件的部分状态反馈”抽象为渲染层传递给逻辑层的“事件”,如图3-7所示。

图3-7 渲染层产生用户交互事件传递给逻辑层

我们给出一个简单的处理事件的小程序代码。

代码清单3-18 事件处理示例

<!-- page.wxml -->
<view id="tapTest" data-hi="WeChat" bindtap="tapName"> Click me! </view>

// page.js
   Page({
  tapName: function(event) {
    console.log(event)
  }
})

事件是通过bindtap这个属性绑定在组件上的,同时在当前页面的Page构造器中定义对应的事件处理函数tapName,当用户点击该view区域时,达到触发条件生成事件tap,该事件处理函数tapName会被执行,同时还会收到一个事件对象event。

4.5.2. 事件类型和事件对象

前边说到触发事件是由“用户在渲染层的行为反馈”以及“组件的部分状态反馈”引起的,由于不同组件的状态不一致,所以我们这里不讨论组件相关的事件(组件的事件可以参考其参数说明,详情见官方文档 https://mp.weixin.qq.com/debug/wxadoc/dev/component/ )
常见的事件类型如表3-10所示。

表3-10 常见的事件类型

类型触发条件
touchstart手指触摸动作开始
touchmove手指触摸后移动
touchcancel手指触摸动作被打断,如来电提醒,弹窗
touchend手指触摸动作结束
tap手指触摸后马上离开
longpress手指触摸后,超过350ms再离开,如果指定了事件回调函数并触发了这个事件,tap事件将不被触发
longtap手指触摸后,超过350ms再离开(推荐使用longpress事件代替)
transitionend会在 WXSS transition 或 wx.createAnimation 动画结束后触发
animationstart会在一个 WXSS animation 动画开始时触发
animationiteration会在一个 WXSS animation 一次迭代结束时触发
animationend会在一个 WXSS animation 动画完成时触发

当事件回调触发的时候,会收到一个事件对象,对象的详细属性如下表所示。

表3-11 事件对象属性

属性类型说明
typeString事件类型
timeStampInteger页面打开到触发事件所经过的毫秒数
targetObject触发事件的组件的一些属性值集合
currentTargetObject当前组件的一些属性值集合
detailObject额外的信息
touchesArray触摸事件,当前停留在屏幕中的触摸点信息的数组
changedTouchesArray触摸事件,当前变化的触摸点信息的数组

这里需要注意的是target和currentTarget的区别,currentTarget为当前事件所绑定的组件,而target则是触发该事件的源头组件。

代码清单3-19 事件对象示例

<!-- page.wxml -->
<view id="outer" catchtap="handleTap">
  <view id="inner">点击我</view>
</view>
// page.js
Page({
  handleTap: function(evt) {
       // 当点击inner节点时
    // evt.target 是inner view组件
       // evt.currentTarget 是绑定了handleTap的outer view组件
       // evt.type == “tap”
       // evt.timeStamp == 1542
       // evt.detail == {x: 270, y: 63}
       // evt.touches == [{identifier: 0, pageX: 270, pageY: 63, clientX: 270, clientY: 63}]
       // evt.changedTouches == [{identifier: 0, pageX: 270, pageY: 63, clientX: 270, clientY: 63}]
  }
})

关于target和currentTarget对象的详细参数如表3-12所示。

表3-12 target和currentTarget事件对象属性

属性类型说明
idString当前组件的id
tagNameString当前组件的类型
datasetObject当前组件上由data-开头的自定义属性组成的集合

关于touch和changedTouches对象的详细参数如表3-13所示。

表3-13 touch和changedTouches对象属性

属性类型说明
identifierNumber触摸点的标识符
pageX, pageYNumber距离文档左上角的距离,文档的左上角为原点 ,横向为X轴,纵向为Y轴
clientX, clientYNumber距离页面可显示区域(屏幕除去导航条)左上角距离,横向为X轴,纵向为Y轴

4.5.3. 事件绑定与冒泡捕获

事件绑定的写法和组件属性一致,以key=“value”的形式,其中:

  1. key以bind或者catch开头,然后跟上事件的类型,如bindtap、catchtouchstart。自基础库版本1.5.0起,bind和catch后可以紧跟一个冒号,其含义不变,如bind:tap、catch:touchstart。同时bind和catch前还可以加上capture-来表示捕获阶段。

  2. value是一个字符串,需要在对应的页面Page构造器中定义同名的函数,否则触发事件时在控制台会有报错信息。
    bind_和capture-bind_的含义分别代表事件的冒泡阶段和捕获阶段,其触发的顺序如图3-8所示。

图3-8 事件捕获和冒泡触发时序

以下示例中,点击 inner view 会先后调用handleTap2、handleTap4、handleTap3、handleTap1。

代码清单3-20 事件的冒泡和捕获

<view
  id="outer"
  bind:touchstart="handleTap1"
  capture-bind:touchstart="handleTap2"
>
  outer view
  <view
    id="inner"
    bind:touchstart="handleTap3"
    capture-bind:touchstart="handleTap4"
  >
    inner view
  </view>
</view>

bind事件绑定不会阻止冒泡事件向上冒泡,catch事件绑定可以阻止冒泡事件向上冒泡。如果将上面代码中的第一个capture-bind改为capture-catch,将只触发handleTap2(capture-catch将中断捕获阶段和取消冒泡阶段)

代码清单3-21 使用catch阻止事件的传递

<view
  id="outer"
  bind:touchstart="handleTap1"
  capture-catch:touchstart="handleTap2"
>
  outer view
  <view
    id="inner"
    bind:touchstart="handleTap3"
    capture-bind:touchstart="handleTap4"
  >
    inner view
  </view>
</view>

注意,除表3-10列举的事件类型之外的其他组件自定义事件,如无特殊声明都是非冒泡事件,如

的submit事件,的input事件,的scroll事件。

4.6. 兼容

小程序的宿主环境一直在迭代更新,提供更多的能力给开发者去完成更多的事情,所以你的小程序会运行在不同版本的宿主环境下。为了让你的小程序在不同环境下都能提供相应的服务,我们需要来了解一下在小程序中如何实现兼容办法。
我们可能需要针对不同手机进行程序上的兼容,此时可以使用 wx.getSystemInfo 或者 wx.getSystemInfoSync 来获取手机品牌、操作系统版本号、微信版本号以及小程序基础库版本号等,通过这个信息,我们可以针对不同平台做差异化的服务。
代码清单3-22 通过wx.getSystemInfoSync获取宿主环境信息

wx.getSystemInfoSync()
/*
  {
    brand: "iPhone",      // 手机品牌
    model: "iPhone 6",    // 手机型号
    platform: "ios",      // 客户端平台
    system: "iOS 9.3.4",  // 操作系统版本
    version: "6.5.23",    // 微信版本号
    SDKVersion: "1.7.0",  // 小程序基础库版本
    language: "zh_CN",    // 微信设置的语言
    pixelRatio: 2,        // 设备像素比
    screenWidth: 667,    // 屏幕宽度
    screenHeight: 375,     // 屏幕高度
    windowWidth: 667,    // 可使用窗口宽度
    windowHeight: 375,     // 可使用窗口高度
    fontSizeSetting: 16   // 用户字体大小设置
  }
 */

随着宿主环境的更新,新版本的宿主环境会提供一些新的API,你可以通过判断此API是否存在来做程序上的兼容。

代码清单3-23 通过判断API是否存在做兼容

if (wx.openBluetoothAdapter) {
  wx.openBluetoothAdapter()
} else {
  // 如果希望用户在最新版本的客户端上体验您的小程序,可以这样子提示
  wx.showModal({
    title: '提示',
    content: '当前微信版本过低,无法使用该功能,请升级到最新微信版本后重试。'
  })
}

小程序还提供了wx.canIUse这个API,用于判断接口或者组件在当前宿主环境是否可用,其参数格式为: ${API}.${method}.${param}.${options}或者${component}.${attribute}.${option}
各个段的含义如下:

  • ${API} 代表 API 名字

  • ${method} 代表调用方式,有效值为return, success, object, callback

  • ${param} 代表参数或者返回值

  • ${options} 代表参数的可选值

  • ${component} 代表组件名字

  • ${attribute} 代表组件属性

  • ${option} 代表组件属性的可选值
    调用的示例代码如下。

    码清单3-24 wx.canIUse调用示例

    // 判断接口及其参数在宿主环境是否可用
    wx.canIUse('openBluetoothAdapter')
    wx.canIUse('getSystemInfoSync.return.screenWidth')
    wx.canIUse('getSystemInfo.success.screenWidth')
    wx.canIUse('showToast.object.image')
    wx.canIUse('onCompassChange.callback.direction')
    wx.canIUse('request.object.method.GET')
    
     // 判断组件及其属性在宿主环境是否可用
    wx.canIUse('contact-button')
    wx.canIUse('text.selectable')
    wx.canIUse('button.open-type.contact')
    

    我们可以选择合适的判断方法来做小程序的向前兼容,以保证我们的小程序在旧版本的微信客户端也能工作正常。在不得已的情况下(小程序强依赖某个新的API或者组件时),还可以通过在小程序管理后台设置“基础库最低版本设置”来达到不向前兼容的目的。例如你选择设置你的小程序只支持1.5.0版本以上的宿主环境,那么当运行着1.4.0版本宿主环境的微信用户打开你的小程序的时候,微信客户端会显示当前小程序不可用,并且提示用户应该去升级微信客户端。

5. 场景应用

5.1. 开发流程基本介绍

在启动开发前,首先我们对整个小程序整体的产品体验有一个清晰的规划和定义,一般会通过交互图或者手稿描绘小程序的界面交互和界面之间的跳转关系。图4-1是一个管理文章留言小程序的交互图。

图4-1 文章留言管理的交互图

紧接着,我们优先完成WXML+WXSS还原设计稿,把界面涉及到的元素和视觉细节先调试完成。最后我们把按照页面交互梳理出每个页面的data部分,填充WXML的模板语法,还有完成JS逻辑部分。

当然并不是要完全按照这样的开发流程来开发小程序,有些时候我们可能在产品交互体验还不明确的情况下,先完成JS逻辑层一些模块的工作并做好测试。高效的开发流程有很多种方式,一般是根据整个团队的工作节奏来选择和开展,我们这一节讨论到的流程只是其中常见的开发流程。

5.2. 基本的布局方法——Flex布局

如果之前你接触过网页开发中的flexbox布局,基本上你可以略过这节。但有一点需要注意的是,你的小程序要求兼容到iOS8以下版本,需要开启样式自动补全。开启样式自动补全,在“设置”—“项目设置”—勾选“上传代码时样式自动补全”。

​ 图4-2 开发者工具开启样式自动补全

在小程序开发中,我们需要考虑各种尺寸终端设备上的适配。在传统网页开发,我们用的是盒模型,通过display:inline | block | inline-block、 position、float来实现布局,缺乏灵活性且有些适配效果难以实现。比如像下面这种常见的信息列表,要求内容高度不确定下保持垂直居中:

​ 图4-3 常见的信息列表排版方式

这种情况下,我们更建议用flex布局。

在开始介绍flex之前,为了表述方便,我们约定以下术语:采用flex布局的元素,简称为“容器”,在代码示例中以container表示容器的类名。容器内的元素简称为“项目”,在代码示例中以item表示项目的类名。

​ 图4-4 container容器和item项目

5.2.1. 基本概念

flex的概念最早是在2009年被提出,目的是提供一种更灵活的布局模型,使容器能通过改变里面项目的高宽、顺序,来对可用空间实现最佳的填充,方便适配不同大小的内容区域。

在不固定高度信息的例子中,我们只需要在容器中设置以下两个属性即可实现内容不确定下的垂直居中。

.container{

  display: flex;

  flex-direction: column;

  justify-content: center;

}

flex不单是一个属性,它包含了一套新的属性集。属性集包括用于设置容器,和用于设置项目两部分。

设置容器的属性有:

display:flex;

flex-direction:row(默认值) | row-reverse | column |column-reverse

flex-wrap:nowrap(默认值) | wrap | wrap-reverse

justify-content:flex-start(默认值) | flex-end | center |space-between | space-around | space-evenly

align-items:stretch(默认值) | center  | flex-end | baseline | flex-start

align-content:stretch(默认值) | flex-start | center |flex-end | space-between | space-around | space-evenly

设置项目的属性有:

order:0(默认值) | <integer>

flex-shrink:1(默认值) | <number>

flex-grow:0(默认值) | <number>

flex-basis:auto(默认值) | <length>

flex:none | auto | @flex-grow @flex-shrink @flex-basis

align-self:auto(默认值) | flex-start | flex-end |center | baseline| stretch

在开始介绍各个属性之前,我们需要先明确一个坐标轴。默认的情况下,水平方向的是主轴(main axis),垂直方向的是交叉轴(cross axis)。

​ 图4-5 默认情况下的主轴与交叉轴

项目是在主轴上排列,排满后在交叉轴方向换行。需要注意的是,交叉轴垂直于主轴,它的方向取决于主轴方向。

​ 图4-6 项目是在主轴上排列,排满后在交叉轴方向换行

接下来的例子如无特殊声明,我们都以默认情况下的坐标轴为例。

5.2.2. 容器属性

设置容器,用于统一管理容器内项目布局,也就是管理项目的排列方式和对齐方式。

flex-direction 属性

通过设置坐标轴,来设置项目排列方向。

.container{
  flex-direction: row(默认值) | row-reverse | column | column-reverse
}

row(默认值):主轴横向,方向为从左指向右。项目沿主轴排列,从左到右排列。

row-reverse:row的反方向。主轴横向,方向为从右指向左。项目沿主轴排列,从右到左排列。

column:主轴纵向,方向从上指向下。项目沿主轴排列,从上到下排列。

column-reverse:column的反方向。主轴纵向,方向从下指向上。项目沿主轴排列,从下到上排列。

​ 图4-7 flex-direction

flex-wrap 属性

设置是否允许项目多行排列,以及多行排列时换行的方向。

.container{
  flex-wrap: nowrap(默认值) | wrap | wrap-reverse
}

nowrap(默认值):不换行。如果单行内容过多,则溢出容器。
wrap:容器单行容不下所有项目时,换行排列。
wrap-reverse:容器单行容不下所有项目时,换行排列。换行方向为wrap时的反方向。

​ 图4-8 flex-wrap

justify-content 属性

设置项目在主轴方向上对齐方式,以及分配项目之间及其周围多余的空间。

.container{

  justify-content: flex-start(默认值) | flex-end | center | space-between | space-around| space-evenly

}

flex-start(默认值):项目对齐主轴起点,项目间不留空隙。

center:项目在主轴上居中排列,项目间不留空隙。主轴上第一个项目离主轴起点距离等于最后一个项目离主轴终点距离。

flex-end:项目对齐主轴终点,项目间不留空隙。
space-between:项目间间距相等,第一个项目离主轴起点和最后一个项目离主轴终点距离为0。
space-around:与space-between相似。不同点为,第一个项目离主轴起点和最后一个项目离主轴终点距离为中间项目间间距的一半。

space-evenly:项目间间距、第一个项目离主轴起点和最后一个项目离主轴终点距离等于项目间间距。

​ 图4-9 justify-content

align-items 属性

设置项目在行中的对齐方式。

.container{

  align-items:stretch(默认值) | flex-start | center | flex-end | baseline

}

stretch(默认值):项目拉伸至填满行高。
flex-start:项目顶部与行起点对齐。
center:项目在行中居中对齐。
flex-end:项目底部与行终点对齐。
baseline:项目的第一行文字的基线对齐。。

​ 图4-10 align-items

align-content 属性

多行排列时,设置行在交叉轴方向上的对齐方式,以及分配行之间及其周围多余的空间。

.container{

  align-content: stretch(默认值) | flex-start | center | flex-end | space-between |space-around | space-evenly

}

stretch(默认值):当未设置项目尺寸,将各行中的项目拉伸至填满交叉轴。当设置了项目尺寸,项目尺寸不变,项目行拉伸至填满交叉轴。

flex-start:首行在交叉轴起点开始排列,行间不留间距。

center:行在交叉轴中点排列,行间不留间距,首行离交叉轴起点和尾行离交叉轴终点距离相等。

flex-end:尾行在交叉轴终点开始排列,行间不留间距。
space-between:行与行间距相等,首行离交叉轴起点和尾行离交叉轴终点距离为0。
space-around:行与行间距相等,首行离交叉轴起点和尾行离交叉轴终点距离为行与行间间距的一半。

space-evenly:行间间距、以及首行离交叉轴起点和尾行离交叉轴终点距离相等。

​ 图4-11 align-content

5.2.3. 项目属性

设置项目,用于设置项目的尺寸、位置,以及对项目的对齐方式做特殊设置。

order 属性

设置项目沿主轴方向上的排列顺序,数值越小,排列越靠前。属性值为整数。

.item{

  order: 0(默认值) | <integer>


}

​ 图4-12 order

flex-shrink 属性

当项目在主轴方向上溢出时,通过设置项目收缩因子来压缩项目适应容器。属性值为项目的收缩因子,属性值取非负数。

.item{

  flex-shrink: 1(默认值) | <number>

}

.item1{

  width: 120px;

  flex-shrink: 2;

}

.item2{

  width: 150px;

  flex-shrink: 3;

}

.item3{// 项目3未设置flex-shrink,默认flex-shrink值为1

  width: 180px;

}

为了加深理解,我们举个例子:

一个宽度为400px的容器,里面的三个项目width分别为120px,150px,180px。分别对这项目1和项目2设置flex-shrink值为2和3。

.container{

  display: flex;

  width: 400px; // 容器宽度为400px

}

在这个例子中,项目溢出 400 - (120 + 150 + 180) = -50px。计算压缩量时总权重为各个项目的宽度乘以flex-shrink的总和,这个例子压缩总权重为120 * 2 + 150 * 3+ 180 * 1 = 870。各个项目压缩空间大小为总溢出空间乘以项目宽度乘以flex-shrink除以总权重:

item1的最终宽度为:120 - 50 * 120 * 2 / 870 ≈ 106px

item2的最终宽度为:150 - 50 * 150 * 3 / 870 ≈ 124px

item3的最终宽度为:180 - 50 * 180 * 1 / 870 ≈ 169px

其中计算时候值如果为小数,则向下取整。

​ 图4-13 flex-shrink

需要注意一点,当项目的压缩因子相加小于1时,参与计算的溢出空间不等于完整的溢出空间。在上面例子的基础上,我们改变各个项目的flex-shrink。

.container{

  display: flex;

  width: 400px; // 容器宽度为400px

}

.item1{

  width: 120px;

  flex-shrink: 0.1;

}

.item2{

  width: 150px;

  flex-shrink: 0.2;

}

.item3{

  width: 180px;

  flex-shrink: 0.3;

}

总权重为:120 * 0.1 + 150 * 0.2 + 180 * 0.3 = 96。参与计算的溢出空间不再是50px,而是50 * (0.1 + 0.2 + 0.3) / 1 =30:

item1的最终宽度为:120 - 30 * 120 * 0.1 / 96 ≈ 116px

item2的最终宽度为:150 - 30 * 150 * 0.2 / 96 ≈ 140px

item3的最终宽度为:180 - 30 * 180 * 0.3 / 96 ≈ 163px

flex-grow 属性

当项目在主轴方向上还有剩余空间时,通过设置项目扩张因子进行剩余空间的分配。属性值为项目的扩张因子,属性值取非负数。

.item{

  flex-grow: 0(默认值) | <number>

}

为了加深理解,我们举个例子:

一个宽度为400px的容器,里面的三个项目width分别为80px,120px,140px。分别对这项目1和项目2设置flex-grow值为3和1。

.container{

  display: flex;

  width: 400px; // 容器宽度为400px

}

.item1{

  width: 80px;

  flex-grow: 3;

}

.item2{

  width: 120px;

  flex-grow: 1;

}

.item3{// 项目3未设置flex-grow,默认flex-grow值为0

  width: 140px;

}

在这个例子中,容器的剩余空间为 400 - (80 + 120 + 140) = 60px。剩余空间按 60 / (3 + 1 + 0) = 15px进行分配:

item1的最终宽度为:80+ (15 * 3) = 125px

item2的最终宽度为:120 + (15 * 1) = 135px

item3的最终宽度为:140 + (15 * 0) =140px

​ 图4-14 flex-grow

需要注意一点,当项目的扩张因子相加小于1时,剩余空间按除以1进行分配。在上面例子的基础上,我们改变各个项目的flex-grow。

.container{

  display: flex;

  width: 400px; // 容器宽度为400px

}

.item1{

  width: 50px;

  flex-grow: 0.1;

}

.item2{

  width: 80px;

  flex-grow: 0.3;

}

.item3{

  width: 110px;

  flex-grow: 0.2;

}

在这个例子中,容器的剩余空间为 400 - (50 + 80 + 110) = 160px。由于项目的flex-grow相加0.1 + 0.3 + 0.2 = 0.6小于1,剩余空间按 160 / 1 = 160px划分。例子中的项目宽度分别为:

item1的最终宽度为:50 + (160 * 0.1) = 66px

item2的最终宽度为:80 + (160 * 0.3) = 128px

item3的最终宽度为:110 + (160 * 0.2) = 142px

flex-basis 属性

当容器设置flex-direction为row或row-reverse时,flex-basis和width同时存在,flex-basis优先级高于width,也就是此时flex-basis代替项目的width属性。

当容器设置flex-direction为column或column-reverse时,flex-basis和height同时存在,flex-basis优先级高于height,也就是此时flex-basis代替项目的height属性。

需要注意的是,当flex-basis和width(或height),其中一个属性值为auto时,非auto的优先级更高。

.item{

  flex-basis: auto(默认值) | <number>px

}

​ 图4-15 flex-basis

flex 属性

是flex-grow,flex-shrink,flex-basis的简写方式。值设置为none,等价于00 auto。值设置为auto,等价于1 1 auto。

.item{

  flex: none | auto | @flex-grow @flex-shrink@flex-basis

}

align-self 属性

设置项目在行中交叉轴方向上的对齐方式,用于覆盖容器的align-items,这么做可以对项目的对齐方式做特殊处理。默认属性值为auto,继承容器的align-items值,当容器没有设置align-items时,属性值为stretch。

.item{

  align-self: auto(默认值) | flex-start | center | flex-end | baseline |stretch

}

图4-16 align-self

5.3. 界面常见的交互反馈

用户和小程序上进行交互的时候,某些操作可能比较耗时,我们应该予以及时的反馈以舒缓用户等待的不良情绪。

5.3.1. 触摸反馈

通常页面会摆放一些button按钮或者view区域,用户触摸按钮之后会触发下一步的操作。这种情况下,我们要对触摸这个行为给予用户一些响应。如图4-17所示,当我们手指触摸了button文字所在的cell区域时,对应的区域底色变成浅灰色,这样用户就可以知道小程序是有及时响应他的这次触摸操作,用户就不会很迷惑。

​ 图4-17 触摸区域底色变成灰色响应用户的触摸操作

小程序的view容器组件和button组件提供了hover-class属性,触摸时会往该组件加上对应的class改变组件的样式。

代码清单4-1 通过hover-class属性改变触摸时的样式

/*page.wxss */

.hover{

  background-color: gray;

}



<!--page.wxml -->

<button hover-class="hover"> 点击button </button>

<view hover-class="hover"> 点击view</view>

对于用户的操作及时响应是非常优秀的体验,有时候在点击button按钮处理更耗时的操作时,我们也会使用button组件的loading属性,在按钮的文字前边出现一个Loading,让用户明确的感觉到,这个操作会比较耗时,需要等待一小段时间。

​ 图4-18 button文字前出现loading

代码清单4-2 设置button的loading属性

<!--page.wxml -->

<button loading="{{loading}}" bindtap="tap">操作</button>



//page.js

Page({

  data: { loading: false },

  tap: function() {

    // 把按钮的loading状态显示出来

    this.setData({

      loading: true

    })

    // 接着做耗时的操作

  }

})

5.3.2. Toast和模态对话框

在完成某个操作成功之后,我们希望告诉用户这次操作成功并且不打断用户接下来的操作。弹出式提示Toast就是用在这样的场景上,Toast提示默认1.5秒后自动消失,其表现形式如图4-19所示。

​ 图4-19 Toast弹出式提示

小程序提供了显示隐藏Toast的接口,代码示例如下所示。

代码清单4-3 显示/隐藏Toast

Page({

  onLoad: function() {

    wx.showToast({ // 显示Toast

      title: '已发送',

      icon: 'success',

      duration: 1500

    })

    // wx.hideToast() // 隐藏Toast

  }

})

特别要注意,我们不应该把Toast用于错误提示,因为错误提示需要明确告知用户具体原因,因此不适合用这种一闪而过的Toast弹出式提示。一般需要用户明确知晓操作结果状态的话,会使用模态对话框来提示,同时附带下一步操作的指引。

​ 图4-20 模态对话框

代码清单4-4 显示模态对话框

Page({

  onLoad: function() {

    wx.showModal({

      title: '标题',

      content: '告知当前状态,信息和解决方法',

      confirmText: '主操作',

      cancelText: '次要操作',

      success: function(res) {

        if (res.confirm) {

          console.log('用户点击主操作')

        } else if (res.cancel) {

          console.log('用户点击次要操作')

        }

      }

    })

  }

})

5.3.3. 界面滚动

往往手机屏幕是承载不了所有信息的,所以内容区域肯定会超出屏幕区域,用户可以通过滑动屏幕来查看下一屏的内容,这是非常常见的界面滚动的交互。

为了让用户可以快速刷新当前界面的信息,一般在小程序里会通过下拉整个界面这个操作来触发,如图4-21所示。

宿主环境提供了统一的下拉刷新交互,开发者只需要通过配置开启当前页面的下拉刷新,用户往下拉动界面触发下拉刷新操作时,Page构造器的onPullDownRefresh回调会被触发,此时开发者重新拉取新数据进行渲染,实例代码如下所示。

代码清单4-5 页面下拉刷新

//page.json

{"enablePullDownRefresh": true }



//page.js

Page({

  onPullDownRefresh: function() {

    // 用户触发了下拉刷新操作

    // 拉取新数据重新渲染界面

    // wx.stopPullDownRefresh() // 可以停止当前页面的下拉刷新。

  }

})

多数的购物小程序会在首页展示一个商品列表,用户滚动到底部的时候,会加载下一页的商品列表渲染到列表的下方,我们把这个交互操作叫为上拉触底。宿主环境提供了上拉的配置和操作触发的回调,如下代码所示。

代码清单4-6 页面上拉触底

//page.json

// 界面的下方距离页面底部距离小于onReachBottomDistance像素时触发onReachBottom回调

{"onReachBottomDistance": 100 }



//page.js

Page({

  onReachBottom: function() {

    // 当界面的下方距离页面底部距离小于100像素时触发回调

  }

})

当然我们有些时候并不想整个页面进行滚动,而是页面中某一小块区域需要可滚动,此时就要用到宿主环境所提供的scroll-view可滚动视图组件。可以通过组件的scroll-x和scroll-y属性决定滚动区域是否可以横向或者纵向滚动,scroll-view组件也提供了丰富的滚动回调触发事件,这部分我们就不再展开细节,读者可以通过scroll-view组件的官方文档了解到细节[1]

5.4. 发起HTTPS网络通信

小程序经常需要往服务器传递数据或者从服务器拉取信息,这个时候可以使用wx.request这个API,在这一节我们会重点讨论wx.request的使用和注意事项。为了叙述方便,假设我们的服务器域名是test.com。

5.4.1. wx.request接口

如果我们需要从 https://test.com/getinfo 接口拉取用户信息,其代码示例如下所示,详细参数如表4-1所示。

代码清单4-7 wx.request调用示例

wx.request({

  url: 'https://test.com/getinfo',

  success: function(res) {

    console.log(res)// 服务器回包信息

  }

})

表4-1 wx.request详细参数

参数名类型必填默认值描述
urlString开发者服务器接口地址
dataObject/String请求的参数
headerObject设置请求的 header,header 中不能设置 Referer,默认header[‘content-type’] = ‘application/json’
methodStringGET(需大写)有效值:OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT
dataTypeStringjson回包的内容格式,如果设为json,会尝试对返回的数据做一次 JSON解析
successFunction收到开发者服务成功返回的回调函数,其参数是一个Object,见表4-2。
failFunction接口调用失败的回调函数
completeFunction接口调用结束的回调函数(调用成功、失败都会执行)

5.4.2. 服务器接口

url参数是当前发起请求的服务器接口地址,小程序宿主环境要求request发起的网络请求必须是https协议请求,因此开发者服务器必须提供HTTPS服务的接口,同时为了保证小程序不乱用任意域名的服务,wx.request请求的域名需要在小程序管理平台进行配置[2],如果小程序正式版使用wx.request请求未配置的域名,在控制台会有相应的报错。

一般我们在开发阶段时,处于开发阶段的服务器接口还没部署到现网的域名下,经常会通过另一个域名来进行开发调试,考虑到这一点,为了方便开发者进行开发调试,开发者工具、小程序的开发版和小程序的体验版在某些情况下[3]允许wx.request请求任意域名。

由于我们一直在迭代更新小程序,那么就会有一个问题:在新版小程序发布时的某段时间内,会有部分用户使用旧版本的小程序[4]。如果接口需要支持新的特性需要修改返回的数据格式,那接口的参数和返回字段至少向前兼容一个版本。举个例子,假设前边的https://test.com/getinfo接口返回的JSON数据为:{ “username”: “zhangsan”,“sex”: “man” },在新版本中,我们需要把sex字段的值改成用0, 1来表示性别男女。为了保持接口向前兼容,我们不应该直接改sex字段值的类型,而是返回的JSON数据中再定义多一个字段sexNumber,这样旧版本通过这个接口拿到的数据格式依旧是能够正常工作的。

5.4.3. 请求参数

通过wx.request这个API,有两种方法把数据传递到服务器:通过url上的参数以及通过data参数。举个例子:我们需要向服务器拿id为1的用户的信息,同时我们把当前小程序的版本带给服务器,让服务器可以做新旧版逻辑兼容,两种方法的代码示例如代码4-8所示。

代码清单4-8 wx.request调用示例

// 通过url参数传递数据

wx.request({

  url:'https://test.com/getinfo?id=1&version=1.0.0',

  success: function(res) {

    console.log(res)// 服务器回包信息

  }

})

   // 通过data参数传递数据

wx.request({

  url: 'https://test.com/getinfo',

     data: { id:1, version:'1.0.0' },

  success: function(res) {

    console.log(res)// 服务器回包信息

  }

})

两种实现方式在HTTP GET请求的情况下表现几乎是一样的,需要留意的是url是有长度限制的,其最大长度是1024字节,同时url上的参数需要拼接到字符串里,参数的值还需要做一次urlEncode。向服务端发送的数据超过1024字节时,就要采用HTTPPOST的形式,此时传递的数据就必须要使用data参数,基于这个情况,一般建议需要传递数据时,使用data参数来传递。

我们再来单独看看POST请求的情况,并不是所有请求都是按照键值对key=value的形式传递到后台服务器,有时候需要传一些比较复杂的数据结构到后台的时候,用JSON格式会更加合适。此时我们可以在wx.request的header参数设置content-type头部为application/json,小程序发起的请求的包体内容就是data参数对应的JSON字符串,代码示例如下。

代码清单4-9 wx.request发起POST请求包体使用json格式

// 请求的包体为 {"a":{"b":[1,2,3],"c":{"d":"test"}}}

wx.request({

  url: 'https://test.com/postdata',

  method: 'POST',

  header: { 'content-type': 'application/json'},

  data: {

    a: {

      b: [1, 2, 3],

      c: { d: "test" }

    }

  },

  success: function(res) {

    console.log(res)// 服务器回包信息

  }

})

5.4.4. 收到回包

通过wx.request发送请求后,服务器处理请求并返回HTTP包,小程序端收到回包后会触发success回调,同时回调会带上一个Object信息,详细参数表4-2所示。

表4-2 wx.request的success返回参数

参数名类型描述
dataObject/String开发者服务器返回的数据
statusCodeNumber开发者服务器返回的 HTTP 状态码
headerObject开发者服务器返回的 HTTP Response Header

尤其注意,只要成功收到服务器返回,无论HTTP状态码是多少都会进入success回调。因此开发者自己通过对回包的返回码进行判断后再执行后续的业务逻辑。

success回调的参数data字段类型是根据header[‘content-type’]决定的,默认header[‘content-type’]是’application/json’,在触发success回调前,小程序宿主环境会对data字段的值做JSON解析,如果解析成功,那么data字段的值会被设置成解析后的Object对象,其他情况data字段都是String类型,其值为HTTP回包包体。

5.4.5. 一般使用技巧

5.4.5.1. 设置超时时间

小程序发出一个HTTPS网络请求,有时网络存在一些异常或者服务器存在问题,在经过一段时间后仍然没有收到网络回包,我们把这一段等待的最长时间称为请求超时时间。小程序request默认超时时间是60秒,一般来说,我们不需要这么长的一个等待时间才收到回包,可能在等待3秒后还没收到回包就需要给用户一个明确的服务不可用的提示。在小程序项目根目录里边的app.json可以指定request的超时时间。

代码清单4-10 app.json指定wx.requset超时时间为3000毫秒

{

  "networkTimeout": {

    "request": 3000

  }

}

5.4.5.2. 请求前后的状态处理

大部分场景可能是这样的,用户点击一个按钮,界面出现“加载中…”的Loading界面,然后发送一个请求到后台,后台返回成功直接进入下一个业务逻辑处理,后台返回失败或者网络异常等情况则显示一个“系统错误”的Toast,同时一开始的Loading界面会消失。我们给出一个常见的wx.request的示例代码,如下所示。

代码清单4-11 wx.request常见的示例代码

var hasClick = false;

Page({

  tap: function() {

    if (hasClick) {

      return

    }

    hasClick = true

    wx.showLoading()



    wx.request({

      url: 'https://test.com/getinfo',

      method: 'POST',

      header: { 'content-type':'application/json' },

      data: { },

      success: function (res) {

        if (res.statusCode === 200) {

          console.log(res.data)// 服务器回包内容

        }

      },

      fail: function (res) {

        wx.showToast({ title: '系统错误' })

      },

      complete: function (res) {

        wx.hideLoading()

        hasClick = false

      }

    })

  }

})

为了防止用户极快速度触发两次tap回调,我们还加了一个hasClick的“锁”,在开始请求前检查是否已经发起过请求,如果没有才发起这次请求,等到请求返回之后再把锁的状态恢复回去。

5.4.6. 排查异常的方法

在使用wx.request接口我们会经常遇到无法发起请求或者服务器无法收到请求的情况,我们罗列排查这个问题的一般方法:

  1. 检查手机网络状态以及wifi连接点是否工作正常。
  2. 检查小程序是否为开发版或者体验版,因为开发版和体验版的小程序不会校验域名。
  3. 检查对应请求的HTTPS证书是否有效,同时TLS的版本必须支持1.2及以上版本,可以在开发者工具的console面板输入showRequestInfo()查看相关信息。
  4. 域名不要使用IP地址或者localhost,并且不能带端口号,同时域名需要经过ICP备案。
  5. 检查app.json配置的超时时间配置是否太短,超时时间太短会导致还没收到回报就触发fail回调。
  6. 检查发出去的请求是否302到其他域名的接口,这种302的情况会被视为请求别的域名接口导致无法发起请求。

5.5. 微信登录

已有的互联网产品在接入小程序会面临一些和登录态相关的问题:怎么获取微信登录态;怎么把微信帐号和自己的帐号进行打通。在这一节中,我们来介绍一下如何把微信登录应用到你的小程序中。

sequenceDiagram
    小程序->>微信服务器: 1.wx.login()获取到微信登录凭证code
    微信服务器->>小程序: 1.返回code
    小程序->>第三方服务器: 2.wx.request把code带到自己服务器
    第三方服务器->>微信服务器: 3.通过code和其他信息换取用户ID
    微信服务器->>第三方服务器: 3.返回用户ID
    第三方服务器->>第三方服务器: 4.绑定微信用户ID和自己业务用户ID
    第三方服务器->>第三方服务器: 5.生成自己业务登录凭证SessionID
    第三方服务器->>小程序: 6.返回业务登录凭证SessionID
    小程序->>第三方服务器: 7.下一次wx.request带上SessionID

5.5.1. 获取微信登录凭证code

首先说到登录,我们可能很正常地想到一个做法:通过wx.login直接拿到微信用户的id编号,再把这个id传到自己的后台,从而知道是哪个微信用户在使用我的服务。而我们上述微信登录的流程中并不是通过wx.login直接获取微信用户的id,那直接获取微信用户id的做法有什么问题呢? 假设现在我们有个接口,通过wx.request请求 https://test.com/getUserInfo?id=1 拉取到微信用户id为1在我们业务侧的个人信息,那么黑客就可以通过遍历所有的id,把整个业务侧的个人信息数据全部拉走,如果我们还有其他接口也是依赖这样的方式去实现的话,那黑客就可以伪装成任意身份来操作任意账户下的数据,想想这给业务带来多大的安全风险。

为了避免这样的风险,wx.login是生成一个带有时效性的凭证,就像是一个会过期的临时身份证一样,在wx.login调用时,会先在微信后台生成一张临时的身份证,其有效时间仅为5分钟。然后把这个临时身份证返回给小程序方,这个临时的身份证我们把它称为微信登录凭证code。如果5分钟内小程序的后台不拿着这个临时身份证来微信后台服务器换取微信用户id的话,那么这个身份证就会被作废,需要再调用wx.login重新生成登录凭证。

由于这个临时身份证5分钟后会过期,如果黑客要冒充一个用户的话,那他就必须在5分钟内穷举所有的身份证id,然后去开发者服务器换取真实的用户身份。显然,黑客要付出非常大的成本才能获取到一个用户信息,同时,开发者服务器也可以通过一些技术手段检测到5分钟内频繁从某个ip发送过来的登录请求,从而拒绝掉这些请求。

5.5.2. 发送code到开发者服务器

在wx.login的success回调中拿到微信登录凭证,紧接着会通过wx.request把code传到开发者服务器,为了后续可以换取微信用户身份id。如果当前微信用户还没有绑定当前小程序业务的用户身份,那在这次请求应该顺便把用户输入的帐号密码一起传到后台,然后开发者服务器就可以校验账号密码之后再和微信用户id进行绑定,小程序端的示例代码如下所示。

代码清单4-12 wx.login获取code后

Page({
  tapLogin: function() {
    wx.login({
      success: function(res) {
        if (res.code) {
          wx.request({
            url: 'https://test.com/login',
            data: {
              username: 'zhangsan', // 用户输入的账号
              password: 'pwd123456', // 用户输入的密码
              code: res.code
            },
            success: function(res) {
              // 登录成功
              if (res.statusCode === 200) {
               console.log(res.data.sessionId)// 服务器回包内容
              }
            }
          })
        } else {
          console.log('获取用户登录态失败!' + res.errMsg)
        }
      }
    });
  }
})

5.5.3. 到微信服务器换取微信用户身份id

开发者的后台拿到了前边wx.login()所生成的微信登录凭证code,此时就可以拿这个code到微信服务器换取微信用户身份。微信服务器为了确保拿code过来换取身份信息的人就是刚刚对应的小程序开发者,到微信服务器的请求要同时带上AppId和AppSecret,这两个信息在小程序管理平台的开发设置界面可以看到,由此可以看出,AppId和AppSecret是微信鉴别开发者身份的重要信息,AppId是公开信息,泄露AppId不会带来安全风险,但是AppSecret是开发者的隐私数据不应该泄露,如果发现泄露需要到小程序管理平台进行重置AppSecret,而code在成功换取一次信息之后也会立即失效,即便凭证code生成时间还没过期。

wxcd018270336d47e3
2cfb9d4671391ee6d324de6f791eb80c

https://mp.weixin.qq.com/wxamp/wadevelopcode/sandbox?lang=zh_CN&token=663677773

开发者服务器和微信服务器通信也是通过HTTPS协议,微信服务器提供的接口地址是:

https://api.weixin.qq.com/sns/jscode2session?appid=<AppId>&secret=<AppSecret>&js_code=<code>&grant_type=authorization_code

URL的query部分的参数中 <AppId>, <AppSecret>, <code> 就是前文所提到的三个信息,请求参数合法的话,接口会返回以下字段。

字段描述
openid微信用户的唯一标识
session_key会话密钥
unionid用户在微信开放平台的唯一标识符。本字段在满足一定条件的情况下才返回。

我们暂时只要关注前两个字段即可,openid就是前文一直提到的微信用户id,可以用这个id来区分不同的微信用户。session_key则是微信服务器给开发者服务器颁发的身份凭证,开发者可以用session_key请求微信服务器其他接口来获取一些其他信息,由此可以看到,session_key不应该泄露或者下发到小程序前端。

可能我们会好奇为什么要设计session_key,如果我们每次都通过小程序前端wx.login()生成微信登录凭证code去微信服务器请求信息,步骤太多造成整体耗时比较严重,因此对于一个比较可信的服务端,给开发者服务器颁发一个时效性更长的会话密钥就显得很有必要了。session_key也存在过期时间,因为篇幅关系,我们不在此展开,可以参考小程序的官方文档关于session_key的相关介绍。

5.5.4. 绑定微信用户身份id和业务用户身份

在4.5.2节提到,业务侧用户还没绑定微信侧身份时,会让用户填写业务侧的用户名密码,这两个值会和微信登录凭证一起请求开发者服务器的登录接口,此时开发者后台通过校验用户名密码就拿到了业务侧的用户身份id,通过code到微信服务器获取微信侧的用户身份openid。微信会建议开发者把这两个信息的对应关系存起来,我们把这个对应关系称之为“绑定”。

有了这个绑定信息,小程序在下次需要用户登录的时候就可以不需要输入账号密码,因为通过wx.login()获取到code之后,可以拿到用户的微信身份openid,通过绑定信息就可以查出业务侧的用户身份id,这样静默授权的登录方式显得非常便捷。

5.5.5. 业务登录凭证SessionId

4.5.3节已经说到微信侧返回的session_key是开发者服务器和微信服务器的会话密钥,同样道理,开发者服务器和开发者的小程序应该也有会话密钥,在本书中我们就把它称之为SessionId。用户登录成功之后,开发者服务器需要生成会话密钥SessionId,在服务端保持SessionId对应的用户身份信息,同时把SessionId返回给小程序。小程序后续发起的请求中携带上SessionId,开发者服务器就可以通过服务器端的Session信息查询到当前登录用户的身份,这样我们就不需要每次都重新获取code,省去了很多通信消耗。我们在4.6.4还会提到如何利用本地数据缓存的能力把SessionId存储起来,以便在它还没过期的时候能重复利用,以提高通信的性能。

5.6. 本地数据缓存

本地数据缓存是小程序存储在当前设备上硬盘上的数据,本地数据缓存有非常多的用途,我们可以利用本地数据缓存来存储用户在小程序上产生的操作,在用户关闭小程序重新打开时可以恢复之前的状态。我们还可以利用本地缓存一些服务端非实时的数据提高小程序获取数据的速度,在特定的场景下可以提高页面的渲染速度,减少用户的等待时间。

5.6.1. 读写本地数据缓存

小程序提供了读写本地数据缓存的接口,通过wx.getStorage/wx.getStorageSync读取本地缓存,通过wx.setStorage/wx.setStorageSync写数据到缓存,其中Sync后缀的接口表示是同步接口[9],执行完毕之后会立马返回,示例代码和参数说明如下所示。

代码清单4-13 wx.getStorage/wx.getStorageSync读取本地数据缓存

wx.getStorage({

  key: 'key1',

  success: function(res) {

    // 异步接口在success回调才能拿到返回值

    var value1 = res.data

  },

  fail: function() {

    console.log('读取key1发生错误')

  }

})



try{

  // 同步接口立即返回值

  var value2 = wx.getStorageSync('key2')

}catch (e) {

  console.log('读取key2发生错误')

}

表4-4 wx.getStorage/wx.getStorageSync详细参数

参数名类型必填描述
keyString本地缓存中指定的 key
successFunction异步接口调用成功的回调函数,回调参数格式: {data: key对应的内容}
failFunction异步接口调用失败的回调函数
completeFunction异步接口调用结束的回调函数(调用成功、失败都会执行)

代码清单4-14 wx.setStorage/wx.setStorageSync写入本地数据缓存

// 异步接口在success/fail回调才知道写入成功与否

wx.setStorage({

  key:"key",

  data:"value1"

  success: function() {

    console.log('写入value1成功')

  },

  fail: function() {

    console.log('写入value1发生错误')

  }

})



try{

  // 同步接口立即写入

  wx.setStorageSync('key', 'value2')

  console.log('写入value2成功')

}catch (e) {

  console.log('写入value2发生错误')

}

表4-5 wx.setStorage/wx.setStorageSync详细参数

参数名类型必填描述
keyString本地缓存中指定的 key
dataObject/String需要存储的内容
successFunction异步接口调用成功的回调函数
failFunction异步接口调用失败的回调函数
completeFunction异步接口调用结束的回调函数(调用成功、失败都会执行)

5.6.2. 缓存限制和隔离

小程序宿主环境会管理不同小程序的数据缓存,不同小程序的本地缓存空间是分开的,每个小程序的缓存空间上限为10MB,如果当前缓存已经达到10MB,再通过wx.setStorage写入缓存会触发fail回调。

小程序的本地缓存不仅仅通过小程序这个维度来隔离空间,考虑到同一个设备可以登录不同微信用户,宿主环境还对不同用户的缓存进行了隔离,避免用户间的数据隐私泄露。

由于本地缓存是存放在当前设备,用户换设备之后无法从另一个设备读取到当前设备数据,因此用户的关键信息不建议只存在本地缓存,应该把数据放到服务器端进行持久化存储。

5.6.3. 利用本地缓存提前渲染界面

讨论一个需求:我们要实现了一个购物商城的小程序,首页是展示一堆商品的列表。一般的实现方法就是在页面onLoad回调之后通过wx.request向服务器发起一个请求去拉取首页的商品列表数据,等待wx.request的success回调之后把数据通过setData渲染到界面上,如下代码所示。

代码清单4-15 page.js拉取商品列表数据展示在界面上

Page({

  onLoad: function() {

    var that = this

    wx.request({

      url: 'https://test.com/getproductlist',

      success: function (res) {

        if (res.statusCode === 200) {

          that.setData({

            list: res.data.list

          })

        }

      }

    })

  }

})

设想一下当用户退出小程序再进来,界面仍然会有白屏现象,因为我们需要等待拉取商品列表的请求回来才能渲染商品列表。当然我们还可以再做一些体验上的优化,例如在发请求前,可能我们会在界面上显示一个Loading提示用户在加载中,但是并没有解决这个延迟渲染的现象,这个时候我们可以利用本地缓存来提前渲染界面。

我们在拉取商品列表后把列表存在本地缓存里,在onLoad发起请求前,先检查是否有缓存过列表,如果有的话直接渲染界面,然后等到wx.request的success回调之后再覆盖本地缓存重新渲染新的列表,如下代码所示。

代码清单4-16 page.js利用本地缓存提前渲染界面

Page({

  onLoad: function() {

    var that = this

    var list =wx.getStorageSync("list")



    if (list) { // 本地如果有缓存列表,提前渲染

      that.setData({

        list: list

      })

    }

    wx.request({

      url: 'https://test.com/getproductlist',

      success: function (res) {

        if (res.statusCode === 200) {

          list = res.data.list

          that.setData({ // 再次渲染列表

            list: list

          })

          wx.setStorageSync("list",list) // 覆盖缓存数据

        }

      }

    })

  }

})

这种做法可以让用户体验你的小程序时感觉加载非常快,但是你还要留意这个做法的缺点,如果小程序对渲染的数据实时性要求非常高的话,用户看到一个旧数据的界面会非常困惑。因此一般在对数据实时性/一致性要求不高的页面采用这个方法来做提前渲染,用以优化小程序体验。

5.6.4. 缓存用户登录态SessionId

在4.4节我们说到处理用户登录态的一般方法,通常用户在没有主动退出登录前,用户的登录态会一直保持一段时间https://developers.weixin.qq.com/ebook?action=get_post_info&docid=000a2c7c9f4a981b0086bd31e5b40a#_ftn10,就无需用户频繁地输入账号密码。如果我们把SessionId记录在Javascript中某个内存变量,当用户关闭小程序再进来小程序时,之前内存的SessionId已经丢失,此时我们就需要利用本地缓存的能力来持久化存储SessionId。

代码清单4-17 利用本地缓存持久存储用户登录态SessionId

//page.js

var app = getApp()

Page({

  onLoad: function() {

    // 调用wx.login获取微信登录凭证

    wx.login({

      success: function(res) {

        // 拿到微信登录凭证之后去自己服务器换取自己的登录凭证

        wx.request({

          url: 'https://test.com/login',

          data: { code: res.code },

          success: function(res) {

            var data = res.data

            // 把 SessionId 和过期时间放在内存中的全局对象和本地缓存里边

            app.globalData.sessionId =data.sessionId

            wx.setStorageSync('SESSIONID',data.sessionId)



            // 假设登录态保持1天

            var expiredTime = +new Date() +1*24*60*60*1000

            app.globalData.expiredTime =expiredTime

            wx.setStorageSync('EXPIREDTIME',expiredTime)

          }

        })

      }

    })

  }

})

在重新打开小程序的时候,我们把上一次存储的SessionId内容取出来,恢复到内存。

代码清单4-18 利用本地缓存恢复用户登录态SessionId

//app.js

App({

  onLaunch: function(options) {

    var sessionId =wx.getStorageSync('SESSIONID')

    var expiredTime =wx.getStorageSync('EXPIREDTIME')

    var now = +new Date()



    if (now - expiredTime <=1*24*60*60*1000) {

      this.globalData.sessionId = sessionId

      this.globalData.expiredTime = expiredTime

    }

  },

  globalData: {

    sessionId: null,

    expiredTime: 0

  }

})

5.7. 设备能力

我们知道PC的程序和手机的程序有很大体验不一样的地方,尤其在信息输入这个体验上差别非常大,PC端可以有键盘、鼠标等等外设来辅助用户输入很多复杂的信息,而用户要在一个小小的手机屏幕上输入复杂信息效率是很低的。小程序的宿主环境提供了非常多的操作设备能力来帮助用户在特定场景下做高效的输入,例如:扫码、操控蓝牙等等能力。当然也有很多设备能力不是为了解决输入低效问题而设计的,它们更多的是解决用户侧一些体验问题,例如:获取设备网络状态;调整屏幕亮度等等,我们在这一章节中来介绍其中几种常见的操作设备能力的场景。

5.7.1. 利用微信扫码能力

为了让用户减少输入,我们可以把复杂的信息编码成一个二维码,利用宿主环境wx.scanCode这个API调起微信扫一扫,用户扫码之后,wx.scanCode的success回调会收到这个二维码所对应的字符串信息。

例如餐厅点餐的小程序,我们给餐厅中每个餐桌编号1-100号,把这个数字编码到二维码中[11],扫码获得编号之后,就可以知道是哪一桌点的菜,大大提高点餐体验和效率。

代码清单4-19 利用wx.scanCode获取二维码的数据

//page.js

Page({

  // 点击“扫码订餐”的按钮,触发tapScan回调

  tapScan: function() {

    // 调用wx.login获取微信登录凭证

    wx.scanCode({

      success: function(res) {

        var num = res.result // 获取到的num就是餐桌的编号

      }

    })

  }

})

还有很多场景可以结合微信扫码能力做到很好的体验,例如通过扫商品上的一维码做一个商品展示的小程序;通过扫共享单车上的二维码去开启单车。我们可以多思考如何利用这个扫码能力去替代一些繁琐的输入操作,让我们的小程序变得更加便捷。

5.7.2. 获取网络状态

我们知道手机连接到互联网有几种方式:Wifi、2G、3G、4G,包括很快到来的5G,每种方式的上传速度和下载速度差异很大,它们的计费方式的差异也导致用户在使用互联网服务的时候有不同的使用习惯。

Wifi相对于其他几种网络连接方式,其速度会更快。Wifi一般都是免费供用户使用,通过移动数据网络是需要根据使用流量进行计费的。我们考虑这样一个场景,小程序需要下载一些文档,然后通过小程序的能力去预览这个文档,这些文档可能文件体积比较大,对于某些用户来说,他们并不想耗费太多的数据流量去预览文档。考虑到这样的情况,我们可以通过小程序提供的获取网络状态的能力,做一些更友好的体验提示。

代码清单4-20 利用wx.getNetworkType获取网络状态

//page.js

Page({

  // 点击“预览文档”的按钮,触发tap回调

  tap: function() {

    wx.getNetworkType({

      success: function(res) {

        // networkType字段的有效值:

        // wifi/2g/3g/4g/unknown(Android下不常见的网络类型)/none(无网络)

        if (res.networkType == 'wifi') {

          // 从网络上下载pdf文档

          wx.downloadFile({

            url:'http://test.com/somefile.pdf',

            success: function (res) {

              // 下载成功之后进行预览文档

              wx.openDocument({

                filePath: res.tempFilePath

              })

            }

          })

        } else {

          wx.showToast({ title: '当前为非Wifi环境' })

        }

      }

    })

  }

})

某些情况下,我们的手机连接到网络的方式会动态变化,例如手机设备连接到一个信号不稳定的Wifi热点,导致手机会经常从Wifi切换到移动数据网络。小程序宿主环境也提供了一个可以动态监听网络状态变化的接口wx.onNetworkStatusChange,让开发者可以及时根据网络状况去调整小程序的体验,wx.onNetworkStatusChange这个接口的使用场景留给读者来思考。

6. tabBar

解决方法1:首先看看再tabBar中引用的页面是否已经注册,若该页面未注册,则应 及时注册页面 解决方法2:若页面已经注册,但还未显示,则应注意页面引用路径是否出错了,并纠正路径; 解决方法3:再以上两个地方均未出错的情况下,tabBar还是未显示,则应做以下的操作: 小程序的启动时的页面一定要出现再tabBar中,并且必须是tabBar中list属性的都一个元素,如此,才能正常地显示tabBar。

7. wx.request

  1. 需要在小程序上配置安全域名,之后才能在后端访问这个域名的内容。
  2. 需要在对应页面后面的ts代码文件中配置指定的 wx.request 方法
goToOpportunity() {
	wx.request({
		url: 'https://oa.rexen.vip/account/test2', //仅为示例,并非真实的接口地址
		data: {
		  x: '',
		  y: ''
		},
		header: {
		  'content-type': 'application/json' // 默认值
		},
		success (res) {
		  console.log(res.data)
		}
	  })
},