用 wagmi v2 + Next.js 14 踩坑实录:手写一个支持多链的 NFT 市场前端
摘要
上个月接了个 NFT 市场的前端外包,甲方要求支持以太坊和 Polygon 双链。我本以为用 wagmi 和 RainbowKit 能快速搞定,结果从钱包切换到合约调用,再到 IPFS 元数据解析,踩了整整两天的坑。本文把我实际项目中遇到的 4 个具体报错和解决方案完整记录下来,代码都是我跑通过的,希望能帮你少走弯路。
背景
甲方是个 NFT 交易平台,核心功能是用户可以在以太坊主网和 Polygon 上上架、购买 NFT。前端是用 Next.js 14 搭的,我负责实现:钱包连接、NFT 列表展示、上架和购买功能。
当时我的技术栈是:Next.js 14(App Router)、wagmi v2、viem、RainbowKit。wagmi v2 刚发布没多久,网上资料不多,很多坑都是我自己摸出来的。
问题分析
最初的想法很简单:用 RainbowKit 做钱包连接,用 wagmi 的 hooks 做合约交互。但实际一跑就发现问题:
- 多链切换时,RainbowKit 的 chain 对象不兼容 wagmi v2:RainbowKit 默认用的是 wagmi v1 的 chain 配置,v2 改用
defineChain,直接复制官方示例会报TypeError: chain is not a function。 - NFT 的元数据(metadata)解析:很多 NFT 的 tokenURI 返回的是 IPFS 链接,需要解析成 HTTP 才能展示图片和属性。
- 合约调用报错:wagmi v2 的
useWriteContract返回的write函数是异步的,直接调用会报Cannot read properties of undefined。 - Next.js 14 的客户端组件和服务端组件混用:wagmi 的 hooks 只能在客户端组件用,但 Next.js 14 默认是服务端组件,导致
useAccount报错。
核心实现
1. 配置 wagmi v2 和 RainbowKit 的多链支持
这是最坑的一步。wagmi v2 的配置方式完全变了,必须用 createConfig 和 defineChain。
// src/lib/wagmi.ts
import { createConfig, http, defineChain } from 'wagmi'
import { mainnet } from 'wagmi/chains'
import { connectorsForWallets } from '@rainbow-me/rainbowkit'
import { metaMaskWallet, walletConnectWallet } from '@rainbow-me/rainbowkit/wallets'
// 定义 Polygon 链,wagmi v2 必须用 defineChain
export const polygon = defineChain({
id: 137,
name: 'Polygon',
network: 'matic',
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
rpcUrls: {
default: { http: ['https://polygon-rpc.com'] },
},
blockExplorers: {
default: { name: 'PolygonScan', url: 'https://polygonscan.com' },
},
})
// 这里有个坑:RainbowKit 的 getDefaultConfig 在 v2 中已废弃,必须手动配置 connectors
const connectors = connectorsForWallets(
[
{
groupName: 'Recommended',
wallets: [metaMaskWallet, walletConnectWallet],
},
],
{
appName: 'NFT Market',
projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
}
)
export const config = createConfig({
chains: [mainnet, polygon],
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
},
connectors,
})
注意这个细节:projectId 必须从环境变量读取,不能硬编码。我当时直接写死了 WalletConnect 的 projectId,结果上线后被人刷了流量,钱包连接一直失败。
2. 封装客户端 Provider
Next.js 14 的 App Router 默认是服务端组件,wagmi 的 hooks 只能在客户端用。所以必须创建一个 Providers 组件包裹整个应用。
// src/app/providers.tsx
'use client'
import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RainbowKitProvider } from '@rainbow-me/rainbowkit'
import { config } from '@/lib/wagmi'
const queryClient = new QueryClient()
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
)
}
然后在 layout.tsx 中引入:
// src/app/layout.tsx
import { Providers } from './providers'
import '@rainbow-me/rainbowkit/styles.css'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
这里有个坑:RainbowKit 的 CSS 必须在 layout.tsx 中全局引入,否则组件样式会错乱。我一开始放在某个页面组件里,结果钱包弹窗样式全乱了。
3. 获取 NFT 列表并解析元数据
NFT 市场的核心功能是展示 NFT 列表。我需要从合约读取 tokenURI,然后解析 IPFS 元数据。
// src/app/page.tsx
'use client'
import { useReadContracts, useAccount } from 'wagmi'
import { useEffect, useState } from 'react'
// NFT 合约 ABI(只保留需要的部分)
const NFT_ABI = [
{
inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }],
name: 'tokenURI',
outputs: [{ internalType: 'string', name: '', type: 'string' }],
stateMutability: 'view',
type: 'function',
},
{
inputs: [],
name: 'totalSupply',
outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
stateMutability: 'view',
type: 'function',
},
] as const
// 解析 IPFS 链接
function resolveIPFS(uri: string): string {
if (uri.startsWith('ipfs://')) {
return uri.replace('ipfs://', 'https://ipfs.io/ipfs/')
}
return uri
}
export default function HomePage() {
const { address } = useAccount()
const [nfts, setNfts] = useState<any[]>([])
const [loading, setLoading] = useState(false)
// 合约地址(实际项目中从环境变量读取)
const contractAddress = '0x...' // 替换为实际合约地址
// 第一步:获取总供应量
const { data: totalSupply } = useReadContracts({
contracts: [
{
address: contractAddress,
abi: NFT_ABI,
functionName: 'totalSupply',
},
],
})
// 第二步:根据 totalSupply 批量获取 tokenURI
useEffect(() => {
if (!totalSupply || !totalSupply[0]?.result) return
const supply = Number(totalSupply[0].result)
const fetchNFTs = async () => {
setLoading(true)
const tokenIds = Array.from({ length: supply }, (_, i) => i + 1)
// 这里有个坑:不能一次性调用太多合约,会超出 gas 限制
// 我分批处理,每批 10 个
const batchSize = 10
const allNFTs: any[] = []
for (let i = 0; i < tokenIds.length; i += batchSize) {
const batch = tokenIds.slice(i, i + batchSize)
const results = await Promise.allSettled(
batch.map(async (tokenId) => {
try {
// 调用 tokenURI
const uri = await fetchTokenURI(contractAddress, tokenId)
const resolvedUri = resolveIPFS(uri)
// 解析元数据
const response = await fetch(resolvedUri)
const metadata = await response.json()
return {
tokenId,
name: metadata.name || `NFT #${tokenId}`,
image: resolveIPFS(metadata.image),
description: metadata.description,
attributes: metadata.attributes || [],
}
} catch (error) {
console.error(`Failed to fetch NFT ${tokenId}:`, error)
return null
}
})
)
allNFTs.push(...results.filter((r) => r.status === 'fulfilled' && r.value).map((r: any) => r.value))
}
setNfts(allNFTs)
setLoading(false)
}
fetchNFTs()
}, [totalSupply])
// 辅助函数:通过 viem 的公共客户端调用合约
async function fetchTokenURI(contractAddress: string, tokenId: number): Promise<string> {
// 这里用 wagmi 的公共客户端,避免频繁创建 provider
const { getPublicClient } = await import('@wagmi/core')
const publicClient = getPublicClient()
const uri = await publicClient.readContract({
address: contractAddress as `0x${string}`,
abi: NFT_ABI,
functionName: 'tokenURI',
args: [BigInt(tokenId)],
})
return uri as string
}
if (!address) {
return <div>请先连接钱包</div>
}
if (loading) {
return <div>加载中...</div>
}
return (
<div className="grid grid-cols-3 gap-4">
{nfts.map((nft) => (
<div key={nft.tokenId} className="border rounded-lg p-4">
<img src={nft.image} alt={nft.name} className="w-full h-48 object-cover" />
<h2 className="text-lg font-bold mt-2">{nft.name}</h2>
<p className="text-sm text-gray-600">{nft.description}</p>
{nft.attributes.length > 0 && (
<div className="mt-2">
{nft.attributes.map((attr: any, index: number) => (
<span key={index} className="inline-block bg-gray-200 rounded px-2 py-1 text-xs mr-1">
{attr.trait_type}: {attr.value}
</span>
))}
</div>
)}
</div>
))}
</div>
)
}
注意这个细节:useReadContracts 返回的结果是数组,每个元素包含 result 和 error 字段。如果合约调用失败,result 会是 undefined,直接使用会报错。
4. 实现购买和上架功能
购买和上架都需要用户签名,wagmi v2 的 useWriteContract 和 useSendTransaction 用法变了。
// src/app/buy.tsx(购买组件)
'use client'
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { parseEther } from 'viem'
const MARKETPLACE_ABI = [
{
inputs: [
{ internalType: 'address', name: 'nftAddress', type: 'address' },
{ internalType: 'uint256', name: 'tokenId', type: 'uint256' },
],
name: 'buyNFT',
outputs: [],
stateMutability: 'payable',
type: 'function',
},
] as const
export function BuyButton({ nftAddress, tokenId, price }: { nftAddress: string; tokenId: number; price: string }) {
const { writeContract, data: hash, error } = useWriteContract()
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash })
const handleBuy = async () => {
try {
await writeContract({
address: nftAddress as `0x${string}`,
abi: MARKETPLACE_ABI,
functionName: 'buyNFT',
args: [nftAddress as `0x${string}`, BigInt(tokenId)],
value: parseEther(price), // 注意:价格单位是 ETH,不是 wei
})
} catch (err) {
console.error('购买失败:', err)
}
}
return (
<button
onClick={handleBuy}
disabled={isConfirming}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
{isConfirming ? '交易确认中...' : `购买 (${price} ETH)`}
</button>
)
}
这里有个坑:useWriteContract 的 writeContract 函数是异步的,但如果你直接 await writeContract(...),它返回的是交易哈希(如果成功),而不是等待交易确认。要等待确认,必须配合 useWaitForTransactionReceipt。
完整代码
由于篇幅限制,完整代码仓库在 GitHub Gist 上。以下是核心文件结构:
src/
├── app/
│ ├── layout.tsx
│ ├── providers.tsx
│ ├── page.tsx
│ └── buy.tsx
├── lib/
│ └── wagmi.ts
└── components/
└── NFTCard.tsx
踩坑记录
-
TypeError: chain is not a function:坑了我 3 小时。原因是 RainbowKit 的getDefaultConfig在 wagmi v2 中已废弃,必须用createConfig手动配置。解决方法:完全放弃getDefaultConfig,改用createConfig+connectorsForWallets。 -
Cannot read properties of undefined (reading 'result'):useReadContracts的返回值结构变了。v1 是[result1, result2],v2 是[{result: ..., error: ...}, ...]。需要加判空:totalSupply?.[0]?.result。 -
IPFS 图片加载失败:有些 NFT 的
image字段是ipfs://xxx,直接放<img>标签里不显示。必须用resolveIPFS函数转成 HTTP 链接,而且最好用ipfs.io或gateway.pinata.cloud等公共网关。 -
useWriteContract调用后交易不确认:我一开始以为writeContract返回的就是交易回执,后来发现它只返回哈希。必须用useWaitForTransactionReceipt监听确认状态。而且要注意:如果用户拒绝签名,writeContract会 throw 一个错误,需要 try-catch 捕获。
小结
这次踩坑让我彻底搞懂了 wagmi v2 的配置方式和多链切换逻辑。核心收获是:wagmi v2 的配置必须用 createConfig + defineChain,合约调用必须区分 writeContract 和 useWaitForTransactionReceipt,NFT 元数据必须处理 IPFS 链接。想继续深挖的话,可以研究 wagmi v2 的 useSimulateContract 做交易模拟,或者用 @tanstack/react-query 做数据缓存优化。