打造类 RainbowKit 的 Solana 钱包连接套件

打造类 RainbowKit 的 Solana 钱包连接套件

核心理念

类似 RainbowKit 的钱包连接套件应该具有美观的 UI、流畅的用户体验和灵活的定制性。基于我们当前项目,可以封装一个更加完善的 Solana 钱包连接套件。

目录结构

bash 复制代码
src/
├── App.tsx                 # 应用入口
├── components/
│   ├── theme/              # 主题相关组件
│   │   ├── theme-provider-context.ts
│   │   ├── theme-provider.tsx
│   │   └── use-theme.ts
│   ├── ui/                 # 通用UI组件
│   │   ├── button.tsx
│   │   ├── dialog.tsx
│   │   ├── dropdown-menu.tsx
│   │   └── sonner.tsx
│   └── wallet/             # 钱包连接组件
│       ├── account-button.tsx  # 账户信息按钮
│       ├── connect-button.tsx  # 连接按钮
│       ├── wallet-connect.tsx  # 主连接组件
│       ├── wallet-dialog.tsx   # 钱包选择对话框
│       └── wallet-provider.tsx # 钱包提供器
├── hooks/
│   └── use-solana-wallet.ts    # Solana钱包自定义钩子
├── i18n/                   # 国际化配置
│   └── index.ts
├── lib/
│   └── utils.ts            # 工具函数
└── main.tsx                # 应用渲染入口

基础架构设计

1. 提供者组件设计(wallet-provider.tsx)

首先,我们需要一个提供者组件作为基础:

tsx 复制代码
// wallet-provider.tsx
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
import {
  ConnectionProvider,
  WalletProvider,
} from "@solana/wallet-adapter-react";
import { UnsafeBurnerWalletAdapter } from "@solana/wallet-adapter-wallets";
import { clusterApiUrl } from "@solana/web3.js";
import { useMemo } from "react";
import { toast } from "sonner";

interface SolanaWalletProviderProps {
  autoConnect?: boolean;
  children: React.ReactNode;
  network?: WalletAdapterNetwork;
}

export function SolanaWalletProvider({
  autoConnect = true,
  children,
  network = WalletAdapterNetwork.Devnet,
}: SolanaWalletProviderProps) {
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);
  const wallets = useMemo(() => [new UnsafeBurnerWalletAdapter()], []);

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider
        autoConnect={autoConnect}
        onError={(error) => {
          toast.error(error.message);
        }}
        wallets={wallets}
      >
        {children}
      </WalletProvider>
    </ConnectionProvider>
  );
}

2. 核心钩子设计(use-solana-wallet.ts)

创建一个自定义钩子,封装 useWallet 并添加额外功能:

ts 复制代码
// use-solana-wallet.ts
import { useWallet } from "@solana/wallet-adapter-react";
import { useCallback, useMemo, useState } from "react";

const COPY_TIMEOUT = 1000;
const ADDRESS_DISPLAY_CHARS = 4;

export function useSolanaWallet() {
  const wallet = useWallet();
  const [isCopied, setIsCopied] = useState(false);

  // 格式化地址显示
  const formattedAddress = useMemo(() => {
    if (!wallet.publicKey) return "";
    const address = wallet.publicKey.toBase58();
    return `${address.slice(0, ADDRESS_DISPLAY_CHARS)}...${address.slice(
      -ADDRESS_DISPLAY_CHARS
    )}`;
  }, [wallet.publicKey]);

  // 复制地址
  const copyAddress = useCallback(async () => {
    if (!wallet.publicKey) return;

    try {
      await navigator.clipboard.writeText(wallet.publicKey.toBase58());
      setIsCopied(true);
      setTimeout(() => setIsCopied(false), COPY_TIMEOUT);
      return true;
    } catch (error) {
      console.error(error);
      return false;
    }
  }, [wallet.publicKey]);

  return {
    ...wallet,
    copyAddress,
    formattedAddress,
    isCopied,
  };
}

3. UI 组件设计

连接按钮组件(connect-button.tsx)
tsx 复制代码
// connect-button.tsx
import { Button } from "@/components/ui/button";
import { useSolanaWallet } from "@/hooks/use-solana-wallet";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";

interface ConnectButtonProps {
  onClick?: () => void;
  size?: "default" | "lg" | "sm";
  variant?: "default" | "outline" | "secondary";
}

export function ConnectButton({
  onClick,
  size = "default",
  variant = "default",
}: ConnectButtonProps) {
  const { t } = useTranslation();
  const { connect, connected, connecting, wallet } = useSolanaWallet();

  const handleConnect = useCallback(() => {
    // 如果有外部传入的 onClick,优先使用
    if (onClick) {
      onClick();
      return;
    }

    // 否则使用默认连接行为
    if (!connected && !connecting && wallet) {
      connect().catch(console.error);
    }
  }, [connect, connected, connecting, wallet, onClick]);

  return (
    <Button
      disabled={connecting}
      onClick={handleConnect}
      size={size}
      variant={variant}
    >
      {connecting ? t("connecting") : t("connect")}
    </Button>
  );
}
钱包对话框组件(wallet-dialog.tsx)
tsx 复制代码
// wallet-dialog.tsx
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { useSolanaWallet } from "@/hooks/use-solana-wallet";
import { cn } from "@/lib/utils";
import { WalletReadyState } from "@solana/wallet-adapter-base";
import { Wallet } from "@solana/wallet-adapter-react";
import { useTranslation } from "react-i18next";

// 添加适当的类型定义
interface WalletButtonProps {
  compact?: boolean;
  onClick: () => void;
  wallet: Wallet;
}

export function WalletDialog({
  onOpenChange,
  open,
}: {
  onOpenChange: (open: boolean) => void;
  open: boolean;
}) {
  const { t } = useTranslation();
  const { select, wallets } = useSolanaWallet();

  // 将钱包分组
  const installedWallets = wallets.filter(
    (wallet) => wallet.readyState === WalletReadyState.Installed
  );

  const otherWallets = wallets.filter(
    (wallet) => wallet.readyState !== WalletReadyState.Installed
  );

  return (
    <Dialog onOpenChange={onOpenChange} open={open}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>{t("selectWallet")}</DialogTitle>
          <DialogDescription>
            {t("connectYourSolanaWalletToAccessFullFeatures")}
          </DialogDescription>
        </DialogHeader>

        {/* 已安装钱包列表 */}
        {installedWallets.length > 0 && (
          <div>
            <h3 className="text-sm text-muted-foreground mb-2">
              {t("detected")}
            </h3>
            <div className="space-y-1">
              {installedWallets.map((wallet) => (
                <WalletButton
                  key={wallet.adapter.name}
                  onClick={() => {
                    select(wallet.adapter.name);
                    onOpenChange(false);
                  }}
                  wallet={wallet}
                />
              ))}
            </div>
          </div>
        )}

        {/* 其他钱包列表 */}
        {otherWallets.length > 0 && (
          <div>
            <h3 className="text-sm text-muted-foreground mb-2">
              {t("otherWallets")}
            </h3>
            <div className="grid grid-cols-2 gap-2">
              {otherWallets.map((wallet) => (
                <WalletButton
                  compact
                  key={wallet.adapter.name}
                  onClick={() => {
                    select(wallet.adapter.name);
                    onOpenChange(false);
                  }}
                  wallet={wallet}
                />
              ))}
            </div>
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}

// 钱包按钮子组件
function WalletButton({ compact = false, onClick, wallet }: WalletButtonProps) {
  return (
    <button
      className={cn(
        "flex gap-2 items-center w-full p-3 rounded-lg hover:bg-accent transition-colors",
        compact ? "flex-col" : "flex-row"
      )}
      onClick={onClick}
    >
      <img
        alt={wallet.adapter.name}
        className={cn(compact ? "w-8 h-8" : "w-6 h-6")}
        src={wallet.adapter.icon}
      />
      <span className={cn(compact ? "text-sm" : "text-base")}>
        {wallet.adapter.name}
      </span>
    </button>
  );
}
用户信息组件(account-button.tsx)
tsx 复制代码
// account-button.tsx
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSolanaWallet } from "@/hooks/use-solana-wallet";
import { CheckIcon, CopyIcon, LogOutIcon, WalletIcon } from "lucide-react";
import { useTranslation } from "react-i18next";

export function AccountButton({
  onChangeWallet,
}: {
  onChangeWallet: () => void;
}) {
  const { t } = useTranslation();
  const { copyAddress, disconnect, formattedAddress, isCopied, wallet } =
    useSolanaWallet();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button className="gap-2" variant="outline">
          {wallet?.adapter.icon && (
            <img
              alt={wallet.adapter.name}
              className="w-4 h-4"
              src={wallet.adapter.icon}
            />
          )}
          {formattedAddress}
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem
          className="justify-center"
          onSelect={(event) => {
            event.preventDefault();
            copyAddress();
          }}
        >
          {isCopied ? (
            <>
              <CheckIcon className="h-4 w-4" />
              {t("copied")}
            </>
          ) : (
            <>
              <CopyIcon className="h-4 w-4" />
              {t("copyAddress")}
            </>
          )}
        </DropdownMenuItem>
        <DropdownMenuItem className="justify-center" onSelect={onChangeWallet}>
          <WalletIcon className="h-4 w-4" />
          {t("changeWallet")}
        </DropdownMenuItem>
        <DropdownMenuItem
          className="justify-center"
          onSelect={() => disconnect().catch(console.error)}
        >
          <LogOutIcon className="h-4 w-4" />
          {t("disconnect")}
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

4. 主连接组件(wallet-connect.tsx)

现在,让我们将所有组件组合成一个主要的连接组件:

tsx 复制代码
// wallet-connect.tsx
import { useSolanaWallet } from "@/hooks/use-solana-wallet";
import { useState } from "react";

import { AccountButton } from "./account-button";
import { ConnectButton } from "./connect-button";
import { WalletDialog } from "./wallet-dialog";

export function WalletConnect() {
  const [dialogOpen, setDialogOpen] = useState(false);
  const { connected, wallet } = useSolanaWallet();

  // 状态处理逻辑简化,使用提前返回模式
  if (!wallet || !connected) {
    return (
      <>
        <ConnectButton
          onClick={!wallet ? () => setDialogOpen(true) : undefined}
        />
        {!wallet && (
          <WalletDialog onOpenChange={setDialogOpen} open={dialogOpen} />
        )}
      </>
    );
  }

  // 已连接状态
  return (
    <>
      <AccountButton onChangeWallet={() => setDialogOpen(true)} />
      <WalletDialog onOpenChange={setDialogOpen} open={dialogOpen} />
    </>
  );
}

使用方式

最后,在应用中使用这个套件:

tsx 复制代码
// App.tsx
import { ThemeProvider } from "./components/theme/theme-provider";
import { Toaster } from "./components/ui/sonner";
import { SolanaWalletProvider } from "./components/wallet/wallet-provider";
import { WalletConnect } from "./components/wallet/wallet-connect";

function App() {
  return (
    <ThemeProvider>
      <SolanaWalletProvider>
        <div className="p-4">
          <header className="flex justify-end">
            <WalletConnect />
          </header>
          {/* 应用其他内容 */}
        </div>
        <Toaster position="top-center" />
      </SolanaWalletProvider>
    </ThemeProvider>
  );
}

结论

通过上述设计,我们可以构建一个类似 RainbowKit 的 Solana 钱包连接套件,具有以下优势:

  1. 组件化设计:每个功能都被拆分为独立组件,便于维护和扩展
  2. 自定义钩子useSolanaWallet 封装常用功能,提供统一接口
  3. 美观的 UI:利用 TailwindCSS 和 Radix UI 组件构建现代界面
  4. 国际化支持:集成 i18next 实现多语言支持
  5. 用户体验优化:区分已安装和未安装钱包,添加复制地址等功能

这种设计不仅满足基本的钱包连接需求,还提供了良好的用户体验和开发者体验,使其成为一个专业的 Solana 钱包连接解决方案。

关于作者

Hi,我是 hyy,一位热爱技术的全栈开发者:

  • 🚀 专注 TypeScript 全栈开发,偏前端技术栈
  • 💼 多元工作背景(跨国企业、技术外包、创业公司)
  • 📝 掘金活跃技术作者
  • 🎵 电子音乐爱好者
  • 🎮 游戏玩家
  • 💻 技术分享达人

加入我们

欢迎加入前端技术交流圈,与 10000+开发者一起:

  • 探讨前端最新技术趋势
  • 解决开发难题
  • 分享职场经验
  • 获取优质学习资源

添加方式:掘金摸鱼沸点 👈 扫码进群

相关推荐
吃没吃2 分钟前
vue2.6-源码学习-Vue 核心入口文件分析
前端
Carlos_sam2 分钟前
Openlayers:海量图形渲染之图片渲染
前端·javascript
XH2764 分钟前
Android Retrofit用法详解
前端
鸭梨大大大5 分钟前
Spring Web MVC入门
前端·spring·mvc
吃没吃8 分钟前
vue2.6-源码学习-Vue 初始化流程分析 (src/core/instance/init.js)
前端
XH27610 分钟前
Android Room用法详解
前端
木木黄木木1 小时前
css炫酷的3D水波纹文字效果实现详解
前端·css·3d
郁大锤1 小时前
Flask与 FastAPI 对比:哪个更适合你的 Web 开发?
前端·flask·fastapi
HelloRevit2 小时前
React DndKit 实现类似slack 类别、频道拖动调整位置功能
前端·javascript·react.js
ohMyGod_1233 小时前
用React实现一个秒杀倒计时组件
前端·javascript·react.js