十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Luxon 日期时间数学运算完全指南:日历数学、时间数学与 Duration 换算精度

Luxon 日期时间数学运算完全指南:日历数学、时间数学与 Duration 换算精度 Luxon 日期时间数学运算完全指南日历数学、时间数学与 Duration 换算精度【免费下载链接】luxon⏱ A library for working with dates and times in JS项目地址: https://gitcode.com/gh_mirrors/lu/luxon本指南系统讲解 Luxon 中与日期时间做数学相关的全部关键知识日历数学calendar math与时间数学time math的区别、多单位运算的先后顺序、DateTime 比较、Duration 的单位换算精度casual 与 longterm以及 Interval 锚定差分的正确姿势。读完本文你将能在跨月、跨年、跨 DST 的场景下写出不踩坑的日期运算代码并理解plus/diff/shiftTo等 API 底层的真实工作机制。为什么日期时间数学会让程序员困惑对许多程序员而言日期时间运算常常反直觉。以 2017 年 2 月 13 日为例说再过恰好一个月你自然会想到 3 月 13 日再过一个月是 4 月 13 日。但因为 2 月比 3 月短两次一个月实际加上的时间长度并不相同。反过来说从 2 月 13 日起 30 天你要去推算它落在 3 月的哪一天。Luxon 中这两类运算分别写为DateTime.local(2017, 2, 13).plus({ months: 1 }).toISODate() // 2017-03-13 DateTime.local(2017, 2, 13).plus({ days: 30 }).toISODate() // 2017-03-15从根本上说存在两种截然不同的运算模式见 docs/math.md日历数学Calendar math作用于高阶、可变长度的单位如年、月、日。时间数学Time math作用于低阶、恒定长度的单位如小时、分钟、秒。哪些单位属于哪种数学使用日历数学的单位年因为闰年而长度不同月因为各月天然长度不同日因为 DST 切换导致某些天是 23 或 25 小时季度始终是 3 个月但月有长短季度也随之变化周天数总是相同但天的长度会变周也跟着变。使用时间数学的单位小时恒为 60 分钟分钟恒为 60 秒秒恒为 1000 毫秒关于闰秒的说明与 JavaScript 整体行为一致ECMA-262 规范第 15.9.1.1 节Luxon不处理闰秒leap seconds。在绝大多数编程环境中闰秒都是作为底层系统时间的不可见变化发生的从 Luxon 的角度看极少数情况下同一个秒可能出现两次。闰秒的实际影响非常有限你无法表示闰秒本身——DateTime.utc(2016, 12, 31, 23, 59, 60).isValid返回false跨越闰秒的diff()计算结果不会精确等于外界真实流逝的秒数。这在你的应用需要精确知道最近 n 秒到底发生了什么的罕见场景下才会浮现如今这一误差也越来越多地被闰秒涂抹leap smear机制缓解。日历数学的正确思考方式不要把日历数学理解为对中间各段长度做繁琐检查而应理解为直接调整该单位本身、并保持更低阶的日期分量不变。回到2 月 13 日 1 个月的例子若没有 Luxon你需要手工操作原生Datevar d new Date(2017-02-13) d.setMonth(d.getMonth() 1) d.toLocaleString() // 3/13/2017, 12:00:00 AMLuxon 底层做的与此如出一辙它不会把运算摊平成毫秒差值——因为用户要的并不是那个。它直接摆弄自己认为的日期应该是什么再用内置的格里高利历Gregorian calendar算出新的时间戳。从源码看src/datetime.js 的adjustTime()正是这么实现的先把dur.years、dur.months、dur.quarters的整数部分直接加到日历年、月上天数部分用Math.min(inst.c.day, daysInMonth(year, month))做月末钳制所以 1 月 31 日加 1 个月得到 2 月 28/29 日再加dur.days与dur.weeks * 7只有把各单位的小数部分与hours、minutes、seconds、milliseconds汇总成一个 Duration 换算成毫秒后追加。这就是高阶单位走日历、低阶单位走时钟的代码级体现。测试 test/datetime/math.test.js 验证了月末场景DateTime.fromISO(2018-01-31T10:00).plus({ months: 1 })得到 2 月 28 日而闰年 2016 年则得到 2 月 29 日。DST 下的日历数学关于 DST 有专门章节见 时区文档这里给一个直观示例以作者所在时区 2017 年 3 月 12 日凌晨春令时拨快为例var start DateTime.local(2017, 3, 11, 10); start.hour // 10, 对照组 start.plus({days: 1}).hour // 10, 保持不变 start.plus({hours: 24}).hour // 11, DST 把钟拨快了一小时也就是说加一天保住了 10 点这个时刻尽管它实际上只过了 23 个小时。test/datetime/math.test.js 在America/Los_Angeles时区复现了同样的行为plus({ days: 1 })后hour仍为 10而plus({ hours: 24 })后hour变为 11。时间数学时间数学则不同它只是拨动时钟在纪元时间戳上做加减。加 63 小时本质上就是加上 63 小时对应的毫秒数。底层实现与日历数学恰好相反Luxon 把它摊平成毫秒、算出新的时间戳、再反推出日期。plus({ hours: 24 })与plus({ days: 1 })在 DST 切换期产生不同结果的根因正在于此。多单位数学的运算顺序一次可以同时对多个单位做数学运算DateTime.fromISO(2017-05-15).plus({months: 2, days: 6}).toISODate(); // 2017-07-21但事情没这么简单。下面这个表达式结果是什么DateTime.fromISO(2017-04-30).plus({months: 1, days: 1}).toISODate();若先加天中间值是 5 月 1 日再加一个月得到 6 月 1 日若先加月中间值是 5 月 30 日再加一天得到 5 月 31 日。顺序决定了结果。Luxon 的规则很简单数学运算按从高阶到低阶的顺序执行highest order to lowest order。因此上例结果是 5 月 31 日。这个规则并非逻辑上必然但它确实符合大多数人的直觉。当然如果分两步做Luxon 也无法强制该规则DateTime.fromISO(2017-04-30).plus({days: 1}).plus({months: 1}).toISODate() // 2017-06-01Luxon 的接口设计让你很难顺手做错这并非巧合。源码层面adjustTime()中orderedUnitsyears → quarters → months → weeks → days → hours → ...的顺序见 src/duration.js与先处理高阶单位的语义一脉相承。比较两个 DateTimeDateTime实现了#valueOf返回纪元时间戳因此可以直接用、、、比较d1 d2 // d1 是否在 d2 之前但要小心比较的是对象身份对不可变类型的库而言这不是有用的语义。请使用#equals同时比较时间与附加元数据如 locale 和时区。如果只关心时间戳是否相等可以d1.toMillis() d2.toMillis() // d1 和 d2 是否是同一时刻 d1 d2 // 同一测试利用对象隐式转换还可以用#hasSame做更精细的比较d1.hasSame(d2, year); // 两个 DateTime 是否处于同一公历年 d1.hasSame(d2, day); // 是否处于同一公历日隐含同年同月注意这些比较是针对日历的例如 d1 在 2017 年hasSame(d2, year)问的是 d2 是否也在 2017 年而不是两者是否相差不到一年——后者需要用diff。源码中hasSame的实现src/datetime.js是把对方时间戳与本方startOf(unit)到endOf(unit)的闭区间比对。若想按某个具体单位比较可以把#startOf与#valueOf组合使用var d1 DateTime.fromISO(2017-04-30); var d2 DateTime.fromISO(2017-04-01); d2 d1 // true d2.startOf(year) d1.startOf(year) // false d2.startOf(month) d1.startOf(month) // false d2.startOf(day) d1.startOf(day) // truestartOf支持year、quarter、month、week、day、hour、minute、second、millisecond等单位源码见 src/datetime.js 的逐级 fall-through 实现测试见 test/datetime/math.test.js。Duration 数学基础Duration是一段时间的量例如3 天零 6 小时。Luxon 并不知道这是哪3 天 6 小时——它只是用抽象、与时间线解耦的方式表达这些量。这既极其有用偶尔也让人困惑。基础用法var dur Duration.fromObject({ days: 3, hours: 6}) // 查看它 dur.toObject() // { days: 3, hours: 6 } // 用分钟表达 dur.as(minutes) // 4680 // 换算成分钟单位 dur.shiftTo(minutes).toObject() // { minutes: 4680 } // 加到一个 DateTime 上 DateTime.fromISO(2017-05-15).plus(dur).toISO() // 2017-05-18T06:00:00.000-04:00从类文档注释src/duration.js可以看到Duration 概念上就是单位 → 数量的映射外加若干配置与方法创建可用fromMillis/fromObject/fromISO变换可用plus/minus/normalize/set/reconfigure/shiftTo/negate输出可用as/toISO/toFormat/toJSON。求差diff用DateTime.diff求两个时刻之间的时间量结果是一个 Durationvar end DateTime.fromISO(2017-03-13); var start DateTime.fromISO(2017-02-13); var diffInMonths end.diff(start, months); diffInMonths.toObject(); // { months: 1 }注意必须指定用于记录差值的单位默认是毫秒var diff end.diff(start); diff.toObject() // { milliseconds: 2415600000 }也可以同时用多个单位var end DateTime.fromISO(2017-03-13); var start DateTime.fromISO(2017-02-11); end.diff(start, [months, days]).toObject() // { months: 1, days: 2 }diff的底层实现见 src/impl/diff.jshighOrderDiffs()按年 → 季度 → 月 → 周 → 天依次尝试用较大单位做差若超调则回退并改用更小单位源码注释明确说明了这一先大后小、超调回溯的游标推进算法剩余毫秒再交给Duration.fromMillis(...).shiftTo(...)处理低阶单位。Casual 与 longterm两种换算精度Duration 是带有特定单位的时间包但 Luxon 允许你在单位之间换算shiftTo返回以指定单位计量的新 Durationas把整个 Duration 换算成某单一单位并返回数值。var dur Duration.fromObject({ months: 4, weeks: 2, days: 6 }) dur.as(days) // 140 dur.shiftTo(days).toObject() // { days: 140 } dur.shiftTo(weeks, hours).toObject() // { weeks: 18, hours: 144 }换算依据是什么首先毫无争议的部分1 周 7 天1 天 24 小时1 小时 60 分钟1 分钟 60 秒1 秒 1000 毫秒这些恒等式可以上下滚动并保持一致例如 1 小时 60 × 60 × 1000 毫秒。但高阶单位并非如此即便不考虑 DST年有时 365 天、有时 366 天月有 28/29/30/31 天。默认情况下Luxon 使用所谓casual宽松换算月周天年1252365季度31391月430这些数字符合直觉大多数场景下够用。但它们不仅不精确甚至自相矛盾Duration.fromObject({ years:1 }).shiftTo(months).shiftTo(days).as(years) // 0.9863013698630136原因很简单12 × 30 ≠ 365。这类误差平时只是烦人一旦累积就可能造成大问题var dur Duration.fromObject({ years: 50000 }); DateTime.now().plus(dur.shiftTo(milliseconds)).year // 51984 DateTime.now().plus(dur).year // 52017两者相差 33 年因此 Luxon 提供了第二种换算方案longterm长期精确基于 400 年历法周期400 年含 146097 天月周天年1252.1775365.2425季度313.0443591.310625月4.34812530.436875这些小数显然不好用这正是它们不是默认方案的原因。源码中两套换算矩阵定义在 src/duration.jslowOrderMatrix周/天/时/分/秒/毫秒、casualMatrix年/季度/月使用 365/91/30 天并展开...lowOrderMatrix、以及由daysInYearAccurate 146097.0 / 400与daysInMonthAccurate 146097.0 / 4800推导的accurateMatrix。Duration构造函数src/duration.js按conversionAccuracy longterm选择矩阵并把它作为实例属性保存下来。哪些方法接受conversionAccuracy凡是从零创建 Duration 的方法都可以Duration.fromObject、Duration.fromISO以及end.diff(start, unit, opts)diff会把opts透传给Duration.fromObject见 src/impl/diff.js。取值casual默认或longterm。它是 Duration 自身的属性之后的任何换算都遵循所选规则由它派生的新 Duration 也会保留该属性Duration.fromObject({ years: 23 }, { conversionAccuracy: longterm }); Duration.fromISO(PY23, { conversionAccuracy: longterm }); end.diff(start, days, { conversionAccuracy: longterm })也可以把一个已存在的 Duration 改造成精确版本var pedanticDuration casualDuration.reconfigure({ conversionAccuracy: longterm });这些 Duration 之后会采用不同的换算方式。test/duration/units.test.js 中对longterm精度与reconfigure({ conversionAccuracy: longterm })均有覆盖性测试。换算会丢失信息在单位之间换算时要格外小心信息很容易丢失。假设我们把一个 diff 换算成了天数var end DateTime.fromISO(2017-03-13); var start DateTime.fromISO(2017-02-13); var diffInMonths end.diff(start, months); diffInMonths.as(days); // 30这只是月与天之间的换算也可以改用 longterm 精确换算但解决不了问题本身。而 2 月 13 日到 3 月 13 日之间的真实天数并不是 30var diffInDays end.diff(start, days); diffInDays.toObject(); // { days: 28 }关键在于diff 的结果是 Duration 对象而 Duration 只是运算吐出来的一堆时间单位不会记住输入的起止时刻Interval 才会。所以在换算单位时某些信息就丢了。这个错误在向上滚动时尤其常见var diff end.diff(start); // 默认单位是毫秒 // 呃这根本不是一个月 diff.as(months); // 0.9319444 // 甚至天数也不对提示我所在的时区有 DST diff.shiftTo(hours).as(days); // 27.958333333333332通常只要想清楚我要用 diff 做什么就不会踩这个坑请直接在你真正需要的单位上做 diff这样 Luxon 才能回答你真正想问的问题var monthsDiff end.diff(start, months); var daysDiff end.diff(start, days);但有时你确实需要一个代表减法本身的对象而不是结果。Interval 可以帮忙——它主要用于跟踪时间范围但也能充当锚定的 diff。例如var end DateTime.fromISO(2017-03-13); var start DateTime.fromISO(2017-02-13); var i Interval.fromDateTimes(start, end); i.length(days); // 28 i.length(months) // 1因为 Interval 保存了两个端点并在每次查询时实时计算length实现见 src/interval.js即this.toDuration(...[unit]).get(unit)所以它每次都能重新求差。当然正因为 Interval 不是抽象的时间包它不能用于 Duration 能用的地方——比如不能直接plus()给 DateTime因为 Luxon 不知道该按哪个单位做数学运算。但你可以在选定单位后把 Interval 转成 Durationi.toDuration(months).toObject(); // { months: 1 } i.toDuration(days).toObject(); // { days: 28 }甚至可以一次选多个单位end DateTime.fromISO(2018-05-25); i start.until(end); i.toDuration([years, months, days]).toObject(); // { years: 1, months: 3, days: 12 }当然一旦转成 Duration就又回到了 diff 时的那种处境——之后的进一步换算依然会丢信息。所以要点是想清楚在每个时点你手中握有的是什么信息。toDuration的实现见 src/interval.js。小结一套可复用的决策框架做加法/减法时先问单位是高阶还是低阶。years/months/days走日历数学保持低阶分量、处理月末钳制与 DSThours/minutes/seconds走时间数学纯毫秒累加多单位运算按高阶到低阶执行。比较时/走valueOf时间戳时间戳相等用toMillis()连元数据一起比用equals()按日历单位比用hasSame()按单位截断后比用startOf()。求差值时直接在目标单位上diff(start, unit)需要锚定且可反复查询的差用Interval.length()/toDuration()。换算 Duration 时认清 casual 与 longterm 两套矩阵及其自洽性差异12 × 30 ≠ 365需要跨世纪精度时用conversionAccuracy: longterm并时刻警惕换算导致的信息丢失。【免费下载链接】luxon⏱ A library for working with dates and times in JS项目地址: https://gitcode.com/gh_mirrors/lu/luxon创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表