背景
上个月,我接手了一个 NFT 艺术平台的 MVP 开发。核心功能很简单:用户连接钱包,查看自己的 NFT,并进行铸造。产品经理说:"登录就用最经典的 MetaMask 连接,简单点。" 我想,这还不简单?用 ethers.js 几行代码的事。结果,从"简单连接"到"稳定可用的登录流程",我花了整整一天半的时间,踩了好几个意想不到的坑。这篇文章,就是把我解决问题的过程原原本本地记录下来。
问题分析
我的第一版代码非常"教科书":
javascript
import { ethers } from 'ethers';
const connectWallet = async () => {
if (window.ethereum) {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();
console.log('Connected:', address);
return address;
} else {
alert('请安装 MetaMask!');
}
};
看起来没问题,对吧?但在实际测试中,问题接踵而至:
- 第一次点击连接,弹窗一闪而过,但状态没更新。 第二次点击才能成功。
- 用户如果拒绝了连接请求,页面没有任何反馈,就像什么都没发生。
- 用户在 MetaMask 里切换了账户或网络,我的前端页面完全感知不到,显示的还是旧信息。
- 代码里到处是
window.ethereum的类型断言as any,TypeScript 疯狂报红。
我意识到,我实现的只是一个"一次性连接动作",而不是一个"可持续管理的钱包连接状态"。真正的生产环境需要的是一个健壮的、能应对各种用户操作和钱包状态变化的登录系统。
核心实现
第一步:安全地获取 Provider 和 处理类型
首先,要解决 window.ethereum 的类型问题。直接使用 any 会丢失类型安全和 IDE 提示。ethers.js v6 推荐从 window.ethereum 创建 BrowserProvider。
这里有个坑: window.ethereum 可能不存在(用户没装钱包),也可能是数组(多个钱包注入)。我们需要安全地处理。
typescript
// utils/ethers.ts
import { BrowserProvider, Eip1193Provider } from 'ethers';
// 声明全局的 ethereum 类型
declare global {
interface Window {
ethereum?: Eip1193Provider;
}
}
/**
* 获取安全的 Ethers BrowserProvider
* @returns {BrowserProvider | null} 返回 Provider 或 null
*/
export const getEthersProvider = (): BrowserProvider | null => {
// 检查 window.ethereum 是否存在
if (typeof window !== 'undefined' && window.ethereum) {
try {
// ethers v6 使用 BrowserProvider 包装 window.ethereum
return new BrowserProvider(window.ethereum);
} catch (error) {
console.error('创建 Provider 失败:', error);
return null;
}
}
console.warn('未检测到钱包扩展(如 MetaMask)。');
return null;
};
第二步:实现核心连接函数,处理用户拒绝
连接钱包的核心是请求账户访问权限。provider.send('eth_requestAccounts', []) 这个方法会触发 MetaMask 弹窗。这里有个关键细节: 必须妥善处理用户点击"拒绝"的情况。
typescript
// hooks/useWallet.ts
import { useState, useCallback } from 'react';
import { getEthersProvider } from '../utils/ethers';
export const useWallet = () => {
const [account, setAccount] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const connectWallet = useCallback(async () => {
setIsConnecting(true);
setError(null); // 清除旧错误
const provider = getEthersProvider();
if (!provider) {
setError('请安装 MetaMask 钱包扩展。');
setIsConnecting(false);
return;
}
try {
// 关键步骤:请求账户访问,这会弹出 MetaMask 授权窗口
const accounts = await provider.send('eth_requestAccounts', []);
const currentAccount = accounts[0];
if (currentAccount) {
setAccount(currentAccount);
console.log('钱包连接成功:', currentAccount);
} else {
setError('未获取到有效账户。');
}
} catch (err: any) {
// **重点:处理用户拒绝等错误**
console.error('连接钱包失败:', err);
if (err.code === 4001) {
// 4001 是用户拒绝连接的错误码
setError('您拒绝了钱包连接请求。');
} else {
setError(`连接失败: ${err.message || '未知错误'}`);
}
} finally {
setIsConnecting(false);
}
}, []);
return { account, isConnecting, error, connectWallet };
};
第三步:监听账户和网络变化
用户不会一直待在同一个账户或网络上。他们可能在 MetaMask 里切换账户,或者从以太坊主网切换到 Polygon。我们的前端必须能实时响应这些变化。
注意这个细节: 监听事件要在连接成功后设置,并且在组件卸载时清理,防止内存泄漏。
typescript
// 在 useWallet 的 connectWallet 函数成功连接后,添加监听逻辑
const setupEventListeners = useCallback((provider: BrowserProvider) => {
// 注意:ethers v6 的 provider 底层是 EIP-1193 的 provider
const ethereum = window.ethereum;
if (!ethereum) return;
// 监听账户变化
const handleAccountsChanged = (accounts: string[]) => {
console.log('账户变化:', accounts);
if (accounts.length === 0) {
// 用户锁定了钱包或切换了所有账户
setAccount(null);
setError('钱包已断开连接。');
} else if (accounts[0] !== account) {
// 切换到新账户
setAccount(accounts[0]);
}
};
// 监听链ID变化(网络切换)
const handleChainChanged = (_chainId: string) => {
// 根据规范,当链发生变化时,应重置页面状态或重新加载
// 一个常见的做法是提示用户或自动刷新
console.log('网络已切换,链ID:', _chainId);
// 简单处理:直接重置账户,需要用户重新连接(或设计更优雅的流程)
setAccount(null);
window.location.reload(); // 许多 DApp 选择刷新页面
};
// 添加监听
ethereum.on('accountsChanged', handleAccountsChanged);
ethereum.on('chainChanged', handleChainChanged);
// 返回清理函数
return () => {
ethereum.removeListener('accountsChanged', handleAccountsChanged);
ethereum.removeListener('chainChanged', handleChainChanged);
};
}, [account]);
// 然后在 connectWallet 成功连接后调用
// const cleanup = setupEventListeners(provider);
// 注意:需要在 React useEffect 或组件卸载逻辑中执行 cleanup()
在实际的 React Hook 实现中,我们需要使用 useEffect 来管理这些副作用的生命周期。
第四步:整合成可复用的 React Hook
将以上所有逻辑整合到一个完整的、易于使用的自定义 Hook 中。
typescript
// hooks/useWallet.ts (完整版)
import { useState, useCallback, useEffect, useRef } from 'react';
import { BrowserProvider } from 'ethers';
import { getEthersProvider } from '../utils/ethers';
export const useWallet = () => {
const [account, setAccount] = useState<string | null>(null);
const [chainId, setChainId] = useState<bigint | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 使用 ref 存储清理函数,避免重复绑定事件
const cleanupRef = useRef<(() => void) | null>(null);
// 1. 初始化:检查是否已授权连接
useEffect(() => {
const checkIfWalletIsConnected = async () => {
const provider = getEthersProvider();
if (!provider) return;
try {
const accounts = await provider.send('eth_accounts', []); // 静默获取,不弹窗
if (accounts.length > 0) {
setAccount(accounts[0]);
const network = await provider.getNetwork();
setChainId(network.chainId);
// 为已连接的账户设置监听
setupEventListeners(provider);
}
} catch (err) {
console.warn('检查已连接账户时出错:', err);
}
};
checkIfWalletIsConnected();
}, []);
// 2. 设置事件监听器的函数
const setupEventListeners = useCallback((provider: BrowserProvider) => {
const ethereum = window.ethereum;
if (!ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
console.log('accountsChanged:', accounts);
if (accounts.length === 0) {
setAccount(null);
setError('钱包已断开。');
} else {
setAccount(accounts[0]);
setError(null);
}
};
const handleChainChanged = (_chainId: string) => {
console.log('chainChanged:', _chainId);
// 网络切换后,建议刷新页面或重新获取所有数据
window.location.reload();
};
ethereum.on('accountsChanged', handleAccountsChanged);
ethereum.on('chainChanged', handleChainChanged);
// 存储清理函数
cleanupRef.current = () => {
ethereum.removeListener('accountsChanged', handleAccountsChanged);
ethereum.removeListener('chainChanged', handleChainChanged);
};
}, []);
// 3. 核心连接函数
const connectWallet = useCallback(async () => {
setIsConnecting(true);
setError(null);
const provider = getEthersProvider();
if (!provider) {
setError('请安装 MetaMask。');
setIsConnecting(false);
return;
}
// 先清理旧监听(如果存在)
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
try {
const accounts = await provider.send('eth_requestAccounts', []);
const currentAccount = accounts[0];
if (currentAccount) {
setAccount(currentAccount);
const network = await provider.getNetwork();
setChainId(network.chainId);
// 设置新监听
setupEventListeners(provider);
}
} catch (err: any) {
console.error('连接失败:', err);
if (err.code === 4001) {
setError('连接请求被拒绝。');
} else {
setError(err.message || '未知连接错误');
}
} finally {
setIsConnecting(false);
}
}, [setupEventListeners]);
// 4. 断开连接(对于 MetaMask,更多是前端状态清除)
const disconnectWallet = useCallback(() => {
setAccount(null);
setChainId(null);
setError(null);
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
console.log('钱包已断开(前端状态)');
// 注意:MetaMask 无法通过代码真正"断开",只能前端清除状态。
// 用户需要自己在 MetaMask 中切换账户或锁定钱包。
}, []);
// 5. 组件卸载时清理监听
useEffect(() => {
return () => {
if (cleanupRef.current) {
cleanupRef.current();
}
};
}, []);
return {
account,
chainId,
isConnecting,
error,
connectWallet,
disconnectWallet,
isConnected: !!account, // 便捷的布尔状态
};
};
完整代码
这是一个可以直接在 React 项目中使用的完整示例组件。
tsx
// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';
const WalletConnector: React.FC = () => {
const {
account,
chainId,
isConnecting,
error,
connectWallet,
disconnectWallet,
isConnected,
} = useWallet();
// 格式化地址:0x1234...5678
const formatAddress = (addr: string | null) => {
if (!addr) return '';
return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`;
};
return (
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>Web3 钱包连接示例</h2>
{error && (
<div style={{ color: 'red', marginBottom: '10px', padding: '10px', background: '#ffe6e6' }}>
<strong>错误:</strong> {error}
</div>
)}
<div style={{ marginBottom: '15px' }}>
<strong>连接状态:</strong>
{isConnected ? (
<span style={{ color: 'green' }}>已连接</span>
) : (
<span style={{ color: 'orange' }}>未连接</span>
)}
</div>
{isConnected && account ? (
<div>
<div style={{ marginBottom: '10px' }}>
<strong>账户地址:</strong>
<code>{formatAddress(account)}</code> ({account})
</div>
<div style={{ marginBottom: '15px' }}>
<strong>当前链ID:</strong>
<code>{chainId?.toString() || '未知'}</code>
</div>
<button
onClick={disconnectWallet}
style={{
padding: '10px 20px',
background: '#ff6b6b',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
断开连接
</button>
<p style={{ fontSize: '0.9em', color: '#666', marginTop: '10px' }}>
(提示:此操作仅清除前端状态。如需完全断开,请在 MetaMask 中锁定钱包。)
</p>
</div>
) : (
<button
onClick={connectWallet}
disabled={isConnecting}
style={{
padding: '12px 24px',
background: isConnecting ? '#ccc' : '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isConnecting ? 'not-allowed' : 'pointer',
fontSize: '16px',
}}
>
{isConnecting ? '连接中...' : '连接 MetaMask 钱包'}
</button>
)}
{!window.ethereum && (
<div style={{ marginTop: '20px', padding: '15px', background: '#fff3cd', borderRadius: '4px' }}>
<p>⚠️ 未检测到 Web3 钱包。</p>
<p>
请安装 <a href="https://metamask.io/" target="_blank" rel="noopener noreferrer">MetaMask</a> 或其他兼容的以太坊钱包扩展。
</p>
</div>
)}
</div>
);
};
export default WalletConnector;
踩坑记录
-
ethers.providers.Web3Provider在 v6 中已废弃- 报错:
ethers.providers.Web3Provider is not a constructor - 原因: 我一开始照着 v5 的文档写,但项目安装的是 v6。
- 解决: 在 ethers.js v6 中,应使用
new ethers.BrowserProvider(window.ethereum)。
- 报错:
-
用户拒绝连接后,再次连接无反应
- 现象: 用户第一次点击"拒绝"后,再次点击连接按钮,MetaMask 不再弹窗。
- 原因: MetaMask 会"记住"用户的拒绝操作。
eth_requestAccounts在用户拒绝后,短时间内再次调用不会触发弹窗。 - 解决: 在 UI 上明确提示用户"您已拒绝,如需连接请刷新页面或手动在 MetaMask 中授权",或者引导用户点击 MetaMask 扩展图标重新授权。这是一个产品层面的设计选择。
-
事件监听器重复绑定导致内存泄漏和多次触发
- 现象: 切换账户时,控制台打印了多次
accountsChanged日志。 - 原因: 每次调用
connectWallet或组件重新渲染时,没有清理旧的事件监听器,导致同一个函数被绑定了多次。 - 解决: 使用
useRef存储清理函数,在绑定新监听前执行旧的清理函数,并在组件卸载时确保清理。
- 现象: 切换账户时,控制台打印了多次
-
TypeScript 类型
window.ethereum报错- 报错:
Property 'ethereum' does not exist on type 'Window & typeof globalThis'. - 解决: 在全局声明文件中(或当前文件顶部)使用
declare global扩展Window接口,并赋予其Eip1193Provider类型(这是 ethers v6 推荐的类型)。这提供了完美的类型安全和代码提示。
- 报错:
小结
通过这次实战,我深刻体会到,一个生产级的 Web3 钱包连接,远不止调用一个 API 那么简单。它需要健壮的错误处理、实时的状态监听、清晰的用户反馈和安全的类型定义。现在,我把这个打磨好的 useWallet Hook 放进了我的项目工具箱里,下次遇到类似需求,就能从容应对了。当然,这只是一个起点,后续还可以在此基础上集成更多功能,比如自动切换至指定测试网、获取用户签名消息、与后端进行登录验证等。