废话

ElementUI 可以说是前端特别是使用 Vue 的开发者家喻户晓的后台管理组件库了。开发者对于一个经常使用的东西,最好是能了解它的原理,减少日常使用产生的 bug,以及方便之后能根据这些进行一些魔改去满足一些需求。

前言

之前在业务开发中遇到过一个日期选择器的需求,和 el-date-picker 组件大体比较类似,但是一些功能还是有些不同,于是研究了一下 Vue2 版本 el-date-picker 源码然后做了相应修改去满足需求。

所以在这里记录一下自己关于 el-date-picker 的一些源码解析(基本的逻辑和主要是 type="dates" 类型也就是多选类型的组件代码),学习下组件库的一些代码编写方式以及一些实现原理,还有自己根据需求修改源码时的一些思路。

为了方便理解,符合平常使用时的直观感受,我解析的方向会是结构,显示,交互,结束。从这些肉眼可见的直观层面入手。各位对其他细节方面有兴趣的可以自己继续深挖。🌝

说明:
本文说的主要都是 Vue2 版本的 ElementUI 里的代码, Vue3 代码组织方式和 Vue2 区别挺大的,不过大部分的组件逻辑还是比较相同的。并且说的 DatePicker 组件也主要是 type="dates" 多选日期类型,其他类型涉及到的逻辑和显示代码也很多,放一篇文章里太多了。

在 Vue2 版本的这个组件里,还涉及到了其他 time-picker 的逻辑之类的,不是本文关注的重点,所以后面会跳过这部分,主要讲 date-picker 相关的部分。

🌚看完本文大概能了解到下面的这些内容。

  • el-date-picker 组件组成部分,组件库组件的注册方式
  • el-date-picker 组件源码部分
  • el-date-picker 组件用到的自定义指令 v-clickoutside
  • 组件库组件样式起作用的大概原理
  • el-date-picker 组件自己修改一些功能的思路

一、el-date-picker 组件组成部分,ElementUI 组件库组件的注册方式

el-date-picker 组件组成部分

为了方便理解和记忆,先把一个东西拆分成各个模块,心底和脑子里有个底还是很有必要的。
el-date-picker 的组成也有好几个组件嵌套和很多方法,所以先大致介绍组件的组成部分,方便后面理解。

image.png

正常来说,el-date-picker 组件是长这样的,主要分为三个部分。为了方便和源码对应,也将他们简称为对应的英文(也可以英文翻译下学下英文和命名哈哈哈😆 )。

第一部分,picker。为了方便用户点击和看数据变化的选择器组件的载体,也就是那个选择日期的类似输入框的东西,会由点击它来触发弹出框控制的显示。

第二部分,panel。一个弹出框控制板的载体,主要跟表格的数据变化有关,并且承接 picker 和 basic 的数据交换。

第三部分,basic。一个用于显示在弹窗里的日期表格或者其他年,月,时间表格,并且负责时间表格的交互功能。

ElementUI 组件库组件的注册方式

介绍这部分的原因主要是,如果之后想把 ElementUI 的某个组件单独拿出来修改,那么了解下组件在组件库里的位置,怎么注册和供外面使用的就挺有必要。

在这里先简单介绍下组件库里组件的注册方式和如何找组件在 ElementUI 里的文件位置。

在 ElementUI 里,使用一个组件的方式是,把组件当做一个 Vue 插件那样,暴露一个 install 方法,引入后直接 Vue.use() 这个对象就可以让 Vue 自动注册这个组件了,下图是按需引入组件时,实际组件的代码内容。
image.png

DatePicker 是一个 Vue 组件,里面有属性 name,方便按需引入时能够 Vue.component 的方式引入,有属性 install,方便按需引入时可以直接 Vue.install。 然后完整引入的方式也是类似的,在 src/index.jsVue.component 一个常规的组件(还有些动态的组件在 Vue2 里是绑定到原型上的,如 $message )。

所以,要改一个组件代码的话,就要去找这个 import 进来的代码,然后下面简单讲下怎么找这个组件的代码在哪。

在 ElementUI,组件的源码都放在了 /packages/ 里对应组件名的文件夹(有的组件库会把代码放在其他名字的文件夹里,例如 VantUI 是 src 文件夹),比如 el-date-picker 组件是 /packages/date-pickerel-input 组件是 /packages/input , 以此类推,然后文件夹里面一般是一个 index.js 供注册和一个 src 目录存放组件实现相关的代码。不过一些公用的组件可能会包含在某个特定名字的文件夹里,例如日期组件涉及到的选择器组件,在 Vue2 版本的 ElementUI 组件库代码里,它的 src 目录里还包含了时间选择器 el-time-picker 的一些代码。

image.png
这个注册时间选择器的 js 文件直接是引入了 date-picker 文件夹下面的文件了。

然后 css 的文件代码则是放在了 /packages/theme-chalk/src 文件夹里对应的 [组件名].scss

看起来这样做我以为是因为以前组件库开发的比较早,所以用的 gulp 去处理 css 文件的编译和打包,不过现在 webpack 有
MiniCssExtractPlugin 插件也能让打包 css 文件,将 js 里的 css 代码抽离成 css 文件,我是不太清楚为什么这里还是用的 gulp 打包 css 文件。而且看到在 ElementPlus里也是用的 gulp 去打包 css 文件,有没有大佬知道这里为什么要这么做呢😂。

二、el-date-picker 源码部分

在上面 DatePicker 的图里,我们能看到引入的文件位置是 ./src/picker/date-picker,点开 src 看到目录结构是这样的

image.png

分为三个文件夹 basic, panel, picker 和一个文件 picker.vue 。

除了 picker 文件夹,其他和上面说的组成部分对应。 picker 文件夹是供组件注册用的那个入口 index 文件,picker.vue 才是那个用于交互的弹出框,从 picker 文件夹可以看到 el-time-select 和 el-time-picker 也是用的这个 picker.vue 作为交互的弹出框。

然后从入口文件和三个部分开始看。

image.png

入口文件主要长这样。

import Picker 这个组件进来,还有三个不同类型的 panel 交互弹出框,分别是常用的一般日期选择的,日期范围选择的,月份范围选择的。

一个 getPanel 函数根据 type 返回不同的 panel ,type 是使用组件时传进来的 prop,默认是 date 类型,也就是最普通的那种日期选择器类型。getPanel 在 created 阶段调用,还有 type 改变的时候重新调用生成。

const getPanel = function (type) {
  if (type === 'daterange' || type === 'datetimerange') {
    return DateRangePanel
  } else if (type === 'monthrange') {
    return MonthRangePanel
  }
  return DatePanel
}

然后我们看到它直接 mixins 了 Picker 代码,因为 Picker 的代码和逻辑实在是太多了,1000行代码,并且囊括了月份范围类型的和日期范围类型的两种逻辑在里面,所以集成了很多类型的判断代码和变量在里面😑稍微有点太耦合了。

接着就是从使用时的直观感受开始慢慢解析其中发生了什么。

一开始我们看到的 picker
picker 是什么,能干什么

上面说了,picker 是为了方便用户点击和看数据变化的选择器组件的载体,会由点击它来触发弹出框控制板的显示。这里有负责 panel 显示的逻辑和负责这个 date-picker 组件最外层数据处理的逻辑。在我之前需要改源码的业务里,我就是在这个文件里面,修改了它的显示逻辑和最后传送给外面的值的格式。

picker 的显示

首先看一下 picker.vue 的 template 部分,会发现根标签下面是个v-if,v-else 判断组合。判断条件是v-if="!ranged" 也就是根据是否类型范围,用 el-input 或者一个div 标签去做供点击显示弹出框的载体。之所以范围类型的要用div标签,是因为里面还有两个input标签,里面有两个标签的原因是,它的这个范围选择类型是有个分隔符和两个位置显示,这就需要两个标签来显示始末值了。

<template>
  <!-- 非范围类型的用 el-input 去做供点击显示弹出框的载体 -->
  <el-input
    class="el-date-editor"
    :class="'el-date-editor--' + type"
    :readonly="!editable || readonly || type === 'dates' || type === 'week'"
    :disabled="pickerDisabled"
    :size="pickerSize"
    :name="name"
    v-bind="firstInputId"
    v-if="!ranged"
    v-clickoutside="handleClose"
    :placeholder="placeholder"
    @focus="handleFocus"
    @keydown.native="handleKeydown"
    :value="displayValue"
    @input="value => (userInput = value)"
    @change="handleChange"
    @mouseenter.native="handleMouseEnter"
    @mouseleave.native="showClose = false"
    :validateEvent="false"
    ref="reference"
  >
    <i
      slot="prefix"
      class="el-input__icon"
      :class="triggerClass"
      @click="handleFocus"
    ></i>
    <i
      slot="suffix"
      class="el-input__icon"
      @click="handleClickIcon"
      :class="[showClose ? '' + clearIcon : '']"
      v-if="haveTrigger"
    ></i>
  </el-input>
  <!-- 如果是范围类型的用 div 标签,因为要显示分隔符和两个 input 元素 -->
  <div
    class="el-date-editor el-range-editor el-input__inner"
    :class="[
      'el-date-editor--' + type,
      pickerSize ? `el-range-editor--${pickerSize}` : '',
      pickerDisabled ? 'is-disabled' : '',
      pickerVisible ? 'is-active' : '',
    ]"
    @click="handleRangeClick"
    @mouseenter="handleMouseEnter"
    @mouseleave="showClose = false"
    @keydown="handleKeydown"
    ref="reference"
    v-clickoutside="handleClose"
    v-else
  >
     <!-- something -->
  </div>
</template>

于是来看第一部分的 el-input 标签。里面一堆的 attribute,都是跟交互有关的。大多比较简单,有兴趣的可以自己去看一下。

picker 显示值的逻辑

这里是我们交互后看到的显示值的逻辑的地方,并且我觉得比较有趣的一点是,日常使用 el-input 是直接 v-model 双向绑定一个值的,但是在这里它是用的

:value="displayValue"
@input="value => (userInput = value)"

这种形式,乍一眼看上去是 v-model 的形式,但是 :value 是 displayValue,这是个 Computed,然后 input 后改变的值是 userInput,这个才是 data ,然后 displayValue 是根据 userInput, parsedValue 和其他的一些值动态计算的。

显示值的部分主要关注 displayValueparsedValue,这两个值都是 computed,而 displayValue 又会依赖 parsedValue 计算。

displayValue() {
      const formattedValue = formatAsFormatAndType(
        this.parsedValue,
        this.format,
        this.type,
        this.rangeSeparator
      )
      if (Array.isArray(this.userInput)) {
        return [
          this.userInput[0] || (formattedValue && formattedValue[0]) || "",
          this.userInput[1] || (formattedValue && formattedValue[1]) || ""
        ]
      } else if (this.userInput !== null) {
        return this.userInput
      } else if (formattedValue) {
        return this.type === "dates"
          ? formattedValue.join(", ")
          : formattedValue
      } else {
        return ""
      }
    },

displayValue 是用来显示给用户看的 picker 里面的值,会根据传入的 format 还有 type 格式化输出给用户看,并且会先判断 userInput ,如果没有才会用的根据 parsedValue 去返回格式化后的值。

首先看 userInput,是 picker.vue 里面的一个 data,主要绑定输入框输入后的值或者当type时范围类型的时候的一个数组绑定值,这些不在本文讨论重点里主要是范围类型的时候才会用到,所以跳过。

接下来是个小重点❗️❗️❗️

parsedValue ,这个比较重要,因为它绑定着一个 watch,会将值同步给 this.picker.value,而这个 value 又会通过 emitInput 触发 $emit(‘input’) 将这个值提交出去,而大多逻辑下都是用的 this.picker.value,而 parsedValue 的变化会直接同步给 this.picker.value。

所以,其实 v-model 使用这个组件的时候,就是用的这个值,this.picker.value。如果要改这个组件,最后改返回去的值,改 parsedValue 和 this.picker.value 是最方便的。😾我当时在业务里对最后返回的值做修改就是在 parsedValue 这里进行了修改。

然后这个值的变化又主要依靠 this.value。简单概括下就是,校验 this.value 存不存在还有是不是日期类型和组件有没有设置格式化,最后返回这个 value 或者格式化后的 value。

parsedValue() {
      if (!this.value) return this.value // component value is not set
      if (this.type === 'time-select') return this.value // time-select does not require parsing, this might change in next major version

      const valueIsDateObject =
        isDateObject(this.value) ||
        (Array.isArray(this.value) && this.value.every(isDateObject))
      if (valueIsDateObject) {
        return this.value
      }

      if (this.valueFormat) {
        return (
          parseAsFormatAndType(
            this.value,
            this.valueFormat,
            this.type,
            this.rangeSeparator
          ) || this.value
        )
      }

      // NOTE: deal with common but incorrect usage, should remove in next major version
      // user might provide string / timestamp without value-format, coerce them into date (or array of date)
      return Array.isArray(this.value)
        ? this.value.map(val => new Date(val))
        : new Date(this.value)
    },
watch:{
    parsedValue: {
          immediate: true,
          handler(val) {
            if (this.picker) {
              this.picker.value = val
            }
          },
        },
}        
点击 picker 显示 panel

使用这个组件的时候是通过点击 picker 去触发显示 panel 的,看下代码,是通过触发 handleFocus 事件。因为这里的 picker 显示的是 el-input 输入框,el-input 提供给外部的事件监听里没有 click ,但是有 focus,而且实现的代码是一个 div 包裹着的 input 标签,前置图标标签和输入标签是并列关系,所以用的是 @focus=“handleFocus” 监听 input 标签的聚焦还有 @click=“handleFocus” 监听 i 标签的点击。

我这里的 panel 只是指 src/panel/date.vue 。因为 types 不同,panel 文件夹还有不同类型的 panel。date.vue 是比较通用的。

handleFocus() {
      const type = this.type

      if (HAVE_TRIGGER_TYPES.indexOf(type) !== -1 && !this.pickerVisible) {
        this.pickerVisible = true
      }
      this.$emit('focus', this)
    },

通过 this.pickerVisible = true ,触发它的监听者。里面的 this.showPicker()会触发 this.mountPicker()和 this.updatePopper()。mountPicker 是挂载 panel 文件的方法。

watch: {
    pickerVisible(val) {
      if (this.readonly || this.pickerDisabled) return
      if (val) {
        this.showPicker()
        this.valueOnOpen = Array.isArray(this.value)
          ? [...this.value]
          : this.value
      } else {
        this.hidePicker()
        this.emitChange(this.value)
        this.userInput = null
        if (this.validateEvent) {
          this.dispatch('ElFormItem', 'el.form.blur')
        }
        this.$emit('blur', this)
        this.blur()
      }
    },
},
methods: {
    showPicker() {
      if (this.$isServer) return
      if (!this.picker) {
        this.mountPicker()
      }
      this.pickerVisible = this.picker.visible = true

      this.updatePopper()

      this.picker.value = this.parsedValue
      this.picker.resetView && this.picker.resetView()

      this.$nextTick(() => {
        this.picker.adjustSpinners && this.picker.adjustSpinners()
      })
    },
}
panel 显示用到的弹出框

提到 updatePopper 是因为,这种弹出框在 ElementUI 里大多是用的 vue-popper 将组件挂载在 document 下面显示的,所以有的时候修改弹窗的一些样式在单文件里加 scoped 的话是改不动的,一般去全局 css 文件去改会好点。

而弹出框的显示是需要一个 reference(触发的载体)popperElm(弹出框的实际内容)的,在picker.vue 代码里,reference 是获取了 el-input 的 ref( 这里是 vue-popper.js 文件里的 createPopper()函数在起作用,而createPopper起作用又是因为调用了 updatePopper(),就很隐秘。。。),popperElm 是在 mountPicker 函数里被 new Vue() 挂载了的 dom 元素,也就是 panel。

this.picker = new Vue(this.panel).$mount();
this.popperElm = this.picker.$el;

我觉得无论这个 updatePopper 还是 reference 和 popperElm 的绑定都太隐性了,这可能就是 vue2 的缺点之一吧,这个方法和属性的绑定是依靠的 picker.vue 开头代码里mixinssrc/utils/vue-popperdatamethods。如果是 vue3 的话就能比较清晰是哪里引入了,感觉这也是 ElementPlus 升级要花那么久时间的原因之一吧,太多地方这样隐式调用了😓。vue-popper 又用的 src/utils/popper.js的对象,内容庞大,这里不作展开。有兴趣的可以研究下用来实现日常的弹出框。

由于 panel 主要是用来显示弹出框的内容和显示表格组件这些东西的,主要上承上启下,并且引用到的地方都比较清晰,有需要的自己 ctrl + f 找就行了,很容易就对应上,所以 panel 这块到了这里我就跳过了。所以接下来就是 basic 了

basic

和上面类似,我这里的 basic 只是指 src/basic/date-table.vue。

basic 的显示代码

先来看下显示用的 template

<template>
  <table
    cellspacing="0"
    cellpadding="0"
    class="el-date-table"
    @click="handleClick"
    @mousemove="handleMouseMove"
    :class="{ 'is-week-mode': selectionMode === 'week' }"
  >
    <tbody>
      <tr>
        <th v-if="showWeekNumber">{{ t('el.datepicker.week') }}</th>
        <th v-for="(week, key) in WEEKS" :key="key">
          {{ t('el.datepicker.weeks.' + week) }}
        </th>
      </tr>
      <tr
        class="el-date-table__row"
        v-for="(row, key) in rows"
        :class="{ current: isWeekActive(row[1]) }"
        :key="key"
      >
        <td v-for="(cell, key) in row" :class="getCellClasses(cell)" :key="key">
          <div>
            <span>
              {{ cell.text }}
            </span>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
</template>
  • 整体布局:根元素是一个 table 标签,进行初始化,cellspacing=“0” cellpadding=“0” 去掉表格本身的间隙和边框。

  • dom 事件监听:通过 @click=“handleClick” 实现事件委托监听每个表格的点击事件,这点还是值得学习的,减少 dom 事件的监听器;还有个监听是 @mousemove ,这个主要是用于选择周类型时候的滑动表格项选中的状态。

  • 国际化语言显示:在这个 date-picker 或者其他组件里经常会看到 t(‘el.xxx’) 这样的代码,这里其实是为了方便做国际化语言,传相同字符串根据语言系统去切换不同国际语言的显示,t 函数是在 mixins 里定义的,里面是一大串字段对应的各国文字,有兴趣的可以自行研究。

  • 表格项的样式实现:实现每个表格项的时候,可以看到 td 里面是有一个 div 和一个 span 标签的,正常来说日常编码 dom 代码是尽量少点,避免没必要的渲染,那它这里其实是为了方便选中时的圆点样式的实现还有范围类型的时候选中范围的样式的实现,在使用范围类型的时候,会有个有趣的现象,范围类型的 panel 是会显示两个控制板的 ,鼠标滑动到当前控制板的非当前月份(灰色那些)会在下一个控制板也就是下个月份的有效日期那里显示活跃状态。不过由于本文主要是说多选类型的,所以这里就不多扯了。🎃主要是 src/panel/date-range.vue 里面的实现。

表格项的生成逻辑(小日历)

😈源代码里,用于遍历的 rows 是一个 computed,一百多行代码,并且生成这个表格也差不多属于写一个日历面板的功能了,于是这里单独抽出来说。

这个文件用到了很多获取日期的函数,被封装在了 utils 文件夹里,是值得学习的,因为还有其他日期组件,都需要用到这些日期相关的函数(因为很多工具函数都封装在了 utils 里并且有依赖性,如果自己要把整块组件代码挪过去的话,偷懒不去找相关依赖函数的话建议整块 utils 文件夹也挪过去🌚不然报错找到裂开)。

由于 rows 里的代码很长,我这里简单概括下。

  • 用一个二维数组存遍历42次生成的每个 cell 对象,存储节点信息,类型,范围等信息,后面点击的时候也是会给这个 cell 添加选中的状态(多选的时候需要标志已选中),配合表格元素原生的 rowIndex, cellIndex 对应上这个 data 里的二维数组的值。

  • 生成表格项的日期。 在使用这个组件的时候我们能观察到,其实在最开始的一行里是会出现上个月的日期的,为了让整个表格的数字和操作连贯起来。并且一般而言一周的第一天的显示是从周日开始,不过这个可以通过传入一个 pickerOption 对象自定义修改开始的第一个 day 是什么,这两者都涉及到了一个偏差的基准 offset

    • 在这个组件里有默认的一个偏差基准 offset 也是 offsetDay ,是0:默认的 firstDayOfWeek 是7也就是周日。7-7=0。既然是偏差,那么为了理解这个概念,就应该先从原本没有偏差的时候去理解会比较容易。(我一开始光看代码很不理解为什么是这样的逻辑,但是如果先从没有偏移时来看,看那些逻辑,然后逐渐去调试偏差,找规律,就会发现这样做的含义了。) 所以先看下没有偏差的时候这个表格是如何生成的。
    • 进来组件会先根据父组件传来的 date 生成当前年月的第一天,然后根据这天调用 getFirstDayOfMonth() 获取这天是 day,表示这天是星期几,计算时用到的是 getDay(),周日回返回0,并且为了方便计算,将0换成7。这个 day 能用来判断上个月应该在这个表格显示多少天。

在代码里能看到会使用父组件传来的 date,而这个 date 一开始是直接 new Date() 的数据,后面如果值选中的日期或者控制板发生变化,在 src/panel/date.vue 里的 date 会发生相应变化。

  • day 是什么?范围是[1-7],对应一二三四五六七。
    • 通过观察表格,我们会发现左上角是可能会出现上个月的天数的数字的,然后是这个月的和下个月的。
      • 首先来看上个月的数字如何获取。
      • 上个月的天数的获取和显示。因为先提到的 day,可以用来判断上个月应该在这个表格显示多少天,所以先提下判断上个月天数的逻辑。涉及到这个变量。表示上个月与到这个月的天数。为啥这个 day 能知道上个月应该在这个表格显示多少天呢?
const numberOfDaysFromPreviousMonth = day + offset < 0 ? 7 + day + offset : day + offset
        • 上个月天数的获取。因为在表格显示的第一天是周日的情况下,我们能发现,如果 day 是周一,那么上个月可以显示的一天,周二显示两天,以此类推。如果是周日的话会显示七天,方便后边修改基准时的计算。
        • 上个月天数的显示。 上个月的天数只可能出现在前两行,所以只要判断当前序号大于等于上个月天数,就直接 count++,否则用上个月总共天数减去上个月的天数然后加上当前序号位置。
if (j + i * 7 >= numberOfDaysFromPreviousMonth) {
  cell.text = count++
} else {
  cell.text =
    dateCountOfLastMonth -
    (numberOfDaysFromPreviousMonth - (j % 7)) +
    1 +
    i * 7
  cell.type = 'prev-month'
}
}
      • 这个月的天数以及下个月的,因为是从1开始的,所以可以用一个 count 累加,在前两行可能会出现上个月的天数,所以前两行判断,当前序号比上个月天数多的,肯定都是直接+1。前两行的前面也有说。如果不是前两行的,只要判断当前 count 是否大于这个月的总共天数,小于等于就直接 ++,大于的就直接减去这个月的总共天数,相当于重新累加。而下个月的则是直接用 count++ 减去这个月与总天数。这里可能有的人以为会先是自增再去减,但是其实也还是先做完运算后再自增的。
if (count <= dateCountOfMonth) {
              cell.text = count++
            } else {
              cell.text = count++ - dateCountOfMonth
              cell.type = 'next-month'
            }
    • 如果有偏差。也就是一周起始日 firstDayOfWeek 不是周日了。会像 3217654 这样以7为0,321这样分别是负方向的-3,-2,-1,654分别是+1,+2,+3。
offsetDay() {
      const week = this.firstDayOfWeek
      // 周日为界限,左右偏移的天数,3217654 例如周一就是 -1,目的是调整前两行日期的位置
      return week > 3 ? 7 - week : -week
    },
      • 如果不理解为啥会是这样,可以假设下,如果 firstDayOfWeek 设置成了六,六日一二三四五 这样显示,前面说了,firstDayOfWeek 是7的时候,如果计算出来这个月的第一天是周一的话,那这个月前面还能显示1天。如果换成了 firstDayOfWeek,很明显前面还可以另外显示多一天,所以 6 -> +1,所以 numberOfDaysFromPreviousMonth 那里是 +offset,所以如果 firstDayOfWeek 是1,那就是-1,按照 3217654 这个数列的规则偏移天数;
        • 还有种情况如果 offset 是 -2,然后 day 是1, numberOfDaysFromPreviousMonth = day + offset = -1,这显然不合理,二三四五六日一,前面还有六天,所以小于0的时候 7 + day + offset。

以上,就是日期表格显示的大概逻辑了。😤

表格的点击操作分析

因为里面也有其他类型组件点击时的判断,会有其他判断,所以只讲一般的逻辑,其他对应类型的代码,在源码里 if else 判断有做详细区分。

表格点击获取时间

因为用的事件委托监听 table 的 @click,所以会先判断 event.target 是不是 td 标签,然后就可以根据它来获取 row,column,然后点击时判断点击的时间就可以通过 row,column来算出选中的时间,抛给父组件。

如果是设置了 firstDayOfWeek 的,则是 -offsetDay,因为 startDate 是按照 firstDayOfWeek = 7 的情况计算的,左移的时候日期相当于左上角减去了原本(也就是默认 firstDayOfWeek = 7 的时候的表格)的几天也就是整体前进了偏移天,右移的时候相当于左上角多出了原本的几天也就是整体要后退了偏移天。

computed:{
    startDate() {
          return getStartDateOfMonth(this.year, this.month)
        },   
},
methods: {
    getDateOfCell(row, column) {
          const offsetFromStart =
            row * 7 + (column - (this.showWeekNumber ? 1 : 0)) - this.offsetDay
          return nextDate(this.startDate, offsetFromStart)
        },
}

getStartDateOfMonth 会根据父组件传过来的 date 计算相应的 year,month,然后通过 getDay() 计算出这年这月的第一天是周几,这里我称为 n,因为 getDay() 周日是0,于是周日的结果会等于7,其他的123456则是对应的123456,根据这个值返回这个月第一天的前 n 天。

表格点击后传给父组件数据的逻辑

最后会通过 this.$emit(‘pick’,val) 将值抛出去给父组件 date.vue。

在 date.vue <date-table @pick=“handleDatePick”>。handleDatePick 先对 val 做一层处理,然后触发 this.emit() 对 val 在做一层处理并且触发 this.$emit(‘pick’) 将数据再提供给外部 picker.vue 监听。

在 picker.vue 上会触发 emitInput,进行最后一次的格式化,然后 this.$emit(‘input’) 将值抛出去。也就是 v-model 拿到的那个值了。

this.picker.$on('pick', (date = '', visible = false) => {
        this.userInput = null
        this.pickerVisible = this.picker.visible = visible
        this.emitInput(date)
        this.picker.resetView && this.picker.resetView()
      })
      
      
emitInput(val) {
      const formatted = this.formatToValue(val);
      if (!valueEquals(this.value, formatted)) {
        this.$emit('input', formatted);
      }
    },

以上,就是显示到点击交互的一个大概过程了,于是这里讲下它点击空白地方消失的过程。

自定义指令 v-clickoutside

这个组件之所以能通过点击空白区域就消失,或者说这个组件库里大多数组件点击其他地方会消失,一般都是用到了这个自定义的 Vue 指令 v-clickoutside。

在 Vue 里,如果要操作 dom 的话,自定义指令是一个好的解决方案,因为 Vue 大多情况下都是靠着 data 去响应 dom 模板的变化,相当于一种映射关系。如果要具体操作 dom ,还是用自定义指令会好一点。指令具体的参数和变量可以参考官方文档,这里主要讲下 v-clickoutside 是怎么实现的。

// 指令实现的文件位置
import Clickoutside from 'element-ui/src/utils/clickoutside'

const nodeList = [];
const ctx = '@@clickoutsideContext';

let startClick;
let seed = 0;

这个指令代码的实现,主要是监听 document 的 mousedown,mouseup 事件。

存储用到该指令的 dom 元素

设置一个数组 nodeList 在 bind 的时候也就是使用到这个指令的地方,将 dom 元素添加到数组里,并且自定义该元素的一个属性 el[ctx],也就是上面定义的 ‘@@clickoutsideContext’, 设置为一个对象,给这个节点存储一些信息,方便在触发事件的时候使用这里的信息去触发一些动作。

设置标识,处理用的函数

el[ctx] 存储从0开始自增的 id,一个用户响应 document 事件的函数 documentHandler,指令绑定的方法名,methodName 和 bindingFn 有点区别,methodName 是拿的指令的表达式,也就是 v-clickoutside=“handleClose” 里的 “handleClose” 这个字符串,而 bindingFn 则是直接存储指令里的值也就是 传到指令来了 handleClose 这个方法,一个是字符串,一个是函数。

bind(el, binding, vnode) {
    nodeList.push(el);
    const id = seed++;
    el[ctx] = {
      id,
      documentHandler: createDocumentHandler(el, binding, vnode),
      methodName: binding.expression,
      bindingFn: binding.value
    };
  },
!Vue.prototype.$isServer && on(document, 'mousedown', e => (startClick = e));

!Vue.prototype.$isServer && on(document, 'mouseup', e => {
  nodeList.forEach(node => node[ctx].documentHandler(e, startClick));
});

function createDocumentHandler(el, binding, vnode) {
  return function(mouseup = {}, mousedown = {}) {
    if (!vnode ||
      !vnode.context ||
      !mouseup.target ||
      !mousedown.target ||
      el.contains(mouseup.target) ||
      el.contains(mousedown.target) ||
      el === mouseup.target ||
      (vnode.context.popperElm &&
      (vnode.context.popperElm.contains(mouseup.target) ||
      vnode.context.popperElm.contains(mousedown.target)))) return;

    if (binding.expression &&
      el[ctx].methodName &&
      vnode.context[el[ctx].methodName]) {
      vnode.context[el[ctx].methodName]();
    } else {
      el[ctx].bindingFn && el[ctx].bindingFn();
    }
  };
}

然后因为监听了鼠标的点击事件,只要一点击,就会将当前点击的 e 对象赋值给这个文件里设置好的变量 startClick,然后当松开鼠标的时候,会遍历 nodeList,也就是所有绑定了该指令的元素,触发它们的事件处理函数,并将松开鼠标时所在的 e 传过去当第一个参数,点击时的 e -> startClick 传过去当第二个参数,也就是对应着 createDocumentHandler() 返回的函数里的 mouseup 和 mousedown,首先判断一系列的存在问题,然后判断传给指令的表达式,如果 vnode.context[el[ctx].methodNmae]) 存在 也就是 this[el[ctx].methodName] 存在的话就调用它,否则调用传给指令的函数 el[ctx].bindingFn()。

所以只要在 document 下面一点击鼠标就会触发这个指令,然后触发绑定该指令的 vnode 里的对应操作函数。

指令的解绑

由于绑定指令的时候,nodeList 会存储绑定的 dom 元素,所以解绑的时候就根据之前那个id找到相同的el,然后从 nodeList 中移除掉。

修改组件样式的地方

最后说一下如果要把这些代码挪到自己项目里,要改样式的话去哪里找。

在看源码的时候会发现组件的 vue 文件里是没有 style 标签的,因为它们都被放在了 packages/theme-chalk/src 目录下对应组件名的 scss 文件,像这个 el-date-picker 就是去找 date-picker.scss 这个文件。

为什么 css 不在 vue 文件里呢?
方便先加载 css 文件

因为这个组件库它打包 css 文件是另外打包的,用的 gulp,theme-chalk 文件夹下,就是一个 gulp-file 的配置文件夹,这样使用的时候就可以像官网那样 import 'element-ui/lib/theme-chalk/index.css';,通过项目里 css-loader,然后再通过对应的样式处理插件,例如是 MiniCssExtractPlugin 插件的话,可以让它最后变成通过 link 标签的方式引入这些样式文件,减少浏览器一开始渲染时的阻塞。(之前我写个人需求的时候,想着按需加载这些组件来用,纳闷他们按需加载组件的时候,例如 el-date-picker 里的 el-input 组件样式是怎么起作用的,后来发现原来是在 date-picker.scss 里直接引入了 input.scss😂)

方便按需加载

众所周知,我们使用 ElementUI 的姿势大多是这样的。

image.png
完整引入按需引入,按需引入,需要用到 babel 的一个插件 babel-plugin-component 去处理。

使用按需加载的时候,不知道你们有没有发现,我们只需要 import { Button } from ‘element-ui’ 就行了

而完整引入的时候除了 import Element from ‘element-ui’; 还需要 import ‘element-ui/lib/theme-chalk/index.css’; 也就是还需要再手动引入 css 文件的。

所以 babel-plugin-component 其实帮我们做了引入 css 文件这一步的,相当于

// 假代码
import Button from 'element-ui/lib/button.js';
import 'element-ui/lib/theme-chalk/button.css'

最后的最后,说下我当时在业务里是怎么改的,我把整个 date-picker 文件夹和 utils 文件夹挪过去了,因为依赖太多了,懒得每个找到对应删除,特别是 utils 文件夹包含了 dom 操作相关的,日期操作相关的,缺一不可那样。😂然后我当时主要改了 picker.vue 因为它是最顶层的组件,直接和外面数据的交互和给子组件设置自定义数据都是在这里,返回给外界使用的数据的时候改了 parsedValue,因为它是影响到返回给外界用的多个数据;然后就是改了 basic,因为它负责表格的显示和交互。

总结

介绍了 element 组件库使用组件的方式,如果要组件文件夹挪过去项目里修改代码的话,能知道在项目里怎么使用它,而且由于组件会用到很多工具函数,组件库里的工具函数很多都是封装在了 utils 文件夹里并且依赖比较多,如果偷懒可以整块 utils 文件夹也挪过去。

介绍了 el-date-picker 的一般类型的代码解析,组件载体的实现,popper.js 弹出框的使用,日期表格的实现,表格点击后传送日期数据给父组件的过程,导致点击屏幕组件消失的自定义指令 v-clickoutside 的实现,element 组件库的样式文件的运行方式。

也算是比较详细的说了用这个组件时一般需要知道的地方了,希望能帮到大家吧。

image.png

Logo

前往低代码交流专区

更多推荐