打造类 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+开发者一起:

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

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

相关推荐
子春一25 分钟前
Flutter 2025 可访问性(Accessibility)工程体系:从合规达标到包容设计,打造人人可用的数字产品
前端·javascript·flutter
白兰地空瓶11 分钟前
别再只会调 API 了!LangChain.js 才是前端 AI 工程化的真正起点
前端·langchain
jlspcsdn1 小时前
20251222项目练习
前端·javascript·html
YangYang9YangYan1 小时前
2026高职会计电算化专业高价值技能证书
大数据·学习·区块链
行走的陀螺仪1 小时前
Sass 详细指南
前端·css·rust·sass
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ2 小时前
React 怎么区分导入的是组件还是函数,或者是对象
前端·react.js·前端框架
LYFlied2 小时前
【每日算法】LeetCode 136. 只出现一次的数字
前端·算法·leetcode·面试·职场和发展
子春一22 小时前
Flutter 2025 国际化与本地化工程体系:从多语言支持到文化适配,打造真正全球化的应用
前端·flutter
QT 小鲜肉2 小时前
【Linux命令大全】001.文件管理之file命令(实操篇)
linux·运维·前端·网络·chrome·笔记
Moonbeam Community2 小时前
Polkadot 2025:从协议工程到可用的去中心化云平台
大数据·web3·去中心化·区块链·polkadot