Как регистрировать данные, не зависящие от местоположения, и неполные даты с помощью Temporal

Веб-функции: Temporal

Запись хронологических данных, которые должны оставаться идентичными независимо от местоположения зрителя, например дат рождения, ежедневных будильников, сроков действия кредитных карт или ежемесячных платежных циклов, исторически была подвержена ошибкам при использовании устаревшего объекта Date. Поскольку Date всегда представляет определенный момент времени и отображается с использованием местного часового пояса зрителя, сохранение даты, например "1990-01-01", может привести к тому, что пользователи к западу от UTC увидят "1989-12-31", в то время как моделирование частичных дат (например, истечение срока действия карты только за месяц) приводит к произвольным дням-заполнителям, которые вызывают ошибки переполнения.

API Temporal предоставляет семейство простых типов, не зависящих от местоположения и не имеющих смещения часового пояса. Они представляют календарные даты и время на часах точно так, как они считываются с календаря или часов:

Тип Что моделируется Примеры использования
Temporal.PlainDate Полная календарная дата (year, month, day) Даты рождения, даты событий, даты оплаты подписок
Temporal.PlainTime Время по часам (hour, minute, second и т. д.) Ежедневные будильники (9:00 по местному времени), часы работы магазинов.
Temporal.PlainDateTime Дата в календаре + время на часах без часового пояса Плавающие местные расписания, сочетающие PlainDate и PlainTime до прикрепления зоны
Temporal.PlainMonthDay Месяц и день без года Ежегодные дни рождения, годовщины, праздники с фиксированной датой.
Temporal.PlainYearMonth Год и месяц без дня Срок действия кредитных карт (2027-12), ежемесячные расчетные периоды

Как реализовать

  1. Выберите наиболее узкий тип Temporal.Plain*, соответствующий вашим данным.
    • Используйте Temporal.PlainDate.from() для полных календарных дат и Temporal.PlainTime.from() для времени.
    • Используйте Temporal.PlainDateTime.from() (или plainDate.toPlainDateTime(plainTime)), чтобы объединить календарную дату и время без привязки к часовому поясу.
    • Используйте Temporal.PlainMonthDay.from() для ежегодных дат без года и преобразуйте в PlainDate с помощью .toPlainDate({ year }) перед выполнением арифметических операций с датами.
    • Используйте Temporal.PlainYearMonth.from() для значений года и месяца, чтобы произвольные номера дней не попадали в расчеты.
  2. Обратите внимание на параметр calendar при создании простых типов. ISO 8601 ('iso8601') – это календарь по умолчанию при анализе строк ISO или создании с помощью PlainDate.from({ year, month, day }). При создании PlainMonthDay или PlainYearMonth из наборов свойств или при работе с календарями, не соответствующими стандарту ISO, явно передавайте calendar (например, { month: 10, day: 31, calendar: 'iso8601' }).
  3. Используйте .add() с параметрами overflow для повторяющихся календарных интервалов. При вызове plainDate.add({ months: 1 }) по умолчанию используется { overflow: 'constrain' }, что позволяет зафиксировать даты конца месяца, например 31 января и 28/29 февраля. Передайте { overflow: 'reject' }, если недопустимые целевые даты должны вызывать RangeError вместо ограничения.

Пример: полные даты, время по часам и PlainDateTime

// 1. Parse a calendar date and a wall-clock time from input strings
const birthdate = Temporal.PlainDate.from('1990-01-01');
const reminderTime = Temporal.PlainTime.from('09:00');

// Combine into a location-agnostic PlainDateTime without string interpolation
const localAppointment = birthdate.toPlainDateTime(reminderTime);

// 2. Display without time zone conversion; outputs the same date in any time zone
console.log(birthdate.toLocaleString('en-GB'));
console.log(localAppointment.toString()); // "1990-01-01T09:00:00"

// 3. Read calendar fields directly from the Plain type. A PlainDate already *is*
// a calendar date, so there is no need for the formatToParts() workaround that
// legacy Date code uses to recover year/month/day from an instant.
const { year, month, day } = birthdate;

Пример: неполные даты (PlainMonthDay и PlainYearMonth)

// Annual recurring event (no year component)
// Include explicit calendar when constructing from an object
const annualHoliday = Temporal.PlainMonthDay.from({
  month: 10,
  day: 31,
  calendar: 'iso8601',
});

const today = Temporal.Now.plainDateISO();
const isToday = annualHoliday.equals(today.toPlainMonthDay());

// Convert PlainMonthDay to a PlainDate with a specific year before doing arithmetic,
// because month lengths (such as February) depend on whether the year is a leap year.
const occurrenceThisYear = annualHoliday.toPlainDate({ year: today.year });

// Monthly expiration (no day component, preventing day-of-month overflow bugs)
const cardExpiry = Temporal.PlainYearMonth.from({
  year: 2027,
  month: 12,
  calendar: 'iso8601',
});
const currentMonth = today.toPlainYearMonth();
const isExpired = Temporal.PlainYearMonth.compare(cardExpiry, currentMonth) < 0;

Пример: повторяющиеся ежемесячные интервалы (overflow: 'constrain' и 'reject')

const billingStart = Temporal.PlainDate.from('2024-01-31');

// Default overflow: 'constrain' clamps Jan 31 + 1 month to the last valid day of February
const nextBilling = billingStart.add({ months: 1 }, { overflow: 'constrain' });
console.log(nextBilling.toString()); // "2024-02-29" (2024 is a leap year)

// Use overflow: 'reject' to throw a RangeError when the target day does not exist
try {
  billingStart.add({ months: 1 }, { overflow: 'reject' });
} catch (err) {
  // RangeError: day 31 does not exist in February
}

Пример: взаимодействие с устаревшими версиями Date

Устаревший тип Date – это временная метка (момент времени), а не календарная дата. Если вы сохраните "1990-01-01" как Date, вам придется выбрать какой-то момент времени (обычно полночь по UTC), и при чтении в часовом поясе к западу от UTC вы получите "31/12/1989". Если для библиотеки или API по-прежнему требуется Date, выполните явное преобразование с помощью Temporal.Instant, а не создавайте строки ISO вручную:

// PlainDate -> Date: attach a time zone (time of day defaults to the start of
// that day) to get an Instant, then hand the epoch milliseconds to Date.
// 'UTC' is a deliberate choice and must be documented; any zone would do,
// but the stored instant depends on it.
const instant = birthdate.toZonedDateTime('UTC').toInstant();
const legacyDate = new Date(instant.epochMilliseconds);

// Date -> PlainDate: go back through Instant (legacyDate.toTemporalInstant()
// is equivalent), then project into an explicit time zone. The result depends
// on the zone, which is exactly the drift risk.
const viewedDate = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime())
  .toZonedDateTimeISO('America/New_York')
  .toPlainDate();

// Compare Plain types with .equals() (or Temporal.PlainDate.compare), not strings.
console.log(viewedDate.equals(birthdate)); // false: the calendar date drifted to 1989-12-31

Стратегическое внедрение и рекомендации

  • РЕКОМЕНДУЕТСЯ использовать Temporal.PlainDate для полных календарных дат, Temporal.PlainTime для времени, Temporal.PlainDateTime для локальных пар дата-время без зоны, Temporal.PlainMonthDay для повторяющихся ежегодных дат и Temporal.PlainYearMonth для сроков действия или расчетных периодов в формате год-месяц.
  • НЕ выполняйте арифметические операции непосредственно с Temporal.PlainMonthDay. Сначала преобразуйте его в Temporal.PlainDate с помощью .toPlainDate({ year }), поскольку продолжительность месяцев зависит от года.
  • РЕКОМЕНДУЕТСЯ использовать .add() в Temporal.PlainDate для повторяющихся календарных интервалов, полагаясь на overflow: 'constrain' (по умолчанию) для ограничения дат в конце месяца или overflow: 'reject' для выдачи RangeError для несуществующих дат.
  • РЕКОМЕНДУЕТСЯ указывать calendar (например, calendar: 'iso8601') при создании PlainMonthDay или простых типов, не соответствующих стандарту ISO, из объектов.
  • НЕ ИСПОЛЬЗУЙТЕ простые типы, если вам нужно указать конкретный момент времени. Используйте Temporal.Instant или Temporal.ZonedDateTime для журналов, временных меток событий или планирования в разных часовых поясах.
  • НЕ создавайте объекты Date из строк ISO, созданных вручную (например, `${date}T00:00:00Z`). Создайте Date из epochMilliseconds Temporal.Instant или Temporal.ZonedDateTime и прочитайте его с помощью Temporal.Instant.fromEpochMilliseconds(date.getTime()) или date.toTemporalInstant(), чтобы часовой пояс был указан явно.
  • НЕ ИСПОЛЬЗУЙТЕ Intl.DateTimeFormat.prototype.formatToParts() для извлечения или сравнения значений года, месяца или дня. Прочитайте .year, .month и .day непосредственно из типа Plain и сравните с .equals().
  • РЕКОМЕНДУЕТСЯ указывать часовые пояса по названию в базе данных IANA (например, America/New_York), а не по фиксированному смещению относительно UTC. Смещение меняется при переходе на летнее время и в соответствии с законодательством, поэтому такие обозначения, как "UTC-5", могут быть неверными в течение части года.
  • Помните, что объекты Temporal неизменяемы. Такие методы, как .add(), .subtract() или .with(), всегда возвращают новый экземпляр с новой ссылкой, а не изменяют исходный объект на месте, что делает их безопасными для хранения непосредственно в реактивном состоянии пользовательского интерфейса.

Резервная стратегия

Резервные варианты и поддержка Temporal в браузерах

Для браузеров, которые пока не поддерживают нативный API Temporal, используйте обнаружение функций и динамически загружайте temporal-polyfill (около 15 КБ min+gz).

Обратите внимание, что импорт temporal-polyfill не приводит к автоматическому назначению объекта Temporal глобальной области видимости. Если ваш код зависит от globalThis.Temporal, назначьте его явно.

// Check if Temporal is supported natively
(async () => {
  if (typeof Temporal === 'undefined') {
    // Load the polyfill conditionally
    const module = await import('https://esm.sh/temporal-polyfill');
    globalThis.Temporal = module.Temporal;
    // Extend Date.prototype if needed
    Date.prototype.toTemporalInstant = module.toTemporalInstant;
  }
  initializeApp();
})();