背景
上个月接了一个 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>
);
运行前记得:
- 安装依赖:
npm install @solana/web3.js@1 react react-dom typescript @types/react - 在浏览器安装 Phantom 钱包扩展
- 切换到 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 代币。