每天一个高级前端知识 - Day 24

5 阅读4分钟

每天一个高级前端知识 - 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*',
  ],
};

🎯 今日挑战

构建一个完整的全栈电商应用,要求:

  1. 使用 Next.js 15 App Router
  2. 实现产品浏览、搜索、筛选功能
  3. 用户认证(邮箱/密码 + OAuth)
  4. 购物车功能(本地存储 + 数据库持久化)
  5. 订单创建和支付集成
  6. 服务端组件和客户端组件合理划分
  7. 实现静态生成(ISR)优化性能
  8. 添加管理后台(产品管理、订单管理)

明日预告:前端设计模式 - 构建可维护的大型应用

💡 全栈箴言:"全栈不是什么都做,而是有能力在需要时跨越边界解决问题。"