FrontendComponentSkill frontend-component

本技能提供创建遵循服务器/客户端模式和现有组件结构的Next.js组件的指导,包括服务器组件和客户端组件的使用场景、模式、组件结构模板以及特定组件模式,如受保护路由、加载指示器、表单处理、吐司通知等。同时涵盖Tailwind CSS模式、辅助功能模式、文件命名约定和宪法要求。此外,还介绍了高级组件模式,如拖放、撤销/重做、实时更新轮询、内联编辑、性能优化、错误边界、PWA模式、缓存策略和错误日志跟踪。

前端开发 0 次安装 0 次浏览 更新于 3/2/2026

前端组件技能

目的:指导创建遵循服务器/客户端模式和现有组件结构的Next.js组件。

概览

Next.js 16+默认使用App Router与服务器组件。仅在需要交互性时使用客户端组件(钩子、事件处理程序、浏览器API)。

服务器与客户端组件

服务器组件(默认)

何时使用

  • 页面和布局
  • 静态内容
  • 从API获取数据(可能的话)
  • SEO优化内容

模式

// 无"use client"指令
import { Metadata } from "next";

export const metadata: Metadata = {
  title: "页面标题",
};

export default function PageComponent() {
  return <div>静态内容</div>;
}

示例frontend/app/layout.tsx, frontend/app/page.tsx

客户端组件(需要时)

何时使用

  • 交互元素(按钮、表单、输入)
  • 事件处理程序(onClick、onChange等)
  • React钩子(useState、useEffect、useRouter等)
  • 浏览器API(localStorage、window、document等)
  • 实时更新
  • 拖放功能

模式

"use client"; // 必须为第一行

import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";

interface ComponentProps {
  prop1: string;
  prop2?: number;
}

export default function ComponentName({ prop1, prop2 }: ComponentProps) {
  const router = useRouter();
  const [state, setState] = useState("");

  useEffect(() => {
    // 副作用
  }, []);

  return <div>{/* 组件JSX */}</div>;
}

示例frontend/components/ProtectedRoute.tsx, frontend/app/signup/page.tsx

组件结构模板

"use client"; // 仅客户端组件时

/**
 * 组件名称
 *
 * 组件功能的简要描述
 */

import { useState, useEffect } from "react";
import { ComponentType } from "@/types";
import { cn } from "@/lib/utils";

interface ComponentProps {
  prop1: string;
  prop2?: number;
  className?: string;
}

export default function ComponentName({ prop1, prop2, className }: ComponentProps) {
  // 状态
  const [state, setState] = useState("");

  // 效果
  useEffect(() => {
    // 副作用
  }, []);

  // 处理程序
  const handleClick = () => {
    // 处理程序逻辑
  };

  // 渲染
  return (
    <div className={cn("base-classes", className)}>
      {/* 组件内容 */}
    </div>
  );
}

特定组件模式

1. ProtectedRoute模式

来源frontend/components/ProtectedRoute.tsx

"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated } from "@/lib/auth";
import LoadingSpinner from "./LoadingSpinner";

interface ProtectedRouteProps {
  children: React.ReactNode;
}

export default function ProtectedRoute({ children }: ProtectedRouteProps) {
  const router = useRouter();
  const [isAuthorized, setIsAuthorized] = useState(false);
  const [isChecking, setIsChecking] = useState(true);

  useEffect(() => {
    async function checkAuth() {
      try {
        const authenticated = await isAuthenticated();

        if (!authenticated) {
          const currentPath = window.location.pathname;
          if (currentPath !== "/signin") {
            sessionStorage.setItem("redirectAfterLogin", currentPath);
          }
          router.push("/signin");
        } else {
          setIsAuthorized(true);
        }
      } catch (error) {
        console.error("认证检查失败:", error);
        router.push("/signin");
      } finally {
        setIsChecking(false);
      }
    }

    checkAuth();
  }, [router]);

  if (isChecking) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <LoadingSpinner size="large" />
      </div>
    );
  }

  if (!isAuthorized) {
    return null;
  }

  return <>{children}</>;
}

模式

  • 登录时检查认证
  • 检查期间显示加载 spinner
  • sessionStorage 中存储预期目的地
  • 未认证时重定向到 /signin
  • 仅在授权时渲染子组件

2. LoadingSpinner模式

来源frontend/components/LoadingSpinner.tsx

interface LoadingSpinnerProps {
  size?: "small" | "medium" | "large";
  color?: string;
  label?: string;
}

export default function LoadingSpinner({
  size = "medium",
  color = "blue",
  label = "加载中...",
}: LoadingSpinnerProps) {
  const sizeClasses = {
    small: "w-4 h-4 border-2",
    medium: "w-8 h-8 border-3",
    large: "w-12 h-12 border-4",
  };

  const colorClasses = {
    blue: "border-blue-600 border-t-transparent",
    gray: "border-gray-600 border-t-transparent",
    white: "border-white border-t-transparent",
  };

  const spinnerClass = `${sizeClasses[size]} ${
    colorClasses[color as keyof typeof colorClasses] || colorClasses.blue
  } rounded-full animate-spin`;

  return (
    <div
      className="flex items-center justify-center"
      role="status"
      aria-label={label}
      aria-live="polite"
    >
      <div className={spinnerClass}></div>
      <span className="sr-only">{label}</span>
    </div>
  );
}

模式

  • 多种大小(小、中、大)
  • 多种颜色(蓝色、灰色、白色)
  • 辅助功能标签(role="status", aria-label, aria-live
  • 屏幕阅读器文本与 sr-only

3. 表单处理模式

来源frontend/app/signup/page.tsx

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { isValidEmail, getPasswordStrength } from "@/lib/utils";

export default function SignupPage() {
  const router = useRouter();
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    password: "",
  });
  const [errors, setErrors] = useState<Record<string, string>>();
  const [isLoading, setIsLoading] = useState(false);
  const [apiError, setApiError] = useState("");

  const validateForm = (): boolean => {
    const newErrors: Record<string, string> = {};

    if (!formData.name.trim()) {
      newErrors.name = "姓名是必填项";
    }

    if (!formData.email.trim()) {
      newErrors.email = "电子邮件是必填项";
    } else if (!isValidEmail(formData.email)) {
      newErrors.email = "请输入有效的电子邮件地址";
    }

    // 更多验证...

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setApiError("");

    if (!validateForm()) {
      return;
    }

    setIsLoading(true);

    try {
      const response = await api.signup(formData);
      if (response.success) {
        router.push("/dashboard");
      } else {
        setApiError(response.message || "注册失败");
      }
    } catch (error: any) {
      setApiError(error.message || "发生错误");
    } finally {
      setIsLoading(false);
    }
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
    // 用户开始输入时清除此字段的错误
    if (errors[name]) {
      setErrors((prev) => ({ ...prev, [name]: "" }));
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 表单字段 */}
    </form>
  );
}

模式

  • 分离表单数据、错误、加载、API错误状态
  • 返回布尔值的验证函数
  • 输入更改时清除错误
  • 提交期间的加载状态
  • 错误处理的 try-catch
  • 成功时重定向

4. ToastNotification模式

来源frontend/components/ToastNotification.tsx

"use client";

import { useEffect, useState } from "react";
import { ToastMessage, ToastType } from "@/types";

export function useToast() {
  const [toasts, setToasts] = useState<ToastMessage[]>([]);

  const showToast = (type: ToastType, message: string, duration?: number) => {
    const id = `toast-${Date.now()}-${Math.random()}`;
    const newToast: ToastMessage = {
      id,
      type,
      message,
      duration,
    };
    setToasts((prev) => [...prev, newToast]);
  };

  const dismissToast = (id: string) => {
    setToasts((prev) => prev.filter((toast) => toast.id !== id));
  };

  return {
    toasts,
    showToast,
    dismissToast,
    success: (message: string, duration?: number) => showToast("success", message, duration),
    error: (message: string, duration?: number) => showToast("error", message, duration),
    // ...
  };
}

模式

  • 自定义钩子用于吐司管理
  • 自动消失,持续时间
  • 堆叠多个吐司
  • 辅助方法(成功、错误、警告、信息)

Tailwind CSS模式

1. 仅实用程序类

<div className="flex items-center justify-center gap-4 p-6 bg-white rounded-lg shadow-md">

模式:使用Tailwind实用程序类,不使用内联样式

2. 使用cn()实用程序的条件类

import { cn } from "@/lib/utils";

<div className={cn(
  "base-classes",
  condition && "conditional-classes",
  className // 允许属性覆盖
)}>

模式:使用@/lib/utils中的cn()用于条件类

3. 暗色模式支持

<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white">

模式:使用dark:前缀用于暗色模式样式

4. 响应式设计

<div className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4">

模式:使用断点前缀(sm:, md:, lg:, xl:)

辅助功能模式(WCAG 2.1 AA)

1. ARIA标签

<button aria-label="关闭对话框">×</button>
<div role="status" aria-live="polite" aria-label="正在加载...">

模式:始终为图标按钮提供aria-label

2. 语义HTML

<nav>
  <ul>
    <li><a href="/">首页</a></li>
  </ul>
</nav>

模式:使用语义HTML元素(nav, main, section, article等)

3. 键盘导航

<button
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") {
      handleClick();
    }
  }}
>

模式:确保所有交互元素键盘可访问

4. 屏幕阅读器文本

<span className="sr-only">正在加载内容</span>

模式:使用sr-only类为屏幕阅读器文本

5. 焦点管理

<input
  autoFocus
  className="focus:outline-none focus:ring-2 focus:ring-blue-500"
/>

模式:始终提供可见的焦点指示器

文件命名约定

  • 页面:小写连字符命名(例如signup.tsx, signin.tsx
  • 组件:帕斯卡命名(例如TaskList.tsx, TaskItem.tsx
  • 布局layout.tsx
  • 错误页面error.tsx, not-found.tsx

宪法要求

  • FR-033:Next.js 16+ App Router结构✅
  • FR-034:服务器组件默认,需要时使用客户端✅
  • FR-035:错误边界✅
  • FR-036:WCAG 2.1 AA合规性✅
  • FR-037:TypeScript严格模式✅
  • FR-038:Prettier格式化✅
  • FR-039:ESLint规则✅

参考资料

  • 规范specs/002-frontend-todo-app/spec.md - 组件规范
  • 现有组件frontend/components/*.tsx - 组件示例
  • 现有页面frontend/app/*.tsx - 页面示例

高级组件模式

1. 拖放模式(第7阶段 - T065)

@dnd-kit/core

"use client";

import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";

export default function SortableTaskList({ tasks, onReorder }: Props) {
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (over && active.id !== over.id) {
      onReorder(active.id, over.id);
    }
  };

  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
        {tasks.map(task => (
          <SortableTaskItem key={task.id} task={task} />
        ))}
      </SortableContext>
    </DndContext>
  );
}

模式:使用@dnd-kit/core进行拖放,拖放结束时处理重新排序

2. 撤销/重做模式(第7阶段 - T066)

"use client";

import { useReducer } from "react";

interface HistoryState<T> {
  past: T[];
  present: T;
  future: T[];
}

function historyReducer<T>(state: HistoryState<T>, action: { type: string; newPresent?: T }): HistoryState<T> {
  const { past, present, future } = state;

  switch (action.type) {
    case "UNDO":
      if (past.length === 0) return state;
      return {
        past: past.slice(0, past.length - 1),
        present: past[past.length - 1],
        future: [present, ...future],
      };
    case "REDO":
      if (future.length === 0) return state;
      return {
        past: [...past, present],
        present: future[0],
        future: future.slice(1),
      };
    case "SET":
      if (action.newPresent === present) return state;
      return {
        past: [...past, present],
        present: action.newPresent!,
        future: [],
      };
    default:
      return state;
  }
}

export function useHistory<T>(initialPresent: T) {
  const [state, dispatch] = useReducer(historyReducer, {
    past: [],
    present: initialPresent,
    future: [],
  });

  const undo = () => dispatch({ type: "UNDO" });
  const redo = () => dispatch({ type: "REDO" });
  const set = (newPresent: T) => dispatch({ type: "SET", newPresent });

  return { state: state.present, set, undo, redo, canUndo: state.past.length > 0, canRedo: state.future.length > 0 };
}

模式:使用useReducer进行撤销/重做功能

3. 实时更新轮询(第7阶段 - T067)

"use client";

import { useEffect, useRef } from "react";

export function usePolling(callback: () => Promise<void>, interval: number = 5000) {
  const intervalRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    const poll = async () => {
      try {
        await callback();
      } catch (error) {
        console.error("轮询错误:", error);
      }
    };

    // 初始调用
    poll();

    // 设置轮询间隔
    intervalRef.current = setInterval(poll, interval);

    // 清理
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [callback, interval]);
}

模式:使用setInterval进行轮询,卸载时清理,优雅处理错误

4. 内联编辑模式(第7阶段 - T068)

"use client";

import { useState } from "react";

export default function InlineEditable({ value, onSave }: Props) {
  const [isEditing, setIsEditing] = useState(false);
  const [editValue, setEditValue] = useState(value);

  const handleSave = () => {
    onSave(editValue);
    setIsEditing(false);
  };

  const handleCancel = () => {
    setEditValue(value);
    setIsEditing(false);
  };

  if (isEditing) {
    return (
      <input
        value={editValue}
        onChange={(e) => setEditValue(e.target.value)}
        onBlur={handleSave}
        onKeyDown={(e) => {
          if (e.key === "Enter") handleSave();
          if (e.key === "Escape") handleCancel();
        }}
        autoFocus
      />
    );
  }

  return (
    <span onClick={() => setIsEditing(true)} className="cursor-pointer">
      {value}
    </span>
  );
}

模式:切换编辑模式,保存时失去焦点/Enter,取消时Escape

5. 性能优化模式(第8阶段 - T072)

代码拆分next/dynamic

import dynamic from "next/dynamic";

// 延迟加载重组件
const TaskStatistics = dynamic(() => import("@/components/TaskStatistics"), {
  loading: () => <LoadingSpinner />,
  ssr: false, // 如果不需要则禁用SSR
});

const TaskDetailModal = dynamic(() => import("@/components/TaskDetailModal"), {
  loading: () => <LoadingSpinner />,
});

模式:使用next/dynamic进行代码拆分,提供加载回退

图像优化

import Image from "next/image";

<Image
  src="/image.jpg"
  alt="描述"
  width={500}
  height={300}
  loading="lazy"
  placeholder="blur"
/>

模式:使用Next.jsImage组件进行自动优化

6. 错误边界模式(第8阶段 - T074)

"use client";

import React, { Component, ErrorInfo, ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export default class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): Partial<State> {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error("ErrorBoundary捕获到错误:", error, errorInfo);
    // 记录到错误跟踪服务
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback || (
          <div className="p-4 bg-red-50 border border-red-200 rounded">
            <h2 className="text-red-800 font-bold">出错了</h2>
            <p className="text-red-600">{this.state.error?.message}</p>
            <button onClick={() => this.setState({ hasError: false })}>重试</button>
          </div>
        )
      );
    }

    return this.props.children;
  }
}

模式:类组件,捕获错误,提供后备UI,记录错误

7. PWA模式(第8阶段 - T069, T070, T071)

服务工作线程设置

// public/sw.js或使用next-pwa
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker
      .register("/sw.js")
      .then((registration) => {
        console.log("SW注册成功:", registration);
      })
      .catch((error) => {
        console.error("SW注册失败:", error);
      });
  });
}

模式:页面加载时注册服务工作线程,处理注册错误

IndexedDB离线存储

import { openDB, DBSchema, IDBPDatabase } from "idb";

interface TaskDB extends DBSchema {
  tasks: {
    key: number;
    value: Task;
    indexes: { "by-user-id": string };
  };
}

export async function getDB(): Promise<IDBPDatabase<TaskDB>> {
  return openDB<TaskDB>("todo-db", 1, {
    upgrade(db) {
      const taskStore = db.createObjectStore("tasks", { keyPath: "id" });
      taskStore.createIndex("by-user-id", "user_id");
    },
  });
}

export async function saveTaskOffline(task: Task) {
  const db = await getDB();
  await db.put("tasks", task);
}

export async function getTasksOffline(userId: string): Promise<Task[]> {
  const db = await getDB();
  return db.getAllFromIndex("tasks", "by-user-id", userId);
}

模式:使用idb库进行IndexedDB,创建存储和索引,处理离线数据

离线同步机制

export async function syncOfflineChanges(userId: string) {
  const db = await getDB();
  const offlineTasks = await db.getAllFromIndex("tasks", "by-user-id", userId);

  for (const task of offlineTasks) {
    if (task.syncStatus === "pending") {
      try {
        await api.createTask(userId, task);
        await db.put("tasks", { ...task, syncStatus: "synced" });
      } catch (error) {
        console.error("同步失败任务:", task.id, error);
      }
    }
  }
}

// 连接恢复时调用
window.addEventListener("online", () => {
  syncOfflineChanges(currentUserId);
});

模式:跟踪同步状态,连接恢复时同步,处理同步错误

8. 缓存策略(第8阶段 - T073)

// 缓存API响应
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟

export async function getCachedData<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
  const cached = cache.get(key);
  if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
    return cached.data;
  }

  const data = await fetcher();
  cache.set(key, { data, timestamp: Date.now() });
  return data;
}

模式:使用Map进行内存缓存,检查过期,获取时更新缓存

9. 错误日志和跟踪(第8阶段 - T075)

export function logError(error: Error, context?: Record<string, any>) {
  console.error("错误:", error, context);

  // 发送到错误跟踪服务(例如,Sentry)
  if (typeof window !== "undefined" && (window as any).Sentry) {
    (window as any).Sentry.captureException(error, {
      extra: context,
    });
  }

  // 或发送到自定义端点
  fetch("/api/log-error", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      message: error.message,
      stack: error.stack,
      context,
      timestamp: new Date().toISOString(),
    }),
  }).catch((err) => console.error("记录错误失败:", err));
}

模式:记录到控制台,发送到错误跟踪服务,包括上下文

常见模式总结

  1. ✅ 默认使用服务器组件
  2. ✅ 仅在需要时添加"use client"
  3. ✅ 使用TypeScript接口进行属性
  4. ✅ 使用cn()实用工具进行条件类
  5. ✅ 始终包括辅助功能属性
  6. ✅ 使用语义HTML元素
  7. ✅ 提供加载状态
  8. ✅ 优雅处理错误
  9. ✅ 使用Tailwind实用程序类
  10. ✅ 支持暗色模式与dark:前缀
  11. ✅ 使用@dnd-kit/core进行拖放
  12. ✅ 使用useReducer进行撤销/重做
  13. ✅ 使用setInterval进行轮询并清理
  14. ✅ 使用next/dynamic进行代码拆分
  15. ✅ 使用错误边界进行错误处理
  16. ✅ 使用IndexedDB进行离线存储
  17. ✅ 连接恢复时同步离线更改
  18. ✅ 缓存API响应并设置过期
  19. ✅ 将错误记录到跟踪服务