Debug VS Code bằng breakpoint thay cho console.log
Cách debug VS Code bằng breakpoint, breakpoint có điều kiện, Watch, Debug Console và logpoint, qua một bug thật khiến đơn hàng 70.000đ chỉ còn 70 đồng.
Vì sao nên debug VS Code bằng breakpoint thay cho console.log? Breakpoint dừng chương trình ở đúng một dòng và cho bạn xem giá trị thật, kiểu thật của mọi biến. Còn console.log chỉ in thứ bạn đoán trước là cần in, và dễ gây hiểu lầm. Cùng mình truy một con số sai bằng cả hai cách nhé!
Đơn hàng 70 đồng
Ở bài trước, bạn đã đọc stack trace để sửa lỗi trang đơn hàng của quán Mộc bị sập. Trang chạy lại, nhưng trong kết quả trả về có một dòng rất lạ: đơn 1044 của chị Lê Chi gồm hai ly cà phê muối, mà tổng tiền chỉ là 70 đồng.
Lần này không có chữ đỏ nào. Trang trả về mã 200, không có stack trace, không có gì chỉ đường. Chương trình chạy trơn tru và cho ra một con số sai.
Dựng lại hệ thống đơn hàng trên máy bạn
Nếu bạn chưa đọc bài trước thì cũng không sao, vì hệ thống chỉ có ba file. Mộc là quán minh họa, còn code thì chạy thật với Node.js 22.18 trở lên, bản Node đầu tiên chạy thẳng được file TypeScript mà không cần cài thêm gì. Bạn tạo một thư mục như sau, rồi mở nó bằng VS Code:
don-hang-moc/
data/orders.json # danh sách đơn hàng
src/orders.ts # các hàm xử lý đơn: tên khách, tổng tiền, danh sách
src/server.ts # máy chủ trả danh sách đơn ở http://localhost:3000/don-hangFile đầu tiên chứa các hàm xử lý đơn. Bạn để ý hàm orderTotal ở dòng 17 đến 19, vì đó là hàm tính tổng tiền và cũng là nơi cả bài xoay quanh:
export type Customer = { name: string; phone: string };
export type OrderItem = { name: string; price: number; quantity: number };
export type Order = {
id: number;
createdAt: string;
source: "web" | "may-cu";
// Đơn từ máy bán hàng cũ (source "may-cu") bán tại quầy có thể không có khách hàng.
customer?: Customer;
items: OrderItem[];
};
export function customerLabel(order: Order): string {
if (!order.customer) return "KHÁCH TẠI QUẦY";
return order.customer.name.toUpperCase();
}
export function orderTotal(order: Order): number {
return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
export function listOrders(orders: Order[]) {
return orders.map((order) => ({
id: order.id,
khach: customerLabel(order),
tong: orderTotal(order),
}));
}File thứ hai là máy chủ. Nó đọc file JSON một lần lúc khởi động, rồi mỗi khi có người mở /don-hang thì trả về mã đơn, tên khách và tổng tiền của từng đơn:
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { listOrders, type Order } from "./orders.ts";
const dataFile = new URL("../data/orders.json", import.meta.url);
const orders: Order[] = JSON.parse(readFileSync(dataFile, "utf8"));
export const server = createServer((req, res) => {
if (req.url === "/don-hang") {
try {
const body = JSON.stringify(listOrders(orders), null, 2);
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(body);
} catch (error) {
console.error(error);
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Lỗi máy chủ");
}
return;
}
res.writeHead(404).end();
});
const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`Đơn hàng Mộc: http://localhost:${port}/don-hang`));File cuối là dữ liệu, bốn đơn hàng của một buổi sáng. Hai đơn đầu đặt qua website, hai đơn sau nhập từ máy bán hàng cũ ở quầy. Bạn chép nguyên file vào, nhưng thử đừng soi kỹ vội. Hãy giả vờ file này có năm nghìn đơn và bạn không thể đọc hết bằng mắt:
[
{
"id": 1041,
"createdAt": "2026-11-03T08:12:00+07:00",
"source": "web",
"customer": { "name": "Nguyễn An", "phone": "0901000001" },
"items": [
{ "name": "Cà phê sữa", "price": 29000, "quantity": 2 },
{ "name": "Bánh mì trứng", "price": 25000, "quantity": 1 }
]
},
{
"id": 1042,
"createdAt": "2026-11-03T09:40:00+07:00",
"source": "web",
"customer": { "name": "Trần Bình", "phone": "0901000002" },
"items": [{ "name": "Bạc xỉu", "price": 32000, "quantity": 1 }]
},
{
"id": 1043,
"createdAt": "2026-11-03T10:05:00+07:00",
"source": "may-cu",
"items": [{ "name": "Cà phê đen", "price": 25000, "quantity": 1 }]
},
{
"id": 1044,
"createdAt": "2026-11-03T10:30:00+07:00",
"source": "may-cu",
"customer": { "name": "Lê Chi", "phone": "0901000004" },
"items": [{ "name": "Cà phê muối", "price": "35.000", "quantity": 2 }]
}
]Bạn chạy máy chủ bằng node src/server.ts, rồi mở terminal thứ hai và gọi curl http://localhost:3000/don-hang. Kết quả cho đơn 1044 sẽ là:
{
"id": 1044,
"khach": "LÊ CHI",
"tong": 70
}Theo menu của quán, một ly cà phê muối giá 35.000đ, hai ly phải là 70.000đ. Hàm orderTotal chỉ lấy giá nhân số lượng rồi cộng dồn, không có gì để sai cả, vậy mà vẫn sai.
Thử console.log trước đã
Phản xạ đầu tiên của hầu hết chúng ta là chèn một dòng console.log vào giữa hàm. Mình cũng làm vậy, để bạn thấy nó dẫn tới đâu. Bạn thay hàm orderTotal trong src/orders.ts bằng bản dưới đây. Hàm vẫn tính như cũ, chỉ thêm một dòng in ra mã đơn, tên món, giá và số lượng của từng món trước khi cộng:
export function orderTotal(order: Order): number {
return order.items.reduce((sum, item) => {
console.log(order.id, item.name, item.price, item.quantity);
return sum + item.price * item.quantity;
}, 0);
}Bạn dừng máy chủ (Ctrl+C), chạy lại node src/server.ts, gọi curl như lúc nãy, và terminal của máy chủ in ra một dòng cho mỗi món:
Đơn hàng Mộc: http://localhost:3000/don-hang
1041 Cà phê sữa 29000 2
1041 Bánh mì trứng 25000 1
1042 Bạc xỉu 32000 1
1043 Cà phê đen 25000 1
1044 Cà phê muối 35.000 2Bạn nhìn dòng cuối xem có thấy gì bất thường không? Mình nhìn lần đầu thì không. Giá 35.000, số lượng 2, hoàn toàn đúng với một ly cà phê muối. Với mắt người Việt, 35.000 chính là ba mươi lăm nghìn, vì chúng ta quen dùng dấu chấm để ngăn hàng nghìn.
Đây là cái bẫy của console.log. Khi in nhiều giá trị cùng lúc, Node.js in chuỗi y như số, không có dấu nháy. Số 35000 và chuỗi "35.000" hiện ra gần như giống hệt nhau, chỉ khác một dấu chấm mà mắt ta tự động đọc thành dấu ngăn hàng nghìn. Terminal có màu thì Node thường tô số bằng màu khác với chuỗi, nhưng phải tinh mắt lắm mới để ý.
Thêm nữa, log này in ra cả năm món của bốn đơn, trong khi mình chỉ quan tâm một đơn. Với file dữ liệu thật vài nghìn đơn, dòng cần tìm sẽ chìm giữa hàng nghìn dòng khác. Và sau khi tìm ra lỗi, bạn còn phải nhớ xóa dòng log, nếu không nó sẽ lặng lẽ đi vào commit.
Mình xóa dòng log, đưa code về như cũ, và thử cách khác.
Đặt breakpoint đầu tiên
Breakpoint là một điểm dừng. Khi chương trình chạy tới dòng có breakpoint, nó tạm dừng lại, và bạn được nhìn vào bên trong: biến nào đang có giá trị gì, hàm nào đã gọi hàm này, dữ liệu đi từ đâu tới.
Trong VS Code, bạn đặt breakpoint bằng cách bấm vào khoảng trống bên trái số dòng. Một chấm đỏ hiện ra. Mình đặt nó ở dòng 18 của src/orders.ts, dòng return order.items.reduce(...) bên trong orderTotal. Nếu bạn vừa thêm console.log ở phần trên, hãy đưa hàm về bản gốc trước, để số dòng khớp với bài.
Tiếp theo, chương trình phải chạy dưới sự theo dõi của trình gỡ lỗi (debugger). Cách đơn giản nhất mà mình hay dùng là JavaScript Debug Terminal. Bạn mở Command Palette bằng Ctrl+Shift+P, gõ "Debug: Create JavaScript Debug Terminal" và chọn. VS Code mở một terminal mới, và theo tài liệu Node.js debugging của VS Code, mọi tiến trình Node.js chạy trong terminal này đều tự động được gỡ lỗi. Bạn chạy máy chủ như bình thường:
node src/server.tsRồi mở một terminal thường khác và gọi trang:
curl http://localhost:3000/don-hangLệnh curl đứng chờ, không trả về gì. Đó là dấu hiệu tốt. Chương trình đã dừng ở dòng 18, và trong VS Code, dòng đó được tô vàng.
Nếu bạn thích bấm F5 hơn, hãy tạo file .vscode/launch.json như sau. Demo của bài có sẵn file này:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Chạy máy chủ đơn hàng",
"program": "${workspaceFolder}/src/server.ts",
"skipFiles": ["<node_internals>/**"]
}
]
}Dòng skipFiles bảo debugger bỏ qua code nội bộ của Node.js khi bạn chạy từng bước. Thiếu dòng này, bấm "đi vào hàm" vài lần là bạn lạc vào các file node:events và node:_http_server, đúng những dòng bạn đã học cách gạch bỏ khi đọc stack trace.
Dừng đúng đơn 1044 bằng breakpoint có điều kiện
Có một vấn đề nhỏ. Hàm orderTotal được gọi cho cả bốn đơn, theo thứ tự 1041, 1042, 1043, 1044. Breakpoint thường sẽ dừng ở đơn 1041 trước, và bạn phải bấm Continue (F5) ba lần mới tới đơn cần xem. Với bốn đơn thì chịu được, với bốn nghìn đơn thì không.
Bạn bấm chuột phải vào chấm đỏ, chọn Edit Breakpoint, chọn loại Expression, rồi gõ điều kiện:
order.id === 1044Chấm đỏ đổi hình để báo đây là breakpoint có điều kiện. Từ giờ chương trình chỉ dừng khi điều kiện đúng. Tài liệu Debug code with Visual Studio Code liệt kê thêm hai loại nữa: dừng sau một số lần chạy qua (hit count), và chỉ bật khi một breakpoint khác đã dừng. Loại biểu thức là loại mình dùng nhiều nhất, vì lỗi thường chỉ xảy ra với một bản ghi cụ thể.
Đọc giá trị trong Variables, Watch và Debug Console
Khi chương trình dừng ở đơn 1044, bạn mở khung Run and Debug bên trái. Phần VARIABLES liệt kê các biến của hàm đang dừng. Bạn mở order, rồi mở items, rồi mở phần tử đầu tiên, và thấy:
order
id: 1044
source: 'may-cu'
items: Array(1)
0:
name: 'Cà phê muối'
price: '35.000'
quantity: 2Lần này price có dấu nháy, còn quantity thì không. Giá là một chuỗi, không phải một con số. Thứ mà console.log giấu đi, debugger hiện ra ngay ở lần nhìn đầu tiên. Nếu muốn chắc chắn hơn, bạn thêm một biểu thức vào khung WATCH, khung này tính lại biểu thức mỗi khi chương trình dừng:
typeof order.items[0].price: 'string'Câu hỏi tiếp theo là vì sao một chuỗi lại cho ra 70. Bạn mở Debug Console bằng Ctrl+Shift+Y. Đây là nơi bạn gõ biểu thức JavaScript và nó được tính ngay trong ngữ cảnh của chỗ đang dừng. Mình gõ thử mấy phép tính, và kết quả bên dưới khớp với những gì mình chạy lại bằng Node.js:
> order.items[0].price * order.items[0].quantity
70
> Number("35.000")
35
> "35.000" + 0
'35.0000'Đến đây thì mọi thứ đã rõ. Khi gặp phép nhân, JavaScript tự đổi chuỗi sang số. Nhưng nó đọc chuỗi theo quy ước tiếng Anh, nơi dấu chấm là dấu thập phân, nên "35.000" trở thành 35 chứ không phải 35.000. Nhân với 2 ra 70.
Dòng thứ ba là một cái bẫy khác cùng họ: với phép cộng, JavaScript làm ngược lại, đổi số thành chuỗi rồi nối vào nhau. Cơ chế tự đổi kiểu này được MDN giải thích trong mục type coercion.
Còn một câu hỏi nữa: chuỗi này từ đâu ra? Khung CALL STACK cho biết ai đã gọi orderTotal. Bấm vào dòng listOrders, rồi dòng trong server.ts, bạn thấy biến orders được tạo bằng JSON.parse từ file data/orders.json. Mở file đó ra, đơn 1044 có "source": "may-cu" và "price": "35.000". Máy bán hàng cũ của quán ghi giá dưới dạng chữ, có dấu chấm ngăn hàng nghìn, còn website thì ghi giá dưới dạng số.
Tức là bạn đã tìm ra nguyên nhân mà không sửa một dòng code nào, và không có dòng log nào phải nhớ xóa.
Logpoint: in log mà không sửa code
Đôi khi bạn không muốn chương trình dừng lại, ví dụ khi máy chủ đang phục vụ nhiều yêu cầu, hoặc khi chỉ muốn theo dõi một giá trị qua nhiều lần chạy. Lúc đó bạn dùng logpoint. Bấm chuột phải vào lề trái, chọn Add Logpoint, rồi gõ nội dung, đặt biểu thức trong ngoặc nhọn:
đơn {order.id}: giá đầu tiên = {JSON.stringify(order.items[0].price)}Logpoint in ra Debug Console mỗi lần chạy qua dòng đó, nhưng không dừng chương trình và không nằm trong file code. Tắt debugger là nó biến mất, nên không có chuyện lỡ commit.
Bạn để ý mình bọc giá trị trong JSON.stringify. Đó là mẹo rút ra từ chính lỗi hôm nay: JSON.stringify luôn in chuỗi kèm dấu nháy kép, nên "35.000" và 35000 không bao giờ trông giống nhau nữa. Mẹo này dùng được cả với console.log thường. Nếu buộc phải log, hãy log { price } hoặc JSON.stringify(price) thay vì price trần trụi.
Sửa ở đâu cho đúng?
Biết nguyên nhân rồi, cách sửa nhanh nhất mà ai cũng nghĩ tới là ép giá về số ngay trong orderTotal, bằng Number():
export function orderTotal(order: Order): number {
return order.items.reduce((sum, item) => sum + Number(item.price) * item.quantity, 0);
}Mình chạy thử trên đơn 1044. Number() cho ra tổng 70. Đổi sang parseInt() cũng ra 70. Cả hai đều đọc "35.000" thành 35, đúng như những gì Debug Console vừa cho thấy. Sửa kiểu này không những không hết lỗi, mà còn khiến người đọc code sau tưởng rằng lỗi đã được xử lý.
Bài học từ bài trước vẫn đúng ở đây: đừng vá ở chỗ lỗi lộ ra, hãy sửa ở chỗ dữ liệu sai đi vào. Dữ liệu đi vào hệ thống tại đúng một chỗ, là lúc server.ts đọc file JSON. Mình thêm một bước chuẩn hóa ngay tại đó, gồm hai việc.
Việc thứ nhất là thêm một kiểu và hai hàm vào src/orders.ts, đặt ngay dưới kiểu Order. Hàm parsePrice đổi một giá bất kỳ trong file thành số đồng. Hàm normalizeOrder chạy parsePrice cho mọi món của một đơn. Hàm orderTotal giữ nguyên bản gốc, không cần Number():
// Dữ liệu đọc từ file, chưa kiểm tra. Máy bán hàng cũ ghi giá dạng chuỗi có dấu chấm ngăn hàng nghìn ("35.000").
export type RawOrder = Omit<Order, "items"> & {
items: { name: string; price: number | string; quantity: number }[];
};
// "35.000" → 35000. Định dạng lạ thì báo lỗi ngay, không đoán.
export function parsePrice(raw: number | string): number {
if (typeof raw === "number") return raw;
const text = raw.trim();
if (/^\d{1,3}(\.\d{3})+$/.test(text)) return Number(text.replaceAll(".", ""));
if (/^\d+$/.test(text)) return Number(text);
throw new Error(`Giá không hợp lệ: "${raw}"`);
}
export function normalizeOrder(raw: RawOrder): Order {
return {
...raw,
items: raw.items.map((item) => ({ ...item, price: parsePrice(item.price) })),
};
}Việc thứ hai là cho server.ts gọi normalizeOrder ngay sau khi đọc file. Chỉ có dòng import và hai dòng đọc dữ liệu thay đổi, phần còn lại giữ nguyên:
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { listOrders, normalizeOrder, type RawOrder } from "./orders.ts";
const dataFile = new URL("../data/orders.json", import.meta.url);
// Chuẩn hóa ngay khi đọc file: từ đây trở đi, giá luôn là số.
const rawOrders: RawOrder[] = JSON.parse(readFileSync(dataFile, "utf8"));
const orders = rawOrders.map(normalizeOrder);
export const server = createServer((req, res) => {
if (req.url === "/don-hang") {
try {
const body = JSON.stringify(listOrders(orders), null, 2);
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(body);
} catch (error) {
console.error(error);
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Lỗi máy chủ");
}
return;
}
res.writeHead(404).end();
});
const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`Đơn hàng Mộc: http://localhost:${port}/don-hang`));Kiểu RawOrder nói thật về dữ liệu trong file: giá có thể là số hoặc chuỗi. Kiểu Order nói về dữ liệu sau khi đã kiểm tra: giá luôn là số. Mọi hàm phía sau, kể cả orderTotal, chỉ làm việc với Order, nên không hàm nào phải lo chuyện chuỗi nữa. Cách làm này giống hệt dấu ? ở bài trước, tức là để kiểu dữ liệu mô tả đúng thực tế, rồi để TypeScript canh gác giúp mình.
Phần đáng bàn nhất là dòng throw. Để thấy hàm xử lý từng kiểu giá ra sao, mình viết một file thử nhỏ, gọi parsePrice với vài giá trị và in kết quả hoặc thông báo lỗi:
import { parsePrice } from "./orders.ts";
for (const value of ["35.000", 35000, "35000", "1.250.000", "35,000", "35.5", "abc"]) {
try {
console.log(JSON.stringify(value), "→", parsePrice(value));
} catch (error) {
console.log(JSON.stringify(value), "→", (error as Error).message);
}
}"35.000" → 35000
35000 → 35000
"35000" → 35000
"1.250.000" → 1250000
"35,000" → Giá không hợp lệ: "35,000"
"35.5" → Giá không hợp lệ: "35.5"
"abc" → Giá không hợp lệ: "abc"Vì sao "35,000" lại bị từ chối? Vì mình không biết máy cũ có bao giờ ghi kiểu đó không, và nếu có thì nó nghĩa là ba mươi lăm nghìn (kiểu Mỹ) hay ba mươi lăm phẩy không (kiểu châu Âu). Đoán sai thì lại ra một tổng tiền sai trong im lặng, đúng loại lỗi vừa tốn của mình cả buổi. Một lỗi kêu to ngay lúc đọc dữ liệu dễ sửa hơn nhiều so với một con số sai nằm lặng lẽ trong báo cáo doanh thu cuối tháng.
Chạy lại máy chủ với code mới:
{
"id": 1044,
"khach": "LÊ CHI",
"tong": 70000
}Hai ly cà phê muối, 70.000đ. Ba đơn còn lại giữ nguyên tổng tiền như trước.
Khi nào console.log vẫn hợp lý?
Mình không nghĩ bạn nên bỏ hẳn console.log. Với một đoạn script nhỏ chạy một lần, in ra một giá trị là nhanh nhất. Với lỗi liên quan đến thời gian, như hai yêu cầu chạy song song tranh nhau ghi dữ liệu, việc dừng chương trình ở breakpoint có thể làm thay đổi thứ tự chạy và khiến lỗi biến mất. Lúc đó log, hoặc logpoint, cho bạn thấy đúng thứ tự thật. Còn trên máy chủ production, nơi bạn không thể gắn debugger, log là thứ duy nhất bạn có, và các bài viết sau của series sẽ nói về cách ghi log sao cho có ích.
Nhưng với câu hỏi "giá trị này lúc chạy thật sự là gì", breakpoint gần như luôn nhanh hơn. Bạn thấy kiểu dữ liệu, thấy cả object, thấy ai đã gọi hàm, và hỏi thêm bất cứ điều gì trong Debug Console mà không phải chạy lại chương trình. Nếu đã thử breakpoint mà vẫn kẹt, bạn có thể chụp lại khung Variables và Call Stack khi đi hỏi người khác. Bài ba câu tự hỏi trước khi nhờ senior giúp có cách chuẩn bị câu hỏi để được trả lời nhanh.
Sửa xong rồi, làm sao để lỗi không quay lại?
Bạn báo cho chủ quán rằng đơn 1044 đã đúng. Chủ quán hỏi lại một câu rất hợp lý: "Lần sau máy cũ gửi dữ liệu kiểu đó nữa thì sao? Lỡ ai sửa code làm hỏng lại thì sao?"
Hiện tại, bằng chứng duy nhất cho thấy lỗi đã được sửa là một lần bạn gọi curl và nhìn bằng mắt. Không có gì ngăn người khác, hoặc chính bạn vài tháng sau, xóa bước chuẩn hóa vì nghĩ nó thừa. Cần có một thứ tự động kiểm tra parsePrice và tổng tiền đơn 1044 mỗi lần code thay đổi. Đó là test, và bài viết tiếp theo của series sẽ viết test đầu tiên bằng Vitest để khóa lỗi này lại. Bạn theo dõi cả câu chuyện ở series Nhận dự án cũ, còn câu hỏi phỏng vấn về breakpoint và console.log có trong ngân hàng câu hỏi phỏng vấn.