Ghi lại dữ liệu không phụ thuộc vào vị trí và ngày một phần bằng Temporal

Tính năng web: Temporal

Việc ghi lại dữ liệu theo trình tự thời gian (vốn không thay đổi bất kể vị trí của người xem, chẳng hạn như ngày sinh, chuông báo hằng ngày, ngày hết hạn thẻ tín dụng hoặc chu kỳ thanh toán hằng tháng) thường dễ xảy ra lỗi khi sử dụng đối tượng Date cũ. Vì Date luôn đại diện cho một thời điểm cụ thể và được hiển thị theo múi giờ địa phương của người xem, nên việc lưu một ngày như "1990-01-01" có thể khiến người dùng ở phía tây UTC nhìn thấy "1989-12-31", trong khi việc lập mô hình ngày một phần (như ngày hết hạn chỉ có tháng) buộc phải có các ngày giữ chỗ tuỳ ý gây ra lỗi tràn.

API Temporal cung cấp một nhóm các loại "Plain" không phân biệt vị trí, không có độ lệch múi giờ và biểu thị ngày theo lịch cũng như giờ theo đồng hồ treo tường chính xác như khi đọc trên lịch hoặc đồng hồ:

Loại Nội dung mô hình hoá Các trường hợp sử dụng thông thường
Temporal.PlainDate Ngày dương lịch đầy đủ (year, month, day) Ngày sinh, ngày diễn ra sự kiện, ngày tính phí gói thuê bao
Temporal.PlainTime Thời gian thực (hour, minute, second, ...) Chuông báo hằng ngày (9:00 sáng theo giờ địa phương của người dùng), giờ hoạt động của cửa hàng
Temporal.PlainDateTime Ngày theo lịch + giờ theo đồng hồ treo tường không có múi giờ Lịch biểu địa phương linh hoạt, kết hợp PlainDate và PlainTime trước khi đính kèm múi giờ
Temporal.PlainMonthDay Tháng và ngày không có năm Sinh nhật, ngày kỷ niệm, ngày lễ cố định lặp lại hằng năm
Temporal.PlainYearMonth Năm và tháng không có ngày Ngày hết hạn của thẻ tín dụng (2027-12), kỳ thanh toán hằng tháng

Cách triển khai

  1. Chọn loại Temporal.Plain* hẹp nhất phù hợp với dữ liệu của bạn:
    • Sử dụng Temporal.PlainDate.from() cho ngày theo lịch đầy đủ và Temporal.PlainTime.from() cho giờ theo đồng hồ treo tường.
    • Sử dụng Temporal.PlainDateTime.from() (hoặc plainDate.toPlainDateTime(plainTime)) khi kết hợp ngày theo lịch và giờ theo đồng hồ mà không liên kết với múi giờ.
    • Sử dụng Temporal.PlainMonthDay.from() cho ngày hằng năm không có năm và chuyển đổi thành PlainDate bằng .toPlainDate({ year }) trước khi thực hiện phép tính ngày.
    • Sử dụng Temporal.PlainYearMonth.from() cho các giá trị năm và tháng để số ngày tuỳ ý không bao giờ bị rò rỉ vào các phép tính.
  2. Lưu ý tham số calendar khi tạo các loại Plain: ISO 8601 ('iso8601') là lịch mặc định khi phân tích cú pháp chuỗi ISO hoặc tạo bằng PlainDate.from({ year, month, day }). Khi tạo PlainMonthDay hoặc PlainYearMonth từ các túi thuộc tính (hoặc khi làm việc với lịch không theo chuẩn ISO), hãy truyền calendar một cách rõ ràng (ví dụ: { month: 10, day: 31, calendar: 'iso8601' }).
  3. Sử dụng .add() với các tuỳ chọn overflow cho khoảng thời gian định kỳ trên lịch: Việc gọi plainDate.add({ months: 1 }) sẽ mặc định là { overflow: 'constrain' }, giới hạn ngày cuối tháng như ngày 31 tháng 1 đến ngày 28/29 tháng 2. Truyền { overflow: 'reject' } khi ngày mục tiêu không hợp lệ sẽ gửi RangeError thay vì kẹp.

Ví dụ: Ngày đầy đủ, giờ đồng hồ và 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;

Ví dụ: Ngày không đầy đủ (PlainMonthDay và 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;

Ví dụ: Khoảng thời gian định kỳ hằng tháng (overflow: 'constrain' so với '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
}

Ví dụ: Khả năng tương tác với Date cũ

Date cũ là dấu thời gian (một thời điểm), không phải ngày theo lịch. Việc lưu trữ "1990-01-01" dưới dạng Date buộc bạn phải chọn một thời điểm (thường là nửa đêm theo giờ UTC) và việc đọc lại thời điểm đó ở múi giờ phía tây của UTC sẽ cho ra "31/12/1989". Nếu thư viện hoặc API vẫn yêu cầu Date, hãy chuyển đổi rõ ràng thông qua Temporal.Instant thay vì tạo chuỗi ISO theo cách thủ công:

// 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

Triển khai chiến lược và các phương pháp hay nhất

  • NÊN sử dụng Temporal.PlainDate cho ngày dương lịch đầy đủ, Temporal.PlainTime cho giờ đồng hồ, Temporal.PlainDateTime cho các cặp ngày giờ địa phương không có múi giờ, Temporal.PlainMonthDay cho ngày định kỳ hằng năm và Temporal.PlainYearMonth cho ngày hết hạn hoặc kỳ thanh toán theo năm-tháng.
  • KHÔNG thực hiện phép tính số học trực tiếp trên Temporal.PlainMonthDay; hãy chuyển đổi thành Temporal.PlainDate trước thông qua .toPlainDate({ year }) vì độ dài tháng thay đổi theo năm.
  • NÊN sử dụng .add() trên Temporal.PlainDate cho các khoảng thời gian định kỳ trên lịch, dựa vào overflow: 'constrain' (mặc định) để giới hạn ngày cuối tháng hoặc overflow: 'reject' để gửi RangeError vào những ngày không tồn tại.
  • NÊN chỉ định calendar (ví dụ: calendar: 'iso8601') khi tạo PlainMonthDay hoặc các loại Plain không theo tiêu chuẩn ISO từ các đối tượng.
  • KHÔNG sử dụng các kiểu Plain nếu bạn cần biểu thị một thời điểm cụ thể trong thời gian thực (một thời điểm). Sử dụng Temporal.Instant hoặc Temporal.ZonedDateTime cho nhật ký, dấu thời gian của sự kiện hoặc lên lịch theo nhiều múi giờ.
  • KHÔNG tạo đối tượng Date từ chuỗi ISO được tạo thủ công (chẳng hạn như `${date}T00:00:00Z`). Tạo Date từ epochMilliseconds của Temporal.Instant hoặc Temporal.ZonedDateTime và đọc lại bằng Temporal.Instant.fromEpochMilliseconds(date.getTime()) hoặc date.toTemporalInstant() để giả định về múi giờ được thể hiện rõ ràng.
  • KHÔNG sử dụng Intl.DateTimeFormat.prototype.formatToParts() để trích xuất hoặc so sánh các giá trị năm, tháng hoặc ngày. Đọc trực tiếp .year, .month và .day từ một loại Plain, rồi so sánh với .equals().
  • NÊN xác định múi giờ theo tên IANA (ví dụ: America/New_York) và không bao giờ xác định theo độ lệch UTC cố định. Độ lệch thay đổi theo giờ mùa hè và luật pháp, vì vậy, các nhãn như "UTC-5" sẽ không chính xác trong một khoảng thời gian trong năm.
  • NÊN nhớ rằng các đối tượng Temporal là bất biến. Các phương thức như .add(), .subtract() hoặc .with() luôn trả về một thực thể mới có tệp đối chiếu mới thay vì thay đổi đối tượng ban đầu tại chỗ, giúp lưu trữ trực tiếp một cách an toàn trong trạng thái giao diện người dùng phản ứng.

Chiến lược dự phòng

Phương án dự phòng và hỗ trợ trình duyệt cho Temporal

Đối với các trình duyệt chưa hỗ trợ API Temporal gốc, hãy sử dụng tính năng phát hiện tính năng và tải động temporal-polyfill (~15 KB min+gz).

Xin lưu ý rằng việc nhập temporal-polyfill không tự động chỉ định đối tượng Temporal cho phạm vi toàn cục. Hãy chỉ định rõ ràng nếu mã của bạn dựa vào 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();
})();