背景:一个看似简单的需求
上个月,我接了一个 DeFi 流动性看板项目的前端任务。需求很明确:用户通过 MetaMask 钱包登录后,可以查看自己在不同链上的流动性池状态。团队后端用 Node.js,前端是 React + TypeScript,钱包交互部分交给我负责。
我心想,这不就是调用 window.ethereum 然后 eth_requestAccounts 吗?网上教程铺天盖地,半天应该能搞定。结果,我花了整整两天,踩了至少 5 个坑,才真正跑通一个生产可用的登录流程。
问题分析:为什么简单的连接会翻车
最初我按常规思路写:
typescript
// 错误示例 - 直接调 MetaMask
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
本地测试时一切正常,但一放到生产环境就出问题:
- 没有检测 MetaMask 是否安装 :用户如果没装 MetaMask,直接调用
window.ethereum会报Cannot read properties of undefined。 - 链 ID 没处理:用户连接的是以太坊主网,但我的项目需要的是 BSC,结果交易一直失败。
- 账号切换没监听:用户换了钱包账号,前端还显示旧地址。
- 签名验证太随意 :我用
eth_sign直接签字符串,用户根本不知道在签什么。
这些坑在教程里很少被提及,但实际项目中每一个都能让你 debug 到怀疑人生。
核心实现:从零搭建一个稳健的钱包登录系统
1. 检测 MetaMask 安装与浏览器兼容
第一步不是连接,而是确认环境。我封装了一个 isMetaMaskInstalled 函数,处理了三种情况:未安装、版本过低、非浏览器环境。
typescript
// utils/wallet.ts
export function isMetaMaskInstalled(): { installed: boolean; error?: string } {
// 非浏览器环境
if (typeof window === 'undefined') {
return { installed: false, error: '当前非浏览器环境' };
}
// 检查 ethereum 对象
if (!window.ethereum) {
return { installed: false, error: '请安装 MetaMask 钱包' };
}
// 检查是否为 MetaMask(避免其他钱包冒充)
if (!window.ethereum.isMetaMask) {
return { installed: false, error: '请使用 MetaMask 钱包' };
}
// 检查版本(可选,建议 >= 10.0.0)
const version = window.ethereum.version || '';
if (version && parseFloat(version) < 10) {
return { installed: false, error: 'MetaMask 版本过低,请升级' };
}
return { installed: true };
}
这里有个坑 :不要直接用
window.ethereum判断,因为某些钱包(如 Coinbase Wallet)也会注入ethereum对象。用isMetaMask属性可以精准判断。我当时就因为没加这个判断,用户装了 Coinbase Wallet 却提示 MetaMask 已安装,导致后续连接失败。
2. 连接钱包与获取账号
检测通过后,才是真正的连接。我用 ethers.js 的 BrowserProvider 来封装连接逻辑,这样后续的合约交互也统一。
typescript
// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useCallback } from 'react';
export function useWallet() {
const [address, setAddress] = useState<string>('');
const [provider, setProvider] = useState<BrowserProvider | null>(null);
const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
const [chainId, setChainId] = useState<number>(0);
const [error, setError] = useState<string>('');
const connect = useCallback(async () => {
try {
// 1. 检测安装
const check = isMetaMaskInstalled();
if (!check.installed) {
setError(check.error || '');
return;
}
// 2. 创建 BrowserProvider(ethers v6 的新 API)
const browserProvider = new BrowserProvider(window.ethereum);
setProvider(browserProvider);
// 3. 请求账号授权
// 注意:这里会弹出 MetaMask 授权窗口
const accounts = await browserProvider.send('eth_requestAccounts', []);
if (accounts.length === 0) {
setError('用户取消了授权');
return;
}
// 4. 获取签名者(Signer)
const walletSigner = await browserProvider.getSigner();
setSigner(walletSigner);
// 5. 获取地址和链 ID
const userAddress = await walletSigner.getAddress();
const network = await browserProvider.getNetwork();
setAddress(userAddress);
setChainId(Number(network.chainId));
setError('');
// 6. 监听账号切换
window.ethereum.on('accountsChanged', (accounts: string[]) => {
if (accounts.length === 0) {
// 用户断开了连接
disconnect();
} else {
// 更新地址
setAddress(accounts[0]);
}
});
// 7. 监听链切换
window.ethereum.on('chainChanged', (chainIdHex: string) => {
setChainId(parseInt(chainIdHex, 16));
// 注意:链切换后需要重新创建 provider 和 signer
// 因为 ethers v6 的 BrowserProvider 会缓存网络信息
window.location.reload(); // 简单粗暴但有效
});
} catch (err: any) {
// 处理用户拒绝连接
if (err.code === 4001) {
setError('用户拒绝了连接请求');
} else {
setError(err.message || '连接失败');
}
}
}, []);
const disconnect = useCallback(() => {
setAddress('');
setProvider(null);
setSigner(null);
setChainId(0);
setError('');
// 移除监听
window.ethereum?.removeAllListeners('accountsChanged');
window.ethereum?.removeAllListeners('chainChanged');
}, []);
return { address, provider, signer, chainId, error, connect, disconnect };
}
注意这个细节 :
BrowserProvider是 ethers v6 新增的,替代了 v5 的Web3Provider。如果你还在用 v5,写法会不同。我当时项目用的 v6,结果参考了 v5 的文档,卡了半天才发现 API 变了。
3. 链切换:让用户一键跳转到目标链
连接成功后,用户可能不在我们期望的链上。比如我的 DeFi 看板主要支持 BSC(链 ID 56),但用户连接的是以太坊主网(链 ID 1)。这时候需要主动切换。
typescript
// utils/chain.ts
export const SUPPORTED_CHAINS = {
56: {
chainId: '0x38',
chainName: 'Binance Smart Chain',
nativeCurrency: { name: 'BNB', symbol: 'BNB', decimals: 18 },
rpcUrls: ['https://bsc-dataseed.binance.org/'],
blockExplorerUrls: ['https://bscscan.com/']
},
// 可以添加更多链
};
export async function switchChain(chainId: number): Promise<boolean> {
try {
// 尝试切换
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: `0x${chainId.toString(16)}` }],
});
return true;
} catch (switchError: any) {
// 如果目标链不存在,则添加
if (switchError.code === 4902) {
try {
const chainConfig = SUPPORTED_CHAINS[chainId];
if (!chainConfig) {
throw new Error('不支持的链 ID');
}
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [chainConfig],
});
return true;
} catch (addError) {
console.error('添加链失败', addError);
return false;
}
}
console.error('切换链失败', switchError);
return false;
}
}
这里有个坑 :
wallet_switchEthereumChain的chainId参数必须是十六进制字符串,且不带0x前缀会报错。我当时传了十进制数字 56,结果 MetaMask 弹窗提示"无效参数",debug 了半小时才发现。
4. 签名验证:安全登录的核心
连接钱包只是第一步,真正的"登录"需要通过签名来验证用户对地址的所有权。我使用的是 eth_signTypedData_v4,它比 eth_sign 更安全,用户能看到签名内容。
typescript
// utils/signature.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
// 生成签名消息
export function createSignMessage(address: string, nonce: string): string {
return JSON.stringify({
domain: {
name: 'DeFi Dashboard',
version: '1',
chainId: 1, // 这里可以动态获取
},
message: {
action: '登录 DeFi Dashboard',
address: address,
nonce: nonce, // 后端生成的随机数,防止重放攻击
timestamp: Date.now(),
},
primaryType: 'Login',
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
],
Login: [
{ name: 'action', type: 'string' },
{ name: 'address', type: 'address' },
{ name: 'nonce', type: 'string' },
{ name: 'timestamp', type: 'uint256' },
],
},
});
}
// 签名并返回签名结果
export async function signLoginMessage(
signer: JsonRpcSigner,
nonce: string
): Promise<string> {
const address = await signer.getAddress();
const message = createSignMessage(address, nonce);
// 使用 eth_signTypedData_v4 签名
const signature = await signer.signTypedData(
JSON.parse(message).domain,
JSON.parse(message).types,
JSON.parse(message).message
);
return signature;
}
注意这个细节 :
signer.signTypedData是 ethers v6 的方法,它会自动调用eth_signTypedData_v4。如果你直接用window.ethereum.request调用,需要手动指定版本。我当时用eth_sign签名,用户看到一串乱码直接拒绝,改用结构化数据后,用户能看到清晰的登录信息,体验好很多。
完整代码:可直接运行的 React 组件
下面是一个完整的 LoginButton 组件,集成了上述所有逻辑:
typescript
// components/LoginButton.tsx
import React, { useState, useEffect } from 'react';
import { useWallet } from '../hooks/useWallet';
import { switchChain } from '../utils/chain';
import { signLoginMessage } from '../utils/signature';
export const LoginButton: React.FC = () => {
const { address, provider, signer, chainId, error, connect, disconnect } = useWallet();
const [isLoading, setIsLoading] = useState(false);
const [loginStatus, setLoginStatus] = useState<string>('');
// 目标链 ID(示例为 BSC)
const TARGET_CHAIN_ID = 56;
const handleLogin = async () => {
setIsLoading(true);
setLoginStatus('');
try {
// 1. 连接钱包
await connect();
if (!signer) {
setLoginStatus('连接失败');
return;
}
// 2. 检查链 ID,不在目标链则切换
if (chainId !== TARGET_CHAIN_ID) {
const switched = await switchChain(TARGET_CHAIN_ID);
if (!switched) {
setLoginStatus('请切换到 BSC 链');
return;
}
}
// 3. 从后端获取 nonce(这里模拟)
const nonce = await fetchNonce(address);
// 4. 签名
const signature = await signLoginMessage(signer, nonce);
// 5. 发送签名到后端验证
const loginResult = await verifySignature(address, signature, nonce);
if (loginResult.success) {
setLoginStatus('登录成功');
// 保存 token 到 localStorage
localStorage.setItem('auth_token', loginResult.token);
} else {
setLoginStatus('签名验证失败');
}
} catch (err: any) {
setLoginStatus(err.message || '登录失败');
} finally {
setIsLoading(false);
}
};
// 模拟后端接口
const fetchNonce = async (address: string): Promise<string> => {
// 实际项目中调用后端 API
return `nonce_${Date.now()}_${address}`;
};
const verifySignature = async (
address: string,
signature: string,
nonce: string
): Promise<{ success: boolean; token?: string }> => {
// 实际项目中调用后端 API
console.log('发送验证请求:', { address, signature, nonce });
return { success: true, token: 'mock_jwt_token' };
};
// 监听错误
useEffect(() => {
if (error) {
setLoginStatus(error);
}
}, [error]);
return (
<div style={{ padding: '20px' }}>
{address ? (
<div>
<p>已连接:{address.slice(0, 6)}...{address.slice(-4)}</p>
<p>链 ID:{chainId}</p>
<button onClick={disconnect}>断开连接</button>
</div>
) : (
<button onClick={handleLogin} disabled={isLoading}>
{isLoading ? '连接中...' : '连接 MetaMask 登录'}
</button>
)}
{loginStatus && <p style={{ color: loginStatus.includes('失败') ? 'red' : 'green' }}>{loginStatus}</p>}
</div>
);
};
踩坑记录:那些让我熬夜的报错
坑 1:ethers.BrowserProvider is not a constructor
- 现象 :引入
ethers后,new BrowserProvider(window.ethereum)报错。 - 原因:我项目同时装了 ethers v5 和 v6,TypeScript 解析到了 v5 的版本。
- 解决 :检查
package.json,确保只有"ethers": "^6.0.0",并删除node_modules重新安装。
坑 2:MetaMask - RPC Error: Internal JSON-RPC error
- 现象 :调用
signTypedData时,MetaMask 弹窗然后报这个错。 - 原因 :我签名的消息中
domain.chainId传的是十进制数字,但 MetaMask 期望十六进制字符串。 - 解决 :将
chainId: 1改为chainId: '0x1'。
坑 3:用户切换账号后,前端不更新
- 现象:用户手动在 MetaMask 切换账号,但前端仍然显示旧地址。
- 原因 :没有监听
accountsChanged事件。 - 解决 :在
connect函数中添加window.ethereum.on('accountsChanged', callback)。
坑 4:链切换后交易一直 pending
- 现象:用户从以太坊切换到 BSC 后,发起交易一直 pending。
- 原因 :
BrowserProvider缓存了旧的网络信息,需要重新创建。 - 解决 :在
chainChanged监听中调用window.location.reload()刷新页面。
小结
这次经历让我深刻体会到,Web3 前端开发不仅仅是调几个 API,更多是处理各种边界情况和用户交互细节。核心收获是:永远假设用户会做出最意外的操作,并做好对应的错误处理 。如果你正在做类似功能,建议进一步研究 EIP-1193 标准,以及如何用 wagmi 或 Web3-Onboard 等库来简化钱包管理------不过那是另一个故事了。