前言

作为从事前端开发的你肯定见过不少的弹框组件,你可曾有想过要自己实现一个弹框组件库,又或者想完全定制化的使用各种标准UI框架中的弹框组件呢?


今天这篇文章将会带着你解析这一系列疑问,以vant-weapp组件库为例,从开发标准的弹窗组件使用到高度定制复合自我审美的弹窗,再到完全研究清楚vant-weapp框架弹窗组件部分源码。


一、vant-weapp弹窗组件介绍

vant-weapp组件库是有赞团队开发的一款灵活简洁且美观的小程序UI组件库,此文将以这个组件库的用法为标准,下文提及的弹框组件均指的是此组件库中的弹框。


弹框分类

vant-weapp中弹框主要分为两大类:弹出层Popup和对话框Dialog,弹出层一般是带有背景遮罩层和内容展示区域用于在不跳转页面情况下进行详情的展示作用,对话框多数用于带有详情展示的同时还带有希望用户确认等操作。如下图所示,图左为典型的Dialog,图右为典型的Popup。


image.png


注册小程序组件

在使用弹框组件之前记得在小程序的app.json文件中先注册组件,详细介绍见快速上手,例如注册van-popup组件代码如下:


// app.json
"usingComponents": {
  "van-popup": "path/to/@vant/weapp/dist/popup/index"
}


在项目中实际使用如下:


image.png


在本文后续分析van-dialog源码中会发现在dialog的index.json中也定义过van-popup组件,但是我们要直接实行van-popup组件必须在小程序的配置文件app.json中按照上图方式进行定义,微信小程序官网说明过自定义组件内部的引入组件只在该组件内生效


注册完组件之后,就可以直接在小程序页面中使用这里注册的自定义组件,组件名称为这里key,例如:<van-popup>。


二、Popup基本用法


常见用法

最常见的用法就是直接使用van-popup组件,通过组件的show属性来控制其是否展示,组件内部嵌套的其他组件或标签是popup组件的内容,如下所示:


// wxml
<button bindtap="showPopup">展示弹出层</button>
<van-popup
    show="{{ show }}"
    position="top"
    bind:close="onClose"
  closeable
>内容</van-popup>

// js
Page({
  data: {
    show: false
  },
  showPopup() {
    this.setData({ show: true });
  },
  onClose() {
    this.setData({ show: false });
  }
});


重点属性分析

van-popup组件可以通过position属性的五个值:center、top、right、bottom、left来快捷的控制是从哪个位置弹出,例如:上例中的弹框从上往下弹出


可以通过round属性来控制弹窗内容是否显示圆角,closeable可以决定是否显示关闭弹框的图标按钮,例如:上例中的弹窗将不显示圆角,同时显示关闭按钮


各种基本的弹窗形式如下:


image.png


三、Dialog对话框基本用法

对话框则是在popup弹出层的基础上添加了额外的内置的标题,快速确定按钮等组件,用于消息提示、消息确认等场景,下面看看其常见用法。


常规用法——带标题

最常规的用法就是直接使用van-dialog组件,通过组件的show属性来控制其是否展示,组件内部嵌套的其他组件或标签是dialog组件的内容,如下所示:


// wxml
<van-dialog
  title="标题"
    message="代码是写出来给人看的,附带能在机器上运行"
  show="{{ show }}"
  confirm-button-open-type="getUserInfo"
  bind:close="onClose"
  bind:getuserinfo="getUserInfo"
>
  <image src="https://img.yzcdn.cn/1.jpg" />
</van-dialog>

// js
Page({
  data: {
    show: true
  },
  getUserInfo(event) {
    console.log(event.detail);
  },
  onClose() {
    this.setData({ close: false });
  }
});


常规用法——无标题


直接使用van-dialog组件,通过组件的show属性来控制其是否展示,组件内部嵌套的其他组件或标签是dialog组件的内容,不使用use-title-slot且不传递title属性,如下所示:


// wxml
<van-dialog
  show="{{ show }}"
  confirm-button-open-type="getUserInfo"
  bind:close="onClose"
  bind:getuserinfo="getUserInfo"
>
  <view class="message">代码是写出来给人看的,附带能在机器上运行</view>
</van-dialog>

// js
Page({
  data: {
    show: true
  },
  getUserInfo(event) {
    console.log(event.detail);
  },
  onClose() {
    this.setData({ close: false });
  }
});


上述两种用法中的use-slot属性表示使用默认的slot(即van-dialog嵌套的wxml内容,比如此处的<view class="message">...)来作为内容输出,bind:getuserinfo等是使用的微信开发能力(即在点击确定按钮时候对调用微信平台API获取到对应的信息,此处是获取用户个人身份信息)


函数式调用——confirm


最常规的另一种用法就是直接使用Dialog、Dialog.alert、Dialog.confirm的方法快速打开弹窗组件,关闭弹框组件则通过Dialog.close,取消弹框的加载状态则使用Dialog.stopLoading,组件内部嵌套的其他组件或标签是dialog组件的内容,如下所示:


// wxml
<van-dialog id="van-dialog">
import Dialog from 'path/to/@vant/weapp/dist/dialog/dialog';

// js
Dialog.alert({
  title: "标题"
  message: '代码是写出来给人看的,附带能在机器上运行'
}).then(() => {
  // on close
});


这里使用函数调用一定要注意在使用van-dialog的页面的wxml中一定需要写这个<van-dialog id="van-dialog" />来使用组件,下文在分析dialog的源码中会讲到(卖个关子),或者你可以先猜一猜😊😊


上面三种van-dialog的常规使用方法的效果如下:


image.png


四、Dialog进阶用法

下面将会提供几个作者在实战中写出的Dialog对话框组件的实战用法


使用use-title-slot定制标题


<van-dialog
  id="van-dialog"
  show="{{ dialogShow }}"
  message="资质原件拍照或扫描可以不加盖公章,复印件需盖章\n\n如是三证合一,则无需提供税务登记证、组织机构代码证"
  message-align="left"
  confirm-button-text="知道了"
  confirm-button-color="#EE712F"
  use-title-slot
>
  <view slot="title" class=" merchant-dialog__title">
    <view class="merchant-dialog__title-text">开户前,请准备以下资料</view>
    <van-icon name="cross" size="40rpx" class="merchant-dialog__title-icon" bindtap="closeDialog" />
  </view>
</van-dialog>
// 样式部分的代码此处省略


触发弹框显示

handleButtonClick1: function () {
  this.setData({
    dialogShow: true
  })
},


此例子如要使用了如下特性:


对应的效果如下:


image.png


使用use-slot定制提示内容


<van-dialog
  id="van-dialog-2"
  use-slot
  use-title-slot
>
  <view slot="title" style="padding-bottom: 10px;">
    <van-icon name="close" color="#fff" size="30" bindtap="closeDialog2" />
  </view>
  <image class="image" src="https://tva1.sinaimg.cn/large/0082zybply1gbylbcwm44j30rs13bdsg.jpg" mode="aspectFit"></image>
</van-dialog>


通过函数调用方式触发弹框显示

handleButtonClick2: function () {
  Dialog({
    selector: '#van-dialog-2',
    showConfirmButton: false,
    closeOnClickOverlay: false,
    className: 'dialog2',
    width: '260px'
  })
},


此例子如要使用了如下特性:


对应效果如下:


image.png


使用css变量定义主题


<van-dialog
  id="van-dialog-3"
  use-title-slot
>
  <view slot="title" style="color: #000;">提示</view>
  <view>
    <view>为了给你推荐更合适的漫展~</view>
    <view>请开启定位权限~</view>
  </view>
</van-dialog>


通过函数调用方式触发弹框显示

handleButtonClick3: function () {
  Dialog({
    selector: '#van-dialog-3',
    showCancelButton: true,
    cancelButtonTrext: '取消',
    confirmButtonText: '去设置',
    cancelButtonColor: '#C46B85',
    confirmButtonColor: '#C46B85',
    message: '为了给你推荐更合适的漫展~\n请开启定位权限~',
    confirmButtonOpenType: 'openSetting',
    width: '260px',
    className: 'dialog3'
  })
},


外部样式类

.dialog-index--dialog3 {
  --dialog-background-color: rgba(255,255,255,0.8);
  --popup-background-color: rgba(255,255,255,0.8);
  --button-default-background-color: transparent;
  color: #666;
}


此例子如要使用了如下特性:


对应效果如下:

image.png


五、开发实际场景中的弹窗组件

如果你仔细看过上面进阶用法中的三种自定义方式的实现代码应该也可以根据UI需求实现自己的弹窗交互效果;这里我已经基于前面提到的三种用法来开发了几个实际场景中的弹框组件:

image.png


这部分的可以直接去看源码https://github.com/JohnieXu/vant-weapp


也可以扫码这个小程序二维码查看效果

image.png


六、源码分析的前置条件

在看完上面几种炫酷的弹框效果后,我们还是按照惯例研究下如此强大的弹框组件的源码。在研究弹框部分源码之前有必有分析一下一套完整UI框架所需要注意的框架级别的整体架构


如何使用less工程化处理样式

处理样式是所有UI框架比不可忽略的核心逻辑之一,在vant-weapp中对样式的处理主要分为以下三部分;源码对应结构如下图所示,使用less的mixins复用实现主题变量控制、公共样式抽离等。


image.png


主题变量

在var.less文件定义了框架所用到的全部的样式控制相关的变量,其中与弹框相关的部分源码如下:


// Dialog
@dialog-width: 320px;
@dialog-small-screen-width: 90%;
@dialog-font-size: @font-size-lg;
@dialog-border-radius: 16px;
@dialog-background-color: @white;
@dialog-header-font-weight: @font-weight-bold;
@dialog-header-line-height: 24px;
@dialog-header-padding-top: @padding-lg;
@dialog-header-isolated-padding: @padding-lg 0;
@dialog-message-padding: @padding-lg;
@dialog-message-font-size: @font-size-md;
@dialog-message-line-height: 20px;
@dialog-message-max-height: 60vh;
@dialog-has-title-message-text-color: @gray-7;
@dialog-has-title-message-padding-top: @padding-sm;

源码:var.less


此文件中的最终会转换成css变量,并非像antd、iview等网页端框架中的样式处理那样编译成变量指向的值。根据css变量作用域的特性,可以在自定义组件的外部样式类中局部覆盖样式变量来改变组件内部的样式。


通用样式

像清除浮动、文字省略、1像素边框等通用的样式类的处理在mixin文件夹下


清除浮动

.clearfix() {
  &::after {
    display: table;
    clear: both;
    content: '';
  }
}

使用常见的after伪类来实现清除浮动


文件省略

.multi-ellipsis(@lines) {
  display: -webkit-box;
  overflow: hidden;
  text-overflow: ellipsis;
  -webkit-line-clamp: @lines;
  /* autoprefixer: ignore next */
  -webkit-box-orient: vertical;
}
.ellipsis() {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

使用less的函数封装了两个处理文字省略方法:单行省略、多行省略


BEM命名


如何工程化的注册自定义组件

微信小程序官方提供了Component构造方法注册自定义组件,为了结合typescript给自定义组件提供更灵活强大的组件注册器对Component进行了下面的功能封装处理


封装通用组件构造方法


function VantComponent<Data, Props, Methods>(
  vantOptions: VantComponentOptions<
    Data,
    Props,
    Methods,
    CombinedComponentInstance<Data, Props, Methods>
  > = {}
): void {
  const options: any = {};
  mapKeys(vantOptions, options, {
    data: 'data',
    props: 'properties',
    mixins: 'behaviors',
    methods: 'methods',
    beforeCreate: 'created',
    created: 'attached',
    mounted: 'ready',
    relations: 'relations',
    destroyed: 'detached',
    classes: 'externalClasses'
  });
  const { relation } = vantOptions;
  if (relation) {
    makeRelation(options, vantOptions, relation);
  }
  // 给所有组件添加默认外部样式类custom-class
  options.externalClasses = options.externalClasses || [];
  options.externalClasses.push('custom-class');
  // 给所有组件添加默认behaviors
  options.behaviors = options.behaviors || [];
  options.behaviors.push(basic);
  // map field to form-field behavior
  if (vantOptions.field) {
    options.behaviors.push('wx://form-field');
  }
  // 默认启用多slot支持、组件中允许全局样式修改
  options.options = {
    multipleSlots: true,
    addGlobalClass: true
  };
  // 最终使用官网构造方法构造组件
  Component(options);
}

源码:component.ts


behaviors复用共享逻辑

behaviors是微信小程序官方用于组件复用data、methods等属性方法的一种方式,和vue中的mixins小作用一致,vant-weapp中定义的mixins如下图所示:


image.png


其中basic是所有自定义组件都复用的一个mxin,给所有自定义的组件提供了三个方法:$emitsetgetRect



源码如下:

// basic.ts
export const basic = Behavior({
  methods: {
    $emit(...args) {
      this.triggerEvent(...args);
    },
    set(data: object, callback: Function) {
      this.setData(data, callback);

      return new Promise(resolve => wx.nextTick(resolve));
    },
    getRect(selector: string, all: boolean) {
      return new Promise(resolve => {
        wx.createSelectorQuery()
          .in(this)[all ? 'selectAll' : 'select'](selector)
          .boundingClientRect(rect => {
            if (all && Array.isArray(rect) && rect.length) {
              resolve(rect);
            }

            if (!all && rect) {
              resolve(rect);
            }
          })
          .exec();
      });
    }
  }
});

源码:basic.ts


生命周期命名

其实生命周期如何命名到不是很重要,vant-weapp对命名进行了转换主要基于以下两个原因:


function mapKeys(source: object, target: object, map: object) {
  Object.keys(map).forEach(key => {
    if (source[key]) {
      target[map[key]] = source[key];
    }
  });
}
mapKeys(vantOptions, options, {
  data: 'data',
  props: 'properties',
  mixins: 'behaviors',
  methods: 'methods',
  beforeCreate: 'created',
  created: 'attached',
  mounted: 'ready',
  relations: 'relations',
  destroyed: 'detached',
  classes: 'externalClasses'
});

源码:component.ts#L24


通过mapKeys方法对VantComponent中传入的生命周期函数进行了转换,转换名生命周期名称与微信小程序一致


自定义组件的样式隔离

微信小程序自定义组件默认样式作用域的范围是为当前组件,也就是说组件文件夹下的wxss中的样式只对该文件夹下的wxml生效(除去标签名、ID选择器)


这种以组件为单位进行样式隔离的模式类似于React框架中处理的组件样式的库styled-components


组件间样式共享

要在组件之前共享样式或者让自定义组件接受外部样式,可行方案有如下几种:


方案

用法

说明

styleIsolation属性配置

1.page-isolated 表示在这个页面禁用 app.wxss ,同时,页面的 wxss 不会影响到其他自定义组件;

2.page-apply-shared 表示在这个页面禁用 app.wxss ,同时,页面 wxss 样式不会影响到其他自定义组件,但设为 shared 的自定义组件会影响到页面;

3.page-shared 表示在这个页面禁用 app.wxss ,同时,页面 wxss 样式会影响到其他设为 apply-shared 或 shared 的自定义组件,也会受到设为 shared 的自定义组件的影响。

styleIsolation 选项从基础库版本 2.6.5 开始支持

addGlobalClass属性配置

表示页面 wxss 样式将影响到自定义组件,但自定义组件 wxss 中指定的样式不会影响页面

1.小程序基础库版本 2.2.3 以上支持

2.等价于styleIsolation: apply-shared

3.vant-weapp中使用的是此方案addGlobalClass: 'true',默认的styleIsolation: 'shared'不生效

externalClasses外部样式类

组件的使用者可以指定这个样式类对应的外部样式名 ,对应样式名的样式在组件内部生效

1.基础库 1.9.90 开始支持

2.vant-weapp中也支持此方式

3.推荐使用此方式来自定义vant-weapp的样式

class="~blue-text"引用父组件样式

即使启用了样式隔离 isolated ,组件仍然可以在局部引用组件所在页面的样式或父组件的样式

基础库 2.9.2 开始支持



使用总结

使用vant-weapp组件库的使用者最佳的自定义组件样式的方式是:采用外部样式类+CSS变量,在无相关CSS变量时才用自己的样式+ !important 确保样式优先级,在自定义组件中使用vant-weapp的组件时候的注意事项参照样式覆盖


自定义组件通信方案

自定义组件通信主要包括组件参数传递事件监听,这两个功能都是微信小程序官网提供的;参数传递是由父传到子的单向传递,而事件监听则是相应原生事件或者自定义事件。自定义事件用于对组件的事件进行封装,自定义事件机制如下:

image.png

这里在van-dialog组件使用位置监听bindclick事件,最终这个事件会在van-dialog组件内部的button的tap时被触发,后面源码分析中的自定义组件的自定义事件全部采用的此种模式。


七、Popup弹出层组件源码分析

组件部分源码结构

popup组件部分源码结构如下:


image.png


组件的命名规范与微信小程序自定义组件的规范相符合(README.md为组件的使用说明文档,用于生成官网的组件文档说明)。


popup组件的配置文件标识当前的index为组件,通过using-components引入了van-iconvan-overlay组件,在对应的wxml中可以直接使用。


组件主要逻辑

弹出层组件主要分类遮盖层内容层,内容层嵌套在遮盖层内部来确保视觉上覆盖在遮盖层之上。


遮盖层及事件

遮盖层通过overlay、overlayStyle等组件属性来控制其是否显示以及遮盖层的样式等,遮盖的事件有onClickOverlay,通过$emit触发组件的自定义事件close。


onClickOverlay() {
  this.$emit('click-overlay');

  if (this.data.closeOnClickOverlay) {
    this.$emit('close');
  }
}


关闭按钮及事件

通过closable属性决定是否显示默认的关闭按钮,也可以通过关闭图标相关属性配置更改按钮样式,关闭按钮的事件有onClickCloseIcon,通过$emit触发组件的自定义事件close。


onClickCloseIcon() {
  this.$emit('close');
},


内容分发

接受一个默认的slot,其位置根据传入的position参数不同有top、right、bottom、left、center五种,根据这五种位置参数有对应的五种不同的弹出位置和动画


过渡动画

使用transform来实现动画效果,根据position参数的五种情况有五种默认动画


// popup/index.less
.van-bottom-enter,
.van-bottom-leave-to {
  transform: translate3d(0, 100%, 0);
}
.van-top-enter,
.van-top-leave-to {
  transform: translate3d(0, -100%, 0);
}
.van-left-enter,
.van-left-leave-to {
  transform: translate3d(-100%, -50%, 0);
}
.van-right-enter,
.van-right-leave-to {
  transform: translate3d(100%, -50%, 0);
}


同时暴露了外部样式类可以用来自定义动画,这里动画阶段划分和vue相同,分类:enter、enter-active、enter-to、leave、leave-active、leave-to


// popup/index.ts
VantComponent({
  classes: [
    'enter-class',
    'enter-active-class',
    'enter-to-class',
    'leave-class',
    'leave-active-class',
    'leave-to-class'
  ],
  ...
}


八、Dialog对话框组件源码分析

组件部分源码结构

dialog组件部分源码结构如下:


image.png


结构同popup组件,不同点在于index.json使用了van-popup、van-button组件,以及多了dialog.ts这个暴露API函数调用方法的文件。


组件布局结构

dialog组件整体基于popup组件,在其默认slot中添加了顶部标题的slot和按钮组元素,大致结构如下


image.png

源码结构:

// dialog/index.wxml
<van-popup
  show="{{ show }}"
  ...
>
  <view
    wx:if="{{ title || useTitleSlot  }}"
    class="van-dialog__header {{ message || useSlot ? '' : 'van-dialog--isolated' }}"
  >
    <slot wx:if="{{ useTitleSlot }}" name="title" />
    <block wx:elif="{{ title }}"> {{ title }}</block>
  </view>

  <slot wx:if="{{ useSlot }}" />
  <view
    wx:elif="{{ message }}"
    class="van-dialog__message {{ title ? 'van-dialog__message--has-title' : '' }} {{ messageAlign ? 'van-dialog__message--' + messageAlign : '' }}"
  >
    <text class="van-dialog__message-text">{{ message }}</text>
  </view>

  <view class="van-hairline--top van-dialog__footer">
    <van-button
      wx:if="{{ showCancelButton }}"
      ...
    >
      {{ cancelButtonText }}
    </van-button>
    <van-button
      wx:if="{{ showConfirmButton }}"
      ...
    >
      {{ confirmButtonText }}
    </van-button>
  </view>
</van-popup>


函数式调用实现

在前面函数式调用——confirm中通过Dialog函数调用来打开弹出框组件,实现函数式调用的核心思路主要是:通过selectComponent(selector)方法根据ID查找(类似于查找DOM、Vue中查找组件实例)对页面中定义渲染好的dialog组件,手动更新其组件实例的数据。


Dialog方法定义如下:

const Dialog: Dialog = options => {
  options = {
    ...Dialog.currentOptions,
    ...options
  };
  return new Promise((resolve, reject) => {
    // context为当前查找组件实例的上下分,默认是当前页面,默认根据ID=van-dialog进行查找
    const context = options.context || getContext();
    const dialog = context.selectComponent(options.selector);
    delete options.context;
    delete options.selector;
    if (dialog) {
      dialog.setData({
        onCancel: reject,
        onConfirm: resolve,
        ...options
      });
      queue.push(dialog);
    } else {
      console.warn('未找到 van-dialog 节点,请确认 selector 及 context 是否正确');
    }
  });
};


函数式调用时候根据传入的options配置去更新找到的组件实例上的属性


函数式调用必须现在保证页面wxml中存在van-dialog组件,自定义context上查找时要确保该上下文上存在van-dialog组件


由微信小程序自定义组件限制不能更新slot,slot需要用组件嵌套来传入


函数式调用中的options会有默认值强制覆盖掉van-dialog组件属性处传入的非id等其他属性,即函数调用的时通过组件传入的属性无效


Dialog.confirm

确认弹窗

调用方法

Dialog.confirm({
    selector: '#van-dialog',
    title: '提示',
    message: '这里放置提示内容'
})


实现方式

Dialog.confirm = options =>
  Dialog({
    showCancelButton: true,
    ...options
  });


调用Dialog时候默认执行定了显示取消按钮,其他无区别


Dialog.close

关闭弹窗

调用方法

Dialog.close()


实现方式

Dialog.close = () => {
  queue.forEach(dialog => {
    dialog.close();
  });
  queue = [];
};


遍历内部缓存的所有调用Dialog方法找到的van-dialog组件实例,执行其close方法


Dialog.setDefaultOptions

更改对话框默认配置

调用方法

Dialog.setDefaultOptions(options)


实现方式

Object.assign(Dialog.currentOptions, options);


通过Object.assign将传入的默认配置合并到内部Dialog.currentOptions配置上


Dialog.resetDefaultOptions

恢复对话框默认配置

调用方法

Dialog.resetDefaultOptions()


实现方式

Dialog.resetDefaultOptions = () => {
  Dialog.currentOptions = { ...Dialog.defaultOptions };
};


 恢复Dialog.currentOptions配置为Dialog.defaultOptions


总结

本文讲解了vant-weapp组件库中的弹框组件的基本用法、进阶用法、定制主题、自定义内容等用法,同时还更进一步的研究了vant-weapp组件中的popup组件、dialog组件的实现。也只有彻底弄懂了UI框架的封装思路我们才能更进一步的修改框架来定制化更复杂更贴合项目要求的各种组件,本文按照由实用到进阶再到研究源码的思路为各位研究框架源码提供另一种方法。


下一步将会在vant-weapp弹框组件之上封装一系列实战的案例,期待你的关注与收藏。


若此文对你有一点点帮助请点个赞鼓励下作者,毕竟原创不易:)


首发自语雀:https://www.yuque.com/johniexu/frontend/gcwh2v


作者博客地址:https://blog.lessing.online/


作者github:https://github.com/johniexu