每天一个高级前端知识 - Day 24
今日主题:全栈前端 - 使用 Next.js/Remix 构建完整应用
核心概念:前端不再只是“前端”
全栈框架让前端开发者能够直接操作数据库、处理文件上传、实现身份认证,真正成为独立的全栈工程师。
🔬 Next.js 15 全栈实战
// ============ App Router 架构 ============
// app/layout.tsx - 根布局
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: '全栈电商平台',
description: '基于 Next.js 15 的全栈应用',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<Navbar />
{children}
<Footer />
</body>
</html>
);
}
// ============ 服务端组件 + 数据库操作 ============
// app/products/page.tsx
import { Suspense } from 'react';
import { ProductCard } from '@/components/ProductCard';
import { ProductFilter } from '@/components/ProductFilter';
import { db } from '@/lib/db';
import { products, categories } from '@/lib/db/schema';
import { eq, sql, and, gte, lte } from 'drizzle-orm';
import { cache } from 'react';
// 缓存数据库查询(跨请求复用)
const getProducts = cache(async (filters?: ProductFilters) => {
// 使用 Drizzle ORM 查询
let query = db.select().from(products);
if (filters?.category) {
query = query.where(eq(products.categoryId, filters.category));
}
if (filters?.minPrice) {
query = query.where(gte(products.price, filters.minPrice));
}
if (filters?.maxPrice) {
query = query.where(lte(products.price, filters.maxPrice));
}
if (filters?.search) {
query = query.where(
sql`${products.name} LIKE ${`%${filters.search}%`}`
);
}
return await query.orderBy(products.createdAt);
});
// Server Component
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const params = await searchParams;
const filters = {
category: params.category as string,
minPrice: params.minPrice ? Number(params.minPrice) : undefined,
maxPrice: params.maxPrice ? Number(params.maxPrice) : undefined,
search: params.search as string,
};
const productList = await getProducts(filters);
const categoryList = await db.select().from(categories);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">商品列表</h1>
{/* 客户端组件 - 筛选器 */}
<Suspense fallback={<FilterSkeleton />}>
<ProductFilter categories={categoryList} initialFilters={filters} />
</Suspense>
{/* 产品列表 */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6 mt-8">
{productList.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
// 静态生成(ISR)
export async function generateStaticParams() {
const categories = await db.select().from(categories);
return categories.map((category) => ({
category: category.slug,
}));
}
// 增量静态再生(每60秒重新验证)
export const revalidate = 60;
// ============ 服务端操作 + 重新验证 ============
// app/api/products/route.ts - API Route
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { products } from '@/lib/db/schema';
import { revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth';
export async function POST(request: NextRequest) {
// 身份验证
const session = await auth();
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// 验证输入
const { name, price, description, categoryId } = body;
if (!name || !price) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
}
// 创建产品
const [product] = await db.insert(products).values({
name,
price,
description,
categoryId,
createdAt: new Date(),
updatedAt: new Date(),
}).returning();
// 重新验证产品列表缓存
revalidateTag('products');
return NextResponse.json(product, { status: 201 });
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id: idParam } = await params;
const id = parseInt(idParam);
const body = await request.json();
const [updated] = await db.update(products)
.set({ ...body, updatedAt: new Date() })
.where(eq(products.id, id))
.returning();
revalidateTag(`product-${id}`);
revalidateTag('products');
return NextResponse.json(updated);
}
// ============ 客户端组件 + 乐观更新 ============
// components/ProductCard.tsx
'use client';
import { useState, useOptimistic, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { HeartIcon, ShoppingCartIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartSolidIcon } from '@heroicons/react/24/solid';
interface ProductCardProps {
product: {
id: number;
name: string;
price: number;
image: string;
isLiked?: boolean;
};
}
export function ProductCard({ product: initialProduct }: ProductCardProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isLiked, setIsLiked] = useState(initialProduct.isLiked || false);
const [optimisticProduct, addOptimistic] = useOptimistic(
initialProduct,
(state, isLike: boolean) => ({ ...state, isLiked: isLike })
);
const toggleLike = async () => {
// 乐观更新
startTransition(() => {
addOptimistic(!isLiked);
});
setIsLiked(!isLiked);
// 实际请求
await fetch(`/api/products/${initialProduct.id}/like`, {
method: 'POST',
});
// 刷新服务器组件
router.refresh();
};
const addToCart = async () => {
const res = await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId: initialProduct.id, quantity: 1 }),
});
if (res.ok) {
// 显示提示
alert('已添加到购物车');
}
};
return (
<div className="group relative bg-white rounded-lg shadow-md overflow-hidden">
{/* 图片 */}
<div className="aspect-square overflow-hidden">
<Image
src={optimisticProduct.image}
alt={optimisticProduct.name}
width={300}
height={300}
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
{/* 收藏按钮 */}
<button
onClick={toggleLike}
className="absolute top-2 right-2 p-2 bg-white rounded-full shadow-md"
disabled={isPending}
>
{optimisticProduct.isLiked ? (
<HeartSolidIcon className="w-5 h-5 text-red-500" />
) : (
<HeartIcon className="w-5 h-5 text-gray-500" />
)}
</button>
{/* 信息 */}
<div className="p-4">
<h3 className="font-semibold text-gray-900 truncate">
{optimisticProduct.name}
</h3>
<div className="mt-2 flex items-center justify-between">
<span className="text-lg font-bold text-red-600">
¥{optimisticProduct.price}
</span>
<button
onClick={addToCart}
className="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors"
>
<ShoppingCartIcon className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}
🚀 Remix 全栈实战
// ============ Remix 路由 + 数据加载 ============
// app/routes/products.$id.tsx
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData, Form, useFetcher } from '@remix-run/react';
import { db } from '~/lib/db';
import { requireUserId } from '~/lib/auth.server';
// Loader - 服务端数据获取
export async function loader({ params, request }: LoaderFunctionArgs) {
const product = await db.product.findUnique({
where: { id: params.id },
include: { reviews: true, category: true },
});
if (!product) {
throw new Response('Not Found', { status: 404 });
}
// 获取用户会话
const userId = await requireUserId(request).catch(() => null);
let isLiked = false;
if (userId) {
const like = await db.like.findUnique({
where: { userId_productId: { userId, productId: product.id } }
});
isLiked = !!like;
}
return json({ product, isLiked });
}
// Action - 服务端操作
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const action = formData.get('_action');
switch (action) {
case 'like':
await db.like.create({
data: { userId, productId: params.id }
});
return json({ success: true });
case 'unlike':
await db.like.delete({
where: { userId_productId: { userId, productId: params.id } }
});
return json({ success: true });
case 'addReview':
const rating = Number(formData.get('rating'));
const content = formData.get('content') as string;
await db.review.create({
data: { userId, productId: params.id, rating, content }
});
return json({ success: true });
default:
return json({ error: 'Invalid action' }, { status: 400 });
}
}
// 组件
export default function ProductDetail() {
const { product, isLiked } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const likeFetcher = useFetcher();
const handleLike = () => {
likeFetcher.submit(
{ _action: isLiked ? 'unlike' : 'like' },
{ method: 'post' }
);
};
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* 产品图片 */}
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
<img
src={product.image}
alt={product.name}
className="w-full h-full object-cover"
/>
</div>
{/* 产品信息 */}
<div>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<div className="text-2xl font-bold text-red-600 mb-4">
¥{product.price}
</div>
<p className="text-gray-600 mb-6">{product.description}</p>
{/* 收藏按钮(乐观 UI) */}
<button
onClick={handleLike}
className="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
>
{isLiked ? '❤️ 已收藏' : '🤍 收藏'}
</button>
{/* 添加到购物车 */}
<fetcher.Form method="post" className="mt-4">
<input type="hidden" name="_action" value="addToCart" />
<input type="number" name="quantity" defaultValue={1} min={1} className="w-20 px-2 py-1 border rounded" />
<button
type="submit"
className="ml-4 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
加入购物车
</button>
</fetcher.Form>
</div>
</div>
{/* 评论区域 */}
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">用户评论</h2>
{/* 添加评论表单 */}
<Form method="post" className="mb-8 p-4 bg-gray-50 rounded-lg">
<input type="hidden" name="_action" value="addReview" />
<div className="mb-4">
<label className="block text-sm font-medium mb-2">评分</label>
<select name="rating" className="px-3 py-2 border rounded">
{[5, 4, 3, 2, 1].map(r => (
<option key={r} value={r}>{r} 星</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">评论</label>
<textarea
name="content"
rows={3}
className="w-full px-3 py-2 border rounded"
required
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
提交评论
</button>
</Form>
{/* 评论列表 */}
<div className="space-y-4">
{product.reviews.map((review) => (
<div key={review.id} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{review.user.name}</span>
<span className="text-yellow-500">{'⭐'.repeat(review.rating)}</span>
</div>
<p className="text-gray-600">{review.content}</p>
<p className="text-sm text-gray-400 mt-2">
{new Date(review.createdAt).toLocaleDateString()}
</p>
</div>
))}
</div>
</div>
</div>
);
}
// 错误边界
export function ErrorBoundary({ error }: { error: Error }) {
return (
<div className="container mx-auto px-4 py-8">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h2 className="text-red-800 font-bold">加载失败</h2>
<p className="text-red-600">{error.message}</p>
</div>
</div>
);
}
🔐 身份认证实现
// ============ NextAuth.js v5 配置 ============
// lib/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from './db';
import { compare } from 'bcryptjs';
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
Credentials({
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, credentials.email as string)
});
if (!user || !user.password) {
return null;
}
const isValid = await compare(credentials.password as string, user.password);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
}
})
],
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30天
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
});
// ============ 中间件保护路由 ============
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { auth } from './lib/auth';
export async function middleware(request: NextRequest) {
const session = await auth();
const isAuthPage = request.nextUrl.pathname.startsWith('/auth');
const isAdminPage = request.nextUrl.pathname.startsWith('/admin');
const isApiRoute = request.nextUrl.pathname.startsWith('/api');
// 未登录且不是认证页面
if (!session && !isAuthPage) {
const signInUrl = new URL('/auth/signin', request.url);
signInUrl.searchParams.set('callbackUrl', request.nextUrl.pathname);
return NextResponse.redirect(signInUrl);
}
// 已登录访问认证页面 -> 重定向到首页
if (session && isAuthPage) {
return NextResponse.redirect(new URL('/', request.url));
}
// 管理员权限检查
if (isAdminPage && session?.user?.role !== 'admin') {
return NextResponse.redirect(new URL('/403', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/dashboard/:path*',
'/admin/:path*',
'/profile/:path*',
'/auth/:path*',
],
};
🎯 今日挑战
构建一个完整的全栈电商应用,要求:
- 使用 Next.js 15 App Router
- 实现产品浏览、搜索、筛选功能
- 用户认证(邮箱/密码 + OAuth)
- 购物车功能(本地存储 + 数据库持久化)
- 订单创建和支付集成
- 服务端组件和客户端组件合理划分
- 实现静态生成(ISR)优化性能
- 添加管理后台(产品管理、订单管理)
明日预告:前端设计模式 - 构建可维护的大型应用
💡 全栈箴言:"全栈不是什么都做,而是有能力在需要时跨越边界解决问题。"