从零实现原生JS日期时间选择器:跨框架复用的完整方案 简介一款基于原生JavaScript编写的日期时间选择器源码面向前端开发者和初级程序员解决表单日期时间录入、日程安排等界面交互需求因无框架依赖可被直接嵌入任意Web项目真正做到“任何地方可用”。压缩包共3个文件一个JS逻辑文件负责日历生成、弹出控制、事件监听、日期校验与格式化一个HTML入口文件定义页面结构与触发元素一个CSS样式文件定制外观布局整体仅8KB加载开销极低。该组件预留了日期格式、禁用日期范围等配置维度源码层次简明便于阅读和二次改造适合学习原生JS中Date对象用法、DOM操作及CSS选择器配合技巧。当前已有774人学习/下载作为轻量工具或前端组件开发参考都很有价值。1. JS日期时间选择器为什么我还在用原生方案接手过一个维护了三年的内部系统页面还在用全局 jQuery新模块却是 Vue 写的。要加一个「计划执行时间」的输入框组件库搬进来还得调样式老页面根本不认。最后决定写一个不依赖任何框架、复制一个 .js 文件就能用的 JS 日期时间选择器挂到哪个容器就渲染在哪个容器里。这个方案解决的是很现实的问题当你需要跨框架、跨页面、甚至跨 iframe 复用一个表单控件时js 原生实现是兼容成本最低的一条路。下面我就从头写一遍这个选择器把日历算法、时间联动、事件机制和容易出问题的点都展开新手能照抄熟手能直接改参数。2. 从零实现 js原生日期选择器DOM结构、日历计算与参数设计2.1 用 JS 生成 DOM而不是写死一段 HTML很多入门教程会把选择器的 HTML 直接贴在页面里用 display:none 控制显隐。短时间能用等到需要第二个实例的时候要么复制一份 HTML 改 id要么两个实例抢同一个面板。字段一多就乱套。我的习惯是写一个工厂函数 createDateTimePicker让选择器自己创建 DOM、自己绑事件、自己维护状态调用方只拿到一个实例对象完全看不见内部结构。这样在 React 项目里可以用在 jQuery 老页面里也能用真正做到了「任何地方可用」全靠一个 js 函数来兜底。先看整体骨架。我把这个函数挂到 window 上不引入模块系统任何页面复制过去就能跑(function (global) { use strict; var WEEKDAYS [日, 一, 二, 三, 四, 五, 六]; var MONTHS [1月, 2月, 3月, 4月, 5月, 6月, 7月, 8月, 9月, 10月, 11月, 12月]; function pad(n) { return n 10 ? 0 n : n; } function createDateTimePicker(config) { var cfg Object.assign({ container: document.body, format: YYYY-MM-DD HH:mm, defaultDate: null, minDate: null, maxDate: null, onChange: null }, config); var container typeof cfg.container string ? document.querySelector(cfg.container) : cfg.container; if (!container) { throw new Error(createDateTimePicker: container 不存在); } var now cfg.defaultDate ? new Date(cfg.defaultDate) : new Date(); if (isNaN(now.getTime())) now new Date(); var state { viewYear: now.getFullYear(), viewMonth: now.getMonth(), selected: null }; var panel document.createElement(div); panel.className dt-picker; function render() { // 日历网格的生成放在 2.2 展开 } panel.addEventListener(click, function (e) { // 事件委托放在第 4 章展开 }); container.appendChild(panel); return { getValue: function () {}, setValue: function () {}, destroy: function () {} }; } global.createDateTimePicker createDateTimePicker; })(window);代码逻辑不复杂但有两个点值得说明。第一为什么用一个立即执行函数包住因为这种工具函数一旦被全局污染比如被别的脚本覆盖了 WEEKDAYS 或 pad整个选择器都会挂掉包一层可以保证内部变量只在闭包里生效对外只暴露 createDateTimePicker 这一个 js 函数。第二config 里用 Object.assign 给默认值做了一层保护调用方传一半参数也不至于报错比如只传 container 和 onChangeformat 自动落到默认的 YYYY-MM-DD HH:mm。这段骨架里 render 还是空的下一步就把日历网格的算法填进去。注意这里 new Date(year, month 1, 0) 中的 month 是 0 基的1 月对应 012 月对应 11这个约定贯穿整个选择器后面格式化输出时再统一加 1。这个位置最容易在传参时搞混我通常在 render 里先把 year、month 打印一遍再调样式。2.2 日历网格的核心计算某月天数、星期偏移与 42 格布局日历面板本质上是一张 6 行 × 7 列的表格。为什么不是 31 个格子而是 42 个因为 1 号未必是周日它前面可能有最多 6 个空位为了保证每个月的面板高度一致翻月时不会产生跳动业界最常见的做法是固定 42 格不够的用空位补齐。计算某月天数和 1 号星期几是 JS 日期对象最典型的两个用法function getDaysInMonth(year, month) { // month 是 0 基month1 表示下个月new Date(year, month1, 0) 的 day 传 0 // 表示取上个月的最后一天也就是这个月的最后一天 return new Date(year, month 1, 0).getDate(); } function getFirstDay(year, month) { return new Date(year, month, 1).getDay(); } function isDisabled(year, month, day) { var t new Date(year, month, day).getTime(); if (cfg.minDate t new Date(cfg.minDate).getTime()) return true; if (cfg.maxDate t new Date(cfg.maxDate).getTime()) return true; return false; } function render() { var year state.viewYear; var month state.viewMonth; var firstDay getFirstDay(year, month); var totalDays getDaysInMonth(year, month); var cells ; var weekdayRow WEEKDAYS.map(function (w) { return span classdt-weekday w /span; }).join(); // 1号之前补空位 for (var i 0; i firstDay; i) { cells span classdt-empty/span; } // 填入日期 for (var d 1; d totalDays; d) { var cls dt-cell; if (isDisabled(year, month, d)) cls disabled; cells span class cls >function buildOptions(start, end, step) { var list []; for (var i start; i end; i step) { list.push(option value i pad(i) /option); } return list.join(); } function renderTimePanel() { var hSel select classdt-hour buildOptions(0, 23, 1) /select; var mSel select classdt-minute buildOptions(0, 59, cfg.minutesStep || 5) /select; var timeHtml div classdt-time span classdt-time-label时间/span hSel : mSel button typebutton classdt-confirm确定/button /div; return timeHtml; }buildOptions 是一个通用的小函数start、end、step 三个参数决定了选项范围。小时固定 step1分钟用配置项。这里有个交互顺序的问题用户先点日期再改时间最后点确定。在这个过程中state.selected 应该保存完整的 Date 对象而不是只保存日期字符串。我一般会在点击日期格子时把时分先置为面板当前下拉框的值用户不调整就直接沿用这样就避免了「选了日期但时间丢成 00:00」的尴尬。3.2 格式化与解析Date 对象与字符串互转时区坑都集中在这里选择器的输出是字符串内部状态是 Date 对象两者之间需要一套稳定的转换函数。很多人在这个环节喜欢直接拼接比如 date.getFullYear() - (date.getMonth()1)遇到个位数月份就输出 6-1格式完全不统一。我习惯用一个 formatDate 函数统一处理这套格式化的 js 函数也正好回答了「js格式化字符串怎么做」这个高频问题function formatDate(date, fmt) { var map { YYYY: date.getFullYear(), MM: pad(date.getMonth() 1), DD: pad(date.getDate()), HH: pad(date.getHours()), mm: pad(date.getMinutes()), ss: pad(date.getSeconds()) }; return fmt.replace(/YYYY|MM|DD|HH|mm|ss/g, function (key) { return map[key]; }); }代码的核心是正则替换fmt 里每出现一个占位符就换成 map 里对应的值。需要注意正则里把 YYYY 写在最前面否则如果用 /YYYY|YY/ 这样的交替分支短的放前面会优先匹配出问题另一个容易被忽视的点是用户在配置里可能写 yyyy-mm-dd 而不是大写我一般会在入口把 fmt 统一转成大写相当于做一次不区分大小写的归一化省得后面处理两套格式。解析方向同样容易出问题。new Date(2024-06-01 10:30:00) 这种带空格、带短横线的字符串Chrome 通常能解析但 iOS 的 Safari 会直接返回 Invalid Date。更稳妥的做法是先转成 ISO 格式再用 Date 构造function parseDate(str) { if (str instanceof Date) return str; if (typeof str ! string) return new Date(str); // 把 YYYY-MM-DD HH:mm:ss 规范成 YYYY-MM-DDTHH:mm:ss var normalized str.replace( /^(\d{4})-(\d{2})-(\d{2})[\sT](\d{2}):(\d{2})(?::(\d{2}))?$/, $1-$2-$3T$4:$5:$6 ); var d new Date(normalized); return isNaN(d.getTime()) ? new Date() : d; }正则里的六个捕获组分别对应年、月、日、时、分、秒其中秒是可选的。这样无论用户传 2024-06-01 10:30 还是 2024-06-01T10:30:00都能被统一收编。凡是用到这个选择器的页面我都会要求 defaultDate、minDate、maxDate 全部走 parseDate而不是直接 new Date因为从这一刻起才不会出现「本地没问题线上手机一片红」的时区玄学。3.3 挂载细节任意容器、iframe 与 Shadow DOM「任何地方可用」这句话的落地关键在 container 参数和样式隔离这两件事。container 支持字符串选择器或 HTMLElement意味着你可以在 Vue 组件的某个 div 里、React 的 useRef 节点里、甚至一个动态创建的弹层里初始化它。挂载即父节点的子元素不需要额外的框架生命周期配合React 里在 useEffect 里创建实例卸载时调用 destroy 即可。iframe 场景则要注意一个边界选择器必须在它要服务的那个 window 里创建。如果父页面要操作 iframe 内部的输入框不能直接在父页面里 new 一个选择器然后把容器指向 iframe 里的元素因为 document 不同事件模型也不同。常见做法是让 iframe 内部页面自己引入这个 js 文件并初始化父页面通过 postMessage 通知 iframe 打开面板或清空值这样事件和 DOM 都留在同一个文档里不会出现「点在面板上没反应」的怪问题。样式隔离同样不能省。我给所有面板元素的类名都加了 dt- 前缀就是为了尽可能避免与页面里已有的全局样式冲突。真实的血泪经验是有些项目在全局写过 * { box-sizing: border-box }直接把我面板里的宽度计算打乱如果遇到这种不可控的全局 JS 或 CSS建议把面板样式写在带 .dt-picker 作用域的样式标签里必要时直接做成内联 style保证任何页面引入后观感一致。4. 事件机制与多实例管理面板不关、回调错乱都出在这里4.1 用事件委托统一处理面板点击避免逐个绑定事件日期网格里最多 42 个格子如果每个格子都 addEventListener一个实例创建、销毁多次之后监听器就成了内存泄漏的温床。我在工厂函数里只给 panel 绑定一次 click然后通过 event 对象上的 target 来判断用户点了什么。这就是事件委托它是 JS 事件机制里性价比最高的一种写法function handleClick(e) { var target e.target; if (target.classList.contains(dt-prev)) { state.viewMonth--; if (state.viewMonth 0) { state.viewMonth 11; state.viewYear--; } render(); return; } if (target.classList.contains(dt-next)) { state.viewMonth; if (state.viewMonth 11) { state.viewMonth 0; state.viewYear; } render(); return; } if (target.classList.contains(dt-confirm)) { var hour parseInt(panel.querySelector(.dt-hour).value, 10); var minute parseInt(panel.querySelector(.dt-minute).value, 10); var base state.selected || new Date(state.viewYear, state.viewMonth, 1); var picked new Date(base.getFullYear(), base.getMonth(), base.getDate(), hour, minute); state.selected picked; commit(); return; } if (target.classList.contains(dt-cell) !target.classList.contains(disabled)) { // 从>function commit() { if (!state.selected) return; var value formatDate(state.selected, cfg.format); if (cfg.input) cfg.input.value value; if (typeof cfg.onChange function) { cfg.onChange(value, { date: state.selected, source: dt-picker }); } hide(); }commit 里第一个参数是格式化字符串第二个参数是内部 Date 对象和来源标记。很多回调写错是因为只拿了字符串又做一次 parseDate绕了一圈反而不必要直接传 Date 对象是为了让业务方有机会做更精细的时间计算比如排期时把 Date 再转成时间戳。source 字段是我加的一个防误判手段当页面里同时有多个输入框共用一个回调时业务方可以区分到底是哪个实例触发的。input 是另一个可配置项我在骨架里故意没展开因为有的场景选择器不挂在输入框旁边而是挂在按钮上。如果你需要快速复用把 input 加进 configcommit 里回填同时把 input 设为只读防止用户手输格式错误的值。只读这件事也有副作用第 5 章会单独讲。4.3 实例注册表与 destroy任何地方可用也要任何地方可卸载工厂函数返回实例对象这个对象里的 destroy 方法不能只是把面板从 DOM 里移除还要把事件监听和内部引用一并清掉。我通常维护一个模块级的实例数组每个实例分配一个自增 iddestroy 时按 id 找到并移除var INSTANCES []; var __id 0; function createDateTimePicker(config) { var instanceId __id; // ... 创建 panel、定义 handleClick 与 commit ... function removeListeners() { panel.removeEventListener(click, handleClick); if (cfg.input) { cfg.input.removeEventListener(focus, handleFocus); } } return { getValue: function () { return state.selected ? formatDate(state.selected, cfg.format) : ; }, setValue: function (date) { var d parseDate(date); state.selected d; state.viewYear d.getFullYear(); state.viewMonth d.getMonth(); render(); if (cfg.input) cfg.input.value formatDate(d, cfg.format); }, destroy: function () { removeListeners(); if (panel.parentNode) panel.parentNode.removeChild(panel); for (var i 0; i INSTANCES.length; i) { if (INSTANCES[i].id instanceId) { INSTANCES.splice(i, 1); break; } } } }; }removeListeners 里 removeEventListener 必须传同一个函数引用所以事件处理函数不能是匿名函数这一点在 4.1 的写法里需要对应调整把 handleClick 抽成命名函数再绑定。注册表的价值在于统一销毁如果你的页面是 SPA路由切换时把所有实例全部 destroy可以一次性避免「面板残留、监听器泄漏」的累积效应。这个设计在大型前端里尤其重要我见过只创建不销毁的旧系统打开几十个弹窗之后整个页面卡顿多半就是这些看不见的 JS 事件没释放。5. 避坑排查原生日期时间选择器最常见的 5 个翻车点5.1 点击面板内部选择器竟然直接关了现象点面板里的日期格子或确定按钮面板反而关闭了。原因实现「点击空白关闭」时在 document 上绑了 click 监听判断条件是「点击位置不在 panel 内就隐藏」。但因为事件冒泡panel 内部的点击也会先触发 document 监听条件判断写得粗糙的话就把面板一起关了。解决在 panel 的点击处理函数里调用 e.stopPropagation()让事件不再冒泡到 document或者更稳妥的做法是不阻止冒泡而是用 panel.contains(e.target) 判断只有点击确实落在 panel 之外才关闭。我推荐后者因为 stopPropagation 会影响其它依赖冒泡的全局插件。排查时可以在 document 的 handler 里 console.log(e.target)看日志里是不是出现了面板内部的子节点。5.2 正常选择后input 里出现 Invalid Date 或 undefined现象日期能选格式化也写了但回填时值是 Invalid Date。原因格式化函数里 date.getFullYear() 本身不会出错出错的通常是 state.selected 没有被正常赋值。常见于点击格子时直接从字符串 new Date(2024-06-01)这种字符串格式在部分浏览器返回 Invalid Date另一种情况是 formatDate 的 map 表里键名和 fmt 不匹配比如 fmt 写成 YYYY-MM-DD hh:mm而 hh 小写没有进 map。解决统一用 parseDate 处理并保证赋值前做 isNaN(d.getTime()) 校验。不要相信任何浏览器对非 ISO 字符串的解析结果尤其是 Safari。从>class NativeDateTimePicker extends HTMLElement { connectedCallback() { if (this.shadowRoot) return; var shadow this.attachShadow({ mode: open }); var wrap document.createElement(div); var format this.getAttribute(format) || YYYY-MM-DD HH:mm; var picker createDateTimePicker({ container: wrap, format: format, onChange: function (value) { this.setAttribute(value, value); this.dispatchEvent(new CustomEvent(change, { detail: { value: value } })); }.bind(this) }); shadow.appendChild(wrap); this.__picker picker; } disconnectedCallback() { if (this.__picker) { this.__picker.destroy(); this.__picker null; } } } customElements.define(native-date-time-picker, NativeDateTimePicker);使用方式变成一行标签native-date-time-picker formatYYYY-MM-DD HH:mm/native-date-time-picker。注意 connectedCallback 里不要每次反复 attachShadow同一元素被移动位置时会触发第二次 connectedCallback所以要先判断 this.shadowRoot 是否已存在自定义元素内部用 createDateTimePicker 创建实例仍然要依赖第 4 章的实例销毁机制disconnectedCallback 里调用 destroy防止列表页移除组件时内存泄漏。验证这个方案是否靠谱我一般会跑两类自检。第一类是功能自检写一个临时页面把 defaultDate 设成 2024 年 2 月连续点 13 次下个月断言视图年月依次递增且 2 月渲染出的日期数是 29再用 getValue 对比 formatDate 的输出确认格式化没有丢前导零。第二类是隔离自检在页面里同时放 5 个不同 format 的实例分别操作再 destroy最后检查 document 上没有残留的 .dt-picker 节点也没有报错。另外必须留一个回归入口把 parseDate 支持的所有字符串格式跑一遍只要有一个返回 NaN 就说明解析逻辑又退回去了。我做这类控件最深的教训就是别相信「这次改动很小」日期处理任何一个边角情况都能在凌晨两点给你上强度。把自检脚本和选择器一起交付后面的维护者会省很多事。希望帮到你。本文还有配套的精品资源点击获取