名称: 前端模式 描述: 适用于Next.js App Router、Clerk认证、shadcn/Radix UI和PostHog分析的前端模式。在构建UI组件、创建页面、实现认证流程或添加分析事件时使用。确保一致的用户体验模式和可访问性标准。
前端模式技能
目的
通过为Next.js App Router、认证、shadcn/ui组件和分析建立的模式,确保一致的前端开发。
何时适用此技能
- 构建新的UI组件或页面
- 实现认证流程
- 添加带验证的表单
- 集成分析事件
- 创建受保护/认证的路由
- 使用shadcn/ui或Radix组件
Next.js App Router 模式
服务器与客户端组件
// 服务器组件(默认)- 用于:
// - 数据获取
// - 认证检查
// - SEO关键内容
// app/dashboard/page.tsx
import { auth } from "@clerk/nextjs/server";
export default async function DashboardPage() {
const { userId } = await auth();
// 服务器端获取数据...
}
// 客户端组件 - 用于:
// - 交互性(onClick、onChange)
// - 浏览器API(localStorage、window)
// - 钩子(useState、useEffect)
// app/dashboard/_components/interactive-widget.tsx
("use client");
import { useState } from "react";
export function InteractiveWidget() {
const [count, setCount] = useState(0);
// 交互逻辑...
}
受保护页面
关键:对于认证页面,始终使用 export const dynamic = 'force-dynamic':
// app/dashboard/[page]/page.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
// 必需 - 构建时认证上下文不可用
export const dynamic = "force-dynamic";
export default async function ProtectedPage() {
const { userId } = await auth();
if (!userId) {
redirect("/sign-in");
}
// 渲染受保护内容...
}
路由组织
app/
├── (auth)/ # 认证路由(登录、注册)
│ ├── sign-in/[[...sign-in]]/page.tsx
│ └── sign-up/[[...sign-up]]/page.tsx
├── (marketing)/ # 公共营销页面
│ ├── page.tsx # 主页
│ └── pricing/page.tsx
├── dashboard/ # 受保护用户区域
│ ├── page.tsx
│ └── _components/ # 页面特定组件
└── admin/ # 仅管理员区域
└── page.tsx
认证模式
服务器组件认证检查
import { auth } from "@clerk/nextjs/server";
export default async function Page() {
const { userId } = await auth();
// userId 是字符串 | null
}
客户端组件认证
"use client"
import { useUser, useAuth } from '@clerk/nextjs';
export function UserProfile() {
const { user, isLoaded, isSignedIn } = useUser();
const { signOut } = useAuth();
if (!isLoaded) return <Skeleton />;
if (!isSignedIn) return <SignInPrompt />;
return <div>欢迎,{user.firstName}!</div>;
}
管理员验证
// app/admin/page.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const { userId, orgId, orgRole } = await auth();
if (!userId) {
redirect("/sign-in");
}
// 验证管理员角色
const ADMIN_ORG_ID = process.env.CLERK_ADMIN_ORG_ID;
const ADMIN_ROLE = "org:admin";
if (orgId !== ADMIN_ORG_ID || orgRole !== ADMIN_ROLE) {
redirect("/admin-denied");
}
// 渲染管理员内容...
}
shadcn/ui 组件模式
导入约定
// 始终使用 @/components/ui 路径别名
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
表单模式(React Hook Form + Zod)
"use client"
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
const FormSchema = z.object({
email: z.string().email('无效邮箱'),
name: z.string().min(1, '姓名必填'),
});
type FormData = z.infer<typeof FormSchema>;
export function MyForm() {
const form = useForm<FormData>({
resolver: zodResolver(FormSchema),
defaultValues: { email: '', name: '' },
});
async function onSubmit(data: FormData) {
// 处理提交...
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>姓名</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">提交</Button>
</form>
</Form>
);
}
按钮变体
// 主要操作
<Button>保存更改</Button>
// 次要操作
<Button variant="secondary">取消</Button>
// 破坏性操作
<Button variant="destructive">删除</Button>
// 幽灵/细微
<Button variant="ghost">了解更多</Button>
// 链接样式
<Button variant="link" asChild>
<Link href="/docs">文档</Link>
</Button>
// 加载状态
<Button disabled={isLoading}>
{isLoading ? '保存中...' : '保存'}
</Button>
分析模式
事件命名约定
使用带类别前缀的snake_case:
// 用户操作
"user_signed_up";
"user_signed_in";
"user_profile_updated";
// 功能使用
"feature_dark_mode_toggled";
"feature_export_clicked";
// 支付
"payment_checkout_started";
"payment_completed";
"subscription_upgraded";
// 导航
"page_viewed";
"cta_clicked";
事件跟踪
"use client"
import { usePostHog } from 'posthog-js/react';
export function TrackableButton() {
const posthog = usePostHog();
function handleClick() {
posthog?.capture('cta_clicked', {
button_text: '开始使用',
page: '/pricing',
variant: 'primary',
});
}
return <Button onClick={handleClick}>开始使用</Button>;
}
可访问性清单
所有组件必需
- [ ] 键盘导航:所有交互元素可通过Tab聚焦
- [ ] 焦点指示器:可见焦点环(Tailwind:
focus:ring-2) - [ ] 颜色对比:文本最小4.5:1
- [ ] 替代文本:所有图像有描述性alt文本
- [ ] ARIA标签:表单输入有标签或aria-label
- [ ] 错误状态:表单错误向屏幕阅读器宣布
模式
// 可访问按钮
<Button aria-label="关闭对话框">
<X className="h-4 w-4" />
</Button>
// 可访问表单字段
<FormItem>
<FormLabel htmlFor="email">邮箱</FormLabel>
<FormControl>
<Input id="email" type="email" aria-describedby="email-error" />
</FormControl>
<FormMessage id="email-error" />
</FormItem>
// 键盘用户跳过链接
<a href="#main-content" className="sr-only focus:not-sr-only">
跳过至主要内容
</a>
响应式设计模式
Tailwind 断点
// 移动优先方法
<div className="
px-4 // 移动:16px内边距
md:px-6 // 平板:24px内边距
lg:px-8 // 桌面:32px内边距
">
// 响应式网格
<div className="
grid
grid-cols-1 // 移动:1列
md:grid-cols-2 // 平板:2列
lg:grid-cols-3 // 桌面:3列
gap-4
">
// 断点隐藏/显示
<div className="hidden md:block">仅桌面</div>
<div className="md:hidden">仅移动</div>
常见错误避免
不要这样做
// 交互组件缺失'use client'
import { useState } from 'react'; // 会出错!
// 在服务器组件中使用钩子
export default async function Page() {
const [state, setState] = useState(); // 会出错!
}
// 认证页面缺失force-dynamic
export default async function ProtectedPage() {
const { userId } = await auth(); // 构建时可能失败!
}
// 直接DOM操作
document.getElementById('foo'); // 使用refs代替
// 内联样式(使用Tailwind)
<div style={{ marginTop: '20px' }}> // 使用className="mt-5"
这样做代替
// 正确客户端组件
"use client"
import { useState } from 'react';
// 带认证的服务器组件
export const dynamic = 'force-dynamic';
export default async function Page() {
const { userId } = await auth();
}
// 使用refs访问DOM
const inputRef = useRef<HTMLInputElement>(null);
// Tailwind类
<div className="mt-5">
参考
- UI模式:
docs/patterns/ui/ - 组件库:
components/ui/(shadcn/ui) - 功能标志:
config/features.ts