微信小程序基础
小程序
组成
小程序代码由 JSON(配置文件)、WXML(页面文件)、WXSS(样式文件)、JS(逻辑文件)组成
其中 WXML 类似 HTML,WXSS 类似 CSS
JSON
起到静态配置作用,无法在运行时更改从而更新变化
WXML
不带逻辑的 WXML 与 HTML 基本相同,要求严格闭合
WXML 中属性大小写敏感
除特殊属性外,属性均为 key="value" 格式
<!-- 注释 -->
<tag attr="val" ...>...</tag>
数据绑定
类似 vue 语法 使用双大括号包括变量
使用的变量需要在同级目录下的与当前 WXML 文件同名的 JS 文件中声明
<!-- 打印变量使用双大括号 -->
<tag>{{var}}</tag>
<!-- 变量属性需要使用双引号包括双大括号 -->
<tag attr="{{var}}"></tag>
<!-- 变量名大小写敏感 -->
<!-- 未定义 和 undefined 的变量不会输出 -->
逻辑语法
<!-- 三元运算 -->
<tag>{{true ? true : false}}</tag>
<!--
算数运算
a = 1
b = 2
c = 3
-->
<tag>{{a + b + c}}</tag>
条件逻辑
<tag wx:if="{{条件}}">如果</tag>
<tag wx:elif="{{条件}}">否则如果</tag>
<tag wx:else="{{条件}}">否则</tag>
列表渲染
<!--
数组当前项下标默认 index 当前项默认 item
wx:for-index, wx:for-item 可分别改变 index 和 item 的默认变量名
-->
<tag wx:for="{{array}}">
{{index}}
{{item}}
</tag>
<!--
表中项目会改变或有新项加入时,使用 wx:key 保持当前项的特征和状态
wx:key 有两种值
字符串,代表 item 的某个 property,该值需要保证是唯一的,无法动态改变
this 代表 item 需要保证是唯一的
-->
<tag wx:for="{{array}}" wx:key="{{item}}"></tag>
模板
<!-- 定义模板 -->
<template name="msgItem">
abcd = {{abcd}}
</template>
<!-- 使用模板 -->
<!-- is 可以动态决定模板 -->
<template is="{{true ? 'msgItem' : 'msgItem'}}" data="{{abcd}}">
...
</template>
导入导出
WXML 提供 import 和 include 两种引用方式
<!--
PATH 为定义了模板的文件路径
引入后可以使用对应文件中定义的模板
import 具有作用域概念
A => B => C
C 不会使用 A 定义的模板
-->
<import src="PATH"/>
<!--
可将目标文件中除 <template/> <wxs/> 外的所有代码引入
相当于拷贝到 include 位置
-->
<include src="PATH"/>
共同属性
属性名 | 类型 | 描述 | 备注 |
---|---|---|---|
id | String | 唯一标识 | 页面唯一 |
class | String | 样式类 | 对应WXSS中定义的样式类 |
style | String | 内联样式 | 可动态设置 |
hidden | Boolean | 是否显示 | 默认显示 |
data-* | Any | 自定义属性 | 触发事件时传入事件处理函数 |
bind*/catch* | EventHandler | 组件事件 |
WXSS
与 CSS 类似,但有所不同
根目录中 app.wxss 为 项目公共样式,会被注入所有页面
尺寸
使用 rpx 尺寸会自动根据屏幕进行适配
引用
/** 使用 @import 'PATH' 导入 */
@import '/app.wxss';
/** 导入的 wxss 最终会被打包到目标文件中 */
内联样式
与 WEB 开发一致
支持动态更改样式
<tag style="color: {{color}}; font-size: {{size}}"></tag>
选择器
类型 | 选择器 | 样例 | 描述 |
---|---|---|---|
类选择器 | .class | .info | 选取所有class='info'的元素 |
id选择器 | #id | #container | 选取id='container'的元素 |
元素选择器 | tag | view | 选取所有view元素 |
伪元素选择器 | ::after | view::after | 在 view 后 插入内容 |
伪元素选择器 | ::before | view::before | 在 view 前 插入内容 |
权重
!important
无限大style=""
1000#id
100.class
10tag
1
权重越高,样式越优先,优先级相同时,后设置覆盖前设置
JS
小程序IDE提供语法转换工具将 ES6 转换为 ES5 语法,从而进行兼容
严格按照加载顺序执行 JS
小程序执行入口文件是 app.js 会根据其中 require 模块顺序执行
app.js 执行完后,会按照 app.json 中定义的 pages 顺序执行
模块化
// a.js
// 使用 module.exports 导出需要导出的内容
// 没有使用 module.exports 导出的内容无法通过 require 使用
module.exports = { abcd: 1 }
// b.js
const modules = require('a')
modules.abcd // 1
作用域
和 NodeJS 中相似
文件中声明的内容只在本文件有效,不同文件可声明同名内容,不会互相影响
全局变量
使用全局函数 getApp() 获取全局实例,并设置相关值
app.js 中 App({}) 中定义的就是全局变量
逻辑和渲染
// exp.wxml
<view>{{msg}}</view>
// exp.js
Page({
onLoad: () => {
this.setData({msg: 'awd'})
}
})
- 渲染层和数据相关
- 逻辑层负责生产处理数据
- 逻辑层通过 Page 实例的 setData 方法传递数据到渲染层
数据驱动
随着界面的复杂,需要维护的变量越多,同时需要处理交互事件,整个程序越来越复杂
通常,界面视图和变量状态是相关联的,数据驱动就是这样的一个处理方法,即,状态变更时,视图也能自动变更
程序和页面
程序构造器 APP()
必须写在项目根目录下的 app.js 文件中
App实例是单例对象,在其他 js文件中使用 getApp() 获取
App构造函数接收一个对象作为参数
App构造函数的参数
参数 | 类型 | 描述 |
---|---|---|
onLaunch | Function | 小程序初始化后触发,全局只触发一次 |
onShow | Function | 小程序启动,或从后台进入前台显示触发 |
onHide | Function | 从前台进入后台触发 |
onError | Function | 发生错误或API调用失败触发,会带上错误信息 |
任意字段 | Any | 任意全局变量,App实例中使用 this 访问 |
程序生命周期和打开场景
生命周期
初次打开小程序时,由微信初始化宿主环境并从网络和缓存取出程序代码包注入
初始化后,微信会给 App 实例派发 onLaunch 事件
进入小程序后,关闭或 home 离开,小程序并没有被直接销毁,称这种情况为进入后台状态,触发 onHide 事件
再次回到微信或再次打开小程序,称这种情况为进入前台,触发 onShow 事件
App 的生命周期由微信根据用户操作主动触发,为避免程序混乱,不应该在其他代码里主动调用生命周期函数
打开场景
打开小程序有很多途径,根据不同途径,有时需要进行不同的业务处理,所以微信会将打开方式传递给 onLaunch 和 onShow 的调用参数 options
onLaunch、onShow 参数 options 项
字段 | 类型 | 描述 |
---|---|---|
path | String | 打开小程序的页面路径 |
query | Object | 打开小程序的页面参数 |
scene | Number | 打开场景值 |
shareTicket | String | shareTicket |
referrerInfo | Object | 从另一个小程序或公众号或App打开时返回 |
referrInfo.appId | String | 来源小程序,公众号,App的Appid |
referrInfo | Object | 来源小程序传递的数据,scene=1037,1038时支持 |
支持返回 referrerInfo.appId 的场景值
场景值 | 场景 | AppID信息 |
---|---|---|
1020 | 公众号 profile | 页相关小程序列表,来源公众号AppID |
1035 | 公众号自定义菜单 | 来源公众号AppID |
1036 | App 分享信息卡片 | 来源应用AppID |
1037 | 小程序打开小程序 | 来源小程序AppID |
1038 | 从另一个小程序返回 | 来源小程序AppID |
1043 | 公众号模板信息 | 来源公众号AppID |
小程序全局数据
App 实例是单例的,因此不同页面可通过 App 实例进行共享数据
所有页面的脚本逻辑都是在一个 JsCore 线程中运行的,页面使用 setTimeOut 或 setInterval 后进行跳转时,定时器不会被清除,需要手动清除
页面
一个页面由界面,配置,逻辑三部分组成
-
界面由 WXML 和 WXSS 描述
-
配置由 JSON 进行描述
-
逻辑由 JS 负责
一个页面的文件需要存放与同一目录下,其中 WXML 和 JS 必须存在, JSON 和 WXSS 可选
页面路径需要在小程序根目录 app.json 中 pages 字段声明,否则不会被渲染注册
页面路径需要去除后缀,同时,pages字段第一个页面路径为小程序首页
页面构造器 Page()
Page() 构造器用于注册一个小程序页面,在页面脚本文件中调用,同样接收一个 Object 参数,data 属性为页面初始数据,on开头的函数为生命周期和事件处理函数
Page 构造器参数
参数属性 | 类型 | 描述 |
---|---|---|
data | Object | 页面的初始数据 |
onLoad | Function | 生命周期函数--监听页面加载,触发时机早于onShow和onReady |
onReady | Function | 生命周期函数--监听页面初次渲染完成 |
onShow | Function | 生命周期函数--监听页面显示,触发事件早于onReady |
onHide | Function | 生命周期函数--监听页面隐藏 |
onUnload | Function | 生命周期函数--监听页面卸载 |
onPullDownRefresh | Function | 页面相关事件处理函数--监听用户下拉动作 |
onReachBottom | Function | 页面上拉触底事件的处理函数 |
onShareAppMessage | Function | 用户点击右上角转发 |
onPageScroll | Function | 页面滚动触发事件的处理函数 |
其他 | Any | 可以添加任意的函数或数据,在Page实例的其他函数中用 this 可以访问 |
生命周期
页面初次加载时,触发 onLoad 事件,在页面没被销毁前只触发一次
页面显示之后,onShow 事件触发,从别的页面返回时,当前页触发 onShow 事件
页面初次渲染完毕 onReady 事件触发,在页面没被销毁前只触发一次,onReady 触发后,表示页面准备完成,逻辑层可以与视图层交互
事件触发顺序:onLoad => onShow => onReady
页面不可见时,触发 onHide,在使用 wx.navigateTo 切换页面,底部 tab 切换时触发
当前页使用 wx.redirectTo 或 wx.navigateBack 返回其它页时,当前页面会被微信回收销毁,此时 onUnload 触发
页面参数 query
假设需要实现一个购物商场小程序,需要做一个商品列表页和商品详情页,点击商品列表商品会跳转到详情页,不可能去为每个商品实现单独的详情页,只需实现一个模板,在列表页打开详情页时将商品id传递,详情页通过 onLoad 回调参数 options 就能拿到商品 id,从而渲染对应商品详情界面
// list
// 小程序的打开路径与网页 URL 类似,在路径后使用英文 ? 分割路径和参数
// 参数为 key=val 形式,多个参数以 & 分割
wx.navigateTo({url: 'detail?id=123'})
// detail
Page({
onLoad: {id} => console.log(id)
})
页面数据
WXML 可通过数据绑定语法绑定自逻辑层传递的数据,这些数据来自于 Page 构造器的 data 字段对象,data 参数是页面初次渲染时的数据
宿主环境提供的 Page 实例原型中有 setData 函数,可以在 Page 实例下调用 this.setData 将数据传递给渲染层,从而更新界面
小程序的逻辑层和渲染层分别在两个线程运行,因此 setData 传递数据实际上是一个异步的过程,所以 setData 第二个参数是一个回调函数,在此次 setData 渲染完毕后触发
// setData 一般调用格式是 setData(data, callback)
// data 是一个 object 对象
// setData({a: 1}, () => console.log('更新a'))
Page({
data: {
a: 1
},
onLoad: options => {
this.setData({a: 2}, () => console.log('更新a'))
}
})
需要注意的地方
- 直接修改 this.data 而不调用 this.setData 无法改变页面状态,还会造成数据不一致
- setData 需要两个线程的一些通信消耗,为了提高性能,每次设置的数据不应超过1024KB
- 不要将 data 任意一项值设为 undefined ,否则可能会引起 bug
页面用户行为
-
下拉刷新 onPullDownRefresh
监听用户下拉刷新事件,需要配置全局 app.json 或 页面 page.json 设置 enablePullDownRefresh 为 true
-
上拉触底 onReachBottom
监听用户上拉触底事件,需配置全局 app.json 或 页面 page.json 设置触发距离 onReachBottomDistance 在触发距离内滑动期间,仅触发一次
-
页面滚动 onPageScroll
监听用户滑动页面时间,参数为 object,包含 scrollTop 字段,表示页面在垂直方向已经滚动的距离 (单位 px)
-
用户转发 onShareAppMessage
只有定义了此事件处理函数,才会显示转发按钮,此事件需要返回一个 object,包含 title 和 path 两个字段,用于自定义转发内容
页面跳转和路由
一个小程序具有多个页面,可通过 wx.navigateTo 推入一个新页面,目前小程序宿主环境限制页面栈最大层级为 10 层,达到限制后无法推入新页面
使用 wx.navigateTo({url: 'PATH'})
可以将 PATH 对应页面推入当前页面栈(入栈)
使用 wx.navigateBack()
可以退出当前页面栈最顶上页面 (出栈)
使用 wx.redirectTo({url: 'PATH'})
可以替换当前页面为 PATH
页面栈到达十层后,往往使用 redirectTo API 进行页面跳转
小程序 tabbar
小程序提供了原生的 Tabbar 支持,可以在 app.json 中声明字段 tabBar 定义 Tabbar 页
可以使用 wx.switchTab({url: "PATH"})
清空原先页面栈(除已声明为 Tabbar 页外的其它页面都会被销毁)然和切换到 PATH 所在 tab 页,如果点击 tabbar 回到 Tabbar 页面,则 Tabbar 不会触发 onLoad, 因为 Tabbar 页面未被销毁
wx.navigateTo
和 wx.redirectTo
只能打开非 TabBar 页面,wx.switchTab
只能打开 Tabbar 页面
还可以使用 wx.reLaunch({url: 'PATH'})
重启小程序,并打开 PATH
页面路由触发方式及页面生命周期函数的对应关系
路由方式 | 触发时机 | 路由前页面生命周期 | 路由后页面生命周期 |
---|---|---|---|
初始化 | 小程序打开的第一个页面 | onLoad, onShow | |
打开新页面 调用 | API wx.navigateTo | onHide | onLoad, onShow |
页面重定向 调用 | API wx.redirectTo | onUnload | onLoad, onShow |
页面返回 调用 | API wx.navigateBack | onUnload | onShow |
Tab | 切换 调用 API wx.switchTab | 请参考表3-6 | 请参考表3-6 |
重启动 | 调用 API wx.reLaunch | onUnload | onLoad, onShow |
当前页面 | 路由后页面 | 触发的生命周期(按顺序) |
---|---|---|
A | A | 无 |
A | B | A.onHide(), B.onLoad(), B.onShow() |
A | B(再次打开) | A.onHide(), B.onShow() |
C | A | C.onUnload(), A.onShow() |
C | B | C.onUnload(), B.onLoad(), B.onShow() |
D | B | D.onUnload(), C.onUnload(), B.onLoad(), B.onShow() |
D(从转发进入) | A | D.onUnload(), A.onLoad(), A.onShow() |
D(从转发进入) | B | D.onUnload(), B.onLoad(), B.onShow() |
组件
一个小程序页面可以分解成多个部分组成,组件就是小程序的基本组成单元,小程序的宿主环境提供了一系列组件
组件是在 WXML 模板文件中声明使用的,小程序使用标签名引用一个组件,通常包含开始标签和结束标签,该标签的属性用来描述该组件
注意
所有组件名和属性都是小写,多个单次以英文横杠 "-" 进行连接
对于一些容器,其内容可以声明在其开始和结束标签之间
组件共有属性
属性名 | 类型 | 描述 | 其他说明 |
---|---|---|---|
id | String | 组件的唯一标示 | 保持整个页面唯一 |
class | String | 组件的样式类 | 在对应的WXSS中定义的样式类 |
style | String | 组件的内联样式 | 可以通过数据绑定进行动态设置的内联样式 |
hidden | Boolean | 组件是否显示 | 所有组件默认显示 |
data-* | Any | 自定义属性 | 组件上触发的事件时,会发送给事件处理函数 |
bind / catch | EventHandler | 事件 | 绑定事件处理函数,详见 事件 |
API
宿主环境提供了丰富的 API,方便调起微信提供的功能
wx 对象
是小程序宿主环境所提供的全局对象,几乎所有的小程序的 API 都挂载在 wx 对象下 (除 Page/App 等特殊的构造器)
一般调用 API 约定
- wx.on 开头的 API 是监听某个时间发生的 API 接口,接受一个 Callback 函数作为参数,当该事件触发,会调用 Callback
- 如未特殊约定,则多数 API 接口为异步接口,都接受一个 Object 参数
- API 的 Object 参数一般有 success (成功)、fail(失败)、complete(总是)三个回调接收调用结果
- wx.get*开头的是获取宿主环境数据的接口
- wx.set*开头的是设置宿主环境数据的接口
// 通过 wx.request 发起的网络请求
wx.request({
url: '/',
data: (),
header: { 'content-type': 'application/json' },
success: res => console.log('成功获取到数据后执行'),
fail: () => console.log('发生网络错误时执行'),
complete: () => console.log('总是触发')
})
注意
部分 API 会拉起微信原生界面,此时会触发 Page 的 onHide 方法,当用户回到小程序时,会触发 Page 的 onShow 方法
事件
UI 界面的程序需要和用户互动,用户可能会点击某个按钮,长按某个区域,这些反馈应进行处理,并呈现给用户
有时行为反馈不一定是用户主动触发,如播放视频,进度条会一直变化,也应该进行处理
事件是通过 bindtap 这个属性绑定在组件上的,同时需要在当前页面的 Page 构造器中定义对应的事件处理函数,用户点击该区域时,达到触发条件生成事件,该事件处理函数会被执行,同时会被传入一个事件对象 event
事件类型和事件对象
常见的事件类型
除以下事件外,如未特殊声明,事件都是非冒泡事件
类型 | 触发条件 |
---|---|
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 动画完成时触发 |
事件对象属性
属性 | 类型 | 说明 |
---|---|---|
type | String | 事件类型 |
timeStamp | Integer | 页面打开到触发事件所经过的毫秒数 |
target | Object | 触发事件的组件的一些属性值集合 |
currentTarget | Object | 当前组件的一些属性值集合 |
detail | Object | 额外的信息 |
touches | Array | 触摸事件,当前停留在屏幕中的触摸点信息的数组 |
changedTouches | Array | 触摸事件,当前变化的触摸点信息的数组 |
注意
target 和 currentTarget 的区别为,currentTarget 为当前事件绑定的组件,target 是触发事件的组件
target 和 currentTarget 对象详细参数
属性 | 类型 | 说明 |
---|---|---|
id | String | 当前组件id |
tagName | String | 当前组件类型 |
dataset | Object | 当前组件上由 data- 开头的自定义属性集合 |
touch 和 changedTouches 对象详细参数
属性 | 类型 | 说明 |
---|---|---|
identifier | Number | 触摸点的标识符 |
pageX、pageY | Number | 距离文档左上角的距离,文档左上角为原点,横向为 X,纵向为 Y |
clientX、clientY | Number | 距离页面可显示区域(屏幕除去导航条)左上角距离,横向为 X,纵向为 Y |
事件绑定和冒泡捕获
事件绑定和组件属性的写法一致,均为 key="val"
形式
- key 以 bind 或 catch 开头,然后跟随事件类型,如 bindtap,catchtouchstart
- 基础库1.5.0起,bind 或 catch 可跟随冒号,但含义不变,如 bind:tap,catch:touchstart,同时 bind 和 catch 前可加上 capture- 表示捕获阶段
- val 是一个字符串,需要在对应的 Page 构造器中定义同名函数,否则触发事件时会报错
- bind 和 capture-bind 分别代表事件的冒泡阶段和捕获阶段
事件捕获是自上而下的,直到达到事件源头
事件冒泡是自下而上的,直到达到绑定处理函数的组件
注意
bind 事件绑定不会阻止冒泡事件向上冒泡,catch 事件绑定会阻止冒泡
兼容
// 获取宿主环境信息
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 是否存在兼容
if (wx.openBluetoothAdapter) {
wx.openBluetoothAdapter()
} else {
// 如果希望用户在最新版本的客户端上体验您的小程序,可以这样子提示
wx.showModal({
title: '提示',
content: '当前微信版本过低,无法使用该功能,请升级到最新微信版本后重试。'
})
}
// wx.canIUse
// 用于判断接口或组件在当前宿主环境是否可用
// 参数格式为
// ${API}.${method}.${param}.${options}
// 或
// ${component}.${attribute}.${option}
// ${API} 代表 API 名称
// ${method} 代表调用方法
// ${param} 代表参数或返回值
// ${options} 代表参数可选项
// ${component} 代表组件名字
// ${attribute} 代表组件属性
// ${option} 代表组件属性的可选项
// 调用
// 判断接口及其参数在宿主环境是否可用
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')
开发应用
交互反馈
触摸时反馈
小程序提供了 hover-class
属性,触摸组件时,会为组件添加上设置的类名的样式
按钮可以设置 loading
属性,接收一个布尔值,为 true 时,在按钮文字前会出现一个 Loading 动画,否则不显示
Toast 和模态框
// 显示 Toast
wx.showToast({
title: '成功', // Toast 内容文本
icon: 'success', // Toast 图标
duration: 1500 // Toast 显示时间 单位毫秒
})
// 隐藏 Toast
wx.hideToast()
// 显示模态框
wx.showModal({
title: '模态框标题',
content: '模态框内容文本',
confirmText: '主操作,一般为确认操作',
cancelText: '次要操作,一般为取消操作',
// 执行操作后
success: res => {
if (res.confirm) console.log('确认')
else if (res.cancel) console.log('取消')
}
})
界面滚动
往往手机屏幕是无法承载所有信息的,内容区域会超出屏幕,用户可以通过滑动屏幕查看下一屏幕的内容,为了让用户可以快速刷新当前界面,一般在小程序里可以通过下拉整个界面触发
宿主环境提供了统一的下拉刷新交互,只需要通过配置就能开启当前页面的下拉刷新,用户触发下拉刷新操作时,Page 构造器的 onPullDownRefresh 回调就会被触发,此时就可以重新获取数据进行刷新
// page.json
// 开启下拉刷新
{ "enablePullDownRefresh": true }
// page.js
Page({
onPullDownRefresh: () => {
// 触发下拉刷新操作
// 拉取新数据重新渲染
// 停止当前页面的下拉刷新
wx.stopPullDownRefresh()
}
})
在滑动一些列表时,滚动到列表底部时,就会加载下一页列表进行渲染,这个操作被称为上拉触底,宿主环境提供了上拉触底的配置和操作触发的回调
// page.json
// 界面的下方距离页面底部距离 小于 onReachBottomDistance 像素时触发回调 onReachottom
{ "onReachBottomDistance": 100 }
// page.js
Page({
onReachBottom: () => {
// 当界面下方距离页面底部的距离 小于 100 像素时触发
}
})
发起 HTTPS 通信
小程序需要经常往服务器传递数据或拉取数据,此时就能使用 wx.request 这个API
wx.request 详细参数
参数名 | 类型 | 必填 | 默认值 | 描述 |
---|---|---|---|---|
url | String | 是 | 开发者服务器接口地址 | |
data | Object/String | 否 | 请求的参数 | |
header | Object | 否 | 设置请求的 header,header 中不能设置 Referer,默认header['content-type'] = 'application/json' | |
method | String | 否 | GET | (需大写)有效值:OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT |
dataType | String | 否 | json | 回包的内容格式,如果设为json,会尝试对返回的数据做一次 JSON解析 |
success | Function | 否 | 收到开发者服务成功返回的回调函数,其参数是一个Object,见表4-2。 | |
fail | Function | 否 | 接口调用失败的回调函数 | |
complete | Function | 否 | 接口调用结束的回调函数(调用成功、失败都会执行) |
服务器接口
url 参数是当前发起请求的服务器接口地址,小程序宿主环境要求 request 发起的网络请求必须是 https 网络协议请求,因此开发者服务器必须提供 https 服务的接口,同时,为保证小程序不乱用任意域名的服务,wx.request 请求的域名需要在小程序管理平台进行配置,如果小程序正式版使用了未配置的域名,则控制台会用相应报错
开发阶段,为方便开发测试,允许使用 wx.request 请求任意域名
如果在新版本需要支持某些特性而需要修改返回的数据格式,而部分用户依旧在使用旧版本,则接口参数和返回字段至少需要向前兼容一个版本,即,不要删除旧字段,而是重新添加,这样旧版本接收的数据格式依旧是能正常工作的
请求参数
通过 wx.request 这个 API,有两种方法将数据传递到服务器,通过 url 上的参数和 data 参数
// 通过 url 传参
wx.request({
url: 'https://test.com/getInfo?id=1&version=1.0.0',
success: res => console.log('返回信息')
})
// 通过 data 传参
wx.request({
url: 'https://test.com/getInfo',
data: {
id: 1,
version: '1.0.0'
},
success: res => console.log('返回信息')
})
两种方法在 HTTP GET 请求中表现几乎一样,但是需要注意 URL 是有长度限制的,最大长度为 1024 字节,同时 URL 上的参数需要拼接到字符串中,参数的值还需要进行一次 URLEncode,向服务器发送的数据超过 1024 字节时,须采用 HTTP POST 形式,此时需要使用 data 参数,基于此,一般建议传递数据时采用 data 参数传递
// POST 请求
// 不是所有请求都是按照 key = val 形式传递的
// 有时需要传递复杂数据,使用 json 格式会更合适
wx.request({
url: 'https://test.com/post',
method: 'POST',
header: {
'content-type': 'application/json'
},
data: {
a: {
b: [1,2,3],
c: { d: "test" }
}
},
success: res => console.log(res)
})
收到回包
通过 wx.request 发送请求后,服务器处理请求并返回 HTTP 包,小程序收到回包后触发 success 回调,同时会带上一个 Object 信息
wx.request 的 success 返回参数
参数名 | 类型 | 描述 |
---|---|---|
data | Object / String | 开发者服务器返回的数据 |
statusCode | Number | 开发者服务器返回的 HTTP 状态码 |
header | Object | 开发者服务器返回的 HTTP 响应头 |
只要成功接收到服务器返回值,无论 HTTP 状态码是多少,都将会执行 success 回调,因此需要开发者自己对返回的状态码进行判断
success 回调参数 data 字段类型是根据 header['content-type'] 决定的,其默认值为 'application/json',触发 success 回调前,小程序宿主环境会对 data 字段的值进行 json 解析,解析成功,则 data 值会被设置为 解析后的 object 对象,其它情况下 data 都是 string 类型,值为 HTTP 回包包体
一般技巧
设置超时
// app.json
// 小程序默认 request 超时时间为 60 秒
// 一般不需要那么长
{
// 设置网络超时时间
"networkTimeout": {
// 请求超时时间为 3000 毫秒(3秒)
"request": 3000
}
}
请求前后的状态处理
// 场景:用户会点击一次按钮,界面出现 加载中... 的 Loading 界面
// 然后发送请求到后台,后台返回成功进入下个 业务逻辑处理
// 返回失败或网络异常等显示一个 系统错误 的 Toast
// 同时 Loading 界面消失
// 锁,用于检测用户是否进行了点击
// 防止用户进行多次点击操作并同时发送大量请求
// 默认用户没有进行点击
let hasClick = false
Page({
// 点击处理函数
tap: () => {
// 首先判断用户是否进行了点击
// 用户已经进行了点击操作则停止方法
if (hasCLick) return
// 否则变更 锁 状态
hasClick = true
// 显示 Loading 界面
wx.showLoading()
// 开始发送请求
wx.request({
url: 'https://test.com/post',
method: 'POST',
header: { 'content-type': 'application/json' },
data: {},
// 成功时
success: res => {
if (res.statusCode === 200) console.log(res.data)
},
// 失败时
fail: res => wx.showToast({title: '系统错误'}),
// 不论成功失败总是执行
complete: res => {
// 结束 Loading 界面
wx.hideLoading()
// 变更 锁 状态
hasClick = false
}
})
}
})
排查错误
使用 wx.request 接口时会遇到无法请求服务器或服务器无法接收请求的情况,一般的排查方法如下:
- 检查手机网络 和 wifi 是否正常工作
- 检查小程序是否为 开发版 或 体验版,开发版和体验版的小程序不会校验域名
- 检查对应请求的 HTTPS 证书是否有效,同时 TLS 版本必须支持 1.2 以上,可在 开发者工具控制台面板输入 showRequestInfo() 查看相关信息
- 域名不要用 ip 地址 或 localhost,且不允许带端口号,同时域名需要经过 ICP 备案
- 检查 app.json 配置的超时时间是否过短导致没接收到回包就触发 fail 回调
- 加成发出的请求是否 302 到其它域名接口,此情况会被视为请求别的域接口导致无法请求
微信登录
流程
- wx.login() 从 微信服务器 获取到微信登录凭证 code 给 小程序
- wx.request 吧 code 传递给 第三方服务器
- 通过 code 和 其它信息 从 微信服务器 获取 用户id 给 第三方服务器
- 第三方服务器绑定微信用户id和自己的业务用户id
- 生成自己业务登录凭证的 sessionId
- 将 sessionId 传递给小程序
- 下一次 wx.request 将会带上 sessionId
获取微信登录凭证 code
wx.login 不是直接获取 微信用户id,因为如果可以直接获取,假设接口为 https://test.com/getUserInfo?userId=1
,作用为获取微信用户id为1的业务用户信息, 那么黑客就可以通过遍历所有id,将整个业务的用户信息获取
如果其它接口也是这样的实现方式,则黑客就能够伪装成任意身份操作任意账号下的数据,会带来极大的安全风险
为避免此风险,wx.login 作用是生成一个带有时效性的凭证,就像一个会过期的临时身份证一般
在 wx.login 调用时,会先在微信后台生成一张有效时间仅五分钟的临时的身份证,然后这个临时身份证将返回给小程序,这个临时身份证称之为微信登录凭证code
假若五分钟内小程序后台不拿这个临时身份证来微信后台服务器换取微信用户id,则此凭证作废,需要重新获取
由于五分钟后凭证会过期,那么黑客就需要在五分钟内穷举所有的用户id,然后去开发者服务器获取用户信息,显然黑客需要付出高成本才能获取,同时,开发者服务器可以通过技术手段检测到五分钟内频繁从某个ip发送的请求从而拒绝
发送 code 到开发者服务器
通过 wx.login 的 success 回调拿到 微信登录凭证,接着通过 wx.request 将 凭证传递到开发者服务器,为了后续可以获取到微信用户id,如果当前微信用户没有绑定当前小程序的业务用户,则此次请求应将用户输入的账号密码一并传递给后台,然后开发者服务器就能检验账户密码之后再和微信用户id进行绑定
Page({
tapLogin: () => {
wx.login({
success: res => {
if (res.code) {
wx.request({
url: 'https://test.com/login',
data: {
username: '用户输入的账户',
password: '用户输入的密码',
code: res.code // 获取的登录凭证
},
success: res => {
// 登录成功
if (res.statusCode === 200) {
console.log(res.data.sessionId)
}
}
})
}
else {
console.log('获取用户登录信息失败', res.errMsg)
}
}
})
}
})
到微信服务器换取微信用户id
此时,开发者后台拿到了前边 wx.login 生成的微信登录凭证 code,就能拿这个 code 到微信服务器换取微信用户身份id
微信服务器为保证使用 code 获取 身份信息的就是刚刚对应的小程序开发者,需要在请求时同时带上 AppId 和 AppSecret
这两个信息在小程序管理平台开发设置界面可以看到,由此可见,AppId 和 AppSecret 是微信鉴别开发者身份的重要信息,AppId 是公开的,但 APPSecret 不应该公开,如泄漏则应到小程序管理平台进行重置
同时,code 在成功获取到一次信息后会立即失效,即使还在有效期间
开发者服务器和微信服务器通信也是经过 HTTPS 协议,微信提供的接口是:
https://api.weixin.qq.com/sns/jscode2session?appid=<CAppId>&secret=<AppSecret>&js_code=<code>&grant_type=authorization_code
接口中 query 部分参数 <AppId> <AppSecret> <code>
就是之前提到的三个信息,参数合法,则返回以下字段
字段 | 描述 |
---|---|
openid | 微信用户的唯一标识,通过此数据区分不同微信用户 |
session_key | 会话秘钥,开发者可以通过此数据请求微信服务器其它接口获取其它信息,不应该泄漏或下发到小程序前端 |
unionid | 用户在微信开放平台的唯一标识符,满足一定条件才返回 |
绑定微信用户和业务用户
业务用户未绑定微信身份时,会让用户填写业务侧的账户密码,这两个值会随微信登录凭证一起请求开发者服务器的登录接口
开发者后台就能通过检验账户密码获得业务侧用户id,通过 code 获取微信侧用户id,微信会建议开发者将这两个信息对应关系存储,这个对应关系称之为绑定
有了绑定信息,则小程序下次用户登录时可以不输入账户密码,通过 wx.login 获取 code 后拿到用户微信身份id,通过绑定信息获取业务侧用户id
业务登录凭证 SessionId
微信侧返回的 session_key 是开发者服务器和微信服务器的会话秘钥,同理,开发者服务器和开发者小程序也应有会话秘钥,称之为 SessionId
用户登录后,开发者服务器需要生成秘钥 SessionId,在服务端保持 SessionId 对应的用户身份信息,同时将 SessionId 返回给小程序
小程序后续发起的请求中带上 SessionId,开发者服务器就能通过服务器端的 SessionId 查询当前登录用户身份,这样就不用每次重新获取 code 省去了很多通信消耗
本地数据缓存
本地数据缓存是小程序存储在当前设备硬盘上的数据,本地缓存有很多用途,可以利用本地数据缓存存储用户在小程序上的操作,在用户关闭小程序重新打开时恢复之前的状态
还可以利用本地缓存一些服务端非实时的数据提高小程序获取数据的速度,在特定的场景下可以提高页面渲染速度,减少用户等待速度
读写本地缓存
小程序提供了读写本地数据缓存的接口,通过 wx.getStorage / wx.getStorageSync 读取本地缓存,通过 wx.setStorage / wx.setStorageSync 写入缓存, Sync 后缀的是同步接口(必须等待接口执行完毕才能进行下一步)
wx.getStorage / wx.getStorageSync 详细参数
参数名 | 类型 | 是否必填 | 描述 |
---|---|---|---|
key | String | 是 | 本地缓存中指定的key |
success | Function | 否 | 异步接口调用成功的回调函数,回调参数格式:{data: key所对应的数据} |
fail | Function | 否 | 异步接口调用失败的回调函数 |
complete | Function | 否 | 异步接口执行完毕后的回调函数,不论成功失败都会执行 |
wx.getStorage({
key: 'key1',
success: res => {
// 异步接口只能在 success 回调才能拿到返回值
const val = res.data
},
fail: () => console.log('读取key1发生错误')
})
try {
// 同步接口立刻返回值
const val = wx.getStorageSync('key2')
}
catch (e) console.log('读取key2发生错误')
wx.setStorage / wx.setStorageSync 详细参数
参数名 | 类型 | 是否必填 | 描述 |
---|---|---|---|
key | String | 是 | 本地缓存中指定的key |
data | Object / String | 是 | 需要缓存的内容 |
success | Function | 否 | 异步接口调用成功的回调函数 |
fail | Function | 否 | 异步接口调用失败的回调函数 |
complete | Function | 否 | 异步接口调用结束后的回调函数,不论成功或失败 |
// 异步接口在 success / fail 回调后才能知道写入成功与否
wx.setStorage({
key: 'key',
data: 'val',
success: () => console.log('写入成功'),
fail: () => console.log('写入失败')
})
try {
// 同步接口立即写入
wx.setStorageSync('key', val)
console.log('写入val成功')
}
catch (e) console.log('写入失败')
缓存限制
小程序宿主环境会管理不同小程序的数据缓存,不同小程序的本地缓存空间是分开的,每个小程序缓存空间上限为 10MB,如果缓存已满,则会触发 fail 回调
考虑到一个设备会登录不同用户,宿主环境还对不同用户的缓存进行了隔离,避免用户数据泄漏
本地缓存存放于当前设备,用户更换设备后无法读取当前设备缓存,因此用户关键信息不应该存储于本地,而是存储于服务器端进行持久化存储
利用本地缓存提前渲染页面
假设需要实现一个商城小程序,有一个商品列表功能,一般实现时通过页面 onLoad 回调后通过 wx.request 请求服务器获取商品列表数据,等待 success 回调后将数据通过 setData 渲染
这样会导致会有白屏现象,因为需要等待商品列表数据回来才能进行渲染,可以做些体验优化,如请求前显示一个 Loading, 但这不能解决问题
此时就可以使用本地缓存渲染界面,拉取商品列表后将列表存储于本地缓存中,在 onLoad 请求前检查是否有缓存,有就直接渲染,然后等待 success 回调重新覆盖缓存并重新渲染列表
Page({
onLoad: function () {
const that = this
const list = wx.getStorageSync('list')
// 获取到了本地缓存中的列表
if (list) that.setData({list: list})
// 请求列表
wx.request({
url: 'https://test.com/list',
success: res => {
if (res.statusCode === 200) {
list = res.data.list
// 重新渲染
that.setData({list: list})
// 覆盖缓存
wx.setStorageSync('list', list)
}
}
})
}
})
此方法可以让用户体验小程序时感觉加载快,但是需要注意,如果小程序对数据实时性要求高,则不应该使用此方法,此方法一般在对数据实时性或一致性要求不高的页面使用
缓存用户登录状态 SessionId
通常用户在没有主动退出登录前,用户的登录状态会一直保持一段时间,无需用户频繁输入账号密码
如果将 SessionId 存放在 js 的某个内存变量,则用户关闭小程序再进入时,之前的 SessionId 就会丢失,此时就可以使用本地缓存来持久化存储 SessionId
// page.js
const app = getApp()
Page({
onLoad: () => {
// 调用 wx.login 获取
wx.login({
success: res => {
// 获取到微信登录凭证后去自己服务器获取自己的登录凭证
wx.request({
url: 'https://test.com/login',
data: {code: res.code},
success: res => {
const data = res.data
// 将 SessionId 和过期时间放在内存中的全局对象和本地缓存中
app.globalData.sessionId = data.sessionId
wx.setStorageSync('sessinId', data.sessionId)
// 假设登录状态保存一天
// +号会将后面跟随的数据转换为数字
// 获取当前时间,转换为毫秒 加上一天时间的毫秒
const expiredTime = +new Date() + 1*24*60*60*1000
app.globalData.expiredTime = expiredTime
wx.setStorageSync('expiredtime', expiredtTime)
}
})
}
})
}
})
重新打开小程序时,将上次存储的 SessionId 取出恢复到内存
// app.js
App({
onLaunch: function (options) {
const sessionId = wx.getStorageSync('sessionId')
const expiredTime = wx.getStorageSync('expiredTime')
const now = +new Date()
// 当前时间的毫秒数 减去 存储的过期时间毫秒数 如果小于等于 一天的毫秒数
// 则证明 SessionId 未过期
// 将取出的 SessionId 存储到全局中
if (now - expiredTime <= 1 *24*60*60*1000) {
this.globalData.sessionId = sessionId
this.globalData.expiredTime = expiredTime
}
},
globalData: {
sessionId: null,
expiredTime: 0
}
})
设备能力
pc和手机端的程序有很多体验不一样的地方,尤其是输入信息这方面,pc有键盘鼠标这些外设,手机只有一个小小的屏幕,小程序宿主环境提供了很多设备操作能力来帮助用户在特定场景下做高效的输入,如扫码,操控蓝牙等,还有一些不是解决输入低效问题而是解决用户侧体验问题的,如获取设备网络状态,调整屏幕亮度等
利用微信扫码
为让用户减少输入,可以把复杂信息编码成一个二维码,利用宿主环境的 wx.scanCode 调起微信扫一扫,用户扫码后 wx.scanCode 的 success 回调会收到这个二维码对应的字符串信息
// page.js
Page({
tapScan: () => {
// 调起 wx.login 获取微信登录凭证
wx.scanCode({
success: res => {
// 获取的 data 就是二维码的数据
const data = res.data
}
})
}
})
获取网络状态
假设一个场景,小程序需要下载一些数据,这些数据可能会比较大,对于某些用户来说,并不想耗费流量去下载数据,因此,可以通过小程序提供的获取网络状态能力去做提示
// page.js
Page({
tap: () => {
wx.getNetworkType({
success: res => {
// networkType 字段值
// wifi / 2g / 3g / 4g / 5g / unknown(Android 下不常见) / none (无网络)
if (res.networkType === 'wifi') {
wx.downloadFile({
url: 'https://test.com/data',
success: res => {
wx.openDocument({
filePath: res.tempFilePath
})
}
})
}
else wx.showToast({ title: '当前为非 wifi 环境'})
}
})
}
})
某些情况下,网络会不稳定,手机会从 wifi 切换到数据网络,小程序宿主环境也提供了一个可以动态监听网络状态变化的接口 wx.onNetworkStatusChange
底层架构
双线程模型
小程序是基于双线程模型的,在此模型中,小程序的逻辑层和渲染层分开在不同的线程运行,这与传统Web单线程模型有很大的不同,这使得小程序架构上多了一些复杂度,也多了一些限制
技术选型
在对小程序的架构进行设计时的要求只有一个,就是 【快】,包括要渲染快,加载快等,当用户点开某个小程序时,我们期望体验到的是只有很短暂的加载界面,在一个过渡动画之后可以马上看到小程序的主界面
首先确定的是使用声明技术渲染小程序界面,这是和开发者的学习门槛息息相关的
一般而言,渲染界面的技术有三种:
- 用纯客户端原生技术进行渲染
- 用纯 Web 技术渲染
- 介于客户端原生技术和 Web 技术之间的,互相结合各自特点的技术(统称 Hybrid 技术)渲染
因为小程序的宿主是微信,所以不太可能用纯客户端原生技术编写小程序,如果这样,那么小程序代码就需要和微信代码一起编包,跟随微信发版本,此方式和开发节奏必然是不对的,因此,我们需要像 Web 技术一样,有一分随时可以更新的资源包放在云端,通过下载到本地,动态执行后渲染界面
如果使用纯 Web 技术渲染小程序,在一些复杂交互页面可能会面临性能问题,这是因为在 Web 技术中,UI 渲染和 JS 脚本执行都是在一个单线程中执行的,这就会导致一些逻辑任务抢占 UI 渲染的资源
以上两种方法结合的 Hybrid 技术在过去演化过数种技术方案,最终类似微信 JSSDK 的 Hybrid 技术被选择,即界面由成熟的 Web 技术渲染,辅以大量的接口提供丰富的客户端原生能力,同时,每个小程序页面都是用不同的 WebView 去渲染的,这样可以提供更好的交互体验,更贴近原生体验,也避免了单个 WebView 的任务过于繁重
管控和安全
基于 Web 技术渲染小程序是存在一些不可控因素和安全风险的,这是因为 Web 技术非常的灵活开放,可以利用 JS 脚本随意跳转网页或改变界面任意内容
为解决管控安全问题,必须阻止开发者使用浏览器提供的开放性接口,但是一个个禁用会导致进入一个攻防战,因为 JS 的灵活性和浏览器接口的丰富性,一个个禁用容易遗漏一些危险的接口,就算禁用了所有接口,或许下次浏览器内核更新而新增一个危险的接口,这是无法避免的
因此,为解决此问题,开发者被提供了一个沙箱环境来运行 JS 代码,此沙箱环境没有任何浏览器接口,只提供纯 JS 的解释执行环境,就像 HTML5 中 的 ServiceWorker、WebWoker 特性,这两者都是启用另一线程执行 JS
但是考虑到小程序是由多个 WebView 的架构,每个小程序页面都是不同 WebView 渲染后显示的,这个架构下不好使用某个 WebView 中的 ServiceWorker 去管理所有的小程序界面
得益于客户端系统有 JS 的解释环境(iOS是内置的 JavaScriptCore 框架,安卓是腾讯x5内核提供的 JsCore 环境),使得可以创建一个单独的线程去执行 Js,此环境下执行的都是有关小程序业务逻辑的代码,也就是此前提及的逻辑层
而界面渲染相关任务则全在 WebWiew 线程中执行,通过逻辑层去控制渲染界面,这一层就是所谓的渲染层
天生的延时
小程序是基于双线程模型的,这意味着任何数据传递都是线程间的通信,即都会存在一定的延时,不像传统的 Web 那样,界面需要更新时,通过调用接口 UI 就会同步渲染,在小程序当中,这一切都会变成异步
异步会使得各部分的运行时序会变得复杂,比如在渲染首屏时,逻辑层与渲染层同时开始初始化工作,但是渲染层需要有逻辑层的数据才能把界面渲染出,如果渲染层初始化工作较快,那就要等待逻辑层的指令才能进行下一步
因此,逻辑层与渲染层需要有一定的机制保证时序正确,而这一切工作由小程序处理,开发者只需要理解生命周期,以及控制合适的时机更新 UI 即可
除逻辑层与渲染层之间通信有延时外,各层与客户端原生交互同样是有延时的,以逻辑层为例,开发者的代码是跑在逻辑层线程上的,而客户端原生是跑在微信主线程(安卓上是线程)之上,所以注册给逻辑层有关客户端能力的接口,实际上也是根微信主线程之间的通信,同样意味着有延时,这就是为什么大部分接口都是异步的原因
组件系统
小程序的视图是在 WebView 中渲染的,搭建视图的方式自然就需要用到 HTML,但是如果直接提供了 HTML 能力,则前面为解决管控和安全所建立的双线程模型就成了摆设,开发者可以利用 A 标签跳转网页,也可以动态执行 JS 等
除管控与安全外,还有一些不足之处:
- 标签众多,增加理解成本
- 接口底层,不利于快速开发
- 能力有限,会限制小程序表现形式
因此,开发者被提供了一套组件框架 —— Exparser,基于此框架,内置了一套组件以覆盖小程序的基础功能,利于开发者快速搭建页面,并自定义和扩展组件
Exparser 框架
是微信小程序的组件组织框架,内置在小程序基础库中,为小程序的各种组件提供基础的支持,小程序内的所有组件(含内置组件和自定义组件)都有 Exparser 组织管理
Exparser 的组件模型与 WebComponents 标准中的 ShadowDOM 高度类似
Exparser 会维护整个页面的节点树相关信息,包括节点属性,事件绑定,相当于一个简化版的 Shadow DOM 实现
Exparser 的主要特点有:
- 基于 Shadow DOM 模型,模型上与 WebComponents 的 Shadow DOM 高度类似,但不依赖浏览器的原生支持,也没有其它依赖库,实现时,还针对性的增加了其它 API 一支持小程序组件编程
- 可在纯 JS 环境中运行,这意味着逻辑层也具有一定的组件树组织能力
- 高效轻量,性能表现良好,在组件实例极多的环境下表现尤其优异,同时代码尺寸也较小
小程序中,所有节点树相关操作都依赖于 Exparser,包括 WXML 到页面最终节点树的构建, CreateSelectorQuery 调用和自定义组件特性等
内置组件
小程序基于 Exparser 内置了一套组件,提供了视图容器类,表单类,导航类,媒体类,开放类等几十种组件,加以配合 WXSS,可以构建出任何界面,功能上也满足绝大部分需求
一般而言,将组件内置到小程序框架中的一个重要原则是:这个组件是基础的,即没有这个组件,小程序架构无法实现或实现不好某类功能
自定义组件
自定义组件是开发者可以自行扩展的组件,开发者可以将常用的节点树结构提取成自定义组件,实现代码复用
ShadowTree 概念
<!--
例子
页面节点树 Composed Tree
-->
<view>
<input-with-label>
<label>
TEXT
</label>
<input/>
</input-with-label>
</view>
<!--
将 input-with-label 抽象成一个组件
组件节点树 Shadow Tree
-->
<label><slot/></label>
<input/>
<!--
调用
在 Exparser 组件模型中,当前节点树和上个节点树会被拼接成 页面节点树
组件的节点树称为 ShadowTree 即 组件内部的实现
最终拼接的页面节点树被称为 ComposedTree 即 将页面所有组件节点树合成之后的树
在进行了这样的组件分离之后,整个页面节点树实质上被拆分成了若干个 ShadowTree
页面的 body 实质上也是一个组件,因此也是一个 ShadowTree
-->
<view>
<input-with-label>
Text
</input-with-label>
</view>
运行原理
在使用自定义组件的小程序页面,Exparser 会接管所有的自定义组件注册与实例化,从外部接口看,小程序基础提供有 Page 和 Component 两个构造器
以 Component 为例,在小程序启动时,构造器会将开发者设置的 properties、data、methods等定义字段,写入 Exparser 的组件注册表中,这个组件在被其它组件引用时,就可以根据这些注册信息创建自定义组件的实例
Page 构造器的大体运行流程与 Component 相仿,只是参数形式不一样,这样每个界面就有一个与之对应的组件,称为 页面根组件
在初始化页面时,Exparser 会创建出页面根组件上的一个实例,用到的其他组件也会响应创建组件实例(递归过程),组件创建过程要点如下:
- 根据组件注册信息,从组件原型上创建出组件节点的 JS 对象,即组件的 this
- 将组件注册信息中的 data 复制一份,作为组件数据,即 this.data
- 将这份数据结合组件 WXML,据此创建出 ShadowTree 由于 ShadowTree 中可能引用其它组件,因而这会递归触发其它组件创建过程
- 将 ShadowTree 拼接到 ComposedTree 上,并生成一些缓存用于优化组件更新性能
- 触发组件的 created 生命周期函数
- 如果不是页面根组件,需要根据组件节点上的属性定义,来设置组件的属性值
- 当组件实例被展示在页面上,触发组件的 attached 生命周期函数,如果 ShadowTree 中 有其它组件,也会逐个触发它们的生命周期函数
组件间通信
不同组件实例的通信有 WXML 属性值传递,事件系统,selectComponent 和 relations 等方式
其中,WXML 属性值传递是从父组件向子组件的基本通信方式,而事件系统是从子组件向父组件的基本通信方式
Exparser 事件系统完全模仿 ShadowDOM 的事件系统,在通常理解中,事件分为冒泡事件和非冒泡事件,但在 ShadowDOM 体系中,冒泡事件还可以划分为 ShadowTree 上的冒泡事件 和 ComposedTree 上冒泡的事件
如果在 ShadowTree 上冒泡,则冒泡只会经过这个组件的 ShadowTree 上的节点,这样就可以有效控制事件冒泡经过的范围
<!-- 组件 -->
<label>
<input/>
<slot/>
</label>
<!-- 使用组件 -->
<view>
<input-with-label>
<button/>
</input-with-label>
</view>
以上例子中,当在 button 上触发一个事件时:
- 如果事件是非冒泡的,则只能在 button 上监听事件
- 如果事件是在 ShadowTree 上冒泡的,则 button,input-with-label,view 可以依次监听到事件
- 如果事件是在 ComposedTree 上冒泡的,则 button,slot,input-with-label,view 可以依次监听到事件
在自定义组件中使用 triggerEvent 触发事件时,可以指定事件的 bubbles,composed 和 capturePhase 属性,用于标注事件的冒泡性质
Component({
methods: {
helloEvent: () => {
this.triggerEvent('hello', {}, {
bubles: true, // 这是一个冒泡事件
composed: true, // 这个事件在 ComposedTree 上冒泡
capturePhase: false // 这个事件没有捕获阶段
})
}
}
})
小程序基础库自身也会通过这套事件系统提供一些用户事件,如tap、touchstart 和 submit 等,其中,tap 等用户触摸引发的事件是在 ComposedTree 上的冒泡事件,其它事件大多是非冒泡事件
原生组件
原生组件运行机制
<map latitude="39.92" longtitude="116.46"></map>
在原生组件内部,其节点树非常简单,基本可以认为只有一个 div 元素,以上代码在渲染层运行时,会经过以下步骤:
- 组件被创建,包括组件属性会被依次赋值
- 组件被插入到 DOM 树中,浏览器内核会立即计算布局,此时我们可以读取出组件相对页面的位置(x,y坐标),宽高
- 组件通知客户端,客户端在相同的位置上,根据宽高插入一块原生区域,此后客户端就在此区域渲染界面
- 当位置或宽高发生变化时,组件会通知客户端做相应的调整
可以看出,原生组件在 WebView 这一层的渲染任务是非常简单的,只需要渲染一个占位元素,之后客户端就会在这块占位元素上叠上一层原生界面,因此,原生组件的层级会比所有在 WebView 层渲染的普通组件要高
引入原生组件主要有三个好处:
- 扩展 Web 的能力,比如输入框组件(input、textarea)有更好的控制键盘的能力
- 体验更好,同时也减轻 WebView 的渲染工作,比如像地图组件 (map)这类比较复杂的组件,其渲染工作不占用 WebView 线程,而是交给更高效的客户端原生处理
- 绕过 setData、数据通信和重新渲染流程,令渲染性能更好,比如像画布组件(canvas)可以直接用一套丰富的绘图接口进行绘制
常用的几个原生组件
组件名 | 名称 | 是否有 context | 描述 |
---|---|---|---|
video | 视频 | 是 | 播放视频 |
map | 地图 | 是 | 展示地图 |
canvas | 画布 | 是 | 提供一个可自由绘图的区域 |
picker | 弹出式选择器 | 佛 | 初始时没有界面,点击时弹出选择器 |
交互比较复杂的原生组件都会提供 context,用于直接操作组件,以 canvas 为例,小程序提供了 wx.createCanvasContext 方法来创建 canvas 的 context,这是一个可以用于操作 canvas 的对象
原生组件渲染限制
原生组件脱离在 webView 渲染流程之外,这带来了一些限制,最主要的限制是一些 CSS 样式无法应用于原生组件,例如:不能在父级节点使用 overflow:hidden来裁剪原生组件的显示区域,不能使用 transform:rotate 让原生组件产生旋转等
由于原生组件会浮于页面其它组件之上(相当于拥有正无穷大的 z-index 值)时其它组件不能覆盖在原生组件上展示,可以通过使用 cover-view 和 cover-image 组件,这两个组件也是原生组件,原生组件之间的层级就可以按照一定的规则控制
小程序和客户端通信原理
视图层组件
内置组件中有部分组件是利用客户端原生提供的能力,这些组件基本就是原生组件
既然需要客户端原生提供的能力,那么就会涉及到视图层与客户端的交互通信,这层通信机制会在 iOS 和 安卓系统的实现方式并不一样
iOS 是利用率 WKWebWiew 提供的 messageHandlers 特性,而在安卓则是王 WebView 的 window 对象注入一个原生方法
最终会封装成 WeiXinJSBridge 这样一个兼容层,主要提供了调用(invoke)和监听(on)两种方法
实际上,在视图层与客户端的交互通信中,开发者只是间接调用的,真正调用是在组件的内部实现中
开发者插入一个原生组件,一般而言,组件运行的时候被插入到 DOM 树中,会调用客户端接口,通知客户端在那个位置渲染原生区域,在后续开发者更新组件属性时,同样会调用客户端提供的更新接口更新原生界面的某些部分
逻辑层接口
逻辑层和客户端原生通信机制与渲染层类似,不同在于,iOS 平台可以往 JsCore 框架注入一个全局的原生方法,而安卓方法则是和渲染层一致
同样的,开发者只是间接的调用到与客户端原生通信的底层接口,逻辑层接口只有在做了层封装后才会暴露给开发者,封装的细节可能是统一入参,做参数校验,渐染平台或版本问题