从零到一集成 RainbowKit:我在多链 DApp 连接上踩过的那些坑

7 阅读9分钟

背景

上个月,我接手了一个多链 NFT 交易平台的前端重构工作。这个平台需要同时支持以太坊主网、Polygon 和 Arbitrum 三条链上的 NFT 交易。原来的实现用了 ethers.js 直接连接 MetaMask,代码里到处都是 window.ethereum.request({ method: 'eth_requestAccounts' }) 这种硬编码,而且多链切换的逻辑写得特别乱——用户切链得手动去 MetaMask 里点,体验很差。

产品经理提了个需求:要像 Uniswap 那样,用户点一下就能切换网络,而且最好能支持更多钱包,比如 Coinbase Wallet、WalletConnect 等。我调研了一圈,发现 RainbowKit 配合 wagmi 是个不错的方案,它封装了钱包连接、网络切换、链状态管理这些繁琐的逻辑,还提供了漂亮的 UI 组件。于是,我决定用 RainbowKit 来重构钱包连接模块。

问题分析

一开始我以为集成 RainbowKit 就是装个包、写几行配置的事。按照官方文档,我快速搭了个 demo:

npm install @rainbow-me/rainbowkit wagmi viem

然后照搬了示例代码:

import { RainbowKitProvider, getDefaultWallets } from '@rainbow-me/rainbowkit';
import { configureChains, createConfig, WagmiConfig } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { publicProvider } from 'wagmi/providers/public';

const { chains, publicClient } = configureChains(
  [mainnet, polygon, arbitrum],
  [publicProvider()]
);

const { connectors } = getDefaultWallets({
  appName: 'My NFT Platform',
  projectId: 'YOUR_PROJECT_ID',
  chains
});

const wagmiConfig = createConfig({
  autoConnect: true,
  connectors,
  publicClient
});

function App() {
  return (
    <WagmiConfig config={wagmiConfig}>
      <RainbowKitProvider chains={chains}>
        {/* 你的应用 */}
      </RainbowKitProvider>
    </WagmiConfig>
  );
}

跑起来一看,连接按钮是出来了,也能弹出钱包选择框。但马上遇到了第一个问题:连接上之后,UI 显示的是“Ethereum”,而不是“Ethereum Mainnet”。产品说这不够专业,得显示完整的网络名称。而且,我们平台的主推链是 Polygon,希望默认选中 Polygon 而不是以太坊。

更麻烦的是,当用户钱包不在我们支持的链上时(比如他在 BSC 上),RainbowKit 默认只是显示一个“Wrong Network”的提示,用户得点好几下才能切换到我们支持的链。这体验肯定不行,我们需要一个“一键切换”的功能。

核心实现

1. 自定义配置与默认链

首先解决默认链和显示名称的问题。我翻看了 RainbowKit 和 wagmi 的文档,发现 configureChains 时可以对链进行自定义。

这里有个坑wagmi/chains 里导出的链对象,其 name 属性就是显示在 UI 上的文本。但直接修改这个对象可能影响其他地方的使用。更好的做法是创建链的副本。

import { Chain } from 'wagmi';

// 自定义链配置,主要是为了控制UI显示
const supportedChains: Chain[] = [
  {
    ...mainnet,
    name: 'Ethereum Mainnet' // 覆盖默认的'Ethereum'
  },
  {
    ...polygon,
    name: 'Polygon Mainnet'
  },
  {
    ...arbitrum,
    name: 'Arbitrum One'
  }
];

// 设置平台默认链(比如我们主推Polygon)
const defaultChain = polygon;

const { chains, publicClient } = configureChains(
  supportedChains,
  [publicProvider()]
);

// 在RainbowKitProvider中指定初始链
<RainbowKitProvider 
  chains={chains} 
  initialChain={defaultChain}
>

这样配置后,UI 显示就符合要求了,而且用户打开页面时,RainbowKit 会优先使用 Polygon 链。

2. 处理错误网络与一键切换

接下来要解决用户连接时钱包处于错误网络的问题。RainbowKit 提供了 Chain 组件来显示网络切换提示,但我想做得更友好一些:当检测到用户在不支持的链上时,直接弹出一个模态框,让他一键切换到我们指定的链(比如 Polygon)。

注意这个细节:直接调用 switchChain 可能会被钱包拒绝(比如 MetaMask 会弹出确认框),所以需要处理用户拒绝的情况。

我创建了一个自定义的 NetworkModal 组件:

import { useSwitchChain } from 'wagmi';
import { useEffect, useState } from 'react';

function NetworkModal() {
  const { chain, chains } = useAccount();
  const { switchChain } = useSwitchChain();
  const [showModal, setShowModal] = useState(false);
  
  // 检查当前链是否支持
  const isUnsupported = chain && !chains.some(c => c.id === chain.id);
  
  useEffect(() => {
    // 如果是不支持的链,显示模态框
    setShowModal(isUnsupported);
  }, [isUnsupported]);
  
  const handleSwitchToPolygon = async () => {
    try {
      await switchChain({ chainId: polygon.id });
      setShowModal(false);
    } catch (error) {
      console.error('用户拒绝切换网络:', error);
      // 可以在这里给用户一个提示
    }
  };
  
  if (!showModal) return null;
  
  return (
    <div className="network-modal-overlay">
      <div className="network-modal">
        <h3>不支持的网络</h3>
        <p>您的钱包当前连接的是 {chain?.name},请切换到以下支持的网络:</p>
        <button onClick={handleSwitchToPolygon}>
          切换到 Polygon Mainnet
        </button>
        <p className="hint">
          如果切换失败,请手动在钱包中切换网络
        </p>
      </div>
    </div>
  );
}

然后在应用里使用这个组件。这样当用户在不支持的链上时,会看到一个友好的提示,而不是 RainbowKit 默认的那个小标签。

3. 自定义连接按钮与主题

RainbowKit 默认的连接按钮样式和我们的设计系统不太搭。我需要自定义主题,并且把连接按钮集成到我们的导航栏里。

RainbowKit 提供了 ConnectButton 组件和 Theme 配置:

import { ConnectButton, Theme } from '@rainbow-me/rainbowkit';
import { darkTheme } from '@rainbow-me/rainbowkit';

// 自定义主题,匹配我们的设计系统
const customTheme: Theme = {
  ...darkTheme(), // 基于暗色主题扩展
  colors: {
    ...darkTheme().colors,
    accentColor: '#8B5CF6', // 主色调改为紫色
    accentColorForeground: '#FFFFFF',
    connectButtonBackground: '#1F2937',
    connectButtonBackgroundError: '#EF4444',
    connectButtonText: '#FFFFFF',
    connectButtonTextError: '#FFFFFF'
  },
  radii: {
    ...darkTheme().radii,
    connectButton: '12px' // 更大的圆角
  },
  fonts: {
    ...darkTheme().fonts,
    body: 'Inter, sans-serif' // 使用我们的字体
  }
};

// 在RainbowKitProvider中使用自定义主题
<RainbowKitProvider 
  chains={chains} 
  theme={customTheme}
  appInfo={{
    appName: 'NFT Trading Platform',
    learnMoreUrl: 'https://docs.ourplatform.com'
  }}
>
  {/* 导航栏中的连接按钮 */}
  <nav className="navbar">
    <div className="logo">NFT Platform</div>
    <div className="nav-links">
      {/* 其他导航链接 */}
    </div>
    <div className="wallet-connect">
      <ConnectButton 
        showBalance={false} // 我们不显示余额
        accountStatus="address" // 只显示地址不显示ENS名
        chainStatus="icon" // 只显示链图标不显示名称
        label="连接钱包"
      />
    </div>
  </nav>
</RainbowKitProvider>

这里有个坑ConnectButtonaccountStatuschainStatus 属性需要根据设计稿仔细调整。如果设计稿里空间有限,可能只能显示图标;如果有足够空间,可以显示完整的地址和链名。

4. 钱包连接状态与业务逻辑集成

钱包连接状态需要和我们的业务逻辑联动。比如,当用户断开钱包连接时,我们需要清空用户数据;当用户切换链时,我们需要更新合约实例。

我创建了一个自定义 hook 来管理这些副作用:

import { useAccount, useDisconnect } from 'wagmi';
import { useEffect } from 'react';
import { useUserStore } from '../stores/userStore';
import { useContractStore } from '../stores/contractStore';

export function useWalletIntegration() {
  const { address, chainId, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const { setUser, clearUser } = useUserStore();
  const { updateContract, clearContract } = useContractStore();
  
  // 监听连接状态变化
  useEffect(() => {
    if (isConnected && address && chainId) {
      // 用户连接成功,设置用户信息
      setUser({ address, chainId });
      
      // 根据链ID更新合约实例
      updateContract(chainId);
      
      // 可以在这里发送连接事件到分析平台
      console.log('用户连接:', { address, chainId });
    } else {
      // 用户断开连接,清理状态
      clearUser();
      clearContract();
      
      console.log('用户断开连接');
    }
  }, [isConnected, address, chainId]);
  
  // 提供一个手动断开连接的方法,可以添加确认对话框
  const handleDisconnect = () => {
    if (window.confirm('确定要断开钱包连接吗?')) {
      disconnect();
    }
  };
  
  return {
    isConnected,
    address,
    chainId,
    handleDisconnect
  };
}

然后在需要钱包状态的组件中使用这个 hook:

function UserProfile() {
  const { isConnected, address, handleDisconnect } = useWalletIntegration();
  
  if (!isConnected) {
    return <p>请先连接钱包</p>;
  }
  
  return (
    <div className="user-profile">
      <p>已连接: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <button onClick={handleDisconnect}>断开连接</button>
    </div>
  );
}

这样,钱包状态的变化就能自动同步到我们的业务状态了。

完整代码

下面是一个完整的、可运行的示例,展示了如何集成 RainbowKit 并实现上述功能:

// App.tsx
import React from 'react';
import { RainbowKitProvider, getDefaultWallets, ConnectButton, Theme } from '@rainbow-me/rainbowkit';
import { configureChains, createConfig, WagmiConfig, Chain } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { publicProvider } from 'wagmi/providers/public';
import { useWalletIntegration } from './hooks/useWalletIntegration';
import NetworkModal from './components/NetworkModal';
import '@rainbow-me/rainbowkit/styles.css';

// 自定义链配置
const supportedChains: Chain[] = [
  { ...mainnet, name: 'Ethereum Mainnet' },
  { ...polygon, name: 'Polygon Mainnet' },
  { ...arbitrum, name: 'Arbitrum One' }
];

const defaultChain = polygon;

// 配置链和提供者
const { chains, publicClient } = configureChains(
  supportedChains,
  [publicProvider()]
);

// 配置钱包连接器
const { connectors } = getDefaultWallets({
  appName: 'NFT Trading Platform',
  projectId: 'YOUR_PROJECT_ID', // 从 WalletConnect Cloud 获取
  chains
});

// 创建 wagmi 配置
const wagmiConfig = createConfig({
  autoConnect: true, // 自动重新连接
  connectors,
  publicClient
});

// 自定义主题
const customTheme: Theme = {
  colors: {
    accentColor: '#8B5CF6',
    accentColorForeground: '#FFFFFF',
    actionButtonSecondaryBackground: 'transparent',
    closeButton: '#6B7280',
    closeButtonBackground: 'transparent',
    connectButtonBackground: '#1F2937',
    connectButtonBackgroundError: '#EF4444',
    connectButtonText: '#FFFFFF',
    connectButtonTextError: '#FFFFFF',
    connectionIndicator: '#10B981',
    error: '#EF4444',
    generalBorder: '#374151',
    generalBorderDim: '#1F2937',
    menuItemBackground: '#111827',
    modalBackdrop: 'rgba(0, 0, 0, 0.5)',
    modalBackground: '#111827',
    modalBorder: '#374151',
    modalText: '#F9FAFB',
    modalTextDim: '#9CA3AF',
    modalTextSecondary: '#D1D5DB',
    profileAction: '#1F2937',
    profileActionHover: '#374151',
    profileForeground: '#111827',
    selectedOptionBorder: '#8B5CF6',
    standby: '#F59E0B'
  },
  radii: {
    connectButton: '12px',
    menuButton: '12px',
    modal: '16px',
    modalMobile: '16px'
  },
  fonts: {
    body: 'Inter, sans-serif'
  }
};

// 主应用组件
function AppContent() {
  useWalletIntegration();
  
  return (
    <div className="app">
      <header className="navbar">
        <div className="logo">NFT Trading Platform</div>
        <div className="wallet-section">
          <ConnectButton
            showBalance={false}
            accountStatus="address"
            chainStatus="icon"
            label="连接钱包"
          />
        </div>
      </header>
      
      <main className="main-content">
        <NetworkModal />
        {/* 你的应用内容 */}
      </main>
    </div>
  );
}

// 应用根组件
export default function App() {
  return (
    <WagmiConfig config={wagmiConfig}>
      <RainbowKitProvider
        chains={chains}
        initialChain={defaultChain}
        theme={customTheme}
        appInfo={{
          appName: 'NFT Trading Platform',
          learnMoreUrl: 'https://docs.ourplatform.com'
        }}
      >
        <AppContent />
      </RainbowKitProvider>
    </WagmiConfig>
  );
}
// hooks/useWalletIntegration.ts
import { useAccount, useDisconnect } from 'wagmi';
import { useEffect } from 'react';

export function useWalletIntegration() {
  const { address, chainId, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  
  useEffect(() => {
    if (isConnected && address && chainId) {
      console.log('钱包已连接:', { address, chainId });
      // 这里可以添加你的业务逻辑,比如获取用户数据
    } else {
      console.log('钱包已断开');
      // 清理用户数据
    }
  }, [isConnected, address, chainId]);
  
  const handleDisconnect = () => {
    if (window.confirm('确定要断开钱包连接吗?')) {
      disconnect();
    }
  };
  
  return {
    isConnected,
    address,
    chainId,
    handleDisconnect
  };
}
// components/NetworkModal.tsx
import React from 'react';
import { useAccount, useSwitchChain } from 'wagmi';
import { polygon } from 'wagmi/chains';

export default function NetworkModal() {
  const { chain, chains } = useAccount();
  const { switchChain } = useSwitchChain();
  const [showModal, setShowModal] = React.useState(false);
  
  const isUnsupported = chain && !chains.some(c => c.id === chain.id);
  
  React.useEffect(() => {
    setShowModal(!!isUnsupported);
  }, [isUnsupported]);
  
  const handleSwitchToPolygon = async () => {
    try {
      await switchChain({ chainId: polygon.id });
      setShowModal(false);
    } catch (error) {
      console.error('切换网络失败:', error);
      alert('请手动在钱包中切换网络到 Polygon');
    }
  };
  
  if (!showModal) return null;
  
  return (
    <div style={{
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      backgroundColor: 'rgba(0, 0, 0, 0.7)',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      zIndex: 1000
    }}>
      <div style={{
        backgroundColor: '#1F2937',
        padding: '24px',
        borderRadius: '16px',
        maxWidth: '400px',
        width: '90%'
      }}>
        <h3 style={{ marginTop: 0, color: '#F9FAFB' }}>
          不支持的网络
        </h3>
        <p style={{ color: '#D1D5DB' }}>
          您的钱包当前连接的是 <strong>{chain?.name}</strong>,
          请切换到支持的网络以继续使用。
        </p>
        <button
          onClick={handleSwitchToPolygon}
          style={{
            backgroundColor: '#8B5CF6',
            color: 'white',
            border: 'none',
            padding: '12px 24px',
            borderRadius: '8px',
            cursor: 'pointer',
            width: '100%',
            fontSize: '16px',
            fontWeight: 'bold'
          }}
        >
          切换到 Polygon Mainnet
        </button>
        <p style={{ 
          color: '#9CA3AF', 
          fontSize: '14px', 
          marginTop: '12px',
          textAlign: 'center'
        }}>
          如果切换失败,请手动在钱包中切换网络
        </p>
      </div>
    </div>
  );
}

踩坑记录

在实际集成过程中,我遇到了几个比较典型的坑:

  1. WalletConnect Project ID 问题

    • 现象:连接时提示 "Invalid projectId" 错误。
    • 原因:直接从官方示例复制了 projectId: 'YOUR_PROJECT_ID',没有去 WalletConnect Cloud 注册获取真实的 ID。
    • 解决:去 cloud.walletconnect.com 注册项目,获取真实的 Project ID。
  2. Next.js 中的 Hydration 错误

    • 现象:在 Next.js 项目中使用时,控制台报 Hydration 错误。
    • 原因:RainbowKit 的一些组件在服务端渲染时和客户端渲染时状态不一致。
    • 解决:用 dynamic 导入并设置 ssr: false
      import dynamic from 'next/dynamic';
      
      const RainbowKitProvider = dynamic(
        () => import('@rainbow-me/rainbowkit').then(mod => mod.RainbowKitProvider),
        { ssr: false }
      );
      
  3. 自定义链的 RPC 节点问题

    • 现象:添加自定义测试网时,交易一直失败。
    • 原因:使用的公共 RPC 节点有速率限制或已经失效。
    • 解决:申请 Infura 或 Alchemy 的免费 API 密钥,使用可靠的 RPC 节点:
      import { alchemyProvider } from 'wagmi/providers/alchemy';
      
      const { chains } = configureChains(
        [mainnet, polygon],
        [
          alchemyProvider({ apiKey: process.env.ALCHEMY_API_KEY! }),
          publicProvider() // 备用公共节点
        ]
      );
      
  4. 移动端样式问题

    • 现象:在手机上连接钱包时,弹窗太大,超出屏幕。
    • 原因:RainbowKit 的模态框默认宽度在移动端不够自适应。
    • 解决:通过自定义主题调整移动端样式:
      const customTheme = {
        // ... 其他配置
        radii: {
          modalMobile: '0px', // 移动端去掉圆角,充分利用屏幕
        }
      };
      

小结

通过这次 RainbowKit 集成,我最大的收获是:不要重复造轮子,但要知道轮子是怎么转的。RainbowKit 确实大大简化了多链钱包连接的开发,但真正用好在生产环境,还是需要理解其背后的 wagmi 和 viem 的工作原理,特别是链状态管理和错误处理。下一步我打算深入研究 RainbowKit 的插件系统,看看如何集成更多定制化的功能,比如交易确认弹窗的自定义、钱包连接后的自动操作等。