事件

什么是事件

  • 事件是视图层到逻辑层的通讯方式。
  • 事件可以将用户的行为反馈到逻辑层进行处理。
  • 事件可以绑定在组件上,当达到触发事件,就会执行逻辑层中对应的事件处理函数。
  • 事件对象可以携带额外信息,如 id, dataset, touches。

事件的使用方式

  • 在组件中绑定一个事件处理函数。

bindtap,当用户点击该组件的时候会在该页面对应的Page中找到相应的事件处理函数。

<view id="tapTest" data-hi="Weixin" bindtap="tapName"> Click me! </view>
  • 在相应的Page定义中写上相应的事件处理函数,参数是event。
Page({
  tapName: function(event) {
    console.log(event)
  }
})
  • 可以看到log出来的信息大致如下:
{
  "type":"tap",
  "timeStamp":895,
  "target": {
    "id": "tapTest",
    "dataset":  {
      "hi":"Weixin"
    }
  },
  "currentTarget":  {
    "id": "tapTest",
    "dataset": {
      "hi":"Weixin"
    }
  },
  "detail": {
    "x":53,
    "y":14
  },
  "touches":[{
    "identifier":0,
    "pageX":53,
    "pageY":14,
    "clientX":53,
    "clientY":14
  }],
  "changedTouches":[{
    "identifier":0,
    "pageX":53,
    "pageY":14,
    "clientX":53,
    "clientY":14
  }]
}

使用WXS函数响应事件

基础库 2.4.4 开始支持,低版本需做兼容处理。

从基础库版本2.4.4开始,支持使用WXS函数绑定事件,WXS函数接受2个参数,第一个是event,在原有的event的基础上加了event.instance对象,第二个参数是ownerInstance,和event.instance一样是一个ComponentDescriptor对象。具体使用如下:

  • 在组件中绑定和注册事件处理的WXS函数。
<wxs module="wxs" src="./test.wxs"></wxs>
<view id="tapTest" data-hi="Weixin" bindtap="{{wxs.tapName}}"> Click me! </view>
**注:绑定的WXS函数必须用{{}}括起来**

  • test.wxs文件实现tapName函数
function tapName(event, ownerInstance) {
  console.log('tap Weixin', JSON.stringify(event))
}
module.exports = {
  tapName: tapName
}

ownerInstance包含了一些方法,可以设置组件的样式和class,具体包含的方法以及为什么要用WXS函数响应事件,请点击查看详情。

事件详解

事件分类

事件分为冒泡事件和非冒泡事件:

  1. 冒泡事件:当一个组件上的事件被触发后,该事件会向父节点传递。
  2. 非冒泡事件:当一个组件上的事件被触发后,该事件不会向父节点传递。

WXML的冒泡事件列表:

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

注:除上表之外的其他组件自定义事件如无特殊声明都是非冒泡事件,如 form 的submit事件,input 的input事件,scroll-view 的scroll事件,(详见各个组件)

普通事件绑定

事件绑定的写法类似于组件的属性,如:

<view bindtap="handleTap">
    Click here!
</view>

如果用户点击这个 view ,则页面的 handleTap 会被调用。

事件绑定函数可以是一个数据绑定,如:

<view bindtap="{{ handlerName }}">
    Click here!
</view>

此时,页面的 this.data.handlerName 必须是一个字符串,指定事件处理函数名;如果它是个空字符串,则这个绑定会失效(可以利用这个特性来暂时禁用一些事件)。

自基础库版本 1.5.0 起,在大多数组件和自定义组件中, bind 后可以紧跟一个冒号,其含义不变,如 bind:tap 。基础库版本 2.8.1 起,在所有组件中开始提供这个支持。

绑定并阻止事件冒泡

bind 外,也可以用 catch 来绑定事件。与 bind 不同, catch 会阻止事件向上冒泡。

例如在下边这个例子中,点击 inner view 会先后调用handleTap3handleTap2(因为tap事件会冒泡到 middle view,而 middle view 阻止了 tap 事件冒泡,不再向父节点传递),点击 middle view 会触发handleTap2,点击 outer view 会触发handleTap1

<view id="outer" bindtap="handleTap1">
  outer view
  <view id="middle" catchtap="handleTap2">
    middle view
    <view id="inner" bindtap="handleTap3">
      inner view
    </view>
  </view>
</view>

互斥事件绑定

自基础库版本 2.8.2 起,除 bindcatch 外,还可以使用 mut-bind 来绑定事件。一个 mut-bind 触发后,如果事件冒泡到其他节点上,其他节点上的 mut-bind 绑定函数不会被触发,但 bind 绑定函数和 catch 绑定函数依旧会被触发。

换而言之,所有 mut-bind 是“互斥”的,只会有其中一个绑定函数被触发。同时,它完全不影响 bindcatch 的绑定效果。

例如在下边这个例子中,点击 inner view 会先后调用 handleTap3handleTap2 ,点击 middle view 会调用 handleTap2handleTap1

<view id="outer" mut-bind:tap="handleTap1">
  outer view
  <view id="middle" bindtap="handleTap2">
    middle view
    <view id="inner" mut-bind:tap="handleTap3">
      inner view
    </view>
  </view>
</view>

事件的捕获阶段

自基础库版本 1.5.0 起,触摸类事件支持捕获阶段。捕获阶段位于冒泡阶段之前,且在捕获阶段中,事件到达节点的顺序与冒泡阶段恰好相反。需要在捕获阶段监听事件时,可以采用capture-bindcapture-catch关键字,后者将中断捕获阶段和取消冒泡阶段。

在下面的代码中,点击 inner view 会先后调用handleTap2handleTap4handleTap3handleTap1

<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>

如果将上面代码中的第一个capture-bind改为capture-catch,将只触发handleTap2

<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>

事件对象

如无特殊说明,当组件触发事件时,逻辑层绑定该事件的处理函数会收到一个事件对象。

BaseEvent 基础事件对象属性列表:

属性 类型 说明 基础库版本
type String 事件类型
timeStamp Integer 事件生成时的时间戳
target Object 触发事件的组件的一些属性值集合
currentTarget Object 当前组件的一些属性值集合
mark Object 事件标记数据 2.7.1

CustomEvent 自定义事件对象属性列表(继承 BaseEvent):

属性 类型 说明
detail Object 额外的信息

TouchEvent 触摸事件对象属性列表(继承 BaseEvent):

属性 类型 说明
touches Array 触摸事件,当前停留在屏幕中的触摸点信息的数组
changedTouches Array 触摸事件,当前变化的触摸点信息的数组

特殊事件: canvas 中的触摸事件不可冒泡,所以没有 currentTarget。

type

代表事件的类型。

timeStamp

页面打开到触发事件所经过的毫秒数。

target

触发事件的源组件。

属性 类型 说明
id String 事件源组件的id
dataset Object 事件源组件上由data-开头的自定义属性组成的集合

currentTarget

事件绑定的当前组件。

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

说明: target 和 currentTarget 可以参考上例中,点击 inner view 时,handleTap3 收到的事件对象 target 和 currentTarget 都是 inner,而 handleTap2 收到的事件对象 target 就是 inner,currentTarget 就是 middle。

dataset

在组件节点中可以附加一些自定义数据。这样,在事件中可以获取这些自定义的节点数据,用于事件的逻辑处理。

在 WXML 中,这些自定义数据以 data- 开头,多个单词由连字符 - 连接。这种写法中,连字符写法会转换成驼峰写法,而大写字符会自动转成小写字符。如:

  • data-element-type ,最终会呈现为 event.currentTarget.dataset.elementType
  • data-elementType ,最终会呈现为 event.currentTarget.dataset.elementtype

示例:

<view data-alpha-beta="1" data-alphaBeta="2" bindtap="bindViewTap"> DataSet Test </view>
Page({
  bindViewTap:function(event){
    event.currentTarget.dataset.alphaBeta === 1 // - 会转为驼峰写法
    event.currentTarget.dataset.alphabeta === 2 // 大写会转为小写
  }
})

mark

在基础库版本 2.7.1 以上,可以使用 mark 来识别具体触发事件的 target 节点。此外, mark 还可以用于承载一些自定义数据(类似于 dataset )。

当事件触发时,事件冒泡路径上所有的 mark 会被合并,并返回给事件回调函数。(即使事件不是冒泡事件,也会 mark 。)

代码示例:

在开发者工具中预览效果

<view mark:myMark="last" bindtap="bindViewTap">
  <button mark:anotherMark="leaf" bindtap="bindButtonTap">按钮</button>
</view>

在上述 WXML 中,如果按钮被点击,将触发 bindViewTapbindButtonTap 两个事件,事件携带的 event.mark 将包含 myMarkanotherMark 两项。

Page({
  bindViewTap: function(e) {
    e.mark.myMark === "last" // true
    e.mark.anotherMark === "leaf" // true
  }
})

markdataset 很相似,主要区别在于: mark 会包含从触发事件的节点到根节点上所有的 mark: 属性值;而 dataset 仅包含一个节点的 data- 属性值。

细节注意事项:

  • 如果存在同名的 mark ,父节点的 mark 会被子节点覆盖。
  • 在自定义组件中接收事件时, mark 不包含自定义组件外的节点的 mark
  • 不同于 dataset ,节点的 mark 不会做连字符和大小写转换。

touches

touches 是一个数组,每个元素为一个 Touch 对象(canvas 触摸事件中携带的 touches 是 CanvasTouch 数组)。 表示当前停留在屏幕上的触摸点。

Touch 对象

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

CanvasTouch 对象

属性 类型 说明 特殊说明
identifier Number 触摸点的标识符
x, y Number 距离 Canvas 左上角的距离,Canvas 的左上角为原点 ,横向为X轴,纵向为Y轴

changedTouches

changedTouches 数据格式同 touches。 表示有变化的触摸点,如从无变有(touchstart),位置变化(touchmove),从有变无(touchend、touchcancel)。

detail

自定义事件所携带的数据,如表单组件的提交事件会携带用户的输入,媒体的错误事件会携带错误信息,详见组件定义中各个事件的定义。

点击事件的detail 带有的 x, y 同 pageX, pageY 代表距离文档左上角的距离。

WXS

WXS(WeiXin Script)是内联在 WXML 中的脚本段。通过 WXS 可以在模版中内联少量处理脚本,丰富模板的数据预处理能力。另外,WXS 还可以用来编写简单的 WXS 事件响应函数。

从语法上看,WXS 类似于有少量限制的 JavaScript。要完整了解 WXS 语法,请参考 WXS 语法参考。

以下是一些使用 WXS 的简单示例。

页面渲染

<!--wxml-->
<wxs module="m1">
var msg = "hello world";

module.exports.message = msg;
</wxs>

<view> {{m1.message}} </view>

页面输出:

hello world

数据处理

// page.js
Page({
  data: {
    array: [1, 2, 3, 4, 5, 1, 2, 3, 4]
  }
})
<!--wxml-->
<!-- 下面的 getMax 函数,接受一个数组,且返回数组中最大的元素的值 -->
<wxs module="m1">
var getMax = function(array) {
  var max = undefined;
  for (var i = 0; i < array.length; ++i) {
    max = max === undefined ?
      array[i] :
      (max >= array[i] ? max : array[i]);
  }
  return max;
}

module.exports.getMax = getMax;
</wxs>

<!-- 调用 wxs 里面的 getMax 函数,参数为 page.js 里面的 array -->
<view> {{m1.getMax(array)}} </view>

页面输出:

5

WXSS

WXSS (WeiXin Style Sheets) 是一套样式语言,用于描述 WXML 的组件样式。

WXSS 用来决定 WXML 的组件应该怎么显示。

为了适应广大的前端开发者,WXSS 具有 CSS 大部分特性。同时为了更适合开发微信小程序,WXSS 对 CSS 进行了扩充以及修改。

与 CSS 相比,WXSS 扩展的特性有:

  • 尺寸单位
  • 样式导入

尺寸单位

  • rpx(responsive pixel): 可以根据屏幕宽度进行自适应。规定屏幕宽为750rpx。如在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素。
设备 rpx换算px (屏幕宽度/750) px换算rpx (750/屏幕宽度)
iPhone5 1rpx = 0.42px 1px = 2.34rpx
iPhone6 1rpx = 0.5px 1px = 2rpx
iPhone6 Plus 1rpx = 0.552px 1px = 1.81rpx

建议: 开发微信小程序时设计师可以用 iPhone6 作为视觉稿的标准。

注意: 在较小的屏幕上不可避免的会有一些毛刺,请在开发时尽量避免这种情况。

样式导入

使用@import语句可以导入外联样式表,@import后跟需要导入的外联样式表的相对路径,用;表示语句结束。

示例代码:

/** common.wxss **/
.small-p {
  padding:5px;
}
/** app.wxss **/
@import "common.wxss";
.middle-p {
  padding:15px;
}

内联样式

框架组件上支持使用 style、class 属性来控制组件的样式。

  • style:静态的样式统一写到 class 中。style 接收动态的样式,在运行时会进行解析,请尽量避免将静态的样式写进 style 中,以免影响渲染速度。
<view style="color:{{color}};" />
  • class:用于指定样式规则,其属性值是样式规则中类选择器名(样式类名)的集合,样式类名不需要带上.,样式类名之间用空格分隔。
<view class="normal_view" />

选择器

目前支持的选择器有:

选择器 样例 样例描述
.class .intro 选择所有拥有 class=”intro” 的组件
#id #firstname 选择拥有 id=”firstname” 的组件
element view 选择所有 view 组件
element, element view, checkbox 选择所有文档的 view 组件和所有的 checkbox 组件
::after view::after 在 view 组件后边插入内容
::before view::before 在 view 组件前边插入内容

全局样式与局部样式

定义在 app.wxss 中的样式为全局样式,作用于每一个页面。在 page 的 wxss 文件中定义的样式为局部样式,只作用在对应的页面,并会覆盖 app.wxss 中相同的选择器。

WXML

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

要完整了解 WXML 语法,请参考WXML 语法参考。

用以下一些简单的例子来看看 WXML 具有什么能力:

数据绑定

<!--wxml-->
<view> {{message}} </view>
// page.js
Page({
  data: {
    message: 'Hello MINA!'
  }
})

列表渲染

<!--wxml-->
<view wx:for="{{array}}"> {{item}} </view>
// page.js
Page({
  data: {
    array: [1, 2, 3, 4, 5]
  }
})

条件渲染

<!--wxml-->
<view wx:if="{{view == 'WEBVIEW'}}"> WEBVIEW </view>
<view wx:elif="{{view == 'APP'}}"> APP </view>
<view wx:elif="{{view == 'MINA'}}"> MINA </view>
<view wx:else> UNKNOWN </view>
// page.js
Page({
  data: {
    view: 'MINA'
  }
})

模板

<!--wxml-->
<template name="staffName">
  <view>
    FirstName: {{firstName}}, LastName: {{lastName}}
  </view>
</template>

<template is="staffName" data="{{...staffA}}"></template>
<template is="staffName" data="{{...staffB}}"></template>
<template is="staffName" data="{{...staffC}}"></template>
// page.js
Page({
  data: {
    staffA: {firstName: 'Hulk', lastName: 'Hu'},
    staffB: {firstName: 'Shang', lastName: 'You'},
    staffC: {firstName: 'Gideon', lastName: 'Lin'}
  }
})

具体的能力以及使用方式在以下章节查看:

数据绑定、列表渲染、条件渲染、模板、引用

视图层 View

框架的视图层由 WXML 与 WXSS 编写,由组件来进行展示。

将逻辑层的数据反映成视图,同时将视图层的事件发送给逻辑层。

WXML(WeiXin Markup language) 用于描述页面的结构。

WXS(WeiXin Script) 是微信小程序的一套脚本语言,结合 WXML,可以构建出页面的结构。

WXSS(WeiXin Style Sheet) 用于描述页面的样式。

组件(Component)是视图的基本组成单元。

API

微信小程序开发框架提供丰富的微信原生 API,可以方便地调起微信提供的能力,例如获取用户信息、本地存储、支付功能等。详细介绍请参考 API 文档。

通常,微信小程序 API 有以下几种类型:

事件监听 API

我们约定,以 on 开头的 API 用来监听某个事件是否触发,例如:wx.onSocketOpen、wx.onCompassChange 等。

这类 API 接受一个回调函数作为参数,当事件触发时会调用这个回调函数,并将相关数据以参数形式传入。

代码示例

wx.onCompassChange(function (res) {
  console.log(res.direction)
})

同步 API

我们约定,以 Sync 结尾的 API 都是同步 API,例如 wx.setStorageSync、wx.getSystemInfoSync 等。此外,也有一些其他的同步 API,例如 wx.createWorker、wx.getBackgroundAudioManager 等,详情参见 API 文档中的说明。

同步 API 的执行结果可以通过函数返回值直接获取,如果执行出错会抛出异常。

代码示例

try {
  wx.setStorageSync('key', 'value')
} catch (e) {
  console.error(e)
}

异步 API

大多数 API 都是异步 API,例如 wx.request、wx.login 等。这类 API 接口通常都接受一个 Object 类型的参数,这个参数支持按需指定以下字段来接收接口调用结果:

Object 参数说明

参数名 类型 必填 说明
success function 接口调用成功的回调函数
fail function 接口调用失败的回调函数
complete function 接口调用结束的回调函数(调用成功、失败都会执行)
其他 Any 接口定义的其他参数

回调函数的参数

successfailcomplete 函数调用时会传入一个 Object 类型参数,包含以下字段:

属性 类型 说明
errMsg string 错误信息,如果调用成功返回 ${apiName}:ok
errCode number 错误码,仅部分 API 支持,具体含义请参考对应 API 文档,成功时为
其他 Any 接口返回的其他数据

异步 API 的执行结果需要通过 Object 类型的参数中传入的对应回调函数获取。部分异步 API 也会有返回值,可以用来实现更丰富的功能,例如 wx.request、wx.connectSocket 等。

代码示例

wx.login({
  success(res) {
    console.log(res.code)
  }
})

异步 API 返回 Promise

基础库 2.10.2 版本起,异步 API 支持 callback 和 promise 两种调用方式。当接口参数 Object 对象中不包含 success/fail/complete 时将默认返回 promise,否则仍按回调方式执行,无返回值。

注意事项

  1. 部分接口如 downloadFilerequestuploadFileconnectSocketcreateCamera(小游戏)本身就有返回值,它们的 promisify 需要开发者自行封装。
  2. 当没有回调参数时,异步接口返回 promise。此时若函数调用失败进入 fail 逻辑,会报错提示 Uncaught (in promise),开发者可通过 catch 来进行捕获。
  3. wx.onUnhandledRejection 可以监听未处理的 Promise 拒绝事件。

代码示例

// callback 形式调用
wx.chooseImage({
  success(res) {
    console.log('res:', res)
  }
})

// promise 形式调用
wx.chooseImage().then(res => console.log('res: ', res))

云开发 API

开通并使用微信云开发,即可使用云开发 API,在微信小程序端直接调用服务端的云函数

代码示例

wx.cloud.callFunction({
  // 云函数名称
  name: 'cloudFunc',
  // 传给云函数的参数
  data: {
    a: 1,
    b: 2,
  },
  success: function(res) {
    console.log(res.result) // 示例
  },
  fail: console.error
})

// 此外,云函数同样支持promise形式调用

模块化

可以将一些公共的代码抽离成为一个单独的 js 文件,作为一个模块。模块只有通过 module.exports 或者 exports 才能对外暴露接口。

注意:

  • exportsmodule.exports 的一个引用,因此在模块里边随意更改 exports 的指向会造成未知的错误。所以更推荐开发者采用 module.exports 来暴露模块接口,除非你已经清晰知道这两者的关系。
  • 微信小程序目前不支持直接引入 node_modules , 开发者需要使用到 node_modules 时候建议拷贝出相关的代码到微信小程序的目录中,或者使用微信小程序支持的 npm 功能。
// common.js
function sayHello(name) {
  console.log(`Hello ${name} !`)
}
function sayGoodbye(name) {
  console.log(`Goodbye ${name} !`)
}

module.exports.sayHello = sayHello
exports.sayGoodbye = sayGoodbye

​在需要使用这些模块的文件中,使用 require 将公共代码引入

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

文件作用域

在 JavaScript 文件中声明的变量和函数只在该文件中有效;不同的文件中可以声明相同名字的变量和函数,不会互相影响。

通过全局函数 getApp 可以获取全局的应用实例,如果需要全局的数据可以在 App() 中设置,如:

// app.js
App({
  globalData: 1
})
// a.js
// The localValue can only be used in file a.js.
var localValue = 'a'
// Get the app instance.
var app = getApp()
// Get the global data and change it.
app.globalData++
// b.js
// You can redefine localValue in file b.js, without interference with the localValue in a.js.
var localValue = 'b'
// If a.js it run before b.js, now the globalData shoule be 2.
console.log(getApp().globalData)

路由事件重写

从基础库 3.8.0 起,微信小程序可以在路由事件下发到基础库但还未进行实际处理之前,改变这次路由事件的目标页面路径及参数。这有一点类似 HTTP 协议中 URL 重定向的效果,但为了不与现有的 页面重定向 redirectTo 混淆,我们将这种新的特性称为 路由重写(Route rewrite)

为了更好地理解这个特性,你可能需要先了解 路由事件 的相关机制

兼容性

目前支持:

  • 微信安卓客户端 8.0.57 及以上版本
  • 微信 iOS 客户端 8.0.61 及以上版本

更多平台适配正在进行中。

另外,有一些 目前已知的问题,也请留意。

在不兼容的客户端或基础库版本上,可以使用 wx.redirectTo 进行回退兼容,具体参考下面用法中的代码示例。

基本用法

例如,我们可以通过这样的方式将所有跳转到页面 A 的路由都重写到页面 B:

// 添加路由事件处理前的监听
wx.onBeforeAppRoute(res => {
  // 监听触发时,判断事件是否需要重写
  if (res.path === '/pages/A/A') {
    // 重写路由事件
    wx.rewriteRoute({
      url: '/pages/B/B',
      success(res) {
        console.info('Rewrite successfully from A to B')
      },
      fail(res) {
        console.error('Rewrite failed, reason: ' + res.errMsg)
        // 由于兼容性问题或场景不适用等原因重写失败,回退
        wx.redirectTo({
          url: '/pages/B/B',
          complete: console.info
        })
      }
    })
    return
  }
})

在这个例子中,如果有一个目标为 /pages/A/A 的路由事件(例如 navigateTo)下发到基础库,wx.onBeforeAppRoute 监听被触发,wx.rewriteRoute 执行重写后,navigateTo 的目标将变为 /pages/B/B。最终会有一个 B 页面被实例化并压入页面栈。

调用时机

在上面的例子中,路由重写接口 wx.rewriteRoutewx.onBeforeAppRoute 监听中执行。这是因为路由重写只能在路由事件下发到基础库,并且该路由事件还未被执行任何处理之前进行。换句话说,如果这次路由事件已经产生了实际影响(例如路由使旧页面被弹出销毁或者新页面被渲染),那我们就不能再重写这次路由事件了。因此目前有且只有 wx.onBeforeAppRoute 一个时机可以进行路由事件的重写,并且路由重写必须在这个监听的回调中 同步 进行。在 wx.onBeforeAppRoute 的回调以外的地方进行重写或者在回调中异步进行重写会导致重写失败。

目标限制

由于路由重写是改变一个已有路由事件的目标路径,不能改变这个事件的事件类型,因此路由重写需要保证重写后新的目标路径和事件类型是匹配的。例如:switchTab 的目标必须是一个 Tab Bar 页面,因此重写也不能将 switchTab 事件重写到非 Tab Bar 页面。

常见用例

此处的代码片段仅做简单的场景演示

  1. 页面未找到的情况下,回到微信小程序主页
    wx.onBeforeAppRoute(res => {
      if (res.notFound) {
        wx.rewriteRoute({
          url: '/pages/index/index?from-not-found=' + encodeURIComponent(res.path),
        })
      }
    })
    
  2. 线下活动结束后,活动页面下线,用户扫描线下旧物料时引导到新活动页;或者线下物料中写错了路径 / 参数,微信小程序中进行兼容:
    wx.onBeforeAppRoute(res => {
      if (res.path === '/pages/old-or-wrong/activity/page') {
        wx.rewriteRoute({
          url: '/pages/new/activity/page',
          preserveQuery: true,
        })
      }
    })
    
  3. 进入新任务页面时,判断用户是否有上次未完成的任务,继续处理:
    wx.onBeforeAppRoute(res => {
      if (res.path === '/pages/task/new-task') {
        const unfinishedTaskId = globalStatus.unfinishedTaskId
        if (typeof unfinishedTaskId === 'string') {
          wx.rewriteRoute({
            url: '/pages/task/perform-task?taskId=' + unfinishedTaskId,
          })
        }
      }
    })
    
  4. 微信小程序从首页下拉冷启动时,读取 storage 中存储的不同用户身份(例如顾客与商家、学生与家长等),跳转到不同的首页
    wx.onBeforeAppRoute(res => {
      if (res.openType === 'appLaunch') {
        const enterOptions = wx.getEnterOptionsSync()
        if (enterOptions.scene === 1089) {
          const userRole = wx.getStorageSync('user-role')
          if (userRole === 'customer') {
            wx.rewriteRoute({ url: '/pages/customer-index/index' })
          } else if (userRole === 'merchant') {
            wx.rewriteRoute({ url: '/pages/merchant-index/index' })
          } else { /* do nothing */ }
        }
      }
    })
    

对比页面重定向

从最终结果上来看,路由重写与页面重定向 redirectTo 都能达到类似的效果(例如在上面的例子中,最终结果都是新建了一个页面 B 的实例作为栈顶),但二者在执行原理和过程上仍有一定的差别。

页面重定向与原路由事件(例如上例中的 navigateTo)是按顺序排队执行,也就是在执行完页面 A 的渲染任务(例如准备页面渲染环境,实例化页面,处理页面栈逻辑压入页面,渲染页面,触发对应的生命周期等)之后,再处理 redirectTo;而路由重写会在原路由事件下发后处理,框架会直接执行页面 B 的渲染任务。在这个流程中,页面 A 没有被实际实例化或渲染过,因此只渲染了一次页面(redirectTo 实际渲染了两个页面),流程更快、更简单,也更能充分发挥 WebView 预加载 的效果。

当然,也不是所有的 redirectTo 都可以被替换为 rewriteRoute,例如需要由用户选择重定向目标或者从其他页面返回后重定向等情况;另一方面,rewriteRoute 作为一个新能力,并非所有的用户的运行环境都支持路由重写。开发者可以在适用 rewriteRoute 的场景和环境下使用 rewriteRoute,而如果场景不合适或者当前运行环境不支持,则回退使用 redirectTo 进行重定向。

常见问题

  1. 为什么我请求后台接口之后再执行 rewriteRoute 会失败?

    目前微信小程序提供的网络请求接口都是异步接口,发起网络请求之后,JS 运行时会在等待服务器响应时执行其他任务。因此,请求后台并等待后台接口返回时,路由事件实际上已经被处理和执行了。

    理论上,我们也可以使路由事件的处理和执行等待网络请求返回。在等待期间,由于路由事件尚未处理,用户会持续停留在上一个页面(页面跳转的情况下)或者看到白屏(微信小程序启动的情况下),而这段时间的长短取决于网络请求的耗时,从而可能导致用户操作打开或跳转后持续没有响应。为了回避这种情况导致的体验恶化,现阶段我们只处理同步进行的路由重写。

  2. 为什么 wx.rewriteRoute 不像 navigateTo 一样可以直接调用,而是要放在监听的回调中?

    因为相比于 wx.navigateTo 是一次路由请求,对应的 navigateTo 是一种路由事件类型,rewriteRoute 实际上并不是一种路由类型,它的作用是对一次已经存在的路由事件进行一些操作。onBeforeAppRoute 监听会在路由事件下发时触发,在这个回调中我们才能准确地对路由事件进行判断和处理。

常见失败及对应原因

  • not supported

    当前客户端平台或版本不支持路由重写能力

  • rewriteRoute is only allowed in a onBeforeAppRoute callback

    在不正确的时机调用 wx.rewriteRoute(见上方 调用时机)

  • rewriteRoute can only be called once in a route event, this page hash been rewritten to "XXX"

    多次重写了同一个路由事件。每一个路由事件只能被重写一次,可以先计算好最终的目标路径再调用。如果确实需要进行连续的重写,应该等待重写后的路由事件重新触发 onBeforeAppRoute 监听回调,再进行重写

  • a "navigateBack" event is not allowed to be rewritten

    页面返回 navigateBack 事件是不能被重写的(因为目标页面是已经存在的原有页面)

  • rewriting a "XXX" event to to a non-tab page("YYY") is not allowed

  • rewriting a "XXX" event to a tab page("YYY") is not allowed

    重写后的目标页面与路由事件类型不匹配(见上方 目标限制)

  • rewriting a route event that belongs to XXX is not allowed.

    微信小程序不能重写目标为插件页面的路由事件,反之插件也不能重写目标为微信小程序或其他插件的路由事件

已知问题

  1. 目前仅能将路由事件重写到同一分包中的页面,暂不能重写到其他分包的页面。这个问题将在后续的客户端版本中修复

页面路由监听

基础库 3.5.5 开始支持,低版本需做兼容处理。

这篇指南主要说明从基础库版本 3.5.5 起可用的 页面路由事件监听函数 的使用方法。如果需要了解页面路由的类型及逻辑等基本信息,可以参考 页面路由。

由于每次路由可能触发多个页面的多个页面生命周期,因此当某个页面的某个生命周期被触发时,微信小程序往往比较难判断它被触发的原因,从而难以做出一些针对路由(而非针对页面)的响应。一个例子是当微信小程序进行重加载 reLaunch 路由时,微信小程序可能需要重设一些全局状态来保证后续逻辑正常工作,或者模拟近似于重新启动的效果。然而从页面生命周期来反向推测 reLaunch 是比较难的,因为即使某一瞬间当前所有页面都被销毁,也不一定是由 reLaunch 引起的(也可能是在仅有单个页面的情况下进行了重定向 redirectTo)。这套接口可以帮助处理这样的场景。

所有监听及触发时序

页面路由监听 触发时机 每次路由中的触发次数
wx.onBeforeAppRoute 路由事件下发到基础库,基础库执行路由逻辑前触发 一次
wx.onAppRoute 路由事件下发到基础库,基础库执行路由逻辑后触发 一次
wx.onAppRouteDone 路由对应的动画(页面推入、推出等)完成时触发 一次
wx.onBeforePageLoad 路由引发的页面创建之前触发 不限
wx.onAfterPageLoad 路由引发的页面创建完成后触发 不限
wx.onBeforePageUnload 路由引发的页面销毁之前触发 不限
wx.onAfterPageUnload 路由引发的页面销毁完成后触发 不限

例如,在一次 redirectTo 中,监听和处理逻辑将按以下顺序触发:

  1. wx.onBeforeAppRoute
  2. wx.onBeforePageUnload
  3. 旧页面 onUnload 生命周期
  4. 旧页面销毁,此过程中页面本身及页面中所有自定义组件的 detached 生命周期被递归触发
  5. 旧页面弹出页面栈,此时开始 getCurrentPages 接口不再能获取到旧页面
  6. wx.onAfterPageUnload
  7. wx.onBeforePageLoad
  8. 创建新页面,此过程中页面本身及页面中所有自定义组件的 created 生命周期被递归触发
  9. 新页面压入页面栈,此时开始 getCurrentPages 接口可以获取到新页面
  10. 挂载新页面,此过程中页面本身及页面中所有自定义组件的 attached 生命周期被递归触发
  11. 新页面 onLoad 生命周期
  12. 新页面 onShow 生命周期
  13. wx.onAfterPageLoad
  14. wx.onAppRoute
  15. (新页面推入动画完成时)wx.onAppRouteDone

对于其他路由,可以结合 页面路由 中的具体路由逻辑进行类推。

路由事件 ID

为了在多次监听回调中识别同一个路由事件,框架会为每一次独立的路由事件生成一个在微信小程序实例中唯一的 ID,称为 路由事件 ID。在所有页面路由监听函数中,事件参数中都将携带一个字符串 routeEventId,表示这个路由事件 ID。微信小程序可以通过读取回调中的 routeEventId,来将同一个路由在不同时间节点触发的不同回调进行关联。例如:

const redirectToContext = {};
wx.onBeforeAppRoute(res => {
  if (res.openType === "redirectTo") {
    redirectToContext[res.routeEventId] = { startTime: new Date() };
  }
});
wx.onBeforePageUnload(res => {
  const context = redirectToContext[res.routeEventId];
  if (context !== undefined) {
    context.from = res.page.is;
    context.data = res.page.data;
  }
});
wx.onAfterPageLoad(res => {
  const context = redirectToContext[res.routeEventId];
  if (context !== undefined) {
    console.log(
      `A "redirectTo" route replaced page "${context.from}" to "${
        res.page.is
      }", which is started at ${context.startTime.toString()}`
    );
    res.page.setData(context.data);
    delete redirectToContext[res.routeEventId];
  }
});

这个例子中,我们通过 routeEventId 关联了一次 redirectTo 中的页面创建和页面销毁:在页面销毁时记录了旧页面的数据,并将其应用到了新页面上。

可能的用例

  1. 进行路由上报,方便还原用户使用路径:

    wx.onAppRoute(res => {
      myReportAppRoute(res.timeStamp, res.openType, res.path, res.query);
    });
    
  2. 微信小程序冷启动或热启动时,重置所有状态:

    wx.onBeforeAppRoute(res => {
      if (["appLaunch", "reLaunch", "autoReLaunch"].includes(res.openType)) {
        myGlobalState.reset();
      }
    });
    

    这可以解决一些常见情景,例如微信小程序当前在后台,用户扫码热启动,触发 autoReLaunch 时进行状态清理。

  3. 新页面创建前先进行网络请求,使页面首屏创建和等待网络请求并行进行:

    const pageRequestData = {};
    wx.onBeforePageLoad(res => {
      pageRequestData[res.routeEventId] = new Promise((resolve, reject) => {
        wx.request({
          url: `https://mysite.wechat.qq.com/page-data?path=${res.path}&param=${res.query.param}`,
          success(res) {
            resolve(res);
          },
          fail(res) {
            reject(res);
          }
        });
      });
    });
    wx.onAfterPageLoad(res => {
      pageRequestData[res.routeEventId]
        .then(data => {
          res.page.setData(data);
        })
        .catch(err => {
          console.error("page data init error", err);
        });
    });
    

    当页面比较复杂时,页面创建需要一定时间。这个做法能充分利用页面的创建时间来等待网络请求返回,从而更快地将业务数据应用到页面上,展示给用户。

页面路由

在微信小程序中,所有页面的创建、销毁及状态转换都由页面路由来表达和进行控制。以下内容会简单介绍微信小程序的页面路由相关逻辑。

路由的时机

路由会以事件形式表示,由微信客户端下发给微信小程序基础库,下发后客户端和基础库将分别同时处理这一次路由事件。路由事件的发起可以大致分为以下两类:

  1. 通过用户的操作(如按下返回按钮)发起。通过这种方式发起时,路由事件将直接由客户端下发到基础库执行;

  2. 由开发者通过 API(如 wx.navigateTo)或者组件(如 <navigator>)发起。通过这种方式发起时,基础库将首先向客户端发起路由请求,客户端确认路由可以被执行后,再将路由事件下发到基础库。其中,如果路由被确定执行,API 的 success 回调函数或组件的 success 事件将被触发,否则将触发 fail

当一次路由被确定执行(API 或组件通知 success)时,没有操作可以取消这一次路由。

当多次路由被连续发起时,如果当前的路由事件还未处理完毕,后续的路由事件将等待当前路由处理,并排队依次执行,直到所有待处理的路由都被执行完毕。

一个简单的例子:用户点击返回按钮触发了 navigateBack,微信小程序在页面栈当前栈顶页的 onUnload 中调用 wx.redirectTo并不能 将当前正在被销毁的页面重定向为一个新页面,而是会先完成页面返回,再将页面返回后的新栈顶页重定向到新的页面。

页面栈

目前,微信小程序的页面会被组织为一个页面栈加若干不在栈中的悬垂页面的组合形式。其中,页面栈按顺序存放了通过跳转依次打开的页面,而当前已经创建但非活跃的 tabBar 页面及处于画中画模式(如 videolive-player 等)中的页面将以悬垂页面的形式存在。

全局接口 getCurrentPages 可以用来获取当前页面栈。

微信小程序冷启动完成后,在整个微信小程序存活过程中(除去某次路由执行到一半的中间状态外),页面栈中都将存在至少一个页面。

页面栈的具体行为可以参见下面具体路由行为中的详细描述。

路由的监听及响应

页面生命周期函数

每个微信小程序页面都有若干生命周期函数,如 onLoad, onShow, onRouteDone, onHide, onUnload 等。它们可以在页面注册时定义,并会在相应的时机触发。所有生命周期函数及它们各自的含义和触发时机可以参见 Page 接口,下面的内容也将详细说明每个路由将如何触发页面的生命周期函数。

页面路由监听

从基础库版本 3.5.5 开始,基础库提供了一组针对路由事件的监听函数。相比页面生命周期函数,它们能更好地针对某次路由进行响应。详见 页面路由监听。

路由类型

微信小程序目前的路由类型可以大致分为以下七种:

1. 微信小程序启动

  • openType: appLaunch

微信小程序启动路由 appLaunch 表示一个新的微信小程序启动,并加载第一个页面。appLaunch 在每个微信小程序实例中会且仅会出现一次,且每个微信小程序实例启动时的第一个路由事件必定为 appLaunch

触发方式

appLaunch 仅能由微信小程序冷启动被动触发,不能由开发者主动触发,启动后也不能通过其他用户操作触发。

页面栈及生命周期处理

由于 appLaunch 必定是启动时的第一个路由,而路由前没有任何页面存在,此时页面栈必定为空。appLaunch 会创建路由事件指定的页面,并将其推入页面栈作为栈中唯一的页面。在这个过程中,这个页面的 onLoad, onShow 两个生命周期将依次被触发。

2. 打开新页面

  • openType: navigateTo

打开新页面路由 navigateTo 表示打开一个新的页面,并将其推入页面栈。

触发方式

  1. 调用 API wx.navigateTo, Router.navigateTo
  2. 使用组件 <navigator open-type="navigateTo"/>
  3. 用户点击一个视频小窗(如 video

navigateTo 的目标必须为非 tabBar 页面。

页面栈及生命周期处理

navigateTo 事件发生时,页面栈当前的栈顶页面将首先被隐藏,触发 onHide 生命周期;之后框架将创建路由事件指定的页面,并将其推入页面栈作为新的栈顶。在这个过程中,这个新页面的 onLoad, onShow 两个生命周期将依次被触发。

作为一种特殊情况,如果 navigateTo 事件发生时,页面栈当前的栈顶页面满足小窗模式逻辑,或事件由用户点击视频小窗发起,那么页面栈及生命周期的的处理会有所不同。

3. 页面重定向

  • openType: redirectTo

页面重定向路由 redirectTo 表示将页面栈当前的栈顶页面替换为一个新的页面。

触发方式

  1. 调用 API wx.redirectTo, Router.redirectTo
  2. 使用组件 <navigator open-type="redirectTo"/>

redirectTo 的目标必须为非 tabBar 页面。

页面栈及生命周期处理

redirectTo 事件发生时,页面栈当前的栈顶页面将首先被弹出并销毁,在此过程中,这个栈顶页面的 onUnload 生命周期将被触发;之后框架将创建路由事件指定的页面,并将其推入页面栈作为新的栈顶。在这个过程中,这个新页面的 onLoad, onShow 两个生命周期将依次被触发。

4. 页面返回

  • openType: navigateBack

页面返回路由 navigateBack 表示将页面栈当前的栈顶的若干个页面依次弹出并销毁。

触发方式

  1. 调用 API wx.navigateBack, Router.navigateBack
  2. 使用组件 <navigator open-type="navigateBack"/>
  3. 用户按左上角返回按钮,或触发操作系统返回的动作(如按下系统返回键、屏幕边缘向内滑动等)
  4. 用户点击一个视频小窗(如 video

如果页面栈中当前只有一个页面,navigateBack 调用请求将失败(无论指定的 delta 是多少);

如果页面栈中当前的页面数量少于调用时指定的 delta + 1(即调用后页面数量将少于一个),navigateBack 将弹出到只剩页面栈当前的页面栈底的页面为止(即至少保留一个页面)。

页面栈及生命周期处理

navigateBack 事件发生时,页面栈当前的栈顶页面将被弹出并销毁,并触发这个页面的 onUnload 生命周期;以上操作将被重复执行多次,直到弹出的页面数量等于指定的页面数量,或当前页面栈中只剩下一个页面。之后,页面栈新的栈顶页面的 onShow 生命周期将被触发。

一种特殊情况是,如果 navigateBack 发生时,页面栈当前的栈顶页面满足小窗模式逻辑,或事件由用户点击视频小窗发起,那么页面栈及生命周期的的处理会有所不同。

5. Tab 切换

  • openType: switchTab

Tab 切换路由 switchTab 表示切换到指定的 tab 页面。

触发方式

  1. 调用 API wx.switchTab, Router.switchTab
  2. 使用组件 <navigator open-type="switchTab"/>
  3. 用户点击 Tab Bar 中的 Tab 按钮

switchTab 的目标必须为 tabBar 页面。

页面栈及生命周期处理

由于 navigateToredirectTo 不能指定 tabBar 页面作为目标,因此当一个 tabBar 页面出现在页面栈中时,它必定为页面栈的第一个页面(即栈底页面);同时,框架会保证任一 tabBar 页面在微信小程序中最多同时存在一个页面实例。switchTab 的行为主要基于这两点进行。

switchTab 事件发生时,如果当前页面栈中存在多于一个页面,页面栈当前的栈顶页面将被弹出并销毁,并触发这个页面的 onUnload 生命周期;以上操作将被重复执行多次,直到页面栈中只剩下一个页面。之后,根据页面栈中仅剩的页面进行不同的处理:

  • 如果这个页面即为目标 tabBar 页面:
    • 如果路由事件开始时页面栈中存在多于一个页面(即目标 tabBar 页面不是栈顶页面),触发目标 tabBar 页面的 onShow 生命周期;
    • 否则(路由事件开始时目标 tabBar 页面是栈顶页面),不触发任何生命周期,直接结束;
  • 否则(该页面不为目标 tabBar 页面时):
    1. 将这个页面从页面栈中弹出;
    2. 如果这个页面为其他 tabBar 页面,该页面成为悬垂页面,并:
      • 如果路由事件开始时页面栈中只有一个页面(即该 tabBar 页面是栈顶页面),触发它的 onHide 生命周期;
      • 否则(路由事件开始时该 tabBar 页面不是栈顶页面),不触发它的任何生命周期;
    3. 否则(这个页面为非 tabBar 页面时),销毁该页面,触发 onUnload 生命周期;
    4. 如果目标 tabBar 页之前已经被创建过(现在是一个悬垂页面),将其推入页面栈,触发 onShow 生命周期;
    5. 否则(目标 tabBar 页不存在实例),创建目标 tabBar 页并推入页面栈,依次触发 onLoad, onShow 生命周期。