从报错到跑通:我用 @solana/web3.js 开发 Solana 钱包连接踩过的三个坑

背景

上个月接了一个 Solana 链上的 NFT 抽奖项目,前端需要让用户连接 Phantom 钱包,然后读取钱包地址,再调用合约进行抽奖。我之前一直在做 EVM 系(以太坊、Polygon)的前端开发,对 Solana 基本是零基础。

当时第一反应是:Solana 应该和 ethers.js 差不多吧,装个 @solana/web3.js 就能搞定。结果装完包,按文档写了几行代码,浏览器控制台直接给我报了一堆红。更离谱的是,同样的代码在 Phantom 官方示例里能跑,在我项目里就报错。当时我就知道,这坑我踩定了。

这篇文章就是用第一人称记录我解决这个问题的完整过程,核心场景是:在 React 项目里用 @solana/web3.js 连接 Phantom 钱包,获取用户地址,并在 devnet 上查询余额

问题分析

最初的思路:直接调 window.solana

我一开始的想法很简单:Phantom 钱包会注入一个 window.solana 对象,我直接用它连接不就行了?于是写了一段这样的代码:

typescript 复制代码
const provider = (window as any).solana;
if (provider?.isPhantom) {
  const resp = await provider.connect();
  console.log(resp.publicKey.toString());
}

这段代码在浏览器控制台里跑确实能弹出钱包连接窗口,也能拿到地址。但是,一旦放到 React 的 useEffect 或者 onClick 事件里,就经常出现 provider.connect is not a function 或者 Cannot read properties of undefined (reading 'connect')

问题出在哪?

排查之后发现,Phantom 注入 window.solana 的时机并不稳定。如果页面加载时 Phantom 扩展还没初始化好,window.solana 就是 undefined。而且直接用 window.solana 的方式,在 React 的严格模式(StrictMode)下会触发多次连接,导致钱包弹窗闪一下就消失。

更关键的是,这种方式没有处理钱包断开连接、账户切换等事件,对于正式项目来说太简陋了。我需要一个更稳定的方案。

为什么不用官方推荐的 @solana/wallet-adapter-react?

其实 Phantom 官方文档推荐的是 @solana/wallet-adapter-react + @solana/wallet-adapter-wallets 这套方案。我一开始也试了,但装完包之后,项目直接报错:

sql 复制代码
Module not found: Can't resolve '@solana/wallet-adapter-base'

或者版本冲突,因为 @solana/web3.js 有 v1 和 v2 两个大版本,而 wallet-adapter 的各个包版本之间依赖关系非常复杂。我当时 npm install 完,package-lock.json 里出现了好几个不同版本的 @solana/web3.js,直接导致运行时崩溃。

所以我的最终方案是:只用 @solana/web3.js 这一个核心依赖,自己封装一个 React Hook 来管理钱包连接。这样版本依赖最简单,出了问题也好排查。

核心实现:搭建基础连接

1. 安装依赖并初始化 Connection

第一步,安装 @solana/web3.js。我这里用的是 v1 的最新版(v1.98.0),因为 v2 刚出不久,很多文档和示例还没更新,我不想冒险。

bash 复制代码
npm install @solana/web3.js@1

然后创建一个 solana.ts 工具文件,初始化一个连接到 devnet 的 Connection 对象。

typescript 复制代码
// src/utils/solana.ts
import { Connection, clusterApiUrl } from '@solana/web3.js';

// 这里有个坑:clusterApiUrl('devnet') 返回的 RPC 地址经常限流
// 如果遇到请求超时,可以换成公共 RPC 或者自己搭的节点
const RPC_URL = clusterApiUrl('devnet');
// 备用 RPC: 'https://api.devnet.solana.com'

export const connection = new Connection(RPC_URL, {
  commitment: 'confirmed', // 默认是 'finalized',但 'confirmed' 更快
});

注意这个细节commitment 参数决定了你查询的数据的确认级别。confirmed 表示区块已经被大多数节点确认,但还没最终确定;finalized 表示区块已经被最终确定。对于前端展示余额或交易状态,confirmed 就足够了,而且响应更快。

2. 封装 useSolana Hook

接下来,我写了一个 React Hook,专门处理 Phantom 钱包的连接、断开和事件监听。

typescript 复制代码
// src/hooks/useSolana.ts
import { useState, useEffect, useCallback } from 'react';
import { PublicKey } from '@solana/web3.js';

interface SolanaWindow {
  solana?: {
    isPhantom?: boolean;
    connect: (opts?: { onlyIfTrusted?: boolean }) => Promise<{ publicKey: PublicKey }>;
    disconnect: () => Promise<void>;
    on: (event: string, handler: (...args: any[]) => void) => void;
    removeListener: (event: string, handler: (...args: any[]) => void) => void;
    publicKey: PublicKey | null;
  };
}

export function useSolana() {
  const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
  const [connecting, setConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // 获取 provider
  const getProvider = useCallback((): SolanaWindow['solana'] | null => {
    if ('solana' in window) {
      const provider = (window as SolanaWindow).solana;
      if (provider?.isPhantom) {
        return provider;
      }
    }
    return null;
  }, []);

  // 连接钱包
  const connect = useCallback(async () => {
    const provider = getProvider();
    if (!provider) {
      setError('请安装 Phantom 钱包');
      return;
    }

    setConnecting(true);
    setError(null);

    try {
      // 这里有个坑:如果用户已经授权过,可以传 { onlyIfTrusted: true } 静默连接
      // 但第一次连接必须传空对象,否则会报错
      const resp = await provider.connect();
      setPublicKey(resp.publicKey);
    } catch (err: any) {
      // 用户拒绝连接时,err.code 是 4001
      if (err.code === 4001) {
        setError('用户取消了连接');
      } else {
        setError(`连接失败: ${err.message}`);
      }
    } finally {
      setConnecting(false);
    }
  }, [getProvider]);

  // 断开钱包
  const disconnect = useCallback(async () => {
    const provider = getProvider();
    if (provider) {
      await provider.disconnect();
      setPublicKey(null);
    }
  }, [getProvider]);

  // 监听账户切换和断开事件
  useEffect(() => {
    const provider = getProvider();
    if (!provider) return;

    const handleAccountChange = (publicKey: PublicKey | null) => {
      setPublicKey(publicKey);
    };

    const handleDisconnect = () => {
      setPublicKey(null);
    };

    provider.on('accountChanged', handleAccountChange);
    provider.on('disconnect', handleDisconnect);

    // 如果已经连接过,恢复状态
    if (provider.publicKey) {
      setPublicKey(provider.publicKey);
    }

    return () => {
      provider.removeListener('accountChanged', handleAccountChange);
      provider.removeListener('disconnect', handleDisconnect);
    };
  }, [getProvider]);

  return {
    publicKey,
    connecting,
    error,
    connect,
    disconnect,
  };
}

这里有个坑provider.on('accountChanged') 事件在 Phantom 钱包里,当用户切换账户时,会触发两次:第一次返回新的 publicKey,第二次返回 null。所以我在 handleAccountChange 里直接更新 publicKey,如果返回 null 就表示用户断开了连接。这个问题我查了好久才在 Phantom 的 GitHub issue 里找到答案。

3. 在 React 组件中使用

有了 Hook,组件里调用就简单了。

tsx 复制代码
// src/components/WalletButton.tsx
import React from 'react';
import { useSolana } from '../hooks/useSolana';

export function WalletButton() {
  const { publicKey, connecting, error, connect, disconnect } = useSolana();

  const handleClick = () => {
    if (publicKey) {
      disconnect();
    } else {
      connect();
    }
  };

  return (
    <div>
      <button onClick={handleClick} disabled={connecting}>
        {connecting ? '连接中...' : publicKey ? '断开钱包' : '连接 Phantom 钱包'}
      </button>
      {publicKey && (
        <p>
          已连接地址:{publicKey.toBase58().slice(0, 4)}...{publicKey.toBase58().slice(-4)}
        </p>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

核心实现:查询余额

拿到地址之后,下一个需求是查询这个地址的 SOL 余额。

4. 查询余额的完整实现

typescript 复制代码
// src/hooks/useSolana.ts 增加一个函数
import { LAMPORTS_PER_SOL } from '@solana/web3.js';

// 在 useSolana Hook 内部增加
const getBalance = useCallback(async () => {
  if (!publicKey) return 0;

  try {
    const balanceLamports = await connection.getBalance(publicKey);
    // LAMPORTS_PER_SOL = 10^9,1 SOL = 10^9 Lamports
    return balanceLamports / LAMPORTS_PER_SOL;
  } catch (err) {
    console.error('查询余额失败:', err);
    return 0;
  }
}, [publicKey]);

注意这个细节 :Solana 链上最小的单位是 Lamport,1 SOL = 1,000,000,000 Lamports。getBalance 返回的是 Lamports 数量,需要除以 LAMPORTS_PER_SOL 才能得到 SOL 数量。这个和以太坊的 wei 是一个道理。

5. 在组件中显示余额

tsx 复制代码
// src/components/WalletInfo.tsx
import React, { useEffect, useState } from 'react';
import { useSolana } from '../hooks/useSolana';

export function WalletInfo() {
  const { publicKey, getBalance } = useSolana();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!publicKey) {
      setBalance(null);
      return;
    }

    const fetchBalance = async () => {
      setLoading(true);
      const bal = await getBalance();
      setBalance(bal);
      setLoading(false);
    };

    fetchBalance();

    // 这里有个坑:如果想实时刷新余额,可以轮询
    // 但不要频繁请求,否则会被 RPC 限流
    const interval = setInterval(fetchBalance, 30000); // 每30秒刷新一次
    return () => clearInterval(interval);
  }, [publicKey, getBalance]);

  if (!publicKey) return null;

  return (
    <div>
      <p>地址:{publicKey.toBase58()}</p>
      <p>余额:{loading ? '加载中...' : `${balance?.toFixed(4) ?? '0'} SOL`}</p>
    </div>
  );
}

核心实现:发送交易

最后,我还需要实现一个简单的转账功能,用来测试 devnet 上的交互。

6. 发送 SOL 转账交易

typescript 复制代码
// src/utils/sendTransaction.ts
import { Transaction, SystemProgram, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { connection } from './solana';

export async function sendSol(
  fromPublicKey: PublicKey,
  toAddress: string,
  amountSol: number
) {
  const toPublicKey = new PublicKey(toAddress);
  const lamports = amountSol * LAMPORTS_PER_SOL;

  // 创建转账指令
  const instruction = SystemProgram.transfer({
    fromPubkey: fromPublicKey,
    toPubkey: toPublicKey,
    lamports,
  });

  // 创建交易
  const transaction = new Transaction().add(instruction);

  // 这里有个坑:必须设置 recentBlockhash 和 feePayer
  // 否则交易会失败
  const { blockhash } = await connection.getLatestBlockhash();
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = fromPublicKey;

  return transaction;
}
tsx 复制代码
// src/components/SendSolForm.tsx
import React, { useState } from 'react';
import { useSolana } from '../hooks/useSolana';
import { sendSol } from '../utils/sendTransaction';

export function SendSolForm() {
  const { publicKey } = useSolana();
  const [toAddress, setToAddress] = useState('');
  const [amount, setAmount] = useState('');
  const [status, setStatus] = useState('');

  const handleSend = async () => {
    if (!publicKey) {
      setStatus('请先连接钱包');
      return;
    }

    try {
      const transaction = await sendSol(publicKey, toAddress, parseFloat(amount));
      
      // 用 Phantom 钱包签名并发送
      const provider = (window as any).solana;
      const signedTx = await provider.signTransaction(transaction);
      const signature = await connection.sendRawTransaction(signedTx.serialize());
      
      // 等待确认
      await connection.confirmTransaction(signature, 'confirmed');
      
      setStatus(`转账成功!交易签名:${signature.slice(0, 8)}...`);
    } catch (err: any) {
      setStatus(`转账失败:${err.message}`);
    }
  };

  return (
    <div>
      <input
        type="text"
        placeholder="接收地址"
        value={toAddress}
        onChange={(e) => setToAddress(e.target.value)}
      />
      <input
        type="number"
        placeholder="SOL 数量"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
      />
      <button onClick={handleSend}>发送 SOL</button>
      {status && <p>{status}</p>}
    </div>
  );
}

完整代码

我把上面所有代码整合到一个 React 组件里,可以直接复制运行。

tsx 复制代码
// App.tsx
import React from 'react';
import { WalletButton } from './components/WalletButton';
import { WalletInfo } from './components/WalletInfo';
import { SendSolForm } from './components/SendSolForm';

export default function App() {
  return (
    <div style={{ padding: '20px' }}>
      <h1>Solana 钱包连接示例</h1>
      <WalletButton />
      <WalletInfo />
      <SendSolForm />
    </div>
  );
}
tsx 复制代码
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

运行前记得:

  1. 安装依赖:npm install @solana/web3.js@1 react react-dom typescript @types/react
  2. 在浏览器安装 Phantom 钱包扩展
  3. 切换到 devnet 网络(Phantom 设置里可以选)

踩坑记录

坑 1:Cannot read properties of undefined (reading 'connect')

原因 :组件渲染时 Phantom 还没注入 window.solana

解决 :在 connect 函数里先调用 getProvider 检查,如果为空就提示用户安装。另外,不要在组件初始化时自动连接,等用户点击按钮再连。

坑 2:Transaction signature verification failure

原因 :发送交易时没有设置 recentBlockhash。Solana 的交易必须包含一个最近区块的哈希,否则网络会拒绝。

解决 :在创建交易后,调用 connection.getLatestBlockhash() 获取并设置。

坑 3:devnet 空投领不到 SOL

原因connection.requestAirdrop 返回的签名可能被限流,或者需要等待确认。

解决 :调用 requestAirdrop 后,必须用 connection.confirmTransaction 等待确认。如果失败,可以换用公共水龙头网站(如 https://solfaucet.com)。

坑 4:accountChanged 事件触发两次

原因 :Phantom 钱包的 bug,切换账户时会先触发一次新地址事件,再触发一次 null 事件。

解决:在事件处理函数里直接更新状态,不要做额外的判断,让 React 自己处理重复渲染。

小结

@solana/web3.js 开发前端,核心就是理解 Connection、Transaction、PublicKey 这几个概念。和以太坊最大的区别是:Solana 的交易需要自己设置 blockhash,而且钱包连接的方式更依赖浏览器扩展注入。如果你之前做 EVM 开发,转到 Solana 时最容易踩的坑就是 Transaction 构造和事件监听。

如果你想继续深挖,可以研究一下 @solana/web3.js v2 的新特性,或者看看怎么用 @solana/spl-token 操作 SPL 代币。

相关推荐
MariaH3 小时前
Node中操作MySQL
前端
还有多久拿退休金3 小时前
一个 var 让整个团队加班到凌晨——JS 闭包的那些暗坑
前端·javascript
weedsfly3 小时前
用了 React/Vue 之后,这些 DOM 操作的坑你踩过几个?
前端·javascript
Asize3 小时前
Ajax 入门:从 JSON 序列化到 XMLHttpRequest
前端·javascript·前端框架
林希_Rachel_傻希希3 小时前
react hooks速通笔记
前端
Csvn3 小时前
🚨 组件卸载后还在 setState?一个被你忽视的内存泄漏和报错根源
前端
乘风gg3 小时前
AI GenUI 真正落地时,前端到底要做什么?
前端·ai编程·cursor
恋猫de小郭4 小时前
苹果 AirPods 协议,Android 也可以使用完整版 AirPods 能力
android·前端·flutter
IT_陈寒4 小时前
JavaScript的默认参数挖坑实录,我掉进去了
前端·人工智能·后端