做中后台系统、工具类小程序,或者记账、打卡、排班这类 App 的同学,多半都用过 uview 这套组件库。它的 calendar 日历组件属于那种“看着很省事、真上手得改几处”的东西:样式开箱即用,单选、多选、区间三种模式齐全,农历、水印、角标也能开关。但只要你把日历往页面上一挂,测试同学大概率会立刻丢过来一句话——“今天之前的日期怎么点不动?”
这就是标题里那个问题的由来。uview calendar 对可选日期本身是有边界的,很多时候这个边界恰好落在“今天”,于是所有历史日期全部变成浅灰,看着像 bug,实际上只是配置没打开。下面把这件事从头到尾过一遍:边界是怎么算出来的、minDate和maxDate该怎么传、日期格式里藏着哪些坑、defaultDate和编辑回显为什么会“失灵”、生日选择、历史补录、报表区间、日历清单标记这几类场景分别怎么配。内容以 uView 2.x 的u-calendar为主线,uview-plus(Vue3 版本)和 uView 1.x 的差异会单独点出来。
适合谁看:正在用 uni-app + uview 做项目的初中级前端,以及被“日期选不了”“选了不回显”“第二次打开位置乱了”这类问题烦过的同学。下面给的代码基本可以直接抄进项目,参数也会带上具体数值和推导过程,而不是只扔一个 API 名字。
1. 先把问题定位清楚:今天之前为什么点不动
1.1 uview calendar 的可选范围是怎么算出来的
理解这个组件,最省事的类比是 Excel 的“数据有效性”。日历上那 42 个格子(6 行 × 7 列)一直都在渲染,农历、角标、水印也照画不误,但能不能被点中,是由一个区间决定的:下界minDate,上界maxDate。落在区间外的格子,组件会把它渲染成浅灰、并且屏蔽点击事件。
问题就出在这个区间的默认值上。在不少版本里,如果不主动传minDate,组件会用“今天”作为下界;而maxDate也有自己的默认取值(通常是当前日期往后推一段时间)。结果就是:你今天打开日历,往前翻到上个月,发现整月全灰——不是渲染坏了,是下界卡在今天。
这里有一个排查时特别容易搞混的点:日历上会出现两种“灰”。一种是区间外的不可选日期,通常字号偏小、颜色更浅、点了没有任何反馈;另一种是补位日期(上个月的尾巴、下个月的开头),它本来就不属于当前月份,样式也不同。第一眼看到一大片灰,先别急着改样式,先确认到底是哪一种。判断方法很简单:给@confirm加一行console.log,能回传值的就说明是可选状态,纯灰点不动的就是被区间挡住了。
还有一种更隐蔽的情况:minDate传了,但格式不对。比如传了'2026-5-1'这种不补零的写法,或者传了一个字符串形式的秒级时间戳,组件解析失败后往往会“静默降级”成默认边界,页面不报错、控制台也不报错,你只会看到日期还是点不动。这也是为什么后文会反复强调日期格式。
1.2 三种选择模式下,边界的表现并不一致
u-calendar的mode有三个值:single、multiple、range。这三个模式下,区间约束的严格程度是不一样的,踩坑的位置也不一样。
| 模式 | 交互方式 | 边界约束点 | 最常见的坑 |
|---|---|---|---|
| single | 点一下即高亮,点确定回传 | 单点必须落在区间内 | 以为点了就生效,其实要点“确定” |
| range | 点两下选起止 | 起点终点都在区间内,且起点不晚于终点 | 只点了起点就点确定,回传数据不完整 |
| multiple | 多次点击累积选中 | 每次点击的日期都要在区间内 | 和monthNum组合时表现不稳定 |
range模式要特别说一句。它的交互是“第一次点击定起点、第二次点击定终点”,如果你只点了一下就按确定,部分版本回传的字段里只有起点没有终点,或者两个字段都为空。所以@confirm里必须做一次校验,别直接把返回值塞进表单。multiple模式则更微妙:它内部维护一个选中数组,如果你同时开了monthNum想一次显示好几个月,数组长度和跨月选中的表现,实测和单选模式差别不小,建议在真机上把目标机型都点一遍再上线。
另外要提醒的是 uView 1.x 和 2.x 的差异。1.x 里u-calendar的 props 命名和事件回传结构都比较“老派”,很多老项目里能看到@confirm回传的是一个数组;2.x 改成了对象结构(含startDate、endDate这类字段)。如果你接手的是老项目又混用了新文档,最容易出现的就是“明明写了e.startDate却永远是 undefined”。判断方法很直接:在@confirm里console.log(JSON.stringify(e))打一次,看结构再写取值逻辑,比对着文档猜快得多。
2. minDate 和 maxDate:把可选范围正确地打开
2.1 一行配置解决八成的问题
先给最小可运行的版本。只要把minDate往前挪,历史日期立刻就活了。
<template> <view class="page"> <view class="field" @click="openCalendar"> <text class="field__label">日期</text> <text class="field__value">{{ displayDate || '请选择' }}</text> </view> <u-calendar :show="calendarShow" mode="single" title="选择日期" confirm-text="确定" :min-date="minDate" :max-date="maxDate" :default-date="defaultDate" :close-on-click-overlay="true" :round="10" @confirm="onConfirm" @close="onClose" ></u-calendar> </view> </template>export default { data() { return { calendarShow: false, // 下界往前放到 1900 年,等于「历史随便选」 minDate: '1900-01-01', // 上界留空,后面在 created 里算出今天 maxDate: '', defaultDate: '', displayDate: '' } }, created() { const today = this.formatDay(new Date()) // 只允许选到「今天」,不允许选未来 this.maxDate = today this.defaultDate = today }, methods: { padZero(n) { return n < 10 ? '0' + n : '' + n }, formatDay(input) { const d = input instanceof Date ? input : new Date(input) return ( d.getFullYear() + '-' + this.padZero(d.getMonth() + 1) + '-' + this.padZero(d.getDate()) ) }, openCalendar() { this.calendarShow = true }, onConfirm(e) { // 先把结构打出来,不同版本字段名有差异 console.log('u-calendar confirm =>', e) const value = e && (e.startDate || e.endDate) if (!value) return this.displayDate = String(value) this.calendarShow = false }, onClose() { this.calendarShow = false } } }这里有一个细节值得解释:为什么要在created里算maxDate,而不是直接在data里写死?因为new Date()在data初始化阶段执行时,组件实例还没建好,而更重要的是——这个值应该跟着“当前时刻”走,而不是跟着“打包时间”走。我见过有人把日期写死在data里,测试当天没问题,隔天上线发现昨天还是“今天”,选不了了。日期边界一定要动态算。
另外,formatDay里我特意没用padStart。这个 API 属于 ES2017,在部分老安卓机型的 WebView 里是缺失的,一旦缺失会静默报错或者直接抛异常。自己写个padZero只有三行,比加 polyfill 省事。
至于show的控制方式,uView 2.x(Vue2)是:show配合手动置false;uview-plus 走 Vue3,推荐用v-model:show,写起来更省心。但不管哪种,都建议同时监听@close,因为用户点遮罩或者点关闭按钮时,组件内部状态变了,你的data不变,下次点开就没反应了。
2.2 日期格式这件事,值得单独开一节
minDate和maxDate支持字符串,也支持时间戳。但“支持”和“好用”是两回事。
推荐写法:'YYYY-MM-DD',月和日都补零。这种格式在浏览器和各家小程序里的解析结果最稳定,前后端接口也基本都用这个,复制粘贴不容易出错。
不推荐:'2026-5-1'、'2026/05/01'、'20260501'。前一种解析行为因环境而异,中间那种在 iOS 上能用但在部分小程序渲染层会有差异,最后一种只有在你明确知道组件内部用的是什么解析器时才敢用。
时间戳也可以,但要确认单位。JS 里是毫秒,后端接口给的是秒。我见过最典型的翻车现场:直接把接口返回的1700000000塞进minDate,结果被当成 1970 年 1 月 20 日的毫秒时间戳,日历直接跳到了上世纪。传时间戳之前先String(ts).length === 13判断一下,长度是 10 就乘 1000。
真正的大坑是new Date()解析字符串时的时区行为。按 ECMAScript 规范,new Date('2026-05-01')这种“只有日期”的字符串会被当作UTC 零点来解析,而new Date('2026/05/01')会被当作本地零点。在东八区,前者换算成本地时间是 5 月 1 日 08:00,看着没问题;但如果你的用户分布在 UTC-5 一带,同一个字符串算出来的本地日期就变成了 4 月 30 日,日期整体偏移一天。
规避方法就一条:凡是接口给的YYYY-MM-DD字符串,直接原样传给组件,不要来回new Date()转换。只有确实需要做日期计算时,才用下面这个安全解析:
/** * 安全解析日期字符串 * 1) 只有日期字符串按本地时区解析(把 - 换成 /) * 2) iOS Safari 不认 '2026-05-01 00:00:00',带时间也要换斜杠 */ export function parseDay(input) { if (input instanceof Date) return new Date(input.getTime()) if (typeof input === 'number') return new Date(input) return new Date(String(input).replace(/-/g, '/')) }顺手再给一个格式化的:
export function formatDay(input) { const d = input instanceof Date ? input : parseDay(input) const pad = (n) => (n < 10 ? '0' + n : '' + n) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) }2.3 常见区间的算法:最近 N 天、上个自然月、上个季度
业务里真正会写死的边界很少,绝大多数是“算出来的”。这几个函数我基本每个项目都要抄一遍,直接放公共utils里。
/** * 今天往前推 n 天,返回 'YYYY-MM-DD' * 先 setHours(0,0,0,0) 归零,避免跨天计算时受当前时刻影响 */ export function daysAgo(n) { const d = new Date() d.setHours(0, 0, 0, 0) d.setDate(d.getDate() - n) return formatDay(d) } /** * 加减月份,自动处理 31 号溢出 */ export function addMonths(input, n) { const src = input instanceof Date ? new Date(input.getTime()) : parseDay(input) const day = src.getDate() // 先把日期拨到 1 号,再加月份,最后把日号补回去 const tmp = new Date(src.getFullYear(), src.getMonth(), 1) tmp.setMonth(tmp.getMonth() + n) // 目标月份的最大天数 const lastDay = new Date(tmp.getFullYear(), tmp.getMonth() + 1, 0).getDate() tmp.setDate(Math.min(day, lastDay)) return formatDay(tmp) } /** * 上个自然月的起止 */ export function lastMonth() { const now = new Date() const firstOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1) const lastOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0) return { start: formatDay(addMonths(firstOfThisMonth, -1)), end: formatDay(lastOfLastMonth) } }addMonths里那几行是整段代码的核心,值得展开说。如果直接d.setMonth(d.getMonth() + 1),假设今天是 3 月 31 日,加一个月会得到 5 月 1 日——因为 4 月没有 31 号,JS 会自动往后溢出。这种错误在“默认查上个月”的功能里非常致命,报表会多带一天数据。正确做法是先把日号固定成 1 号(保证不会溢出),加完月份,再把原来的日号用Math.min(原日号, 目标月最大天数)补回去。new Date(y, m + 1, 0)这个写法能拿到某个月的最后一天,是 JS 里一个很好用的小技巧,第 0 天等于上个月的最后一天。
再看两个具体的边界取值,方便你直接对照:
| 业务诉求 | minDate | maxDate | 说明 |
|---|---|---|---|
| 生日、入职日期 | '1900-01-01' | 今天 | 下界放到足够久远即可 |
| 历史补录(近 90 天) | daysAgo(89) | 今天 | 含今天共 90 天,注意 -1 |
| 上个月报表 | lastMonth().start | lastMonth().end | 上下界都锁死 |
| 项目排期(未来一年) | 今天 | addMonths(new Date(), 12) | 与补录正好相反 |
“含今天共 90 天”这个说法要留意:daysAgo(89)才是 90 天,daysAgo(90)是 91 天。这种差一错误在验收时基本不会有人发现,但等业务方拿着数据对不上来问的时候,解释成本很高。
3. defaultDate 与回显:别让选中项凭空消失
3.1 defaultDate 的形态取决于 mode
defaultDate这个 prop 的名字有点误导性——它不只是“默认值”,在编辑类页面里,它就是你用来回显已有数据的入口。不同模式下它的形态不一样:
mode="single":传一个'YYYY-MM-DD'字符串,比如'2026-05-01'。mode="range":传一个长度为 2 的数组,['2026-05-01', '2026-05-10']。mode="multiple":传一个日期数组,里面每一项都是'YYYY-MM-DD'。
容易踩的是第三种。多选模式下如果你传的数组里有超出minDate/maxDate范围的日期,那些日期不会被高亮,但也不会报错,你只会在点开日历时发现“明明存了 5 条,只亮了 3 条”。所以多选场景在回显前,先把数组按区间过滤一遍。
function pickValid(list, minDate, maxDate) { return (list || []).filter((d) => { if (minDate && d < minDate) return false if (maxDate && d > maxDate) return false return true }) }这里我用了字符串直接比较大小,而不是new Date()转一遍。原因是'YYYY-MM-DD'这种补零格式的字典序,和它的时间先后顺序是完全一致的,直接比较既快又绕开了时区问题。这是一个很好用的小技巧,在只做“日期比较”而不做“日期运算”的场景下,能省掉大量转换代码。
3.2 编辑页回显:组件状态和你的 data 是两套
日历组件内部维护了自己的一份渲染状态:当前展示的月份、已选中的格子、滚动的偏移量。你从接口拿到数据、改掉defaultDate,组件不一定能感知到——因为它可能只在初始化那一刻读了一次这个值。
最稳的解决办法是强制重建组件,有两种写法。
第一种是用v-if。弹窗没打开的时候不渲染,每次打开都是全新实例,状态绝对干净。
<u-calendar v-if="calendarShow" :show="calendarShow" :default-date="defaultDate" :min-date="minDate" :max-date="maxDate" @confirm="onConfirm" @close="onClose" ></u-calendar>第二种是用:key。给组件绑一个会变的 key,值一变就重建。
<u-calendar :key="calendarKey" :show="calendarShow" :default-date="defaultDate" @confirm="onConfirm" @close="onClose" ></u-calendar>// 每次打开前换一个 key openCalendar() { this.defaultDate = this.form.date || '' this.calendarKey = Date.now() this.calendarShow = true }第二种写法更轻一点,因为不需要反复创建销毁整个日历。但要注意key变化和show变化的时序:如果同一帧里既改了 key 又改了 show,某些机型上会出现弹层闪一下的观感。稳妥起见可以把show = true放到this.$nextTick里。
3.3 @confirm 到底回传了什么
这是被问得最多的问题之一,我的建议永远是:先打印,再写逻辑。
onConfirm(e) { console.log('u-calendar confirm =>', JSON.stringify(e)) // single 模式:取 startDate 兜底 // range 模式:取 startDate + endDate // multiple 模式:部分版本回传数组,用 Array.isArray 判一下 let value = '' if (Array.isArray(e)) { value = e.join(' / ') } else if (e && e.endDate && e.startDate && e.startDate !== e.endDate) { value = e.startDate + ' ~ ' + e.endDate } else if (e && (e.startDate || e.endDate)) { value = e.startDate || e.endDate } if (!value) { // range 模式只选了一半就点确定的兜底 uni.showToast({ title: '请选择完整日期', icon: 'none' }) return } this.form.date = value this.calendarShow = false }这段代码看着啰嗦,但每一行都是被不同版本的文件“教育”出来的。返回结构有对象、有数组、字段名有别,写一套防御性取值,比在版本升级时挨个文件排查要划算得多。另外range模式下“只选一半就点确定”是真实会发生的操作,别指望用户按你的预期走。
还有一个细节:@confirm触发后,组件不会自动关闭。必须自己把show置成false。这一点和很多人的直觉相反,也是“点了确定没反应”的常见原因——其实是值已经拿到了,只是弹层还盖在上面。
4. 四类业务场景的完整配置
4.1 生日、入职日期:能选很久以前,但不能选未来
这是最典型的“历史日期可选”场景。要点有三个:下界放得足够远、上界锁在今天、打开时默认定位到一个合理的位置。
// 生日场景 this.minDate = '1900-01-01' this.maxDate = this.formatDay(new Date()) // 没有历史数据时,默认落在 1995-01-01,别落在今天 this.defaultDate = this.form.birthday || '1995-01-01'为什么默认值要特意给 1995 而不是今天?因为生日选择器的下界是 1900 年,如果默认打开就是今天,用户得往上滑一百多年,体验非常糟。给一个统计学上更集中的中位数年份,用户平均要滑的距离就短很多。这个细节文档里不会写,但它直接影响表单的填写完成率。
如果还想更进一步,可以在组件外部加一排“50 后 / 60 后 / 70 后 / 80 后 / 90 后 / 00 后”的快捷标签,点了之后把defaultDate改成对应十年前的 1 月 1 日,再重建组件定位过去。这个改造大概二十行代码,但体验提升非常明显。
4.2 历史补录:只能选最近 N 天
补录类的功能(打卡补卡、工时补录、报销补单)通常给一个可回退的窗口期,既满足业务需求,又避免有人改到很久以前的数据。
const N = 90 this.minDate = this.daysAgo(N - 1) // 含今天共 N 天 this.maxDate = this.formatDay(new Date()) this.defaultDate = this.form.date || this.maxDate这里必须配合后端的二次校验。前端的minDate只是“不让选”,是体验层的约束,绕过成本极低。接口层一定要用服务端时间重新算一遍窗口,不然这个限制等于没有。
顺便说一个容易被忽略的点:minDate算出来的“今天”是客户端时间。如果用户在设置里把手动时间调成一个月前,daysAgo(89)就会整体前移,他能选到的窗口也跟着前移。对于有合规要求的补录业务,下界最好由服务端下发,前端只做展示,不要自己算。
4.3 报表区间:range 模式选过去一个季度
range模式的配置比单点复杂一点,因为要处理默认区间和区间顺序。
this.mode = 'range' this.minDate = this.addMonths(new Date(), -14) // 往前 14 个月 this.maxDate = this.formatDay(new Date()) // 默认选中「上个自然季度」,这里用一个示例值 this.defaultDate = ['2025-01-01', '2025-03-31']range模式有三个实测确认过的行为,提前知道能省不少调试时间:
- 起止日期必须在
minDate到maxDate之间,任一端越界,整段区间都不会高亮。 - 用户第二次点击如果在起点之前,部分版本会自动交换起止,部分版本则直接忽略这次点击。上线前一定要在真机上点一遍“先点后面、再点前面”的顺序。
- 回传的两个日期是含首含尾的,别自己再
+1或-1,直接拿去查库就行。
4.4 多选与长跨度:multiple 搭配 monthNum
monthNum控制一次渲染几个月,比如设成 3,弹层里就能连看三个月。这个功能在“选几个不连续的日期”时很好用,但要注意两点。
第一,monthNum值越大,初始渲染的 DOM 节点越多。设成 12 就是一年,在低端安卓机上打开弹层会有肉眼可见的白屏。我的经验是不要超过 3,超过 3 就改成“上个月 / 下个月”的翻页切换。
第二,multiple模式和monthNum组合时,跨月选中的状态同步在某些版本里不稳定。如果你的业务确实需要跨月多选,建议先写一个最小 demo,把 uview 的当前版本锁死,跑通了再往业务里合。
多选回显记得做区间过滤,前面给过的pickValid直接拿来用:
this.defaultDate = pickValid(this.form.dates, this.minDate, this.maxDate)5. 用 formatter 做标记、灰化和业务提示
5.1 formatter 的执行时机
formatter是u-calendar里最有价值的一个 prop。它在渲染每一个日期格子之前被调用一次,你可以在里面改这个格子的附加信息,比如底部的角标文案、小红点、是否置灰。
关键是先把参数结构打出来看一眼,因为不同版本传进来的day对象字段名可能有差异:
formatter(day) { // 第一次接这个 prop 时一定要打这一行,看清结构再写业务 // console.log('day =>', JSON.stringify(day)) return day }打完你基本会看到类似date('2026-05-01'这种完整日期字符串)、以及年、月、日这些字段。有完整日期字符串就够了,直接拿它当 key 去查业务数据字典。
5.2 标记 + 灰化:日历清单场景的完整实现
假设我们在做一个“日历清单”,每个日期上有几条待办,我们希望:有数据的日期显示红点和条数,超过起止范围的日期置灰。
data() { return { minDate: '1900-01-01', maxDate: '', // 形如 { '2026-05-01': 3, '2026-05-08': 1 } dateCountMap: {} } }, methods: { formatter(day) { const key = day.date const count = this.dateCountMap[key] if (count > 0) { // 底部角标文案 day.bottomInfo = count + ' 条' // 红点标识,字段名以实际版本为准 day.dot = true } // 手动兜一层越界置灰,防止某些版本对自定义区间处理不到位 if (this.minDate && key < this.minDate) { day.disable = true } if (this.maxDate && key > this.maxDate) { day.disable = true } return day } }有几个点必须说明白。第一,day.date因为天然是补零的YYYY-MM-DD,可以直接拿来当对象的 key,也能直接做字符串比较,不用转Date,这是它比时间戳更方便的地方。第二,day上的可写字段(比如角标文案、红点、是否置灰的具体字段名)在不同版本里叫法不完全一样,我上面写的是常见写法,但上线前一定要console.log确认一遍,改错字段名是不会报错的,只会“什么都没发生”。第三,disable这类属性按官方设计应该由minDate/maxDate自动处理,手动兜一层只是防御性写法,如果你的版本本身就正常,可以删掉。
5.3 formatter 的性能与两个注意点
formatter会在每个格子渲染前执行,monthNum = 12的时候一次就是三百多次调用。所以它里面不能做耗时操作,尤其不能同步请求接口、不能在里面new Date()循环、不能写复杂的正则。
正确的做法是提前把业务数据整理成一张dateCountMap这样的哈希表,formatter里只做一次 O(1) 的查表。我见过有人在formatter里直接遍历一个几百条的数组做find,日历一打开就卡两秒,还以为是组件本身性能差。
另外两个坑:一是formatter里不要修改传入对象以外的任何外部状态,它可能在一次渲染里被调用多次,写外部变量会导致状态不可预测;二是如果需要“选中态”也跟着业务走,优先用defaultDate,不要在formatter里硬塞选中样式,很容易和组件内部状态打架。
6. 常见问题速查表与排查顺序
上面讲了不少原理,真到现场排查时,按下面这个顺序走基本都能定位。
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 历史日期全灰、点不动 | minDate没传或默认落在今天 | 显式传'1900-01-01'或业务下界 |
传了minDate还是不生效 | 日期格式不合法,被静默降级 | 改成补零的'YYYY-MM-DD' |
| 日期整体差一天 | 时区解析问题 | 字符串原样传,别来回new Date() |
| 时间戳传进去跳到 1970 年 | 秒级时间戳被当毫秒 | 判断长度,10 位则* 1000 |
| 点“确定”后弹层不关 | 组件不自动关闭 | 在@confirm里手动置show = false |
| 点确定回调里字段是 undefined | 版本间返回结构不同 | 打印结构后写防御性取值 |
| 编辑页改数据后,日历还是旧选中 | 组件内部状态未重建 | 用v-if或换:key重建 |
| range 只选一半就能点确定 | 缺少校验 | @confirm里校验起止是否齐全 |
| 点遮罩关不掉 | closeOnClickOverlay为 false | 显式设为true |
| 打开日历时整段白屏 | monthNum太大 | 控制在 3 以内或改翻页 |
| 日历被导航栏/自定义 tabbar 挡住 | 弹层层级或父级 transform | 提到页面根节点,检查祖先transform |
老安卓上报padStart未定义 | ES2017 API 缺失 | 自己写padZero |
这张表里,我想再强调最后一条和“被挡住”那条,因为它们和minDate完全无关,但经常被误以为是同一个问题。
padStart的排查过程很有代表性:报错只在特定机型出现,开发机永远复现不了。遇到“只在某些机型出问题”的情况,第一反应应该是怀疑新语法,而不是怀疑逻辑。Array.prototype.includes、Object.assign、String.prototype.padStart这几个是重灾区,写公共工具函数时能避则避。
“被挡住”这条则是布局问题。日历弹层用的是 fixed 定位,如果它的某个祖先元素上有transform、filter或者perspective,fixed 的参照物就会从视口变成那个祖先,弹层的位置会整个偏掉。在小程序里还会叠加原生组件的层级问题——自定义 tabbar、原生video、map都可能盖在上面。排查方法很简单:把日历组件挪到页面最外层,只让它是那个容器的直接子节点,如果位置立刻正常了,就说明是祖先元素的问题。
7. 组件扛不住的时候,替代方案怎么选
7.1 什么时候该考虑换方案
u-calendar覆盖了 90% 的常规需求,但有几类情况它会比较吃力。
一是需要连续滚动浏览很长时间跨度,比如“从 1 月连续滑到 12 月”,monthNum做不到这种无限滚动,硬撑会卡。二是需要复杂的自定义交互,比如拖拽框选一段区间、在格子里画进度圆环。三是需要在非 Vue 环境或者跨框架复用。
这几类情况下,比较务实的做法是:保留u-calendar处理普通表单,只在那一两个特殊页面自己实现一个轻量日历。不要为了一个页面去改造整个组件库,改完的下场通常是升级版本时全部冲突。
7.2 自己写一个轻量日历的核心思路
自己写其实没有想象中复杂,核心就是一个二维数组的生成逻辑。给定某年某月,先算出这个月 1 号是星期几,再算出这个月有多少天,然后往前补上个月的尾巴、往后补下个月的开头,凑满 6 行 7 列就完事了。
/** * 生成某个月的日历矩阵,6 行 7 列 */ function buildMonth(year, month) { const firstDay = new Date(year, month - 1, 1) const startWeek = firstDay.getDay() // 0 是周日 const daysInMonth = new Date(year, month, 0).getDate() const daysInPrevMonth = new Date(year, month - 1, 0).getDate() const cells = [] // 上个月补位 for (let i = startWeek - 1; i >= 0; i--) { cells.push({ day: daysInPrevMonth - i, current: false }) } // 本月 for (let i = 1; i <= daysInMonth; i++) { cells.push({ day: i, current: true }) } // 下个月补位,补到 42 个 let next = 1 while (cells.length < 42) { cells.push({ day: next++, current: false }) } return cells }有了这个矩阵,剩下的就是渲染和点击判断。再配上disabled判断逻辑(把日期拼成'YYYY-MM-DD'再和上下界做字符串比较),一个能选历史日期的日历就成型了,代码量大概两百行。它的好处是完全可控——想怎么标注就怎么标注,想怎么滑动就怎么滑动,不用再被组件版本的返回结构折腾。
我在两个项目里做过这个切换:一次是因为需要跨年连续滚动,一次是因为需要在格子里面画数据条。两次的经验都一样——写的时候比想象中快,但边界情况的调试时间比写代码本身长得多,尤其是农历、闰年和补位点击这几块。所以如果你的需求只是“能选今天之前的日期”,老老实实把minDate配好就行,真的没必要重写。
最后分享一个我在实际项目里养成的习惯:所有跟日期相关的组件,我都会在页面上挂一个隐藏的调试入口(连点标题五次弹出),里面直接显示当前的minDate、maxDate、defaultDate和最近一次@confirm的原始返回。测试同学反馈“选不了”的时候,让他进这个面板截个图,比来回问“你选的哪天”“你手机时间是几点”快十倍。日期问题十有八九不是逻辑错,而是某一个参数没传到位,把它摆到明面上,排查时间能从半小时压到两分钟。