Tại sao tôi dành nhiều năm để giải quyết vấn đề tính dự đoán trong trạng thái CSS

23 tháng 4, 2026·6 phút đọc

Bài viết chia sẻ hành trình phát triển công cụ Tasty nhằm giải quyết sự phức tạp khi quản lý các trạng thái CSS như hover, active hay disabled. Thay vì dựa vào thứ tự nguồn và độ đặc hiệu, Tasty sử dụng bộ chọn loại trừ lẫn nhau để đảm bảo hành vi của component luôn nhất quán và dễ bảo trì.

Tại sao tôi dành nhiều năm để giải quyết vấn đề tính dự đoán trong trạng thái CSS

Tại sao tôi dành nhiều năm để giải quyết vấn đề tính dự đoán trong trạng thái CSS

Bạn đã bao giờ thay đổi thứ tự của hai quy tắc CSS và làm hỏng một component mà không hề thay đổi logic của nó chưa? Hãy xem xét ví dụ kinh điển về một nút bấm (button):

.btn:hover     { background: dodgerblue; }
.btn[disabled] { background: gray; }

Cả hai bộ chọn (selector) này đều có độ đặc hiệu (specificity) như nhau. Khi một nút bấm vừa bị hover (rê chuột) vừa bị disabled (vô hiệu hóa), trình duyệt sẽ dựa vào thứ tự nguồn (source order) để quyết định áp dụng style nào. Nếu quy tắc :hover nằm cuối, nút bị vô hiệu hóa sẽ chuyển sang màu xanh. Nếu quy tắc [disabled] nằm cuối, nó sẽ giữ màu xám.

Vấn đề này có vẻ nhỏ nhặt, nhưng nó chỉ ra một vấn đề lớn hơn: trạng thái của component trong CSS thường hoạt động dựa trên sự chồng chéo (overlap).

Gánh nặng của sự chồng chéo

Khi một component chỉ có một hoặc hai trạng thái, sự chồng chéo này có vẻ dễ quản lý. Tuy nhiên, khi bạn bắt đầu thêm vào :hover, :active, disabled, dark mode (chế độ tối), breakpoints (điểm ngắt), data attributes, container queries và các lớp phủ (overrides), mọi thứ trở nên hỗn loạn rất nhanh. Lúc này, bạn không còn chỉ viết style nữa, bạn đang duy trì một hệ thống giải quyết xung đột trong đầu mình.

Điều này không chỉ dẫn đến các xung đột ngẫu nhiên mà còn gây khó khăn ngày càng tăng trong việc tùy chỉnh các component hiện có một cách an toàn khi các yêu cầu thực tế bắt đầu chất đống.

Đó chính là vấn đề tôi liên tục gặp phải khi xây dựng các hệ thống component. Phần khó khăn nhất không phải là viết phiên bản đầu tiên của một component, mà là mở rộng nó sau này mà không phải mở lại toàn bộ vấn đề giải quyết trạng thái.

Thay đổi tư duy: Từ viết selector đến khai báo trạng thái

Tại một thời điểm, tôi ngừng hỏi "Làm thế nào để viết bộ chọn này?" và bắt đầu đặt ra một câu hỏi tốt hơn:

Nếu trạng thái của component có thể được biểu diễn theo cách khai báo (declarative), trong khi trình biên dịch xử lý logic bộ chọn cần thiết để làm cho nó xác định (deterministic) thì sao?

Câu hỏi đó cuối cùng đã trở thành Tasty.

Ý tưởng cốt lõi của Tasty

Thay vì viết các bộ chọn cạnh tranh nhau thông qua cascade (sự xếp chồng) và specificity, tôi muốn mô tả các trạng thái có thể có của một thuộc tính dưới dạng một bản đồ (map):

const Button = tasty({
  styles: {
    fill: {
      '': '#primary',
      ':hover': '#primary-hover',
      ':active': '#primary-pressed',
      '[disabled]': '#surface',
    },
  },
});

Được áp dụng theo thứ tự ưu tiên, điều này có nghĩa là:

  • Khi bị vô hiệu hóa, dùng màu #surface.
  • Nếu không, khi đang active (nhấn), dùng màu #primary-pressed.
  • Nếu không, khi đang hover, dùng màu #primary-hover.
  • Nếu không, dùng màu #primary.

Phần quan trọng nhất là những gì xảy ra tiếp theo. Tasty biên dịch bản đồ trạng thái này thành các bộ chọn không thể chồng chéo lên nhau:

/* [disabled] thắng tuyệt đối */
.t0[disabled]                                { background: var(--surface-color); }

/* :active bị loại trừ khi disabled */
.t0:active:not([disabled])                   { background: var(--primary-pressed-color); }

/* :hover bị loại trừ khi :active hoặc disabled */
.t0:hover:not(:active):not([disabled])       { background: var(--primary-hover-color); }

/* Mặc định bị loại trừ khi bất kỳ cái nào ở trên khớp */
.t0:not(:hover):not(:active):not([disabled]) { background: var(--primary-color); }

Bây giờ, không còn tranh chấp nào cho cascade cần phải giải quyết. Không có hai nhánh nào có thể khớp tại cùng một thời điểm.

Tại sao điều này quan trọng?

Một nút bị hover và bị disabled chỉ là cách dễ nhất để thấy vấn đề. Nỗi đau thực sự bắt đầu khi các trạng thái giao nhau theo những cách ít rõ ràng hơn.

Có thể dark mode đến từ một thuộc tính gốc, hoặc từ prefers-color-scheme, hoặc từ cả hai. Có thể khoảng cách thay đổi bên trong một vùng chứa hẹp, nhưng chỉ ở chiều rộng máy tính bảng. Có thể một biến thể destructive (nguy hiểm) hoạt động khác khi hover nhưng không khi đang tải. Mỗi quy tắc trong số này đều dễ hiểu khi đứng riêng lẻ. Phần khó khăn là bề mặt tương tác giữa chúng.

Tôi muốn một mô hình trong đó việc thêm một trạng thái mới không có nghĩa là phải suy nghĩ lại toàn bộ ma trận bộ chọn trong đầu.

Hành trình phát triển

Ý tưởng cốt lõi thì đơn giản, nhưng biến nó thành một công cụ thực tế là phần khó khăn. Việc đi từ "hoạt động cho các điều kiện trạng thái đơn giản" đến "có thể hỗ trợ các hệ thống component thực tế" đã mất nhiều năm và hàng trăm lần lặp lại.

Phần khó khăn không bao giờ là tạo ra một bộ chọn thông minh. Phần khó khăn là xây dựng một hệ thống vẫn giữ được sự mạch lạc khi tất cả các yếu tố như pseudo-classes, thuộc tính, media queries, container queries và các bộ chọn lồng nhau xuất hiện cùng lúc.

Một phần khó khăn mang tính kỹ thuật (phân tích, chuẩn hóa, tạo bộ chọn, caching). Một phần khác mang tính khái niệm: Tôi phải liên tục quyết định Tasty thực sự là gì. Một định dạng đối tượng CSS đẹp hơn? Một trình tạo CSS nguyên tử? Một ngôn ngữ hệ thống thiết kế? Trong thực tế, nó liên tục trở thành tất cả những thứ đó cùng một lúc.

Kết luận

Tôi không nghĩ "các bộ chọn loại trừ lẫn nhau" thú vị vì chúng thông minh. Tôi nghĩ chúng thú vị vì chúng loại bỏ một loại sự mơ hồ mà tác giả không nên phải lo lắng từ đầu.

Khi tôi style một component, tôi muốn mô tả nó trông như thế nào trong mỗi trạng thái có ý nghĩa. Tôi không muốn mã hóa thủ công logic tie-breaker (giải quyết hòa) của trình duyệt mỗi khi các trạng thái đó giao nhau.

Đó là mục tiêu mà Tasty hướng tới: hành vi component có thể dự đoán trước, ít lỗi hồi quy hơn từ thứ tự nguồn, và dễ dàng mở rộng các component hiện có. Nếu bạn đang xây dựng các component cần tồn tại qua nhiều năm sửa đổi, tính dự đoán bắt đầu mang lại giá trị thực tế rất lớn.

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗