视频链接:
2.11 滚动选择器:如何自定义省市区多级联动选择器?(二)
片 1
上节课我们开始学习了 picker、picker-view、picker-view-column 组件,了解了他们的主要属性,这节课呢,我们尝试使用 picker-view,用两种方式,自定义实现一个三级联动的选择器。
我们都知道在小程序当中,受限于代码包和运行环境,页面的交互效果在体验上会大打折扣。为了提高页面的渲染效率,微信团队推出了 wxs 这种脚本语言。
与 js 不同的是,这种脚本语言,它是专门用于处理视图交互逻辑的,并且它还是只在视图层中运行的,用它实现自定义组件,可以显著提高组件的运行效率。这节课我们就看一看,如何使用这种方式实现三级联动的选择器组件。
我们先看一看使用picker -view这个组件时,在开发中经常会遇到什么问题。
picker 与 picker-view 有什么区别?
片 2
picker 是从手机底部滑起的选择器,而 picker-view 是嵌入小程序页面里边的选择器。
还有,在 picker-view 中,只可以放置 picker-view-column 组件,放其它子组件不会显示。
picker-view-column 是滚动选择器的子项,它只可以放置在 picker-view 中,做为它的子节点,也就是每一个的选择项,它们的高度会自动设置成与 picker-view 选中框的高度一致的。
我们已经选择了值,为什么还要给选择器的 value 属性绑定值呢?
片 3
我们先看一段代码。
<picker bindchange="bindPickerChange" value="{{index}}" range="{{array}}">
<view class="picker">
当前选择:{{array[index]}}
view>
picker>
<picker model:value="{{index}}" range="{{array}}">
<view class="picker">
当前选择:{{array[index]}}
view>
picker>
片 4
在上面这段代码里面,index 是我们从 bindPickerChange 这个 js 函数里面取到的,但同时我们又把它绑定到了 value 这个属性上面去(见上面代码)。
这是因为在小程序开发里面,这个属性的绑定,默认它不是双向绑定。我们可以从事件函数里面,在 change 事件函数里,取到新的值,但是这个值,怎么在界面上去展示,例如下面在「当前选择」文本后面的展示,这些都需要我们开发者自己手动去完成。
这个逻辑相对是清晰的,信息的获取,和展示,都由我们自己负责。
除了单向绑定,小程序还支持简单的双向绑定(见上面代码)。例如在 picker 的 value 属性前面,加一个 model:限定词,这就代表双向绑定。当 picker 的 value 变化时,会自动更新代码里的 index 变量;当 index 变量变化时,也会自动更新 model 的 value 值。
这实现上是一种语法糖,和 vue 框架里的双向绑定是类似的。在这里 model:value,相当于对组件添加了一个 valueChange 事件,在这个事件中自动更新了 index 变量。这部分代码是编译器在编译时自动为我们添加的,我们在源码里面是看不到的。
(可以从本课视频中查看示例及运行效果)
在 picker-view 中,indicator-style 与 indicator-class 这两个属性是做什么用的?
片 5
它们主要是用于控制选择项样式的,我们看一个效果图。
片 6
它们是用于指定中间选择的这一行容器组件的样式的,不管是有多少列选择项,被选择的永远都在中间那一列中,所以是一处样式控制它们的。其中 indicator-style 属性,直接写内联样式;indicator-class 是写在样式文件中的类样式名称。
因为组件封装了内部的实现,我们在外围没有办法直接修改内部组建的样式,所以微信团队提供了这两个属性。
但其实这种控制内部样式效果的方式,并不是最佳的。如果每一个内部组件它都有一个顶端类名称的话,我们在外面可以直接去重写子组件的类名称,也同样可以达到控制内部样式的目的。并且这种控制方式具有一定的通用性,还更加的灵活,更符合程序员的思维方式。
如何用 picker-view 实现一个多列选择器?例如三级联动的省市区选择器。
片 7
这也是本节课开始时我们提出的问题。
有人可能会说,picker 组件本身不就是有一个 mode 是 region,本身就支持省、市、区三级联动的吗?为什么还要自己写呢?并且还要使用 picker-view 去写,而不是使用 picker 的 multiSelector多级选择的模式去实现。
从架构上来看,虽然 picker 它支持多列选择这种方式,但是它的多列方式,是通过数据源绑定的方式去实现的,我们对于它的样式很难控制。
而 picker-view 呢,它是基于它的子组件 picker-view-column,这种松耦合的架构去实现的,它本身没有数据源,它的所有数据,都是在 picker-view-column 这个子组件里面,由开发者自己通过 wx:for 循环去绑定的。
对于 picker-view 的每一列,我们都可以自己去控制它的样式,还有数据,这给我们的自定义开发,提供了很大的方便。基于这一点来考虑,自定义实现多列的选择器,优先选择肯定是 picker-view 而不是 picker。
有人可能会说,picker 是底部滑出的,而 picker-view 是页面嵌入的。
picker-view 也可以实现同样的底部滑出效果,这一点不是问题。把 picker-view 放在滑出的面板上,就可以实现这样的效果了。至于嵌入组件的蒙层样式,可以通过 mask-style 或者 mask-class 控制,这两个属性是专门用于控制蒙层效果的,这一点也不是问题。
接下来我们就看一看,如何基于 picker-view 实现自定义的选择器。
片 8
上面是我们实现的选择器的运行效果。
miniprogram/components/region-picker-view/index.wxml:
<picker-view class="pick-view__group" bindchange="cityChange" value="{{value}}" wx:key="*this">
<picker-view-column indicator-class="item_active">
<view wx:for="{{provinces}}" class="picker-item" wx:key="index">{{item.name}}view>
picker-view-column>
<picker-view-column>
<view wx:for="{{citys}}" class="picker-item" wx:key="index">{{item.name}}view>
picker-view-column>
<picker-view-column>
<view wx:for="{{areas}}" class="picker-item" wx:key="index">{{item.name}}view>
picker-view-column>
picker-view>
片 9
在代码中,我们通过三个 picker-view-column,分别渲染省市区三个选择项。然后在 change 事件中。去改变后两列的数据源。因为第 1 列省,这个数据源是固定的,第 2 列城市,这一列的数据源,它取决于我们第 1 列选择了什么,而第 3 列又取决于第 2 列选择了什么。这就是我们说的联动,关于数据联动这块逻辑的处理,是放在 change 事件的句柄函数中去做的。
change 事件绑定的函数叫做 cityChange,接下来我们看一看这个函数的代码。
miniprogram/components/region-picker-view/index.js:
cityChange(e) {
var value = e.detail.value
...
var provinceNum = value[0]
var cityNum = value[1]
var areaNum = value[2]
if (this.data.value[0] !== provinceNum) {
var id = provinces[provinceNum].id
this.setData({
value: [provinceNum, 0, 0],
citys: address.citys[id],
areas: address.areas[address.citys[id][0].id]
})
} else if (this.data.value[1] !== cityNum) {
var id = citys[cityNum].id
this.setData({
value: [provinceNum, cityNum, 0],
areas: address.areas[citys[cityNum].id]
})
} else {
this.setData({
value: [provinceNum, cityNum, areaNum]
})
}
}
片 10
我们看到在源码中,对于数据的处理,分了三种情况:如果变动的是第 1 列省,那么第 2 列和第 3 列的数据,自动就会改变。第 2 种情况,改变的是城市这一列的选择值,那我们就需要处理 citys 这个数据源。而第 3 列,其实无论它怎么选择,它都不会影响前面两列,所以它不会引起数据变化。
接下来我们再看一看,关于省市区的数据格式是怎么样的。
miniprogram/components/region-picker-view/city.js:
var provinces = [{
"name": "北京市",
"id": "110000"
},...]
var citys = {
"110000": [
{
"province": "北京市",
"name": "市辖区",
"id": "110100"
}
],...]
var areas = {
"110100": [
{
"city": "市辖区",
"name": "东城区",
"id": "110101"
},...]
module.exports = {
citys,//cities
provinces,
areas
}
片 11
从上面的代码中可以看出来:第 1 个省这个数据,它是一个数组,它里面的每一个元素就是一个省的描述数据,有名称、有 id;第 2 个 citys(cities),就不是一个数组了,它是一个对象,这个对象里的 key,是前面省的 ID;第 3 个 areas,也是一个对象,和 citys 一样,它的 key 是前面城市的 ID。
在我们的 picker-view 组件里面,在选择了值以后,通过 change 事件派发,我们拿到的 value,是我们在 picker-view-column 绑定的数据源中的索引,是从 0 开始的。所以我们在处理省、市、区数据的时候,我们需要先通过索引取到省、市的 ID 号,然后再以省市 ID 去取到相应的数据。
(可以从本课视频中查看示例及运行效果)
对于目前实现的这个示例,有两个问题:
(一)第 1 个问题,我们最好不要在它的 change 事件里面,去改变视图。因为我们滑动的时候,可能会涉及到连续的,有多次的滑动,在用户没有选到目标值之前,可能会有多次的 change 事件派发。我们最好在用户选择结束之后,例如在他的 touchend 事件中,再去判断有没有变化,如果有变化的话,再去做改变相关数据源的事情。
通过测试发现,对于 picker 组件,当 mode 为 multiSelector 时,当手指滑动时,columnchange 事件会有多次派发。picker 组件的 change 事件,是在单击“确定”按钮之后才派发的。而对于 picker-view 组件,在滑动选择的过程当中,它的 change 事件不派发,change 事件只在选定之后派发了一次。
在 picker-view 组件中,它还有一个 pickstart 和一个 pickend 事件,分别代表滚动选择的开始和结束,有了这两个事件,我们就没有必要从 touchend 事件中自己去做状态的判断了。我们可以从 change 事件中拿到 value,存储起来,然后在 pickend 事件里,在选择结束的时候再做处理。
从以上两点来看,picker-view 组件的设计,是优于 picker 组件的。picker 组件的功能,其实使用 picker-view 也是完全可以实现的。
这是第 1 点。
(二)第 2 点,就是我们这个关于交互的操作,最好是写在 wxs 模块里面,而不要在 js 里面去做这件事情。减少视图层与逻辑层之间的通讯,可以显著提高界面的流畅性。
那基于以上 2 点呢,我们接下来使用 wxs 脚本,开始尝试把这个自定义组件改写一下。
miniprogram/components/region-picker-view2/index.js:
// var address = require('./city')
Component({
options: {
multipleSlots: false
},
properties: {},
data: {
value: [0, 0, 0], // 地址选择器省市区 暂存 currentIndex
regionText: '', //所在地区
provinces: null, // 一级地址
citys: null, // 二级地址
areas: null, // 三级地址
visible: false
},
ready(){},
methods: {}
})
片 12
在改写之前,我们所有的逻辑代码,都是在 js 文件里的。在改写之后,我们所有的逻辑代码都挪到了 wxs 模块里面,而在原来的 js 文件里面,只剩下了数据源的定义(见上面代码),只是数据定义,没有数据,数据是我们在 wxs 模块里面去设置的,稍后我们就可以看到。
我们知道在 wxs 模块里面,没有办法触发视图更新。唯一的办法就是通过页面实例对象的 callMethod 方法,间接调用 setData 进行更新。但是在调用之前呢,我们首先要有一个组建描述对象,而这个组件描述对象呢,目前我们又只能从绑定的事件函数的参数列表里面,才可以拿到,那在开始的时候,用户还没有互动呢,没有事件,那这个时候我们怎么把数据绑定上去呢?这条道路是不是不通啊?
我还设想过,把数据分别写成 js 和 wxs 两种格式,存成两个文件,分别在两个地方处理。也就是说,在 js 文件里面,页面加载的时候,我们先设置一下省、市、区的初始值,然后后续数据更新的时候,再在这个 wxs 里面去新数据。但是这样的问题也显而易见啊,因为它会给我们的维护带来麻烦,同样的数据要写成两个文件,它们的格式并不完全一样,这对我们来说是一种额外的维护负担。可见这种方法,虽然能行得通,但是很蹩脚,不是一种好方法。
我还想过另外一个办法,就是让自定义组件 pop-up——这是我们在自定义组件中引用的另一个自定义组件,它的作用就是为了创造一个底部滑出的窗口,在popup的ready周期函数里面 ,派发出来一个ready事件,然后我们在mxml代码里面去监听这个事件,把这个事件绑定到我们的wxs模块里边的js函数上,这样不就可以拿到一个组件描述对象了吗?
Cannot use wxs function to handle custom event "ready"
片 13
但是这个方法也是行不通的。当我这样做的时候,微信开发者工具给我报了这样的一条警告(见上面),大概意思是说,在 wxs 模块里面定义的函数,不能用于处理自定义事件的。
最后这个问题的解决,我先是通过一个逻辑或运算符解决的,我们看一下代码。
miniprogram/components/region-picker-view2/index.wxml:
<picker-view class="pick-view__group" bindpickstart="{{region.onPickStart}}" bindpickend="{{region.onPickEnd}}" bindchange="{{region.onChange}}" value="{{value}}" wx:key="*this">
<picker-view-column indicator-class="item_active">
<view wx:for="{{provinces || region.provinces}}" class="picker-item" wx:key="index">{{item.name}}view>
picker-view-column>
<picker-view-column>
<view wx:for="{{citys || region.citys}}" class="picker-item" wx:key="index">{{item.name}}view>
picker-view-column>
<picker-view-column>
<view wx:for="{{areas || region.areas}}" class="picker-item" wx:key="index">{{item.name}}view>
picker-view-column>
picker-view>
片 14
在上面代码中我们看到,在第一个 picker-view-column 组件上,我们通过 wx:for 绑定数据源时,先取的是 js 里面的 provinces,然后再取的是 region,也就是 wxs 模块里的 provinces(见上面代码)。后面关于城市和区域的绑定方法,与第一列省的绑定方法是类似的。
虽然 wxs 不能触发视图更新,但是它可以提供第 1 次的数据绑定。所以在这里呢,我们取了个巧,第 1 次的数据是由 wxs 模块提供的,而当我们开始互动以后,我们会通过 setData 去设置互动之后的新数据,这个时候 js 文件里面的数据源就已经有数据了。这种方法,让我们对数据的维护及管理,是在一个地方进行的,这方便了我们的开发,代码也更加清晰。
但是代码改造到这里,就算完美了吗?肯定不是的。
目前在我们的wxs模块里面,以及js代码里面,现在可是存在了两份省、市、区数据源的定义。这可能是不太合理的。软件信息的定义要清晰明确,一个信息只能在一个地方定义,这样维护起来才简单。
对wxs模块,小程序还有一个WxsPropObserver机制,这个机制允许我们在wxml组件上,以wxs模块里定义的js函数,监听属性的变化,并且不只是变化之后,才可以触发函数,第一次渲染时也能触发。
<view change:class="{{region.onPropSigned}}" class="address-item" ....>
...
view>
region.onPropSigned = function(newValue, oldValue, ownerInstance, instance){
ownerInstance.callMethod("setData", {
provinces: region.provinces
, citys: region.citys
, areas: region.areas
, value: [0, 0, 0] // 地址选择器省市区 暂存 currentIndex
, regionText: ''
})
}
<picker-view-column indicator-class="item_active">
<view wx:for="{{provinces}}" class="picker-item" wx:key="index">{{item.name}}view>
picker-view-column>
片15
在上面这个代码中,我们在wxs模块里定义了一个onPropSigned函数,这个函数有四个形参,前两个是新旧值,第三个ownerInstance并不一定是页面对象,它是触发事件的组件的父组件的ComponentDescriptor 实例,只有当触发事件的父组件是页面时,它也是页面对象。
但无论是不是页面对象,都没有关系,它都有ComponentDescriptor 描述,都有callMethod方法,这对我们当前已经足够了。在这个事件函数中,我们将js里面的数据源默认值都给定义好了,原来在绑定数据时使用的逻辑或操作符,现在也不需要了。
好了,现在我们再看一下两个选择器的实现方法,它们的运行效果是一样的。后面这种方式的实现,在效率上应该是优于前面纯 js 实现的方法的。小程序自定义组件,优先选择在 wxs 模块里面,完成主要逻辑甚至全部逻辑的编写。
(可以从本课视频中查看示例及运行效果)
好,今天的课就讲到这里,今天主要介绍了使用两种方式,自定义实现省、市、区三级联动的选择器。在小程序的自定义组件开发中,我们要以第 2 种方式为主。下节课我们开始介绍滑动选择器,并着手实现一个竖向的 slider 组件,并且也是在 wxs 模块里实现的。
2020 年 5 月 25 日