普通视图

发现新文章,点击刷新页面。
昨天以前首页

用 wagmi v2 + WebSocket 硬磕 NFT 上架失败:一个前端开发者踩过的实时状态同步坑

作者 竹林818
2026年4月30日 18:01

背景:一个让我抓狂的 NFT 上架问题

去年秋天,我在帮一个 NFT 交易市场做前端重构。项目用的是 Next.js 14 App Router,合约是 OpenSea 兼容的 Seaport 协议,但前端完全不依赖 OpenSea SDK——因为我们自己改了上架逻辑,用了个简化版的 ListOrder 函数。用户上架 NFT 时,先调用合约的 approve,再调用 createOrder,然后前端得立刻显示这个 NFT 已经上架,状态从“可出售”变成“已挂单”。

问题来了:用户用 MetaMask 确认交易后,前端列表死活不更新。我一开始想,那简单,交易确认后重新 fetch 一下用户挂单列表不就行了?但我天真了——用户上架后,合约事件还没被索引服务(比如 The Graph)同步,直接调 RPC 查 userOrders 映射,返回的还是空数组。更糟的是,用户连续上架两个 NFT,第一个刚确认第二个又触发,状态全乱套。

当时项目排期紧,PM 每天问我“上架按钮点了怎么没反应”,我嘴上说“链上确认需要时间”,心里知道这锅不能全甩给区块链。我必须找到一个方案:用户钱包确认交易后,前端能实时监听到 OrderCreated 事件,然后自动刷新列表,而不是靠用户手动刷新页面。

问题分析:轮询为什么不行?

我的第一版方案很简单:用 ethers.jsprovider.on("block") 监听新区块,每出一个块就去查一次 userOrders。代码大概长这样:

// 第一版:轮询方案(已废弃)
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(MARKET_ADDRESS, MARKET_ABI, provider);

useEffect(() => {
  const handleBlock = async (blockNumber: number) => {
    const orders = await contract.getUserOrders(userAddress);
    setOrders(orders);
  };
  provider.on("block", handleBlock);
  return () => provider.off("block", handleBlock);
}, [userAddress]);

这个方案有几个致命问题:

  1. 延迟太高:以太坊平均出块时间 12 秒,用户上架后要等 12 秒才能看到变化。如果用户连着上架两个,第一个还没被索引第二个就触发了,列表会回滚到中间状态。
  2. RPC 调用次数爆炸:每个区块都调 RPC,如果用户页面开着不动,一小时调几百次,Infura 直接限流。我们项目用的 Alchemy 免费层,没几天就超量了。
  3. React 18 严格模式useEffect 在开发环境会执行两次,导致事件监听注册两次,然后清理函数只执行一次,最后监听器泄漏。我当时在控制台看到一堆 Event listener added 警告,查了半天才发现是严格模式的锅。

后来我试过用 setInterval 每 5 秒轮询,但用户体验更差——列表明明没变化,却在不停闪烁。而且用户钱包切换链时,旧的 provider 没清理,监听还在老链上跑,数据全错。

核心实现:从轮询到事件驱动的迁移

第一步:用 wagmi 的 useWatchContractEvent 替换轮询

我决定彻底放弃 ethers.js 的轮询方案,改用 wagmi v2 的事件监听。wagmi 的 useWatchContractEvent 本质上是对 eth_subscribe 的封装,通过 WebSocket 直接监听合约事件,不用自己管理 provider 和清理逻辑。

先安装 wagmi v2 和 viem:

npm install wagmi viem @tanstack/react-query

然后配置 wagmi 客户端。这里有个坑:wagmi v2 默认用 HTTP 传输,要启用 WebSocket 监听事件,必须显式指定 transports 为 WebSocket 地址。

// lib/wagmi.ts
import { createConfig, http, webSocket } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';

export const config = createConfig({
  chains: [mainnet, sepolia],
  transports: {
    [mainnet.id]: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
    [sepolia.id]: webSocket('wss://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'),
  },
});

注意这个细节:如果你同时需要 HTTP 请求(比如读合约状态),可以在 transports 里用 fallback

transports: {
  [mainnet.id]: fallback([
    webSocket('wss://...'),
    http('https://...'),
  ]),
}

这样 wagmi 会优先用 WebSocket,如果断开自动降级到 HTTP。我当时没加这个,结果 WebSocket 偶尔断连后,所有事件监听都失效了,页面直接卡死。

第二步:实现上架表单 + 实时监听

核心组件 ListNFTForm:用户选择 NFT,输入价格,点击上架。上架成功后,自动监听 OrderCreated 事件更新列表。

// components/ListNFTForm.tsx
'use client';
import { useState } from 'react';
import { useAccount, useWriteContract, useWatchContractEvent } from 'wagmi';
import { parseEther } from 'viem';
import { MARKET_ABI, MARKET_ADDRESS } from '@/lib/contract';

export default function ListNFTForm() {
  const { address } = useAccount();
  const [tokenId, setTokenId] = useState('');
  const [price, setPrice] = useState('');
  const [isPending, setIsPending] = useState(false);

  // 1. 写合约:上架 NFT
  const { writeContractAsync } = useWriteContract();

  const handleList = async () => {
    if (!tokenId || !price) return;
    setIsPending(true);
    try {
      const tx = await writeContractAsync({
        address: MARKET_ADDRESS,
        abi: MARKET_ABI,
        functionName: 'createOrder',
        args: [BigInt(tokenId), parseEther(price)],
      });
      // 这里不立刻刷新列表,等事件回调
      console.log('交易已发送:', tx);
    } catch (err) {
      console.error('上架失败:', err);
    } finally {
      setIsPending(false);
    }
  };

  // 2. 实时监听 OrderCreated 事件
  const [recentOrders, setRecentOrders] = useState<bigint[]>([]);

  useWatchContractEvent({
    address: MARKET_ADDRESS,
    abi: MARKET_ABI,
    eventName: 'OrderCreated',
    // 只监听当前用户的事件
    args: { seller: address },
    onLogs(logs) {
      // 这里有个坑:logs 可能包含多个事件,需要过滤
      const newTokenIds = logs
        .filter(log => log.args.seller === address)
        .map(log => log.args.tokenId);
      setRecentOrders(prev => [...new Set([...newTokenIds, ...prev])]);
    },
  });

  return (
    <div>
      <input value={tokenId} onChange={e => setTokenId(e.target.value)} placeholder="Token ID" />
      <input value={price} onChange={e => setPrice(e.target.value)} placeholder="价格 (ETH)" />
      <button onClick={handleList} disabled={isPending}>
        {isPending ? '上架中...' : '上架 NFT'}
      </button>
      <h3>最近上架的 NFT (ID):</h3>
      <ul>
        {recentOrders.map(id => <li key={id}>{id.toString()}</li>)}
      </ul>
    </div>
  );
}

这里有个坑useWatchContractEventargs 过滤参数,在 wagmi v2 中只支持精确匹配,不支持 undefined 表示“不过滤”。如果你写成 args: { seller: undefined },它会直接报错。所以要么不传 args(监听所有事件),要么传具体的地址。我当时传了 seller: address,但用户刚连接钱包时 addressundefined,导致监听器注册失败。后来我加了个条件:

const { address } = useAccount();
// 只有 address 存在时才注册监听
const enabled = !!address;
useWatchContractEvent({
  address: MARKET_ADDRESS,
  abi: MARKET_ABI,
  eventName: 'OrderCreated',
  args: enabled ? { seller: address } : undefined,
  onLogs(logs) { /* ... */ },
  enabled, // wagmi v2 支持这个选项
});

第三步:处理跨链和多钱包切换

NFT 市场通常支持多链。用户如果在 Sepolia 上架,然后切换到 Ethereum Mainnet,之前的事件监听必须清理。wagmi 的 useWatchContractEvent 会自动根据当前链切换,但有个问题:它不会清理旧链上的订阅。

我踩了个坑:用户在 Sepolia 上架了一个 NFT,然后切换到 Mainnet,Mainnet 上也监听到了 OrderCreated 事件——因为 WebSocket 连接还在老链上跑。后来发现是 wagmi 的 webSocket 传输在切换链时不会自动断开旧连接。

解决方案:在组件卸载或链切换时手动清理。但 wagmi v2 没有暴露 unwatch 方法,我只好用 useEffect 的清理函数配合 useChainId

import { useChainId } from 'wagmi';

export default function ListNFTForm() {
  const chainId = useChainId();

  // 每次链变化时,重新挂载组件
  useEffect(() => {
    // 清理旧状态
    setRecentOrders([]);
  }, [chainId]);

  // ... 其余代码
}

这个方案不完美,但至少能保证链切换后列表清空,不会显示错误的数据。更优雅的方案是用 wagmi 的 useSyncExternalStore 自己写一个订阅管理器,但对我们项目来说够用了。

第四步:处理交易确认和事件延迟

用户上架后,writeContractAsync 返回的是交易哈希,不是确认状态。useWatchContractEvent 在交易被打包进区块时就触发,但此时交易可能还没被最终确认(比如 L2 的即时确认 vs L1 的 12 秒)。如果列表在交易刚打包时就更新,用户看到 NFT 已经上架,但几秒后区块重组,事件消失,列表又变回未上架状态。

我当时的解决方案:在 onLogs 回调里不直接更新状态,而是先存到本地,等交易确认后再正式更新。但这样太复杂,而且 wagmi 的 useWaitForTransactionReceipt 可以解决这个问题。

最终方案:把事件监听和交易确认分开。用户上架后,用 useWaitForTransactionReceipt 等待交易确认,确认后再主动查一次列表。同时事件监听作为辅助,提前展示“上架中”的占位状态。

// 等待交易确认
const { data: receipt } = useWaitForTransactionReceipt({
  hash: txHash, // 从 writeContractAsync 返回的哈希
});

useEffect(() => {
  if (receipt) {
    // 交易已确认,重新查询列表
    refetchOrders();
  }
}, [receipt]);

这样用户体验更好:上架后立刻看到占位,交易确认后列表自动刷新,事件监听作为实时更新的补充。

完整代码:一个可运行的 NFT 上架模块

我把上述逻辑整合成一个完整的组件,可以直接复制到 Next.js 14 项目中运行。前提是你已经配好了 wagmi 客户端(参考前面的 lib/wagmi.ts)。

// components/NFTMarketplace.tsx
'use client';
import { useState, useEffect } from 'react';
import { useAccount, useChainId, useWriteContract, useWatchContractEvent, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
import { MARKET_ABI, MARKET_ADDRESS } from '@/lib/contract';

export default function NFTMarketplace() {
  const { address, isConnected } = useAccount();
  const chainId = useChainId();
  
  // 上架表单状态
  const [tokenId, setTokenId] = useState('');
  const [price, setPrice] = useState('');
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined);
  
  // 订单列表状态
  const [orders, setOrders] = useState<Array<{ tokenId: bigint; price: bigint; seller: string }>>([]);
  
  // 写合约
  const { writeContractAsync } = useWriteContract();
  
  // 等待交易确认
  const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
    hash: txHash,
  });
  
  // 交易确认后刷新列表
  useEffect(() => {
    if (isConfirmed) {
      setTxHash(undefined);
      // 这里可以调用 RPC 重新查询列表
      // 但为了演示,我们直接用事件数据
    }
  }, [isConfirmed]);
  
  // 实时监听 OrderCreated 事件
  const enabled = !!address && !!isConnected;
  useWatchContractEvent({
    address: MARKET_ADDRESS,
    abi: MARKET_ABI,
    eventName: 'OrderCreated',
    args: enabled ? { seller: address } : undefined,
    onLogs(logs) {
      const newOrders = logs.map(log => ({
        tokenId: log.args.tokenId as bigint,
        price: log.args.price as bigint,
        seller: log.args.seller as string,
      }));
      setOrders(prev => [...newOrders, ...prev]);
    },
    enabled,
  });
  
  // 上架处理
  const handleList = async () => {
    if (!tokenId || !price || !address) return;
    try {
      const hash = await writeContractAsync({
        address: MARKET_ADDRESS,
        abi: MARKET_ABI,
        functionName: 'createOrder',
        args: [BigInt(tokenId), parseEther(price)],
      });
      setTxHash(hash);
    } catch (err) {
      console.error('上架失败:', err);
    }
  };
  
  // 链切换时清空列表
  useEffect(() => {
    setOrders([]);
  }, [chainId]);
  
  if (!isConnected) return <div>请连接钱包</div>;
  
  return (
    <div style={{ padding: '20px' }}>
      <h2>NFT 上架</h2>
      <div>
        <input
          value={tokenId}
          onChange={e => setTokenId(e.target.value)}
          placeholder="Token ID"
          style={{ marginRight: '8px' }}
        />
        <input
          value={price}
          onChange={e => setPrice(e.target.value)}
          placeholder="价格 (ETH)"
          style={{ marginRight: '8px' }}
        />
        <button onClick={handleList} disabled={isConfirming}>
          {isConfirming ? '确认中...' : '上架'}
        </button>
      </div>
      
      <h3 style={{ marginTop: '30px' }}>我的挂单</h3>
      {orders.length === 0 ? (
        <p>暂无挂单</p>
      ) : (
        <ul>
          {orders.map((order, i) => (
            <li key={i}>
              Token #{order.tokenId.toString()} - {order.price.toString()} wei
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

这个组件可以直接放到 Next.js 14 的 app/page.tsx 里运行,前提是你已经配置好 wagmi provider 和合约地址。

踩坑记录

  1. WebSocket 连接泄漏:wagmi v2 的 useWatchContractEvent 在组件卸载时不会自动断开 WebSocket。我在开发环境频繁热更新,控制台看到 WebSocket connection to 'wss://...' failed。解决方式是在 lib/wagmi.ts 里用 fallback 确保 HTTP 降级,同时在组件里用 enabled 控制只在需要时监听。

  2. React 18 严格模式导致事件重复useWatchContractEvent 在严格模式下会注册两次监听,但 wagmi 内部做了去重,所以不会触发两次回调。但 onLogs 回调里的状态更新会触发两次渲染。我在 setOrders 里用了函数式更新 prev => [...newOrders, ...prev],避免了重复添加。

  3. 事件参数类型不匹配:合约事件定义 OrderCreated(uint256 tokenId, uint256 price, address seller),但 wagmi 的 log.args 返回的是 unknown 类型。我需要手动断言 log.args.tokenId as bigint。后来发现用 viemdecodeEventLog 可以更安全地解析。

  4. 链切换后事件监听不更新:用户从 Sepolia 切到 Mainnet,useWatchContractEvent 会重新注册,但 wagmi 的 webSocket 传输不会自动断开旧链的连接。我加了个 useEffect 监听 chainId,变化时清空列表,但更好的做法是用 wagmi 的 useDisconnect 手动清理。

小结

从 ethers.js 轮询到 wagmi 事件驱动,核心收获是:Web3 前端的状态同步,不要靠定时器,要用合约事件驱动。wagmi v2 的 useWatchContractEvent 封装了 WebSocket 订阅和清理逻辑,但要注意链切换、严格模式和类型断言这些细节。如果你想继续深挖,可以研究 wagmi 的 useSyncExternalStoreviemcreateEventFilter,实现更精细的事件过滤和批量处理。

Next.js部署:从本地跑得欢,到线上飞得稳

作者 kyriewen
2026年4月29日 18:57

你在本地npm run dev,页面秒开,爽得不行。一部署到服务器,慢得像老太太过马路,图片加载半天,首屏白屏几秒,用户投诉。今天我们就来把Next.js应用“送上天”——从部署到优化,让你的线上应用和本地一样快。而且,还能免费蹭HTTPS和CDN。

前言

Next.js最大的优势之一就是部署极其方便。官方团队就是做部署平台Vercel的,所以你用Next.js,就等于半只脚踩进了“一键部署”的门槛。但这不代表你可以随便扔到服务器就跑。部署姿势不对,照样卡成狗。

今天我们讲两种部署方式:无脑简单版(Vercel)手搓硬核版(自托管),以及上线前必做的优化。

一、Vercel部署:连命令都不用记

Vercel是Next.js的亲爹(母公司)。你把代码推到GitHub/GitLab,Vercel自动构建、部署、给CDN、给HTTPS,甚至自动给你一个.vercel.app域名。

步骤

  1. 把代码推到Git仓库。
  2. 登录vercel.com,用GitHub账号登录。
  3. 点击“Import Project”,选择你的仓库。
  4. 默认配置(Next.js自动识别),点击Deploy。
  5. 几十秒后,你会得到一个链接 https://你的项目.vercel.app,已经上线了。

之后每次git push,Vercel自动重新部署。你连服务器都不用买。

优点:零运维、自动HTTPS、全球CDN、自动优化(图片、字体)、免费额度够用(个人项目)。
缺点:自带域名在国内访问可能慢(但可绑定自己的域名,解析到国内CDN节点)。流量超了要付费。

生活比喻:你把菜做好,递给外卖骑手,他帮你送到客户手里,你什么都不用管。

二、自托管(自己买服务器):更自由但更折腾

如果你必须用国内服务器、或者公司要求私有化部署,那就得自己搭。

方案一:Node.js服务器运行

npm run build   # 构建生产版本
npm start       # 启动Node服务器(默认3000端口)

然后用Nginx反向代理,配置HTTPS。注意:你需要自己管理进程(用PM2),自己配置CDN,自己处理日志。

pm2 start npm --name "nextjs" -- start

方案二:静态导出(如果全站都是SSG)

如果你的所有页面都用了getStaticProps(没有getServerSideProps),可以导出纯静态文件,放到Nginx或OSS上。

next build && next export

会生成out文件夹,直接扔到任意静态托管(如阿里云OSS + CDN),超便宜,超快。

缺点:不能用getServerSideProps、API Routes等服务器特性。

方案三:Docker容器化

写Dockerfile,构建镜像,跑在K8s或Docker Compose上。

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

适合云原生环境。

三、上线前必做的优化:让Next.js飞起来

1. 图片用next/image,别用<img>

next/image自动压缩、懒加载、转webp、响应式。你啥都不用做,图片体积直接小一半。

import Image from 'next/image';
<Image src="/hero.png" width={1200} height={600} alt="Hero" />

:宽高必须指定,或者用layout="fill" + 父容器相对定位。

2. 字体用next/font

Next.js 13+内置字体优化,自动内联CSS、避免布局偏移。

import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
<html className={inter.className}>

3. 脚本用next/script

控制第三方脚本加载时机,不阻塞页面。

import Script from 'next/script';
<Script src="https://example.com/tracker.js" strategy="afterInteractive" />

4. 开启压缩(Vercel默认开启,自托管需配置)

next.config.js中:

module.exports = {
  compress: true, // 开启gzip,Nginx也要配
};

5. 移除未使用的CSS(结合Tailwind或PurgeCSS)

如果你用Tailwind,默认已清理。如果用普通CSS,可以考虑@fullhuman/postcss-purgecss

6. 设置缓存头

自托管时,在Nginx里对静态资源(_next/static)设置永久缓存:

location /_next/static {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

7. 启用增量静态再生(ISR)

不需要每个页面都getServerSideProps,能用getStaticProps + revalidate尽量用。

export async function getStaticProps() {
  return { props: { data }, revalidate: 60 };
}

四、性能监控:别靠猜

部署后,用Vercel Analytics(付费)或自建Google Lighthouse CI,定期跑分。也可以集成Sentry监控运行时错误。

npm install @sentry/nextjs

配置后,线上报错自动发到Sentry,不用等用户骂你。

五、总结:从开发到上线,一条龙

  • 个人项目/创业公司:无脑用Vercel,省下的时间写代码。
  • 企业自托管:用Node.js + PM2 + Nginx,或Docker + K8s。
  • 纯静态站点next export扔OSS。
  • 优化必做:图片、字体、压缩、缓存、ISR。

做了这些,你的Next.js应用就能从“本地火箭”变成“太空飞船”。用户打开,秒开;谷歌爬虫,狂喜;老板看数据,点头。

你的数据该在哪儿拿?Next.js三种姿势一次讲清

作者 kyriewen
2026年4月28日 18:38

前言

Next.js给了你三把“钥匙”去开门拿数据。选错了,要么页面慢得像蜗牛,要么用户数据混乱,要么服务器天天崩。别怕,其实规则很简单:数据变不变?要不要实时?要不要SEO? 回答了这三个问题,答案就出来了。

我们用“开餐厅”来打个比方:

  • 静态生成(SSG):菜单印在纸上,顾客看的是纸质菜单。印刷一次管好几天,永不变化。
  • 服务端渲染(SSR):电子黑板,每次客人来,服务员现写菜单,保证最新。
  • 客户端渲染(CSR):客人扫码点餐,手机上的菜单是动态加载的。

三种方式对应三种数据需求。

一、getStaticProps:预制菜,又快又省

适用场景:数据基本不变,或者你可以接受一定延迟更新。比如博客文章、产品介绍页、帮助文档。

怎么做:在构建时(next build)获取数据,生成静态HTML。之后每次请求直接返回这个HTML,速度极快,还能放CDN。

// pages/posts/[id].js
export async function getStaticPaths() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  const paths = posts.map(post => ({ params: { id: post.id } }));
  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const post = await fetch(`https://api.example.com/posts/${params.id}`).then(r => r.json());
  return {
    props: { post },
    revalidate: 60, // 增量静态再生(ISR):60秒后重新生成,不是必须
  };
}

优点:快(CDN缓存)、省服务器资源、SEO完美。 缺点:构建时数据必须是可得的;如果有大量页面,构建时间会变长(可用fallback: true或ISR缓解)。

生活比喻:预制菜包。你提前做好,客人来了热一下就能吃。适合麦当劳(每家的巨无霸都一样)。

二、getServerSideProps:现点现做,永远新鲜

适用场景:数据随用户不同而变化,或者数据实时性要求极高。比如用户个人主页、购物车、实时搜索页。

怎么做:每次请求都跑到服务器上执行getServerSideProps,获取数据后渲染成HTML返回。

// pages/profile.js
export async function getServerSideProps(context) {
  const { req } = context;
  const token = req.cookies.token;
  const user = await fetch('https://api.example.com/user', {
    headers: { Authorization: `Bearer ${token}` }
  }).then(r => r.json());
  return { props: { user } };
}

优点:数据永远最新,可以读取请求上下文(cookies、headers),适合个性化内容。 缺点:每个请求都调用服务器,性能比静态生成差,不能放CDN。

生活比喻:餐厅里的大厨现炒。客人点一份炒一份,味道新鲜,但慢一点,厨师也累。

三、客户端获取(CSR):扫码点餐,不占服务资源

适用场景:数据实时性要求高,但SEO不重要,或者你不希望服务器压力大。比如用户仪表盘、图表数据、实时消息列表。

怎么做:在组件里用useEffect + fetch,或者用SWR、React Query等库。

import { useState, useEffect } from 'react';

export default function Dashboard() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch('/api/dashboard')
      .then(r => r.json())
      .then(setData);
  }, []);
  if (!data) return <div>加载中...</div>;
  return <div>{/* 渲染数据 */}</div>;
}

优点:服务器负担小(只提供API),可以实时更新,适合交互密集的后台。 缺点:首次加载有白屏,SEO不友好(因为内容靠JS填充)。

生活比喻:扫码点餐。客人自己看手机菜单,下单后厨房再做。菜单可以随时改,但客人得先扫个码(等待JS加载)。

四、混合模式:ISR(增量静态再生)

Next.js还提供了一个“中间态”:revalidate 参数。你依然用getStaticProps,但指定一个秒数,超过这个时间后,下次请求会尝试重新生成页面,同时返回旧版本。这样既有静态的速度,又能定期更新。

return { props: { data }, revalidate: 3600 }; // 每小时更新一次

适合数据偶尔变化,但又不希望每次请求都重新生成的场景,比如电商的商品库存(每小时更新就行)。

五、怎么选?决策树

  1. 数据是否需要实时(每次请求都要最新)?

    • 是 → 是否需要SEO?是 → getServerSideProps;否 → 客户端获取。
    • 否 → 进入2
  2. 数据是否依赖用户身份(cookies/headers)?

    • 是 → getServerSideProps
    • 否 → getStaticProps(甚至可以ISR)
  3. 数据量巨大且变化频繁,但不需要SEO? → 客户端获取。

简单口诀

  • 博客文章、产品介绍:getStaticProps
  • 用户个人中心、购物车:getServerSideProps
  • 后台图表、实时看板:客户端获取。

六、实战:一个混合使用的首页

假设首页包含:顶部最新公告(实时)、中间产品列表(每天更新一次)、底部用户推荐(个性化)。

  • 公告:getServerSideProps(实时,且SEO重要?其实公告可以客户端获取,但为了体验,可以SSR)。
  • 产品列表:getStaticProps + revalidate: 86400(一天更新一次)。
  • 用户推荐:客户端获取(依赖登录状态,且SEO不重要)。
export default function Home({ announcement, products }) {
  const [recommendations, setRecommendations] = useState([]);
  useEffect(() => {
    fetch('/api/recommendations').then(r => r.json()).then(setRecommendations);
  }, []);
  return (
    <>
      <Announcement data={announcement} />
      <ProductList data={products} />
      <Recommendations data={recommendations} />
    </>
  );
}

export async function getServerSideProps() {
  const announcement = await fetchAnnouncement(); // 实时
  const products = await fetchProducts(); // 缓存一小时
  return {
    props: { announcement, products },
    revalidate: 3600, // 对products有效,对announcement无效(因为SSR总是实时)
  };
}

但注意:getServerSideProps里不能同时用revalidate(它只对静态生成有效)。如果你要混合,可以把产品数据用getStaticProps单独抽出来,或者全部在客户端获取。上面代码只是示意:实际中getServerSideProps的返回值里加revalidate是无意义的。

更佳实践:产品列表单独做一个静态页面,首页通过客户端请求该接口。

七、总结:没有银弹,只有合适

  • getStaticProps:适合“快、不变、要SEO”。
  • getServerSideProps:适合“实时、要SEO、可接受稍慢”。
  • 客户端获取:适合“实时、不要SEO、降低服务器压力”。

掌握这三种,你就能游刃有余地驾驭Next.js的数据层。别再每页都用客户端fetch了,下次老板说页面慢,你就能有理有据地告诉他:“这个页面应该用ISR!”

接入 MCP 之后,我如何让 Skill 稳定消费 Tool / Resource / Prompt

作者 倾颜
2026年4月27日 12:01

本文对应项目版本:v0.0.11

接入 MCP 之后,下一步是不是应该马上做 Agent?

这是我在 AI Mind 本版本里反复提醒自己的问题。

先简单介绍一下项目背景。AI Mind 是一个按版本持续演进的 AI Native Runtime Skeleton,不是一次性做完的 AI 产品。它从本地聊天闭环开始,逐步长出结构化流式协议、Tool Calling、Multi-Tool Runtime、Skill Runtime、MCP 接入,以及后续会继续推进的 Agent / 数据层能力。

到前一版本为止,项目里已经有了一条比较稳定的聊天主链:请求从 /api/chat 进入,经过 chat-service(聊天服务 facade,负责对外暴露稳定入口)和 runtime(聊天主链编排层,负责规划、工具执行和最终回答生成),再通过 @ai-mind/stream-core(流式协议、生命周期和 writer 内核)返回前端可消费的流式 chunk。

前面版本已经完成基础 MCP 接入,天气走 MCP Tool,文件读取升级成 MCP Resource。照这个节奏继续往下,很容易产生一个冲动:既然外部能力已经接进来了,那是不是该让模型自己决定下一步调用什么?

我最后把重心放在了更靠前的一层:先让能力面变得清楚、稳定、可消费。

本版本的主题不是 Agent,也不是 Remote MCP 教程,而是补了一层更基础的东西:Capability Surface(能力表面,用来描述能力、约束 Skill 承接范围,并把能力消费事实带进 Runtime)。

为了让第一次看到这个项目的读者也能顺着看下去,这里先把几个词放在当前项目语境里:MCP 负责把外部能力接进来,Tool 是可执行动作,Resource 是可读取上下文,Prompt 是可注入模型的任务消息,Skill 则负责承接某类稳定任务模式。

Capability Surface 可以先理解成一层“能力名片”:它先说清楚一个能力是谁、来自哪里、在本地还是远端、当前能不能用、哪个 Skill 可以承接。至于这个能力最终怎么执行,仍然交给 Tool / Resource / Prompt 各自的运行链路。

我更想复盘的是:在一个真实 AI Runtime 项目里,接入 MCP 之后、走向更完整的计划与执行之前,为什么需要先把能力表面收清楚。

它解决的问题是:当系统里同时存在 Tool、Resource、Prompt、本地 MCP、远端 MCP、Skill 路由和前端执行事实时,Skill 到底应该用什么方式稳定理解这些能力,并让 Runtime 安全地消费它们?

先看结论

MCP 接入解决的是“能力怎么接进来”,Capability Surface 解决的是“这些能力如何被描述、被 Skill 承接、被 Runtime 消费、被前端承接”。

本版本我先把 Tool / Resource / Prompt 收成统一的能力描述层,再让 reader-skill(文档读取、项目上下文和外部信息查询类 Skill)消费本版本固定的 local / remote MCP capability。

如果把这件事压成一条链路,大概是这样:

用户问题
  -> Skill(reader-skill)
    -> capabilitySelectors(能力选择范围)
      -> Tool / Resource / Prompt(三类能力)
        -> Runtime 消费
          -> 前端消息 part(执行事实卡片)

ai-1.gif


1. 为什么接入 MCP 后,我先补能力表面

接入 MCP 之后继续往上做调度,看起来很自然。

因为这时候系统已经有了外部能力来源:MCP server 可以暴露 Tool,可以暴露 Resource,也可以暴露 Prompt。再往上一层,好像就该让运行时自己规划:先读哪个资源,再拿哪个 Prompt,再调用哪个 Tool。

但在我这个阶段,这一步太早了。

问题不在于更高层调度不重要,而是它之前还有一个更基础的问题没有解决:

  • 系统里的能力是否能被统一描述?
  • Skill 是否知道自己可以承接哪些能力?
  • Prompt 是否已经被当成一等能力,而不是塞进工具链里的附属品?
  • 前端消息模型是否能承接能力执行事实,而不只是最终答案?
  • Runtime 是否真的消费了 capability metadata,而不是只把它当展示文案?

如果这些问题没有先收住,上层调度很容易变成一个过早的总入口:它看起来什么都能管,但底下的能力边界、错误语义、前端表达都还没清楚。

所以本版本我选择先补 Capability Surface。

这个名字听起来有点抽象,但在项目里它很具体:它是一层让 Skill 和 Runtime 能稳定理解能力对象的表面。它不替代 Tool、不替代 Resource、不替代 Prompt,也不替代 MCP 接入层。它只是先把“系统里有什么能力、来自哪里、在本地还是远端、属于哪类能力、当前是否可用”这些信息讲清楚。

对我来说,这比马上做更高层调度更值得先做。

因为后续计划与执行要做得稳,前提是能力面已经稳。


2. Capability Model 统一的是描述层,不是执行链

本版本最重要的一个判断是:Capability Model 只统一能力描述,不统一执行链。

这句话如果没有提前说清楚,很容易把事情做重。

Tool、Resource、Prompt 虽然都可以叫 capability,但它们的运行时语义完全不同:

  • Tool 通常是一次可执行动作,可能由模型 tool call 触发,也可能被 runtime 主动调用。
  • Resource 更像外部上下文读取,重点是把内容拿回来进入后续回答。
  • Prompt 是模板或消息注入,重点是生成一组可进入模型上下文的消息。

所以我没有把它们硬抽成一个统一的 executeCapability() 大协议。

统一的是这层描述对象:

export const capabilityTypes = ['prompt', 'resource', 'tool'] as const
export type CapabilityType = (typeof capabilityTypes)[number]

export const capabilityProviderKinds = ['internal', 'mcp'] as const
export type CapabilityProviderKind = (typeof capabilityProviderKinds)[number]

export const capabilityLocations = ['local', 'remote'] as const
export type CapabilityLocation = (typeof capabilityLocations)[number]

export interface CapabilityIdentity {
    name: string
    capabilityType: CapabilityType
    providerKind: CapabilityProviderKind
    location: CapabilityLocation
    serverId?: string
}

export interface CapabilityDefinition extends CapabilityIdentity {
    capabilityId: string
    title: string
    description: string
    availability: CapabilityAvailability
}

这段代码解决的是“能力身份如何被稳定描述”的问题。

这里有几个字段很关键:

  • capabilityType:能力是 toolresource 还是 prompt
  • providerKind:能力来自 internal 还是 mcp
  • location:能力在 local 还是 remote
  • serverId:如果来自 MCP,它属于哪个 server
  • availability:能力当前是否可用,不只是一句 true / false

capabilityId 也不是直接用 name,而是按 providerKind:location:capabilityType:serverId?:name 组合出来。这样做是为了避免重名。

一个本地 MCP server 里可以有 summary,一个远端 MCP server 里也可以有 summary。如果只靠 name,后面 Skill、Runtime、前端都会开始猜。capabilityId 把能力身份收成稳定规则,后面再扩 remote server 或 discovery,才不会一开始就欠债。

这一层的克制点也很重要:它只描述能力,不接管能力执行。

Tool / Resource / Prompt 仍然保持各自的执行语义。Capability Model 只是让它们能被同一套语言描述出来。


3. Skill Metadata 是 Skill 的表面,不是 Workflow

在之前的版本里,Skill 已经存在,但它更多像一组运行时规则:命中哪个 Skill、允许用哪些 Tool、拼什么 system prompt。

到了本版本,我想把 Skill 的“表面”讲清楚。

这里的表面不是 UI,而是 Skill 对外声明自己的方式:

  • 我是谁?
  • 我主要处理什么任务?
  • 我可以承接哪些能力来源?
  • 我允许消费哪些 capability?
  • 如果能力不可用,我怎么回退?

对应到类型上,就是 SkillDefinition(Skill 的统一定义对象,承载系统提示词、工具范围和 capability 选择范围)。这里最关键的新增字段是 capabilitySelectors,它用结构化条件描述 Skill 可承接的能力范围。

我刻意没有给 Skill 再包一层复杂的 metadata 对象,也没有把它扩成 workflow 定义。因为本版本的 Skill Metadata 只承担几件事:

  • 自描述
  • routing 辅助
  • 前端轻展示
  • capability 承接范围声明
  • fallback 策略声明

它不承担:

  • 多步 workflow
  • 通用 capability 调度
  • 通用 planner
  • 模型自主继续决策

reader-skill(阅读类 Skill,负责文件读取、文档总结、项目上下文和 MCP 文档能力)就是本版本最关键的例子:

export const readerSkillDefinition: SkillDefinition = {
    skillId: 'reader-skill',
    name: '阅读技能',
    allowedTools: ['city-weather', 'local-text-read'],
    sourceKinds: ['mcp'],
    capabilitySelectors: [
        { providerKind: 'mcp', location: 'local', capabilityType: 'tool', names: ['city-weather'] },
        { providerKind: 'mcp', location: 'local', capabilityType: 'resource', names: ['local-text-read'] },
        { providerKind: 'mcp', location: 'local', capabilityType: 'prompt', names: ['local-file-summary'] },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'resource' },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'prompt' },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'tool' },
    ],
    fallbackPolicy: 'direct-answer',
}

这段配置的意义不在于“列了一堆能力”,而在于它把 Skill 的边界声明出来了。

reader-skill 可以承接本地 MCP Tool、Resource、Prompt,也可以承接来自 project-assistant-service(本版本新增的远端 MCP 服务)的三类 remote capability。但这不代表它拥有通用调度权。它只是声明:这些能力属于我的可承接范围。

真正是否执行,仍然由 Runtime 在具体上下文里决定。

这样 Skill 就没有偷偷长成上层调度器。


4. Prompt 为什么要成为一等 Capability

我在本版本很想强调 Prompt。

在很多 AI 应用里,Prompt 容易被当成“内部模板文件”,或者被塞进 Tool 调用前后的某段字符串里。短期看没问题,长期看会让能力面变得不完整。

如果 Tool 是“做一个动作”,Resource 是“取一段上下文”,那 Prompt 就应该是“生成一组可注入模型上下文的任务消息”。

它不是 Tool。

因为 Prompt 本身不执行外部动作,也不应该伪装成一次 tool call。

它也不是 Resource。

因为模板文件只是 Prompt 的存储介质,不等于 Prompt capability 本身。真正被消费的是“带参数注入后的 Prompt 消息”。

所以本版本补了两个 Prompt:

  • local-file-summary(本地文件总结 Prompt,通过 project-files-server 暴露)
  • tasklist-draft(远端 tasklist 草稿 Prompt,通过 project-assistant-service 暴露)

本地 Prompt 的运行时消费落在 prompt-context.ts(本地 Prompt 上下文注入模块,负责判断是否需要读取 prompt、注入参数并转换成模型消息):

export function resolvePromptContextInvocation(
    request: ChatRequest,
    executedToolResults: ExecutedToolResult[]
): PromptContextInvocation | null {
    const userGoal = getLastUserMessageText(request)

    if (!shouldUseLocalFileSummaryPrompt(userGoal)) {
        return null
    }

    const localTextReadResult = getLatestSuccessfulLocalTextReadResult(executedToolResults)

    if (!localTextReadResult) {
        return null
    }

    const filename = getLocalTextReadFilename(localTextReadResult.toolCall)

    if (!filename) {
        return null
    }

    return {
        promptName: LOCAL_FILE_SUMMARY_PROMPT_NAME,
        source: 'mcp',
        location: 'local',
        serverId: LOCAL_FILE_SUMMARY_SERVER_ID,
        input: formatPromptInvocationInput(filename, userGoal),
        execute: () => buildLocalSummaryPromptContextMessages(filename, userGoal),
    }
}

这段代码解决的是“Prompt 如何进入最终回答上下文”的问题。

它的执行链是:

  1. 用户先触发文件读取,例如读取 README.md
  2. Runtime 拿到最近一次成功的 local-text-read 结果
  3. 判断当前用户目标是否需要总结、摘要、提炼
  4. 获取 local-file-summary Prompt
  5. 注入 filename / content / userGoal
  6. 把 Prompt 消息转成模型上下文

这里我只把 Prompt 当成一类执行事实展示出来。前端需要稳定知道的是:

  • 哪个 Prompt 被使用了
  • 来自哪里
  • 属于 local 还是 remote
  • 注入了几条上下文消息
  • 是否失败

至于内部 Prompt 模板正文,则继续留在 Runtime 和模型上下文里,不作为前端展示重点。

ai-2.png


5. Remote MCP 只验证最小闭环,不做远程业务平台

本版本确实新增了一个 remote MCP server。

这里的 remote 很朴素:它不在 apps/webapp 进程内,而是一个独立服务,通过 Streamable HTTP 被 Webapp 消费。

但它的定位非常克制:只验证 remote capability 最小闭环。

新增服务是 apps/project-assistant-service(独立 NestJS 服务,当前只承载 remote MCP mock capability)。它通过官方 MCP SDK 暴露三类能力:

  • Resource:project://latest-context
  • Prompt:tasklist-draft
  • Tool:check_doc_consistency

服务侧注册能力的代码在 mcp-capability.service.ts(远端 MCP 能力注册服务,负责创建 MCP server 并注册 Resource / Prompt / Tool)。这里我只保留了“每类 capability 一个最小 mock”的形态,用来证明远端能力面可以成立。

我没有在这里接数据库,没有接第三方 API,也没有做远程文件系统。三个 capability 都是 mock 数据。这样做不是因为远端能力不重要,而是因为本版本要验证的是另一件事:

Webapp 作为 MCP 消费端,能不能通过 remote Streamable HTTP 稳定消费 Resource / Prompt / Tool 三类能力,并把执行事实并入当前聊天主链?

Webapp 侧的 server definition 也保持很明确:transport=streamable-httplocation=remoteserverId=project-assistant-service,并声明它同时具备 prompts / resources / tools 三类 capability。

这层配置解决的是“远端 MCP server 如何进入 Webapp 能力注册表”的问题。

这里我只做了 mock Bearer Token:

  • 无 token:unauthorized
  • 错 token:forbidden
  • 正确 token:正常连接

没有做用户态登录透传,也没有做 OAuth。

这也是本版本的边界:remote MCP 是为了验证 remote capability surface 和 runtime 消费链路,不是为了提前做一个生产级远程业务平台。


6. Capability Metadata 不能只用于展示,必须进入 Runtime

如果 capability 只停留在 catalog 和前端展示,本版本其实还不完整。

真正让我觉得本版本站住了的,是 Capability Metadata 进入了 Runtime 消费闭环

先用一个真实请求把链路放具体一点。

当我在前端输入:

帮我检查一下当前文档之间有没有明显不一致的地方

我希望它不是直接让模型凭空回答,而是先命中 reader-skill,再确认这个 Skill 是否声明过可承接 check_doc_consistency 这类 remote tool capability。确认通过后,Runtime 才去调用 project-assistant-service 暴露的 remote Tool,并把结果注入最终回答。

也就是说,reader-skill 已经声明的 capabilitySelectors,不能只是文档信息。Runtime 必须真正基于这层声明决定本轮能不能消费某个 capability。

对应模块是 capability-context.ts(最小 remote capability 消费层,只处理 reader-skill 下本版本固定远端能力,不做通用 planner)。

先看入口:

export function resolveCapabilityContextInvocations(request: ChatRequest, skillDefinition?: SkillDefinition): RemoteCapabilityInvocation[] {
    if (skillDefinition?.skillId !== 'reader-skill') {
        return []
    }

    const userGoal = getLastUserMessageText(request)
    const invocations: RemoteCapabilityInvocation[] = []
    const candidates: Array<[RemoteCapabilityName, CapabilityType, boolean]> = [
        [LATEST_CONTEXT_RESOURCE_NAME, 'resource', matchesAny(userGoal, PROJECT_CONTEXT_PATTERNS)],
        [TASKLIST_DRAFT_PROMPT_NAME, 'prompt', matchesAny(userGoal, TASKLIST_DRAFT_PATTERNS)],
        [DOC_CONSISTENCY_TOOL_NAME, 'tool', matchesAny(userGoal, DOC_CONSISTENCY_PATTERNS)],
    ]

    for (const [name, capabilityType, matched] of candidates) {
        const identity = createRemoteCapabilityIdentity(name, capabilityType)

        if (matched && isRemoteCapabilityAllowed(skillDefinition, identity)) {
            invocations.push(createRemoteCapabilityInvocation(name, capabilityType, userGoal))
        }
    }

    return invocations
}

这段代码解决的是“Skill metadata 如何进入 runtime 判断”的问题。

这里有两个约束很重要:

  1. 只有命中 reader-skill 才会进入这层 remote capability 消费。
  2. 即使用户输入命中了高置信规则,也必须通过 isRemoteCapabilityAllowed() 检查 capabilitySelectors

也就是说,Runtime 不会绕过 Skill 声明去随便调远端能力。

执行阶段也没有把三类 capability 强行揉成一种协议,而是保留各自语义:

function createRemoteCapabilityInvocation(
    name: RemoteCapabilityName,
    capabilityType: CapabilityType,
    userGoal: string
): RemoteCapabilityInvocation {
    const invocation: RemoteCapabilityInvocation = {
        capabilityType,
        execute: async options => {
            if (capabilityType === 'resource') {
                return executeRemoteResourceInvocation(invocation, options)
            }

            if (capabilityType === 'prompt') {
                return executeRemotePromptInvocation(invocation, options)
            }

            return executeRemoteToolInvocation(invocation, options)
        },
        input: capabilityType === 'resource' ? LATEST_CONTEXT_RESOURCE_URI : `goal=${userGoal}`,
        location: 'remote',
        name,
        serverId: PROJECT_ASSISTANT_SERVER_ID,
        source: 'mcp',
    }

    return invocation
}

这段代码体现了本版本的核心取舍:

  • invocation 形态是统一的
  • 但 Resource / Prompt / Tool 的执行语义不是统一的

Resource 会 readResource(),并把完整内容作为模型上下文。

Prompt 会 getPrompt(),并把返回 messages 转成模型上下文。

Tool 会 callTool(),并把执行结果作为最终回答依据。

如果某个 capability 失败,也不会直接打断整轮对话。Runtime 会写出统一错误 chunk,再注入一条“能力不可用”的上下文,让最终回答不要编造结果。

这个点很小,但非常关键。

因为这意味着 capability metadata 不再只是“给人看”的资料,而是真的进入了 Runtime 决策。


7. 前端为什么要承接执行事实

AI 应用的前端如果只承接最终答案,很多运行时事实会被藏起来。

这在普通聊天里问题不大,但一旦系统开始接 Tool、Resource、Prompt、MCP、Skill,前端就需要承接更多运行时事实。它不只是服务用户感知,也是在帮整个系统保持技术完整性:Runtime 写出了什么,前端就能稳定接住什么。

本版本扩展了流式协议:

export interface SkillSelectedChunk {
    type: 'skill-selected'
    skillId: string
    name: string
    description?: string
}

export interface PromptStartChunk {
    type: 'prompt-start'
    partId: string
    promptName: string
    source?: 'internal' | 'mcp'
    location?: 'local' | 'remote'
    serverId?: string
    input?: string
}

export interface PromptEndChunk {
    type: 'prompt-end'
    partId: string
    promptName: string
    source?: 'internal' | 'mcp'
    location?: 'local' | 'remote'
    serverId?: string
    status: 'completed' | 'failed'
    messageCount?: number
}

这段协议解决的是“前端消息模型如何稳定承接 Skill 命中和 Prompt 执行事实”的问题。

同时,原来的 Tool / Resource chunk 也补上了 source / location / serverId。这样前端就不只是知道“调用了一个工具”,还知道:

  • capability 类型是什么
  • 来源是 internal 还是 mcp
  • 位置是 local 还是 remote
  • 属于哪个 serverId
  • 当前状态是 called、completed 还是 failed

前端消费逻辑落在 use-chat-stream.ts(聊天流消费 hook,负责把 NDJSON chunk 合并成前端消息 part):

case 'skill-selected': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current => appendPart(current, messageId, createSkillPart(chunk.skillId, chunk.name, chunk.description)))
    return
}

case 'prompt-start': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        appendPart(
            current,
            messageId,
            createPromptPart(chunk.partId, chunk.promptName, 'called', chunk.source, chunk.location, chunk.serverId, chunk.input)
        )
    )
    return
}

case 'prompt-end': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        updatePromptPart(current, messageId, chunk.partId, part => ({
            ...part,
            promptName: chunk.promptName,
            source: chunk.source ?? part.source,
            location: chunk.location ?? part.location,
            serverId: chunk.serverId ?? part.serverId,
            status: chunk.status,
            messageCount: chunk.messageCount,
        }))
    )
    return
}

这段代码解决的是“流式协议如何落到前端消息结构”的问题。

最终前端不只是渲染答案,而是会把这些运行时事实沉淀成消息 part:

  • Skill 命中:阅读技能
  • Prompt 注入:tasklist-draft
  • Resource 读取:latest-context
  • Tool 执行:check_doc_consistency
  • 来源:MCP
  • 位置:remote
  • 服务:project-assistant-service

ai-3.png

我很喜欢这一步,因为它让 Runtime 不再像一个黑盒。

在调试、观察或复盘一轮回答时,我们能知道这轮回答依赖了哪个 Skill,消费了哪类 capability,来自本地还是远端。

这不是 UI 小修,而是协议层、运行时和产品表达一起往前走了一步。


8. 本版本刻意没做什么

这篇文章的主角是 Capability Surface,所以边界也要说清楚。

本版本刻意没有做这些事:

  • 不做 Agent
  • 不做 workflow
  • 不做多 remote server 编排
  • 不做模型自由规划任意 capability 调用
  • 不做复杂 OAuth 或账号体系
  • 不接数据库
  • 不接第三方 API
  • 不做 remote 文件系统
  • 不把 Tool / Resource / Prompt 强行抽成同一条执行链

这些不是“以后都不做”,而是本版本先不做。

因为当前更需要验证的是:

  • Capability 能不能先被统一描述?
  • Skill 能不能先声明自己可承接的能力范围?
  • Prompt 能不能成为 Tool / Resource 之外的一等 capability?
  • Remote MCP 能不能用一个 server 跑通最小闭环?
  • Metadata 能不能真正进入 Runtime,而不是停留在展示?
  • 前端消息模型能不能把执行事实承接下来?

这几个问题没有先收住,继续往更高层计划与执行走,复杂度会涨得很快。


9. 回到这次实践,我得到的几个判断

做完这一轮后,我对 AI Runtime 里的能力层有了一个更明确的判断:

做更完整的 Agent Runtime 之前,最好先有 Capability Surface。

更具体一点,我收获了 4 个判断。

第一,Capability Model 应该先是一层描述模型

它不需要一开始就包办执行链。先把 Tool / Resource / Prompt 的身份、来源、位置、可用性描述清楚,就已经很有价值。

第二,Skill Metadata 是 Skill 的表面,不是 planner

capabilitySelectors 用来表达 Skill 可承接什么能力,而不是让 Skill 变成 workflow 引擎。

第三,Prompt 应该是一等 capability

Prompt 不应该长期伪装成 Tool,也不应该只作为 Resource 背后的模板文件存在。它有自己的生命周期、参数注入方式和前端执行事实。

第四,Remote MCP 可以先做最小闭环

一个 remote server,三类 mock capability,一套 Streamable HTTP transport,一个 mock token,已经足够验证 MCP 接入层、Runtime、Skill、Protocol、Frontend 是否能跑通。

对我来说,本版本最有价值的地方,不是项目多了一个 project-assistant-service,也不是前端多了几张卡片。

真正的价值是:Capability 从“能被列出来”走到了“能被 Skill 声明、能被 Runtime 消费、能被前端消息模型承接”。

这才是从接入 MCP 继续往上层运行时走之前,我认为应该补上的一层。


10. 后续我会怎么继续往前推

短期内,我不会急着把这层扩成通用 Agent Runtime。

更合理的节奏是:

  • 先继续观察 Capability Model 是否足够承载更多本地 / 远端能力
  • 再考虑 remote MCP discovery 或 server 配置化
  • 再让更多 Skill 基于 capabilitySelectors 消费稳定能力
  • 最后再谈 Agent Runtime 如何基于这些能力做计划、执行和继续决策

换句话说,这条线更像从接入 MCP 走到 Capability Surface,再让 Skill Runtime 稳定消费更多 MCP 能力来源,最后再进入 Agent Runtime。

我现在更愿意先把中间这层做扎实。


项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章刚好对正在做 AI Runtime、MCP 接入、Tool Calling 或 Skill 分层的同路人有一点参考价值,欢迎来仓库里看看。

如果大家也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。后面我会继续沿着 Capability Surface、MCP 能力治理、Skill Runtime 和 Agent Runtime,把这套骨架一点点往前推。

Next.js从入门到实战保姆级教程(第十四章):性能优化深度实践

2026年4月26日 09:45

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

"过早优化是万恶之源"——Donald Knuth。但这并非否定优化的价值,而是强调先测量,再优化的原则。缺乏数据支撑的优化往往偏离正确方向,甚至适得其反。

一、Core Web Vitals 核心指标

Google 将三个关键用户体验指标定义为 Core Web Vitals,直接影响搜索排名和用户留存率:

graph LR
    root((Core Web Vitals))
    LCP["LCP<br/>最大内容绘制<br/>(加载速度)"] -->|目标| LCP_GOOD[< 2.5s]
    INP["INP<br/>交互响应时间<br/>(响应速度)"] -->|目标| INP_GOOD[< 200ms]
    CLS["CLS<br/>累积布局偏移<br/>(视觉稳定性)"] -->|目标| CLS_GOOD[< 0.1]
    root-->LCP
    root-->INP
    root-->CLS

1. 指标详解

指标 全称 含义 优秀阈值 影响权重
LCP Largest Contentful Paint 最大内容元素渲染完成时间 < 2.5s 25%
INP Interaction to Next Paint 用户交互到页面响应时间 < 200ms 25%
CLS Cumulative Layout Shift 页面加载过程布局跳动总量 < 0.1 25%

(1)LCP(最大内容绘制)
衡量首屏主要内容的加载速度,通常是 Hero 图片、主标题或关键文本块。优化重点在于减少资源加载时间和渲染阻塞。

(2)INP(交互响应时间)
替代了之前的 FID(First Input Delay),更全面地评估页面整个生命周期内的交互响应能力。包括点击、按键、触摸等所有用户交互。

(3)CLS(累积布局偏移)
量化页面加载过程中元素位置意外变化的程度。常见原因包括图片未设置尺寸、动态插入广告、字体切换等。

2. 测量工具链

(1) 开发阶段

Vercel平台提供了两个核心分析工具,专门用于监控和优化部署在Vercel上的Next.js应用的性能与用户行为。但是你可以将它用在开发环境上,虽然不会发送数据,但是可以使用它结合控制台来测量性能数据。

// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next';
import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ 
  children 
}: { 
  children: React.ReactNode 
}) {
  return (
    <html lang="zh-CN">
      <body>
        {children}
        {/* Vercel 提供的性能监控组件 */}
        <SpeedInsights />
        <Analytics />
      </body>
    </html>
  );
}

(2)生产环境监控

当应用上线后,你的目标变成了**“监控真实用户体验”** 和 “数据驱动业务决策”。你可以需要更细粒度的控制和数据上报。这时候你可以选择使用web-vitals库。

// app/layout.tsx
import type { ReportHandler } from 'web-vitals';

/**
 * 上报 Web Vitals 指标
 * @param metric - 性能指标对象
 */
export function reportWebVitals(metric: ReportHandler) {
  // 发送到自定义分析服务
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    label: metric.label,
    timestamp: Date.now(),
  });

  // 使用 sendBeacon 确保数据可靠发送
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/analytics', body);
  } else {
    fetch('/api/analytics', {
      method: 'POST',
      body,
      keepalive: true,
    });
  }

  // 或发送到 Google Analytics
  if (window.gtag) {
    window.gtag('event', metric.name, {
      value: Math.round(
        metric.name === 'CLS' ? metric.value * 1000 : metric.value
      ),
      event_label: metric.id,
      non_interaction: true,
    });
  }
}

二、代码分割与懒加载

1. 动态导入:按需加载组件

Next.js 默认对每个路由自动进行代码分割,但针对大型第三方库非首屏组件,可进一步优化:

// components/LazyChart.tsx
import dynamic from 'next/dynamic';

/**
 * 重型图表组件 - 动态导入
 * 仅在用户滚动到可视区域时加载
 */
const HeavyChart = dynamic(
  () => import('@/components/HeavyChart'),
  {
    loading: () => (
      <div className="h-64 bg-gray-100 animate-pulse rounded-lg flex items-center justify-center">
        <span className="text-gray-400">图表加载中...</span>
      </div>
    ),
    ssr: false,  // 禁用服务端渲染
  }
);

/**
 * 富文本编辑器 - 仅编辑时加载
 */
const RichTextEditor = dynamic(
  () => import('@/components/RichTextEditor'),
  {
    ssr: false,
    loading: () => (
      <div className="min-h-[200px] border rounded-lg p-4 bg-gray-50">
        <div className="animate-pulse space-y-2">
          <div className="h-4 bg-gray-200 rounded w-3/4"></div>
          <div className="h-4 bg-gray-200 rounded w-full"></div>
          <div className="h-4 bg-gray-200 rounded w-5/6"></div>
        </div>
      </div>
    ),
  }
);

export { HeavyChart, RichTextEditor };

2. ssr: false 的使用场景

某些库(比如地图组件、富文本编辑器组件)依赖浏览器环境 API(windowdocumentCanvas),在服务端渲染时会报错。此时需设置 ssr: false:

// 地图组件
const MapComponent = dynamic(
  () => import('@/components/Map'),
  {
    ssr: false,
    loading: () => (
      <div className="h-96 bg-gray-200 rounded-lg flex items-center justify-center">
        <div className="text-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
          <span className="text-gray-600">地图加载中...</span>
        </div>
      </div>
    ),
  }
);

常见需要 ssr: false 的库:

  • 地图库(Leaflet、Mapbox GL JS)
  • 部分图表库(D3.js、Chart.js)
  • 实时协作编辑器(Tiptap、ProseMirror)
  • Canvas 绘图库(Fabric.js、Konva)
  • WebGL 相关库(Three.js)

三、使用缓存策略优化性能

缓存是性能优化的重要组成部分,合理的使用缓存,可以获得不错的效果。

Next.js缓存策略的深度剖析请阅读《从原理到实践深度剖析缓存策略》,本文将简单带过。

Next.js 提供四层缓存机制,理解其工作原理对性能优化至关重要:

graph TB
    Request[用户请求] --> RC[Request Memoization<br/>请求记忆化<br/>单次请求周期内去重]
    RC --> DC[Data Cache<br/>数据缓存<br/>跨请求持久化存储]
    DC --> FC[Full Route Cache<br/>完整路由缓存<br/>HTML + RSC Payload]
     DC[Data Cache<br/>数据缓存]
     FC[Full Route Cache<br/>完整路由缓存]
    FC --> RRC[Router Cache<br/>路由缓存<br/>客户端内存缓存]
    RRC --> User[返回给用户]
    
    style RC fill:#e1f5ff
    style DC fill:#fff4e1
    style FC fill:#e8f5e9
    style RRC fill:#fce4ec

1. 数据缓存配置策略

// 1. 永久缓存(静态内容)
const siteConfig = await fetch('/api/site-config', {
  cache: 'force-cache',  // 类似 SSG,构建时获取,永久缓存
});

// 2. 实时数据(不缓存)
const livePrices = await fetch('/api/crypto-prices', {
  cache: 'no-store',  // 每次请求获取最新数据
});

// 3. 定时重新验证(推荐用于大多数场景)
const blogPosts = await fetch('/api/posts', {
  next: { revalidate: 3600 },  // 每小时重新验证
});

// 4. 按标签失效(最灵活的方案)
const userProfile = await fetch(`/api/users/${userId}`, {
  next: { tags: ['user', `user-${userId}`] },
});

2. 缓存失效管理

使用 revalidateTag, revalidatePath手动让缓存失效。

// app/actions/user.ts
'use server';

import { revalidateTag, revalidatePath } from 'next/cache';

/**
 * 更新用户信息并失效相关缓存
 */
export async function updateUserProfile(
  userId: string,
  data: UserProfileUpdate
) {
  // 更新数据库
  await db.users.update({
    where: { id: userId },
    data,
  });

  // 方式一: 按标签失效(推荐)
  revalidateTag(`user-${userId}`);
  revalidateTag('users');

  // 方式二: 按路径失效
  revalidatePath(`/profile/${userId}`);
  revalidatePath('/users');

  return { success: true };
}

3. 缓存策略选择指南

数据类型 更新频率 推荐策略 示例
网站配置(Logo、导航) 极少 force-cache + 发布时重建 公司信息、联系方式
博客文章列表 每天数次 revalidate: 3600 CMS 内容
产品价格 实时变化 no-storerevalidate: 60 电商商品、股票价格
用户个人数据 操作后立即 no-store + 按标签失效 个人资料、订单状态
新闻/动态信息 频繁更新 revalidate: 300 社交媒体 feed
统计数据 每小时 revalidate: 3600 仪表盘数据

四、Bundle 分析与优化

过大的 JavaScript bundle 是 LCP 的主要杀手。Next.js 内置 Bundle Analyzer 工具:

安装与配置

npm install @next/bundle-analyzer
// next.config.ts
import type { NextConfig } from 'next';
import withBundleAnalyzer from '@next/bundle-analyzer';

const config: NextConfig = {
  // 你的其他配置
};

const withAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});

export default withAnalyzer(config);
# 运行分析
ANALYZE=true npm run build

这将打开交互式可视化界面,展示每个包的大小和依赖关系。

1. 常见优化策略

(1)替换重型依赖

导入依赖时采用按需导入的方式。

// ❌ 导入整个 lodash (~70KB gzipped)
import _ from 'lodash';
const grouped = _.groupBy(items, 'category');

// ✅ 按需导入 (几 KB)
import groupBy from 'lodash/groupBy';
const grouped = groupBy(items, 'category');

// ✅ 更好: 使用原生 JavaScript
const grouped = items.reduce<Record<string, typeof items>>((acc, item) => {
  const key = item.category;
  if (!acc[key]) acc[key] = [];
  acc[key].push(item);
  return acc;
}, {});

(2) 日期处理库优化

使用支持按需导入(模块化)、更轻量的库代替moment库。

// ❌ moment.js (~300KB gzipped)
import moment from 'moment';
const formatted = moment(date).format('YYYY-MM-DD');

// ✅ date-fns (按需导入,几 KB)
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';

const formatted = format(new Date(date), 'yyyy年MM月dd日', {
  locale: zhCN,
});

(3) 图标库优化

// ❌ 导入整个图标库
import { Icon } from '@iconify/react';

// ✅ 按需导入单个图标
import { HomeIcon } from '@heroicons/react/24/outline';
import { UserIcon } from '@heroicons/react/24/outline';

// 使用
<HomeIcon className="w-6 h-6" />

2. Bundle 优化检查清单

  • 移除未使用的依赖(npm prune)
  • 使用 Tree Shaking 友好的库
  • 避免重复打包相同库
  • 大型库使用动态导入
  • 定期运行 Bundle Analyzer 审查

五、数据库查询优化

Next.js 服务端组件直接查询数据库的特性,使得数据库最佳实践尤为重要。数据库的查询优化也是性能优化的一大组成部分。

1. N+1 查询问题

// ❌ N+1 问题: 21 次数据库查询
const posts = await db.posts.findMany({ take: 20 });
const postsWithAuthors = await Promise.all(
  posts.map(async post => ({
    ...post,
    author: await db.users.findUnique({ 
      where: { id: post.authorId } 
    }),
  }))
);

// ✅ 使用 include 关联查询: 1 次查询
const postsWithAuthors = await db.posts.findMany({
  take: 20,
  include: {
    author: {
      select: { 
        id: true, 
        name: true, 
        avatar: true 
      },
    },
  },
});

2. 分页查询实现

// lib/pagination.ts
interface PaginationResult<T> {
  data: T[];
  nextCursor: string | null;
  hasNextPage: boolean;
}

/**
 * 游标分页查询(适合无限滚动)
 */
export async function getPostsWithCursor(
  cursor?: string,
  pageSize = 10
): Promise<PaginationResult<Post>> {
  const posts = await db.posts.findMany({
    take: pageSize + 1,  // 多取一条判断是否有下一页
    ...(cursor && {
      skip: 1,
      cursor: { id: cursor },
    }),
    orderBy: { createdAt: 'desc' },
    select: {
      id: true,
      title: true,
      summary: true,
      publishedAt: true,
      author: {
        select: { name: true, avatar: true },
      },
    },
  });

  const hasNextPage = posts.length > pageSize;
  const data = hasNextPage ? posts.slice(0, -1) : posts;
  const nextCursor = hasNextPage ? data[data.length - 1].id : null;

  return { data, nextCursor, hasNextPage };
}

六、并行数据获取

串行数据获取会产生"瀑布效应",显著增加页面加载时间:

// ❌ 串行获取: 总时间 = A + B + C = 330ms
const userProfile = await getUserProfile(userId);     // 100ms
const userPosts = await getUserPosts(userId);         // 150ms
const followerCount = await getFollowerCount(userId); // 80ms

// ✅ 并行获取: 总时间 = max(A, B, C) = 150ms
const [userProfile, userPosts, followerCount] = await Promise.all([
  getUserProfile(userId),
  getUserPosts(userId),
  getFollowerCount(userId),
]);

性能提升: 从 330ms 降至 150ms,提升 54%


七、性能优化检查清单

部署前使用此清单进行全面检查:

1. 图片优化

  • 使用 <Image> 组件替代 <img>
  • 首屏图片添加 priority 属性
  • 设置正确的 sizes 属性
  • 使用 placeholder="blur" 改善加载体验
  • 外部图片源已配置白名单

2.JavaScript 优化

  • 大型第三方库使用 dynamic() 懒加载
  • 避免导入整个 lodashmoment 等大型库
  • 运行 Bundle Analyzer 检查包大小
  • 移除未使用的依赖

3. 缓存优化

  • 根据数据更新频率设置合适的缓存策略
  • 使用 revalidateTag 按需失效缓存
  • 静态内容使用 force-cache
  • 实时数据使用 no-store

4. 数据获取优化

  • 多个独立请求使用 Promise.all 并行获取
  • 避免 N+1 查询问题,使用 ORM 的 include
  • 实现分页,避免一次加载全量数据
  • 使用数据库索引优化查询性能

5. 渲染优化

  • 首屏重要内容在服务端渲染(SSR/SSG)
  • 使用 Suspense 局部加载,避免全页等待
  • 客户端组件控制在必要的最小范围
  • 避免不必要的 useEffect 和重渲染

6. Core Web Vitals

  • LCP < 2.5s
  • INP < 200ms
  • CLS < 0.1
  • 已配置 Web Vitals 监控

八、本章小结

通过本章学习,你应该掌握了:

  • Core Web Vitals 三大核心指标及其优化方法
  • 代码分割与动态导入的使用场景
  • Next.js 四层缓存机制的工作原理
  • Bundle 分析工具的使用和优化策略
  • 数据库查询优化技巧(N+1 问题、分页)
  • 并行数据获取的性能优势
  • 完整的性能优化检查清单

下一章将进入部署与运维环节——将精心优化的应用成功推向生产环境。

Next.js从入门到实战保姆级教程(第十七章):综合实战项目(下)——前端页面、性能优化与部署

2026年4月27日 08:11

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

这是全栈博客系统实战的下篇。在上篇《全栈博客系统架构与核心功能》中,我们完成了数据库设计、认证系统、Server Actions 等后端核心功能。本篇将聚焦前端页面开发、用户体验优化和生产部署,带你完成从代码到上线的完整流程。

一、📖 前置准备

在开始之前,请确保你已经:

  • ✅ 完成了上篇的所有内容
  • ✅ 数据库已初始化并运行
  • ✅ Auth.js 配置完成
  • ✅ Server Actions 可以正常调用

如果还没有,建议先回顾上篇内容《博客系统架构与核心功能》


二、🎨 Markdown 渲染与代码高亮

1. 为什么选择 MDX?

传统 Markdown 的局限性:

  • ❌ 无法使用 React 组件
  • ❌ 交互功能受限
  • ❌ 动态内容难以集成

MDX (Markdown + JSX) 的优势:

  • ✅ 在 Markdown 中嵌入 React 组件
  • ✅ 支持自定义渲染逻辑
  • ✅ 完美的 TypeScript 类型支持

例如,你可以在文章中这样写:

这是一段普通文本。

<Callout type="info">
  这是一个提示框组件!
</Callout>

```javascript
console.log('代码块自动高亮');
```

2. 安装依赖

npm install next-mdx-remote shiki rehype-autolink-headings rehype-slug

依赖说明:

  • next-mdx-remote: 在服务端安全地渲染 MDX
  • shiki: VS Code 同款语法高亮引擎(比 Prism.js 更准确)
  • rehype-autolink-headings: 自动为标题添加锚点链接
  • rehype-slug: 为标题生成 ID

3. 创建 MDX 渲染器组件

创建 components/MDXRenderer.tsx:

// components/MDXRenderer.tsx
import { MDXRemote } from 'next-mdx-remote/rsc';
import { serialize } from 'next-mdx-remote/serialize';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import { CodeBlock } from './CodeBlock';
import { Callout } from './Callout';

interface MDXRendererProps {
  content: string;
}

/**
 * MDX 内容渲染器
 * 
 * 工作流程:
 * 1. serialize: 将 Markdown 字符串编译为 MDX AST
 * 2. MDXRemote: 在服务端渲染为 HTML
 * 3. components: 自定义组件映射表
 * 
 * @param content - Markdown 内容
 */
export async function MDXRenderer({ content }: MDXRendererProps) {
  // 序列化 MDX 内容
  const mdxSource = await serialize(content, {
    mdxOptions: {
      rehypePlugins: [
        rehypeSlug,  // 先生成 slug
        [rehypeAutolinkHeadings, { 
          behavior: 'wrap',  // 将整个标题包装为链接
          properties: {
            className: ['anchor-link'],
          },
        }],
      ],
    },
  });

  return (
    <article className="prose prose-lg max-w-none dark:prose-invert prose-headings:relative">
      <MDXRemote
        {...mdxSource}
        components={{
          // 自定义代码块渲染
          pre: CodeBlock,
          // 自定义提示框
          Callout,
          // 可以添加更多自定义组件
          img: CustomImage,
          a: CustomLink,
        }}
      />
    </article>
  );
}

/**
 * 自定义图片组件(懒加载)
 */
function CustomImage(props: React.ImgHTMLAttributes<HTMLImageElement>) {
  return (
    <img 
      {...props} 
      loading="lazy"  // 懒加载
      className="rounded-lg shadow-md"
    />
  );
}

/**
 * 自定义链接组件(外部链接新窗口打开)
 */
function CustomLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
  const href = props.href;
  const isExternal = href?.startsWith('http');

  return (
    <a
      {...props}
      {...(isExternal && {
        target: '_blank',
        rel: 'noopener noreferrer',
      })}
      className="text-blue-600 hover:underline"
    />
  );
}

🔍 代码解析:

(1) 为什么使用 next-mdx-remote/rsc?

import { MDXRemote } from 'next-mdx-remote/rsc';  // RSC 版本
  • RSC 版本: 在服务端渲染,性能更好
  • 客户端版本: next-mdx-remote/client,用于交互式 MDX

(2) Rehype 插件的作用

rehypePlugins: [
  rehypeSlug,  // 为 h1-h6 添加 id 属性
  [rehypeAutolinkHeadings, { behavior: 'wrap' }],  // 将标题变为可点击链接
]

执行顺序很重要:

  1. rehypeSlug 先执行,生成 id="introduction"
  2. rehypeAutolinkHeadings 后执行,包裹为 <a href="#introduction"><h2>...</h2></a>

(3) Components 映射表

components={{
  pre: CodeBlock,  // 替换所有 <pre> 标签
  Callout,         // 支持自定义 <Callout> 组件
}}

当 MDX 中出现 <pre> 时,会自动使用 CodeBlock 组件渲染。

4. 代码高亮组件

创建 components/CodeBlock.tsx:

// components/CodeBlock.tsx
import { codeToHtml } from 'shiki';

interface CodeBlockProps {
  children: React.ReactNode;
  className?: string;
}

/**
 * 代码块组件(带语法高亮)
 * 
 * Shiki 优势:
 * - 使用 TextMate grammar,与 VS Code 一致
 * - 支持主题切换
 * - 输出静态 HTML,无运行时 JS
 */
export async function CodeBlock({ children, className }: CodeBlockProps) {
  // 提取语言信息(如 language-jsx)
  const match = /language-(\w+)/.exec(className || '');
  const lang = match ? match[1] : 'text';
  
  // 获取代码内容
  const code = String(children).replace(/\n$/, '');

  // 使用 Shiki 生成高亮 HTML
  const html = await codeToHtml(code, {
    lang,
    theme: 'github-dark',  // 可切换主题
  });

  return (
    <div className="relative my-6 rounded-lg overflow-hidden">
      {/* 语言标签 */}
      <div className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">
        {lang}
      </div>
      
      {/* 高亮代码 */}
      <div 
        dangerouslySetInnerHTML={{ __html: html }}
        className="overflow-x-auto"
      />
    </div>
  );
}

⚡ 性能优化:

Shiki 是异步的,所以组件必须是 async:

export async function CodeBlock({ ... }) {
  const html = await codeToHtml(code, { ... });
  // ...
}

Next.js 会在服务端等待异步操作完成,然后缓存结果。

5. 提示框组件

创建 components/Callout.tsx:

// components/Callout.tsx
interface CalloutProps {
  type?: 'info' | 'warning' | 'error' | 'success';
  children: React.ReactNode;
}

const icons = {
  info: '💡',
  warning: '⚠️',
  error: '❌',
  success: '✅',
};

const styles = {
  info: 'bg-blue-50 border-blue-200 text-blue-800',
  warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
  error: 'bg-red-50 border-red-200 text-red-800',
  success: 'bg-green-50 border-green-200 text-green-800',
};

/**
 * 提示框组件
 * 
 * 使用示例:
 * <Callout type="warning">
 *   这是一个警告提示
 * </Callout>
 */
export function Callout({ type = 'info', children }: CalloutProps) {
  return (
    <div className={`p-4 my-4 border-l-4 rounded ${styles[type]}`}>
      <div className="flex items-start gap-3">
        <span className="text-xl">{icons[type]}</span>
        <div className="flex-1">{children}</div>
      </div>
    </div>
  );
}

三、🏠 首页文章列表

1. 页面结构

创建 app/page.tsx:

// app/page.tsx
import { getPosts } from '@/lib/posts';
import Link from 'next/link';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';

// ==================== 元数据 ====================

export const metadata = {
  title: '全栈博客 - 分享技术与思考',
  description: '专注于 Next.js、React、TypeScript 等现代 Web 开发技术',
};

// ==================== 缓存策略 ====================

/**
 * 每小时重新验证一次
 * 
 * 为什么不是静态生成?
 * - 文章可能频繁更新
 * - 需要显示最新评论数、点赞数
 * - revalidate 平衡了性能和时效性
 */
export const revalidate = 3600;

// ==================== 页面组件 ====================

export default async function HomePage() {
  // 获取第一页的 10 篇文章
  const { posts, pagination } = await getPosts({ 
    page: 1, 
    pageSize: 10 
  });

  return (
    <div className="container mx-auto px-4 py-8">
      {/* Hero 区域 */}
      <section className="mb-12 text-center">
        <h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
          全栈博客
        </h1>
        <p className="text-xl text-gray-600">
          分享 Next.js、React、TypeScript 等现代 Web 开发技术
        </p>
      </section>

      {/* 文章列表 */}
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>

      {/* 分页 */}
      {pagination.totalPages > 1 && (
        <Pagination 
          currentPage={pagination.page}
          totalPages={pagination.totalPages}
        />
      )}

      {/* 空状态 */}
      {posts.length === 0 && (
        <EmptyState />
      )}
    </div>
  );
}

/**
 * 文章卡片组件
 */
function PostCard({ post }: { post: any }) {
  return (
    <article className="group border rounded-lg overflow-hidden hover:shadow-lg transition-all duration-300">
      {/* 封面图 */}
      {post.coverImage && (
        <Link href={`/blog/${post.slug}`}>
          <Image
            src={post.coverImage}
            alt={post.title}
            width={400}
            height={200}
            className="w-full h-48 object-cover group-hover:scale-105 transition-transform"
          />
        </Link>
      )}
      
      <div className="p-4">
        {/* 标题 */}
        <h2 className="text-xl font-semibold mb-2 line-clamp-2">
          <Link 
            href={`/blog/${post.slug}`}
            className="hover:text-blue-600 transition-colors"
          >
            {post.title}
          </Link>
        </h2>
        
        {/* 摘要 */}
        <p className="text-gray-600 text-sm mb-4 line-clamp-2">
          {post.excerpt}
        </p>
        
        {/* 元信息 */}
        <div className="flex items-center justify-between text-sm text-gray-500">
          <div className="flex items-center gap-2">
            {post.author.image && (
              <Image
                src={post.author.image}
                alt={post.author.name || ''}
                width={24}
                height={24}
                className="rounded-full"
              />
            )}
            <span>{post.author.name}</span>
          </div>
          
          <div className="flex gap-3">
            <span title="浏览量">👁 {post.viewCount}</span>
            <span title="评论数">💬 {post._count.comments}</span>
            <span title="点赞数">❤️ {post._count.likes}</span>
          </div>
        </div>
        
        {/* 日期和阅读时间 */}
        <div className="mt-3 flex items-center gap-3 text-xs text-gray-400">
          <time dateTime={post.publishedAt?.toISOString()}>
            {formatDate(post.publishedAt || post.createdAt)}
          </time>
          {post.readingTime && (
            <>
              <span></span>
              <span>{post.readingTime} 分钟阅读</span>
            </>
          )}
        </div>
        
        {/* 标签 */}
        {post.tags.length > 0 && (
          <div className="flex flex-wrap gap-2 mt-3">
            {post.tags.slice(0, 3).map(({ tag }) => (
              <Link
                key={tag.id}
                href={`/tags/${tag.slug}`}
                className="px-2 py-1 text-xs rounded-full hover:opacity-80 transition-opacity"
                style={{ 
                  backgroundColor: `${tag.color}20`, 
                  color: tag.color 
                }}
              >
                {tag.name}
              </Link>
            ))}
          </div>
        )}
      </div>
    </article>
  );
}

/**
 * 分页组件
 */
function Pagination({ 
  currentPage, 
  totalPages 
}: { 
  currentPage: number;
  totalPages: number;
}) {
  return (
    <nav className="flex justify-center gap-2 mt-8">
      {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
        <Link
          key={page}
          href={`/?page=${page}`}
          className={`px-4 py-2 rounded ${
            page === currentPage
              ? 'bg-blue-600 text-white'
              : 'bg-gray-100 hover:bg-gray-200'
          }`}
        >
          {page}
        </Link>
      ))}
    </nav>
  );
}

/**
 * 空状态组件
 */
function EmptyState() {
  return (
    <div className="text-center py-12">
      <div className="text-6xl mb-4">📝</div>
      <h3 className="text-xl font-semibold mb-2">暂无文章</h3>
      <p className="text-gray-600">
        博主正在努力创作中,敬请期待...
      </p>
    </div>
  );
}

📖 设计要点解析:

(1) 渐进增强原则

<Link href={`/blog/${post.slug}`}>
  <Image src={post.coverImage} alt={post.title} />
</Link>

即使 JavaScript 未加载,用户仍可点击链接跳转,保证基本可用性。

(2) 图片优化

<Image
  src={post.coverImage}
  width={400}
  height={200}
  className="group-hover:scale-105 transition-transform"
/>

next/image 自动:

  • ✅ 生成多种尺寸的图片
  • ✅ 转换为现代格式(WebP/AVIF)
  • ✅ 懒加载(非首屏图片)
  • ✅ 防止布局偏移(CLS)

(3) 文本截断

className="line-clamp-2"  // 最多显示 2 行

Tailwind CSS 的实用类,优雅地处理长文本。


四、📄 文章详情页

1. 动态路由页面

创建 app/blog/[slug]/page.tsx:

// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/posts';
import { notFound } from 'next/navigation';
import { MDXRenderer } from '@/components/MDXRenderer';
import { CommentSection } from '@/components/CommentSection';
import { LikeButton } from '@/components/LikeButton';
import { BookmarkButton } from '@/components/BookmarkButton';
import { auth } from '@/auth';
import Image from 'next/image';

interface BlogPostPageProps {
  params: Promise<{ slug: string }>;
}

// ==================== 元数据生成 ====================

export async function generateMetadata({ params }: BlogPostPageProps) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  
  if (!post) {
    return {};
  }

  return {
    title: post.title,
    description: post.excerpt || post.content.substring(0, 160),
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: post.coverImage ? [{ url: post.coverImage }] : [],
      type: 'article',
      publishedTime: post.publishedAt?.toISOString(),
      authors: [post.author.name].filter(Boolean),
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: post.coverImage ? [post.coverImage] : [],
    },
  };
}

// ==================== 静态参数生成(可选优化) ====================

/**
 * 预生成热门文章的静态页面
 * 
 * 适用场景:
 * - 访问量高的文章
 * - 不经常更新的内容
 * 
 * 注意:如果文章很多,不要全部预生成,会导致构建缓慢
 */
export async function generateStaticParams() {
  // 只预生成最近 10 篇文章
  const { posts } = await getPosts({ 
    page: 1, 
    pageSize: 10,
    published: true 
  });

  return posts.map(post => ({
    slug: post.slug,
  }));
}

// ==================== 页面组件 ====================

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const { slug } = await params;
  
  // 并行获取文章和当前用户
  const [post, session] = await Promise.all([
    getPostBySlug(slug),
    auth(),
  ]);

  if (!post) {
    notFound();
  }

  return (
    <article className="container mx-auto px-4 py-8 max-w-4xl">
      {/* 文章头部 */}
      <PostHeader post={post} />

      {/* 互动按钮 */}
      <InteractionBar 
        postId={post.id}
        initialLiked={false}
        initialBookmarked={false}
        user={session?.user || null}
      />

      {/* 文章内容 */}
      <MDXRenderer content={post.content} />

      {/* 标签 */}
      <PostTags tags={post.tags} />

      {/* 作者信息 */}
      <AuthorCard author={post.author} />

      {/* 评论区 */}
      <CommentSection 
        postId={post.id} 
        comments={post.comments}
        currentUser={session?.user || null}
      />
    </article>
  );
}

/**
 * 文章头部组件
 */
function PostHeader({ post }: { post: any }) {
  return (
    <header className="mb-8 pb-8 border-b">
      {/* 标题 */}
      <h1 className="text-4xl md:text-5xl font-bold mb-6">
        {post.title}
      </h1>
      
      {/* 作者和日期 */}
      <div className="flex flex-wrap items-center gap-4 text-gray-600">
        {post.author.image && (
          <Image
            src={post.author.image}
            alt={post.author.name || ''}
            width={40}
            height={40}
            className="rounded-full"
          />
        )}
        <span className="font-medium">{post.author.name}</span>
        <span></span>
        <time dateTime={post.publishedAt?.toISOString()}>
          {new Date(post.publishedAt || post.createdAt).toLocaleDateString('zh-CN', {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
          })}
        </time>
        <span></span>
        <span>{post.readingTime} 分钟阅读</span>
        <span></span>
        <span>👁 {post.viewCount} 次阅读</span>
      </div>

      {/* 封面图 */}
      {post.coverImage && (
        <div className="mt-6 rounded-lg overflow-hidden">
          <Image
            src={post.coverImage}
            alt={post.title}
            width={1200}
            height={600}
            priority  // 首屏图片,优先加载
            className="w-full h-auto"
          />
        </div>
      )}
    </header>
  );
}

/**
 * 互动按钮栏
 */
function InteractionBar({ 
  postId, 
  initialLiked, 
  initialBookmarked,
  user 
}: { 
  postId: string;
  initialLiked: boolean;
  initialBookmarked: boolean;
  user: any;
}) {
  return (
    <div className="flex gap-4 mb-8 pb-8 border-b">
      <LikeButton 
        postId={postId} 
        initialLiked={initialLiked}
        isAuthenticated={!!user}
      />
      <BookmarkButton 
        postId={postId} 
        initialBookmarked={initialBookmarked}
        isAuthenticated={!!user}
      />
    </div>
  );
}

/**
 * 标签组件
 */
function PostTags({ tags }: { tags: any[] }) {
  if (tags.length === 0) return null;

  return (
    <div className="flex flex-wrap gap-2 my-8">
      {tags.map(({ tag }) => (
        <Link
          key={tag.id}
          href={`/tags/${tag.slug}`}
          className="px-3 py-1 text-sm rounded-full transition-opacity hover:opacity-80"
          style={{ 
            backgroundColor: `${tag.color}20`, 
            color: tag.color 
          }}
        >
          #{tag.name}
        </Link>
      ))}
    </div>
  );
}

/**
 * 作者卡片
 */
function AuthorCard({ author }: { author: any }) {
  return (
    <div className="my-12 p-6 bg-gray-50 rounded-lg">
      <div className="flex items-center gap-4">
        {author.image && (
          <Image
            src={author.image}
            alt={author.name || ''}
            width={60}
            height={60}
            className="rounded-full"
          />
        )}
        <div>
          <h3 className="font-semibold text-lg">{author.name}</h3>
          {author.bio && (
            <p className="text-gray-600 text-sm mt-1">{author.bio}</p>
          )}
        </div>
      </div>
    </div>
  );
}

🎯 关键知识点:

(1)Metadata API

export async function generateMetadata({ params }) {
  return {
    title: post.title,
    openGraph: { /* Facebook/Twitter 预览 */ },
    twitter: { /* Twitter Card */ },
  };
}

SEO 最佳实践:

  • title: 控制在 60 字符以内
  • description: 150-160 字符,包含关键词
  • openGraph.images: 至少 1200x630 像素
  • twitter.card: 使用 summary_large_image 获得大卡片

(2) generateStaticParams

export async function generateStaticParams() {
  const { posts } = await getPosts({ page: 1, pageSize: 10 });
  return posts.map(post => ({ slug: post.slug }));
}

何时使用?

  • ✅ 访问量高的页面(首页、热门文章)
  • ✅ 内容不频繁变化
  • ❌ 文章数量巨大(会导致构建缓慢)

效果:

  • 这些页面在构建时生成静态 HTML
  • 访问时无需服务端渲染,速度极快

(3)并行数据获取

const [post, session] = await Promise.all([
  getPostBySlug(slug),
  auth(),
]);

而不是串行:

// ❌ 慢
const post = await getPostBySlug(slug);
const session = await auth();

五、💬 评论组件实现

1. 评论列表

创建 components/CommentSection.tsx:

// components/CommentSection.tsx
'use client';

import { useState } from 'react';
import { createComment } from '@/app/actions/comment';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';

interface CommentSectionProps {
  postId: string;
  comments: any[];
  currentUser: any;
}

/**
 * 评论区组件
 * 
 * 功能:
 * - 显示评论列表(支持嵌套)
 * - 发表评论
 * - 回复评论
 * - Optimistic UI(乐观更新)
 */
export function CommentSection({ 
  postId, 
  comments,
  currentUser 
}: CommentSectionProps) {
  const [commentList, setCommentList] = useState(comments);
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);

  /**
   * 提交评论
   */
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!content.trim()) return;
    if (!currentUser) {
      alert('请先登录');
      return;
    }

    setLoading(true);

    try {
      const result = await createComment({
        postId,
        content,
        parentId: replyingTo || undefined,
      });

      if (result.success && result.comment) {
        // Optimistic Update: 立即更新 UI
        if (replyingTo) {
          // 添加到回复列表
          setCommentList(prev =>
            prev.map(comment =>
              comment.id === replyingTo
                ? {
                    ...comment,
                    replies: [...(comment.replies || []), result.comment],
                  }
                : comment
            )
          );
        } else {
          // 添加到顶级评论
          setCommentList(prev => [...prev, result.comment]);
        }

        // 清空表单
        setContent('');
        setReplyingTo(null);
      } else {
        alert(result.error || '评论失败');
      }
    } catch (error) {
      console.error(error);
      alert('评论失败,请稍后重试');
    } finally {
      setLoading(false);
    }
  };

  return (
    <section className="mt-12 pt-8 border-t">
      <h2 className="text-2xl font-bold mb-6">
        评论 ({commentList.length})
      </h2>

      {/* 评论表单 */}
      <CommentForm
        content={content}
        onChange={setContent}
        onSubmit={handleSubmit}
        loading={loading}
        placeholder={
          replyingTo ? '撰写回复...' : '写下你的评论...'
        }
        onCancel={() => setReplyingTo(null)}
        isReply={!!replyingTo}
      />

      {/* 评论列表 */}
      <div className="space-y-6 mt-8">
        {commentList.map(comment => (
          <CommentItem
            key={comment.id}
            comment={comment}
            currentUser={currentUser}
            onReply={(commentId) => setReplyingTo(commentId)}
            replyingTo={replyingTo}
          />
        ))}

        {commentList.length === 0 && (
          <p className="text-center text-gray-500 py-8">
            暂无评论,来发表第一条评论吧!
          </p>
        )}
      </div>
    </section>
  );
}

/**
 * 评论表单组件
 */
function CommentForm({
  content,
  onChange,
  onSubmit,
  loading,
  placeholder,
  onCancel,
  isReply,
}: any) {
  return (
    <form onSubmit={onSubmit} className="mb-8">
      <textarea
        value={content}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        rows={4}
        className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
        required
      />
      
      <div className="flex justify-end gap-2 mt-3">
        {isReply && (
          <button
            type="button"
            onClick={onCancel}
            className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
          >
            取消
          </button>
        )}
        <button
          type="submit"
          disabled={loading || !content.trim()}
          className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {loading ? '提交中...' : '发表评论'}
        </button>
      </div>
    </form>
  );
}

/**
 * 单条评论组件
 */
function CommentItem({ 
  comment, 
  currentUser, 
  onReply,
  replyingTo 
}: any) {
  const isReplying = replyingTo === comment.id;

  return (
    <div className="flex gap-4">
      {/* 头像 */}
      {comment.author.image && (
        <Image
          src={comment.author.image}
          alt={comment.author.name || ''}
          width={40}
          height={40}
          className="rounded-full flex-shrink-0"
        />
      )}

      <div className="flex-1">
        {/* 评论头部 */}
        <div className="flex items-center gap-2 mb-2">
          <span className="font-medium">{comment.author.name}</span>
          <time 
            className="text-sm text-gray-500"
            dateTime={comment.createdAt}
          >
            {formatDate(comment.createdAt)}
          </time>
        </div>

        {/* 评论内容 */}
        <p className="text-gray-700 mb-3 whitespace-pre-wrap">
          {comment.content}
        </p>

        {/* 回复按钮 */}
        {currentUser && !isReplying && (
          <button
            onClick={() => onReply(comment.id)}
            className="text-sm text-blue-600 hover:underline"
          >
            回复
          </button>
        )}

        {/* 回复表单 */}
        {isReplying && (
          <div className="mt-4 ml-8">
            <CommentForm
              content=""
              onChange={() => {}}
              onSubmit={async (e: any) => {
                e.preventDefault();
                // 实际应由父组件处理
              }}
              loading={false}
              placeholder="撰写回复..."
              onCancel={() => onReply(null)}
              isReply={true}
            />
          </div>
        )}

        {/* 回复列表 */}
        {comment.replies?.length > 0 && (
          <div className="mt-4 space-y-4 ml-8">
            {comment.replies.map((reply: any) => (
              <CommentItem
                key={reply.id}
                comment={reply}
                currentUser={currentUser}
                onReply={onReply}
                replyingTo={replyingTo}
              />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

💡 Optimistic UI 原理:

提交评论的时候采用了乐观更新的方式:

// 1. 立即更新 UI(假设成功)
setCommentList(prev => [...prev, newComment]);

// 2. 发送请求
const result = await createComment(data);

// 3. 如果失败,回滚
if (!result.success) {
  setCommentList(prev => prev.filter(c => c.id !== newComment.id));
}

优势:

  • ✅ 用户体验极佳,无需等待服务器响应
  • ✅ 减少感知延迟

风险:

  • ⚠️ 需要处理失败情况
  • ⚠️ 不适合关键操作(如支付)

后续的点赞收藏功能也采用乐观更新。


六、❤️ 点赞与收藏按钮

1. 点赞按钮

创建 components/LikeButton.tsx:

// components/LikeButton.tsx
'use client';

import { useState } from 'react';
import { toggleLike } from '@/app/actions/interaction';

interface LikeButtonProps {
  postId: string;
  initialLiked: boolean;
  isAuthenticated: boolean;
}

/**
 * 点赞按钮(Optimistic UI)
 * 
 * 交互流程:
 * 1. 用户点击
 * 2. 立即切换 UI 状态
 * 3. 后台发送请求
 * 4. 如果失败,回滚状态
 */
export function LikeButton({ 
  postId, 
  initialLiked,
  isAuthenticated 
}: LikeButtonProps) {
  const [liked, setLiked] = useState(initialLiked);
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    if (!isAuthenticated) {
      alert('请先登录');
      return;
    }

    // Optimistic Update
    const previousState = liked;
    setLiked(!previousState);
    setLoading(true);

    try {
      const result = await toggleLike(postId);

      if (!result.success) {
        // 回滚
        setLiked(previousState);
        alert(result.error);
      }
    } catch (error) {
      // 回滚
      setLiked(previousState);
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
        liked
          ? 'bg-red-50 text-red-600 hover:bg-red-100'
          : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
      }`}
    >
      <span className={`text-xl ${liked ? 'animate-pulse' : ''}`}>
        {liked ? '❤️' : '🤍'}
      </span>
      <span>{liked ? '已点赞' : '点赞'}</span>
    </button>
  );
}

2. 收藏按钮

创建 components/BookmarkButton.tsx:

// components/BookmarkButton.tsx
'use client';

import { useState } from 'react';
import { toggleBookmark } from '@/app/actions/interaction';

interface BookmarkButtonProps {
  postId: string;
  initialBookmarked: boolean;
  isAuthenticated: boolean;
}

export function BookmarkButton({ 
  postId, 
  initialBookmarked,
  isAuthenticated 
}: BookmarkButtonProps) {
  const [bookmarked, setBookmarked] = useState(initialBookmarked);
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    if (!isAuthenticated) {
      alert('请先登录');
      return;
    }

    const previousState = bookmarked;
    setBookmarked(!previousState);
    setLoading(true);

    try {
      const result = await toggleBookmark(postId);

      if (!result.success) {
        setBookmarked(previousState);
        alert(result.error);
      }
    } catch (error) {
      setBookmarked(previousState);
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
        bookmarked
          ? 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100'
          : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
      }`}
    >
      <span className="text-xl">
        {bookmarked ? '⭐' : '☆'}
      </span>
      <span>{bookmarked ? '已收藏' : '收藏'}</span>
    </button>
  );
}

七、🔐 登录页面

1. 自定义登录页

创建 app/auth/signin/page.tsx:

// app/auth/signin/page.tsx
import { signIn } from '@/auth';
import { Github } from 'lucide-react';

export const metadata = {
  title: '登录 - 全栈博客',
  description: '使用 GitHub 账号登录',
};

export default function SignInPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
        <h1 className="text-3xl font-bold text-center mb-8">欢迎回来</h1>
        
        <form
          action={async () => {
            'use server';
            await signIn('github', { 
              redirectTo: '/' 
            });
          }}
          className="space-y-4"
        >
          <button
            type="submit"
            className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
          >
            <Github className="w-5 h-5" />
            使用 GitHub 登录
          </button>
        </form>

        <p className="mt-6 text-center text-sm text-gray-600">
          登录后即可评论、点赞、收藏文章
        </p>
      </div>
    </div>
  );
}

🔑 Server Actions 表单:

<form action={async () => {
  'use server';
  await signIn('github', { redirectTo: '/' });
}}>
  <button type="submit">登录</button>
</form>

这种写法:

  • ✅ 无需 JavaScript 也可工作
  • ✅ 自动处理 CSRF Token
  • ✅ 简洁优雅

八、⚡ 性能优化深度实践

1. 图片懒加载与优先级

// 首屏图片:优先加载
<Image
  src={heroImage}
  priority  // 关键!
  alt="Hero"
/>

// 非首屏图片:懒加载(默认行为)
<Image
  src={thumbnail}
  alt="Thumbnail"
  loading="lazy"  // 可省略,默认就是 lazy
/>

2. 字体优化

创建 app/layout.tsx:

// app/layout.tsx
import { Inter } from 'next/font/google';

// Next.js 自动优化字体
const inter = Inter({ 
  subsets: ['latin'],
  display: 'swap',  // 避免 FOIT(Flash of Invisible Text)
});

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        {children}
      </body>
    </html>
  );
}

优势:

  • ✅ 自动托管字体文件(CDN)
  • ✅ 消除布局偏移
  • ✅ 预加载关键字体

3. 代码分割

Next.js App Router 自动进行代码分割:

  • 每个路由独立 bundle
  • 客户端组件按需加载
  • 第三方库 Tree Shaking

无需手动配置!

4. 流式渲染(Streaming)

对于慢查询,可以使用 Suspense:

// app/blog/[slug]/page.tsx
import { Suspense } from 'react';

export default function BlogPostPage({ params }) {
  return (
    <article>
      {/* 快速加载的部分 */}
      <PostHeader />
      
      {/* 慢查询部分:流式加载 */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </article>
  );
}

async function Comments() {
  // 模拟慢查询
  await new Promise(resolve => setTimeout(resolve, 2000));
  return <div>评论内容...</div>;
}

function CommentsSkeleton() {
  return <div className="animate-pulse">加载中...</div>;
}

效果:

  • 用户先看到文章头部
  • 评论逐步加载,无需等待

九、🚀 部署上线

1. Vercel 部署(推荐)

步骤 1:推送代码到 GitHub

git init
git add .
git commit -m "feat: 完成博客系统"
git remote add origin https://github.com/yourusername/fullstack-blog.git
git push -u origin main

步骤 2:连接 Vercel

  1. 访问 vercel.com
  2. 点击 "New Project"
  3. 导入 GitHub 仓库
  4. 配置环境变量

步骤 3:配置环境变量

在 Vercel Dashboard → Settings → Environment Variables 中添加:

DATABASE_URL=postgresql://...
AUTH_SECRET=your-secret-key
GITHUB_ID=your-github-id
GITHUB_SECRET=your-github-secret
OPENAI_API_KEY=sk-your-key
NEXT_PUBLIC_APP_URL=https://your-domain.vercel.app

步骤 4:自动部署

每次推送到 main 分支,Vercel 会自动:

  1. 安装依赖
  2. 执行 next build
  3. 部署到全球 CDN
  4. 提供预览 URL

2. 数据库托管(Neon)

Neon 提供免费 Serverless PostgreSQL:

  1. 注册 neon.tech
  2. 创建新项目
  3. 获取连接字符串
  4. 更新 DATABASE_URL

优势:

  • ✅ 免费 tier: 0.5 GB 存储
  • ✅ 自动扩缩容
  • ✅ 分支功能(类似 Git)

3. 自定义域名

在 Vercel Dashboard → Settings → Domains 中:

  1. 添加你的域名
  2. 按提示配置 DNS(CNAME/A Record)
  3. 等待 SSL 证书签发(自动)

十一、📊 监控与分析

1. Vercel Analytics

app/layout.tsx 中添加:

import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

安装依赖:

npm install @vercel/analytics

功能:

  • 页面浏览量
  • 用户地理位置
  • 设备类型
  • 性能指标

2. Core Web Vitals 监控

Vercel 自动收集:

  • LCP(Largest Contentful Paint): 最大内容绘制时间
  • FID(First Input Delay): 首次输入延迟
  • CLS(Cumulative Layout Shift): 累积布局偏移

目标值:

  • LCP < 2.5s
  • FID < 100ms
  • CLS < 0.1

十二、📝 本章小结

通过上下两篇的学习,你已完成了一个生产级全栈博客系统:

✅ 已完成功能

模块 功能 技术栈
用户系统 GitHub OAuth 登录 Auth.js
文章管理 CRUD、Markdown 渲染 Prisma、MDX
AI 增强 自动摘要、标签推荐 OpenAI API
社交互动 评论、点赞、收藏 Server Actions
性能优化 缓存、懒加载、流式渲染 Next.js 内置
部署运维 Vercel 自动化部署 CI/CD

🎯 核心知识点回顾

  1. App Router 架构: 文件系统路由、嵌套布局、并行路由
  2. React Server Components: 服务端渲染、减少客户端 JS
  3. Server Actions: 类型安全的表单处理
  4. 数据缓存策略: revalidateTaggenerateStaticParams
  5. 性能优化: next/image、字体优化、代码分割
  6. SEO 最佳实践: Metadata API、Open Graph、Sitemap

🚀 下一步扩展方向

  1. 全文搜索: 集成 Meilisearch 或 Algolia
  2. RSS 订阅: 生成 RSS/Atom Feed
  3. 邮件通知: 新评论提醒(Resend/SendGrid)
  4. 管理后台: 文章审核、数据统计、用户管理
  5. 暗黑模式: next-themes 实现主题切换
  6. 国际化: next-intl 多语言支持
  7. PWA: 离线访问、推送通知

💪 练习作业

  1. 实现"相关文章推荐"功能(基于标签相似度)
  2. 添加"阅读进度条"(客户端组件)
  3. 实现"代码复制"按钮(CodeBlock 组件)
  4. 添加 Google Analytics 集成
  5. 实现简单的站内搜索(使用 Prisma 全文搜索)

🎉 结语

恭喜你完成了这个完整的 Next.js 全栈项目!

从环境配置到生产部署,你已掌握了:

  • ✅ 现代 Web 开发的最佳实践
  • ✅ 全栈应用的架构设计思路
  • ✅ 性能优化与 SEO 技巧
  • ✅ 自动化部署与监控

记住: 学习编程最好的方式就是不断实践。在此基础上,尝试添加新功能、优化现有代码、重构架构。

祝你成为一名优秀的 Next.js 全栈开发者! 🚀


资源链接:

Next.js从入门到实战保姆级教程(第十六章):实战项目(上)——全栈博客系统架构与核心功能

2026年4月27日 08:10

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

理论学一百遍,不如动手做一遍。 本章将带你从零开始构建一个有实际价值的全栈博客系统,将前面所有章节的知识融会贯通。我们将分上下两篇完成这个项目,上篇聚焦架构设计与核心功能实现,下篇将完善前端页面、性能优化及部署上线。

一、📋 项目规划与设计思路

1. 为什么选择博客系统作为实战项目?

在开始编码之前,我们先思考一个问题:为什么博客系统是学习 Next.js 的最佳实战项目?

mindmap
  root((为什么选博客系统))
    (技术覆盖面广)
      路由系统
      数据获取
      表单处理
      认证鉴权
    (业务逻辑完整)
      CRUD 操作
      权限控制
      缓存策略
      SEO 优化
    (可扩展性强)
      评论系统
      AI 集成
      搜索功能
      管理后台
    (真实应用场景)
      个人品牌
      技术分享
      作品集展示

博客系统看似简单,实则涵盖了现代 Web 开发的几乎所有核心技术点:

  1. 内容管理系统(CMS):文章的创建、编辑、删除
  2. 用户系统:注册、登录、权限管理
  3. 交互功能:评论、点赞、收藏
  4. 性能优化:缓存策略、图片优化、SEO
  5. AI 增强:智能摘要、标签推荐

通过这个项目,你将真正理解如何将理论知识转化为生产力

2. 功能特性全景图

让我们先明确这个博客系统要实现哪些功能:

(1)核心功能模块

模块 功能点 技术要点
用户系统 邮箱/GitHub 登录、个人资料管理 Auth.js、Session 管理
文章系统 Markdown 编写、代码高亮、标签分类 MDX、Shiki、Prisma
AI 功能 自动生成摘要、智能标签推荐 OpenAI API、Vercel AI SDK
社交互动 评论、点赞、收藏、RSS 订阅 Server Actions、Optimistic UI
管理后台 文章审核、数据统计、用户管理 RBAC 权限控制

3. 技术选型决策过程

在实际项目中,技术选型不是越新越好,而是要权衡多个维度:

(1)框架选择:Next.js 15 App Router

  • React Server Components 提升性能
  • 文件系统路由简化开发
  • 内置优化(Image/Font/Metadata)
  • Vercel 生态无缝集成

(2) 数据库方案:PostgreSQL + Prisma ORM

  • 关系型数据库适合博客数据结构
  • Prisma 提供类型安全的查询
  • Neon 提供免费 Serverless PostgreSQL
  • 迁移工具简化数据库版本管理

(3) 认证方案:Auth.js (NextAuth v5)

  • 官方推荐的 Next.js 认证方案
  • 支持 OAuth 和凭证登录
  • 与 Prisma 适配器完美集成
  • Session 管理开箱即用

(4)样式方案:Tailwind CSS

  • 实用优先,开发效率高
  • 与 Next.js 深度集成
  • 响应式设计简单易用
  • 社区组件库丰富

关键决策原则:

  • 稳定性优先: 选择成熟稳定的技术栈,而非最新但未经验证的
  • 生态完整: 优先考虑有良好文档和社区支持的技术
  • 开发体验: 减少样板代码,提高开发效率
  • 可维护性: 类型安全、清晰的代码结构

二、🚀 项目初始化与环境搭建

第一步:创建 Next.js 项目

打开终端,执行以下命令:

npx create-next-app@latest fullstack-blog

在交互式提示中,按以下方式选择:

✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No

为什么要这样配置?

  • TypeScript: 提供类型安全,减少运行时错误,是生产项目的标配
  • ESLint: 自动检测代码问题,保持代码质量
  • Tailwind CSS: 快速构建 UI,避免手写大量 CSS
  • src/ 目录: 更好的项目结构组织,分离源代码和配置文件
  • App Router: Next.js 13+ 的推荐路由方案,支持 RSC

第二步:安装核心依赖

进入项目目录后,我们需要安装几类依赖:

cd fullstack-blog

# 1️⃣ 数据库相关
npm install prisma @prisma/client

# 2️⃣ 认证相关
npm install next-auth@beta @auth/prisma-adapter bcryptjs

# 3️⃣ Markdown 渲染
npm install next-mdx-remote shiki rehype-autolink-headings rehype-slug

# 4️⃣ 表单验证
npm install zod react-hook-form @hookform/resolvers

# 5️⃣ AI 集成
npm install ai openai

# 6️⃣ 工具库
npm install date-fns slugify clsx tailwind-merge

# 7️⃣ 开发依赖(类型定义)
npm install -D @types/bcryptjs

依赖分类解析:

类别 包名 作用
ORM prisma, @prisma/client 类型安全的数据库访问层
认证 next-auth@beta Next.js 官方认证库 v5 版本
密码加密 bcryptjs 用户密码哈希加密
MDX next-mdx-remote 在服务端渲染 Markdown
代码高亮 shiki VS Code 同款语法高亮引擎
表单 zod, react-hook-form Schema 验证 + 高性能表单管理
AI ai, openai Vercel AI SDK + OpenAI 客户端
工具 date-fns, slugify 日期格式化、URL 友好字符串生成

第三步:环境变量配置

在项目根目录创建 .env.local 文件:

# .env.local

# ==================== 数据库配置 ====================
# 本地开发使用 PostgreSQL
DATABASE_URL="postgresql://user:password@localhost:5432/blog"
# Prisma 直连 URL(用于迁移等操作)
DIRECT_URL="postgresql://user:password@localhost:5432/blog"

# ==================== 认证配置 ====================
# Auth.js 会话加密密钥(至少 32 字符)
AUTH_SECRET="your-secret-key-min-32-characters-long!!!"
# GitHub OAuth 凭据(需在 GitHub Developer Settings 中创建)
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"

# ==================== AI 配置 ====================
# OpenAI API Key(从 https://platform.openai.com 获取)
OPENAI_API_KEY="sk-your-openai-api-key"

# ==================== 应用配置 ====================
# 应用基础 URL(开发环境)
NEXT_PUBLIC_APP_URL="http://localhost:3000"

⚠️ 安全提醒:

  • .env.local 已默认添加到 .gitignore,不会提交到 Git
  • AUTH_SECRET 可使用命令生成: openssl rand -base64 32
  • 生产环境需在部署平台(Vercel/Docker)配置这些变量

第四步:启动开发服务器

npm run dev

访问 http://localhost:3000,如果看到 Next.js 欢迎页面,说明项目初始化成功! 🎉


三、🗄️ 数据库设计与 Prisma 建模

1. 为什么需要精心设计数据库?

数据库设计直接影响应用的性能、可扩展性和维护成本。对于博客系统,我们需要考虑:

  1. 实体关系: 用户、文章、标签、评论之间的关系
  2. 索引优化: 加速常用查询(如按 slug 查找文章)
  3. 数据完整性: 外键约束、级联删除
  4. 扩展预留: 未来可能添加的功能(如点赞、收藏)

2. ER 图(Entity-Relationship Diagram)

erDiagram
    USER ||--o{ POST : writes
    USER ||--o{ COMMENT : comments
    USER ||--o{ LIKE : likes
    USER ||--o{ BOOKMARK : bookmarks
    
    POST ||--o{ POST_TAG : has
    TAG ||--o{ POST_TAG : tagged_in
    POST ||--o{ COMMENT : receives
    POST ||--o{ LIKE : gets
    POST ||--o{ BOOKMARK : saved
    
    COMMENT ||--o{ COMMENT : replies_to
    
    USER {
        String id PK
        String email UK
        String name
        Role role
    }
    
    POST {
        String id PK
        String slug UK
        String title
        Boolean published
    }
    
    TAG {
        String id PK
        String name UK
        String slug UK
    }
    
    COMMENT {
        String id PK
        String postId FK
        String parentId FK
    }

3. Prisma Schema 详解

创建 prisma/schema.prisma 文件:

// prisma/schema.prisma

// 1. 生成器配置:告诉 Prisma 生成什么语言的客户端
generator client {
  provider = "prisma-client-js"
}

// 2. 数据源配置:指定数据库类型和连接字符串
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

// 3. 枚举类型:用户角色
enum Role {
  USER   // 普通用户
  ADMIN  // 管理员
}

// ==================== 核心模型 ====================

// 用户模型
model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  bio           String?   @db.Text
  website       String?
  github        String?
  role          Role      @default(USER)
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  // 关联关系
  accounts  Account[]
  sessions  Session[]
  posts     Post[]
  comments  Comment[]
  likes     Like[]
  bookmarks Bookmark[]

  @@map("users")
}

// 文章模型
model Post {
  id          String     @id @default(cuid())
  title       String
  slug        String     @unique
  content     String     @db.Text
  excerpt     String?    // AI 生成的摘要
  coverImage  String?
  published   Boolean    @default(false)
  featured    Boolean    @default(false)
  viewCount   Int        @default(0)
  readingTime Int?       // 预计阅读时间(分钟)
  authorId    String
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
  publishedAt DateTime?

  // 关联关系
  author    User       @relation(fields: [authorId], references: [id])
  tags      PostTag[]
  comments  Comment[]
  likes     Like[]
  bookmarks Bookmark[]

  // 索引优化查询性能
  @@index([slug])
  @@index([published])
  @@index([createdAt])
  @@map("posts")
}

// 标签模型
model Tag {
  id          String    @id @default(cuid())
  name        String    @unique
  slug        String    @unique
  description String?
  color       String    @default("#6366f1")

  posts PostTag[]

  @@map("tags")
}

// 文章-标签多对多关系表
model PostTag {
  postId String
  tagId  String

  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag  Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([postId, tagId])
  @@map("post_tags")
}

// 评论模型(支持嵌套回复)
model Comment {
  id        String   @id @default(cuid())
  content   String   @db.Text
  authorId  String
  postId    String
  parentId  String?  // 父评论 ID,用于嵌套评论
  approved  Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  author   User      @relation(fields: [authorId], references: [id])
  post     Post      @relation(fields: [postId], references: [id], onDelete: Cascade)
  parent   Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
  replies  Comment[] @relation("CommentReplies")

  @@index([postId])
  @@index([approved])
  @@map("comments")
}

// 点赞模型
model Like {
  userId String
  postId String

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@id([userId, postId])
  @@map("likes")
}

// 收藏模型
model Bookmark {
  userId    String
  postId    String
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@id([userId, postId])
  @@map("bookmarks")
}

// ==================== Auth.js 所需模型 ====================

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("accounts")
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("sessions")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
  @@map("verification_tokens")
}

📝 Schema 设计要点解析:

(1) 主键策略:cuid() vs uuid()

id String @id @default(cuid())
  • cuid: 更短、更易读、按时间排序,适合大多数场景
  • uuid: 标准 UUID v4,更长但全球唯一
  • 自增 ID: 不适合分布式系统,不推荐

(2) 索引优化

@@index([slug])        // 加速按 slug 查询文章
@@index([published])   // 加速筛选已发布文章
@@index([postId])      // 加速查询文章的评论

何时添加索引?

  • ✅ 经常用于 WHERE 条件的字段
  • ✅ 外键字段
  • ❌ 低基数字段(如布尔值)
  • ❌ 频繁更新的字段

(3) 级联删除

post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

当文章被删除时,自动删除相关的评论、点赞、收藏记录,保持数据一致性

(4) 自引用关系(嵌套评论)

parent   Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
replies  Comment[] @relation("CommentReplies")

通过 parentId 实现评论的树形结构,支持无限层级回复。

4. 初始化数据库

执行以下命令创建数据库表:

# 1. 生成 Prisma Client(TypeScript 类型定义)
npx prisma generate

# 2. 创建数据库迁移
npx prisma migrate dev --name init

# 3. (可选)可视化查看数据库
npx prisma studio

迁移文件说明:

执行 migrate dev 后,会在 prisma/migrations/ 目录生成 SQL 文件:

-- prisma/migrations/20260412000000_init/migration.sql

CREATE TABLE "users" (
    "id" TEXT NOT NULL,
    "email" TEXT NOT NULL,
    "name" TEXT,
    "role" "Role" NOT NULL DEFAULT 'USER',
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,
    
    CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

-- ... 其他表的创建语句

💡 最佳实践:

  • 每次修改 Schema 都创建新的迁移
  • 迁移文件应提交到 Git,便于团队协作
  • 生产环境使用 prisma migrate deploy 而非 dev

四、🔐 认证系统集成(Auth.js)

1. 认证流程概览

sequenceDiagram
    participant User as 用户
    participant App as Next.js App
    participant Auth as Auth.js
    participant DB as Database
    participant OAuth as GitHub OAuth
    
    User->>App: 点击"使用 GitHub 登录"
    App->>Auth: 重定向到 /api/auth/signin/github
    Auth->>OAuth: 请求授权
    OAuth->>User: 显示授权页面
    User->>OAuth: 确认授权
    OAuth->>Auth: 返回授权码
    Auth->>OAuth: 交换访问令牌
    Auth->>DB: 创建/更新用户记录
    Auth->>App: 设置 Session Cookie
    App->>User: 重定向到首页(已登录状态)

2. 配置 Auth.js

创建 auth.ts 文件(项目根目录):

// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from '@/lib/db';

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(db),
  
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
  ],
  
  callbacks: {
    // Session 回调:自定义 Session 数据
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        session.user.role = user.role;
      }
      return session;
    },
    
    // JWT 回调:将用户信息编码到 Token
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },
  },
  
  pages: {
    signIn: '/auth/signin',  // 自定义登录页面
  },
});

🔑 关键配置解析:

(1) Adapter(适配器模式)

adapter: PrismaAdapter(db)

Auth.js 通过适配器与不同数据库交互。PrismaAdapter 会自动:

  • 创建/更新用户记录
  • 管理 OAuth 账户绑定
  • 处理 Session 生命周期

(2) Providers(认证提供者)

providers: [
  GitHub({ /* 配置 */ }),
  // 可以添加更多: Google、Email、Credentials...
]

每个 Provider 对应一种登录方式。GitHub OAuth 需要在 GitHub Developer Settings 中创建应用,获取 Client IDClient Secret

(3) Callbacks(回调函数)

callbacks: {
  async session({ session, user }) {
    // 在这里可以向 session 添加额外数据
    session.user.id = user.id;
    return session;
  }
}

常见用途:

  • 向 Session 添加用户 ID、角色等信息
  • 根据用户角色限制访问
  • 记录登录日志

(3)创建 API 路由

Next.js App Router 中,Auth.js 的路由位于 app/api/auth/[...nextauth]/route.ts:

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

export const { GET, POST } = handlers;

路由命名规则:

  • [...nextauth] 是动态路由段,匹配所有 /api/auth/* 路径
  • Auth.js 内部会根据子路径分发请求(如 /api/auth/signin)

4. 封装认证辅助函数

创建 lib/auth.ts:

// lib/auth.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

/**
 * 获取当前会话(服务端组件中使用)
 */
export async function getCurrentUser() {
  const session = await auth();
  return session?.user || null;
}

/**
 * 要求用户登录(未登录则重定向)
 */
export async function requireAuth() {
  const session = await auth();
  
  if (!session?.user) {
    redirect('/auth/signin?callbackUrl=' + encodeURIComponent(
      typeof window !== 'undefined' ? window.location.pathname : '/'
    ));
  }
  
  return session;
}

/**
 * 检查是否为管理员
 */
export async function requireAdmin() {
  const session = await requireAuth();
  
  if (session.user.role !== 'ADMIN') {
    throw new Error('权限不足');
  }
  
  return session;
}

使用示例:

// app/dashboard/page.tsx
import { requireAuth } from '@/lib/auth';

export default async function DashboardPage() {
  // 未登录会自动重定向到登录页
  const session = await requireAuth();
  
  return <div>欢迎, {session.user.name}</div>;
}

五、✍️ 文章 CRUD 核心功能

Server Actions 架构设计

在 Next.js 13+ 中,Server Actions 是处理表单提交和数据突变的首选方案,相比传统 API Routes 有以下优势:

对比项 Server Actions API Routes
类型安全 ✅ 端到端类型推断 ❌ 需手动定义接口
渐进增强 ✅ 无 JS 也可工作 ❌ 依赖客户端 JS
代码复用 ✅ 直接导入函数 ❌ 需 HTTP 请求
安全性 ✅ 自动 CSRF 保护 ⚠️ 需手动实现

1. 创建文章 Action

创建 app/actions/post.ts:

// app/actions/post.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import slugify from 'slugify';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';

// ==================== Schema 定义 ====================

/**
 * 创建文章的验证 Schema
 * 
 * Zod 的优势:
 * 1. 运行时验证 + TypeScript 类型推断
 * 2. 详细的错误信息
 * 3. 可组合、可扩展
 */
const createPostSchema = z.object({
  title: z.string()
    .min(1, '标题不能为空')
    .max(200, '标题不能超过 200 字符'),
  
  content: z.string()
    .min(100, '文章内容至少 100 字符'),
  
  excerpt: z.string()
    .max(500, '摘要不能超过 500 字符')
    .optional(),
  
  coverImage: z.string()
    .url('请输入有效的图片 URL')
    .optional(),
  
  tagIds: z.array(z.string())
    .min(1, '至少选择一个标签'),
  
  published: z.boolean()
    .default(false),
});

// 从 Schema 推断 TypeScript 类型
type CreatePostInput = z.infer<typeof createPostSchema>;

/**
 * 创建新文章
 * 
 * @param data - 文章数据
 * @returns 创建结果
 * 
 * 使用场景:
 * - 管理后台创建文章
 * - 用户投稿功能
 */
export async function createPost(data: CreatePostInput) {
  // 1. 身份验证
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '未授权,请先登录' 
    };
  }

  // 2. 数据验证
  const validated = createPostSchema.safeParse(data);
  
  if (!validated.success) {
    return { 
      success: false, 
      error: '数据验证失败',
      details: validated.error.flatten().fieldErrors
    };
  }

  // 3. 生成 URL 友好的 slug
  const slug = slugify(validated.data.title, { 
    lower: true,      // 转小写
    strict: true,     // 严格模式,移除特殊字符
  });

  // 4. 检查 slug 是否已存在
  const existingPost = await db.post.findUnique({
    where: { slug },
  });

  if (existingPost) {
    // 如果 slug 冲突,添加时间戳后缀
    const uniqueSlug = `${slug}-${Date.now()}`;
    return await savePost({ ...validated.data, slug: uniqueSlug }, session.user.id!);
  }

  return await savePost({ ...validated.data, slug }, session.user.id!);
}

/**
 * 保存文章到数据库(内部函数)
 */
async function savePost(
  data: CreatePostInput & { slug: string }, 
  authorId: string
) {
  try {
    const post = await db.post.create({
      data: {
        title: data.title,
        slug: data.slug,
        content: data.content,
        excerpt: data.excerpt,
        coverImage: data.coverImage,
        published: data.published,
        publishedAt: data.published ? new Date() : null,
        authorId,
        // 关联标签(多对多关系)
        tags: {
          create: data.tagIds.map(tagId => ({
            tag: { connect: { id: tagId } },
          })),
        },
      },
    });

    // 5. 失效相关缓存
    revalidateTag('posts');           // 文章列表缓存
    revalidateTag(`user-${authorId}`); // 用户文章列表缓存

    return { 
      success: true, 
      postId: post.id,
      message: '文章创建成功'
    };
  } catch (error) {
    console.error('Failed to create post:', error);
    return { 
      success: false, 
      error: '创建文章失败,请稍后重试'
    };
  }
}

📖 代码解析:

(1) 为什么使用 'use server' 指令?

'use server';

这个指令告诉 Next.js:

  • 该文件中的所有导出函数都在服务端执行
  • 可以在函数中访问数据库、环境变量等敏感资源
  • 客户端调用时会自动序列化参数和返回值

(2) Zod Schema 验证的重要性

const validated = createPostSchema.safeParse(data);

if (!validated.success) {
  return { error: '数据验证失败', details: validated.error.flatten() };
}

防御性编程原则:

  • 永远不要信任客户端传来的数据
  • ✅ 在服务端进行二次验证
  • ✅ 提供清晰的错误提示

(3)缓存失效策略

revalidateTag('posts');

当我们创建/更新/删除文章后,需要通知 Next.js 清除相关缓存:

  • revalidateTag('posts'): 清除所有文章列表的缓存
  • revalidatePath('/blog'): 清除特定路径的缓存

缓存失效时机:

  • 创建文章 → 清除列表缓存
  • 更新文章 → 清除详情 + 列表缓存
  • 删除文章 → 清除详情 + 列表 + 用户缓存

2. 获取文章列表(带缓存)

创建 lib/posts.ts:

// lib/posts.ts
import { db } from '@/lib/db';
import { cache } from 'react';

interface GetPostsOptions {
  page?: number;
  pageSize?: number;
  tagSlug?: string;
  search?: string;
  published?: boolean;
}

/**
 * 获取文章列表(带 React Cache)
 * 
 * cache() 的作用:
 * - 在同一请求中多次调用时,只执行一次数据库查询
 * - 配合 Next.js 数据缓存,实现多层缓存
 */
export const getPosts = cache(async ({
  page = 1,
  pageSize = 10,
  tagSlug,
  search,
  published = true,
}: GetPostsOptions = {}) => {
  const skip = (page - 1) * pageSize;

  // 构建动态查询条件
  const where = {
    published,
    ...(tagSlug && {
      tags: {
        some: {
          tag: { slug: tagSlug },
        },
      },
    }),
    ...(search && {
      OR: [
        { title: { contains: search, mode: 'insensitive' as const } },
        { content: { contains: search, mode: 'insensitive' as const } },
      ],
    }),
  };

  // 并行查询:文章列表 + 总数
  const [posts, total] = await Promise.all([
    db.post.findMany({
      where,
      skip,
      take: pageSize,
      orderBy: { publishedAt: 'desc' },
      include: {
        author: {
          select: { id: true, name: true, image: true },
        },
        tags: {
          include: {
            tag: { select: { id: true, name: true, slug: true, color: true } },
          },
        },
        _count: {
          select: { comments: true, likes: true },
        },
      },
    }),
    db.post.count({ where }),
  ]);

  return {
    posts,
    pagination: {
      page,
      pageSize,
      total,
      totalPages: Math.ceil(total / pageSize),
    },
  };
});

🎯 性能优化技巧:

(1) 使用 Promise.all 并行查询

const [posts, total] = await Promise.all([
  db.post.findMany({ /* ... */ }),
  db.post.count({ where }),
]);

而不是串行:

// ❌ 慢:两个查询依次执行
const posts = await db.post.findMany({ /* ... */ });
const total = await db.post.count({ where });

(2) 精确选择字段

include: {
  author: {
    select: { id: true, name: true, image: true }, // 只取需要的字段
  },
}

避免 select: true 取出所有字段,减少网络传输和内存占用。

(3) 使用 _count 聚合查询

_count: {
  select: { comments: true, likes: true },
}

直接在数据库层面统计数量,避免在应用层遍历数组。

4. 获取单篇文章详情

继续在 lib/posts.ts 中添加:

/**
 * 根据 slug 获取文章详情
 * 
 * @param slug - 文章 URL 标识
 * @returns 文章详情或 null
 */
export const getPostBySlug = cache(async (slug: string) => {
  const post = await db.post.findUnique({
    where: { slug },
    include: {
      author: {
        select: { id: true, name: true, image: true, bio: true },
      },
      tags: {
        include: {
          tag: { select: { id: true, name: true, slug: true, color: true } },
        },
      },
      // 获取顶级评论(不包括回复)
      comments: {
        where: { approved: true, parentId: null },
        include: {
          author: { select: { id: true, name: true, image: true } },
          // 嵌套获取回复评论
          replies: {
            include: {
              author: { select: { id: true, name: true, image: true } },
            },
          },
        },
        orderBy: { createdAt: 'asc' },
      },
      _count: {
        select: { likes: true, bookmarks: true },
      },
    },
  });

  if (!post) {
    return null;
  }

  // 异步增加浏览量(不阻塞响应)
  incrementViewCount(post.id);

  return post;
});

/**
 * 增加文章浏览量
 */
async function incrementViewCount(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { viewCount: { increment: 1 } },
  });
}

💡 设计思考:

为什么浏览量更新不等待?

// 不阻塞主流程
incrementViewCount(post.id);
return post;
  • 用户体验优先: 用户无需等待计数器更新
  • ✅ 即使更新失败,也不影响文章展示
  • ⚠️ 注意:在高并发场景可能需要队列或批量更新优化

六、🤖 AI 功能集成

1. 为什么要在博客中集成 AI?

传统博客系统的痛点:

  • ❌ 作者需要手动编写摘要,耗时耗力
  • ❌ 标签选择主观,不利于 SEO
  • ❌ 相关文章推荐算法复杂

AI 可以解决这些问题:

  • 自动生成摘要: 节省作者时间
  • 智能标签推荐: 基于内容语义分析
  • 个性化推荐: 提升用户停留时长

2. 配置 OpenAI

创建 lib/ai.ts:

// lib/ai.ts
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';

/**
 * 使用 AI 生成文章摘要
 * 
 * @param content - 文章正文
 * @returns 生成的摘要文本
 * 
 * 应用场景:
 * - 创建文章时自动生成 excerpt
 * - 批量处理历史文章
 */
export async function generateExcerpt(content: string): Promise<string> {
  // 限制输入长度,避免超出 Token 限制
  const truncatedContent = content.substring(0, 2000);

  const { text } = await generateText({
    model: openai('gpt-4-turbo'),
    prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读

文章内容:
${truncatedContent}`,
    temperature: 0.7, // 创造性:0-1,越高越随机
  });

  return text.trim();
}

/**
 * 智能推荐标签
 * 
 * @param title - 文章标题
 * @param content - 文章正文
 * @returns 标签名称数组
 */
export async function suggestTags(
  title: string,
  content: string
): Promise<string[]> {
  const { text } = await generateText({
    model: openai('gpt-4-turbo'),
    prompt: `基于以下文章标题和内容,推荐 3-5 个相关的技术标签。
要求:
1. 标签应为常见的技术术语
2. 用逗号分隔,不要编号
3. 每个标签不超过 10 个字符

标题: ${title}
内容: ${content.substring(0, 1500)}`,
    temperature: 0.5, // 更低温度,更稳定
  });

  // 解析返回结果
  return text
    .split(',')
    .map(tag => tag.trim())
    .filter(Boolean)
    .slice(0, 5); // 最多 5 个标签
}

/**
 * 生成文章预计阅读时间
 * 
 * @param content - 文章正文
 * @returns 阅读时间(分钟)
 */
export function calculateReadingTime(content: string): number {
  const wordsPerMinute = 300; // 中文阅读速度
  const wordCount = content.length / 2; // 粗略估算中文字数
  return Math.ceil(wordCount / wordsPerMinute);
}

⚙️ AI 配置最佳实践:

(1)Temperature 参数调优

temperature: 0.7  // 摘要生成:需要一定创造性
temperature: 0.5  // 标签推荐:需要稳定性
  • 0.0-0.3: 确定性输出,适合事实性问题
  • 0.4-0.7: 平衡创造性和准确性
  • 0.8-1.0: 高创造性,适合创意写作

(2)Prompt Engineering 技巧

prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读`

有效 Prompt 的要素:

  • ✅ 明确任务目标
  • ✅ 列出具体要求
  • ✅ 提供示例(Few-shot Learning)
  • ✅ 限制输出格式

(3) 成本控制

const truncatedContent = content.substring(0, 2000);
  • 限制输入长度,减少 Token 消耗
  • 对于长文章,可以分段处理后合并
  • 考虑使用更便宜的模型(如 gpt-3.5-turbo)进行测试

3. 在创建文章时调用 AI

修改 createPost 函数:

// app/actions/post.ts
import { generateExcerpt, calculateReadingTime } from '@/lib/ai';

export async function createPost(data: CreatePostInput) {
  // ... 前面的验证逻辑 ...

  // 如果没有提供摘要,使用 AI 生成
  let excerpt = validated.data.excerpt;
  if (!excerpt) {
    excerpt = await generateExcerpt(validated.data.content);
  }

  // 计算阅读时间
  const readingTime = calculateReadingTime(validated.data.content);

  // 保存到数据库
  const post = await db.post.create({
    data: {
      // ... 其他字段 ...
      excerpt,
      readingTime,
    },
  });

  return { success: true, postId: post.id };
}

🎯 用户体验优化:

可以在前端显示"AI 生成中..."的加载状态:

// components/AIExcerptGenerator.tsx
'use client';

import { useState } from 'react';
import { generateExcerpt } from '@/app/actions/ai';

export function AIExcerptGenerator({ content }: { content: string }) {
  const [loading, setLoading] = useState(false);
  const [excerpt, setExcerpt] = useState('');

  const handleGenerate = async () => {
    setLoading(true);
    try {
      const result = await generateExcerpt(content);
      setExcerpt(result);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={handleGenerate} disabled={loading}>
        {loading ? 'AI 生成中...' : '✨ 自动生成摘要'}
      </button>
      {excerpt && <textarea value={excerpt} />}
    </div>
  );
}

七、💬 评论系统实现

1. 评论系统设计要点

评论系统是博客的社交核心,需要考虑:

  1. 嵌套回复: 支持楼中楼式讨论
  2. 审核机制: 防止垃圾评论
  3. 实时更新: 新评论即时显示
  4. 权限控制: 仅登录用户可评论

2. 创建评论 Action

创建 app/actions/comment.ts:

// app/actions/comment.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';

const commentSchema = z.object({
  postId: z.string().min(1, '文章 ID 不能为空'),
  content: z.string()
    .min(1, '评论内容不能为空')
    .max(5000, '评论不能超过 5000 字符'),
  parentId: z.string().optional(), // 回复评论时填写
});

type CreateCommentInput = z.infer<typeof commentSchema>;

/**
 * 发表评论
 * 
 * @param data - 评论数据
 * @returns 创建结果
 */
export async function createComment(data: CreateCommentInput) {
  // 1. 身份验证
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录后再评论' 
    };
  }

  // 2. 数据验证
  const validated = commentSchema.safeParse(data);
  
  if (!validated.success) {
    return { 
      success: false, 
      error: '数据验证失败',
      details: validated.error.flatten().fieldErrors
    };
  }

  // 3. 检查文章是否存在
  const post = await db.post.findUnique({
    where: { id: validated.data.postId },
    select: { id: true, published: true },
  });

  if (!post || !post.published) {
    return { 
      success: false, 
      error: '文章不存在或未发布' 
    };
  }

  // 4. 如果是回复,检查父评论是否存在
  if (validated.data.parentId) {
    const parentComment = await db.comment.findUnique({
      where: { id: validated.data.parentId },
    });

    if (!parentComment) {
      return { 
        success: false, 
        error: '父评论不存在' 
      };
    }
  }

  try {
    // 5. 创建评论
    const comment = await db.comment.create({
      data: {
        content: validated.data.content,
        authorId: session.user.id!,
        postId: validated.data.postId,
        parentId: validated.data.parentId,
        approved: true, // 默认通过审核(可改为 false 启用审核)
      },
      include: {
        author: { 
          select: { id: true, name: true, image: true } 
        },
      },
    });

    // 6. 失效缓存
    revalidateTag(`post-${validated.data.postId}`);

    return { 
      success: true, 
      comment,
      message: '评论成功'
    };
  } catch (error) {
    console.error('Failed to create comment:', error);
    return { 
      success: false, 
      error: '评论失败,请稍后重试'
    };
  }
}

🔒 安全防护措施:

(1) 防 XSS 攻击

虽然我们在数据库中存储原始内容,但在渲染时需要转义:

// 使用 dangerouslySetInnerHTML 时要谨慎
<div dangerouslySetInnerHTML={{ __html: sanitize(comment.content) }} />

可以使用 dompurify 库清理 HTML:

npm install dompurify
npm install -D @types/dompurify

(2) 频率限制

防止用户刷评论:

// 检查用户最近 1 分钟内的评论次数
const recentComments = await db.comment.count({
  where: {
    authorId: session.user.id!,
    createdAt: {
      gte: new Date(Date.now() - 60 * 1000), // 1 分钟内
    },
  },
});

if (recentComments >= 5) {
  return { 
    success: false, 
    error: '评论过于频繁,请稍后再试' 
  };
}

(3) 敏感词过滤

const bannedWords = ['广告', '赌博', '色情'];

if (bannedWords.some(word => validated.data.content.includes(word))) {
  return { 
    success: false, 
    error: '评论包含不当内容' 
  };
}

八、👍 点赞与收藏功能

1. 为什么需要点赞和收藏?

  • 点赞: 量化文章受欢迎程度,激励作者
  • 收藏: 用户个人知识库,方便后续查阅
  • 数据分析: 了解用户偏好,优化内容策略

2. 切换点赞状态

创建 app/actions/interaction.ts:

// app/actions/interaction.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';

/**
 * 切换点赞状态(点赞/取消点赞)
 * 
 * @param postId - 文章 ID
 * @returns 操作结果
 */
export async function toggleLike(postId: string) {
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录' 
    };
  }

  // 检查是否已点赞
  const existing = await db.like.findUnique({
    where: {
      userId_postId: {
        userId: session.user.id!,
        postId,
      },
    },
  });

  try {
    if (existing) {
      // 取消点赞
      await db.like.delete({
        where: {
          userId_postId: {
            userId: session.user.id!,
            postId,
          },
        },
      });
      
      return { success: true, liked: false };
    } else {
      // 添加点赞
      await db.like.create({
        data: {
          userId: session.user.id!,
          postId,
        },
      });
      
      return { success: true, liked: true };
    }
  } catch (error) {
    console.error('Failed to toggle like:', error);
    return { 
      success: false, 
      error: '操作失败' 
    };
  } finally {
    // 无论成功与否,都失效缓存
    revalidateTag(`post-${postId}`);
  }
}

/**
 * 切换收藏状态
 * 
 * @param postId - 文章 ID
 * @returns 操作结果
 */
export async function toggleBookmark(postId: string) {
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录' 
    };
  }

  const existing = await db.bookmark.findUnique({
    where: {
      userId_postId: {
        userId: session.user.id!,
        postId,
      },
    },
  });

  try {
    if (existing) {
      await db.bookmark.delete({
        where: {
          userId_postId: {
            userId: session.user.id!,
            postId,
          },
        },
      });
      
      return { success: true, bookmarked: false };
    } else {
      await db.bookmark.create({
        data: {
          userId: session.user.id!,
          postId,
        },
      });
      
      return { success: true, bookmarked: true };
    }
  } catch (error) {
    console.error('Failed to toggle bookmark:', error);
    return { 
      success: false, 
      error: '操作失败' 
    };
  } finally {
    revalidateTag(`user-${session.user.id}`);
  }
}

💡 设计模式:Toggle Pattern

点赞/收藏这类功能是典型的 Toggle 模式:

  1. 检查当前状态
  2. 如果存在则删除,不存在则创建
  3. 返回新状态

这种模式的优点:

  • ✅ 幂等性:多次调用结果一致
  • ✅ 简化前端逻辑:无需分别实现"点赞"和"取消点赞"
  • ✅ 原子操作:避免竞态条件

九、📝 本章小结

通过实战项目上篇的学习,我们已经完成了博客系统的后端核心功能:

项目初始化: Next.js 15 + TypeScript + Tailwind CSS
数据库设计: Prisma Schema 建模,理解关系型数据结构
认证系统: Auth.js 集成 GitHub OAuth
文章 CRUD: Server Actions 实现数据突变
AI 集成: OpenAI 自动生成摘要和标签
评论系统: 嵌套评论 + 安全防护
互动功能: 点赞、收藏的 Toggle 模式

核心知识点回顾:

知识点 应用场景 关键代码
Server Actions 表单提交、数据突变 'use server'
Zod 验证 输入数据校验 z.object().parse()
React Cache 同请求内去重查询 cache(fn)
Revalidate Tag 缓存失效策略 revalidateTag()
Prisma Relations 多对多、自引用关系 @relation
AI Integration 智能摘要生成 generateText()

十、🚀 下篇预告

下篇中,我们将实现:

  1. 前端页面开发:

    • 首页文章列表
    • 文章详情页(MDX 渲染)
    • 登录/注册页面
    • 管理后台
  2. UI 组件实现:

    • Markdown 代码高亮
    • 评论组件(嵌套显示)
    • 点赞/收藏按钮(Optimistic UI)
  3. 性能优化:

    • 图片懒加载
    • 并行数据获取
    • 流式渲染
  4. 部署上线:

    • Vercel 部署
    • 环境变量配置
    • 域名绑定

敬请期待! 🎉


练习作业:

  1. 尝试添加"文章编辑"功能(提示:参考 createPost,使用 db.post.update)
  2. 实现"删除文章"功能,并处理级联删除
  3. 添加"草稿箱"功能(区分 published: true/false)
  4. 实现简单的全文搜索(使用 Prisma 的 contains 查询)

完成这些练习,你将真正掌握 Next.js 全栈开发的核心技能! 💪

Next.js从入门到实战保姆级教程(第十二章):认证鉴权与中间件

2026年4月25日 21:17

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

认证(Authentication) 解决"你是谁"的问题,鉴权(Authorization) 解决"你能做什么"的问题。这两者构成了应用安全的基石,实施不当将导致严重的安全漏洞。

一、认证方案选型

在 Next.js 生态中实现认证,主要有三种技术路线:

graph TD
    A[需要认证功能] --> B{项目复杂度与需求}
    B -->|快速开发<br/>标准需求| C[Auth.js<br/>原NextAuth.js]
    B -->|高度定制<br/>企业内建| D[手动实现 JWT]
    B -->|托管服务<br/>全功能| E[Clerk / Supabase Auth]

    C --> F[支持OAuth社交登录<br/>内置Session管理<br/> App Router深度集成]
    D --> G[完全自定义控制<br/> 需编写更多代码<br/>自行处理安全细节]
    E --> H[ 功能最全面<br/> 零维护成本<br/>产生服务费用<br/>厂商锁定风险]

方案选择建议

场景 推荐方案 理由
初创项目/MVP Auth.js 快速搭建,社区活跃
企业内部系统 手动JWT 已有认证基础设施
SaaS产品 Clerk/Supabase 节省开发时间,专注业务
高安全要求 手动JWT + 审计 完全掌控安全策略

对于大多数应用场景,Auth.js(原 NextAuth.js v5) 是最佳选择:支持数十种 OAuth 提供商(Google、GitHub、微信等),内置 Session 管理机制,与 Next.js App Router 深度集成。

本章重点讲解 Auth.js 方案,同时剖析 JWT 手动实现的底层原理。


二、Auth.js 完整认证实现

1. 安装与基础配置

npm install next-auth@beta

(1)实例化Next-Auth

// 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 { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import type { DefaultSession } from 'next-auth';

// 扩展 Session 类型
declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: string;
    } & DefaultSession['user'];
  }
}

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    // GitHub OAuth 登录
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),

    // Google OAuth 登录
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),

    // 邮箱密码登录(Credentials Provider)
    Credentials({
      name: '邮箱密码',
      credentials: {
        email: { label: '邮箱', type: 'email', placeholder: 'example@email.com' },
        password: { label: '密码', type: 'password' },
      },
      async authorize(credentials) {
        // 参数验证
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        // 查询用户
        const user = await db.users.findUnique({
          where: { email: credentials.email as string },
        });

        // 用户不存在或无密码
        if (!user || !user.hashedPassword) {
          return null;
        }

        // 验证密码
        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        );

        if (!isValid) {
          return null;
        }

        // 返回用户信息(敏感信息不返回)
        return {
          id: user.id,
          name: user.name,
          email: user.email,
          image: user.avatar,
          role: user.role,
        };
      },
    }),
  ],

  // Session 配置
  session: {
    strategy: 'jwt',  // 使用 JWT,无需数据库存储 Session
    maxAge: 30 * 24 * 60 * 60,  // 30 天过期
  },

  // 自定义页面路径
  pages: {
    signIn: '/login',      // 自定义登录页
    signOut: '/logout',    // 自定义登出页
    error: '/auth/error',  // 错误页面
  },

  // Callbacks:自定义认证流程
  callbacks: {
    /**
     * JWT Token 回调
     * 在 token 创建/更新时触发
     */
    async jwt({ token, user }) {
      // 首次登录时,将用户信息添加到 token
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },

    /**
     * Session 回调
     * 每次读取 session 时触发
     */
    async session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
  },

  // 调试模式(仅开发环境)
  debug: process.env.NODE_ENV === 'development',
});

(2)定义route handler

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

// 导出 GET 和 POST 处理器
export const { GET, POST } = handlers;

(3)填写配置信息

# .env.local
AUTH_SECRET="your-secret-key-min-32-chars"  # 使用 openssl rand -base64 32 生成
GITHUB_ID="your-github-app-id"
GITHUB_SECRET="your-github-app-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

安全提示AUTH_SECRET 必须至少 32 个字符,生产环境务必使用强随机字符串。

2. 登录页面实现

// app/login/page.tsx
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';

interface LoginPageProps {
  searchParams: Promise<{ callbackUrl?: string; error?: string }>;
}

export default async function LoginPage({ 
  searchParams 
}: LoginPageProps) {
  const { callbackUrl, error } = await searchParams;

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
      <div className="w-full max-w-md p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
        <h1 className="text-2xl font-bold text-center mb-8 text-gray-900">
          欢迎登录
        </h1>

        {/* 错误提示 */}
        {error && (
          <div 
            className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm border border-red-200"
            role="alert"
          >
            {error === 'CredentialsSignin'
              ? '邮箱或密码错误,请重试'
              : '登录失败,请稍后重试'}
          </div>
        )}

        {/* 邮箱密码登录表单 */}
        <form
          action={async (formData) => {
            'use server';
            try {
              await signIn('credentials', {
                email: formData.get('email'),
                password: formData.get('password'),
                redirectTo: callbackUrl || '/dashboard',
              });
            } catch (error) {
              if (error instanceof AuthError) {
                redirect(`/login?error=${error.type}`);
              }
              throw error;
            }
          }}
          className="space-y-4"
        >
          <div>
            <label 
              htmlFor="email" 
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              邮箱地址
            </label>
            <input
              id="email"
              name="email"
              type="email"
              required
              autoComplete="email"
              className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="your@email.com"
            />
          </div>

          <div>
            <label 
              htmlFor="password" 
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              密码
            </label>
            <input
              id="password"
              name="password"
              type="password"
              required
              autoComplete="current-password"
              className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="••••••••"
            />
          </div>

          <button
            type="submit"
            className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
          >
            登录
          </button>
        </form>

        {/* 分隔线 */}
        <div className="my-6 flex items-center gap-4">
          <hr className="flex-1 border-gray-300" />
          <span className="text-gray-400 text-sm">或使用以下方式登录</span>
          <hr className="flex-1 border-gray-300" />
        </div>

        {/* OAuth 登录按钮 */}
        <div className="space-y-3">
          {/* GitHub 登录 */}
          <form
            action={async () => {
              'use server';
              await signIn('github', {
                redirectTo: callbackUrl || '/dashboard',
              });
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-2 border border-gray-300 py-2 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
                <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
              </svg>
              使用 GitHub 登录
            </button>
          </form>

          {/* Google 登录 */}
          <form
            action={async () => {
              'use server';
              await signIn('google', {
                redirectTo: callbackUrl || '/dashboard',
              });
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-2 border border-gray-300 py-2 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24">
                <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
                <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
                <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
                <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
              </svg>
              使用 Google 登录
            </button>
          </form>
        </div>

        {/* 注册链接 */}
        <p className="mt-6 text-center text-sm text-gray-600">
          还没有账号?{' '}
          <Link 
            href="/register" 
            className="text-blue-600 hover:text-blue-700 font-medium"
          >
            立即注册
          </Link>
        </p>
      </div>
    </div>
  );
}

三、Session 获取与管理

Auth.js 提供了多种场景下的 Session 获取方式:

1. 服务端组件中获取

// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  // 获取当前会话
  const session = await auth();

  // 未登录则重定向
  if (!session?.user) {
    redirect('/login?callbackUrl=/dashboard');
  }

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-2xl font-bold mb-4">
        欢迎,{session.user.name}
      </h1>
      <p className="text-gray-600">邮箱:{session.user.email}</p>
      <p className="text-gray-600">角色:{session.user.role}</p>
    </div>
  );
}

2. 客户端组件中获取

// components/UserMenu.tsx
'use client';

import { useSession, signOut } from 'next-auth/react';
import Image from 'next/image';
import Link from 'next/link';

export function UserMenu() {
  const { data: session, status } = useSession();

  // 加载中状态
  if (status === 'loading') {
    return (
      <div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" />
    );
  }

  // 未登录
  if (!session) {
    return (
      <Link 
        href="/login"
        className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
      >
        登录
      </Link>
    );
  }

  // 已登录
  return (
    <div className="flex items-center gap-3">
      {session.user.image && (
        <Image
          src={session.user.image}
          alt={`${session.user.name} 的头像`}
          width={32}
          height={32}
          className="rounded-full border border-gray-200"
        />
      )}
      <span className="text-sm text-gray-700">{session.user.name}</span>
      <button
        onClick={() => signOut({ callbackUrl: '/' })}
        className="text-sm text-gray-500 hover:text-red-600 transition-colors"
      >
        退出
      </button>
    </div>
  );
}

需在根布局中包裹 SessionProvider

// app/layout.tsx
import { SessionProvider } from 'next-auth/react';
import { auth } from '@/auth';
import type { ReactNode } from 'react';

interface RootLayoutProps {
  children: ReactNode;
}

export default async function RootLayout({ 
  children 
}: RootLayoutProps) {
  const session = await auth();

  return (
    <html lang="zh-CN">
      <body>
        <SessionProvider session={session}>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
}

四、Middleware:路由级认证保护

中间件(Middleware)运行在 Edge Runtime,在请求到达路由处理程序之前执行。这是实现路由级别认证保护的最佳位置——比在每个页面单独检查 Session 高效得多。

1. 基础路由保护

// middleware.ts(项目根目录)
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export default auth((req: NextRequest) => {
  const { nextUrl } = req;
  const session = req.auth;
  const isLoggedIn = !!session?.user;

  // 定义受保护路由
  const protectedRoutes = [
    '/dashboard',
    '/profile',
    '/settings',
    '/admin',
  ];

  const isProtectedRoute = protectedRoutes.some(route => 
    nextUrl.pathname.startsWith(route)
  );

  // 未登录访问受保护页面 → 重定向到登录页
  if (isProtectedRoute && !isLoggedIn) {
    const redirectUrl = new URL('/login', nextUrl);
    redirectUrl.searchParams.set('callbackUrl', nextUrl.pathname);
    return NextResponse.redirect(redirectUrl);
  }

  // 已登录访问登录/注册页 → 重定向到仪表板
  if (isLoggedIn && ['/login', '/register'].includes(nextUrl.pathname)) {
    return NextResponse.redirect(new URL('/dashboard', nextUrl));
  }

  return NextResponse.next();
});

// 配置中间件匹配规则(排除静态资源)
export const config = {
  matcher: [
    /*
     * 匹配所有路径,除了:
     * - api(API 路由)
     * - _next/static(静态文件)
     * - _next/image(图片优化)
     * - favicon.ico(网站图标)
     * - public 文件夹
     */
    '/((?!api|_next/static|_next/image|favicon.ico|public).*)',
  ],
};

2. 基于角色的访问控制(RBAC)

// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export default auth((req: NextRequest) => {
  const { nextUrl } = req;
  const session = req.auth;
  const userRole = session?.user?.role;

  // 管理员路由保护
  if (nextUrl.pathname.startsWith('/admin')) {
    // 未登录
    if (!session?.user) {
      return NextResponse.redirect(new URL('/login', nextUrl));
    }
    
    // 非管理员
    if (userRole !== 'admin') {
      return NextResponse.redirect(new URL('/403', nextUrl));
    }
  }

  // 编辑者路由保护
  if (nextUrl.pathname.startsWith('/editor')) {
    if (!session?.user) {
      return NextResponse.redirect(new URL('/login', nextUrl));
    }
    
    const allowedRoles = ['admin', 'editor'];
    if (!allowedRoles.includes(userRole || '')) {
      return NextResponse.redirect(new URL('/403', nextUrl));
    }
  }

  return NextResponse.next();
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

五、手动实现 JWT 认证(深入理解)

虽然推荐使用 Auth.js,但理解 JWT 认证的底层原理对应对复杂场景至关重要。许多企业内部系统已有后端 API 提供 JWT,前端只需处理 Token 的存储和传递。

1. JWT 工作流程

sequenceDiagram
    participant User as 用户
    participant Client as 客户端
    participant Server as 服务端
    participant DB as 数据库

    User->>Client: 提交邮箱密码
    Client->>Server: POST /api/login
    Server->>DB: 验证用户凭证
    DB-->>Server: 返回用户信息
    Server->>Server: 生成 JWT Token
    Server-->>Client: 返回 JWT
    Client->>Client: 存储于 HttpOnly Cookie
    User->>Client: 访问受保护页面
    Client->>Server: 携带 JWT Cookie
    Server->>Server: 验证 JWT 有效性
    Server-->>Client: 返回数据

2. 安全 Token 管理

一般可以把Token存在HTTPOnly Cookie里。

// lib/session.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';

// JWT 密钥(生产环境从环境变量读取)
const SECRET_KEY = new TextEncoder().encode(
  process.env.JWT_SECRET || 'fallback-secret-change-in-production'
);

/**
 * 创建 Session Token
 * @param userId - 用户ID
 * @param role - 用户角色
 */
export async function createSession(
  userId: string, 
  role: string
): Promise<void> {
  const token = await new SignJWT({ userId, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')  // 7 天过期
    .sign(SECRET_KEY);

  const cookieStore = await cookies();

  // 将 JWT 存储在 HttpOnly Cookie 中
  cookieStore.set('session', token, {
    httpOnly: true,           // JavaScript 无法读取,防止 XSS
    secure: process.env.NODE_ENV === 'production',  // 仅 HTTPS
    sameSite: 'lax',          // 防止 CSRF
    maxAge: 7 * 24 * 60 * 60, // 7 天
    path: '/',
  });
}

/**
 * 验证并读取 Session
 * @returns Session 载荷或 null
 */
export async function getSession(): Promise<{ 
  userId: string; 
  role: string 
} | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('session')?.value;

  if (!token) {
    return null;
  }

  try {
    const { payload } = await jwtVerify(token, SECRET_KEY);
    return payload as { userId: string; role: string };
  } catch (error) {
    // Token 无效或已过期
    console.error('Invalid token:', error);
    return null;
  }
}

/**
 * 删除 Session(退出登录)
 */
export async function deleteSession(): Promise<void> {
  const cookieStore = await cookies();
  cookieStore.delete('session');
}

为什么使用 Cookie 而非 localStorage?
localStorage 可被 JavaScript 访问,易受 XSS 攻击(恶意脚本窃取 Token)。HttpOnly Cookie 无法被 JavaScript 访问,XSS 攻击无法窃取 Token。这是 Web 安全的核心实践之一。


六、安全防护最佳实践

1. 敏感信息绝不暴露于客户端

// ❌ 危险:在客户端存储权限信息
localStorage.setItem('isAdmin', 'true');

// ✅ 正确:仅在服务端验证权限
export async function DELETE(request: Request) {
  const session = await getSession();
  const user = await db.users.findUnique({ 
    where: { id: session?.userId } 
  });

  if (user?.role !== 'admin') {
    return new Response('Forbidden', { status: 403 });
  }

  await deleteData();
}

2. 密码哈希存储

import bcrypt from 'bcryptjs';

// 注册时:哈希密码
const saltRounds = 12;  // 10-12 为合理范围
const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);
await db.users.create({ 
  data: { email, hashedPassword } 
});

// 登录时:验证密码
const isValid = await bcrypt.compare(plainPassword, hashedPassword);
if (!isValid) {
  throw new Error('密码错误');
}

绝对禁止明文存储密码,即使数据库泄露,哈希密码也能保护用户安全。

3. 限制登录尝试次数

// lib/rate-limit.ts
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const MAX_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60; // 15 分钟

/**
 * 检查登录速率限制
 */
export async function checkRateLimit(email: string): Promise<{
  allowed: boolean;
  remainingAttempts?: number;
  lockoutUntil?: Date;
}> {
  const key = `login_attempts:${email}`;
  const attempts = await redis.get<number>(key) || 0;

  if (attempts >= MAX_ATTEMPTS) {
    const ttl = await redis.ttl(key);
    return {
      allowed: false,
      lockoutUntil: new Date(Date.now() + ttl * 1000),
    };
  }

  return {
    allowed: true,
    remainingAttempts: MAX_ATTEMPTS - attempts,
  };
}

/**
 * 记录登录失败
 */
export async function recordFailedAttempt(email: string): Promise<void> {
  const key = `login_attempts:${email}`;
  await redis.incr(key);
  await redis.expire(key, LOCKOUT_DURATION);
}

/**
 * 重置登录尝试计数
 */
export async function resetLoginAttempts(email: string): Promise<void> {
  const key = `login_attempts:${email}`;
  await redis.del(key);
}

4. CSRF 防护

Auth.js 内置 CSRF 保护。若手动实现 JWT,需添加 CSRF Token:

// 生成 CSRF Token
import { randomBytes } from 'crypto';

export function generateCsrfToken(): string {
  return randomBytes(32).toString('hex');
}

// 验证 CSRF Token
export function verifyCsrfToken(
  tokenFromCookie: string,
  tokenFromBody: string
): boolean {
  return tokenFromCookie === tokenFromBody;
}

七、本章小结

通过本章学习,你应该掌握了:

  • Auth.js 的完整配置与使用方法
  • OAuth 社交登录与邮箱密码登录的实现
  • Session 在服务端和客户端组件中的获取方式
  • Middleware 路由保护与 RBAC 权限控制
  • JWT 手动实现的底层原理与安全存储
  • 常见安全陷阱及防护策略(XSS、CSRF、暴力破解)

下一章《从原理到实践深度剖析缓存策略》将继续更深入地剖析 Next.js 的多层缓存架构,揭示其工作原理、最佳实践以及常见陷阱。

Next.js从入门到实战保姆级教程(第十一章):错误处理与加载状态

2026年4月25日 21:16

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

应用的质量不仅体现在正常运行时,更体现在出错和加载场景下的用户体验。因此,做好错误和边界处理是构建健壮应用的核心之一。Next.js 通过特殊文件约定,使这些"边缘情况"的处理变得系统化、规范化。

一、Next.js 的"文件即配置"理念

前面我们已经深入讲解过,在 App Router 中,Next.js的理念是“文件即配置”,路由系统就是在这样一套机制下建立起来的。同样,在Next.js中错误处理和加载状态也是通过特定命名的文件实现,而非全局配置:

app/
├── layout.tsx          # 根布局
├── page.tsx            # 首页
├── loading.tsx         # 首页加载状态
├── error.tsx           # 首页错误边界
├── not-found.tsx       # 404 页面
├── global-error.tsx    # 全局错误边界
└── blog/
    ├── page.tsx        # 博客列表页
    ├── loading.tsx     # 博客列表加载状态(覆盖父级)
    ├── error.tsx       # 博客错误边界(仅影响博客路由)
    └── [slug]/
        ├── page.tsx    # 文章详情页
        └── error.tsx   # 文章详情错误边界

核心特性:每个文件的作用范围限定在其所在目录及子目录。blog/error.tsx 仅处理博客相关路由的错误,不影响其他部分。


二、Loading处理:流式渲染的加载骨架

loading.tsx 定义路由段加载期间的 UI,基于 React Suspense 机制。当同级 page.tsx 等待数据时,立即显示加载状态。

1. 基础用法

// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      <div className="h-8 bg-gray-200 rounded animate-pulse w-1/2" />
      <div className="space-y-2">
        {Array.from({ length: 5 }).map((_, i) => (
          <div 
            key={i} 
            className="h-24 bg-gray-100 rounded animate-pulse" 
          />
        ))}
      </div>
    </div>
  );
}

2. 骨架屏 vs Loading Spinner

在传统的处理中,当用户在等待时,我们会使用Loading Spinner(比如一朵旋转的菊花)方案来提醒用户。这种方式某些程度上会造成一些心智负担。随着骨架屏的出现,越来越多的应用都考虑使用骨架屏来替代Loading Spinner。

(1)Loading Spinner 的问题

  • 用户无法预知等待时间
  • 缺乏内容结构预期
  • 容易产生焦虑感

(2)骨架屏的优势

  • 展示页面大致结构
  • 降低用户心理负担
  • 提升感知性能
// components/ArticleCardSkeleton.tsx
export function ArticleCardSkeleton() {
  return (
    <div className="border rounded-xl overflow-hidden animate-pulse">
      {/* 图片占位 */}
      <div className="aspect-video bg-gray-200" />
      
      <div className="p-4 space-y-3">
        {/* 标题占位 */}
        <div className="h-6 bg-gray-200 rounded w-3/4" />
        
        {/* 描述占位 */}
        <div className="h-4 bg-gray-100 rounded" />
        <div className="h-4 bg-gray-100 rounded w-5/6" />
        
        {/* 作者信息占位 */}
        <div className="flex items-center gap-2 mt-4">
          <div className="w-8 h-8 bg-gray-200 rounded-full" />
          <div className="h-4 bg-gray-100 rounded w-24" />
        </div>
      </div>
    </div>
  );
}
// app/blog/loading.tsx
import { ArticleCardSkeleton } from '@/components/ArticleCardSkeleton';

export default function Loading() {
  return (
    <div className="container mx-auto py-8">
      {/* 页面标题骨架 */}
      <div className="h-10 bg-gray-200 rounded w-48 mb-8 animate-pulse" />
      
      {/* 文章卡片网格 */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {Array.from({ length: 6 }).map((_, i) => (
          <ArticleCardSkeleton key={i} />
        ))}
      </div>
    </div>
  );
}

3. 局部 Suspense:精细化加载控制

loading.tsx 作用于整个路由段。如需对特定区域独立控制,使用 React Suspense 组件:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserStats } from '@/components/UserStats';
import { RecentActivity } from '@/components/RecentActivity';
import { StatsSkeleton } from '@/components/skeletons';

export default function DashboardPage() {
  return (
    <div className="dashboard-grid">
      {/* 统计数据:独立加载 */}
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>

      {/* 最近活动:稍后加载 */}
      <Suspense fallback={<div className="text-gray-500">加载动态...</div>}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

流式渲染优势

  • 各区域并行加载
  • 数据就绪即显示
  • 避免"全或无"的等待体验

三、Error处理:局部错误边界

error.tsx 创建 React 错误边界,捕获同级 page.tsx 或子组件抛出的错误,不影响应用其他部分

1. 基础实现

// app/blog/error.tsx
'use client';  // 必须为客户端组件

import { useEffect } from 'react';

interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;  // 重试函数
}

export default function BlogError({ error, reset }: ErrorProps) {
  useEffect(() => {
    // 记录错误到监控系统
    console.error('[Blog Error]', error);
    // errorTrackingService.capture(error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center min-h-96 gap-6 p-8">
      <div className="text-6xl" role="img" aria-label="困惑表情">😕</div>
      
      <h2 className="text-2xl font-bold text-gray-900">
        博客内容加载失败
      </h2>
      
      <p className="text-gray-500 text-center max-w-md">
        {error.message || '发生了一个意外错误,请稍后再试'}
      </p>
      
      <button
        onClick={() => reset()}
        className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
      >
        重试
      </button>
    </div>
  );
}

为什么必须是客户端组件? 错误边界需要维护状态(错误状态)和注册事件处理函数(reset),这些都是客户端特性。

2. 错误边界作用域

理解错误捕获范围对调试至关重要:

app/
├── error.tsx           # 捕获根级错误(不捕获 layout.tsx 错误)
├── layout.tsx          # ← 此处的错误 error.tsx 无法捕获
└── blog/
    ├── error.tsx       # 捕获 blog/page.tsx 及子路由错误
    ├── layout.tsx      # ← 此处的错误 blog/error.tsx 无法捕获
    └── page.tsx        # 此处错误被 blog/error.tsx 捕获

关键规则error.tsx 无法捕获同级 layout.tsx 的错误,因为错误边界包裹的是"兄弟"(page),而非"父亲"(layout)。


四、全局错误处理:最终防线

当根 layout.tsx 出现错误时,由 global-error.tsx 处理:

// app/global-error.tsx
'use client';

interface GlobalErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function GlobalError({ 
  error, 
  reset 
}: GlobalErrorProps) {
  return (
    // 需手动提供 html 和 body 标签(根 layout 已崩溃)
    <html lang="zh-CN">
      <body>
        <div className="flex min-h-screen items-center justify-center bg-gray-50">
          <div className="text-center p-8">
            <h1 className="text-4xl font-bold text-red-600 mb-4">
              应用出现严重错误
            </h1>
            <p className="text-gray-600 mb-6">
              错误代码:{error.digest || '未知错误'}
            </p>
            <button 
              onClick={() => reset()} 
              className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
            >
              刷新页面
            </button>
          </div>
        </div>
      </body>
    </html>
  );
}

global-error.tsx 是应用的最后保障,触发频率极低,但确保了应用永不陷入完全不可用状态。


五、404 页面

1. 基础实现

// app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen gap-6 p-8">
      <div className="text-9xl font-bold text-gray-200">404</div>
      
      <h2 className="text-2xl font-bold text-gray-900">
        页面不存在
      </h2>
      
      <p className="text-gray-500 text-center max-w-md">
        你访问的页面可能已被移除或地址有误
      </p>
      
      <Link
        href="/"
        className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
      >
        回到首页
      </Link>
    </div>
  );
}

2. 服务端触发 404

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

interface PageProps {
  params: Promise<{ slug: string }>;
}

export default async function BlogPost({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  // 文章不存在,触发 404
  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  );
}

notFound() 抛出特殊错误,Next.js 捕获后显示最近的 not-found.tsx。这不被视为"错误",而是正常的业务逻辑分支。


六、Server Actions 中的错误处理

根据错误类型选择合适的处理方式:

方式一:返回错误状态(可预期错误)

适用于表单验证、业务逻辑校验等场景:

// app/actions/auth.ts
'use server';

import { redirect } from 'next/navigation';

interface LoginState {
  error?: string;
}

export async function login(
  prevState: LoginState, 
  formData: FormData
): Promise<LoginState> {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  // 查找用户
  const user = await findUserByEmail(email);

  // 验证凭证
  if (!user || !await verifyPassword(password, user.hashedPassword)) {
    // 返回错误状态,UI 显示提示信息
    return { error: '邮箱或密码错误' };
  }

  // 创建会话
  await createSession(user.id);
  
  // 重定向
  redirect('/dashboard');
}

方式二:抛出错误(不可预期错误)

适用于数据库异常、网络故障等场景:

export async function updateProfile(formData: FormData) {
  'use server';

  try {
    // 执行更新操作
    await db.users.update({ /* ... */ });
    
    // 缓存失效
    revalidatePath('/profile');
  } catch (error) {
    // 抛出的错误被最近的 error.tsx 捕获
    console.error('Profile update failed:', error);
    throw new Error('更新失败,请稍后重试');
  }
}

七、错误监控集成

生产环境需实施错误监控,在用户反馈前发现问题。

1. 使用Sentry 集成

npx @sentry/wizard@latest -i nextjs

Sentry 自动捕获未处理错误并发送至 Dashboard,包含完整调用栈和用户上下文。

2. 自定义错误日志

即使不使用第三方服务,也应记录错误:

'use client';

import { useEffect } from 'react';

interface ErrorPageProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function ErrorPage({ error, reset }: ErrorPageProps) {
  useEffect(() => {
    // 发送至自有日志系统
    fetch('/api/log-error', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message: error.message,
        stack: error.stack,
        digest: error.digest,
        timestamp: new Date().toISOString(),
        url: window.location.href,
        userAgent: navigator.userAgent,
      }),
    }).catch(() => {
      // 日志失败不应影响错误页面展示
    });
  }, [error]);

  return (
    // ... 错误 UI
  );
}

八、最佳实践总结

1. 差异化恢复策略

根据错误类型提供不同的解决方案:

错误类型 恢复策略 示例
网络抖动 重试按钮 API 请求超时
数据异常 刷新页面 缓存数据损坏
权限问题 重新登录 Token 过期
资源缺失 返回首页 文章已删除

2. 隐藏技术细节

// ❌ 危险:暴露内部实现
<p>{error.message}</p>
<p>{error.stack}</p>

// ✅ 安全:友好提示
<p>抱歉,加载内容时遇到问题。我们已记录此错误,将尽快修复。</p>

// 技术细节仅发送至日志系统

3. 区分错误类型

  • 用户错误(4xx):帮助用户修正输入
  • 系统错误(5xx):显示错误页面并提供恢复选项

4. 保持错误页面简洁

错误页面应避免复杂的数据获取,防止自身出错导致无限循环。

5. 渐进增强原则

  • 优先保证核心功能可用
  • 次要功能降级显示
  • 优雅地处理部分失败

九、本章小结

通过本章学习,你应该掌握了:

  • Next.js 特殊文件的命名约定和作用域
  • loading.tsx 与骨架屏的实现方法
  • error.tsx 错误边界的捕获范围
  • global-error.tsx 的最终保障机制
  • not-found.tsxnotFound() 函数的使用
  • Server Actions 中的两种错误处理方式
  • 错误监控服务的集成方法
  • 生产环境的错误处理最佳实践

下一章将深入探讨认证鉴权与中间件——这是所有实际应用都必须面对的核心安全话题。

别再只看 Long Task 了:页面卡顿到底是 React、Layout,还是 V8 GC?

作者 DiffServ
2026年4月25日 15:27

别再只看 Long Task 了:页面卡顿到底是 React、Layout,还是 V8 GC?

"页面卡了,到底是谁的锅?"

🎬 在开始之前,先看看这个

在阅读任何文字之前,请先看这个视频:

🎬 点击播放视频实录

  • UI彻底死亡:主线程被冻结数百毫秒
  • 红线断崖:postMessage通道完全崩溃(延迟→∞)
  • 绿线傲慢:AudioWorklet 物理心跳依然丝滑跳动

这不是特效,这是发生在你浏览器里的物理事实。

0. 页面卡了,老板只问一句话

用户说页面卡。产品说转化掉了。后端说接口很快。前端打开 DevTools,只看到一坨 Long Task。

于是所有人开始猜:是不是 React 组件太多?是不是列表没虚拟滚动?是不是 CSS layout thrashing?是不是 Chrome 又抽风?

传统前端监控只能告诉你"卡了",但很难告诉你"是谁让世界暂停了"。

这里顺手点名 rAFPerformanceObserver、Long Task、web-vitals 的局限:它们都在主线程语境里观察主线程。

1. 为什么传统卡顿监控会失明?

核心论点:如果监控代码和业务代码在同一个线程,它们会一起死。

1.1 requestAnimationFrame

能看到帧间隔变大,但它自己也被主线程调度影响。它像是在心脏停跳后醒来补记一笔:"刚才好像断片了 700ms。"

另外需要注意的是,当页面处于后台标签页时,浏览器会暂停 rAF 回调以节省电量,这也会导致帧间隔看起来非常大(可能达到数秒),但这并不是被 STW 卡住,而是浏览器的正常省电行为。这也是为什么在生产监控中必须结合 document.visibilityState 来判断 rAF 间隔异常的真实原因。

1.2 Long Task API

能看到超过 50ms 的主线程长任务,但它更擅长记录 JS 执行和任务阻塞,不等于能精确切开 V8 STW 的瞬间。

1.3 DevTools Performance

适合开发环境复盘,但不适合生产环境持续采样。用户现场不会帮你开 DevTools。

这一节的结尾要引出:我们需要一个不坐在主线程里的观察者。

2. STW Sentinel 的定位:不是替代 web-vitals,而是补上黑匣子

不要把 stw-sentinel 写成"吊打所有监控"。更高级的写法是:

web-vitals 看用户体验结果,Long Task 看主线程任务,STW Sentinel 看主线程之外的物理心跳。

监控手段 能回答的问题 盲区
web-vitals 用户体验是否变差 很难解释底层原因
Long Task 主线程是否被长任务占用 不一定能区分业务 JS、Layout、GC
rAF delta 帧是否断了 采样者自己也会被卡住
STW Sentinel 主线程冻结期间外部时间是否仍稳定流逝 需要 COOP/COEP 与 AudioWorklet 环境

STW Sentinel 不是性能监控的全部,而是卡顿归因链路里缺失的那颗钉子。

3. 生产接入架构:不要只 console.warn,要做事件归因

不要只记录 deltaMs,要记录上下文。

import { STWSentinel } from 'stw-sentinel'

const sentinel = new STWSentinel({
  thresholdMs: 10,
  onSpike: (deltaMs, entry) => {
    // deltaMs 已经是换算好的毫秒值
    // 如果需要原始微秒值:const deltaUs = entry.deltaUs
    reportSTW({
      deltaMs,
      deltaUs: entry.deltaUs, // 原始微秒值,精度更高
      timestamp: performance.now(),
      route: location.pathname,
      visibility: document.visibilityState,
      userAgent: navigator.userAgent,
      recentAction: getLastUserAction(),
      recentLongTasks: getRecentLongTasks(),
      memory: getMemorySnapshotSafely(),
    })
  },
})

建议上报字段:

字段 作用
deltaMs STW 或调度尖峰长度
route 哪个页面最容易卡
recentAction 是否发生在点击、输入、滚动之后
recentLongTasks 和 Long Task 做交叉验证
visibilityState 排除后台标签页误判
deviceMemory 低端设备分层
hardwareConcurrency CPU 核心数分层
browser Chrome / Edge / Safari 差异
releaseVersion 对应前端版本回归

4. 卡顿归因矩阵:如何判断是谁的锅?

情况 A:Long Task 高,STW 不高

结论倾向:业务 JS、React render、同步计算、JSON parse、大循环、第三方 SDK。

处理方向:

  • 拆任务
  • useMemo / memo
  • 虚拟列表
  • Web Worker
  • 减少同步 JSON parse
  • 延迟第三方 SDK 初始化

情况 B:Long Task 高,STW 也高

结论倾向:业务代码制造了内存压力,触发 V8 GC/STW。

注意:V8 GC 本身不会产生独立的 Long Task 条目。GC 停顿通常表现为某个已有业务任务的执行时间被异常拉长(例如一个 30ms 的任务因为触发 GC 变成 120ms)。Long Task API 不会单独记录"GC 花了 90ms",只会记录这个被拉长的业务任务及其 attribution。

补充说明:现代 V8 的 GC 已经通过 Orinoco 项目做了大量并发优化(并发标记、并发清扫等),大多数场景下面临的是短暂的 STW 停顿。但在高内存压力、大堆、频繁分配的场景下,仍可能出现百毫秒级的 STW 停顿。

典型场景:

  • 短时间创建大量对象
  • 大数组频繁 map/filter/reduce
  • 虚拟 DOM 大规模重建
  • 不可控缓存膨胀
  • 频繁 JSON.parse/stringify
  • 大对象深拷贝

情况 C:STW 高,但 Long Task 不明显

结论倾向:传统主线程观测没抓到完整现场,或者 GC 停顿发生在监控盲区。

处理方向:

  • 看内存分配曲线
  • 看路由切换前后的对象增长
  • 看第三方脚本
  • 看是否存在大规模临时对象

情况 D:rAF 掉帧,但 STW 稳定

结论倾向:渲染、布局、合成、GPU、CSS、图片解码等问题。

处理方向:

  • 查 Layout Thrashing
  • 查 forced reflow
  • 查大面积 repaint
  • 查 CSS filter/backdrop-filter
  • 查图片解码与 canvas

情况 E:STW 高,但代码看起来没问题

结论倾向:浏览器扩展脚本干扰、或第三方脚本异常。

处理方向:

  • 在隐身窗口复现问题,排除扩展干扰
  • 检查是否有注入脚本
  • 使用 Chrome DevTools 的 Performance 面板录制,查看 Call Tree 里是否有陌生脚本

5. 一个真实案例:React 页面卡顿,最后不是 React 的锅

案例结构:

  • 页面:大型数据看板
  • 现象:切换筛选条件时偶发 300ms 卡顿
  • 传统监控:Long Task 记录不稳定
  • 怀疑对象:React 组件重渲染
  • 接入 STW Sentinel:发现卡顿前后出现 120ms STW spike
  • 继续排查:筛选逻辑中大量 JSON 深拷贝 + 临时对象创建
  • 修复:结构共享、缓存复用、减少中间数组
  • 结果:STW spike 从 120ms 降到 18ms,交互延迟下降

我们不是让 V8 不 GC,而是减少把 V8 逼到 Stop-The-World 的概率。

6. 阈值怎么设:不要迷信 16.6ms

  • 5ms 以下:通常不需要报警,但可以采样
  • 10ms:适合开发环境敏感阈值
  • 16.6ms:一帧预算
  • 50ms:Long Task 标准线
  • 100ms+:用户明显感知
  • 300ms+:交互断裂
  • 700ms+:事故现场

推荐策略:

  • 开发环境:thresholdMs = 5~10
  • 灰度环境:thresholdMs = 10~20
  • 生产环境:分层采样,重点记录 50ms+ 和 100ms+

阈值不是物理真理,是业务容忍度。 游戏、音频、交易、编辑器、看板、后台管理系统的阈值不一样。

7. 生产环境注意事项:这把武器有保险

7.1 COOP/COEP 会影响资源加载

很多人配置 Cross-Origin-Embedder-Policy: require-corp 后,会发现第三方图片、脚本、iframe、CDN 资源出问题。

建议:

  • 先在实验域名或灰度域名启用
  • 检查第三方资源 CORP/CORS
  • 避免直接在全站裸上

7.2 AudioContext 必须用户手势后启动

建议:

  • 在用户第一次点击、滚动、输入后懒启动
  • 不要在页面加载时强行初始化
  • 对后台标签页降采样或暂停

7.3 不要全量上报所有心跳

生产环境只上报异常尖峰和少量采样窗口。

  • 正常心跳留在本地环形缓冲区
  • 超过阈值才 drain + report
  • 同一 session 做限流

7.4 兼容性要诚实

不是所有浏览器、所有嵌入环境都适合跑这套东西。尤其是微信内置浏览器、企业内嵌 WebView、老 Safari、跨域资源复杂的老项目,都要给降级策略。

环境 支持情况 备注
Chrome 66+ ✅ 完整支持 AudioWorklet + SAB 完整支持
Edge 79+ ✅ 完整支持 基于 Chromium
Safari 14.5+ ⚠️ 部分支持 AudioWorklet 支持,但 SAB 限制更严格
Safari 14.4 及以下 ❌ 不支持 AudioWorklet 未实现
Firefox 76+ ⚠️ 部分支持 AudioWorklet 支持,但 COOP/COEP 行为有差异
微信内置浏览器 ❌ 通常不支持 取决于底层内核版本
企业 WebView (Android) ⚠️ 取决于系统 WebView 版本 需要 Android 7+

降级策略:在不支持的环境中,可以回退到基于 postMessagerAF 的轻量监控,虽然会被主线程卡死影响,但总比没有监控要好。

8. 升维:前端性能监控要从"指标"走向"物理观测"

过去我们用指标描述用户体验:LCP、FID、INP、CLS。现在我们还需要一层更底层的东西:物理心跳。

因为当主线程停止呼吸时,所有跑在主线程里的监控都会变成事后回忆。

STW Sentinel 不是为了证明 AudioWorklet 有多酷,而是为了把前端卡顿从玄学、猜测和甩锅,拉回到可观测、可归因、可复现的工程系统里。


如果你只想试一下,5 行代码接入:

npm install stw-sentinel

如果你想定位真实业务卡顿,请记录上下文、交叉 Long Task、按路由和设备聚合。

页面卡了不可怕,可怕的是你不知道它为什么卡。


🔗 相关文章:

🔗 在线实验室diffserv.xyz/lab

🔗 GitHubgithub.com/hlng2002/st…

❌
❌