Галоўная / Артыкулы

Пяць спяваемых падаслаў API, якія тылуцьна зноў вводзяць проблемы з датамі

Дазвольце даклэ расказаць, як API Temporal у JavaScript все ўсё можа спрычыніць проблемы з часовымя регіонамі, серыялізацыёй, точнасцю і трываласцю, якщо не выправіць пяць распашчытых спосабаў неправильнага выкарыстоўвання.

Выдавец: REACTAPP.TOP9 хв чытання1977 слоў

Крыніцы: JavaScript Temporal API: 5 Critical Mistakes and How to Fix Them!

Упомненне працуючы кожны дзень, якое выдаеся за гадзіну даўжэй кожы раз, калі змінюецца часавая зона за метой эканаміі святла. Заява на падтрымку, занесена пад неправы дзень календаря, таму што сервер працуе за стандартам UTC, а кліент знаходзится ў абсолютна іншай часовай зоне. Фінансавы атлас, які періядычна не праходзі тэст на роўнасць у системе CI, без явнага спосабу відтворэння гэтай проблемы на локальным комп’ютере.

Ні адна з гэтых проблем не ўзначае баг, вызваны Date. Це тэмпаральныя багі, і яны з’яўляюцца ў кодавых базах, якія адразу ж апынуліся ў падтрымцы Temporal, каб ужо не сталкнуцца з багамі, relacionаванымі з датамі.

Пачка Node.js 26.8.2 падае з Temporal у всіх версіях, і для яго выкарыстоўвання не трэба жадных эксперыментальных настройкаў. Chrome 144 таксама падтрымляе яго з самага пачатку. Комітэт TC39 пераказаў гэтыя прыказы на 4-ю стадію, што значыць, ён больш не ўважаецца тэматыкай для майбутніх прыемлень — гэта вялікі частака мовы, і команды актыўна выкарыстоўваюць яго ў замену на Moment.js, date-fns і спецыяльныя аперацыі з датамі, базаваныя на Date. Самэ ў гэтым процесе міграцыі часта выклікаюцца пяць нижэй пераказаных адмылак.

Temporal рашае старыя проблемы класу Date — зменнасць, неясную парсацію і абсэнцыю чыстай межы між календарным датам і точным моментам часу — шляхом застосавання строгей, более яснай модэлі дадзеных. Проблемы пачынаюцца тады, калі разработчыкі спрыяюць яго як проста „лепшую версію Date“. Самэў гэтыя падазрэння ствараюць тут описаныя памылкі, і гэтыя памылкі часта бываюць ўскрытней старых, а не лёгкія да выяўлення.

Памылка 1: Выклік Temporal.Now.plainDateISO() без выбору, каго „сёньдзень“ маецца на увазе

Гэта, верагодна, самая частая памылка, галоўнай прычыной якой є тое, што код, який яе спрычынае, на першы погляд выглядае абсалютна правільным.

// Inside an API route running on a server in us-east-1
function getTodaysDigestDate() {
  const today = Temporal.Now.plainDateISO();
  return today.toString();
}

Калікацыя Temporal.Now.plainDateISO() без аргументаў выкарыстоўвае часовую зону сістэмы — тое ўзьязе, у якім працюе сервер, а не тая, у якой знаходзіцца канечны корыстнік. Уявіце кліента ў Токіё, які запускае вашае прыладдзе о 8 ранку за местным часам: якщо сервер знаходзіцца ў формате UTC і ўсё яшчэ не перайшоў па полуночы, датаванне даных гэтага кліента будзе падписвацца датай з вяршынкі. Це тая ж проблема з адхыленням на адну единіцу, якая раней спрабоўвала завадзіць new Date().toDateString() — яна проста з’явілася знову ў коде, які разработчыкі супакоўваліся, што ён ад неё захіщаны, толькі таму, што ў яму викорыстоўваецца Temporal.

Кращы падход:

function getTodaysDigestDate(userTimeZone) {
  // userTimeZone comes from the user's profile or resolved request context,
  // e.g. "Asia/Tokyo" — never the server's implicit zone.
  const today = Temporal.Now.plainDateISO(userTimeZone);
  return today.toString();
}

Temporal ніколи не пазіраваў пытанне «па адносу да якой зоны?» — ён проста вымагае ад вас явна даўаць на ёго адпаведзь у замест на тое, каб гэта рабілася бездзвучна за замовчаннем. Кожны вызыв Temporal.Now.*, у яком не указана конкрэтная зона, ўособлівае рашэнне, якое вы прымелі не усвядомляючы гэтага. Варта абшукаць у вашай базе коду вызывы Temporal.Now.plainDateISO(), Temporal.Now.zonedDateTimeISO() і Temporal.Now.plainDateTimeISO() без указання зоны — кожны з іх бездзвучна адпавядае на пытанне «сьегодні, па чыёй меркі?» адпаведдзю «за зонаю, у якой працуе сервер».

Памылка 2: Прыпуск, што перадача даных у формате JSON пераводзіць значэння Temporal знову у об’ект Temporal

Об’екты типу Temporal выклікаюць метод toJSON(), таму ўсуненне іх у JSON.stringify() здаецца працюваць без проблем — і самэ гэта здаецца успех якраз і стварае прычыну наступнага бага.

const state = { renewalDate: Temporal.PlainDate.from("2026-11-03") };
localStorage.setItem("sub", JSON.stringify(state));
// ...later, on page reload...
const restored = JSON.parse(localStorage.getItem("sub"));
const daysLeft = restored.renewalDate.since(Temporal.Now.plainDateISO()).days;

JSON.stringify пад слоем вызвае toJSON() і стварае звычную строчку у формате ISO. Але JSON.parse не можа з’ясаваць, што гэта строчка спачатку выклікалася для представлення об’екта Temporal.PlainDate — ён проста вяртае строчку, а не экземпляр класу. Выконанне вышэўказанага фрагмента спрычынае памятку TypeError: restored.renewalDate.since is not a function; гэта зазвычай выклікаецца ў режыме працы, толькі пасля таго, як значэнне працавала через які-небудзь механізм серыялізацыі: localStorage, вызов postMessage да Web Worker, параметр, пераданы сервернаму компоненту, або зберажаны склад Redis. У локальнай розробце, калі працуеш у той самый вікні і пад час адной экзекуцыі, гэту проблему лёгка зовсім не пазначыць — яна з’яўляецца толькі тады, калі значэнне фактычна серыялізуецца і пазней зноў чытаецца.

Лепшы падход:

const restored = JSON.parse(localStorage.getItem("sub"));
const renewalDate = Temporal.PlainDate.from(restored.renewalDate);
const daysLeft = renewalDate.since(Temporal.Now.plainDateISO()).days;

Основныя правілы такія: кожны тип Temporal адпавядае статычнаму канстрактару .from() для перадзначэння, і калі якое-небудзь з эых значэнняя праходзіць через слой, які спачатку ператварае ў просты текст або JSON — чы то сховішча браузера, паведамлення працоўніка, пакет даных API, чы то перадача межу серверам і кліентам у фрэймворку — вы несете адпаведальнасць за яго явную перадзначэнне пасля таго, як ён прыбудзе. Нічога не будзе автаматычна выканаць гэта перадзначэнне за вас, і нічога не будзе выдаць паведамленне пра прыпуск гэтага крока.

Памылка 3: Забывчыця пра тое, што ваша база дадзеных не можа падтрымаць такую точнасць, як у Temporal

Temporal.Instant вимервае час да нанасекунды. Практычна жадныя з систем, куды вы перадаеце гэта значэнне, не можу зберагчы такі рэвель деталей.

const capturedAt = Temporal.Now.instant(); // nanosecond precision
await db.query(
  "INSERT INTO events (captured_at) VALUES ($1)",
  [capturedAt.toString()]
);
// later, comparing the freshly captured instant to the one read back from Postgres
const row = await db.query("SELECT captured_at FROM events WHERE id = $1", [id]);
const stored = Temporal.Instant.from(row.captured_at);
console.log(capturedAt.equals(stored)); // false — intermittently, not always

Тып столбца timestamptz у Postgres падржае толькі точнасць у мікросекундах, а звычны об’ект Date у JavaScript падржае толькі мілісекунды. Якщо запісаць об’ект Instant з точнасцю у нанасекундах безпасебна ў адны з гэтых типаў, дадатковыя цифры проста будуць адсечаны пад час запісу. Порэшчанне, паказанае ў вышэйшым фрагменте, тэхнічна не ўскладненая — гэта проста прабава пораўняць значэнне з рывень точнасці, якога сховішча на самай працэ падтрымляе. Якщо гэта застаецца непазначаным, гэта можа прывести да періядычных непрацяснаў тэстаў, пераказу пад час перагляду дублікатав, калі іноды „дублікат“ працягваецца, або да ситуацыі, калі два запісы, якія адбыліся ў той самы мікросекунд, стаюць нерозрывнымі без жаданняя такога рэзультата.

Правільнае выкарыстоўванне:

const capturedAt = Temporal.Now.instant().round({ smallestUnit: "microsecond" });
await db.query(
  "INSERT INTO events (captured_at) VALUES ($1)",
  [capturedAt.toString()]
);
const row = await db.query("SELECT captured_at FROM events WHERE id = $1", [id]);
const stored = Temporal.Instant.from(row.captured_at);
console.log(capturedAt.equals(stored)); // true, consistently

Заўсёды заокруглівайце значэнне да той точнасці, якую практычна падтрымае ваш слой зберагача, до яго запісу, а не пасля таго, калі вы вялі час на пораўнання, якое вырашаеся толькі перыядычна.

Памылка 4: Зберагаччаўанне фіксаванага момента для запуску падзеі, якая ўсё ще не адбылася

Гэтая памылка ўзначай небяпечная, таму што яна зазвычай праходзіць тэставанне і потрапляе ў працоўны режым — аблікаванне памылкі відбываецца толькі два разы на год.

function scheduleReminder(zone) {
  // "Remind me at 9 AM tomorrow" — computed once, at creation time
  const fireAt = Temporal.Now.zonedDateTimeISO(zone)
    .add({ days: 1 })
    .toInstant();
  return fireAt; // stored as-is, e.g. in a job queue's "run_at" column
}

Instant пазначае адзін, фіксаваны момент на часовай лініі UTC; як толькі местны час пераканаліваецца ў такі момент, він больш не „памятае“, што це адпаваў 9 гадзін ранку за местным часам. Якщо запланаваная дата падпадае пасля змены часовых поясов у супаноўшчы з моментам стварэння напамінання, зсув UTC для таго часовага пояса змініцца, і напамінанне будзе выконвацца о 8 гадзін або 10 гадзін ранку за местным часам, а не о 9. У самы момент выконання коду няма нічога нештаціонарнага — проблема выявляецца толькі пасля змены часоў, і самэ гэта є тыпам дыфекта, які называецца „нестабільным“ і продовжвае з’яўляцца ў кожную весну і осень.

Правільнае выкарыстоўванне:

function scheduleReminder(zone) {
  // Store the wall-clock intent, not a resolved instant
  const wallTime = Temporal.Now.plainDateTimeISO(zone).add({ days: 1 });
  return { wallTime: wallTime.toString(), timeZone: zone };
}
function resolveNextFireTime({ wallTime, timeZone }) {
  // Resolve to an actual Instant only when you're about to act on it
  return Temporal.PlainDateTime.from(wallTime)
    .toZonedDateTime(timeZone)
    .toInstant();
}

Калі на самай працоўны мета — „9 ранку за местным часам, незалежна ад таго, які будзе схыл часовых поясаў“, трэба заставіць значэння з стаўкі гадзінака разам з часовым поясам, і толькі ў самы пачатак выкарыстоўвання перакануць яго ў конкрэтны Instant — адразу перед тым, як прыняць рашэнне, а не тады, калі было запланавана.

Памылка 5: Вжыванне матэматыкі з мілісекундамі заместо Temporal.Duration

Звычкі, якія засталіся з Date.getTime(), не зникаюць сама сабою толькі таму, што вы перайшлі на новы тип.

function oneWeekLater(zonedDateTime) {
  const msPerWeek = 7 * 24 * 60 * 60 * 1000;
  const nextMs = zonedDateTime.epochMilliseconds + msPerWeek;
  return Temporal.Instant.fromEpochMilliseconds(nextMs)
    .toZonedDateTimeISO(zonedDateTime.timeZoneId);
}

Календарная недзе не завжоды складаецца з роўна 168 гадзін. Калі перыяд, які вы вырахоўваеце, перакрывае межу часовых поясоў з пераходам на летній час, даданне мілісекунд у розмяре 7 * 24 гадзін да значэння эпохі паўтарае некоракты час у местным гадзінніку — запланаваная на „вёсць недзе пазней“ зустрэч адхіляецца на гадзіну, нават як сама календарная дата застаецца незмяненаю. Цэе, по суты, менш выражаная версія Памылкі 4, і яна часта з’яўляецца ў будзь-яй функцыі, якая яшчэ выкорыстоўвае простую арыметыку мілісекунд, проста таму, што так раней выконваліся вырахунакі з Date.

Правы спосаб выкарыстоўвання:

function oneWeekLater(zonedDateTime) {
  return zonedDateTime.add({ weeks: 1 });
}

Методы такія як .add() і .subtract(), створаныя на базе Temporal.Duration, врачоўваюць календар і часовыя зоны, таму фраза „чэрез адну недзелю“ правільна перакладаецца на той самы час за стаўкамі гадзінакаў у той самы дзень тыжня, з урахоўваннем летньага часу. Калі вы почнёте выраховваць сяроднюю размінку ў мілісекундах і пасля цього яе пераканваты, вы выйдзіце за межы часткі API Temporal, створанай для правільнага адкалкулявання.

Што спаўнае ўсі пяць ошибак

Кожна з эйх проблем мае адну і тую ж прычыну: Temporal вымагае часткова яснасці ў паводзе зоны часу, тачнасці і намеру, а кожная памылка вынікае з таго, што прыкметна викорыстоўваюцца неявныя прыпускі, якія зазвычай викорыстоўваецца з Date. Temporal ніколі не быў задуманы як механічны заменнік — цэў строгейшы модель, якая викорыстоўвае назвы методаў, якія проста выглядаюць знайома.

Короткае падсумованне:

  • Ніколы не вызывайце Temporal.Now.* без часткова яснага выбору, каторай зоны часу „зараз“ вам насправды трэба
  • Вярнайце значэння Temporal за дапамою .from() з обох бакоў будь-якага межы серыялізацыі — JSON ператварае іх у звычныя строкі, а не жывыя об’екты, пад час перавозкі
  • Заўважніцу трэба заокругліваць вярху, каб яна пасвядзела таму, што насправды можа быць зберажана ў слоі аховы, пры яго запісе, а не пазней пад час дэбагавання пераканальнай перацёкі, якая неспадзевана паспеўшае
  • Для запісу падзей, якія адбудуцца ў будучыні, трэба зберагаць час па стандартнаму гадзінніку разам з часовым поясам, і ператвараць яго на Instant толькі тады, калі вы збіраецеся ўжываць гэты час
  • Для всіх задач, якія стосуюцца календарных дат, лепш вжываць арыметыку на базе Temporal.Duration — .add(), .subtract() — замест вычыслаў на адзіны мілісэкунду эпохі
  • Temporal дзейсна лепа рашае проблемы, якія накупіліся за дзесяткі гадоў з Date. Але ён заменяе тую старую хаоснасць на меньшыя, але болей чыстыя проблемы, і тепер, калі яго стандартна падтрымлівае як Node 26, так і Chrome 144, больш заўсёды команд сталакаецца на гэтыя проблемы безпосередна ў продакшене пад час міграцыі