Next.js 14 + wagmi v2 构建 NFT 市场:从列表渲染到链上交易的全链路实践

5 阅读1分钟

背景

上个月,我接手了一个基于 Polygon 链的 NFT 交易市场前端重构项目。原项目是用 Create React App 搭的,状态管理比较混乱,读取 NFT 列表和钱包交互的代码耦合严重。这次我决定用 Next.js 14(App Router)和 wagmi v2 重写,目标是构建一个响应快、用户体验好、且易于维护的前端。

项目核心需求很简单:1. 从智能合约中读取正在出售的 NFT 列表并展示;2. 用户连接钱包后可以购买 NFT。听起来不复杂,但实际开发中,如何在 App Router 的 Server/Client 组件架构下优雅地管理 Web3 数据流,如何可靠地处理购买交易并同步 UI 状态,这些问题让我踩了不少坑。

问题分析

一开始,我按照传统思路,打算在页面组件(Server Component)里直接用 viem 的公共客户端读取合约数据。但很快发现两个问题:第一,合约的 tokenURI 返回的是指向 IPFS 或 HTTP 的链接,需要在客户端解析;第二,NFT 的当前价格和出售状态可能随时变化,需要实时性。

我的第一个方案是在 useEffect 里调用合约,但这样无法利用 Next.js 的服务器端渲染优势,首屏加载慢。接着尝试用 wagmiuseReadContract,但它在 Server Component 里不能直接用。我意识到,问题的核心是如何在 Next.js 14 的架构下,合理分割服务端静态数据获取和客户端动态链上交互

经过排查,我决定采用这样的架构:1. 服务端用简单的 RPC 调用获取 NFT 的基础 ID 列表;2. 客户端用 wagmi 订阅合约事件并获取动态数据(如价格、是否已售);3. 购买交易使用 wagmiuseWriteContract 配合状态监听来更新 UI。

核心实现

1. 项目初始化与依赖配置

首先,我用 pnpm create next-app@latest 创建了项目,选择了 TypeScript 和 Tailwind CSS。然后安装核心依赖:

pnpm add viem wagmi @rainbow-me/rainbowkit
pnpm add -D @types/node

这里有个坑wagmi v2 对 TypeScript 版本和 Node.js 类型有要求,如果遇到类型错误,可能需要检查 tsconfig.json 中的 lib 字段是否包含 DOMES2020

接下来,创建 app/providers.tsx 文件来配置 wagmiRainbowKit 的 Provider。这是整个应用 Web3 功能的基石。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { polygon } from 'wagmi/chains';
import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import '@rainbow-me/rainbowkit/styles.css';

// 1. 设置查询客户端
const queryClient = new QueryClient();

// 2. 创建 wagmi 配置
const config = createConfig({
  chains: [polygon], // 我们主要用 Polygon 链
  transports: {
    [polygon.id]: http('https://polygon-rpc.com'), // 公共 RPC,生产环境建议用 Infura 或 Alchemy
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>{children}</RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

然后在 app/layout.tsx 中包裹这个 Provider。注意这个细节Providers 组件必须标记为 'use client',但 layout 本身可以是 Server Component。

2. 服务端获取 NFT 基础列表

我创建了一个简单的服务端组件 app/page.tsx 来获取 NFT 列表的“骨架”。这里我只获取 NFT 的 token ID,因为元数据(图片、名称)和动态数据(价格)需要客户端获取。

// app/page.tsx
import { createPublicClient, http } from 'viem';
import { polygon } from 'viem/chains';
import NFTMarketClient from './components/NFTMarketClient';

// 这是一个模拟的 NFT 市场合约 ABI 片段
const MARKET_ABI = [
  {
    inputs: [],
    name: 'getAllListedTokens',
    outputs: [{ name: '', type: 'uint256[]' }],
    stateMutability: 'view',
    type: 'function',
  },
] as const;

const CONTRACT_ADDRESS = '0x...'; // 你的合约地址

export default async function HomePage() {
  // 在服务端创建公共客户端
  const client = createPublicClient({
    chain: polygon,
    transport: http('https://polygon-rpc.com'),
  });

  let tokenIds: bigint[] = [];
  try {
    // 调用合约读取上架的 NFT ID 数组
    const data = await client.readContract({
      address: CONTRACT_ADDRESS,
      abi: MARKET_ABI,
      functionName: 'getAllListedTokens',
    });
    tokenIds = data as bigint[];
  } catch (error) {
    console.error('Failed to fetch token IDs:', error);
    // 生产环境应有更完善的错误处理
  }

  // 将 BigInt 转换为字符串,因为 React 的 props 需要可序列化
  const initialTokenIds = tokenIds.map(id => id.toString());

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-8">NFT Marketplace</h1>
      {/* 将初始数据传递给客户端组件 */}
      <NFTMarketClient initialTokenIds={initialTokenIds} />
    </div>
  );
}

这样做的好处是,即使用户没装钱包,首屏也能看到 NFT 的列表框架,提升感知速度。

3. 客户端组件:数据订阅与展示

真正的重头戏在客户端组件 app/components/NFTMarketClient.tsx。这里需要完成三件事:1. 用 useReadContract 并行获取每个 NFT 的详情;2. 展示列表;3. 处理购买。

首先,定义完整的合约 ABI:

// app/components/NFTMarketClient.tsx
'use client';

import { useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { ConnectButton } from '@rainbow-me/rainbowkit';

const FULL_MARKET_ABI = [
  // 列出 NFT 的函数(省略)
  // ...
  // 读取列表的函数
  {
    inputs: [],
    name: 'getAllListedTokens',
    outputs: [{ name: '', type: 'uint256[]' }],
    stateMutability: 'view',
    type: 'function',
  },
  // 获取某个 NFT 列表信息的函数
  {
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    name: 'getListing',
    outputs: [
      { name: 'seller', type: 'address' },
      { name: 'price', type: 'uint256' },
      { name: 'isActive', type: 'bool' },
    ],
    stateMutability: 'view',
    type: 'function',
  },
  // 购买 NFT 的函数
  {
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    name: 'buyToken',
    outputs: [],
    stateMutability: 'payable',
    type: 'function',
  },
  // 列表更新事件
  {
    type: 'event',
    name: 'TokenListed',
    inputs: [
      { indexed: true, name: 'tokenId', type: 'uint256' },
      { indexed: false, name: 'seller', type: 'address' },
      { indexed: false, name: 'price', type: 'uint256' },
    ],
  },
  {
    type: 'event',
    name: 'TokenSold',
    inputs: [
      { indexed: true, name: 'tokenId', type: 'uint256' },
      { indexed: false, name: 'buyer', type: 'address' },
    ],
  },
] as const;

然后,在组件内部,我们需要为每个 tokenId 调用 getListing 来获取价格和状态。这里有个性能坑:如果循环调用 useReadContract,会导致过多的请求。我采用了 Promise.all 配合 wagmiclient.readContract 来批量读取。

// 在组件内部
import { useEffect, useState } from 'react';
import { usePublicClient } from 'wagmi';

interface NFTListing {
  tokenId: string;
  price: bigint | null;
  isActive: boolean | null;
  seller: `0x${string}` | null;
}

export default function NFTMarketClient({ initialTokenIds }: { initialTokenIds: string[] }) {
  const [listings, setListings] = useState<NFTListing[]>([]);
  const publicClient = usePublicClient();

  // 批量获取列表详情
  useEffect(() => {
    const fetchListings = async () => {
      if (!publicClient || initialTokenIds.length === 0) return;

      const promises = initialTokenIds.map(async (tokenId) => {
        try {
          const data = await publicClient.readContract({
            address: CONTRACT_ADDRESS,
            abi: FULL_MARKET_ABI,
            functionName: 'getListing',
            args: [BigInt(tokenId)],
          }) as [string, bigint, boolean]; // 对应 seller, price, isActive

          return {
            tokenId,
            seller: data[0] as `0x${string}`,
            price: data[1],
            isActive: data[2],
          };
        } catch (error) {
          console.error(`Failed to fetch listing for token ${tokenId}:`, error);
          return {
            tokenId,
            seller: null,
            price: null,
            isActive: null,
          };
        }
      });

      const results = await Promise.all(promises);
      setListings(results);
    };

    fetchListings();
  }, [initialTokenIds, publicClient]);

4. 实现购买功能与状态同步

购买功能需要处理交易发送、等待确认和 UI 状态更新。wagmi v2 的 useWriteContractuseWaitForTransactionReceipt 钩子让这个过程清晰了很多。

// 继续在组件内部
const CONTRACT_ADDRESS = '0x...';

export default function NFTMarketClient({ initialTokenIds }: { initialTokenIds: string[] }) {
  // ... 之前的 state 和 effect

  const { writeContract, data: hash, isPending: isWriting } = useWriteContract();
  const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
    hash,
  });

  const handleBuy = (tokenId: string, price: bigint) => {
    if (!price) return;
    
    writeContract({
      address: CONTRACT_ADDRESS,
      abi: FULL_MARKET_ABI,
      functionName: 'buyToken',
      args: [BigInt(tokenId)],
      value: price, // 支付金额
    });
  };

  // 交易确认成功后,更新本地状态
  useEffect(() => {
    if (isConfirmed && hash) {
      // 这里可以添加更精细的逻辑,比如根据交易日志更新特定的 NFT 状态
      // 简单起见,我们重新获取所有列表
      setListings(prev => prev.map(item => 
        item.price === null ? { ...item, isActive: false } : item
      ));
      alert('Purchase successful!');
    }
  }, [isConfirmed, hash]);

  return (
    <div>
      <div className="flex justify-end mb-4">
        <ConnectButton />
      </div>
      
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {listings.map((item) => (
          <div key={item.tokenId} className="border rounded-lg p-4 shadow">
            <div className="h-48 bg-gray-200 mb-4 rounded">NFT #{item.tokenId}</div>
            <div className="mb-2">
              <span className="font-semibold">Price: </span>
              {item.price ? `${Number(item.price) / 1e18} MATIC` : 'Loading...'}
            </div>
            <button
              onClick={() => item.price && handleBuy(item.tokenId, item.price)}
              disabled={!item.isActive || isWriting || isConfirming}
              className="w-full bg-blue-600 text-white py-2 rounded disabled:bg-gray-400"
            >
              {isWriting || isConfirming ? 'Processing...' : 'Buy Now'}
            </button>
            {!item.isActive && (
              <p className="text-red-500 text-sm mt-2">This NFT is no longer for sale.</p>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

注意这个细节value 字段是 payable 函数的关键,它指定了随交易发送的 Native Token 数量。这里我们传递的是 NFT 的标价。

完整代码

由于篇幅限制,这里提供最核心的整合版本,省略了部分样式和错误处理的细节。关键部分都已包含。

// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'NFT Marketplace',
  description: 'A simple NFT marketplace built with Next.js and wagmi',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

其他文件 (providers.tsx, page.tsx, components/NFTMarketClient.tsx) 的代码已在前文分步给出,组合起来即可运行。记得替换 CONTRACT_ADDRESS 为你自己的合约地址,并完善 ABI。

踩坑记录

  1. BigInt 序列化错误:在 Server Component 中获取的 bigint 类型无法直接通过 props 传递给 Client Component。Next.js 会报序列化错误。解决:在服务端转换为字符串 id.toString(),在客户端需要时再转回 BigInt

  2. useReadContract 在循环中性能低下:最初我在 listings.map 里直接为每个 NFT 调用 useReadContract,导致同时发起数十个请求,页面卡顿。解决:改用 useEffect 配合 publicClient.readContract 进行批量读取,只触发一次状态更新。

  3. 交易成功后 UI 状态不同步:用户购买成功后,列表中的 NFT 状态没有立即更新。虽然链上状态已变,但其他用户刷新前仍能看到“购买”按钮。解决:利用 useWaitForTransactionReceiptisSuccess 状态触发本地数据重新获取或乐观更新。更完善的方案是监听合约的 TokenSold 事件。

  4. RainbowKit 主题与 Next.js 冲突:在 layout.tsx 中引入 RainbowKit 的 CSS 文件时,如果顺序不对,会导致 Tailwind 样式被覆盖。解决:确保在 providers.tsx 中先导入 @rainbow-me/rainbowkit/styles.css,再在 globals.css 中定义自定义样式。

小结

这次重构让我深刻体会到,在 Next.js App Router 中构建 Web3 应用,核心是明确数据获取的边界:服务端获取静态或准静态数据,客户端管理动态和交互状态。wagmi v2 的钩子与 TanStack Query 的集成让缓存和状态管理变得直观,而 viem 的强类型 ABI 支持大大减少了运行时错误。

这个方案还可以继续优化,比如实现 NFT 元数据(图片、名称)的获取和缓存、使用 useMemo 优化计算、以及集成更复杂的事件监听系统来实现真正的实时更新。希望我的踩坑记录能帮你绕过这些弯路。