Giá trị chỉ có ngày không phải timestamp
Giữ ngày sinh theo lịch Gregory, định dạng thời điểm theo múi giờ và kiểm tra vì sao hai buổi trưa liên tiếp có thể cách nhau 23 hoặc 25 giờ.

Ô ngày sinh chứa 2000-02-29. Một đoạn mã đọc giá trị đó vào JavaScript Date, định dạng cho người ở California rồi hiển thị ngày 28 tháng 2. Không ai sửa ngày sinh. Chương trình đã đổi ý nghĩa của dữ liệu: từ một ngày trên lịch thành thời điểm nửa đêm cụ thể trên dòng thời gian toàn cầu.
Nếu bạn đã quen chuỗi, hàm và điều khiển biểu mẫu HTML, bài này phát triển các giao ước ngày tháng phía sau chúng. Ta sẽ tách ngày Gregory khỏi thời điểm phát hành, dựng ví dụ so sánh có định dạng tiếng Việt và tiếng Anh, rồi xem vì sao cộng một ngày có thể đòi hỏi quyết định về lịch hẹn. Các ví dụ chạy trong trình duyệt, không cần thư viện ngày tháng.
Xác định dữ liệu có phải một thời điểm hay không
Giá trị chỉ có ngày xác định năm, tháng và ngày trong một hệ lịch. Nó không có giờ hay múi giờ. Một thời điểm xác định một điểm trên dòng thời gian; các múi giờ có thể hiển thị ngày lịch khác nhau cho cùng điểm ấy. Vì vậy ngày sinh và thời điểm bản phát hành bắt đầu được cung cấp cần hai giao ước dữ liệu khác nhau.
Tài liệu Date của MDN mô tả Date là timestamp tính bằng mili giây kể từ epoch. Múi giờ cục bộ đến từ môi trường; đối tượng không lưu một múi giờ IANA được chọn. Khi phân tích dạng ISO chỉ có ngày được hỗ trợ mà không có độ lệch múi giờ, JavaScript dùng UTC. Dạng có cả ngày và giờ nhưng thiếu độ lệch lại dùng giờ cục bộ. Bỏ phần giờ không có nghĩa là “chưa quyết định múi giờ”.
Chạy đoạn sau trong console:
const accidentalInstant = new Date("2026-09-20");
const losAngelesDate = new Intl.DateTimeFormat("en-US", {
calendar: "gregory",
timeZone: "America/Los_Angeles",
year: "numeric", month: "2-digit", day: "2-digit",
}).format(accidentalInstant);
console.log(accidentalInstant.toISOString()); // 2026-09-20T00:00:00.000Z
console.log(losAngelesDate); // 09/19/2026
Thời điểm nửa đêm UTC đó vẫn là ngày 19 tháng 9 ở Los Angeles. Thêm hậu tố giờ hay cắt một chuỗi định dạng khác không sửa được giao ước dữ liệu đã chọn sai. Giữ YYYY-MM-DD khi chuỗi biểu diễn một ngày lịch. Lưu thời điểm bằng timestamp có đơn vị được quy định hoặc chuỗi ngày giờ có độ lệch rõ ràng khi sự kiện thực sự thuộc dòng thời gian. Tên múi giờ có thể là thông tin bổ sung phục vụ hiển thị hay lập lịch, thay vì thuộc tính ngầm của timestamp.
Định dạng đổi cách trình bày ngày sinh. Múi giờ đổi cách đọc giờ và ngày lịch của thời điểm phát hành.
Kiểm tra ngày lịch trước khi định dạng
Biểu thức chính quy kiểm tra hình thức, không kiểm tra tháng 2 có ngày 30 hay không. Bộ phân tích dưới đây nhận đúng năm Gregory bốn chữ số từ 0001 tới 9999, tháng hai chữ số và ngày hai chữ số. Nó từ chối năm 0, ngày nhuận không hợp lệ và cách viết khác. Phạm vi này cố ý hẹp hơn cú pháp ngày của HTML, vốn cho phép năm dương có từ bốn chữ số trở lên.
Theo quy tắc nhuận Gregory, năm 2000 nhuận còn 1900 không nhuận. Đây là giao ước tính toán lịch được dùng trong bài, kể cả với ngày xa trong quá khứ; bộ phân tích không tự suy ra địa phương lúc ấy dùng lịch dân sự nào. Tránh dùng khả năng phân tích dễ dãi hay việc constructor tự chuyển ngày vượt giới hạn để kiểm tra đầu vào. Một ngày tháng 3 sau chuẩn hóa không chứng minh ngày tháng 2 mà người dùng nhập là hợp lệ.
function parseDateOnly(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new RangeError("expected YYYY-MM-DD");
}
const [year, month, day] = value.split("-").map(Number);
const leap = year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0);
const lengths = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (year < 1 || month < 1 || month > 12 || day < 1 || day > lengths[month - 1]) {
throw new RangeError("invalid Gregorian date in years 0001 through 9999");
}
return { year, month, day };
}
function formatCalendarDate(value, locale) {
const { year, month, day } = parseDateOnly(value);
const proxy = new Date(0);
proxy.setUTCFullYear(year, month - 1, day);
return new Intl.DateTimeFormat(locale, {
calendar: "gregory", timeZone: "UTC",
year: "numeric", month: "long", day: "numeric",
}).format(proxy);
}
function formatInstant(milliseconds, locale, timeZone) {
if (!Number.isSafeInteger(milliseconds) || Number.isNaN(new Date(milliseconds).getTime())) {
throw new RangeError("expected representable integer epoch milliseconds");
}
return new Intl.DateTimeFormat(locale, {
calendar: "gregory", timeZone,
year: "numeric", month: "long", day: "numeric",
hour: "2-digit", minute: "2-digit", timeZoneName: "short",
}).format(milliseconds);
}
formatCalendarDate chỉ dùng một Date tạm làm cầu nối tới Intl.DateTimeFormat. Cả gán trường ngày lẫn định dạng đều dùng UTC, và hệ lịch được chỉ rõ là Gregory. Đối tượng tạm không trở thành ngày sinh được lưu. setUTCFullYear cũng tránh cách xử lý đặc biệt năm từ 0 tới 99 của constructor nhận số và Date.UTC. Ở đây, 0099-01-01 vẫn thuộc năm 99.
Hàm định dạng thời điểm riêng nhận số nguyên mili giây từ epoch trong khoảng biểu diễn được. Nó không đoán giá trị số từ API có đơn vị giây hay mili giây. Truyền giây vào giao ước mili giây có thể tạo ngày hợp lệ nhưng sai hoàn toàn, nên còn phải kiểm tra đơn vị tại ranh giới API. Locale điều khiển cách trình bày; múi giờ được chỉ định điều khiển cách đọc thời điểm theo giờ địa phương. Intl.DateTimeFormat mô tả các tùy chọn ấy; dấu câu và nhãn múi giờ có thể thay đổi theo dữ liệu locale của môi trường chạy.
So sánh hai ý nghĩa bằng điều khiển HTML
Tạo tài liệu HTML mới. Đặt markup này trong body, CSS tiếp theo trong phần tử style, rồi các hàm trợ giúp và mã khởi tạo trong script nằm sau markup. Ngày sinh là dữ liệu giả; thời điểm phát hành cũng là dữ liệu minh họa cố định. Ví dụ không gửi biểu mẫu hay lưu lên máy chủ.
<section id="date-demo" aria-labelledby="demo-title" data-locale="vi-VN"
data-ready="Sẵn sàng. Ngày sinh giữ nguyên ngày lịch khi đổi múi giờ." data-stopped="Đã dừng. Tải lại để chạy tiếp." data-invalid="Nhập ngày đầy đủ, hợp lệ trong các năm từ 0001 tới 9999.">
<h1 id="demo-title">Giữ ngày sinh, đổi cách hiển thị thời điểm</h1>
<p>Ngày sinh giả và thời điểm phát hành minh họa cố định có hai giao ước khác nhau. Locale đổi cách trình bày; múi giờ được chọn chỉ áp dụng cho thời điểm phát hành.</p>
<fieldset disabled>
<legend>Điều khiển hiển thị ngày</legend>
<label for="birthday">Ngày sinh (bắt buộc)</label>
<input id="birthday" type="date" value="2000-02-29" required
min="0001-01-01" max="9999-12-31" aria-describedby="date-help date-error">
<p id="date-help">Chọn ngày Gregory đầy đủ trong các năm từ 0001 tới 9999.</p>
<p id="date-error"></p>
<label for="display-locale">Locale dùng định dạng</label>
<select id="display-locale"><option value="en-US">English (US)</option><option value="vi-VN">Tiếng Việt</option></select>
<label for="display-zone">Múi giờ phát hành</label>
<select id="display-zone">
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="UTC">UTC</option>
<option value="Asia/Ho_Chi_Minh">Asia/Ho_Chi_Minh</option>
<option value="Pacific/Kiritimati">Pacific/Kiritimati</option>
</select>
</fieldset>
<p>Chuỗi ngày sinh: <code id="serialized-date">2000-02-29</code></p>
<p>Ngày sinh được hiển thị: <output id="calendar-output" for="birthday display-locale"></output></p>
<p><code>2026-09-20T00:30:00Z</code></p>
<p>Thời điểm phát hành: <output id="instant-output" for="display-locale display-zone"></output></p>
<p id="demo-state">Ví dụ so sánh này cần JavaScript.</p>
</section>
Ô nhập ngày HTML có thể trình bày giao diện nhập theo ngôn ngữ địa phương nhưng cung cấp giá trị chuẩn hóa. valueAsNumber và valueAsDate cung cấp cách đổi sang timestamp; việc có các thuộc tính ấy không biến ngày sinh thành sự kiện lúc nửa đêm UTC. Trong ví dụ này, hãy đọc chuỗi value. Ô cũng có giới hạn rõ ràng vì ứng dụng nhận phạm vi nhỏ hơn cú pháp ngày HTML.
* { box-sizing: border-box; }
body { margin: 1rem; font: 1rem/1.6 system-ui, sans-serif; }
#date-demo { max-inline-size: 40rem; overflow-wrap: anywhere; }
fieldset { min-inline-size: 0; margin: 0; }
label { display: block; margin-block-start: .75rem; }
input, select { inline-size: 100%; min-inline-size: 0; font: inherit; }
output, code { overflow-wrap: anywhere; }
:focus-visible { outline: 3px solid #315b32; outline-offset: 3px; }
function mountDateDemo() {
const root = document.querySelector("#date-demo");
const fields = root.querySelector("fieldset");
const birthday = root.querySelector("#birthday");
const locale = root.querySelector("#display-locale");
const zone = root.querySelector("#display-zone");
const error = root.querySelector("#date-error");
const release = Date.parse("2026-09-20T00:30:00Z");
locale.value = root.dataset.locale;
function render() {
root.querySelector("#instant-output").textContent = formatInstant(release, locale.value, zone.value);
root.querySelector("#serialized-date").textContent = birthday.value || "—";
try {
if (!birthday.validity.valid) throw new RangeError("invalid input");
root.querySelector("#calendar-output").textContent = formatCalendarDate(birthday.value, locale.value);
birthday.removeAttribute("aria-invalid");
error.textContent = "";
} catch {
root.querySelector("#calendar-output").textContent = "—";
birthday.setAttribute("aria-invalid", "true");
error.textContent = root.dataset.invalid;
}
}
birthday.addEventListener("input", render);
locale.addEventListener("change", render);
zone.addEventListener("change", render);
fields.disabled = false;
root.querySelector("#demo-state").textContent = root.dataset.ready;
render();
return () => {
birthday.removeEventListener("input", render);
locale.removeEventListener("change", render);
zone.removeEventListener("change", render);
fields.disabled = true;
root.querySelector("#demo-state").textContent = root.dataset.stopped;
};
}
const disposeDateDemo = mountDateDemo();
Đổi múi giờ phát hành từ Los Angeles sang UTC. Thời điểm phát hành chuyển từ buổi tối địa phương ngày 19 tháng 9 sang rạng sáng ngày 20 tháng 9; thời điểm được lưu không hề di chuyển. Chuỗi ngày sinh và ngày lịch vẫn giữ nguyên. Đổi locale định dạng sẽ đổi từ ngữ và thứ tự trình bày của cả hai đầu ra, độc lập với lựa chọn múi giờ ấy.
Các điều khiển ban đầu bị vô hiệu hóa để ví dụ chưa khởi tạo không tỏ ra có thể tương tác khi thiếu JavaScript. Mỗi lần render dùng textContent cập nhật nội dung trên các phần tử hiện có và giữ nguyên phần tử input. Ngày trống hay sai tạo lời giải thích nhìn thấy được, có liên kết mô tả với ô nhập; sửa thành ngày hợp lệ sẽ xóa trạng thái sai. Hàm dọn dẹp gỡ đúng các listener và vô hiệu hóa ví dụ. Nó không thay đổi hay xóa dữ liệu máy chủ vì ví dụ không có phần lưu trữ ấy.
Cách nhập ngày và popup chọn tùy chọn khác nhau giữa các trình duyệt. Ở chiều rộng hẹp với chữ phóng lớn, điều khiển native đang đóng có thể chỉ hiện một phần giá trị; chuỗi ngày và đầu ra định dạng tách riêng vẫn giữ đủ thông tin. Hãy kiểm tra trình duyệt và công nghệ hỗ trợ mà sản phẩm cần phục vụ trước khi coi ví dụ nhỏ này là biểu mẫu hoàn chỉnh. Fixture xác lập thao tác bàn phím native trong trình duyệt đã kiểm tra, không xác lập mọi cách tương tác của bộ chọn ngày hay phản hồi bằng giọng nói.
Ảnh chụp Chrome thật của ví dụ nguyên trạng sau khi dùng bàn phím chọn UTC. Ô nhập, chuỗi chuẩn hóa và các đầu ra định dạng cho phép so sánh việc chỉnh sửa, dạng dữ liệu lưu và cách hiển thị mà không suy điều này từ điều kia.
Một ngày lịch không phải lúc nào cũng bằng 86.400.000 mili giây
Ví dụ tiếp theo dùng độ lệch múi giờ rõ ràng của Los Angeles quanh hai lần đổi giờ mùa hè năm 2026. Nó so sánh buổi trưa của hai ngày lịch liên tiếp. Hai cặp ấy cách nhau lần lượt 23 và 25 giờ thực trôi qua:
const springNoons = [
Date.parse("2026-03-07T12:00:00-08:00"),
Date.parse("2026-03-08T12:00:00-07:00"),
];
const autumnNoons = [
Date.parse("2026-10-31T12:00:00-07:00"),
Date.parse("2026-11-01T12:00:00-08:00"),
];
console.log((springNoons[1] - springNoons[0]) / 3600000); // 23
console.log((autumnNoons[1] - autumnNoons[0]) / 3600000); // 25
console.log(formatInstant(springNoons[0] + 86400000, "en-US", "America/Los_Angeles"));
// March 8, 2026 at 01:00 PM PDT in this browser's locale data
Cộng đúng 86.400.000 mili giây vào buổi trưa đầu tiên trong cặp mùa xuân sẽ tới 13 giờ địa phương hôm sau. Khoảng thời gian trôi qua là đúng; ý định về giờ trên đồng hồ sẽ sai nếu người dùng yêu cầu trưa ngày mai. Ngược lại, thao tác theo ngày lịch cục bộ có thể giữ giờ trên đồng hồ nhưng thay đổi khoảng thời gian trôi qua. MDN nêu rõ setDate dùng giờ cục bộ và có thể đi qua lần chuyển giờ như vậy.
Các độ lệch xác định những ví dụ Los Angeles cụ thể này. Sơ đồ không quy định cách lặp lịch hẹn ở nơi khác hay thời gian khác.
Vì vậy cuộc họp lặp “9 giờ sáng mỗi thứ Hai” cần quy tắc lịch và tên múi giờ, cùng chính sách xử lý giờ địa phương không tồn tại hoặc xuất hiện hai lần. Timeout theo thời gian thực trôi qua cần một khoảng thời gian. Chuỗi ngày sinh không biểu diễn đầy đủ điều nào trong hai điều đó; cứ cộng số mili giây của một ngày cũng không cài đặt được cả hai. Quy tắc múi giờ còn có thể thay đổi, nên hãy nêu ứng dụng dùng dữ liệu múi giờ nào khi việc lập lịch tương lai có ý nghĩa quan trọng.
Để tự kiểm chứng, thử 1900-02-29, 2000-02-29, 0099-01-01 và 9999-12-31 với bộ phân tích. Trường hợp đầu phải bị từ chối; các trường hợp còn lại phải giữ nguyên trường ngày. Sau đó đổi múi giờ của trình duyệt nhưng giữ các tùy chọn tường minh của hàm định dạng: ngày sinh phải giữ ngày lịch, còn chuỗi có ngày giờ nhưng thiếu độ lệch có thể xác định thời điểm khác. Cuối cùng, so sánh setDate(getDate() + 1) theo giờ cục bộ với cộng 24 giờ qua hai lần chuyển giờ minh họa. Ghi rõ yêu cầu đang nói về trường lịch, lịch hẹn địa phương hay thời gian trôi qua trước khi chọn cách biểu diễn.
