การบันทึกข้อมูลที่ไม่ขึ้นอยู่กับสถานที่และวันที่บางส่วนด้วย Temporal

ฟีเจอร์บนเว็บ: Temporal

การบันทึกข้อมูลตามลำดับเวลาที่ควรจะเหมือนกันไม่ว่าผู้ดูจะอยู่ที่ใด เช่น วันเกิด การปลุกประจำวัน วันหมดอายุของบัตรเครดิต หรือรอบการเรียกเก็บเงินรายเดือน มักจะเกิดข้อผิดพลาดได้ง่ายเมื่อใช้ออบเจ็กต์ Date เดิม เนื่องจาก Date แสดงถึงช่วงเวลาที่เฉพาะเจาะจงเสมอและแสดงโดยใช้เขตเวลาท้องถิ่นของผู้ชม การบันทึกวันที่ เช่น "1990-01-01" อาจส่งผลให้ผู้ใช้ที่อยู่ทางตะวันตกของ UTC เห็น "1989-12-31" ในขณะที่การจำลองวันที่บางส่วน (เช่น วันหมดอายุของบัตรที่ระบุเฉพาะเดือน) จะบังคับให้ใช้วันที่ที่กำหนดเองเป็นตัวยึดตำแหน่ง ซึ่งทำให้เกิดข้อบกพร่องด้านโอเวอร์โฟลว์

Temporal API มีประเภท "ธรรมดา" ที่ไม่ขึ้นอยู่กับสถานที่ตั้ง ซึ่งไม่มีการชดเชยเขตเวลาและแสดงวันที่ในปฏิทินและเวลาตามนาฬิกาติดผนังตามที่อ่านจากปฏิทินหรือนาฬิกา

ประเภท สิ่งที่โมเดลจำลอง กรณีการใช้งานทั่วไป
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 เมื่อสร้างประเภท Plain: ISO 8601 ('iso8601') เป็นปฏิทินเริ่มต้นเมื่อแยกวิเคราะห์สตริง ISO หรือสร้างด้วย PlainDate.from({ year, month, day }) เมื่อสร้าง PlainMonthDay หรือ PlainYearMonth จาก Property Bag หรือเมื่อทำงานกับปฏิทินที่ไม่ใช่ 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 หรือประเภท Plain ที่ไม่ใช่ ISO จากออบเจ็กต์
  • อย่าใช้ประเภท Plain หากต้องการแสดงช่วงเวลาที่เจาะจงในเวลาจริง (ทันที) ใช้ 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 โดยตรงจากประเภทธรรมดา และเปรียบเทียบกับ .equals()
  • ควรระบุเขตเวลาตามชื่อ IANA (เช่น America/New_York) และไม่ควรระบุตามการชดเชยเวลา UTC แบบคงที่ ค่าชดเชยจะเปลี่ยนไปตามเวลาออมแสงและกฎหมาย ดังนั้นป้ายกำกับอย่าง "UTC-5" จึงไม่ถูกต้องในบางช่วงของปี
  • ควรจำไว้ว่าออบเจ็กต์ Temporal เปลี่ยนแปลงไม่ได้ เมธอดอย่าง .add(), .subtract() หรือ .with() จะแสดงผลอินสแตนซ์ใหม่ที่มีการอ้างอิงใหม่เสมอ แทนที่จะเปลี่ยนแปลงออบเจ็กต์เดิมในตำแหน่งเดิม ทำให้จัดเก็บในสถานะ UI แบบรีแอ็กทีฟได้โดยตรงอย่างปลอดภัย

กลยุทธ์สำรอง

เบราว์เซอร์ที่รองรับและทางเลือกสำรองสำหรับ Temporal

สำหรับเบราว์เซอร์ที่ยังไม่รองรับ Temporal API แบบเนทีฟ ให้ใช้การตรวจหาฟีเจอร์และโหลด temporal-polyfill แบบไดนามิก (~15 KB 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();
})();