用 wagmi v2 + Next.js 14 踩坑实录:手写一个支持多链的 NFT 市场前端

21 阅读5分钟

用 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 做合约交互。但实际一跑就发现问题:

  1. 多链切换时,RainbowKit 的 chain 对象不兼容 wagmi v2:RainbowKit 默认用的是 wagmi v1 的 chain 配置,v2 改用 defineChain,直接复制官方示例会报 TypeError: chain is not a function
  2. NFT 的元数据(metadata)解析:很多 NFT 的 tokenURI 返回的是 IPFS 链接,需要解析成 HTTP 才能展示图片和属性。
  3. 合约调用报错:wagmi v2 的 useWriteContract 返回的 write 函数是异步的,直接调用会报 Cannot read properties of undefined
  4. Next.js 14 的客户端组件和服务端组件混用:wagmi 的 hooks 只能在客户端组件用,但 Next.js 14 默认是服务端组件,导致 useAccount 报错。

核心实现

1. 配置 wagmi v2 和 RainbowKit 的多链支持

这是最坑的一步。wagmi v2 的配置方式完全变了,必须用 createConfigdefineChain

// 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 返回的结果是数组,每个元素包含 resulterror 字段。如果合约调用失败,result 会是 undefined,直接使用会报错。

4. 实现购买和上架功能

购买和上架都需要用户签名,wagmi v2 的 useWriteContractuseSendTransaction 用法变了。

// 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>
  )
}

这里有个坑useWriteContractwriteContract 函数是异步的,但如果你直接 await writeContract(...),它返回的是交易哈希(如果成功),而不是等待交易确认。要等待确认,必须配合 useWaitForTransactionReceipt

完整代码

由于篇幅限制,完整代码仓库在 GitHub Gist 上。以下是核心文件结构:

src/
├── app/
│   ├── layout.tsx
│   ├── providers.tsx
│   ├── page.tsx
│   └── buy.tsx
├── lib/
│   └── wagmi.ts
└── components/
    └── NFTCard.tsx

踩坑记录

  1. TypeError: chain is not a function:坑了我 3 小时。原因是 RainbowKit 的 getDefaultConfig 在 wagmi v2 中已废弃,必须用 createConfig 手动配置。解决方法:完全放弃 getDefaultConfig,改用 createConfig + connectorsForWallets

  2. Cannot read properties of undefined (reading 'result')useReadContracts 的返回值结构变了。v1 是 [result1, result2],v2 是 [{result: ..., error: ...}, ...]。需要加判空:totalSupply?.[0]?.result

  3. IPFS 图片加载失败:有些 NFT 的 image 字段是 ipfs://xxx,直接放 <img> 标签里不显示。必须用 resolveIPFS 函数转成 HTTP 链接,而且最好用 ipfs.iogateway.pinata.cloud 等公共网关。

  4. useWriteContract 调用后交易不确认:我一开始以为 writeContract 返回的就是交易回执,后来发现它只返回哈希。必须用 useWaitForTransactionReceipt 监听确认状态。而且要注意:如果用户拒绝签名,writeContract 会 throw 一个错误,需要 try-catch 捕获。

小结

这次踩坑让我彻底搞懂了 wagmi v2 的配置方式和多链切换逻辑。核心收获是:wagmi v2 的配置必须用 createConfig + defineChain,合约调用必须区分 writeContractuseWaitForTransactionReceipt,NFT 元数据必须处理 IPFS 链接。想继续深挖的话,可以研究 wagmi v2 的 useSimulateContract 做交易模拟,或者用 @tanstack/react-query 做数据缓存优化。