背景
上个月我在做一个 DeFi 资产管理面板,核心功能是让用户连接 MetaMask 钱包后,查看自己的代币余额和交易历史。这个项目用的是 React + TypeScript 技术栈,团队选定了 ethers.js 作为区块链交互库,因为大家对它比较熟悉,而且文档相对完善。
项目启动时,我天真地以为钱包登录就是调几个 API 的事------毕竟 MetaMask 都发展这么多年了,连接钱包这种基础功能应该很成熟。但真正动手做的时候,我发现事情远没那么简单。特别是用户刷新页面后,钱包连接状态丢失,需要重新点击连接按钮,这种体验在 Web3 应用里简直不能忍。我当时就在想:总不能每次刷新都让用户重新授权吧?那跟传统 Web2 的 session 过期有啥区别?
问题分析
我最初的思路非常直接:在 React 组件里用 window.ethereum 对象,调用 eth_requestAccounts 方法获取用户地址,然后存到 React 的 useState 里。代码大概长这样:
typescript
const [account, setAccount] = useState<string>('');
const connectWallet = async () => {
if (!window.ethereum) {
alert('请安装 MetaMask');
return;
}
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
setAccount(accounts[0]);
};
这段代码在首次连接时确实能跑通,但问题很快就暴露了:
- 刷新页面后状态丢失 :
useState是内存状态,页面一刷新就没了。用户需要重新点击连接按钮,体验极差。 - 用户主动断开连接无法感知 :MetaMask 允许用户在插件里断开网站连接,但
window.ethereum不会主动通知前端。用户断开后,前端还显示已连接,导致后续调用全部报错。 - 链切换后签名验证失败:用户如果在不同链(比如从 Ethereum 主网切到 Polygon)签名,生成的签名格式不同,后端验证时容易出问题。
我当时排查这些问题的过程很痛苦。先是怀疑 ethers.js 的版本问题,试了 v5 和 v6 的不同写法;然后又怀疑 React 的渲染机制,加了 useEffect 监听账户变化;最后才发现,核心问题在于钱包连接状态需要持久化到 localStorage,并且要监听 MetaMask 的事件来实时更新状态。
核心实现
第一步:封装 Provider 和 Signer,解决初始化问题
首先要明确一个概念:ethers.js 里的 Provider 和 Signer 是两个不同的东西。Provider 只负责读数据(比如查询余额),Signer 才能签名和发送交易。连接钱包时,我们既需要 Provider 来获取链上信息,也需要 Signer 来执行写操作。
我当时踩的第一个坑是:直接用 new ethers.providers.Web3Provider(window.ethereum) 创建 Provider,但没考虑用户还没授权的情况。如果用户还没点击连接,这个 Provider 虽然创建成功了,但调用 getSigner() 会直接报错。
正确的做法是:先检查用户是否已经授权,再决定如何创建 Provider。
这里有个坑 :ethers.providers.Web3Provider 在 v5 和 v6 的导入方式不同。我用的是 v5,导入路径是 ethers.providers;如果是 v6,要改成 ethers.BrowserProvider。项目里统一用 v5 避免混淆。
typescript
// utils/wallet.ts
import { ethers } from 'ethers';
// 检查 MetaMask 是否安装
export const isMetaMaskInstalled = (): boolean => {
return typeof window.ethereum !== 'undefined' && window.ethereum.isMetaMask;
};
// 获取 Provider(只读)
export const getProvider = (): ethers.providers.Web3Provider | null => {
if (!isMetaMaskInstalled()) return null;
return new ethers.providers.Web3Provider(window.ethereum);
};
// 获取 Signer(需要用户授权)
export const getSigner = async (): Promise<ethers.Signer | null> => {
const provider = getProvider();
if (!provider) return null;
try {
// 关键:先请求账户授权,再获取 Signer
await provider.send('eth_requestAccounts', []);
return provider.getSigner();
} catch (error) {
console.error('用户拒绝授权:', error);
return null;
}
};
第二步:持久化连接状态,解决刷新丢失问题
我决定把钱包地址存到 localStorage,同时监听 MetaMask 的 accountsChanged 事件来实时更新。这样用户刷新页面后,从 localStorage 读取地址,再重新创建 Provider 和 Signer。
这里有个坑 :localStorage 里存的地址必须是 checksum 格式(即正确的大小写),否则 ethers.js 的一些方法会报 invalid address 错误。所以我统一用 ethers.utils.getAddress() 做格式化。
typescript
// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
import { getProvider, getSigner, isMetaMaskInstalled } from '../utils/wallet';
const STORAGE_KEY = 'wallet_address';
export const useWallet = () => {
const [account, setAccount] = useState<string>('');
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [chainId, setChainId] = useState<number>(0);
const [isConnecting, setIsConnecting] = useState(false);
// 从 localStorage 恢复连接状态
useEffect(() => {
const savedAccount = localStorage.getItem(STORAGE_KEY);
if (savedAccount && isMetaMaskInstalled()) {
// 重新初始化 Provider 和 Signer
const prov = getProvider();
if (prov) {
setProvider(prov);
// 注意:这里不能直接 getSigner,因为用户可能已经断开连接
// 先尝试获取账户,如果失败就清除 localStorage
prov
.send('eth_accounts', [])
.then((accounts: string[]) => {
if (accounts.length > 0 && accounts[0] === savedAccount) {
setAccount(ethers.utils.getAddress(accounts[0]));
setSigner(prov.getSigner());
} else {
// 用户已断开连接,清除本地状态
localStorage.removeItem(STORAGE_KEY);
}
})
.catch(() => {
localStorage.removeItem(STORAGE_KEY);
});
}
}
}, []);
// 连接钱包
const connect = useCallback(async () => {
if (!isMetaMaskInstalled()) {
alert('请安装 MetaMask 浏览器插件');
return;
}
setIsConnecting(true);
try {
const signer = await getSigner();
if (signer) {
const address = await signer.getAddress();
const formattedAddress = ethers.utils.getAddress(address);
setAccount(formattedAddress);
setSigner(signer);
setProvider(getProvider());
// 持久化到 localStorage
localStorage.setItem(STORAGE_KEY, formattedAddress);
}
} catch (error) {
console.error('连接失败:', error);
} finally {
setIsConnecting(false);
}
}, []);
// 断开连接
const disconnect = useCallback(() => {
setAccount('');
setSigner(null);
setProvider(null);
localStorage.removeItem(STORAGE_KEY);
}, []);
return { account, provider, signer, chainId, isConnecting, connect, disconnect };
};
第三步:监听 MetaMask 事件,实现实时状态同步
这是最容易被忽略的部分。MetaMask 会触发三个关键事件:accountsChanged(账户切换)、chainChanged(链切换)、disconnect(断开连接)。如果不监听这些事件,用户切换到其他账户时,前端还显示旧账户信息。
这里有个坑 :accountsChanged 事件在用户断开连接时会返回空数组 [],而不是 null 或 undefined。我当时没注意这个细节,导致断开后地址还是旧值。
typescript
// 在 useWallet hook 中添加事件监听
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
// 用户断开了连接
disconnect();
} else {
// 用户切换了账户
const newAccount = ethers.utils.getAddress(accounts[0]);
setAccount(newAccount);
localStorage.setItem(STORAGE_KEY, newAccount);
// 重新创建 Signer
const prov = getProvider();
if (prov) {
setProvider(prov);
setSigner(prov.getSigner());
}
}
};
const handleChainChanged = (chainIdHex: string) => {
// 链 ID 是十六进制字符串,转为十进制数字
const newChainId = parseInt(chainIdHex, 16);
setChainId(newChainId);
// 链切换后需要重新创建 Provider 和 Signer
const prov = getProvider();
if (prov) {
setProvider(prov);
setSigner(prov.getSigner());
}
};
const handleDisconnect = () => {
disconnect();
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
window.ethereum.on('disconnect', handleDisconnect);
return () => {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
window.ethereum.removeListener('disconnect', handleDisconnect);
};
}, [disconnect]);
第四步:签名验证,确保用户真实拥有钱包
登录不仅仅是连接钱包,还要验证用户确实拥有这个地址。常见的做法是让用户签名一段消息,后端验证签名是否匹配。
这里有个坑 :签名时一定要指定 chainId,否则在不同链上签名生成的 v 值不同,导致验证失败。我用的是 eth_signTypedData_v4 标准,但为了兼容性,团队决定用简单的 personal_sign。
typescript
// utils/signature.ts
import { ethers } from 'ethers';
// 生成登录消息
export const createLoginMessage = (address: string, nonce: string): string => {
return `欢迎登录 DeFi Dashboard\n地址: ${address}\nNonce: ${nonce}\n时间戳: ${Date.now()}`;
};
// 用户签名
export const signMessage = async (
signer: ethers.Signer,
message: string
): Promise<string> => {
try {
const signature = await signer.signMessage(message);
return signature;
} catch (error) {
console.error('签名失败:', error);
throw error;
}
};
// 前端验证签名(简单验证,生产环境应在后端做)
export const verifySignature = (
address: string,
message: string,
signature: string
): boolean => {
try {
const recoveredAddress = ethers.utils.verifyMessage(message, signature);
return recoveredAddress.toLowerCase() === address.toLowerCase();
} catch {
return false;
}
};
完整代码
以下是一个完整的 React 组件示例,集成了上述所有逻辑。复制到项目中,安装 ethers@5 和 react 即可运行。
typescript
// App.tsx
import React, { useEffect } from 'react';
import { useWallet } from './hooks/useWallet';
import { createLoginMessage, signMessage, verifySignature } from './utils/signature';
const App: React.FC = () => {
const { account, provider, signer, isConnecting, connect, disconnect } = useWallet();
const [loginStatus, setLoginStatus] = React.useState<string>('');
const handleLogin = async () => {
if (!signer || !account) return;
try {
// 生成随机 nonce(生产环境应由后端生成)
const nonce = Math.random().toString(36).substring(2, 15);
const message = createLoginMessage(account, nonce);
const signature = await signMessage(signer, message);
// 验证签名
const isValid = verifySignature(account, message, signature);
if (isValid) {
setLoginStatus(`登录成功!签名已验证。`);
// 这里应该把 signature 发送给后端进行进一步验证
} else {
setLoginStatus('签名验证失败,请重试。');
}
} catch (error) {
setLoginStatus('签名过程出错,请重试。');
}
};
return (
<div style={{ padding: '2rem' }}>
<h1>DeFi Dashboard</h1>
{!account ? (
<button onClick={connect} disabled={isConnecting}>
{isConnecting ? '连接中...' : '连接钱包'}
</button>
) : (
<div>
<p>已连接账户: {account}</p>
<button onClick={handleLogin}>签名登录</button>
<button onClick={disconnect} style={{ marginLeft: '1rem' }}>
断开连接
</button>
{loginStatus && <p>{loginStatus}</p>}
</div>
)}
</div>
);
};
export default App;
踩坑记录
-
eth_requestAccounts和eth_accounts的区别 :前者会弹出 MetaMask 授权窗口,后者只返回当前已授权的账户。我一开始在useEffect里用了eth_requestAccounts,导致每次刷新都弹出授权窗口,用户体验极差。后来改成eth_accounts才解决。 -
ethers v5 和 v6 的 API 不兼容 :项目初期我混用了 v5 和 v6 的写法,比如
provider.getSigner()在 v5 里是同步的,但在 v6 里变成了异步。后来统一锁定ethers@5.7.2才避免了这些奇怪的问题。 -
签名时的
chainId问题 :用户如果在 Polygon 链上签名,生成的签名v值是 0 或 1,而不是标准的 27 或 28。后端验证时需要做特殊处理,否则会验证失败。我们的解决方案是在后端统一用ethers.utils.verifyMessage验证,它内部会处理v值的兼容。 -
localStorage 跨域问题 :在开发环境用
localhost,生产环境用正式域名,localStorage 的 key 是隔离的。用户从开发环境切换到生产环境需要重新连接。这个问题无解,只能提醒用户注意。
小结
通过这个项目,我深刻体会到 Web3 前端的核心难点不在于 API 调用,而在于状态管理和事件监听。钱包连接不是一次性的操作,而是一个需要持续维护的会话。如果你也在做类似功能,建议优先考虑使用 wagmi 或 RainbowKit 这类封装更完善的库,它们已经帮你处理了大部分边界情况。但如果想深入理解底层原理,自己用 ethers.js 实现一遍,绝对值得。下一步我打算研究如何用 viem 替代 ethers.js,据说它在类型安全和性能上更有优势。