17

第十五集: 从零开始实现一套pc端vue的ui组件库( 日历组件 )

1. 本集定位

'日历组件'在后台管理系统里面是十分常见的, 在pc端的展示方式基本都为一个方方的表格, 别看功能单一, 这个组件做起来还是有点意思的, 本次我来实现的组件只包含最核心的功能,也就是日期的选择, Element-ui里面的日期组件功能很多有兴趣的同学可以去看看他的思想.

效果展示
图片描述

2. 需求分析

  1. 一个输入框用来展示以及点击弹出'日历组件'.
  2. 展示日期选择使用6*7的矩形.
  3. 可以按年份与月份进行翻页.
  4. 当本月第一天不是周日的时候, 要显示上一个月的最后几天.
  5. 可以选择一个日期.
  6. 个人不太喜欢手动输入日期这个操作, 所以本次是禁止手动输入的.

3. 基础的搭建

vue-cc-ui/src/components/DatePicker/index.js

import DatePicker from './main/datePicker.vue'

DatePicker.install = function(Vue) {
  Vue.component(DatePicker.name, DatePicker);
};

export default DatePicker

vue-cc-ui/src/components/DatePicker/main/datePicker.vue

<template>
  <div class="cc-date" ref='popover'>
   // 用来展示日期的那个输入框
    <input readonly
           type="text"
           class="cc-date-input"
           // 这是个很有用的指令, 接下来我讲一下他
           v-clickoutside='hide'
           :value='formatDare'
           // 每次聚焦都会呼出日历
           @focus='isShowPanel = true'>
    // 接下来的'日历'就在它里面做了.
    <div v-show='isShowPanel'
         class="cc-date-pannel"
         ref='content'
         :style="{
               top:top+'px',
               left:left+'px'
           }">
    </div>
  </div>
</template>

export default {
  name: "ccDatePicker",
  props: {
    value: {
      type: Date, // 指定类型不许是日期类型
      default: () => new Date() // 你不传我就取当前时间呗
    }
  },
data() {
    return {
      top: 0,
      left: 0,
      isShowPanel: false,
    };
  },
//...

v-clickoutside : 判断点击的是不是自身
这个方法一定要挂在组件内部的指令上, 不要污染全局.

const Clickoutside = {
  bind(el, bindings, vnode) {
   // 单独抽出来是为了最后好把它移除
    const handleClick = function(e) {
      // 如果点击的元素不在目标元素的包裹内, 那就说明点击了与元素无关的位置.
      if (!el.contains(e.target)) {
       // 虚拟dom的context属性可以找到这个实例, 调用他的hide方法可以隐藏这个dom
        vnode.context[bindings.expression]();
      }
    };
    el.handleClick = handleClick;
    document.addEventListener('click', handleClick);
  },
  unbind(el) {
    document.removeEventListener('click', el.handleClick);
  }
};

export default Clickoutside;

创给指令的hide方法

methods: {
 hide() {
      this.isShowPanel = false;
    },
//...

给他定个位把, 具体出现在哪里
其实这个我们上一个组件已经封装好了方法
我们先观察这个isShowPanel, 如果他出现, 那我们就计算出现的位置

watch: {
    isShowPanel(val) {
      if (val) {
        this.$nextTick(() => {
          this.setPosion(); // 这个方法是真正获取位置的
        });
      }
    }
  },

setPosion

    setPosion() {
      let { popover, content } = this.$refs;
      let { left, top } = getPopoverPosition( // 这个函数上一集有说明, 不赘述了.
        popover,
        content,
        "bottom-start",
        3
      );
      this.top = top;
      this.left = left;
    }

上面的步骤我们做到了点击input弹出日期选择, 点击其他地方让其消失

4.样式很重要

  1. 首先要有header展示具体的年月日以及前进与后退.
  2. 其次是一个title展示'周一''周二'...这种.
  3. 具体的显示框来显示具体的day.

展示一下结构代码
首先是第一排

<div class="pannel-nav">
    <span><</span>
    <span>←</span>
    <div class="pannel-selected">
    // 像这种结构有人用v-for生成...
    // 其实有时候直接写出来更直观, 仁者见仁吧.
      <span>{{formatDare.split('-')[0]}}年 </span>
      <span>{{formatDare.split('-')[1]}}月 </span>
      <span>{{formatDare.split('-')[2]}}日</span>
    </div>
    <span>→</span>
    <span>></span>
</div>

formatDare: 是用来展示时间的 --> '年-月-日'

  computed: {
    formatDare() {
      let { year, month, day } = getYMD(this.value),
        result = `${year}-${month + 1}-${day}`;
      return result;
    },
  // ...

展示星期

<div class="pannel-content">
    <ul class="pannel-content__title">
      <li v-for="i in weeksList"
          :key="i">{{i}}</li>
    </ul>
//...
 data() {
    return {
      top: 0,
      left: 0,
      isShowPanel: false,
      weeksList: ["日", "一", "二", "三", "四", "五", "六"]
    };
  },

重头戏: 展示day天
思路: 例如当前是' x年n月 ';

  1. 计算出x年n月有多少天.
  2. 计算出x年n月的第一天是星期几.
  3. 如果是星期日, 那就不用添加上一个月的日期, 直接开头就显示本月的1日.
  4. 如果不是星期日, 需要上个月的日期来补全.
  5. 求出x年n-1月有多少天, 这里要注意, 很可能-1导致跨年了, 所以要判断好边界.
  6. 在当前日期比如有31天展示完毕, 需要用下个月的日期来填补所有剩下来的格子.

template

<ul class="pannel-content__item"
    v-for="i in 6"
    :key="i">
  <li v-for="j in 7"
    :key="j">{{getVisibeDaysIndex(i,j).day}}</li>
</ul>

计算当前有多少天

getVisibeDaysIndex(i, j) {
      i = i - 1;
      j = j - 1;
      let index = i * 7 + j; // 当前第几个格子
      return this.visibeDays[index];
    },

visibeDays: 它是比较核心的方法

    visibeDays() {
      let result = [],
        { year, month } = getYMD(this.value),
        // 传入年,月,日,就会返回相应的date实例, 用getDay取得星期几;
        dayOffset = new Date(year, month, 1).getDay(),
        // 传入年月, 求出本月几天, 这个方法下面会讲.
        dateCountOfMonth = getDayCountOfMonth(year, month),
        // 这个是求得上一个月
        previousMonth = month - 1;
        // 没有0月, 所以需要变为12月, 年份-1;
      if (previousMonth === 0) {
        year--;
        previousMonth = 12;
      }
      // 取得上个月有多少天, 这样才能知道现实上个月的最后一天是不是31;
      let dateCountOfLastMonth = getDayCountOfMonth(year, previousMonth);
      // 把取得完毕的数据传给专门把它们做成数组用于展示的函数;
      this.getDayList(
        dayOffset,
        dateCountOfMonth,
        dateCountOfLastMonth,
        result
      );
      // 这个结果直接返回出去就行
      return result;
    }

vue-cc-ui/src/assets/js/handelDate.js
这里面就是对日期相关的处理

export function getYMD(date){
  let day = date.getDate();
  let month = date.getMonth();
  let year = date.getFullYear();
  return {
    year, month, day
  }
}

export const getDayCountOfMonth = function(year, month) {
    if (month === 3 || month === 5 || month === 8 || month === 10) {
      return 30;
    }
  
    if (month === 1) {
      if (year % 4 === 0 && year % 100 !== 0 || year % 400 === 0) {
        return 29;
      } else {
        return 28;
      }
    }
  
    return 31;
  };

把日期整理为使用的数组
getDayList

  1. readOnly为真, 显示为灰色不可选, 为假就是正常的黑色可选
  2. activate为真, 则显示高亮, 表示被选中
    getDayList(dayOffset, dateCountOfMonth, dateCountOfLastMonth, result) {
    // 处理上个月的日期, 没有的话当然就不走这个循环
      for (let i = 0; i < dayOffset; i++) {
        result.unshift({ readOnly: true, day: dateCountOfLastMonth - i });
      }
    // 处理当前月的天数
      let day = getYMD(this.value).day;
      for (let i = 1; i <= dateCountOfMonth; i++) {
        let obj = { day: i, activate: true };
        if (day !== i) {
          obj.activate = false;
        }
        result.push(obj);
      }
     // 总个数减去已使用的数, 把剩余空间填满
      let len = 42 - result.length;
      for (let i = 1; i <= len; i++) {
        result.push({ readOnly: true, day: i });
      }
      // 这个函数处理好了也没必要有返回值
    },

上面的步骤做完其实就已经可以正常显示当前月了

5.选中日期 与 切换月年

其实随着核心代码的完成, 周边的功能都是很好添加的, 这也就是为什么写代码一定要符合设计模式;
选中某一天

<li v-for="j in 7"
  @click="handlerActiveDay(getVisibeDaysIndex(i,j,true))"
  :class="{
     'active-date': getVisibeDaysIndex(i,j).activate,
     'read-only':getVisibeDaysIndex(i,j).readOnly
  }"
  :key="j">{{getVisibeDaysIndex(i,j).day}}</li>

handlerActiveDay: 这里我在getVisibeDaysIndex传了第三个参数
因为这里我只需要他返回给我具体的序号就行了, 而不是具体哪天.

getVisibeDaysIndex(i, j, type) {
  i = i - 1;
  j = j - 1;
  let index = i * 7 + j;
  return type ? index : this.visibeDays[index];
},
 handlerActiveDay(index) {
  let result = this.visibeDays[index],
    { year, month } = getYMD(this.value);
  if (!result.readOnly) {
   // 这一步其实是与用户的 v-model相结合的.
    this.$emit("input", new Date(year, month, result.day));
  }
},

前进与后退

<span @click="handlerChangeYear(-1)"><</span>
<span @click="handlerChangeMonth(-1)">←</span>
// ...
<span @click="handlerChangeMonth(1)">→</span>
<span @click="handlerChangeYear(1)">></span>

月份的
handlerChangeMonth
注意不要超出边界

handlerChangeMonth(n) {
  let { year, month } = getYMD(this.value);
  month += n;
  if (month === 0) {
    month = 12;
    year += n;
  } else if (month === 13) {
    month = 1;
    year += n;
  }
  this.$emit("input", new Date(year, month, 1));
},

年份
handlerChangeYear
没必要判断负数了, 毕竟选一个公元前的时间这种情况太极端了, 没必要浪费性能去判断了.

handlerChangeYear(n) {
  let { year, month } = getYMD(this.value);
  year += n;
  this.$emit("input", new Date(year, month, 1));
},

6. 具体的scss样式

@import './common/var.scss';
@import './common/mixin.scss';
@import './common/extend.scss';

@include b(date) {
    position: relative;
    display: inline-block;
    @include b(date-input){
      border: 1px solid $--color-disabled;
      // 输入框的outline根据需求来判断到底要不要清理吧.
      outline: 0px;
      padding: 8px;
      font-size: 16px;
      border-radius:7px; 
    }
    @include b(date-pannel){
       // 这种弹出框肯定是要针对视口定位的
        position: fixed;
        border: 1px solid $--color-disabled;
        background-color: $--color-white;
        width: 280px;
        padding: 8px;
        border-radius:7px; 
        .pannel-nav{
            display: flex;
            align-items: center;
            // 整体有一个环绕效果
            justify-content: space-around;
            // 外圈的轮廓
            box-shadow: 0px 2px 2px 2px $--color-difference;
            padding: 6px 0;
            margin-bottom: 10px;
            .pannel-selected{
              width: 160px;
              text-align: center;
            }
            &>span{
                &:hover{
                    cursor: pointer;
                    color: $--color-nomal
                }
            }
        }
        .pannel-content{
            box-shadow: 0px 2px 2px 2px $--color-difference;
            ul{
                display: flex;
            }
            li{
                text-align: center;
                flex: 1;
                height: 35px;
                line-height: 35px;
            }
            .read-only{
               color: $--color-disabled;
            }
            .active-date{
                @extend .active-item;
            }
            .pannel-content__item{
                cursor: pointer;
                border: 1px solid $--color-difference;
                // li标签中, 没有.read-only class的标签;
                li:not(.read-only){
                  // 平时是处于缩小状态的
                    transition: all .2s;
                    transform: scale(.8);
                    &:hover{
                        transform: scale(1.3);
                        @extend .active-item;
                    }
                }
            }
        }
      }
}

end

大家都可以一起交流, 共同学习,共同进步, 早日实现自我价值!!
下一集聊聊'tree组件'
作者对tree组件有些不一样的理解, 所以做出来的组件也比较怪异吧,但是我挺喜欢我的想法, 下一期与大家分享一下另类的tree.

github:github
个人技术博客(组件的官网):博客
仿写Vue项目(这个项目里面也有很多有趣的想法): 项目地址
相关文章:链接描述


lulu_up
5.7k 声望6.9k 粉丝

自信自律, 终身学习, 创业者