Tìm hiểu cách định dạng và chuyển đổi thời gian trong JavaScript mà không gặp bất ngờ: timestamp, chuỗi ISO, múi giờ, DST, quy tắc phân tích và các mẫu đáng tin cậy.

Các lỗi liên quan thời gian trong JavaScript hiếm khi rõ ràng như “đồng hồ sai.” Thay vào đó chúng xuất hiện dưới dạng những dịch chuyển nhỏ gây bối rối: một ngày đúng trên máy của bạn nhưng sai trên máy đồng nghiệp, một phản hồi API trông ổn cho tới khi hiển thị ở múi giờ khác, hoặc một báo cáo “lép vế một đơn vị” quanh thay đổi mùa.
Bạn thường sẽ nhận ra một (hoặc nhiều) trong số này:
+02:00) không phải thứ bạn mong đợi.Một nguồn đau đầu lớn là từ việc từ thời gian có thể ám chỉ nhiều khái niệm khác nhau:
Date tích hợp của JavaScript cố gắng bao phủ tất cả, nhưng nó chủ yếu biểu diễn một instant trong khi liên tục thúc bạn về hiển thị cục bộ, khiến việc chuyển đổi vô ý trở nên dễ dàng.
Hướng dẫn này thực tế: làm sao để có chuyển đổi dự đoán được giữa trình duyệt và server, chọn định dạng an toàn hơn (như ISO 8601), và nhận diện những bẫy kinh điển (giây vs mili giây, UTC vs local, và khác biệt khi phân tích). Mục tiêu không phải lý thuyết nhiều hơn—mà là giảm những bất ngờ kiểu “tại sao lại dịch?”
Lỗi thời gian trong JavaScript thường bắt đầu bằng việc trộn các biểu diễn trông giống nhau nhưng không phải.
1) Epoch milliseconds (number)
Một số thuần như 1735689600000 thường là “mili giây kể từ 1970-01-01T00:00:00Z”. Nó biểu diễn một instant mà không kèm định dạng hay múi giờ.
2) Đối tượng Date (wrapper quanh một instant)
Một Date lưu cùng loại instant như một timestamp. Phần gây nhầm: khi bạn in một Date, JavaScript định dạng nó theo quy tắc cục bộ của môi trường trừ khi bạn yêu cầu khác.
3) Chuỗi định dạng (dành cho người đọc)
Chuỗi như "2025-01-01", "01/01/2025 10:00", hoặc "2025-01-01T00:00:00Z" không phải một thứ duy nhất. Một số rõ ràng (ISO 8601 với Z), số khác phụ thuộc ngôn ngữ, và một vài chuỗi không bao gồm múi giờ.
Một instant có thể hiển thị khác nhau theo múi giờ:
const instant = new Date("2025-01-01T00:00:00Z");
instant.toLocaleString("en-US", { timeZone: "UTC" });
// "1/1/2025, 12:00:00 AM"
instant.toLocaleString("en-US", { timeZone: "America/Los_Angeles" });
// "12/31/2024, 4:00:00 PM" (previous day)
Chọn một biểu diễn nội bộ duy nhất (thường là epoch milliseconds hoặc UTC ISO 8601) và giữ nhất quán trên toàn app và API. Chỉ chuyển đổi đến/đi Date và chuỗi định dạng ở biên: khi nhận input và khi hiển thị UI.
“Timestamp” thường nghĩa là epoch time (còn gọi Unix time): số đơn vị kể từ 1970-01-01 00:00:00 UTC. Khó khăn: các hệ thống khác nhau dùng đơn vị khác nhau.
Date của JavaScript là nguồn gây nhầm vì nó dùng mili giây. Nhiều API, cơ sở dữ liệu, và log dùng giây.
17040672001704067200000Cùng một thời điểm, nhưng phiên bản mili có ba chữ số 0 thêm vào.
Dùng phép nhân/chia rõ ràng để đơn vị hiển nhiên:
// seconds -> Date
const seconds = 1704067200;
const d1 = new Date(seconds * 1000);
// milliseconds -> Date
const ms = 1704067200000;
const d2 = new Date(ms);
// Date -> seconds
const secondsOut = Math.floor(d2.getTime() / 1000);
// Date -> milliseconds
const msOut = d2.getTime();
Date()Trông có vẻ hợp lý nhưng sai khi ts ở đơn vị giây:
const ts = 1704067200; // seconds
const d = new Date(ts); // WRONG: treated as milliseconds
Kết quả sẽ là một ngày ở 1970, vì 1,704,067,200 mili giây chỉ khoảng 19 ngày sau epoch.
Khi không chắc đơn vị, thêm các bảo vệ nhanh:
function asDateFromUnknownEpoch(x) {
// heuristic thô: giây ~1e9-1e10, mili giây ~1e12-1e13
if (x < 1e11) return new Date(x * 1000); // giả sử là giây
return new Date(x); // giả sử là mili giây
}
const input = Number(valueFromApi);
console.log({ input, digits: String(Math.trunc(input)).length });
console.log('as ISO:', asDateFromUnknownEpoch(input).toISOString());
Nếu số chữ số ~10 thì có lẽ là giây. Nếu ~13 thì là mili giây. In toISOString() khi debug: nó rõ ràng và giúp bạn phát hiện sai đơn vị ngay.
Date của JavaScript có thể gây rối vì nó lưu một instant duy nhất, nhưng có thể hiển thị instant đó ở các múi giờ khác nhau.
Nội bộ, Date về cơ bản là “mili giây kể từ epoch Unix (1970-01-01T00:00:00Z)”. Con số đó biểu diễn một thời điểm UTC. “Sự dịch” xảy ra khi bạn yêu cầu JavaScript định dạng thời điểm đó theo thời gian cục bộ (dựa trên cài đặt máy) so với UTC.
Nhiều API Date có biến thể cục bộ và UTC. Chúng trả về các giá trị khác nhau cho cùng một instant:
const d = new Date('2025-01-01T00:30:00Z');
d.getHours(); // giờ theo múi giờ *cục bộ*
d.getUTCHours(); // giờ theo UTC
d.toString(); // chuỗi thời gian local
d.toISOString(); // UTC (luôn kết thúc với Z)
Nếu máy bạn ở New York (UTC-5), thời gian UTC đó có thể hiển thị là “19:30” vào ngày trước đó ở local. Trên server đặt về UTC, nó sẽ xuất là “00:30”. Cùng một instant, hiển thị khác nhau.
Log thường dùng Date#toString() hoặc nội suy Date một cách ngầm định, và đó là chuỗi theo múi giờ môi trường. Điều này khiến cùng mã có thể in các timestamp khác nhau trên laptop, CI và production.
Lưu và truyền thời gian dưới dạng UTC (ví dụ epoch mili giây hoặc ISO 8601 với Z). Chuyển đổi sang múi giờ người dùng chỉ khi hiển thị:
toISOString() hoặc gửi epoch mili giâyIntl.DateTimeFormatNếu bạn phát triển nhanh (ví dụ với workflow tạo ứng dụng trong Koder.ai), nên gắn quy ước này vào contract API từ đầu: đặt tên trường rõ ràng (createdAtMs, createdAtIso) và giữ server (Go + PostgreSQL) và client (React) đồng ý về ý nghĩa mỗi trường.
Nếu cần chuyển ngày/giờ giữa trình duyệt, server và DB, chuỗi ISO 8601 là mặc định an toàn. Chúng rõ ràng, được hỗ trợ rộng rãi, và (quan trọng nhất) mang theo thông tin múi giờ.
Hai định dạng trao đổi tốt:
2025-03-04T12:30:00Z2025-03-04T12:30:00+02:00Z nghĩa là gì?
Z là viết tắt của Zulu time, tên khác của UTC. Vậy 2025-03-04T12:30:00Z là “12:30 theo UTC”.
Khi nào offset như +02:00 quan trọng?
Offset quan trọng khi một sự kiện gắn với bối cảnh múi giờ địa phương (hẹn giờ, đặt chỗ, giờ mở cửa). 2025-03-04T12:30:00+02:00 mô tả một khoảnh khắc sớm hơn 2 giờ so với UTC, và không cùng instant với 2025-03-04T12:30:00Z.
Chuỗi như 03/04/2025 là cạm bẫy: là 4 tháng 3 hay 3 tháng 4? Người dùng và môi trường khác nhau hiểu khác nhau. Ưu tiên 2025-03-04 (ISO date) hoặc full ISO datetime.
const iso = "2025-03-04T12:30:00Z";
const d = new Date(iso);
const back = d.toISOString();
console.log(iso); // 2025-03-04T12:30:00Z
console.log(back); // 2025-03-04T12:30:00.000Z
Hành vi “round-trip” này là thứ bạn muốn cho API: nhất quán, có thể dự đoán và nhận biết múi giờ.
Date.parse() có vẻ tiện: đưa chuỗi, nhận timestamp. Vấn đề là với những chuỗi không rõ ràng theo ISO 8601, việc phân tích phụ thuộc heuristic của trình duyệt. Những heuristic này khác nhau giữa engine và phiên bản, nghĩa là cùng một input có thể parse khác (hoặc không parse) tùy nơi chạy mã.
Date.parse() khác nhauJavaScript chỉ chuẩn hóa việc parse đáng tin cậy cho chuỗi dạng ISO 8601 (và ngay cả khi đó, chi tiết như múi giờ vẫn quan trọng). Với các định dạng “thân thiện”—như "03/04/2025", "March 4, 2025", hoặc "2025-3-4"—trình duyệt có thể hiểu:
Nếu bạn không thể dự đoán chính xác hình dạng chuỗi, bạn không thể dự đoán kết quả.
YYYY-MM-DDBẫy phổ biến là dạng plain "YYYY-MM-DD" (ví dụ "2025-01-15"). Nhiều dev mong đợi nó được hiểu là đêm khuya local. Thực tế, một số môi trường coi dạng này là đêm khuya UTC.
Sự khác biệt này quan trọng: đêm khuya UTC chuyển sang thời gian cục bộ có thể thành ngày trước ở các múi giờ âm (ví dụ châu Mỹ) hoặc dịch giờ bất ngờ. Đây là cách dễ dàng để có lỗi “tại sao ngày bị lệch một ngày?”.
Với input server/API:
2025-01-15T13:45:00Z hoặc 2025-01-15T13:45:00+02:00."YYYY-MM-DD") và tránh chuyển nó thành Date trừ khi bạn xác định múi giờ.Với input người dùng:
03/04/2025 trừ khi UI ép ý nghĩa.Thay vì trông chờ Date.parse() “tự suy luận”, chọn một trong các mẫu sau:
new Date(year, monthIndex, day) cho ngày local).Khi dữ liệu thời gian quan trọng, “nó parse trên máy tôi” là chưa đủ—hãy làm cho quy tắc parse rõ ràng và nhất quán.
Nếu mục tiêu là “hiển thị ngày/giờ theo cách người dùng mong đợi”, công cụ tốt nhất trong JavaScript là Intl.DateTimeFormat. Nó dùng quy tắc locale của người dùng (thứ tự, dấu phân cách, tên tháng) và tránh cách dựng chuỗi mong manh như month + '/' + day.
Tự format thường cố định kiểu US, quên số 0 ở đầu, hoặc tạo kết quả 24/12 giờ gây nhầm. Intl.DateTimeFormat cũng cho bạn rõ ràng múi giờ nào đang hiển thị—rất quan trọng khi dữ liệu lưu dưới UTC nhưng UI cần phản ánh thời gian cục bộ.
Với “chỉ hiển thị đẹp”, dateStyle và timeStyle là đơn giản nhất:
const d = new Date('2025-01-05T16:30:00Z');
// Locale người dùng + múi giờ local của người dùng
console.log(new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(d));
// Ép một múi giờ cụ thể (tốt cho thời gian sự kiện)
console.log(new Intl.DateTimeFormat('en-GB', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'UTC'
}).format(d));
Nếu bạn cần chu kỳ giờ nhất quán (ví dụ toggle 24/12), dùng hour12:
console.log(new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
}).format(d));
Chọn một hàm định dạng cho mỗi “loại” timestamp trong UI (thời gian tin nhắn, log, bắt đầu sự kiện), và giữ quyết định timeZone có chủ ý:
Cách này cho bạn output nhất quán, thân thiện locale mà không cần duy trì bộ chuỗi format mong manh.
Daylight Saving Time (DST) là khi múi giờ thay đổi offset UTC (thường một giờ) vào ngày nhất định. Phức tạp nằm ở chỗ: DST không chỉ “thay offset”—mà còn thay đổi sự tồn tại của một số thời điểm local.
Khi đồng hồ nhảy lên (spring forward), một khoảng thời gian local không xảy ra. Ví dụ, ở nhiều vùng, đồng hồ nhảy từ 01:59 lên 03:00, nên 02:30 local là “không tồn tại.”
Khi đồng hồ quay lại (fall back), một khoảng local xảy ra hai lần. Ví dụ, 01:30 có thể xuất hiện một lần trước chuyển và một lần sau, nghĩa là cùng thời gian đồng hồ có thể ám chỉ hai instant khác nhau.
Hai việc này không tương đương quanh ranh DST:
Nếu DST bắt đầu tối nay, “ngày mai 9:00” có thể chỉ cách bạn 23 giờ. Nếu DST kết thúc tối nay, có thể là 25 giờ.
// Kịch bản: lên lịch “cùng giờ local ngày mai”
const d = new Date(2025, 2, 8, 9, 0); // Mar 8, 9:00 local
const plus24h = new Date(d.getTime() + 24 * 60 * 60 * 1000);
const nextDaySameLocal = new Date(d);
nextDaySameLocal.setDate(d.getDate() + 1);
// Quanh DST, plus24h và nextDaySameLocal có thể khác nhau 1 giờ.
setHours gây bất ngờNếu bạn làm date.setHours(2, 30, 0, 0) vào ngày “spring forward”, JavaScript có thể điều chỉnh nó thành thời gian hợp lệ khác (thường là 03:30), vì 02:30 không tồn tại local.
setDate) thay vì cộng mili giây.Z để instant không mơ hồ.Một nguồn lỗi phổ biến là dùng Date để biểu diễn thứ không phải khoảnh khắc lịch.
Một timestamp trả lời “khi nào nó xảy ra?” (một instant cụ thể như 2025-12-23T10:00:00Z). Một duration trả lời “kéo dài bao lâu?” (như “3 phút 12 giây”). Hai khái niệm khác nhau, và trộn chúng dẫn đến toán học rối và hiệu ứng múi giờ/DST bất ngờ.
Date không phù hợp cho durationDate luôn biểu diễn một điểm trên timeline so với epoch. Nếu bạn lưu “90 giây” dưới dạng Date, bạn thực ra lưu “1970-01-01 cộng 90 giây” theo một múi giờ. Khi format, nó có thể hiện 01:01:30, dịch 1 giờ, hoặc chọn một ngày bạn không mong.
Với duration, ưu tiên số thuần:
HH:mm:ssMột formatter đơn giản cho countdown hoặc độ dài media:
function formatHMS(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
formatHMS(75); // "00:01:15" (countdown timer)
formatHMS(5423); // "01:30:23" (media duration)
Nếu bạn chuyển từ phút, nhân trước (minutes * 60) và giữ giá trị là số cho tới khi render.
Khi so sánh thời gian trong JavaScript, cách an toàn nhất là so sánh số, không phải văn bản đã định dạng. Một Date về cơ bản là wrapper quanh số epoch mili giây, nên bạn muốn so sánh thành “số vs số”.
Dùng getTime() (hoặc Date.valueOf()) để so sánh đáng tin cậy:
const a = new Date('2025-01-10T12:00:00Z');
const b = new Date('2025-01-10T12:00:01Z');
if (a.getTime() < b.getTime()) {
// a is earlier
}
// Cũng được:
if (+a < +b) {
// unary + gọi valueOf()
}
Tránh so sánh chuỗi đã format như "1/10/2025, 12:00 PM"—chúng phụ thuộc locale và không sắp đúng. Ngoại lệ chính là chuỗi ISO 8601 cùng định dạng và cùng múi giờ (ví dụ tất cả ...Z), vốn có thể so sánh theo thứ tự từ điển.
Sắp xếp theo thời gian dễ nếu bạn sắp theo epoch mili giây:
items.sort((x, y) => new Date(x.createdAt).getTime() - new Date(y.createdAt).getTime());
Lọc phần tử trong một phạm vi tương tự:
const start = new Date('2025-01-01T00:00:00Z').getTime();
const end = new Date('2025-02-01T00:00:00Z').getTime();
const inRange = items.filter(i => {
const t = new Date(i.createdAt).getTime();
return t >= start && t < end;
});
“Bắt đầu ngày” phụ thuộc bạn muốn là local hay UTC:
// Bắt đầu/kết thúc ngày local
const d = new Date(2025, 0, 10); // Jan 10 trong local
const localStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
const localEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
// Bắt đầu/kết thúc ngày UTC
const utcStart = new Date(Date.UTC(2025, 0, 10, 0, 0, 0, 0));
const utcEnd = new Date(Date.UTC(2025, 0, 10, 23, 59, 59, 999));
Chọn một định nghĩa sớm và giữ nguyên xuyên suốt logic so sánh và phạm vi.
Lỗi thời gian có vẻ ngẫu nhiên cho đến khi bạn xác định bạn có gì (timestamp? chuỗi? Date?) và nơi xảy ra dịch (parse, chuyển múi giờ, định dạng).
Bắt đầu bằng việc log cùng một giá trị theo ba cách khác nhau. Điều này nhanh chóng lộ ra bạn đang thiếu giây hay mili, local hay UTC, hay lỗi parse.
console.log('raw input:', input);
const d = new Date(input);
console.log('toISOString (UTC):', d.toISOString());
console.log('toString (local):', d.toString());
console.log('timezone offset (min):', d.getTimezoneOffset());
Cần nhìn gì:
toISOString() sai lạc lớn (ví dụ năm 1970 hoặc tương lai xa), nghi ngờ giây vs mili giây.toISOString() đúng nhưng toString() “dịch”, bạn đang thấy hiển thị múi giờ local.getTimezoneOffset() thay đổi theo ngày, bạn đang qua DST.Nhiều báo cáo “chạy được trên máy tôi” chỉ là mặc định môi trường khác nhau.
console.log(Intl.DateTimeFormat().resolvedOptions());
console.log('TZ:', process.env.TZ);
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);
Nếu server chạy UTC nhưng laptop bạn chạy local, output đã format sẽ khác trừ khi bạn chỉ định timeZone rõ ràng.
Viết unit test quanh biên DST và các thời điểm “edge”:
Nếu bạn lặp nhanh, cân nhắc đưa các test này vào scaffold. Ví dụ, khi tạo app React + Go trong Koder.ai, bạn có thể thêm bộ test “hợp đồng thời gian” nhỏ ngay từ đầu (ví dụ payload API + assert parse/format) để bắt regressions trước khi deploy.
"2025-03-02 10:00".locale và (khi cần) timeZone.Xử lý thời gian đáng tin phần lớn dựa vào chọn một “nguồn chân lý” và giữ nhất quán từ lưu trữ đến hiển thị.
Lưu và tính toán bằng UTC. Xem thời gian local của người dùng là chi tiết trình bày.
Truyền ngày giữa hệ thống bằng chuỗi ISO 8601 kèm offset rõ (ưu tiên Z). Nếu gửi epoch số, ghi chú đơn vị và giữ nhất quán (mili giây là mặc định phổ biến trong JS).
Dùng Intl.DateTimeFormat (hoặc toLocaleString) để format cho người dùng, và truyền timeZone rõ khi cần output xác định (ví dụ luôn hiển thị UTC hoặc vùng kinh doanh cụ thể).
Z (ví dụ 2025-12-23T10:15:00Z). Nếu dùng epoch, đặt tên trường rõ như createdAtMs để đơn vị hiển nhiên.Cân nhắc một thư viện chuyên cho date-time nếu bạn cần sự kiện định kỳ, luật múi giờ phức tạp, toán DST-safe (“cùng giờ local ngày mai”), hoặc nhiều parse từ input không nhất quán. Giá trị nằm ở API rõ ràng hơn và ít edge-case hơn.
Nếu bạn muốn đi sâu, tham khảo thêm các hướng dẫn liên quan đến thời gian trong blog. Nếu bạn đang đánh giá công cụ hoặc tùy chọn hỗ trợ, xem phần định giá.