有時候我們會需要寫程式去計算兩個時間點的差距,以求得年齡、經過了多少的時間,或是否還在某個期限之內等等的資訊。日(day)與時、分、秒的差距很好算,因為它們是固定的時間(不考慮閏秒)。但年、月就不一樣了,一個月可能有28至31天,一年可能有365或366天,我們無法直接將兩個時間點(的時間戳記)相減之後去除上一個固定的單位(例如拿相差的秒數去除以2592000秒)來算出隔了多少年、月,硬是要這麼算肯定是會出錯的。



讓我們一步一步來吧,從簡單的需求開始,再逐步延伸了解怎麼正確計算年、月、日和時間的差距。

但我們必須要先釐清我們的輸入,也就是「時間點」到底是什麼。一般來說我們會使用「時間戳記(timestamp)」來儲存時間點,它是一個使用秒、毫秒、奈秒等單位來表示從格林威治標準時間的西元1970年1月1日經過了多少時間的數值,如果這個數是負數,表示這個時間是從格林威治標準時間的西元1970年1月1日再往前算。

舉幾個例子,時間戳記以毫秒為單位的話,格林威治標準時間的西元1970年1月1日0時0分0秒的時間戳記就是0,而西元1970年1月2日0時0分0秒的時間戳記就是86400000

注意那個「格林威治標準時間」,如果是其他時區的日期時間,必須要先把它換算成格林威治標準時間才能計算時間戳記。例如,台北時區的西元1970年1月1日0時0分0秒的時間戳記就是-28800000

參考以下的JavaScript程式:

const date = new Date(1970, 1 - 1, 1, 0, 0, 0);

console.log(date.getTimezoneOffset());

const timestamp = date.getTime();

console.log(timestamp); // timestamp in milliseconds

JavaScript的Date物件的「月」是從0開始算的。Date物件提供的getTime方法會回傳這個Date物件的時間戳記(單位:毫秒)。

不考慮年、月和時間,只計算兩個時間點相差了幾天

把兩個時間點相減,再除以一天的時間,就會是天數。十分簡單。

const dayDiff = (from: Date, to: Date) => {
    return (to.getTime() - from.getTime()) / 86400000;
};

不考慮年、月,計算兩個時間點相差了幾天和時分秒

把兩個時間點相減,再除以一天的時間,就會是天數。不過這個天數可能會有小數的部份,我們必須要捨棄掉小數,對天取整,再去計算剩下來不足一天的秒數,再把單位換算成時+分+秒。

const dayTimeDiff = (from: Date, to: Date) => {
    let milliseconds = to.getTime() - from.getTime();

    const days = Math.trunc(milliseconds / 86400000);
    milliseconds -= days * 86400000;

    const hours = Math.trunc(milliseconds / 3600000);
    milliseconds -= hours * 3600000;

    const minutes = Math.trunc(milliseconds / 60000);
    milliseconds -= minutes * 60000;

    const seconds = Math.trunc(milliseconds / 1000);
    milliseconds -= seconds * 1000;

    return {
        days,
        hours,
        minutes,
        seconds,
        milliseconds,
    };
};

以上程式需注意到必須要用trunc(直接取整),而不是floor(取最接近但小於等於原值的整數)。

考慮年、月,計算兩個時間點相差的年、月、日和時間

先介紹一個常見的爛方法,或許它很容易實作,但計算結果肯定是不完全正確的。

這個爛方法是這樣的:將格林威治標準時間的西元1970年1月1日0時0分0秒(此時間點的時間戳記為0)當作是基準值,去計算我們要計算差距的兩個時間點的時間戳記的差,再把這個差當作是新的時間戳記,利用程式語言提供的日期函式庫再轉年、月、日、時、分、秒,然後將年減去基準值的年、月、日、時、分、秒,此時這組年、月、日、時、分、秒就是原本兩個時間點的差距。

// 不要用,這是爛Code!
const dateTimeDiff = (from: Date, to: Date) => {
    const base = new Date(0);

    const newTimestamp = to.getTime() - from.getTime();

    const newDate = new Date(newTimestamp);

    return {
        years: newDate.getFullYear() - base.getFullYear(),
        months: newDate.getMonth() - base.getMonth(),
        days: newDate.getDate() - base.getDate(),
        hours: newDate.getHours() - base.getHours(),
        minutes: newDate.getMinutes() - base.getMinutes(),
        seconds: newDate.getSeconds() - base.getSeconds(),
        milliseconds: newDate.getMilliseconds() - base.getMilliseconds(),
    };
};

這個方法乍看之下好像可行,但稍微細想一下就會發現很不合理。

首先,當fromto還要更晚時,很明顯是完全不能用這個方式運算的。比方說,西元2001年1月1日0時0分0秒至2000年12月31日0時0分0秒,算出來會有{ years: -1, months: 11, days: 30 }這樣詭異的結果,然而正確應是{ days: -1 }

再來,這個作法顯然根本沒有考慮到大月、小月和平年閏年的二月的天數各是不一樣的。所以像是西元2000年1月1日0時0分0秒至2000年3月1日0時0分0秒,算出來的結果便是{ months: 2, days: 1 },然而正確應是{ months: 2 }

還有就是使用時間戳記會遇到的夏令時間(Daylight saving time)等會讓時間忽快忽慢的問題。以台北時區來說,像是西元1950年1月1日0時0分0秒至1950年6月1日0時0分0秒,算出來的結果是{ months: 5, hours: -1 },但我們想要的答案應該要是{ months: 5 }

那麼該如何才能把日期時間的差距算正確呢?

以下三點極為重要:

  1. 先決定要從哪個時間點開始往前或是往後移動。
  2. 從最大的單位(年)開始移動。當年份和月份移動完,要移動天數的時候,要先判斷目前被移動的日期在移動之後的年月下有無超過該月的天數,如果超過就以該月的最後一天算。
  3. 較晚的時間點的當天時間若比較早的時間點的當天時間還要更早。在計算日期的時候,若是要從較早的時間點開始移動,要先把較晚的時間點減1天;反之,若是要從較晚的時間點開始移動,要先把較早的時間點加1天。最後計算較晚的時間點和較早的時間點的當天時間差時(差會是負數),再把剛才少算的1天加回來。

「移動」是什麼意思?例如要計算西元2000年1月1日2001年3月5日的差距,即西元2000年1月1日要「走」幾年、幾月、幾天才會到2001年3月5日。2000年1月1日往未來移動1年會是2001年1月1日2001年1月1日往未來移動2個月會是2001年3月1日2001年3月1日往未來移動4天會是2001年3月5日。所以,西元2000年1月1日2001年3月5日的差距為「1年2個月又4天」。

即便是同一組的兩個時間點,分別從較早的時間點或是較晚的時間點開始移動,也可能會得到不同的差距。

例如西元2000年8月11日2001年7月10日2000年8月11日往未來移動10個月會是2001年6月11日2001年6月11日往未來移動20天是2001年7月1日2001年7月1日往未來移動9天會是2001年7月10日。所以差距為「10 個月又 29 天」。

但若是反過來,變成西元2001年7月10日2000年8月11日2001年7月10日往過去移動10個月會是2000年9月10日2000年9月10日往過去移動10天會是2000年8月31日2000年8月31日再往過去移動20天會是2000年8月11日。所以差距為「-10 個月又 -30 天」。有沒有,是不是不一樣了!

至於以上第二點的「該月最大天數」是在說,假如西元2013年12月31日,往過去移動1年又10個月,應變成2月29日,而非2月31日,也非2月28日,因為西元2012年是閏年。這樣的話,目的時間點若是西元2012年2月28日,才能正確地利用29減去28,算出除了1年又10個月之外還差了1天。

第三點是考慮到「當天的時分秒時間」,計算日期的時候把未滿一天的差距先扣掉,最後計算時間的時候再加回來,會比較好算。

其實計算時間和日期的差距的方式是有點複雜的,還要判斷閏年以及一個月有幾天。判斷閏年的程式可以參考這篇文章:

https://magiclen.org/year-helper/

所以筆者把這個程式包成了套件,方便重複使用。

Date Differencer

「Date Differencer」是筆者開發的套件,用上面所介紹的方式實作出了正確計算時間和日期的差距的函數,並且還提供把差距加回來的函數。

Rust 上使用 Date Differencer

給Rust用的套件還沒上架,在等Rust最多人用的日期套件chrono釋出0.5更新。有需要者可以搶先用。

https://github.com/magiclen/date-differencer

在 Node.js 上使用 Date Differencer

npm install date-differencer
import {
    dateDiff, dateTimeDiff, dayDiff, dayTimeDiff,
    addDateTimeDiff, addDayTimeDiff
} from "date-differencer";

const a = new Date(2022, 5, 6, 0);
const b = new Date(2023, 7, 9, 1);

console.log(dateDiff(a, b));
/*
{
    "years": 1,
    "months": 2,
    "days": 3
}
*/

console.log(dateTimeDiff(a, b));
/*
{
    "years": 1,
    "months": 2,
    "days": 3,
    "hours": 1,
    "minutes": 0,
    "seconds": 0,
    "milliseconds": 0
}
*/

console.log(Math.trunc(dayDiff(a, b))); // (365 + 31 + 30 + 3) = 429

console.log(dayTimeDiff(a, b));
/*
{
    "days": 429,
    "hours": 1,
    "minutes": 0,
    "seconds": 0,
    "milliseconds": 0
}
*/

console.log(addDateTimeDiff(a, dateTimeDiff(a, b))); // the same as b
console.log(addDayTimeDiff(a, dayTimeDiff(a, b)));   // the same as b

在網頁瀏覽器上使用 Date Differencer

<script src="https://cdn.jsdelivr.net/gh/magiclen/ts-date-differencer/dist/date-differencer.min.js"></script>
<script>
    console.log(DateDifferencer.dateDiff(new Date(2022, 5, 6), new Date(2023, 7, 9)));
    /*
    {
        "years": 1,
        "months": 2,
        "days": 3
    }
    */
</script>