Viết code mà người khác đọc được
Nội dung bài viết

Code thông minh thì viết ra sướng tay nhưng bảo trì thì cực khổ. Tôi nhận ra điều này rõ nhất khi chuyển từ làm một mình sang dẫn dắt một đội: những đoạn code tôi từng tự hào lại chính là những đoạn khiến người khác phải hỏi tôi mỗi khi cần sửa.

Dưới đây là những gì tôi hướng tới thay vì sự thông minh.

Đặt tên cho tốt

Tên tốt xóa bỏ nhu cầu viết chú thích. Nếu một hàm cần cả đoạn văn để giải thích thì thường nó cần một cái tên tốt hơn hoặc một phạm vi nhỏ hơn.

// Phải đọc code mới hiểu nó làm gì
function process(d) {
  return d.filter(x => x.s === 1 && x.t > Date.now());
}

// Tên nói rõ ý định
function getActiveUpcomingBookings(bookings) {
  return bookings.filter(
    booking => booking.status === STATUS.CONFIRMED && booking.checkInAt > Date.now()
  );
}

Vài quy tắc tôi áp dụng:

  • Hàm là động từ, biến là danh từ. calculateTotal() chứ không phải total().
  • Hàm trả về boolean bắt đầu bằng is, has, can. isExpired, hasPermission, canCancel.
  • Đừng viết tắt, trừ những từ cả ngành đều hiểu như id, url, api. Bạn tiết kiệm được vài ký tự khi gõ và bắt người đọc trả giá mãi mãi.
  • Độ dài tên nên tỉ lệ với phạm vi sống của nó. Biến i trong một vòng lặp ba dòng thì ổn. Biến d sống suốt một hàm 50 dòng thì không.

Một dấu hiệu rõ ràng: nếu tên hàm có chữ “and” thì hàm đó đang làm hai việc.

Giữ hàm nhỏ và ở cùng một mức trừu tượng

Vấn đề không hẳn nằm ở số dòng mà ở việc trộn lẫn các mức trừu tượng. Đoạn code dưới đây có 15 dòng nhưng đọc rất mệt vì nó vừa nói chuyện nghiệp vụ vừa nói chuyện kỹ thuật chi tiết:

async function createBooking(input) {
  if (!input.roomId) throw new Error("Thiếu roomId");
  if (!input.checkIn) throw new Error("Thiếu checkIn");

  const conn = await pool.getConnection();
  const [rows] = await conn.query(
    "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
    [input.roomId]
  );
  if (!rows.length) throw new Error("Không tìm thấy phòng");

  const nights = Math.ceil((input.checkOut - input.checkIn) / 86400000);
  const total = rows[0].price * nights;
  // ... còn tiếp
}

Tách ra thì mỗi tầng đọc được độc lập:

async function createBooking(input) {
  validateBookingInput(input);

  const room = await findAvailableRoom(input.roomId, input.checkIn, input.checkOut);
  const total = calculateTotal(room.price, input.checkIn, input.checkOut);

  return saveBooking({ ...input, total });
}

Hàm ngoài cùng giờ đọc như mô tả quy trình nghiệp vụ. Ai cần biết chi tiết thì đi sâu vào từng hàm con, còn ai chỉ muốn nắm luồng chính thì đọc bốn dòng là đủ.

Làm cho trường hợp phổ biến trở nên hiển nhiên

Hãy tối ưu đường đọc code cho 90% trường hợp thường gặp, đẩy các trường hợp biên ra ngoài rìa.

Kỹ thuật hữu ích nhất ở đây là early return. So sánh hai cách viết:

// Logic chính bị chôn sâu trong các tầng if lồng nhau
function cancelBooking(booking, user) {
  if (booking) {
    if (booking.status === "confirmed") {
      if (user.id === booking.userId || user.role === "admin") {
        if (booking.checkInAt > Date.now()) {
          return doCancel(booking);
        } else {
          throw new Error("Đã quá hạn hủy");
        }
      } else {
        throw new Error("Không có quyền");
      }
    } else {
      throw new Error("Trạng thái không hợp lệ");
    }
  } else {
    throw new Error("Không tìm thấy");
  }
}

// Xử lý hết trường hợp bất thường trước, logic chính nằm ở cuối, không thụt lề
function cancelBooking(booking, user) {
  if (!booking) throw new Error("Không tìm thấy");
  if (booking.status !== "confirmed") throw new Error("Trạng thái không hợp lệ");
  if (!canCancel(user, booking)) throw new Error("Không có quyền");
  if (booking.checkInAt <= Date.now()) throw new Error("Đã quá hạn hủy");

  return doCancel(booking);
}

Cách thứ hai đọc như một danh sách điều kiện, và bạn không phải giữ trong đầu xem mình đang ở nhánh else nào.

Chú thích nên giải thích “vì sao”, không phải “cái gì”

Code đã nói được nó làm gì. Thứ code không nói được là vì sao nó lại như vậy.

// Chú thích vô dụng: lặp lại đúng thứ code đã nói
// Tăng biến đếm lên 1
counter++;

// Chú thích có giá trị: giữ lại bối cảnh sẽ mất đi
// VNPAY trả về mã phản hồi dạng chuỗi, kể cả với giá trị số.
// So sánh bằng == sẽ khớp nhầm "00" với 0, nên bắt buộc dùng ===.
if (response.vnp_ResponseCode === "00") {

Loại chú thích thứ hai là thứ cứu người bảo trì sau này khỏi việc “dọn dẹp” một đoạn code trông có vẻ thừa nhưng thực ra đang xử lý một tình huống thật.

Chú thích tốt nhất tôi từng đọc thường bắt đầu bằng “Đừng đổi thành X vì…”.

Nhất quán quan trọng hơn hoàn hảo

Trong một đội, việc mọi người viết giống nhau có giá trị hơn việc từng người viết theo cách tối ưu của riêng mình.

Đó là lý do tôi thiết lập ESLint và Prettier ngay từ đầu mỗi dự án và cho chúng chạy trong CI. Không phải vì cách định dạng của Prettier là đúng nhất, mà vì nó chấm dứt hoàn toàn các cuộc tranh luận về dấu cách và dấu chấm phẩy, để dành thời gian review cho những thứ thực sự đáng bàn.

Khi bước vào một codebase có sẵn, hãy viết theo phong cách đang có ở đó, kể cả khi bạn thích cách khác. Một file trộn hai phong cách khó đọc hơn một file nhất quán theo phong cách bạn không thích.

Cấu trúc thư mục cũng là code dễ đọc

Người mới vào dự án đọc cây thư mục trước khi đọc bất kỳ dòng code nào. Nhóm theo tính năng nói lên nhiều điều hơn nhóm theo loại file:

components/          features/
  Button.jsx           booking/
  BookingForm.jsx        BookingForm.jsx
  PaymentForm.jsx        api.js
services/              payment/
  booking.js             PaymentForm.jsx
  payment.js             api.js

Cấu trúc bên phải cho biết ứng dụng này làm gì. Cấu trúc bên trái chỉ cho biết nó được viết bằng gì.

Kiểm tra thực tế

Cách đánh giá code dễ đọc hay không mà tôi thấy đáng tin nhất: đưa cho một người trong đội chưa từng làm phần đó và nhờ họ giải thích lại đoạn code làm gì.

Nếu họ phải hỏi bạn, thì đó không phải lỗi của họ.

Code dễ đọc là món quà cho đồng đội, và cho chính bạn của sáu tháng sau.