背景
上个月,我接手了一个基于 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 的服务器端渲染优势,首屏加载慢。接着尝试用 wagmi 的 useReadContract,但它在 Server Component 里不能直接用。我意识到,问题的核心是如何在 Next.js 14 的架构下,合理分割服务端静态数据获取和客户端动态链上交互。
经过排查,我决定采用这样的架构:1. 服务端用简单的 RPC 调用获取 NFT 的基础 ID 列表;2. 客户端用 wagmi 订阅合约事件并获取动态数据(如价格、是否已售);3. 购买交易使用 wagmi 的 useWriteContract 配合状态监听来更新 UI。
核心实现
1. 项目初始化与依赖配置
首先,我用 pnpm create next-app@latest 创建了项目,选择了 TypeScript 和 Tailwind CSS。然后安装核心依赖:
bash
pnpm add viem wagmi @rainbow-me/rainbowkit
pnpm add -D @types/node
这里有个坑 :wagmi v2 对 TypeScript 版本和 Node.js 类型有要求,如果遇到类型错误,可能需要检查 tsconfig.json 中的 lib 字段是否包含 DOM 和 ES2020。
接下来,创建 app/providers.tsx 文件来配置 wagmi 和 RainbowKit 的 Provider。这是整个应用 Web3 功能的基石。
typescript
// 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,因为元数据(图片、名称)和动态数据(价格)需要客户端获取。
typescript
// 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:
typescript
// 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 配合 wagmi 的 client.readContract 来批量读取。
typescript
// 在组件内部
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 的 useWriteContract 和 useWaitForTransactionReceipt 钩子让这个过程清晰了很多。
typescript
// 继续在组件内部
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 的标价。
完整代码
由于篇幅限制,这里提供最核心的整合版本,省略了部分样式和错误处理的细节。关键部分都已包含。
typescript
// 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>
);
}
typescript
// app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
其他文件 (providers.tsx, page.tsx, components/NFTMarketClient.tsx) 的代码已在前文分步给出,组合起来即可运行。记得替换 CONTRACT_ADDRESS 为你自己的合约地址,并完善 ABI。
踩坑记录
-
BigInt 序列化错误 :在 Server Component 中获取的
bigint类型无法直接通过 props 传递给 Client Component。Next.js 会报序列化错误。解决 :在服务端转换为字符串id.toString(),在客户端需要时再转回BigInt。 -
useReadContract在循环中性能低下:最初我在listings.map里直接为每个 NFT 调用useReadContract,导致同时发起数十个请求,页面卡顿。解决 :改用useEffect配合publicClient.readContract进行批量读取,只触发一次状态更新。 -
交易成功后 UI 状态不同步 :用户购买成功后,列表中的 NFT 状态没有立即更新。虽然链上状态已变,但其他用户刷新前仍能看到"购买"按钮。解决 :利用
useWaitForTransactionReceipt的isSuccess状态触发本地数据重新获取或乐观更新。更完善的方案是监听合约的TokenSold事件。 -
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 优化计算、以及集成更复杂的事件监听系统来实现真正的实时更新。希望我的踩坑记录能帮你绕过这些弯路。