乐趣区

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

第十五集: 从零开始实现一套 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>
</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>

月份的
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 项目 (这个项目里面也有很多有趣的想法): 项目地址
相关文章: 链接描述

退出移动版