Next.js 14 + wagmi v2 构建 NFT 市场:从列表渲染到实时更新的完整链路
背景
上个月,我接手了一个新的 Web3 项目:为一个基于 Base 链的 NFT 系列开发一个轻量级的交易市场前端。核心需求很简单:展示该系列的所有 NFT,显示每个 NFT 的当前挂单价格,并且当用户购买或取消挂单时,页面上的信息要能实时更新,无需手动刷新。
技术栈选型很明确:Next.js 14(App Router)、TypeScript、Tailwind CSS,以及 Web3 交互的核心——wagmi v2 和 viem。我心想,这不过是把链上数据读出来,监听几个事件,再渲染成列表,用 useAccount 和 useReadContract 不就搞定了吗?结果,从第一行代码开始,坑就一个接一个地来了。
问题分析
我的初始思路非常直接:在页面组件里,用 wagmi 的 useReadContract 读取 NFT 合约的 totalSupply,然后循环获取每个 Token 的元数据和挂单信息,最后用 useEffect 监听 ListingUpdated 和 Transfer 事件来触发数据重拉。
但一上手就发现了几个致命问题:
-
水合(Hydration)错误:在服务端组件(Server Component)中直接使用
useAccount或useReadContract会导致错误,因为这些钩子依赖于浏览器环境。 - 性能灾难:如果我有 1000 个 NFT,难道要发起 1000+ 次 RPC 调用吗?页面加载会慢到无法接受。
-
实时更新失效:简单地用
useEffect监听事件,在用户切换钱包或断开连接时,监听器会混乱,导致更新不及时或重复更新。 - 状态同步难题:交易(购买、挂单)提交后,如何优雅地等待链上确认,并立即更新 UI,而不是等用户手动刷新?
最初的方案完全走不通。我意识到,必须把服务端初始渲染、客户端状态管理、批量数据获取和事件驱动更新这几个环节拆解开,设计一个更清晰的架构。
核心实现
1. 架构分层:服务端获取初始数据
首先,我放弃了在页面组件里直接调用 Web3 钩子获取所有数据的想法。对于 NFT 列表这种相对静态的初始数据,应该在服务端获取。我创建了一个服务端函数,使用 Viem 的公共客户端(Public Client)来读取链上数据。
关键点:在 App Router 中,我们可以在 Server Component 或 Server Action 里直接与区块链交互,无需钱包连接。这完美解决了初始渲染的问题。
// app/api/nfts/route.ts
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';
// 初始化一个不需要钱包的公共客户端
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.NEXT_PUBLIC_RPC_URL),
});
// NFT 合约 ABI 片段
const NFT_ABI = [
{
name: 'totalSupply',
type: 'function',
stateMutability: 'view',
inputs: [],
outputs: [{ type: 'uint256' }],
},
{
name: 'tokenURI',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [{ type: 'string' }],
},
] as const;
// 市场合约 ABI 片段
const MARKET_ABI = [
{
name: 'listings',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [
{ name: 'seller', type: 'address' },
{ name: 'price', type: 'uint256' },
{ name: 'isActive', type: 'bool' },
],
},
] as const;
export async function GET() {
try {
const totalSupply = await publicClient.readContract({
address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
abi: NFT_ABI,
functionName: 'totalSupply',
});
const nftDataPromises = [];
// 注意:这里用 Number 转换只适用于总量不大的情况,真实项目需考虑 BigInt
for (let i = 0; i < Number(totalSupply); i++) {
const promise = Promise.all([
// 获取元数据 URI
publicClient.readContract({
address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
abi: NFT_ABI,
functionName: 'tokenURI',
args: [BigInt(i)],
}),
// 获取挂单信息
publicClient.readContract({
address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
abi: MARKET_ABI,
functionName: 'listings',
args: [BigInt(i)],
}),
]).then(([tokenURI, listing]) => ({
tokenId: i,
tokenURI,
listing,
}));
nftDataPromises.push(promise);
}
const nfts = await Promise.all(nftDataPromises);
return Response.json({ nfts });
} catch (error) {
console.error('Failed to fetch NFTs:', error);
return Response.json({ error: 'Fetch failed' }, { status: 500 });
}
}
这里有个坑:直接循环调用 RPC 在 NFT 数量多时确实慢。在生产环境中,你应该考虑让合约本身返回批量数据,或者使用 The Graph 这类索引服务。我这里为了演示核心流程,先采用简单循环。
2. 客户端状态与实时更新
服务端提供了初始数据,但购买、挂单等交互后的实时更新必须在客户端处理。我创建了一个客户端组件 NftList,它接收服务端的初始数据,并负责管理动态状态。
实时更新的核心是 监听链上事件。wagmi v2 提供了 useWatchContractEvent 钩子,但直接用在列表组件里会导致每个 NFT 卡片都创建一个监听器,性能极差。我的方案是:在父级组件只监听市场合约的全局事件。
// components/nft-list.tsx
'use client';
import { useEffect, useState } from 'react';
import { useWatchContractEvent } from 'wagmi';
import { NftCard } from './nft-card';
// 市场合约 ABI 事件片段
const MARKET_EVENT_ABI = [
{
type: 'event',
name: 'ListingUpdated',
inputs: [
{ indexed: true, name: 'tokenId', type: 'uint256' },
{ indexed: false, name: 'seller', type: 'address' },
{ indexed: false, name: 'price', type: 'uint256' },
{ indexed: false, name: 'isActive', type: 'bool' },
],
},
] as const;
interface NftListProps {
initialNfts: Array<{
tokenId: number;
tokenURI: string;
listing: [string, bigint, boolean];
}>;
}
export function NftList({ initialNfts }: NftListProps) {
// 使用服务端数据初始化状态
const [nfts, setNfts] = useState(initialNfts);
// 关键:监听全局的 ListingUpdated 事件
useWatchContractEvent({
address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
abi: MARKET_EVENT_ABI,
eventName: 'ListingUpdated',
onLogs(logs) {
console.log('ListingUpdated logs:', logs);
// 当事件触发时,更新对应 NFT 的挂单信息
logs.forEach((log) => {
const { tokenId, price, isActive } = log.args;
if (tokenId !== undefined) {
setNfts((prev) =>
prev.map((nft) =>
nft.tokenId === Number(tokenId)
? {
...nft,
listing: [log.args.seller || '0x', price || 0n, isActive || false],
}
: nft
)
);
}
});
},
});
// 一个手动刷新函数,用于在交易确认后主动触发(作为兜底)
const refreshData = async () => {
const res = await fetch('/api/nfts');
const data = await res.json();
if (data.nfts) setNfts(data.nfts);
};
return (
<div>
<button onClick={refreshData} className="mb-4 p-2 bg-gray-200 rounded">
手动刷新数据
</button>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{nfts.map((nft) => (
<NftCard key={nft.tokenId} nft={nft} onActionSuccess={refreshData} />
))}
</div>
</div>
);
}
注意这个细节:useWatchContractEvent 的回调函数中,log.args 的类型可能是 undefined,必须做防御性判断,否则 TypeScript 会报错,运行时也可能崩溃。
3. 交易交互与乐观更新
用户点击“购买”时,如果等到交易上链确认(可能十几秒)才更新 UI,体验会很差。我采用了 乐观更新(Optimistic Update) 的策略:先立即更新本地状态,假设交易会成功;如果交易失败,再回滚状态。
// components/nft-card.tsx
'use client';
import { useState } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
interface NftCardProps {
nft: {
tokenId: number;
tokenURI: string;
listing: [string, bigint, boolean];
};
onActionSuccess?: () => void;
}
export function NftCard({ nft, onActionSuccess }: NftCardProps) {
const { address } = useAccount();
const [isUpdating, setIsUpdating] = useState(false);
const { data: hash, writeContract, error } = useWriteContract();
const { isLoading: isConfirming } = useWaitForTransactionReceipt({ hash });
const [seller, price, isActive] = nft.listing;
const handleBuy = async () => {
if (!address || !isActive) return;
setIsUpdating(true); // 开始乐观更新
// 这里可以立即调用父组件传递的回调,或者用状态管理更新本地列表
// 为了简化,我们假设父组件会通过事件监听更新,这里只处理自身加载状态
try {
writeContract({
address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
abi: [
{
name: 'buyToken',
type: 'function',
stateMutability: 'payable',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [],
},
] as const,
functionName: 'buyToken',
args: [BigInt(nft.tokenId)],
value: price,
});
} catch (err) {
console.error('Buy failed:', err);
setIsUpdating(false); // 回滚乐观更新
}
};
// 交易确认后的处理
useEffect(() => {
if (hash && !isConfirming) {
console.log('Transaction confirmed!');
setIsUpdating(false);
onActionSuccess?.(); // 通知父组件刷新数据
}
}, [hash, isConfirming, onActionSuccess]);
return (
<div className="border p-4 rounded-lg shadow">
<img src={`https://ipfs.io/ipfs/${nft.tokenURI.split('://')[1]}`} alt={`NFT ${nft.tokenId}`} className="w-full h-48 object-cover rounded" />
<div className="mt-2">
<p className="font-bold">Token ID: {nft.tokenId}</p>
<p>Price: {price ? parseFloat(parseEther(price.toString()).toString()).toFixed(4)} ETH</p>
<p>Status: {isActive ? 'For Sale' : 'Not Listed'}</p>
</div>
{isActive && address !== seller && (
<button
onClick={handleBuy}
disabled={isUpdating || isConfirming}
className={`mt-2 w-full py-2 rounded ${isUpdating || isConfirming ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600 text-white'}`}
>
{isUpdating || isConfirming ? 'Processing...' : 'Buy Now'}
</button>
)}
{error && <p className="text-red-500 text-sm mt-1">Error: {error.message}</p>}
</div>
);
}
这里有个大坑:乐观更新时,你更新的状态必须与链上最终状态一致。比如购买后,NFT 的卖家会变,挂单状态会变为 false。如果只是简单地把 isActive 设为 false,但卖家地址没变,就会与链上数据不一致。最稳妥的方式是,在交易发送后,立即用事件监听来更新,或者等确认后触发一次数据重拉。
4. 页面集成与配置
最后,将服务端数据获取和客户端组件在页面中组装起来。页面是服务端组件,它获取数据并传递给客户端组件。
// app/page.tsx
import { NftList } from '@/components/nft-list';
async function getInitialNfts() {
// 在构建时或请求时从 API 路由获取数据
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/nfts`, {
// 根据需求配置缓存
// next: { revalidate: 60 }, // ISR: 每60秒重新验证
cache: 'no-store', // 每次请求都获取最新数据
});
if (!res.ok) {
throw new Error('Failed to fetch NFTs');
}
return res.json();
}
export default async function HomePage() {
const data = await getInitialNfts();
return (
<main className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">NFT Marketplace</h1>
{/* 将服务端数据作为 prop 传递给客户端组件 */}
<NftList initialNfts={data.nfts || []} />
</main>
);
}
同时,需要在项目根目录配置 wagmi 的 Provider。注意 Next.js 14 App Router 中,Provider 必须是客户端组件。
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { base } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';
const queryClient = new QueryClient();
const config = createConfig({
chains: [base],
connectors: [injected()],
transports: {
[base.id]: http(process.env.NEXT_PUBLIC_RPC_URL),
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}
// 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',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
完整代码结构
项目的主要文件结构如下:
my-nft-marketplace/
├── app/
│ ├── api/
│ │ └── nfts/
│ │ └── route.ts # 服务端 API,获取初始 NFT 数据
│ ├── layout.tsx # 根布局,包含 Providers
│ ├── page.tsx # 主页(服务端组件)
│ └── providers.tsx # Wagmi & React Query Provider
├── components/
│ ├── nft-list.tsx # NFT 列表客户端组件(核心状态与事件监听)
│ └── nft-card.tsx # 单个 NFT 卡片组件(交易交互)
├── .env.local # 环境变量(合约地址、RPC URL)
└── package.json
踩坑记录
-
NEXT_PUBLIC_变量在服务端为undefined:我一开始把合约地址放在.env.local但没加NEXT_PUBLIC_前缀,导致在服务端 API 路由中读取不到。解决:确保所有需要在浏览器和服务端共享的变量都以NEXT_PUBLIC_开头。 -
useWatchContractEvent监听不到事件:我一开始把监听器放在NftCard组件里,结果组件卸载时监听器也被移除了,而且重复创建。解决:将全局事件监听提升到父组件(NftList),并确保合约地址和 ABI 正确。 -
BigInt 序列化错误:从服务端 API 返回的数据中包含
bigint类型的价格,直接JSON.stringify会报错。解决:在服务端将bigint转换为字符串,或者在客户端使用 Viem 的parseEther等工具处理。我在 API 路由中返回了原始数据,在组件内处理转换。 -
交易确认后状态不同步:用户购买成功后,列表里该 NFT 的
isActive状态变了,但卖家地址还是旧的。解决:这是因为我的乐观更新逻辑不完整。最终我选择依赖useWatchContractEvent的事件监听作为主要更新源,手动刷新作为兜底,保证了数据与链上严格同步。
小结
这套方案的核心收获是 “服务端初始化,客户端维护动态状态,事件驱动更新”。它既保证了首屏加载速度,又实现了流畅的实时交互。对于更复杂的场景,比如分页、筛选、多链支持,还可以在此基础上扩展,例如引入状态管理库来集中管理 NFT 数据,或者用 The Graph 替代批量 RPC 调用。Web3 前端开发就是这样,每一个需求都在逼你更深入地理解数据流和链上链下的同步逻辑。