原生 JS 实现自定义单选日历组件(无 UI 库、纯手写、禁用过去日期)

前言

在后台管理、表单页面开发中,日期选择是高频需求。项目中经常会遇到两种场景:一是不想引入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 路径即可直接运行,可直接投入生产项目使用。

相关推荐
Ulyanov2 小时前
刚体动力学方程——牛顿-欧拉与陀螺效应的奥秘
开发语言·python·目标跟踪·雷达电子对抗·导引头
罗超驿2 小时前
15.Java异常处理:从束手无策到从容应对
java·开发语言
MoonBit月兔2 小时前
MoonBit v0.10.4版本更新
开发语言·人工智能·编程·moonbit
小樱花的樱花2 小时前
Linux 多线程编程:互斥锁(Mutex)详解
linux·c语言·开发语言
mONESY2 小时前
Skill 技能与 MCP 协议的底层逻辑与协同真相
javascript
南川琼语2 小时前
HTTP——AJAX
前端·javascript·ajax
滴滴滴嘟嘟嘟.3 小时前
强化学习-PPO 奖励塑形实验:Pendulum-v1 中角速度惩罚权重的影响
开发语言·python
灵晔君3 小时前
【C++】标准模板库STL——set /multiset/map /multimap
开发语言·c++