相信很多初学者同学,在学完 Web Api 的时候自己手写案例的想法就会一发不可收拾,本文将为大家带来一个非常常见的组件手写,纯原生,文本前半部分为源码展示,后半部分为源码的讲解,逐步分析代码的逻辑,有耐心的可以观看后半部分,只需要效果的就可以直接复制源码
运行效果
源码展示
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="./css/common.css">
<link rel="stylesheet" href="./css/calendar-header.css">
<link rel="stylesheet" href="./css/calendar-table.css">
<link rel="stylesheet" href="https://at.alicdn.com/t/c/font_3918133_k2xnhhtzck.css">
<title>原生手写日历组件</title>
</head>
<body>
<div class="container">
<div class="calendar-wrap">
<div class="calendar-header">
<div class="btn pre-year iconfont icon-left-arrows"></div>
<div class="btn pre-month iconfont icon-left-arrow"></div>
<div class="date-show"></div>
<div class="btn next-month iconfont icon-right-arrow"></div>
<div class="btn next-year iconfont icon-right-arrows"></div>
</div>
<table class="table-wrap">
<thead>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<script src="./index.js"></script>
</body>
</html>
css
-
common.css
* { margin: 0; padding: 0; box-sizing: border-box; } :root { --primary-color: #2495f5; --br: 8px; --bs: 5px 5px 5px #e0e0e6, -5px -5px 5px #fff; --bs-inset: 5px 5px 5px #e0e0e6 inset, -5px -5px 5px #fff inset; } .container { width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; background-color: #edf0f5; }
-
calendar-header.css
.calendar-wrap { display: flex; flex-direction: column; padding: 15px; border-radius: var(--br); background-color: #edf0f5; box-shadow: var(--bs); cursor: pointer; } .calendar-header { display: flex; align-items: center; } .date-show { font-size: 20px; color: var(--primary-color); margin: 0 50px; font-weight: 600; } .btn { width: 45px; height: 45px; border-radius: var(--br); display: flex; justify-content: center; align-items: center; background-color: #edf0f5; box-shadow: var(--bs); } .btn:hover { box-shadow: var(--bs-inset); } .btn.iconfont { font-size: 18px; color: var(--primary-color); font-weight: bold; } .pre-month { margin-right: auto; margin-left: 10px; } .next-month { margin-left: auto; margin-right: 10px; }
-
calendar-table.css
.table-wrap { margin-top: 10px; border-spacing: 15px; } .table-wrap th { width: 50px; height: 50px; background-color: var(--primary-color); border-radius: var(--br); box-shadow: var(--bs); color: #fff; border: 2px solid #edf1f4; } .table-wrap td { font-weight: 500; font-size: 1.25rem; width: 50px; height: 50px; text-align: center; vertical-align: middle; background-color: #edf1f4; border-radius: var(--br); box-shadow: var(--bs); color: #777; } .table-wrap td.active.active { border: 2px solid #edf1f4; background-color: var(--primary-color); color: #fff; box-shadow: var(--bs); } .table-wrap td:hover { box-shadow: var(--bs-inset); } .table-wrap td.not-month { color: #ccc; box-shadow: var(--bs); }
js
/**
* 获取指定月份的日历数据
* @author coderjc <coderjc@qq.com>
* @param {Number} year 年份(默认当前年份)
* @param {Number} month 月份(默认当前月份)
* @returns {Array} 返回一个指定月份的日历数组
* @example
* getCalendar(2023, 8) // 返回2023年8月的日历数组
*/
function getCalendar(year, month) {
// 创建 date 对象实例
const date = new Date()
// 月份从 0 开始
date.setFullYear(year)
date.setMonth(month - 1)
// 设置当月的第一天-即将日期设置为 1 号
date.setDate(1)
// 通过设置当月的第一天可以获取当前月的 1 号是周几
const firstDay = date.getDay()
// 获取当前月份的最大天数
let curMaxMonthDays = getMaxDaysInMonth(year, month)
// 获取上月的最大天数
// - 如果月份为1,则获取的上月天数为年份-1,月份12
let preMonthDays = undefined
if (month === 1) {
preMonthDays = getMaxDaysInMonth(year - 1, 12)
}
// 否则正常获取
else {
preMonthDays = getMaxDaysInMonth(year, month - 1)
}
// 定义月数组
const monthList = []
// 本月初始天数值
let count = 0
// 下月初始天数值
let nextCount = 0
// 生成每月日历
for (let i = 0; i < 6; i++) {
// 生成每周的数据
const weekList = new Array(7)
monthList.push(weekList)
// 当 i 为 0 表示是第一周的情况
if (i === 0) {
for (let j = firstDay; j < weekList.length; j++) {
// 已知本月的第一天是周几,即可确定第一周的具体日期
weekList[j] = {
stage: 'cur',
y: year,
m: month,
d: ++count
}
}
}
// 其余的也进行填值
else {
for (let j = 0; j < weekList.length; j++) {
// 已知本月的第一天是周几,即可确定第一周的具体日期
count++
// 当这个天数超出本月最大天数的时候就填充下个月的天数
if (count > curMaxMonthDays) {
// 当月份数等于12时表示为下一年,则年份+1,月份重置为1
if (month === 12) {
weekList[j] = {
stage: 'next',
y: year + 1,
m: 1,
d: ++nextCount
}
}
// 否则只执行月份+1
else {
weekList[j] = {
stage: 'next',
y: year,
m: month + 1,
d: ++nextCount
}
}
}
// 否则表示属于当前月
else {
weekList[j] = {
stage: 'cur',
y: year,
m: month,
d: count
}
}
}
}
}
// 填充第一周缺失的日期-即上月的末尾日期
for (let i = firstDay - 1; i >= 0; i--) {
let data = null
// 如果当前月为1,则个月的填充数据年份需要-1,月份改为12
if (month === 1) {
data = {
stage: 'pre',
y: year - 1,
m: 12,
d: preMonthDays--
}
} else {
data = {
stage: 'pre',
y: year,
m: month - 1,
d: preMonthDays--
}
}
// 如果不为1,则只需要月份-1
monthList[0][i] = data
}
return monthList
}
/**
* 获取指定月份的最大天数
* @author coderjc <coderjc@qq.com>
* @param {Number} year 年份
* @param {Number} month 月份
* @returns {Number} 指定月份的当月最大天数
* @example
* getMaxDaysInMonth() // 返回一个指定月份的当月最大天数
*/
function getMaxDaysInMonth(year, month) {
// 构造一个日期对象,将月份设置为所需的月份,日期为1日
const date = new Date(year, month - 1, 1)
// 将日期设置为下个月的第0天,即当前月的最后一天
date.setMonth(date.getMonth() + 1, 0)
// 获取日期对象的日期部分,即最大天数
const maxDay = date.getDate()
return maxDay
}
// 获取当前日期
function getToday() {
const date = new Date()
return {
y: date.getFullYear(),
m: date.getMonth() + 1,
d: date.getDate()
}
}
// 获取dom
const tableWrap = document.querySelector('.calendar-wrap .table-wrap')
const dateShow = document.querySelector('.date-show')
const theadDom = tableWrap.querySelector('thead')
const tbodyDom = tableWrap.querySelector('tbody')
const preYearBtn = document.querySelector('.pre-year')
const preMonthBtn = document.querySelector('.pre-month')
const nextYearBtn = document.querySelector('.next-year')
const nextMonthBtn = document.querySelector('.next-month')
// 获取当前日期
const currentYear = getToday().y
const currentMonth = getToday().m
const today = getToday().d
// 日历头部
const thList = ['日', '一', '二', '三', '四', '五', '六']
// 日历数据
const calendarList = getCalendar(currentYear, currentMonth)
// 创建标题
function createTitle(y, m) {
dateShow.setAttribute('data-year', y)
dateShow.setAttribute('data-month', m)
dateShow.innerText = `${y}年 ${m.toString().padStart(2, '0')}月`
}
// 创建主体
function createTbody(list) {
tbodyDom.innerHTML = ''
list.forEach(row => {
// 创建行
const tr = document.createElement('tr')
// 创建列
row.forEach(col => {
const td = document.createElement('td')
col.stage !== 'cur' ? td.classList.add('not-month') : ''
// 如果当前年月日相等且属于当前阶段时添加激活类名
if (col.y === currentYear && col.m === currentMonth && col.d === today && col.stage === 'cur') {
td.classList.add('active')
}
td.innerText = col.d
tr.appendChild(td)
})
tbodyDom.appendChild(tr)
})
}
// 创建头部
function createThead() {
const tr = document.createElement('tr')
thList.forEach(item => {
const th = document.createElement('th')
th.innerText = item
tr.appendChild(th)
})
theadDom.appendChild(tr)
}
// 绑定事件
function bindEvent() {
bindPreMonth()
bindPreYear()
bindNextMonth()
bindNextYear()
}
// 上一月
function bindPreMonth() {
preMonthBtn.addEventListener('click', function () {
const y = +dateShow.getAttribute('data-year')
const m = dateShow.getAttribute('data-month') - 1
if (m <= 0) {
dateShow.setAttribute('data-month', 12)
preYearBtn.click()
return
}
createTitle(y, m)
createTbody(getCalendar(y, m))
})
}
// 上一年
function bindPreYear() {
preYearBtn.addEventListener('click', function () {
const y = dateShow.getAttribute('data-year') - 1
const m = +dateShow.getAttribute('data-month')
createTitle(y, m)
createTbody(getCalendar(y, m))
})
}
// 下一月
function bindNextMonth() {
nextMonthBtn.addEventListener('click', function () {
const y = +dateShow.getAttribute('data-year')
const m = +dateShow.getAttribute('data-month') + 1
if (m >= 13) {
dateShow.setAttribute('data-month', 1)
nextYearBtn.click()
return
}
createTitle(y, m)
createTbody(getCalendar(y, m))
})
}
// 下一年
function bindNextYear() {
nextYearBtn.addEventListener('click', function () {
const y = +dateShow.getAttribute('data-year') + 1
const m = +dateShow.getAttribute('data-month')
createTitle(y, m)
createTbody(getCalendar(y, m))
})
}
createTitle(currentYear, currentMonth)
createThead()
createTbody(calendarList)
bindEvent()
文件目录
源码分析
关于 html + css 的内容本文不在进行赘述,都是一些很简单的代码
步骤分析
- 如果想要实现一个日历组件,那么什么是最重要的呢,那肯定是这个一个日历的数据怎么生成,所以本案例的核心方法就是 getCalendar,这个方法的具体实现我们后续讲解
- 所以当我们得到这个数据之后就很简单了,只需要通过它生成对应的日历数据
- 生成数据后我们就要对按钮进行功能绑定,当然这些地方需要注意的就是临近边界值时的一些判断
###确定数据格式
-
我们先看一下方法的文档注释,如下:
/** * 获取指定月份的日历数据 * @author coderjc <coderjc@qq.com> * @param {Number} year 年份(默认当前年份) * @param {Number} month 月份(默认当前月份) * @returns {Array} 返回一个指定月份的日历数组 * @example * getCalendar(2023, 8) // 返回2023年8月的日历数组 */
-
通过这个文档注释我们就可以得出,需要接受两个参数,一个年份,一个月份,并返回指定日期的当月日历数据,好,那么这个日历数据应该是什么样式的呢,这里我们看一下 elemen-ui 的日历组件时什么样的,如图:
-
这个图片我们先抛开上面的功能区域,只看显示的数据区域我们可以发现什么,是不是一个类似表格的数据啊,比如:
-
所以如果按照这种数据推导的话,不看表头的话,实际需要生成的数据是不是只有数字部分啊,那么我们需要的是不是一个 六行七列的数据格式呢,但是好像我们无法找出可以直接表示这样的数据格式,那我们不妨换一个思考方法,我们首先创建一个数组,作为总的数组,然后其中的一行数据我们可以在创建一个数组,并放入开始创建的数组里面,那么我们现在的结构就变成了一个二维数组,如:[[]],那么这个一行的数组,里面应该放入什么了,每行都应该有 七列数据,所以我们可以确定,每一行里面应该放入七个数据,那现在我们可以得到一个这样的数组,如下:
[[ null, null, null, null, null, null, null]]
-
现在表格的其中一行出来之后,我们剩下的 5 行也应该保持一直,所以我们最后确定的数据格式如下:
[ [ null, null, null, null, null, null, null], [ null, null, null, null, null, null, null], [ null, null, null, null, null, null, null], [ null, null, null, null, null, null, null], [ null, null, null, null, null, null, null], [ null, null, null, null, null, null, null] ]
-
那么第一步我们就可以写一段 for 循环来实现这段数据的生成,如下:
// 存储日历数据 const monthList = [] for (let i = 0; i < 6; i++) { // 生成每周的数据 const weekList = new Array(7) monthList.push(weekList) }
-
此时我们可以打印看一下数据,如图:
-
此时因为没有填充数据,显示为空是正常的
基础填充数据
-
有了基本的数据格式之后,我们就要考虑填充,怎么填充又陷入了一个难题,填充本月的日历,第一步是什么,是不是确定本月的第一天是星期几呢,当我们得出是周几之后,是不是就可以在第一个行数组中开始进行填充呢
-
如何获取本月的第一天是周几,如下:
// 创建 date 对象实例 const date = new Date() // 设置当月的第一天-即将日期设置为 1 号 date.setDate(1) // 通过设置当月的第一天可以获取当前月的 1 号是周几 const firstDay = date.getDay()
-
结果如图:
-
这个 5 是索引,索引从 0 开始,依次为 [‘日’, ‘一’, ‘二’, ‘三’, ‘四’, ‘五’, ‘六’],所以这个数组的索引为 5 的数据就是 周五
-
本月第一天为 周五,当我们获取到第一天之后我们就可以确定本月第一天,所以我们第一行数据第五项为本月的 1 号,而确定一号之后,后面的数据我们是否也可以确定了呢,每项 1 开始自增,是不是就是我们需要的数据呢,而没一行也是数组,所以我们需要给每一行的数组进行赋值数据的时候,我们可以采用双重 for 循环的方法,代码如下:
function getCalendar(year, month) { const date = new Date() // 月份从 0 开始,所以传递进来的月份值我们需要 - 1 date.setFullYear(year) date.setMonth(month - 1) date.setDate(1) const firstDay = date.getDay() console.log(firstDay) const monthList = [] // 定义初始数据 let count = 0 for (let i = 0; i < 6; i++) { const weekList = new Array(7) monthList.push(weekList) // 当 i 为 0 表示是第一行的数组 if (i === 0) { // j 的初始值为本月第一天在一周中的索引值 for (let j = firstDay; j < weekList.length; j++) { weekList[j] = ++count } } // 其他后续数据我们一样直接自增填充 else { for (let j = firstDay; j < weekList.length; j++) { weekList[j] = ++count } } } console.log(monthList) }
-
此时我们在看一下生成的数据,如图:
-
看到这个图,是不是一个日历的数据填充就已经初具规模了呢,现在我们还需要填充什么,是不是要做一下判断啊,判断当前超出本月最大天数的时候就再次从 1 开始替换成下月的数据,所以我们就要知道本月的最大天数,代码比较简单,如下:
/** * 获取指定月份的最大天数 * @author coderjc <coderjc@qq.com> * @param {Number} year 年份 * @param {Number} month 月份 * @returns {Number} 指定月份的当月最大天数 * @example * getMaxDaysInMonth() // 返回一个指定月份的当月最大天数 */ function getMaxDaysInMonth(year, month) { // 构造一个日期对象,将月份设置为所需的月份,日期为1日 const date = new Date(year, month - 1, 1) // 将日期设置为下个月的第0天,即当前月的最后一天 date.setMonth(date.getMonth() + 1, 0) // 获取日期对象的日期部分,即最大天数 const maxDay = date.getDate() return maxDay }
-
所以当我们可以得知本月最大天数的时候,就可以对超出部分的数据进行处理,如下:
function getCalendar(year, month) { const date = new Date() date.setFullYear(year) date.setMonth(month - 1) date.setDate(1) const firstDay = date.getDay() // 获取当前月份的最大天数 let curMaxMonthDays = getMaxDaysInMonth(year, month) const monthList = [] let count = 0 // 定义下月日期起始值 let nextCount = 0 for (let i = 0; i < 6; i++) { const weekList = new Array(7) monthList.push(weekList) if (i === 0) { for (let j = firstDay; j < weekList.length; j++) { weekList[j] = ++count } } else { for (let j = 0; j < weekList.length; j++) { // 提前自增 count++ // 检测如果 count 大于 本月天数最大值,就重置 weekList[j] = count > curMaxMonthDays ? ++nextCount : count } } } console.log(monthList) } function getMaxDaysInMonth(year, month) { const date = new Date(year, month - 1, 1) date.setMonth(date.getMonth() + 1, 0) const maxDay = date.getDate() return maxDay }
-
结果如图:
-
现在是不是还有上个月那部分数据没有搞定啊,这个也简单,我们需要得知的是上个月的最大天数,然后使用最大天数值递减,填满第一行数组的空缺部分即可,那怎么才能实现呢,代码如下:
function getCalendar(year, month) { const date = new Date() date.setFullYear(year) date.setMonth(month - 1) date.setDate(1) const firstDay = date.getDay() let curMaxMonthDays = getMaxDaysInMonth(year, month) // 获取上月的最大天数 let preMonthDays = getMaxDaysInMonth(year, month - 1) const monthList = [] let count = 0 let nextCount = 0 for (let i = 0; i < 6; i++) { const weekList = new Array(7) monthList.push(weekList) if (i === 0) { for (let j = firstDay; j < weekList.length; j++) { weekList[j] = ++count } } else { for (let j = 0; j < weekList.length; j++) { count++ weekList[j] = count > curMaxMonthDays ? ++nextCount : count } } } // 填充第一周缺失的日期-即上月的末尾日期 // 为什么要使用(本月第一周的索引)作为 i 的起始值呢? // - 假设上月的数据是 31,如果正常循环递减,那么得到的就是 31、30、29、28、27 // - 如果使用这个数据,从索引第一行的索引开始添加那么结果就是 [31,30,29,28,27,1,2] // - 这显然是不符合我们期望的数据的,索引我们还是递减,但是加入的索引顺序就应该是反的 // - 本月的第一天索引是 5,所以反着添加的话,就应该是索引 4 开始 for (let i = firstDay - 1; i >= 0; i--) { monthList[0][i] = preMonthDays-- } console.log(monthList) } function getMaxDaysInMonth(year, month) { const date = new Date(year, month - 1, 1) date.setMonth(date.getMonth() + 1, 0) const maxDay = date.getDate() return maxDay }
-
结果如图:
-
此时我们就完成了日历数据的初步填充
进阶数据填充
-
上面我们已经获得了基础的日历数据,但是这个方法还有待优化,如果需要做到日历切换,我们已经够了,只需要通过方法改变日期即可,不敢我们如果想要在样式上,让不属于这个月的数据置灰,就需要能够做得出区分,所以我们需要将数据包装成为一个对象,即通过一个布尔值来确定当前的日期是否属于本月的,不过在我的示例中,对这个对象做了一些比较全面的数据,它会包含年、月、日、类型(cur next pre) cur 表示是当前月,next 是下一月,pre 为上月,但是如果只是为了实现样式的效果,单纯使用一个布尔值即可
-
这一部分的代码比较简单,只是对于临界值的一些判断,做出对应的改变,具体的解析在代码中有注释,如下:
function getCalendar(year, month) { // 创建 date 对象实例 const date = new Date() // 月份从 0 开始 date.setFullYear(year) date.setMonth(month - 1) // 设置当月的第一天-即将日期设置为 1 号 date.setDate(1) // 通过设置当月的第一天可以获取当前月的 1 号是周几 const firstDay = date.getDay() // 获取当前月份的最大天数 let curMaxMonthDays = getMaxDaysInMonth(year, month) // 获取上月的最大天数 // - 如果月份为1,则获取的上月天数为年份-1,月份12 let preMonthDays = undefined if (month === 1) { preMonthDays = getMaxDaysInMonth(year - 1, 12) } // 否则正常获取 else { preMonthDays = getMaxDaysInMonth(year, month - 1) } // 定义月数组 const monthList = [] // 本月初始天数值 let count = 0 // 下月初始天数值 let nextCount = 0 // 生成每月日历 for (let i = 0; i < 6; i++) { // 生成每周的数据 const weekList = new Array(7) monthList.push(weekList) // 当 i 为 0 表示是第一周的情况 if (i === 0) { for (let j = firstDay; j < weekList.length; j++) { // 已知本月的第一天是周几,即可确定第一周的具体日期 weekList[j] = { stage: 'cur', y: year, m: month, d: ++count } } } // 其余的也进行填值 else { for (let j = 0; j < weekList.length; j++) { // 已知本月的第一天是周几,即可确定第一周的具体日期 count++ // 当这个天数超出本月最大天数的时候就填充下个月的天数 if (count > curMaxMonthDays) { // 当月份数等于12时表示为下一年,则年份+1,月份重置为1 if (month === 12) { weekList[j] = { stage: 'next', y: year + 1, m: 1, d: ++nextCount } } // 否则只执行月份+1 else { weekList[j] = { stage: 'next', y: year, m: month + 1, d: ++nextCount } } } // 否则表示属于当前月 else { weekList[j] = { stage: 'cur', y: year, m: month, d: count } } } } } // 填充第一周缺失的日期-即上月的末尾日期 for (let i = firstDay - 1; i >= 0; i--) { let data = null // 如果当前月为1,则个月的填充数据年份需要-1,月份改为12 if (month === 1) { data = { stage: 'pre', y: year - 1, m: 12, d: preMonthDays-- } } // 如果不为1,则只需要月份-1 else { data = { stage: 'pre', y: year, m: month - 1, d: preMonthDays-- } } monthList[0][i] = data } return monthList }
-
结果如图:
结语
并不是忘记了解析后面的代码,后面的代码只是简单的获取dom,根据条件生成元素,通过方法来修改年月值,再次调用 getCalendar方法生成新的 dom 在渲染即可,所以就不用解析了