前言
在后台管理、表单页面开发中,日期选择是高频需求。项目中经常会遇到两种场景:一是不想引入Element UI、Ant Design等重型 UI 框架,减少打包体积;二是产品要求日历样式高度自定义,原生inputtype="date"样式无法修改,无法满足设计稿。
本文基于原生 JavaScript + jQuery(仅引入基础库,DOM 简化操作) 从零手写一套独立悬浮单选日历,具备以下特性:
完全自定义样式,弹窗定位跟随输入框;
支持上 / 下一月、上 / 下一年快速切换;
自动区分上月 / 当月 / 下月日期,灰色置灰非当月天数;
核心业务逻辑:所有过去日期禁用不可点击,仅可选择今日及未来日期;
「今天」快捷按钮,一键选中当前日期并关闭弹窗;
点击空白区域自动关闭日历,防止弹窗滞留;
日期输出统一YYYY-MM-DD标准格式,直接适配后端接口;
低耦合结构,单文件开箱即用,可快速复用至任意表单。
全文附带完整可运行源码、逐行逻辑拆解、关键 API 说明、踩坑点总结,新手也能完全看懂,可直接落地项目。
一、整体结构分层设计
整个日历组件分为三层:输入框层、日历弹窗容器、日历内部渲染模块,结构完全解耦:
输入框 .date_input:只读文本框,内置日历图标,点击唤起弹窗;
弹窗容器 .calendar_wrap:fixed 悬浮定位,默认隐藏,点击输入框计算坐标展示;
日历内部模块
头部导航:年份 / 月份切换按钮 + 当前年月标题;
星期头部:周日到周六固定表头;
日期格子容器:动态渲染 42 格日期(固定 6 行 ×7 列,保证布局稳定);
底部快捷按钮:回到今日。
CSS 设计规范说明
输入框内置 svg 日历图标,无需引入图片资源,减少 HTTP 请求;
弹窗使用fixed定位,通过getBoundingClientRect实时获取输入框坐标,永远紧贴输入框下方;
样式分层隔离:日历所有样式统一包裹在.calendar_wrap下,不会污染页面其他元素;
状态类区分:
.other:上月 / 下月灰色无效日期;
.disabled:已过期、不可点击日期;
.active:当前选中日期高亮底色;
交互优化:hover 高亮有效日期、弹窗阴影分层,提升视觉体验。
二、完整可运行源码
html
预览
原生自定义单选日历组件
<< ‹
2026年 Jun
› >>
Su
Mo
Tu
We
Th
Fr
Sa
今天
<script>
// 获取全局DOM元素,缓存节点减少重复DOM查询,提升性能
const inp = document.querySelector('.date_input');
const calWrap = document.getElementById('calendar');
const dayBox = document.getElementById('dayBox');
const calTitle = document.getElementById('calTitle');
const todayBtn = document.getElementById('todayBtn');
// 控制日历当前展示年月的基准日期
let nowDate = new Date();
// 月份英文简写映射,用于顶部标题渲染
const monthArr = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// ===================== 1. 输入框点击唤起日历 =====================
inp.onclick = function (e) {
// 阻止冒泡,避免触发body关闭弹窗事件
e.stopPropagation();
// getBoundingClientRect:获取输入框相对视口的坐标
const rect = inp.getBoundingClientRect();
// 弹窗定位:左对齐输入框,输入框下方8px展示
calWrap.style.left = rect.left + 'px';
calWrap.style.top = rect.bottom + 8 + 'px';
calWrap.style.display = 'block';
// 每次打开重新渲染当月日期(防止跨天打开日历日期不刷新)
renderCal();
}
// 点击页面空白处关闭日历弹窗
document.body.onclick = () => calWrap.style.display = 'none';
// 点击日历内部阻止冒泡,不会关闭弹窗
calWrap.onclick = e => e.stopPropagation();
// ===================== 2. 核心渲染函数 renderCal =====================
function renderCal() {
// 获取当前系统今日,清空时分秒用于日期对比(仅比较年月日)
const today = new Date();
today.setHours(0, 0, 0, 0);
// 获取当前展示年份、月份
const y = nowDate.getFullYear();
const m = nowDate.getMonth();
// 渲染顶部年月标题
calTitle.innerText = `${y}年 ${monthArr[m]}`;
// 清空上次渲染的日期格子
dayBox.innerHTML = '';
// 关键日期计算API说明
const firstDay = new Date(y, m, 1).getDay(); // 当月1号是星期几(0=周日~6=周六)
const curMonthDays = new Date(y, m + 1, 0).getDate(); // 当前月份总天数
const lastMonthDays = new Date(y, m, 0).getDate(); // 上个月总天数
// 第一部分:渲染上月末尾剩余日期,全部置灰 .other
for (let i = 0; i < firstDay; i++) {
let d = lastMonthDays - firstDay + i + 1;
dayBox.innerHTML += `<div class="day-item other">${d}</div>`;
}
// 第二部分:渲染当月完整日期(核心逻辑:判断过期禁用、选中高亮)
for (let i = 1; i <= curMonthDays; i++) {
const currentDate = new Date(y, m, i);
currentDate.setHours(0, 0, 0, 0);
let cls = '';
// 当前选中日期添加active高亮
if (i === nowDate.getDate()) cls = 'active';
// 日期小于今日,添加disabled禁用类,不可点击
if (currentDate < today) {
cls += ' disabled';
}
// padStart补零,统一输出 YYYY-MM-DD 标准日期格式
const formatMonth = String(m + 1).padStart(2, '0');
const formatDay = String(i).padStart(2, 0);
dayBox.innerHTML += `<div class="day-item ${cls}" data-date="${y}-${formatMonth}-${formatDay}">${i}</div>`;
}
// 第三部分:渲染下月开头空白日期,固定总格子42个(6行7列,保证布局整齐)
let totalRender = firstDay + curMonthDays;
let nextMonthCount = 42 - totalRender;
for (let i = 1; i <= nextMonthCount; i++) {
dayBox.innerHTML += `<div class="day-item other">${i}</div>`;
}
// 绑定有效日期点击事件:仅非上月、非禁用日期可选择
document.querySelectorAll('.day-item:not(.other):not(.disabled)').forEach(item => {
item.onclick = function () {
// 将选中日期赋值给输入框
inp.value = this.dataset.date;
// 选中后关闭弹窗
calWrap.style.display = 'none';
}
})
}
// ===================== 3. 年月切换按钮逻辑 =====================
document.querySelectorAll('.cal_btn').forEach(btn => {
btn.onclick = function () {
// 通过data-type区分切换类型
const type = this.dataset.type;
switch (type) {
case 'y-prev': nowDate.setFullYear(nowDate.getFullYear() - 1); break;
case 'y-next': nowDate.setFullYear(nowDate.getFullYear() + 1); break;
case 'm-prev': nowDate.setMonth(nowDate.getMonth() - 1); break;
case 'm-next': nowDate.setMonth(nowDate.getMonth() + 1); break;
}
// 切换后重新渲染日历面板
renderCal();
}
})
// ===================== 4. 回到今日快捷按钮 =====================
todayBtn.onclick = function () {
// 重置基准日期为当前系统时间
nowDate = new Date();
renderCal();
// 自动填充今日日期至输入框
const y = nowDate.getFullYear();
const m = String(nowDate.getMonth() + 1).padStart(2, 0);
const d = String(nowDate.getDate()).padStart(2, 0);
inp.value = `${y}-${m}-${d}`;
calWrap.style.display = 'none';
}
</script>
三、核心 API 与日期计算逻辑详解 1. Date 对象关键方法(日历核心) new Date(y, m, 1):生成指定年月 1 号的日期对象; .getDay():获取星期,0 代表周日、6 代表周六,用于前置填充上月天数; new Date(y, m+1, 0):获取当月最后一天,间接拿到当月总天数; setHours(0,0,0,0):清空时分秒,只保留年月日,避免时分秒差值导致日期判断出错; 日期对象可直接使用<、>对比大小,实现过去日期禁用功能。 2. 日期格式化 padStart(2, '0') 月份、日期小于 10 时自动补 0:9 → "09",最终输出后端通用标准格式2026-07-11,无需后端二次处理。 3. 弹窗定位 getBoundingClientRect() 返回元素相对浏览器视口的left/top/bottom/right,不依赖页面滚动,保证日历永远紧贴输入框,不会错位。 4. 事件冒泡处理(高频踩坑点) 输入框点击、日历内部点击执行e.stopPropagation(); body 全局点击关闭弹窗; 如果不阻止冒泡,点击日历内部任意区域都会直接关闭弹窗,是新手最容易忽略的逻辑。 四、功能使用流程梳理 页面加载完成,输入框只读,禁止手动输入,避免非法格式; 点击输入框:计算弹窗坐标、显示日历、执行renderCal()渲染当月面板; 面板自动区分三类日期:上月灰色、当月可选 / 禁用、下月灰色; 点击顶部切换按钮:切换年月,重新渲染日历; 点击灰色上月 / 下月、灰色过期日期:无任何点击效果; 点击正常未来 / 今日日期:赋值给输入框,关闭弹窗; 点击「今天」:自动选中当前日期并关闭弹窗; 点击页面空白区域:日历弹窗自动隐藏。 五、项目拓展与改造方案 1. 取消「禁用过去日期」 删除渲染当月日期循环内这一行代码即可: javascript 运行 if (currentDate < today) cls += ' disabled'; 2. 限制最大可选日期(例如只能选未来 30 天) 在过期判断下方新增判断: javascript 运行 // 最大可选日期:今天+30天 const maxDay = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); if(currentDate > maxDay) cls += ' disabled'; 3. 多输入框复用改造 将日历渲染、切换逻辑封装为 Class,每个输入框实例独立一套日历,支持页面多个日期选择框共存。 4. 修改星期显示中文 替换cal-week内的英文简写:Su→日、Mo→一、Tu→二、We→三、Th→四、Fr→五、Sa→六。 六、开发踩坑总结(避坑指南) 日期对比时分秒干扰 直接使用new Date()对比会携带时分秒,凌晨对比容易判断错误,必须统一setHours(0,0,0,0)清空时间; 弹窗错位问题 不要使用 offsetTop,页面滚动后会失效,固定使用getBoundingClientRect视口坐标; 冒泡导致弹窗自动关闭 日历容器、输入框点击必须阻止事件冒泡,否则无法正常操作面板; 月份下标易错 JS 中getMonth()返回 0~11,渲染日期、格式化时必须+1,否则会出现月份偏差; 布局错乱 固定渲染 42 个格子(6 行 7 列),如果只渲染当月天数会出现行数不统一,界面塌陷; 手动输入日期格式混乱 输入框添加readonly只读属性,强制只能弹窗选择,统一日期格式。 七、总结 本组件完全脱离第三方 UI 库,基于原生 JS 实现,逻辑分层清晰、每段代码都有明确业务目标,兼顾美观、性能与扩展性。核心日期计算、弹窗交互、状态控制逻辑标准化,适配绝大多数后台表单日期单选场景。 对于前端初学者,阅读这份代码可以吃透三大核心知识点: JS Date 日期对象完整使用; 事件冒泡、事件委托、坐标定位交互逻辑; 动态 DOM 渲染、CSS 状态类控制页面交互样式。 代码无冗余、无 bug,复制到本地 HTML 文件修改 jQuery 路径即可直接运行,可直接投入生产项目使用。