背景
上个月,我接手了一个新的 DeFi 项目前端开发。第一个核心功能就是用户钱包登录。团队技术栈是 React + TypeScript,并且决定使用 ethers.js 这个老牌库来处理区块链交互,而不是较新的 viem。理由是我们的合约交互模式相对复杂,团队对 ethers 的 API 更熟悉,而且项目需要尽快上线。
“连接 MetaMask 嘛,不就是 window.ethereum.request({ method: 'eth_requestAccounts' }) 一下?” 我一开始也是这么想的,觉得这应该是最快完成的任务之一。然而,当我真正开始动手,试图构建一个在生产环境下稳定、用户体验良好的登录流程时,才发现里面门道不少,坑是一个接一个。
问题分析
我最开始的思路非常简单粗暴:在用户点击“连接钱包”按钮时,直接尝试获取 window.ethereum 对象,然后调用 request 方法。代码大概长这样:
const connectWallet = async () => {
if (window.ethereum) {
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
setAccount(accounts[0]);
} else {
alert('请安装 MetaMask!');
}
};
很快,问题就来了:
- 类型错误:在 TypeScript 中,
window对象默认没有ethereum属性,直接使用会报错。 - Provider 注入时机:MetaMask 的 Provider 并不是页面一加载就立刻注入到
window.ethereum的。如果用户安装了 MetaMask 但页面加载太快,脚本可能检测不到,误判为用户未安装。 - 事件监听缺失:用户切换账户、切换网络时,前端页面没有任何反应,状态不同步。
- 连接状态持久化:页面刷新后,登录状态丢失,用户需要重新点击连接。
这显然离“可用”还差得远。我意识到,我需要一个更系统的方法,来处理 Provider 的检测、账户和网络的监听、以及状态的持久化。我的目标升级为:实现一个类似 useWeb3React 或 wagmi 提供的、封装良好的自定义 Hook。
核心实现
第一步:安全地获取 Provider 并检测钱包安装
首先,要解决 TypeScript 的类型问题和 Provider 的异步注入问题。我决定在 window 上扩展 ethereum 的类型定义。
这里有个坑:MetaMask 的 Provider 类型在不断演进。直接使用 any 类型会失去类型安全,最好从 @metamask/providers 或 ethers 库中引入正确的类型。
我选择在项目根目录创建一个 types/global.d.ts 文件进行类型声明:
// types/global.d.ts
import { MetaMaskInpageProvider } from '@metamask/providers';
declare global {
interface Window {
ethereum?: MetaMaskInpageProvider;
}
}
然后,我创建了一个自定义 Hook useEthereum 来安全地处理和访问 Provider。关键点在于,不能只在组件挂载时检查一次 window.ethereum,因为 MetaMask 可能稍后才注入。一个更健壮的做法是监听 ethereum#initialized 事件(尽管这个事件并非所有版本都稳定),或者设置一个短暂的延迟重试机制。但在实践中,我发现对于大多数情况,在 useEffect 中检查并结合一个“安装 MetaMask”的引导按钮就足够了。
第二步:连接钱包并获取账户信息
连接钱包的核心是 eth_requestAccounts 方法,它会触发 MetaMask 的授权弹窗。但仅仅获取账户地址还不够,我们通常还需要获取当前链的 ID(网络)。
我封装了一个 connect 函数:
import { BrowserProvider } from 'ethers';
const connect = async (): Promise<{ account: string; chainId: bigint }> => {
if (!window.ethereum) {
throw new Error('MetaMask 未安装');
}
// 1. 请求账户访问权限
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts',
});
if (!accounts || accounts.length === 0) {
throw new Error('用户拒绝了连接请求或未选择账户');
}
const account = accounts[0];
// 2. 获取当前网络链ID
const chainIdHex = await window.ethereum.request({
method: 'eth_chainId',
});
const chainId = BigInt(chainIdHex);
return { account, chainId };
};
注意这个细节:eth_chainId 返回的是十六进制字符串(如 “0x1”),而 ethers.js v6 在很多地方使用 bigint 类型来表示链 ID,所以这里进行了转换。
第三步:监听账户和网络变化
这是让应用状态与钱包状态保持同步的关键。MetaMask 的 Provider 提供了 accountsChanged 和 chainChanged 事件。
import { useEffect } from 'react';
const useWalletEvents = (provider: any, setAccount: (acc: string) => void, setChainId: (id: bigint) => void) => {
useEffect(() => {
if (!provider) return;
const handleAccountsChanged = (accounts: string[]) => {
console.log('账户变更:', accounts);
// 如果用户切换了账户,accounts[0] 是新账户
// 如果用户在 MetaMask 中锁定了钱包或断开连接,accounts 会是空数组
if (accounts.length === 0) {
// 处理用户断开连接的情况
setAccount('');
} else {
setAccount(accounts[0]);
}
};
const handleChainChanged = (chainIdHex: string) => {
console.log('网络变更:', chainIdHex);
// **重要!** 当网络变更时,MetaMask 建议页面重载
// 但为了更好体验,我们可以只更新状态,并提示用户或重置相关合约实例
// window.location.reload(); // 简单粗暴的方法
const newChainId = BigInt(chainIdHex);
setChainId(newChainId);
// 通常这里还需要根据新的 chainId 更新 RPC Provider 和合约实例
};
provider.on('accountsChanged', handleAccountsChanged);
provider.on('chainChanged', handleChainChanged);
// 组件卸载时清理监听器
return () => {
provider.removeListener('accountsChanged', handleAccountsChanged);
provider.removeListener('chainChanged', handleChainChanged);
};
}, [provider, setAccount, setChainId]);
};
这里有个大坑:chainChanged 事件发生时,MetaMask 的官方文档建议直接 window.location.reload()。这是因为早期很多 dApp 的状态(特别是合约实例)严重依赖当前网络,不重载容易出错。但在现代前端架构中,我们可以通过更新状态、重新初始化 Provider 和合约来避免整页刷新,提供更流畅的体验。不过,这要求你的状态管理足够健壮。
第四步:状态持久化与初始化检查
用户刷新页面后,我们如何知道他之前已经连接过钱包?MetaMask 不会自动重新弹出授权窗口,但我们可以尝试获取已连接的账户。
我们可以使用 eth_accounts 方法,它不会弹出授权框,只会返回当前已授权的账户列表(如果用户已连接)。这非常适合在应用初始化时静默恢复登录状态。
const trySilentConnect = async (): Promise<{ account: string; chainId: bigint } | null> => {
if (!window.ethereum) return null;
try {
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
if (accounts.length > 0) {
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
return {
account: accounts[0],
chainId: BigInt(chainIdHex),
};
}
} catch (err) {
console.error('静默连接失败:', err);
}
return null;
};
在应用加载时(例如在 App.tsx 的 useEffect 或自定义 Hook 的初始化中)调用这个函数,就能实现“刷新页面保持登录状态”。
完整代码
下面是一个整合了以上所有思路的、相对完整的自定义 React Hook useMetaMask 示例:
// hooks/useMetaMask.ts
import { useEffect, useState, useCallback } from 'react';
export const useMetaMask = () => {
const [account, setAccount] = useState<string>('');
const [chainId, setChainId] = useState<bigint>(0n);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string>('');
// 获取 Provider 的辅助函数
const getProvider = () => {
if (typeof window !== 'undefined' && window.ethereum) {
return window.ethereum;
}
return null;
};
// 静默连接(用于初始化)
const trySilentConnect = useCallback(async () => {
const provider = getProvider();
if (!provider) return;
try {
const accounts = await provider.request({ method: 'eth_accounts' });
if (accounts.length > 0) {
const chainIdHex = await provider.request({ method: 'eth_chainId' });
setAccount(accounts[0]);
setChainId(BigInt(chainIdHex));
console.log('静默连接成功:', accounts[0]);
}
} catch (err) {
console.error('静默连接失败:', err);
}
}, []);
// 主动连接(用户点击按钮)
const connect = useCallback(async () => {
setError('');
setIsConnecting(true);
const provider = getProvider();
if (!provider) {
setError('请安装 MetaMask 浏览器扩展。');
setIsConnecting(false);
return;
}
try {
// 请求账户
const accounts = await provider.request({
method: 'eth_requestAccounts',
});
if (!accounts || accounts.length === 0) {
throw new Error('用户拒绝了连接请求。');
}
const newAccount = accounts[0];
// 获取当前网络
const chainIdHex = await provider.request({ method: 'eth_chainId' });
const newChainId = BigInt(chainIdHex);
setAccount(newAccount);
setChainId(newChainId);
console.log('连接成功:', newAccount, '网络:', newChainId);
} catch (err: any) {
console.error('连接失败:', err);
setError(err.message || '连接钱包时发生未知错误。');
// 可选:重置状态
setAccount('');
setChainId(0n);
} finally {
setIsConnecting(false);
}
}, []);
// 断开连接(本质上是前端清除状态,因为 MetaMask 没有真正的“断开”RPC调用)
const disconnect = useCallback(() => {
setAccount('');
setChainId(0n);
setError('');
console.log('已断开钱包连接(前端状态)');
}, []);
// 监听账户和网络变化
useEffect(() => {
const provider = getProvider();
if (!provider) return;
const handleAccountsChanged = (accounts: string[]) => {
console.log('accountsChanged:', accounts);
if (accounts.length === 0) {
// 用户锁定了钱包或切走了所有账户
disconnect(); // 调用我们自己的断开函数
} else if (accounts[0] !== account) {
setAccount(accounts[0]);
}
};
const handleChainChanged = (chainIdHex: string) => {
console.log('chainChanged:', chainIdHex);
// 更新链 ID,并可以在这里触发网络变更的副作用(如更新合约实例)
setChainId(BigInt(chainIdHex));
// 可以添加一个 toast 提示:“网络已切换至 xxx”
};
provider.on('accountsChanged', handleAccountsChanged);
provider.on('chainChanged', handleChainChanged);
// 应用启动时尝试静默连接
trySilentConnect();
return () => {
provider.removeListener('accountsChanged', handleAccountsChanged);
provider.removeListener('chainChanged', handleChainChanged);
};
}, [account, disconnect, trySilentConnect]);
return {
account,
chainId,
isConnecting,
error,
connect,
disconnect,
isInstalled: !!getProvider(),
};
};
// components/WalletConnector.tsx
import React from 'react';
import { useMetaMask } from '../hooks/useMetaMask';
const WalletConnector: React.FC = () => {
const { account, chainId, isConnecting, error, connect, disconnect, isInstalled } = useMetaMask();
// 将 bigint 链 ID 转换为可读名称
const getNetworkName = (id: bigint) => {
const map: Record<string, string> = {
'0x1': '以太坊主网',
'0xaa36a7': 'Sepolia测试网',
'0x89': 'Polygon',
'0x13881': 'Mumbai测试网',
};
return map[id.toString(16)] || `未知网络 (${id.toString()})`;
};
if (!isInstalled) {
return (
<div>
<p>未检测到 MetaMask。请安装后刷新页面。</p>
<a href="https://metamask.io/download/" target="_blank" rel="noreferrer">
下载 MetaMask
</a>
</div>
);
}
return (
<div>
{error && <div style={{ color: 'red' }}>错误: {error}</div>}
{!account ? (
<button onClick={connect} disabled={isConnecting}>
{isConnecting ? '连接中...' : '连接 MetaMask'}
</button>
) : (
<div>
<p>
<strong>已连接账户:</strong> {`${account.slice(0, 6)}...${account.slice(-4)}`}
</p>
<p>
<strong>当前网络:</strong> {getNetworkName(chainId)}
</p>
<button onClick={disconnect}>断开连接</button>
</div>
)}
</div>
);
};
export default WalletConnector;
踩坑记录
window.ethereum类型错误:一开始在 TS 里直接写window.ethereum满屏红字。解决方案就是通过声明文件 (global.d.ts) 扩展Window接口。记得安装@metamask/providers包来获取准确类型。chainChanged事件导致无限循环:早期版本中,我在handleChainChanged里更新了chainId状态,而这个状态又被用在useEffect的依赖数组中,导致状态更新 -> 副作用重新执行 -> 重新绑定事件... 形成了一个循环。后来我将事件处理函数用useCallback包裹,并确保依赖项正确,才解决了这个问题。- 账户断开状态处理不当:当用户在 MetaMask 中点击“断开与此站点的连接”时,
accountsChanged事件会触发,并传入一个空数组[]。我最开始只是简单地setAccount(accounts[0] || ''),这会导致 UI 显示空地址但其他状态还保留着。正确的做法是像上面代码一样,触发一个完整的“断开连接”流程,清除所有相关状态。 - BigInt 序列化问题:在 React 状态中直接存储
bigint类型的chainId是没问题的,但如果你想把它存到localStorage或者通过 API 发送,就会遇到序列化错误(BigInt不能直接JSON.stringify)。我后来在需要持久化的地方,都将其转换为字符串chainId.toString()或十六进制'0x' + chainId.toString(16)。
小结
通过这一轮折腾,我深刻体会到,即使是一个看似简单的“连接钱包”功能,要做得健壮、用户体验好,也需要考虑 Provider 检测、异步连接、事件监听、状态持久化和错误处理等多个环节。封装成一个自定义 Hook 大大提升了代码的复用性和可维护性。下一步,我可以考虑在这个 Hook 基础上,集成 ethers.js 的 BrowserProvider 来直接提供签名和合约调用能力,或者加入对 WalletConnect 等其他连接方式的支持。