第 13 章:Tailwind CSS 深度集成

在 Next.js 项目中深度集成 Tailwind CSS——从安装配置、主题定制、Design Token 体系、暗色模式、响应式策略,到自定义插件开发与性能优化。

本章目标:在 Next.js App Router 项目中建立一套完整的 Tailwind CSS 工程体系——涵盖安装配置、Design Token 设计、主题定制、暗色模式、响应式策略、自定义插件,以及与 shadcn/ui 的协作模式。


13.1 为什么选择 Tailwind CSS

CSS 方案对比

方案类型包体积学习曲线与 Next.js 适配
Tailwind CSSUtility-first极小(PurgeCSS)✅ 原生支持
CSS ModulesScoped CSS中等✅ 原生支持
Styled ComponentsCSS-in-JS较大(运行时)⚠️ 需 "use client"
vanilla-extractZero-runtime CSS-in-JS✅ 编译时
Panda CSSZero-runtime Utility✅ 编译时

Tailwind CSS 在 Next.js 中的优势

  1. 零运行时开销:编译时生成 CSS,不增加 JS bundle
  2. 与 RSC 完美兼容:不需要 "use client",Server Component 直接使用
  3. 设计系统一致性:通过 Design Token 统一间距、颜色、字体
  4. JIT 编译器:按需生成,生产包极小
  5. 生态丰富:shadcn/ui、Headless UI、Radix UI 均基于 Tailwind

13.2 安装与配置

通过 create-next-app 安装(推荐)

npx create-next-app@latest my-app --tailwind --typescript --app --eslint

手动安装

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

配置文件

// tailwind.config.ts

import type { Config } from 'tailwindcss';

const config: Config = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './lib/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

export default config;
// postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

在根布局中引入:

// app/layout.tsx

import './globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>{children}</body>
    </html>
  );
}

13.3 Design Token 体系

什么是 Design Token?

Design Token 是设计系统的原子化变量——颜色、字体、间距、圆角等基础值。Tailwind 的 theme.extend 就是 Token 的载体。

完整 Token 配置

// tailwind.config.ts

import type { Config } from 'tailwindcss';

const config: Config = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
  ],

  theme: {
    extend: {
      // ============ 颜色 ============
      colors: {
        // 品牌色
        brand: {
          50: '#eff6ff',
          100: '#dbeafe',
          200: '#bfdbfe',
          300: '#93c5fd',
          400: '#60a5fa',
          500: '#3b82f6',  // 主色
          600: '#2563eb',
          700: '#1d4ed8',
          800: '#1e40af',
          900: '#1e3a8a',
          950: '#172554',
        },

        // 语义色
        success: {
          light: '#d1fae5',
          DEFAULT: '#10b981',
          dark: '#065f46',
        },
        warning: {
          light: '#fef3c7',
          DEFAULT: '#f59e0b',
          dark: '#92400e',
        },
        danger: {
          light: '#fee2e2',
          DEFAULT: '#ef4444',
          dark: '#991b1b',
        },

        // 中性色
        surface: {
          DEFAULT: '#ffffff',
          secondary: '#f9fafb',
          tertiary: '#f3f4f6',
        },
      },

      // ============ 字体 ============
      fontFamily: {
        sans: [
          'Inter',
          'PingFang SC',
          'Microsoft YaHei',
          'sans-serif',
        ],
        mono: [
          'JetBrains Mono',
          'Fira Code',
          'monospace',
        ],
        display: [
          'Cal Sans',
          'Inter',
          'sans-serif',
        ],
      },

      fontSize: {
        '2xs': ['0.625rem', { lineHeight: '0.875rem' }],
        'xs': ['0.75rem', { lineHeight: '1rem' }],
        'sm': ['0.875rem', { lineHeight: '1.25rem' }],
        'base': ['1rem', { lineHeight: '1.5rem' }],
        'lg': ['1.125rem', { lineHeight: '1.75rem' }],
        'xl': ['1.25rem', { lineHeight: '1.75rem' }],
        '2xl': ['1.5rem', { lineHeight: '2rem' }],
        '3xl': ['1.875rem', { lineHeight: '2.25rem' }],
        '4xl': ['2.25rem', { lineHeight: '2.5rem' }],
        '5xl': ['3rem', { lineHeight: '1.16' }],
        '6xl': ['3.75rem', { lineHeight: '1.1' }],
      },

      // ============ 间距 ============
      spacing: {
        '18': '4.5rem',
        '22': '5.5rem',
        '30': '7.5rem',
        '128': '32rem',
        '144': '36rem',
      },

      // ============ 圆角 ============
      borderRadius: {
        '4xl': '2rem',
        '5xl': '2.5rem',
      },

      // ============ 阴影 ============
      boxShadow: {
        'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
        'medium': '0 4px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 30px -5px rgba(0, 0, 0, 0.04)',
        'strong': '0 10px 40px -10px rgba(0, 0, 0, 0.2)',
        'glow': '0 0 20px rgba(59, 130, 246, 0.3)',
      },

      // ============ 动画 ============
      animation: {
        'fade-in': 'fadeIn 0.5s ease-out',
        'slide-up': 'slideUp 0.5s ease-out',
        'slide-down': 'slideDown 0.3s ease-out',
        'scale-in': 'scaleIn 0.2s ease-out',
        'spin-slow': 'spin 3s linear infinite',
        'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
      },

      keyframes: {
        fadeIn: {
          '0%': { opacity: '0' },
          '100%': { opacity: '1' },
        },
        slideUp: {
          '0%': { opacity: '0', transform: 'translateY(20px)' },
          '100%': { opacity: '1', transform: 'translateY(0)' },
        },
        slideDown: {
          '0%': { opacity: '0', transform: 'translateY(-10px)' },
          '100%': { opacity: '1', transform: 'translateY(0)' },
        },
        scaleIn: {
          '0%': { opacity: '0', transform: 'scale(0.95)' },
          '100%': { opacity: '1', transform: 'scale(1)' },
        },
        pulseSoft: {
          '0%, 100%': { opacity: '1' },
          '50%': { opacity: '0.7' },
        },
      },

      // ============ 容器 ============
      containers: {
        '2xs': '16rem',
      },

      // ============ 过渡 ============
      transitionDuration: {
        '400': '400ms',
        '2000': '2000ms',
      },
    },
  },

  plugins: [],
};

export default config;

使用方式

// 品牌色
<h1 className="text-brand-500">Hello</h1>
<button className="bg-brand-600 hover:bg-brand-700 text-white">Submit</button>

// 语义色
<div className="bg-success-light text-success-dark">操作成功</div>
<div className="bg-danger-light text-danger-dark">操作失败</div>

// 自定义字体
<h1 className="font-display text-5xl">大标题</h1>
<code className="font-mono text-sm">const x = 1;</code>

// 自定义阴影
<div className="shadow-soft p-6 rounded-xl">卡片</div>

// 自定义动画
<div className="animate-fade-in">淡入内容</div>
<div className="animate-slide-up">上滑内容</div>

13.4 CSS 变量与主题切换

定义 CSS 变量

/* app/globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    /* 亮色主题 */
    --color-background: 255 255 255;
    --color-foreground: 15 23 42;
    --color-card: 255 255 255;
    --color-card-foreground: 15 23 42;
    --color-popover: 255 255 255;
    --color-popover-foreground: 15 23 42;
    --color-primary: 59 130 246;
    --color-primary-foreground: 255 255 255;
    --color-secondary: 241 245 249;
    --color-secondary-foreground: 15 23 42;
    --color-muted: 241 245 249;
    --color-muted-foreground: 100 116 139;
    --color-accent: 241 245 249;
    --color-accent-foreground: 15 23 42;
    --color-destructive: 239 68 68;
    --color-destructive-foreground: 255 255 255;
    --color-border: 226 232 240;
    --color-input: 226 232 240;
    --color-ring: 59 130 246;
    --radius: 0.5rem;
  }

  .dark {
    /* 暗色主题 */
    --color-background: 15 23 42;
    --color-foreground: 248 250 252;
    --color-card: 30 41 59;
    --color-card-foreground: 248 250 252;
    --color-popover: 30 41 59;
    --color-popover-foreground: 248 250 252;
    --color-primary: 96 165 250;
    --color-primary-foreground: 15 23 42;
    --color-secondary: 51 65 85;
    --color-secondary-foreground: 248 250 252;
    --color-muted: 51 65 85;
    --color-muted-foreground: 148 163 184;
    --color-accent: 51 65 85;
    --color-accent-foreground: 248 250 252;
    --color-destructive: 239 68 68;
    --color-destructive-foreground: 255 255 255;
    --color-border: 51 65 85;
    --color-input: 51 65 85;
    --color-ring: 96 165 250;
  }
}

@layer base {
  * {
    @apply border-border;
  }

  body {
    @apply bg-background text-foreground;
    font-feature-settings: "rlig" 1, "calt" 1;
  }
}

在 Tailwind 中映射 CSS 变量

// tailwind.config.ts

import type { Config } from 'tailwindcss';

const config: Config = {
  darkMode: 'class',

  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
  ],

  theme: {
    extend: {
      colors: {
        background: 'rgb(var(--color-background) / <alpha-value>)',
        foreground: 'rgb(var(--color-foreground) / <alpha-value>)',
        card: {
          DEFAULT: 'rgb(var(--color-card) / <alpha-value>)',
          foreground: 'rgb(var(--color-card-foreground) / <alpha-value>)',
        },
        popover: {
          DEFAULT: 'rgb(var(--color-popover) / <alpha-value>)',
          foreground: 'rgb(var(--color-popover-foreground) / <alpha-value>)',
        },
        primary: {
          DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
          foreground: 'rgb(var(--color-primary-foreground) / <alpha-value>)',
        },
        secondary: {
          DEFAULT: 'rgb(var(--color-secondary) / <alpha-value>)',
          foreground: 'rgb(var(--color-secondary-foreground) / <alpha-value>)',
        },
        muted: {
          DEFAULT: 'rgb(var(--color-muted) / <alpha-value>)',
          foreground: 'rgb(var(--color-muted-foreground) / <alpha-value>)',
        },
        accent: {
          DEFAULT: 'rgb(var(--color-accent) / <alpha-value>)',
          foreground: 'rgb(var(--color-accent-foreground) / <alpha-value>)',
        },
        destructive: {
          DEFAULT: 'rgb(var(--color-destructive) / <alpha-value>)',
          foreground: 'rgb(var(--color-destructive-foreground) / <alpha-value>)',
        },
        border: 'rgb(var(--color-border) / <alpha-value>)',
        input: 'rgb(var(--color-input) / <alpha-value>)',
        ring: 'rgb(var(--color-ring) / <alpha-value>)',
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
    },
  },

  plugins: [
    require('tailwindcss-animate'),
  ],
};

export default config;

使用语义化 Token

// ❌ 不要硬编码颜色
<div className="bg-white text-gray-900 dark:bg-slate-800 dark:text-white">
  Hello
</div>

// ✅ 使用语义化 Token
<div className="bg-card text-card-foreground">
  Hello
</div>

// ✅ 主色按钮
<button className="bg-primary text-primary-foreground hover:bg-primary/90">
  提交
</button>

// ✅ 次要按钮
<button className="bg-secondary text-secondary-foreground hover:bg-secondary/80">
  取消
</button>

13.5 暗色模式

三种切换策略

1. 系统偏好(默认)

// tailwind.config.ts
const config: Config = {
  darkMode: 'media', // 根据 prefers-color-scheme
};
<div className="bg-white dark:bg-slate-900">
  <p className="text-gray-900 dark:text-gray-100">
    自动跟随系统暗色模式
  </p>
</div>

2. 手动切换(class 策略)

// tailwind.config.ts
const config: Config = {
  darkMode: 'class', // 根据 html 元素的 class
};
// app/components/ThemeToggle.tsx
'use client';

import { useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

export function ThemeToggle() {
  const [theme, setTheme] = useState<Theme>('system');

  useEffect(() => {
    // 从 localStorage 读取
    const stored = localStorage.getItem('theme') as Theme | null;
    if (stored) {
      setTheme(stored);
    }
  }, []);

  useEffect(() => {
    const root = document.documentElement;

    if (theme === 'system') {
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      root.classList.toggle('dark', systemDark);
    } else {
      root.classList.toggle('dark', theme === 'dark');
    }

    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <div className="flex items-center gap-1 p-1 bg-muted rounded-lg">
      <button
        onClick={() => setTheme('light')}
        className={`px-3 py-1.5 rounded-md text-sm transition-colors ${
          theme === 'light' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
        }`}
      >
        ☀️
      </button>
      <button
        onClick={() => setTheme('dark')}
        className={`px-3 py-1.5 rounded-md text-sm transition-colors ${
          theme === 'dark' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
        }`}
      >
        🌙
      </button>
      <button
        onClick={() => setTheme('system')}
        className={`px-3 py-1.5 rounded-md text-sm transition-colors ${
          theme === 'system' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
        }`}
      >
        💻
      </button>
    </div>
  );
}

3. 防止闪烁(FOUC)

// app/layout.tsx

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN" suppressHydrationWarning>
      <head>
        {/* 内联脚本:在 CSS 解析前设置 dark class */}
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  var theme = localStorage.getItem('theme');
                  if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
                    document.documentElement.classList.add('dark');
                  }
                } catch (e) {}
              })();
            `,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

使用 next-themes(推荐方案)

npm install next-themes
// app/providers.tsx
'use client';

import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </ThemeProvider>
  );
}
// app/components/ThemeToggle.tsx
'use client';

import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);

  if (!mounted) {
    return <div className="w-[120px] h-[36px]" />; // 占位
  }

  return (
    <select
      value={theme}
      onChange={(e) => setTheme(e.target.value)}
      className="px-3 py-1.5 bg-muted rounded-lg text-sm"
    >
      <option value="light">浅色</option>
      <option value="dark">深色</option>
      <option value="system">系统</option>
    </select>
  );
}

13.6 响应式设计策略

断点系统

// tailwind.config.ts

const config: Config = {
  theme: {
    screens: {
      'xs': '475px',     // 额外添加的超小屏断点
      'sm': '640px',     // 手机横屏
      'md': '768px',     // 平板
      'lg': '1024px',    // 小笔记本
      'xl': '1280px',    // 桌面
      '2xl': '1536px',   // 大屏
    },
  },
};

Mobile-First 实践

// ❌ Desktop-First(不推荐)
<div className="grid grid-cols-4 max-md:grid-cols-2 max-sm:grid-cols-1">

// ✅ Mobile-First(推荐)
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">

容器查询(Container Queries)

npm install -D @tailwindcss/container-queries
// tailwind.config.ts
plugins: [
  require('@tailwindcss/container-queries'),
],
// 父容器声明
<div className="@container">
  {/* 子元素根据父容器宽度响应 */}
  <div className="grid grid-cols-1 @md:grid-cols-2 @lg:grid-cols-3">
    <Card />
    <Card />
    <Card />
  </div>
</div>

响应式排版

/* app/globals.css */

@layer components {
  .prose-responsive {
    @apply prose
           prose-sm
           md:prose-base
           lg:prose-lg
           prose-headings:font-display
           prose-headings:tracking-tight
           prose-a:text-primary
           prose-a:no-underline
           hover:prose-a:underline
           prose-img:rounded-lg
           prose-code:font-mono
           prose-code:text-sm
           dark:prose-invert;
  }
}

13.7 @apply 与组件类

使用 @apply 提取公共样式

/* app/globals.css */

@layer components {
  /* 按钮变体 */
  .btn {
    @apply inline-flex items-center justify-center
           rounded-lg font-medium
           transition-colors duration-200
           focus:outline-none focus:ring-2 focus:ring-offset-2
           disabled:opacity-50 disabled:pointer-events-none;
  }

  .btn-primary {
    @apply btn bg-primary text-primary-foreground
           hover:bg-primary/90
           focus:ring-primary;
  }

  .btn-secondary {
    @apply btn bg-secondary text-secondary-foreground
           hover:bg-secondary/80
           focus:ring-secondary;
  }

  .btn-outline {
    @apply btn border border-input bg-background
           hover:bg-accent hover:text-accent-foreground
           focus:ring-ring;
  }

  .btn-ghost {
    @apply btn hover:bg-accent hover:text-accent-foreground;
  }

  .btn-destructive {
    @apply btn bg-destructive text-destructive-foreground
           hover:bg-destructive/90
           focus:ring-destructive;
  }

  /* 按钮尺寸 */
  .btn-sm {
    @apply h-8 px-3 text-xs;
  }
  .btn-md {
    @apply h-10 px-4 text-sm;
  }
  .btn-lg {
    @apply h-12 px-6 text-base;
  }

  /* 输入框 */
  .input {
    @apply flex h-10 w-full rounded-lg border border-input
           bg-background px-3 py-2 text-sm
           placeholder:text-muted-foreground
           focus:outline-none focus:ring-2 focus:ring-ring
           disabled:cursor-not-allowed disabled:opacity-50;
  }

  /* 卡片 */
  .card {
    @apply rounded-xl border border-border bg-card text-card-foreground
           shadow-soft;
  }

  .card-header {
    @apply p-6 pb-0;
  }

  .card-content {
    @apply p-6;
  }

  .card-footer {
    @apply p-6 pt-0;
  }

  /* Badge */
  .badge {
    @apply inline-flex items-center rounded-full px-2.5 py-0.5
           text-xs font-medium transition-colors;
  }

  .badge-primary {
    @apply badge bg-primary/10 text-primary;
  }

  .badge-success {
    @apply badge bg-success-light text-success-dark;
  }
}

使用方式

<button className="btn-primary btn-md">提交</button>
<button className="btn-outline btn-md">取消</button>
<button className="btn-ghost btn-sm">更多</button>

<input className="input" placeholder="请输入..." />

<div className="card">
  <div className="card-header">
    <h3 className="text-lg font-semibold">标题</h3>
  </div>
  <div className="card-content">
    <p>内容</p>
  </div>
  <div className="card-footer">
    <button className="btn-primary btn-sm">操作</button>
  </div>
</div>

@apply vs React 组件

适用 @apply 的场景:
  ✅ 全局基础样式(body、h1-h6、a 标签)
  ✅ 简单的 CSS 类组合
  ✅ 第三方库样式覆盖

适用 React 组件的场景:
  ✅ 需要 props 控制变体(size、variant)
  ✅ 需要条件渲染逻辑
  ✅ 需要组合多个子组件
  ✅ 需要事件处理

13.8 Tailwind 插件

常用插件

# 动画插件
npm install -D tailwindcss-animate

# 容器查询
npm install -D @tailwindcss/container-queries

# 排版(prose)
npm install -D @tailwindcss/typography

# 表单样式
npm install -D @tailwindcss/forms

# 纵横比
npm install -D @tailwindcss/aspect-ratio
// tailwind.config.ts

import type { Config } from 'tailwindcss';

const config: Config = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  darkMode: 'class',
  theme: {
    extend: {
      // ...
    },
  },
  plugins: [
    require('tailwindcss-animate'),
    require('@tailwindcss/typography'),
    require('@tailwindcss/forms'),
    require('@tailwindcss/container-queries'),
    require('@tailwindcss/aspect-ratio'),
  ],
};

export default config;

排版插件使用

// 文章渲染
<article className="prose prose-lg dark:prose-invert max-w-none">
  <div dangerouslySetInnerHTML={{ __html: article.content }} />
</article>

// 自定义 prose 变体
<article className="prose
  prose-headings:font-display
  prose-headings:tracking-tight
  prose-a:text-primary
  prose-a:no-underline
  hover:prose-a:underline
  prose-code:font-mono
  prose-code:before:content-['']
  prose-code:after:content-['']
  prose-img:rounded-xl
  dark:prose-invert
">
  <div dangerouslySetInnerHTML={{ __html: content }} />
</article>

自定义插件

// tailwind.config.ts

const plugin = require('tailwindcss/plugin');

const config: Config = {
  plugins: [
    // 文本渐变插件
    plugin(function ({ addUtilities, theme }) {
      addUtilities({
        '.text-gradient': {
          'background-clip': 'text',
          '-webkit-background-clip': 'text',
          '-webkit-text-fill-color': 'transparent',
        },
        '.text-gradient-primary': {
          'background-image': `linear-gradient(to right, ${theme('colors.brand.400')}, ${theme('colors.brand.600')})`,
          'background-clip': 'text',
          '-webkit-background-clip': 'text',
          '-webkit-text-fill-color': 'transparent',
        },
      });
    }),

    // Glass 效果插件
    plugin(function ({ addUtilities }) {
      addUtilities({
        '.glass': {
          'background': 'rgba(255, 255, 255, 0.1)',
          'backdrop-filter': 'blur(10px)',
          '-webkit-backdrop-filter': 'blur(10px)',
          'border': '1px solid rgba(255, 255, 255, 0.2)',
        },
        '.glass-dark': {
          'background': 'rgba(0, 0, 0, 0.2)',
          'backdrop-filter': 'blur(10px)',
          '-webkit-backdrop-filter': 'blur(10px)',
          'border': '1px solid rgba(255, 255, 255, 0.1)',
        },
      });
    }),

    // 滚动条隐藏
    plugin(function ({ addUtilities }) {
      addUtilities({
        '.scrollbar-hide': {
          '-ms-overflow-style': 'none',
          'scrollbar-width': 'none',
          '&::-webkit-scrollbar': {
            display: 'none',
          },
        },
      });
    }),
  ],
};

使用方式:

<h1 className="text-4xl font-bold text-gradient-primary">
  渐变标题
</h1>

<div className="glass p-6 rounded-xl">
  毛玻璃效果
</div>

<div className="overflow-x-auto scrollbar-hide">
  <div className="flex gap-4">
    {/* 横向滚动内容 */}
  </div>
</div>

13.9 与 shadcn/ui 协作

shadcn/ui 初始化

npx shadcn@latest init

生成的配置:

// components.json
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "app/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}

安装组件

# 按需安装组件
npx shadcn@latest add button
npx shadcn@latest add input
npx shadcn@latest add card
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
npx shadcn@latest add table
npx shadcn@latest add toast
npx shadcn@latest add form

组件源码(完全可控)

// components/ui/button.tsx

import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
        outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
        secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-9 px-4 py-2',
        sm: 'h-8 rounded-md px-3 text-xs',
        lg: 'h-10 rounded-md px-8',
        icon: 'h-9 w-9',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';

export { Button, buttonVariants };

cn 工具函数

// lib/utils.ts

import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
npm install clsx tailwind-merge

使用 shadcn/ui 组件

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';

export default function ContactForm() {
  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>联系我们</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <Input placeholder="您的姓名" />
        <Input type="email" placeholder="邮箱地址" />
      </CardContent>
      <CardFooter>
        <Button>发送</Button>
        <Button variant="outline" className="ml-2">取消</Button>
      </CardFooter>
    </Card>
  );
}

13.10 性能优化

生产环境 CSS 瘦身

Tailwind v3 内置 JIT(Just-In-Time)编译器,只生成你使用到的 CSS 类

# 开发环境:动态编译
npm run dev

# 生产构建:自动 tree-shaking
npm run build
# 输出 CSS 通常 < 10KB(gzip 后)

避免常见性能陷阱

// ❌ 动态拼接类名(JIT 无法检测)
const color = 'blue';
<div className={`bg-${color}-500`}>

// ✅ 使用完整类名
<div className="bg-blue-500">

// ✅ 或使用 safelist(不得已时)
// tailwind.config.ts
safelist: [
  'bg-blue-500',
  'bg-green-500',
  'bg-red-500',
],

减少重复样式

// ❌ 在每个组件上重复写相同的类
<div className="flex items-center justify-center rounded-lg bg-card p-6 shadow-soft">
<div className="flex items-center justify-center rounded-lg bg-card p-6 shadow-soft">

// ✅ 提取为组件或 @apply
<div className="card card-content flex items-center justify-center">

图片优化

// 使用 Next.js Image 组件 + Tailwind
import Image from 'next/image';

<div className="relative aspect-video overflow-hidden rounded-xl">
  <Image
    src="/hero.jpg"
    alt="Hero"
    fill
    className="object-cover"
    priority
  />
</div>

13.11 实战:构建博客首页

// app/page.tsx

import Link from 'next/link';
import { getArticles } from '@/lib/services/article';
import { ThemeToggle } from '@/app/components/ThemeToggle';

export default async function HomePage() {
  const { articles } = await getArticles({ limit: 6, featured: true });

  return (
    <div className="min-h-screen bg-background">
      {/* 导航栏 */}
      <nav className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-lg">
        <div className="max-w-6xl mx-auto flex items-center justify-between px-4 h-16">
          <Link href="/" className="text-xl font-display font-bold text-gradient-primary">
            MyBlog
          </Link>

          <div className="flex items-center gap-6">
            <Link href="/articles" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
              文章
            </Link>
            <Link href="/about" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
              关于
            </Link>
            <ThemeToggle />
          </div>
        </div>
      </nav>

      {/* Hero 区域 */}
      <section className="relative overflow-hidden">
        <div className="absolute inset-0 bg-gradient-to-br from-brand-500/10 via-transparent to-brand-600/10" />
        <div className="relative max-w-6xl mx-auto px-4 py-24 md:py-32 text-center">
          <h1 className="text-4xl md:text-6xl font-display font-bold tracking-tight animate-fade-in">
            探索 <span className="text-gradient-primary">Next.js</span> 的无限可能
          </h1>
          <p className="mt-6 text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto animate-slide-up">
            从基础到生产实战,系统学习 App RouterServer ComponentsServer Actions 等最新技术。
          </p>
          <div className="mt-8 flex items-center justify-center gap-4 animate-slide-up">
            <Link
              href="/articles"
              className="btn-primary btn-lg"
            >
              开始阅读
            </Link>
            <Link
              href="/about"
              className="btn-outline btn-lg"
            >
              了解更多
            </Link>
          </div>
        </div>
      </section>

      {/* 文章列表 */}
      <section className="max-w-6xl mx-auto px-4 py-16">
        <div className="flex items-center justify-between mb-8">
          <h2 className="text-2xl font-bold">精选文章</h2>
          <Link href="/articles" className="text-sm text-primary hover:underline">
            查看全部 
          </Link>
        </div>

        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {articles.map((article, i) => (
            <Link
              key={article.id}
              href={`/articles/${article.slug}`}
              className="group card hover:shadow-medium transition-all duration-300"
              style={{ animationDelay: `${i * 100}ms` }}
            >
              {/* 封面图 */}
              <div className="aspect-video overflow-hidden rounded-t-xl">
                <div className="w-full h-full bg-gradient-to-br from-brand-400 to-brand-600 group-hover:scale-105 transition-transform duration-300" />
              </div>

              {/* 内容 */}
              <div className="p-5">
                <div className="flex items-center gap-2 mb-3">
                  {article.category && (
                    <span className="badge-primary">{article.category}</span>
                  )}
                  <time className="text-2xs text-muted-foreground">
                    {new Date(article.createdAt).toLocaleDateString('zh-CN')}
                  </time>
                </div>

                <h3 className="font-semibold text-lg group-hover:text-primary transition-colors line-clamp-2">
                  {article.title}
                </h3>

                <p className="mt-2 text-sm text-muted-foreground line-clamp-2">
                  {article.excerpt}
                </p>

                <div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
                  <span>{article._count.comments} 评论</span>
                  <span>{article.views} 阅读</span>
                </div>
              </div>
            </Link>
          ))}
        </div>
      </section>

      {/* 底部 */}
      <footer className="border-t border-border bg-surface-secondary">
        <div className="max-w-6xl mx-auto px-4 py-12">
          <div className="flex flex-col md:flex-row items-center justify-between gap-4">
            <p className="text-sm text-muted-foreground">
              © 2025 MyBlog. All rights reserved.
            </p>
            <div className="flex items-center gap-6">
              <Link href="/privacy" className="text-sm text-muted-foreground hover:text-foreground">
                隐私政策
              </Link>
              <Link href="/terms" className="text-sm text-muted-foreground hover:text-foreground">
                使用条款
              </Link>
            </div>
          </div>
        </div>
      </footer>
    </div>
  );
}

本章小结

Key Takeaways

  1. Tailwind CSS 是 Next.js 的最佳 CSS 方案:零运行时、RSC 兼容、JIT 编译
  2. Design Token 是设计系统的基础:通过 CSS 变量 + Tailwind 映射实现主题切换
  3. 暗色模式有三种策略:系统偏好、手动切换、next-themes 方案(推荐)
  4. Mobile-First 是响应式设计的正确方式:从小屏开始,逐步增强
  5. shadcn/ui 是 Tailwind 生态的最佳组件库:源码可控、完全可定制
  6. 性能关键在于 JIT:只生成使用到的类,避免动态拼接类名

下一步

下一章我们将深入 组件库选型与实战——对比 shadcn/ui、MUI、Ant Design 在 Next.js 中的适配方式,并构建一套完整的业务组件体系。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页