命令式选择器

引子

业务中常有弹窗选择器这样的需求:

  1. 用户触发事件,弹窗弹出
  2. 用户选择(操作)弹窗内容,产生结果
  3. 弹窗关闭,页面获得结果

在使用vue或react等MVVM框架开发时,我们一般会开发一个AddressPicker,TimePicker等等之类的弹窗组件,然后引入,放在页面上,传入props,绑定回调

仔细想想,是不是有些繁琐,而且这样一个业务实际上与页面不应该耦合这么深,它应该是一个独立于页面之外的组件.侵入性不应该这么强.

审查上述3个步骤,会发现它跟window.prompt非常像,无非就是第2个步骤比在一个input里输入复杂一些罢了.
如果我们能够让弹窗以这种命令式的方式调用,那这种方式无疑是侵入性最低的.使用它的步骤也要少很多,引入,调用,获得结果.影响只局限于调用的地方.

实现思路

如果我们要实现一个prompt或者confirm之类的方法,最接近原生的用法就是promise+async/await.
如题图那样

1
let result = await pickSomething(opt)

那么这个方法里肯定是需要返回promise的

1
const pickSomething = opt => new Promise(resolve=>{})

而弹出弹框,其实就是将组件挂载到页面中.这种事情其实是平时做的极少的—不管是vue还是react,都有根节点的概念,只有入口文件挂载根节点我们才会跟dom打交道,而这都是自动生成模板或直接复制黏贴的.
react使用render挂载,而vue则直接操作vue实例的$el属性即可–它其实就是一个dom.
以vue为例

1
2
3
4
function createVm(opt) {
const vm = new Com({data: opt}).$mount()
document.body.appendChild(vm.$el)
}

这样就将组件弹出来了.
那么组件内如何通知方法调用者呢.其实直接把resolve作为prop或者属性传给组件即可,组件直接使用this.resolve()即可使外部promise获得结果.

1
2
3
4
5
6
7
8
9
10
// 调用方法
const pickSomething = (opt = {}) => new Promise(resolve => {
opt = {...dftOpt, ...opt, resolve}
createVm(opt)
})

// 组件内
confirm() {
this.resolve({success: true, data: payload})
}

以下是一个大致的框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Main from './Main'
import Vue from 'vue'

const Com = Vue.extend(Main)

function createVm(opt) {
const vm = new Com({data: opt}).$mount()
document.body.appendChild(vm.$el)
vm.show = true
}

const dftOpt = {
list: [],
value: ''
}

export default (opt = {}) => new Promise(resolve => {
opt = {...dftOpt, ...opt, resolve}
createVm(opt)
})

组件内需要注意的就是需要在关闭–无论是取消还是确认后,销毁组件并从页面中移除相关dom.

进阶

以上在数据固定的情况下已经够用了.但如果情况复杂些,数据是不固定的.是由用户在弹窗中的操作动态产生的.比如地址三级联动.难道我们要把所有地址全部传进去,并且在组件里指定如何更新数据吗?
这样显然不够通用,而且数据应该由外部产生.组件只是数据的消费者.
比较合适的解决方式就是在option中增加一个获取最新数据的方法.组件内发生需要更新数据的情况下,调用它获得最新数据.这有可能是异步,因此也应该返回promise.

1
2
3
4
5
6
7
8
9
10
// 调用时添加
const dftOpt = {
getList: payload => new Promise(resolve => resolve([]))
}
// 组件内部
async onFresh() {
// 将获得新数据的参数,三级联动即最新的勾选项,选了什么省之类的
let result = await this.getList(this.value)
this.list = result
}

示例代码

使用方式
命令式函数
组件

组件内缓存结果

如果再复杂一些,弹窗内展示的数据是一个分页加载的列表,而我们需要从列表中选择一部分作为结果返回,并且再次打开时,已选的结果要能继续展示.

一步一步来.

  1. 分页加载
    按照上述思路可以很容易解决.通过getList,我们每次将最新的查询参数抛出,由外部返回一个新的列表,我们甚至还可以给组件增加搜索,刷新,修改pageSize等功能.
  2. 勾选
    实现这个需要我们修改数据,或者增加一个对照组.
    而value实际上就是一个对照组.我们可以利用它来缓存用户的勾选结果,勾选状态变化时,相应地操作value,当最终确认的时候,才将其作为结果抛出.
    除此之外,我们还需要修改传入的list,在组件的生命周期内,getList可能会多次调用,而value是一以贯之的,这也是对照的意义所在.我们在getList中,参照value,将其打上标记,为了避免污染公共数据,最好将其clone一份.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    async onFresh() {
    let res = await this.getList({...params, keywords: this.keywords})
    if (res.success) {
    this.list = res.data.map(it => {
    it = {...it}
    // 示例,请按实际需求修改
    let index = this.valueGuids.findIndex(i => i === it.guid)
    if (index !== -1) {
    it.isPicked = true
    it.count = this.value[index].count
    }
    return it
    })
    }
    }

补充, 这种模式下,list就需要处理后再传入了,或者更统一的做法,不传list,而是由组件内派发获取初始数据的事件.统一使用getList获取数据

因为每次调用pickSomething方法,都会创建一个新的组件实例,所以在页面有多个位置调用相同服务的情况下,它们的value不会互相污染,组件内不需要写大量区别调用者的逻辑.
而在调用上增加的复杂性,可以通过封装来解决,在组件和页面之间,增加一层服务层,即上述进阶部分,以此来降低页面和组件间的耦合程度.提高各自的可维护性.