Luxon 日期时间数学运算完全指南:日历数学、时间数学与 Duration 换算精度
2026/9/21 1:54:17 网站建设 项目流程

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 的角度看,极少数情况下同一个"秒"可能"出现两次"。闰秒的实际影响非常有限:

  1. 你无法表示闰秒本身——DateTime.utc(2016, 12, 31, 23, 59, 60).isValid返回false
  2. 跨越闰秒的diff()计算结果不会精确等于外界真实流逝的秒数。这在你的应用需要精确知道"最近 n 秒到底发生了什么"的罕见场景下才会浮现;如今这一误差也越来越多地被闰秒涂抹(leap smear)机制缓解。

日历数学的正确思考方式

不要把日历数学理解为对中间各段长度做繁琐检查,而应理解为直接调整该单位本身、并保持更低阶的日期分量不变。回到"2 月 13 日 + 1 个月"的例子,若没有 Luxon,你需要手工操作原生Date

var d = new Date('2017-02-13') d.setMonth(d.getMonth() + 1) d.toLocaleString() //=> '3/13/2017, 12:00:00 AM'

Luxon 底层做的与此如出一辙:它不会把运算摊平成毫秒差值——因为用户要的并不是那个。它直接摆弄自己认为的"日期应该是什么",再用内置的格里高利历(Gregorian calendar)算出新的时间戳。

从源码看,src/datetime.js 的adjustTime()正是这么实现的:先把dur.yearsdur.monthsdur.quarters的整数部分直接加到日历年、月上,天数部分用Math.min(inst.c.day, daysInMonth(year, month))做月末钳制(所以 1 月 31 日加 1 个月得到 2 月 28/29 日),再加dur.daysdur.weeks * 7;只有把各单位的小数部分hoursminutessecondsmilliseconds汇总成一个 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-01'

Luxon 的接口设计让你很难"顺手"做错,这并非巧合。源码层面,adjustTime()orderedUnitsyears → quarters → months → weeks → days → hours → ...)的顺序(见 src/duration.js)与"先处理高阶单位"的语义一脉相承。

比较两个 DateTime

DateTime实现了#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') //=> true

startOf支持yearquartermonthweekdayhourminutesecondmillisecond等单位(源码见 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求两个时刻之间的时间量,结果是一个 Duration:

var 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.js:highOrderDiffs()按"年 → 季度 → 月 → 周 → 天"依次尝试用较大单位做差,若超调则回退并改用更小单位(源码注释明确说明了这一"先大后小、超调回溯"的游标推进算法),剩余毫秒再交给Duration.fromMillis(...).shiftTo(...)处理低阶单位。

Casual 与 longterm:两种换算精度

Duration 是带有特定单位的时间包,但 Luxon 允许你在单位之间换算:

  • shiftTo返回以指定单位计量的新 Duration;
  • as把整个 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.js:lowOrderMatrix(周/天/时/分/秒/毫秒)、casualMatrix(年/季度/月使用 365/91/30 天并展开...lowOrderMatrix)、以及由daysInYearAccurate = 146097.0 / 400daysInMonthAccurate = 146097.0 / 4800推导的accurateMatrixDuration构造函数(src/duration.js)按conversionAccuracy === "longterm"选择矩阵,并把它作为实例属性保存下来。

哪些方法接受conversionAccuracy凡是从零创建 Duration 的方法都可以:Duration.fromObjectDuration.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 日之间的真实天数并不是 30:

var 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 转成 Duration:

i.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。

小结:一套可复用的决策框架

  1. 做加法/减法时:先问单位是高阶还是低阶。years/months/days走日历数学(保持低阶分量、处理月末钳制与 DST),hours/minutes/seconds走时间数学(纯毫秒累加);多单位运算按高阶到低阶执行。
  2. 比较时</>valueOf(时间戳),时间戳相等用toMillis(),连元数据一起比用equals(),按日历单位比用hasSame(),按单位截断后比用startOf()
  3. 求差值时:直接在目标单位上diff(start, unit);需要"锚定"且可反复查询的差,用Interval.length()/toDuration()
  4. 换算 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询