让我们构建一个 Vue 供电的月历

Avatar of Mateusz Rybczonek
Mateusz Rybczonek

DigitalOcean 为您旅程的每个阶段提供云产品。从 200 美元的免费信用额度开始!

您是否曾经在网页上看到过日历,并想过,他们是怎么做到的?对于这样的事情,使用插件甚至嵌入式 Google 日历可能是自然而然的做法,但实际上,制作一个日历比您想象的要简单得多。尤其是在我们使用 Vue 的组件驱动能力时。

我在 CodeSandbox 上设置了一个演示,以便您可以看到我们的目标,但阐明我们试图做什么总是一个好主意

  • 创建一个显示当前月份日期的月视图网格
  • 显示上个月和下个月的日期,以便网格始终充满
  • 指示当前日期
  • 显示当前选定月份的名称
  • 导航到上个月和下个月
  • 允许用户只需单击一下即可返回到当前月份

哦,我们还将将其构建为一个单页应用程序,该应用程序从 Day.js(一个超轻量级的实用程序库)获取日历日期。

步骤 1:从基本标记开始

我们将直接跳入模板。如果您不熟悉 Vue,Sarah 的 入门系列 是一个不错的起点。还值得注意的是,我将在本文中链接到 Vue 2 文档。Vue 3 目前处于测试阶段,其文档可能会发生变化。

让我们从为日历创建基本模板开始。我们可以将我们的标记概述为三个层,其中我们有

  • 日历标题部分。这将显示包含当前选定月份的组件以及负责在月份之间分页的元素。
  • 日历网格标题部分。一个表头,其中包含一个列表,其中包含一周中的日期,从星期一开始。
  • 日历网格。您知道,当前月份中的每一天,在网格中表示为一个正方形。

让我们在一个名为 CalendarMonth.vue 的文件中编写此内容。这将是我们的主要组件。

<!-- CalendarMonth.vue -->
<template>
  <!-- Parent container for the calendar month -->
  <div class="calendar-month">
     
    <!-- The calendar header -->
    <div class="calendar-month-header"
      <!-- Month name -->
      <CalendarDateIndicator />
      <!-- Pagination -->
      <CalendarDateSelector />
    </div>

    <!-- Calendar grid header -->
    <CalendarWeekdays />

    <!-- Calendar grid -->
    <ol class="days-grid">
      <CalendarMonthDayItem />
    </ol>
  </div>
</template>

现在我们有一些标记可以一起使用,让我们更进一步,创建所需的组件。

步骤 2:标题组件

在我们的标题中,我们有两个组件

  • CalendarDateIndicator 显示当前选定的月份。
  • CalendarDateSelector 负责在月份之间分页。

让我们从 CalendarDateIndicator 开始。此组件将接受一个 selectedDate 属性,该属性是一个 Day.js 对象,该对象将正确格式化当前日期并将其显示给用户。

<!-- CalendarDateIndicator.vue -->
<template>
  <div class="calendar-date-indicator">{{ selectedMonth }}</div>
</template>

<script>
export default {
  props: {
    selectedDate: {
      type: Object,
      required: true
    }
  },

  computed: {
    selectedMonth() {
      return this.selectedDate.format("MMMM YYYY");
    }
  }
};
</script>

这很容易。让我们继续创建允许我们在月份之间导航的分页组件。它将包含三个负责选择上个月、本月和下个月的元素。我们将添加一个 事件监听器,当元素被点击时,它会触发相应的方法。

<!-- CalendarDateSelector.vue -->
<template>
  <div class="calendar-date-selector">
    <span @click="selectPrevious">﹤</span>
    <span @click="selectCurrent">Today</span>
    <span @click="selectNext">﹥</span>
  </div>
</template>

然后,在脚本部分,我们将设置组件将接受的两个道具

  • currentDate 允许我们当点击“今天”按钮时返回到当前月份。
  • selectedDate 告诉我们当前选择了哪个月份。

我们还将定义负责根据当前选定的日期使用 subtractadd 方法从 Day.js 计算新选定日期的方法。每个方法还将 $emit 一个事件到父组件,其中包含新选定的月份。这使我们能够在一个地方(这将是我们的 CalendarMonth.vue 组件)保留选定日期的值,并将其传递给所有子组件(即标题、日历网格)。

// CalendarDateSelector.vue
<script>
import dayjs from "dayjs";

export default {
  name: "CalendarDateSelector",

  props: {
    currentDate: {
      type: String,
      required: true
    },

    selectedDate: {
      type: Object,
      required: true
    }
  },

  methods: {
    selectPrevious() {
      let newSelectedDate = dayjs(this.selectedDate).subtract(1, "month");
      this.$emit("dateSelected", newSelectedDate);
    },

    selectCurrent() {
      let newSelectedDate = dayjs(this.currentDate);
      this.$emit("dateSelected", newSelectedDate);
    },

    selectNext() {
      let newSelectedDate = dayjs(this.selectedDate).add(1, "month");
      this.$emit("dateSelected", newSelectedDate);
    }
  }
};
</script>

现在,让我们回到 CalendarMonth.vue 组件并使用我们新创建的组件。

要使用它们,我们首先需要导入并 注册 这些组件,我们还需要创建将作为道具传递给这些组件的值

  • today 正确格式化今天的日期,并用作“今天”分页按钮的值。
  • selectedDate 是 当前选定的日期(默认设置为今天的日期)。

在我们可以呈现组件之前,我们需要做的最后一件事是创建一个负责更改 selectedDate 值的方法。当收到来自分页组件的事件时,将触发此方法。

// CalendarMonth.vue
<script>
import dayjs from "dayjs";
import CalendarDateIndicator from "./CalendarDateIndicator";
import CalendarDateSelector from "./CalendarDateSelector";

export default {
  components: {
    CalendarDateIndicator,
    CalendarDateSelector
  },

  data() {
    return {
      selectedDate: dayjs(),
      today: dayjs().format("YYYY-MM-DD")
    };
  },

  methods: {
    selectDate(newSelectedDate) {
      this.selectedDate = newSelectedDate;
    }
  }
};
</script>

现在我们拥有了渲染日历标题所需的一切

<!-- CalendarMonth.vue -->
<template>
  <div class="calendar-month">
    <div class="calendar-month-header">
      <CalendarDateIndicator
        :selected-date="selectedDate"
        class="calendar-month-header-selected-month"
      />
      <CalendarDateSelector
        :current-date="today"
        :selected-date="selectedDate"
        @dateSelected="selectDate"
      />
    </div>
  </div>
</template>

这是一个停止并 查看我们到目前为止所取得的成果 的好地方。我们的日历标题正在执行我们想要的所有操作,因此让我们继续前进,为日历网格创建组件。

步骤 3:日历网格组件

在这里,我们再次有两个组件

  • CalendarWeekdays 显示工作日名称。
  • CalendarMonthDayItem 表示日历中的某一天。

CalendarWeekdays 组件包含一个列表,该列表遍历工作日标签(使用 v-for 指令)并为每个工作日呈现该标签。在脚本部分,我们需要定义我们的工作日并创建一个 computed 属性以使其在模板中可用并缓存结果以防止我们将来不得不重新计算它。

// CalendarWeekdays.vue
<template>
  <ol class="day-of-week">
    <li
      v-for="weekday in weekdays"
      :key="weekday"
    >
      {{ weekday }}
    </li>
  </ol>
</template>


<script>
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

export default {
  name: 'CalendarWeekdays',

  computed: {
    weekdays() {
      return WEEKDAYS
    }
  }
}
</script>

接下来是 CalendarMonthDayItem。它是一个列表项,它接收一个 day 属性,该属性是一个对象,以及一个布尔类型的 prop,isToday,它允许我们为列表项设置样式以指示它是当前日期。我们还有一个 computed 属性,它将接收到的日期对象格式化为我们所需的 日期格式D 或月份中的数字日期)。

// CalendarMonthDayItem.vue
<template>
  <li
    class="calendar-day"
    :class="{
      'calendar-day--not-current': !isCurrentMonth,
      'calendar-day--today': isToday
    }"
  >
    <span>{{ label }}</span>
  </li>
</template>


<script>
import dayjs from "dayjs";

export default {
  name: "CalendarMonthDayItem",

  props: {
    day: {
      type: Object,
      required: true
    },

    isCurrentMonth: {
      type: Boolean,
      default: false
    },

    isToday: {
      type: Boolean,
      default: false
    }
  },

  computed: {
    label() {
      return dayjs(this.day.date).format("D");
    }
  }
};
</script>

好的,现在我们有了这两个组件,让我们看看如何将它们添加到我们的 CalendarMonth 组件中。

我们首先需要导入并 注册 它们。我们还需要创建一个 computed 属性,该属性将返回一个表示我们日期的对象数组。每一天都包含一个 date 属性和一个 isCurrentMonth 属性。

// CalendarMonth.vue
<script>
import dayjs from "dayjs";
import CalendarMonthDayItem from "./CalendarMonthDayItem";
import CalendarWeekdays from "./CalendarWeekdays";


export default {
  name: "CalendarMonth",

  components: {
    // ...
    CalendarMonthDayItem,
    CalendarWeekdays
  },

  computed: {
    days() {
      return [
        { date: "2020-06-29", isCurrentMonth: false },
        { date: "2020-06-30", isCurrentMonth: false },
        { date: "2020-07-01", isCurrentMonth: true },
        { date: "2020-07-02", isCurrentMonth: true },
        // ...
        { date: "2020-07-31", isCurrentMonth: true },
        { date: "2020-08-01", isCurrentMonth: false },
        { date: "2020-08-02", isCurrentMonth: false }
      ];
    }
  }
};
</script>

然后,在模板中,我们可以呈现我们的组件。同样,我们使用 v-for 指令呈现所需数量的日期元素。

<!-- CalendarMonth.vue -->
<template>
  <div class="calendar-month">
    <div class="calendar-month-header">
      // ...
    </div>

    <CalendarWeekdays/>

    <ol class="days-grid">
      <CalendarMonthDayItem
        v-for="day in days"
        :key="day.date"
        :day="day"
        :is-today="day.date === today"
      />
    </ol>
  </div>
</template>

好的,事情开始看起来不错了。看看 我们现在的位置。它看起来不错,但正如您可能注意到的那样,模板目前仅包含静态数据。月份被硬编码为 7 月,日期数字也被硬编码。我们将通过计算特定月份应该显示哪个日期来更改它。让我们深入代码!

步骤 4:设置当前月份日历

让我们思考一下如何计算特定月份应该显示的日期。这就是 Day.js 真正发挥作用的地方。它提供我们所需的所有数据,以便使用真实的日历数据正确地将日期放置在给定月份的正确日期列中。它允许我们获取和设置从 月份的开始日期 到我们显示数据所需的所有 日期格式选项 的任何内容。

我们将

  • 获取当前月份
  • 计算日期应该放置的位置(工作日)
  • 计算显示上个月和下个月日期的天数
  • 将所有日期放在单个数组中

我们已经在我们的 CalendarMonth 组件中导入了 Day.js。我们还将依靠几个 Day.js 插件来寻求帮助。WeekDay 帮助我们设置一周的第一天。有些人喜欢星期日作为一周的第一天。其他人喜欢星期一。哎呀,在某些情况下,从星期五开始是有意义的。我们将从星期一开始。

WeekOfYear 插件返回一年中当前周的数字值。一年中有 52 周,因此我们可以说从 1 月 1 日开始的那一周是一年中的第一周,依此类推。

以下是我们放入 CalendarMonth.vue 中以将所有这些内容付诸实践的内容

// CalendarMonth.vue
<script>
import dayjs from "dayjs";
import weekday from "dayjs/plugin/weekday";
import weekOfYear from "dayjs/plugin/weekOfYear";
// ...


dayjs.extend(weekday);
dayjs.extend(weekOfYear);
// ...

这非常简单,但现在真正的乐趣开始了,因为我们现在将使用日历网格。让我们停下来思考一下我们真正需要做什么才能做到这一点。

首先,我们希望日期数字落在正确的工作日列中。例如,2020 年 7 月 1 日是星期三。日期编号应该从那里开始。

如果一个月第一天是星期三,这意味着第一周的星期一和星期二将会有空的网格项。这个月的最后一天是7月31日,是星期五。这意味着网格最后一周的星期六和星期天将是空的。我们希望用前后月份的结尾和开头日期填充这些空白,以便日历网格始终是满的。

Calendar grid showing the first two and last two days highlighted in red, indicated they are coming from the previous and next months.

添加当前月份的日期

要将当前月份的天数添加到网格中,我们需要知道当前月份有多少天。我们可以使用 Day.js 提供的 daysInMonth 方法获取该信息。让我们为此创建一个 computed 属性。

// CalendarMonth.vue
computed: {
  // ...
  numberOfDaysInMonth() {
      return dayjs(this.selectedDate).daysInMonth();
  }
}

当我们知道天数后,创建一个长度等于当前月份天数的空数组。然后我们使用 map() 方法遍历该数组,并为每个元素创建一个日期对象。我们创建的对象具有任意结构,因此如果需要,可以添加其他属性。

但是,在这个例子中,我们需要一个 date 属性来检查特定日期是否为当前日期。我们还将返回一个 isCurrentMonth 值,用于检查日期是否在当前月份内或之外。如果它在当前月份之外,我们将对其进行样式设置,以便用户知道它们超出了当前月份的范围。

// CalendarMonth.vue
computed: {
  // ...
  currentMonthDays() {
    return [...Array(this.numberOfDaysInMonth)].map((day, index) => {
      return {
        date: dayjs(`${this.year}-${this.month}-${index + 1}`).format("YYYY-MM-DD")
        isCurrentMonth: true
      };
    });
  },
}

添加上个月的日期

要获取上个月的日期以在当前月份中显示,我们需要检查所选月份的第一天是星期几。这就是我们可以使用 Day.js 的 WeekDay 插件的地方。让我们为此创建一个辅助方法。

// CalendarMonth.vue
methods: {
  // ...
  getWeekday(date) {
    return dayjs(date).weekday();
  },
}

然后,根据该值,我们需要检查上个月的最后一个星期一是哪一天。我们需要这个值来知道当前月份视图中应该显示上个月多少天。我们可以通过从当前月份的第一天减去星期几的值来获得该值。例如,如果这个月第一天是星期三,我们需要减去三天才能得到上个月的最后一个星期一。有了这个值,我们就可以创建一个日期对象数组,从上个月的最后一个星期一开始到该月的结束。

// CalendarMonth.vue
computed: {
  // ...
  previousMonthDays() {
    const firstDayOfTheMonthWeekday = this.getWeekday(this.currentMonthDays[0].date);
    const previousMonth = dayjs(`${this.year}-${this.month}-01`).subtract(1, "month");

    // Cover first day of the month being sunday (firstDayOfTheMonthWeekday === 0)
    const visibleNumberOfDaysFromPreviousMonth = firstDayOfTheMonthWeekday ? firstDayOfTheMonthWeekday - 1 : 6;

    const previousMonthLastMondayDayOfMonth = dayjs(this.currentMonthDays[0].date).subtract(visibleNumberOfDaysFromPreviousMonth, "day").date();

    return [...Array(visibleNumberOfDaysFromPreviousMonth)].map((day, index) = {
      return {
        date: dayjs(`${previousMonth.year()}-${previousMonth.month() + 1}-${previousMonthLastMondayDayOfMonth + index}`).format("YYYY-MM-DD"),
        isCurrentMonth: false
      };
    });
  }
}

添加下个月的日期

现在,让我们反过来计算我们需要从下个月获取哪些日期来填充当前月份的网格。幸运的是,我们可以使用我们刚刚为上个月计算创建的相同辅助方法。不同之处在于,我们将通过从 7 中减去该星期几数值来计算下个月应该显示多少天。

因此,例如,如果这个月的最后一天是星期六,我们需要从 7 中减去一天来构建从下个月需要的日期数组(星期天)。

// CalendarMonth.vue
computed: {
  // ...
  nextMonthDays() {
    const lastDayOfTheMonthWeekday = this.getWeekday(`${this.year}-${this.month}-${this.currentMonthDays.length}`);
    const nextMonth = dayjs(`${this.year}-${this.month}-01`).add(1, "month");
    const visibleNumberOfDaysFromNextMonth = lastDayOfTheMonthWeekday ? 7 - lastDayOfTheMonthWeekday : lastDayOfTheMonthWeekday;

    return [...Array(visibleNumberOfDaysFromNextMonth)].map((day, index) => {
      return {
        date: dayjs(`${nextMonth.year()}-${nextMonth.month() + 1}-${index + 1}`).format("YYYY-MM-DD"),
        isCurrentMonth: false
      };
    });
  }
}

好的,我们知道如何创建所有需要的日期,所以让我们使用它们并将所有日期合并到一个包含我们想要在当前月份显示的所有日期的单个数组中,包括来自前后月份的填充日期。

// CalendarMonth.vue
computed: {
  // ...
  days() {
    return [
      ...this.previousMonthDays,
      ...this.currentMonthDays,
      ...this.nextMonthDays
    ];
  },
}

 ,就是这样!查看 最终演示 以查看所有内容的组合。