前端组件技能
目的:指导创建遵循服务器/客户端模式和现有组件结构的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));
}
模式:记录到控制台,发送到错误跟踪服务,包括上下文
常见模式总结
- ✅ 默认使用服务器组件
- ✅ 仅在需要时添加"use client"
- ✅ 使用TypeScript接口进行属性
- ✅ 使用
cn()实用工具进行条件类 - ✅ 始终包括辅助功能属性
- ✅ 使用语义HTML元素
- ✅ 提供加载状态
- ✅ 优雅处理错误
- ✅ 使用Tailwind实用程序类
- ✅ 支持暗色模式与
dark:前缀 - ✅ 使用
@dnd-kit/core进行拖放 - ✅ 使用
useReducer进行撤销/重做 - ✅ 使用
setInterval进行轮询并清理 - ✅ 使用
next/dynamic进行代码拆分 - ✅ 使用错误边界进行错误处理
- ✅ 使用IndexedDB进行离线存储
- ✅ 连接恢复时同步离线更改
- ✅ 缓存API响应并设置过期
- ✅ 将错误记录到跟踪服务