Chapter 08

组件设计模式

@apply 的合理使用、shadcn/ui 复制粘贴组件库,以及完整的表单/弹窗/下拉实现

1. @apply:提取组件 vs 不提取

@apply 允许在 CSS 中复用工具类,将重复的类名组合提取为自定义类。但它存在明显权衡——使用前务必思考清楚

适合 @apply 的场景

  • 跨多个 HTML 文件重复的组合
  • 第三方 HTML(不能改 class)
  • Markdown 生成的内容

不适合 @apply 的场景

  • 在 React/Vue 中可以提取组件
  • 只用了一两次的样式
  • 团队约定用 utility-first 风格
/* ⚠️ 权衡:@apply 让 HTML 更干净,但失去了 utility-first 的灵活性 */

@layer components {
  .btn {
    @apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-semibold
           transition-all duration-200 focus:outline-none focus-visible:ring-2;
  }
  .btn-primary {
    @apply bg-cyan-500 text-white hover:bg-cyan-600 active:bg-cyan-700
           focus-visible:ring-cyan-500 focus-visible:ring-offset-2;
  }
  .btn-outline {
    @apply border-2 border-cyan-500 text-cyan-600 hover:bg-cyan-50
           dark:hover:bg-cyan-950;
  }
  .input {
    @apply w-full px-3 py-2 rounded-lg border border-gray-300
           focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500
           dark:bg-gray-800 dark:border-gray-600 dark:text-white
           outline-none transition-shadow;
  }
}
<!-- 使用:HTML 更简洁 -->
<button class="btn btn-primary">提交</button>
<button class="btn btn-outline">取消</button>
<input class="input" placeholder="请输入">

2. shadcn/ui:复制粘贴组件库

shadcn/ui 是目前最流行的 Tailwind 组件解决方案,它不是传统 npm 包,而是"复制粘贴"模式——你把组件源码直接复制到项目中,完全拥有所有权,可以随意修改。

ℹ️

Headless UI:只提供行为(可访问性、键盘导航、ARIA 属性),不提供样式的 UI 库。代表作有 Headless UI(Tailwind Labs 出品)和 Radix UI。shadcn/ui 就是在 Radix UI 基础上加了 Tailwind 样式。

# 安装 shadcn/ui(以 Next.js 项目为例)
npx shadcn@latest init

# 添加需要的组件(会复制源码到 src/components/ui/)
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
npx shadcn@latest add form input

3. 完整表单组件实现

<!-- 完整的联系表单 -->
<form class="space-y-6 bg-white dark:bg-gray-900 rounded-2xl p-8 max-w-lg">
  <h2 class="text-2xl font-bold text-gray-900 dark:text-white">联系我们</h2>

  <!-- 双列:姓名 + 邮箱 -->
  <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
    <div>
      <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
        姓名 <span class="text-red-500">*</span>
      </label>
      <input
        type="text"
        class="
          peer w-full px-3 py-2 rounded-lg text-sm
          border border-gray-300 dark:border-gray-600
          bg-white dark:bg-gray-800 text-gray-900 dark:text-white
          placeholder:text-gray-400
          focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent
          invalid:border-red-400 invalid:focus:ring-red-400
          transition-shadow
        "
        placeholder="张三"
        required
      >
      <p class="hidden peer-invalid:block text-xs text-red-500 mt-1">请填写姓名</p>
    </div>
    <div>
      <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">邮箱</label>
      <input type="email" class="w-full px-3 py-2 rounded-lg text-sm border border-gray-300
                                      focus:outline-none focus:ring-2 focus:ring-cyan-500
                                      dark:bg-gray-800 dark:border-gray-600 dark:text-white"
             placeholder="user@example.com">
    </div>
  </div>

  <!-- Select 下拉 -->
  <div>
    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">问题类型</label>
    <select class="w-full px-3 py-2 rounded-lg text-sm border border-gray-300
                           focus:outline-none focus:ring-2 focus:ring-cyan-500
                           dark:bg-gray-800 dark:border-gray-600 dark:text-white bg-white">
      <option>技术问题</option>
      <option>账单问题</option>
      <option>产品反馈</option>
    </select>
  </div>

  <!-- Textarea -->
  <div>
    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">详细描述</label>
    <textarea rows="4" class="w-full px-3 py-2 rounded-lg text-sm border border-gray-300
                                    focus:outline-none focus:ring-2 focus:ring-cyan-500
                                    dark:bg-gray-800 dark:border-gray-600 dark:text-white
                                    resize-none"
              placeholder="请详细描述您的问题..."></textarea>
  </div>

  <!-- 提交按钮 -->
  <button type="submit"
          class="w-full bg-cyan-500 hover:bg-cyan-600 active:bg-cyan-700
                         text-white font-semibold py-3 rounded-xl
                         transition-colors duration-200
                         focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500">
    发送消息
  </button>
</form>

4. Modal 弹窗组件

<!-- 弹窗遮罩 + 内容 -->
<div id="modal" class="fixed inset-0 z-50 flex items-center justify-center p-4
                          bg-black/50 backdrop-blur-sm
                          hidden data-[open]:flex">
  <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl
                    w-full max-w-md max-h-[90vh] overflow-y-auto
                    animate-fade-up">
    <!-- 弹窗头部 -->
    <div class="flex items-center justify-between px-6 py-4
                      border-b border-gray-200 dark:border-gray-700">
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">确认操作</h3>
      <button onclick="closeModal()"
              class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
                             p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">✕</button>
    </div>
    <!-- 弹窗内容 -->
    <div class="px-6 py-4">
      <p class="text-gray-600 dark:text-gray-400">你确定要执行这个操作吗?</p>
    </div>
    <!-- 弹窗底部 -->
    <div class="flex gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
      <button class="flex-1 px-4 py-2 rounded-lg border border-gray-300
                             text-gray-700 hover:bg-gray-50 font-medium">取消</button>
      <button class="flex-1 px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600
                             text-white font-medium">确认删除</button>
    </div>
  </div>
</div>

本章小结:在 React/Vue 项目中,优先通过提取组件来复用样式,而非 @apply。shadcn/ui 是目前最符合 Tailwind 精神的组件库——"复制粘贴"模式让你完全掌控代码,不受黑盒限制。表单和弹窗是最常用的组件,掌握这两个模式可以覆盖 80% 的 UI 需求。