Tại sao tôi tách biệt `variant` khỏi `intent` trong thiết kế API Component
Hầu hết các thư viện component đều gặp vấn đề thuộc tính `variant` trở nên quá phức tạp và khó quản lý khi kết hợp nhiều phong cách. Bài viết trình bày cách giải quyết bằng cách tách biệt `variant` (trọng lượng thị giác) và `intent` (ý nghĩa ngữ nghĩa) để tạo ra hệ thống linh hoạt, dễ mở rộng và khám phá hơn cho các nhà phát triển.

Mọi thư viện thành phần (component library) thường đều khởi đầu theo một cách giống hệt nhau. Bạn thêm một Button (Nút bấm). Nó cần kiểu primary và kiểu danger, nên bạn sử dụng một prop variant. Đơn giản enough.
Sau đó, ai đó cần một nút ghost (trong suốt) mà cũng phải báo hiệu danger. Bạn thêm variant="ghost-danger". Rồi đến outline-success, link-warning, secondary-danger. Mỗi sự kết hợp mới lúc đầu đều có vẻ hợp lý. Nhưng sáu tháng sau, bạn sở hữu một prop chấp nhận tới 17 chuỗi ký tự khác nhau, mà người dùng sẽ không bao giờ phát hiện ra một nửa trong số đó vì hệ thống kiểu (type system) không chỉ dẫn họ đến đó.
Đây không phải là vấn đề công cụ. Đây là vấn đề mô hình hóa (modeling problem). Prop variant đang làm hai công việc không liên quan đến nhau, và việc gộp chúng vào một chỗ chính là nguyên nhân gây ra sự "bùng nổ" này.
Hai yếu tố độc lập
Khi nhìn vào những gì variant thực sự mã hóa trong hầu hết các API Button, nó đang mang hai tín hiệu riêng biệt:
Trọng lượng thị giác (Visual weight): Mức độ chú ý mà thành phần đòi hỏi. Nút Primary rất nổi bật. Nút Ghost thì nhẹ nhàng. Outline nằm ở giữa. Đây là quyết định về trình bày (presentation).
Ý nghĩa ngữ nghĩa (Semantic meaning): Hành động đó truyền tải điều gì. Danger nghĩa là phá hủy. Success là xác nhận. Warning là hãy thận trọng. Đây là quyết định về giao tiếp.
Đây là hai trục độc lập (orthogonal axes). Một nút Ghost có thể mang tính nguy hiểm. Một nút Outline có thể xác nhận thành công. Không có mối quan hệ cố hữu giữa việc một nút trông nổi bật thế nào và ý nghĩa của nó là gì. Ép hai tín hiệu này vào một prop duy nhất tạo ra một sự liên kết sai lầm, vốn trở nên đắt đỏ hơn với mỗi biến thể bạn thêm vào.
Giải pháp là dành cho mỗi mối quan tâm một prop riêng biệt.
Sự tách biệt
Trong nuka-ui, thư viện thành phần React mã nguồn mở của tôi được xây dựng trên Tailwind v4, API tách biệt hai yếu tố này thành hai props độc lập:
variantkiểm soát trọng lượng thị giác:primary,secondary,outline,ghost,link.intentkiểm soát ý nghĩa ngữ nghĩa:default,danger,success,warning.
Mọi sự kết hợp đều hợp lệ. API người dùng sẽ trông như sau:
<Button variant="primary" intent="default" />
<Button variant="ghost" intent="danger" />
<Button variant="outline" intent="success" />
<Button variant="secondary" intent="warning" />
Hãy so sánh với cách tiếp cận phẳng (flat approach) truyền thống:
<Button variant="ghost-danger" />
<Button variant="outline-success" />
<Button variant="secondary-warning" />
Cách tiếp cận phẳng không khó đọc. Vấn đề nằm ở khả năng khám phá (discoverability) và khả năng mở rộng. Người dùng khi nhìn vào kiểu của variant không có cách nào biết sự kết hợp nào tồn tại, sự kết hợp nào là có chủ đích, và sự kết hợp nào bạn chưa kịp xây dựng. Mô hình hai prop làm cho không gian tổng thể trở nên rõ ràng và đồng nhất. Mọi biến thể đều hoạt động với mọi intent. Không có khoảng trống nào.
CVA xử lý giao điểm như thế nào
Việc tách biệt các props không làm cho việc định kiểu trở nên đơn giản hơn. Nó làm cho việc đó trở nên trung thực hơn. Bạn vẫn cần định nghĩa mỗi sự kết hợp trông như thế nào. Công việc đó được thực hiện thông qua compoundVariants của CVA (Class Variance Authority):
const buttonVariants = cva(baseClasses, {
variants: {
variant: {
primary: [],
secondary: [],
outline: [],
ghost: [],
link: [],
},
intent: {
default: "",
danger: "",
success: "",
warning: "",
},
},
compoundVariants: [
{
variant: "primary",
intent: "default",
className: [
"bg-[var(--nuka-accent-bg)]",
"text-[var(--nuka-text-inverse)]",
"hover:bg-[var(--nuka-accent-bg-hover)]",
],
},
{
variant: "primary",
intent: "danger",
className: [
"bg-[var(--nuka-danger-base)]",
"text-[var(--nuka-text-inverse)]",
"hover:brightness-90",
],
},
{
variant: "ghost",
intent: "danger",
className: [
"text-[var(--nuka-danger-text)]",
"hover:bg-[var(--nuka-danger-bg)]",
],
},
// một mục cho mỗi tổ hợp variant x intent
],
defaultVariants: {
variant: "primary",
intent: "default",
size: "md",
},
})
5 biến thể nhân với 4 intent cho bạn 20 mục compound variant chỉ cho riêng Button. Bạn viết tất cả 20 mục một cách rõ ràng. Đó là cái giá phải trả cho một API hoàn chỉnh và có chủ đích, và đó là chi phí viết một lần. Sau khi được viết, giao điểm được bao phủ hoàn toàn. Việc thêm trường hợp sử dụng cho người dùng không yêu cầu thay đổi thư viện, chỉ cần truyền hai props bạn đã có sẵn.
TypeScript thực thi hợp đồng này tại thời điểm biên dịch (compile time):
interface ButtonProps extends ButtonVariantProps {
variant?: "primary" | "secondary" | "outline" | "ghost" | "link"
intent?: "default" | "danger" | "success" | "warning"
}
Các giá trị prop không hợp lệ sẽ không thể vượt qua quá trình build. Hệ thống kiểu phản ánh bề mặt API thực tế, thay vì một tai nạn lịch sử về cách các biến thể được tích lũy.
Lợi ích khi mở rộng quy mô
Mô hình này cộng hưởng trên toàn bộ thư viện. Trong nuka-ui, nó được áp dụng cho Button, Alert, Badge, Tag, Code, Input và Checkbox. Mỗi thành phần định nghĩa các trục variant và intent của riêng mình và xử lý các giao điểm thông qua các biến thể phức hợp (compound variants). Mô hình tư duy nhất quán ở khắp mọi nơi. Người dùng hiểu cách Button hoạt động sẽ hiểu cách Alert hoạt động.
Việc thêm một intent mới, ví dụ như info, yêu cầu thêm N mục compound variant cho mỗi thành phần, trong đó N là số lượng biến thể của thành phần đó. Nó tốn nhiều công hơn là thêm một chuỗi biến thể phẳng đơn lẻ, nhưng phạm vi bị giới hạn và có thể dự đoán được. Bạn biết chính xác những gì cần làm, và bạn không thể vô tình bỏ sót một sự kết hợp vì lưới (grid) đã được khai báo rõ ràng.
Sự đánh đổi và khi không nên áp dụng
Sự dài dòng là có thật. Hai mươi mục compound variant cho mỗi thành phần là rất nhiều dòng code. Nếu thư viện của bạn nhỏ và không gian biến thể ổn định, cách tiếp cận phẳng sẽ ít chi phí hơn và có lẽ là đủ tốt. Mô hình này chứng minh giá trị của nó ở quy mô lớn, khi phương án thay thế là một prop variant với một chuỗi các chuỗi ký tự (string union) không ngừng lớn lên mà không ai có thể nhớ hết.
Người dùng cũng phải hiểu hai props thay vì một. Đối với hầu hết các kỹ sư cao cấp (senior engineers), điều này không thành vấn đề. Sự tách biệt trở nên trực giác một khi được đặt tên. Đối với một thư viện component hướng tới người dùng ít kinh nghiệm hơn, khái niệm bổ sung có thể cần đầu tư nhiều hơn vào tài liệu hướng dẫn.
Mô hình này cũng không áp dụng phổ quát. Ví dụ, Banner trong nuka-ui chỉ dùng intent. Không có prop variant vì Banner chỉ có một trọng lượng thị giác. Áp dụng toàn bộ lưới này cho một thành phần chỉ có một chế độ trình bày sẽ là sự áp dụng máy móc, không phải thiết kế. Câu hỏi cần đặt ra là thành phần đó thực sự có các trục trọng lượng thị giác và ngữ nghĩa độc lập hay không. Nếu không, hãy dùng mô hình đơn giản hơn.
Hai phương án thay thế đáng được nhắc tên cụ thể, vì tôi đã cân nhắc cả hai trước khi đến đây:
Biến thể phẳng (Flat variants) đơn giản để triển khai ban đầu. Vấn đề bùng nổ chỉ trở nên đau đớn khi thư viện phát triển, chính là lúc bạn ít có khả năng tái cấu trúc (refactor) API nhất.
Thuộc tính dữ liệu CSS (CSS data attributes) như data-intent="danger" giúp giảm bề mặt prop nhưng mất đi an toàn kiểu (type safety) của TypeScript và làm API trở nên ngầm định. Cách tiếp cận props rõ ràng và thân thiện với công cụ hơn.
Xem nó trong thực tế
Toàn bộ triển khai có trong kho lưu trữ nuka-ui tại https://github.com/ku5ic/nuka-ui. Storybook trực tiếp tại https://ku5ic.github.io/nuka-ui cho thấy mọi sự kết hợp variant và intent trên tất cả các thành phần. Nếu bạn muốn theo dõi khi thư viện tiến đến bản xuất bản đầu tiên trên npm, hãy gắn sao (star) cho repo là cách dễ nhất để cập nhật tiến độ.
Hầu hết các vấn đề API component thực chất là vấn đề mô hình hóa ngụy trang. Vấn đề này chỉ tình cờ dễ thấy một khi bạn biết chỗ nào cần nhìn.
Bài viết liên quan

Công nghệ
Dairy Queen tích hợp chatbot AI vào hệ thống drive-thru để tăng tốc độ phục vụ
17 tháng 4, 2026

Công nghệ
Nhà Trắng gặp gỡ Anthropic: Thảo luận về mô hình AI Mythos và rủi ro an ninh mạng
17 tháng 4, 2026

Công nghệ
Cursor đàm phán huy động hơn 2 tỷ USD với định giá 50 tỷ USD khi tăng trưởng doanh nghiệp bùng nổ
17 tháng 4, 2026
