31

Vue实战:日期选择器

 3 years ago
source link: https://zhuanlan.zhihu.com/p/270328053
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在日常工作中需要填写日期的时候,会用到日期选择器,来方便的进行日、月、年的选择。这里我们会用 Vue 来实现一个日期选择器,效果如下:

aAFVZ3V.gif!mobile

实现功能:

CSS

组件的使用方式很简单,只需要传入对应的日期对象 value 即可:

<template>
  <div class="date-picker">
    <go-date-picker v-model="value"></go-date-picker>
  </div>
</template>

<script>
export default {
  name: 'DatePicker',
  data () {
    return {
      value: undefined
    };
  },
};
</script>

下面就开始一步步实现组件吧 !

日期选择弹出层

当用户点击输入框时,会弹出日期选择面板。在组件内部,会通过 visible 来控制弹出层的显示隐藏:

<template>
  <div class="go-date-picker" ref="picker">
    <go-input
      class="go-date-picker-input"
      @focus="visible=true"
      prefix="calendar"
      placeholder="请选择时间"
    >
    </go-input>
    <div ref="popover" class="go-date-picker-popover" v-if="visible">
        <!-- day/month/year  panel    -->
    </div>
  </div>
</template>

<script>
export default {
  name: 'GoDatePicker',
  props: {
    value: {
      type: Date,
      default: () => new Date()
    }
  },
  data () {
    return {
      visible: false,
    };
  },
  mounted () {
    document.body.addEventListener('click', this.onClickBody);
  },
  beforeDestroy () {
    document.body.removeEventListener('click', this.onClickBody);
  },
  methods: {
    onClickBody (e) { // Vue内部会自动帮我们修改this指向
      const { picker} = this.$refs;
      // 过滤掉弹出层和日期选择器内的元素
      if (picker.contains(e.target)) {
        return;
      }
      this.visible = false;
    },
  }
};
</script>

当输入框激活时,显示弹出层,当点击外部区域时,会隐藏弹出层。需要注意的是当 点击 date-picker 内部,弹出层并不会隐藏

Node.contains(otherNode) 可以用来判断 otherNode 是否是 Node 的后代节点(包括 Node 本身),返回 Boolean 。这里我们通过这个 api 来判断点击的元素 e.target 是否在 date-picker 内部,如果是的话不会隐藏弹出层,可以让用户在 date-picker 中进行相应的操作。

展示天面板

当用户点击输入框后,首先弹出的是天面板,面板头部会显示当前的年月信息。面板主体有 6 行,会分别包括上月、当前月、下月的天数:

MzaiYz.jpg!mobile

显示头部信息

我们会对传入的 value 进行拷贝,在内部通过 tempValue 来进行保存,并且监听 value 的变化,保证 tempValue 可以获取到 value 的最新值。当我们在内部切换日期面板而没有选中某个日期时,就不会更新 value ,而只是更新内部的 tempValue 属性:

<script>
export default {
  name: 'GoDatePicker',
  props: {
    value: {
      type: Date,
      default: () => new Date()
    }
  },
  components: { PickerDays, PickerMonths, PickerYears },
  data () {
    return {
      visible: false,
      mode: 'picker-days',
      tempValue: cloneDate(this.value),
    };
  },
  computed: {
    formatDate () {
      const [year, month, day] = getYearMonthDay(this.tempValue);
      return { year, month: month + 1, day };
    },
  },
  watch: {
    value (val) {
      this.tempValue = cloneDate(val);
    }
  },
  // some code ...
};
</script>

formatDate 计算属性会通过 tempValue 计算出当前的年、月、日,方便展示。

显示内容区域

内容区域的展示会复杂很多,实现的思路如下:

  • 获取当前月第一天是星期几,推导出前一个月展示的天数
  • 获取当月的展示总天数
  • 总共要展示的天数为 42,减去前一个月和当前月展示的天数即为下个月展示的天数
<script>
export default {
  name: 'PickerDays',
  data () {
    return {
      weeks: ['一', '二', '三', '四', '五', '六', '日']
    };
  },
  // some code ...
  computed: {
    getDays () {
      const [year, month] = getYearMonthDay(this.tempValue);
      // 0 ~ 6, 需要将0转换为7
      let startWeek = new Date(year, month, 1).getDay();
      if (startWeek === 0) {
        startWeek = 7;
      }
      const prevLastDay = getPrevMonthLastDay(year, month);
      const curLastDay = getCurrentMonthLastDay(year, month);
      const days = [...this.getPrevMonthDays(prevLastDay, startWeek), ...this.getCurrentMonthDays(curLastDay), ...this.getNextMonthDays(curLastDay, startWeek)];
      // 转换成二维数组
      return toMatrix(days, 7);
    },
  },
  methods: {
    // 获取前一个月天数
    getPrevMonthDays (prevLastDay, startWeek) {
      const [year, month] = getYearMonthDay(this.tempValue);
      const prevMonthDays = [];
      for (let i = prevLastDay - startWeek + 1; i <= prevLastDay; i++) {
        prevMonthDays.push({
          date: new Date(year, month - 1, i),
          status: 'prev'
        });
      }
      return prevMonthDays;
    },
    // 获取当前月天数
    getCurrentMonthDays (curLastDay) {
      const [year, month] = getYearMonthDay(this.tempValue);
      const curMonthDays = [];
      for (let i = 1; i <= curLastDay; i++) {
        curMonthDays.push({
          date: new Date(year, month, i),
          status: 'current'
        });
      }
      return curMonthDays;
    },
    // 获取下一个月天数
    getNextMonthDays (curLastDay, startWeek) {
      const [year, month] = getYearMonthDay(this.tempValue);
      const nextMonthDays = [];
      for (let i = 1; i <= 42 - startWeek - curLastDay; i++) {
        nextMonthDays.push({
          date: new Date(year, month + 1, i),
          status: 'next'
        });
      }
      return nextMonthDays;
    },
    getDay (cell) {
      return cell.date.getDate();
    },
  }
};
</script>

我们将前一个月、当前月、下一个月的日期信息组成一个数组,然后转换位为拥有 6 个子数组,每个子数组中有 7 条信息的二维数组,方便遍历展示:

<template>
  <div class="go-picker-days">
    <!--  some code ...  -->
    <div class="go-date-picker-days-row" v-for="(row,i) in getDays" :key="`${row}-${i}`">
      <div
        class="go-date-picker-days-cell"
        v-for="(cell,j) in row"
        :key="`${cell}-${j}`"
      >
        {{ getDay(cell) }}
      </div>
    </div>
  </div>
</template>

数组的格式如下:

IvQZv2J.jpg!mobile

在计算日期时,如果传入的天数为 0,则表示前一个月的最后一天。利用这个特性,可以节省我们很多的计算逻辑:

export const getCurrentMonthLastDay = (year, month) => {
  return new Date(year, month + 1, 0).getDate();
};

export const getPrevMonthLastDay = (year, month) => {
  return new Date(year, month, 0).getDate();
};

在遍历展示的天的过程中,还可以通过日期信息来为其设置样式:

<template>
  <div class="go-picker-days">
    <div class="go-date-picker-days-row" v-for="(row,i) in getDays" :key="`${row}-${i}`">
      <div
        class="go-date-picker-days-cell"
        :class="dayClasses(cell)"
        v-for="(cell,j) in row"
        :key="`${cell}-${j}`"
      >
        {{ getDay(cell) }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  // some code ...
  methods: {
    dayClasses (cell) {
      return {
        prev: cell.status === 'prev',
        next: cell.status === 'next',
        active: this.isSameDay(cell.date, this.value),
        today: this.isToday(cell.date)
      };
    },
    // 是否是选中的天
    isSameDay (date1, date2) {
      const [y1, m1, d1] = getYearMonthDay(date1);
      const [y2, m2, d2] = getYearMonthDay(date2);
      return y1 === y2 && m1 === m2 && d1 === d2;
    },
    // 是否是今天
    isToday (date) {
      const [y1, m1, d1] = getYearMonthDay(date);
      const [y2, m2, d2] = getYearMonthDay();
      return y1 === y2 && m1 === m2 && d1 === d2;
    }
  }
};
</script>
}

通过 dayClasses 方法,我们分别添加如下 class :

prev
next
active
today

之后便可以根据 class 来为这些不同状态分别添加不同的样式了。

月份切换

在面板的头部,支持点击左右箭头进行月份切换。其实现利用了 Date.prototype.setMonth 方法:

<template>
  <div class="go-picker-days">
    <div class="go-date-picker-popover-header">
      <span class="go-date-picker-prev" @click="changeMonth(-1)">‹</span>
      <span class="go-date-picker-info" @click="$emit('mode-change','picker-months')">
        {{ formatDate.year }}年{{ formatDate.month }}月{{ formatDate.day }}日</span>
      <span class="go-date-picker-next" @click="changeMonth(1)">›</span>
    </div>
  </div>
</template>

<script>
export default {
  name: 'PickerDays',
  methods: {
    changeMonth (value) {
      const [, month] = getYearMonthDay(this.tempValue);
      const timestamp = cloneDate(this.tempValue).setMonth(month + value);
      // 通过.sync修饰符绑定,使用update:xxx来进行修改值
      this.$emit('update:tempValue', new Date(timestamp));
    }
  }
};
</script>

内部会传入设置的月份,如果值为-1 或者 13 的话,会自动切换到前一年或后一年,而不用担心时间混乱。

选择天

当点击面板中的某天后,需要更新用户传入的 value 。而在 value 更新后,由于在组件内我们 watchvalue ,所以也会同时更新 tempValue ,使页面中的数据和 value 保持一致:

<template>
  <div class="go-picker-days">
    <div class="go-date-picker-days-row" v-for="(row,i) in getDays" :key="`${row}-${i}`">
      <div
        class="go-date-picker-days-cell"
        :class="dayClasses(cell)"
        v-for="(cell,j) in row"
        :key="`${cell}-${j}`"
        @click="onClickDay(cell)"
      >
        {{ getDay(cell) }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'PickerDays',
  // 引入混合器
  mixins: [emitter],
  // some code ...
  methods: {
    onClickDay (cell) {
      this.dispatch('input', cell.date, 'GoDatePicker');
    },
    // some code...
  }
};
</script>

这里进行了跨组件调用 this.$emit('input') 事件,需要从子到父一直通过 @ 进行事件监听,并使用 this.$emit('input') 继续向上触发事件。为了简化这个过程,在混合器内封装了 dispatch 方法,方便跨组件之间的方法触发:

// src/mixins/emitter.js
const emitter = {
  methods: {
    dispatch (event, params, componentName) {
      let parent = this.$parent;
      while (parent) {
        if (parent.$options.name === componentName) {
          return parent.$emit(event, params);
        }
        parent = parent.$parent;
      }
    }
  }
};

export default emitter;

如果不理解 dispatch 的实现过程的话,可以参考笔者的 这篇文章

展示月面板

代码中将年月日面板分别拆分成了不同的组件,然后通过动态组件来进行展示。

月面板的界面效果如下:

FRneaya.jpg!mobile

我们在代码内部定义了数组 months 来代表所有月份,并且通过 toMatrix 将其转换为拥有 3 个子数组的二维数组,方便进行遍历:

<template>
  <div class="go-picker-months">
    <!--  some code ...  -->
    <div class="go-date-picker-popover-content">
      <div class="go-date-picker-months">
        <div class="go-date-picker-months-row" v-for="(row,i) in months" :key="`${row}-${i}`">
          <div
            class="go-date-picker-months-cell"
            v-for="(cell,j) in row" :key="`${cell}-${j}`"
            :class="monthClasses(i,j)"
            @click="onClickMonth(i,j)"
          >
            {{ cell }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
const MONTHS = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
export default {
  name: 'PickerMonths',
  data () {
    return {
      months: toMatrix(MONTHS, 4)
    };
  },
  methods: {
    monthClasses (i, j) {
      const month = j + i * 4;
      return {
        active: this.isSameMonth(month),
        current: this.isCurrentMonth(month)
      };
    },
    onClickMonth (i, j) {
      const month = j + i * 4;
      const { year, day } = this.formatDate;
      this.dispatch('input', new Date(year, month, day), 'GoDatePicker');
      this.$emit('mode-change', 'picker-years');
    },
    isCurrentMonth (month) {
      const year = this.formatDate.year;
      const [year2, month2] = getYearMonthDay(new Date());
      return year === year2 && month === month2;
    },
    isSameMonth (month) {
      const year = this.formatDate.year;
      const [year2, month2] = getYearMonthDay(this.value);
      return year === year2 && month === month2;
    }
  }
};
</script>

在遍历过程中可以通过 i,j 来获取到对应项的真实月份,根据月份和 formatDate 得到的 tempValue 所对应的当前面板的年份,可以添加不同的类名,从而设置不同的样式。

在点击月份后,会更新用户传入的 value ,然后跳转到年面板,下面我们来介绍年面板的实现。

展示年面板

年面板会展示 10 年的年份列表,可以通过左右箭头后退或前进 10 年,其效果如下:

fMfamyV.jpg!mobile

我们需要计算出开始年份和结束年份,然后生成拥有 4 个子数组的二维数组在页面中遍历展示:

<template>
  <div class="go-picker-years">
    <div class="go-date-picker-popover-header">
      <span class="go-date-picker-prev" @click="changeYear(-10)">‹</span>
      <span class="go-date-picker-info">{{ startYear }}-{{ endYear }}</span>
      <span class="go-date-picker-next" @click="changeYear(10)">›</span>
    </div>
    <div class="go-date-picker-popover-content">
      <div class="go-date-picker-years">
        <div class="go-date-picker-years-row" v-for="(row,i) in years" :key="`${row}-${i}`">
          <div
            class="go-date-picker-years-cell"
            v-for="(cell,j) in row" :key="`${cell}-${j}`"
            :class="yearClasses(cell)"
            @click="onClickYear(cell)"
          >
            {{ cell }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'PickerYears',
  computed: {
    startYear () {
      const { year } = this.formatDate;
      return year - year % 10;
    },
    endYear () {
      return this.startYear + 9;
    },
    years () {
      const arr = [];
      for (let i = this.startYear; i <= this.endYear; i++) {
        arr.push(i);
      }
      return toMatrix(arr, 4);
    }
  },
};
</script>

在生成年份列表后,可以根据列表中的年份信息来为其设置不同的样式:

<script>
export default {
  methods: {
    yearClasses (year) {
      return {
        active: this.isSameYear(year),
        current: this.isCurrentYear(year)
      };
    },
    // 当前所处年分
    isCurrentYear (year) {
      const [year2] = getYearMonthDay(new Date());
      return year === year2;
    },
    // 与用户传入的value相同的激活年份
    isSameYear (year) {
      const [year2] = getYearMonthDay(this.value);
      return year === year2;
    }
  }
}
</script>

当点击左右箭头时,会调用 Date.prototype.setFullYear 来进行年份的切换:

<script>
export default {
  methods: {
    changeYear (value) {
      const [year] = getYearMonthDay(this.tempValue);
      const timestamp = cloneDate(this.tempValue).setFullYear(year + value);
      this.$emit('update:tempValue', new Date(timestamp));
    },
  }
}
</script>

在点击对应的年份后,会更新 value 并切换到选择天面板:

<script>
export default {
  methods: {
    onClickYear (year) {
      const { month, day } = this.formatDate;
      this.dispatch('input', new Date(year, month, day), 'GoDatePicker');
      this.$emit('mode-change', 'picker-days');
    },
  }
}
</script>

到这里我们已经实现年、月、天的选择,日期选择器的基本功能已经全部实现 。

输入当前日期

用户不仅可以通过面板选择时间,也可以通过输入框来输入时间。

当用户在输入框中输入内容后,会将用户输入的内容与正则进行匹配,如果匹配不成功将会忽略用户的输入内容。如果匹配成功,会通过正则单元将用户填写的年月日拿到,然后用它们更新用户传入的 value ,进而更新整个日期选择器的数据。

上述逻辑的代码如下:

<template>
  <div class="go-date-picker" ref="picker">
    <go-input
      class="go-date-picker-input"
      @focus="visible=true"
      v-model="displayValue"
      prefix="calendar"
      placeholder="请选择时间"
    >
    </go-input>
  </div>
</template>

<script>
export default {
  name: 'GoDatePicker',
  computed: {
    displayValue: {
      get () {
        const [year, month, day] = getYearMonthDay(this.value);
        return `${year}-${month + 1}-${day}`;
      },
      set (e) { // 为计算属性绑定set方法,在更新值的时候会调用
        if (e?.target?.value) {
          const reg = /(\d+)-(\d+)-(\d+)/;
          const value = e.target.value;
          const matched = value.match(reg);
          if (matched) { // 如果匹配到的话,通过正则单元获取到年月日更新value
            const [, year, month, day] = matched;
            this.$emit('input', new Date(year, month - 1, day));
          }
        }
      }
    },
  },
};
</script>

在输入框中输入内容的时候,由于为计算属性 displayValue 设置了 v-model ,所以需要为其设置 set 方法。在 set 方法中通过 String.prototype.match 获取匹配结果,进而更新 value

这个功能可以让我们直接输入日期信息,而不用为了选择某个跨度比较大的时间而进行不停的前进后退操作。

结语

日期选择器的难点在于年、月、天列表的展示,需要我们对 Date 的一些 api 有一定的了解,否则会导致很多没有必要的计算逻辑。剩下的一些 CSS 样式比较简单,需要花些耐心多去调试。

希望这篇文章能够帮助你了解日期选择器的实现原理,在工作和面试时更加游刃有余!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK