用ethers.js连接MetaMask实现Web3钱包登录:从踩坑到稳定运行的完整记录

12 阅读1分钟

前言:一个看似简单的需求,让我折腾了两天

事情是这样的:上个月我在做一个DeFi看板项目,核心功能是让用户连接MetaMask钱包后,能看到自己的资产和交易记录。老板说"钱包登录很简单,就是调个接口嘛",我当时也觉得——不就是调个window.ethereum吗?结果真正动手时,踩坑踩到怀疑人生。

从连接失败、签名验证错误,到用户拒绝连接后状态混乱,再到不同链之间切换导致数据错乱……每一个问题都让我抓狂。但最终,我总结出了一套稳定可用的方案。这篇文章就是把我这两天的踩坑经历完整记录下来,希望对你有帮助。


背景:一个DeFi看板项目的前端需求

当时我正在开发一个叫"DeFiDash"的项目,本质上就是一个聚合看板,用户连接钱包后能查看自己在多个链上的资产、持仓和交易记录。前端用的是React + TypeScript,后端是Node.js。

核心需求其实就三个:

  1. 用户点击"连接钱包"按钮,弹出MetaMask授权窗口
  2. 用户签名一条消息,后端验证签名后返回JWT token
  3. 页面根据用户地址展示对应的链上数据

看起来很简单对吧?但真正实现时,我遇到了第一个大坑——连接过程的状态管理。


问题分析:为什么"一行代码"搞不定?

我最初的思路是直接写一个connectWallet函数:

// 第一版代码,天真到不行
async function connectWallet() {
  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
  setAddress(accounts[0]);
}

然后我发现了三个问题:

问题1:用户拒绝连接时,代码会直接崩溃。 如果用户点击了MetaMask弹窗的"取消"按钮,request会抛出一个错误,但我的代码没有捕获它,导致页面白屏。

问题2:连接后没有验证链ID。 用户可能连接的是以太坊主网,也可能连接的是Goerli测试网,但我根本没有检查。后来用户反馈说"连接后看不到资产",排查了半天才发现是链ID不匹配。

问题3:页面刷新后连接状态丢失。 用户连接成功后,刷新页面就需要重新连接。这体验太差了,而且每次刷新都弹MetaMask窗口,用户会疯的。

这三个问题让我意识到,钱包登录远不止"调一个接口"那么简单。我需要一个完整的连接流程,包括状态管理、错误处理、链ID验证和持久化。


核心实现:一步步搭建稳定的钱包登录

第一步:初始化Provider和检测MetaMask

在React中,我习惯把所有Web3相关的逻辑封装在一个自定义Hook里。首先,我需要一个provider——这是与区块链交互的底层对象。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

export function useWallet() {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [address, setAddress] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [error, setError] = useState<string>('');

  // 初始化:检测MetaMask是否安装
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setError('请安装MetaMask浏览器插件');
      return;
    }
    // 注意:这里不要自动请求连接,只在用户点击按钮时才触发
    const ethersProvider = new BrowserProvider(window.ethereum);
    setProvider(ethersProvider);
  }, []);
}

这里有个坑: 不要一加载页面就调用eth_requestAccounts。有些用户不想连接钱包,只是浏览页面,自动弹窗会吓到他们。正确的做法是只检测MetaMask是否存在,连接操作交给用户点击按钮触发。

第二步:实现连接逻辑,处理所有异常

连接钱包的核心是调用eth_requestAccounts,但必须做好错误处理。我当时用了一个笨办法——直接在catch里打印错误信息,后来发现不同错误类型需要不同处理方式。

// hooks/useWallet.ts(续)
const connect = useCallback(async () => {
  if (!provider) {
    setError('Provider未初始化');
    return;
  }

  try {
    // 关键:先请求账户,再获取签名者
    const accounts = await provider.send('eth_requestAccounts', []);
    if (accounts.length === 0) {
      throw new Error('没有获取到账户');
    }

    const userAddress = accounts[0];
    const userSigner = await provider.getSigner();
    const network = await provider.getNetwork();
    const userChainId = Number(network.chainId);

    // 验证链ID是否在支持的范围内
    const SUPPORTED_CHAIN_IDS = [1, 5, 137]; // 以太坊主网、Goerli、Polygon
    if (!SUPPORTED_CHAIN_IDS.includes(userChainId)) {
      // 这里可以提示用户切换网络,但先保存状态
      console.warn(`当前链ID ${userChainId} 不在支持列表中`);
    }

    setAddress(userAddress);
    setSigner(userSigner);
    setChainId(userChainId);
    setError('');

    // 持久化:把地址存到localStorage,下次刷新时自动恢复
    localStorage.setItem('walletAddress', userAddress);
    localStorage.setItem('walletChainId', userChainId.toString());

  } catch (err: any) {
    // 处理不同类型的错误
    if (err.code === 4001) {
      // 用户拒绝了连接请求
      setError('用户拒绝了连接请求');
    } else if (err.code === -32002) {
      // MetaMask正在处理另一个请求
      setError('请先处理MetaMask中的其他请求');
    } else {
      setError(err.message || '连接钱包失败');
    }
  }
}, [provider]);

注意这个细节: 错误码4001是用户拒绝,-32002是重复请求。这两个错误码我查了MetaMask文档才搞清楚,之前一直用err.message判断,结果发现不同版本的MetaMask返回的消息格式不一样。

第三步:消息签名与后端验证

登录不只是连接钱包,还需要让后端验证用户身份。最常用的方式是"消息签名"——前端让用户签名一条包含随机数(nonce)的消息,后端用公钥验证签名。

// hooks/useWallet.ts(续)
const signMessage = useCallback(async (message: string): Promise<string> => {
  if (!signer) {
    throw new Error('请先连接钱包');
  }

  try {
    // 注意:message应该包含一个nonce,防止重放攻击
    const signature = await signer.signMessage(message);
    return signature;
  } catch (err: any) {
    if (err.code === 4001) {
      throw new Error('用户取消了签名');
    }
    throw new Error('签名失败: ' + err.message);
  }
}, [signer]);

// 实际登录流程
const login = useCallback(async () => {
  if (!address) {
    setError('请先连接钱包');
    return;
  }

  try {
    // 1. 从后端获取nonce
    const nonceResponse = await fetch('/api/auth/nonce?address=' + address);
    const { nonce } = await nonceResponse.json();

    // 2. 让用户签名nonce
    const message = `欢迎登录DeFiDash,本次登录的随机码为:${nonce}`;
    const signature = await signMessage(message);

    // 3. 发送地址和签名到后端验证
    const loginResponse = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address, signature, message }),
    });

    const { token } = await loginResponse.json();
    // 存储token,后续API请求带上
    localStorage.setItem('authToken', token);
    setError('');

  } catch (err: any) {
    setError(err.message || '登录失败');
  }
}, [address, signMessage]);

这里有个坑: 签名消息的格式很重要。有些项目直接签address,这太不安全了,因为任何网站都可以伪造。一定要包含nonce,并且最好加上一些上下文信息(比如"欢迎登录XXX"),让用户在MetaMask里能看到清晰的内容。

第四步:页面刷新后自动恢复连接状态

用户连接成功后刷新页面,如果直接显示"未连接",体验很差。我通过localStorage保存地址,在页面加载时尝试恢复。

// hooks/useWallet.ts(续)
// 页面加载时恢复连接
useEffect(() => {
  const savedAddress = localStorage.getItem('walletAddress');
  const savedChainId = localStorage.getItem('walletChainId');

  if (savedAddress && provider) {
    // 恢复时只设置地址,不主动请求连接
    setAddress(savedAddress);
    setChainId(Number(savedChainId));
    // 注意:这里不设置signer,因为signer需要用户授权
    // 实际使用时,如果用户需要签名,再调用connect获取signer
  }
}, [provider]);

// 监听账户变化和链变化
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    if (accounts.length === 0) {
      // 用户断开了连接
      disconnect();
    } else {
      setAddress(accounts[0]);
      localStorage.setItem('walletAddress', accounts[0]);
    }
  };

  const handleChainChanged = (chainIdHex: string) => {
    const newChainId = parseInt(chainIdHex, 16);
    setChainId(newChainId);
    localStorage.setItem('walletChainId', newChainId.toString());
    // 链变化后,signer需要重新获取
    if (provider) {
      provider.getSigner().then(setSigner);
    }
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  return () => {
    window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum.removeListener('chainChanged', handleChainChanged);
  };
}, [provider]);

const disconnect = useCallback(() => {
  setAddress('');
  setSigner(null);
  setChainId(0);
  localStorage.removeItem('walletAddress');
  localStorage.removeItem('walletChainId');
  localStorage.removeItem('authToken');
}, []);

注意这个细节: chainChanged事件返回的是十六进制字符串(比如"0x5"),需要转成十进制。我当时直接用了parseInt,但忘记加基数参数,导致"0x5"被解析成0。排查了半天才发现。


完整代码:可直接运行的React Hook

我把上面所有代码整合成一个完整的useWallet Hook,你可以直接复制到项目中使用。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

interface WalletState {
  provider: BrowserProvider | null;
  signer: JsonRpcSigner | null;
  address: string;
  chainId: number;
  error: string;
  isConnecting: boolean;
}

export function useWallet() {
  const [state, setState] = useState<WalletState>({
    provider: null,
    signer: null,
    address: '',
    chainId: 0,
    error: '',
    isConnecting: false,
  });

  // 初始化:检测MetaMask
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setState(prev => ({ ...prev, error: '请安装MetaMask插件' }));
      return;
    }
    const ethersProvider = new BrowserProvider(window.ethereum);
    setState(prev => ({ ...prev, provider: ethersProvider }));
  }, []);

  // 恢复上次连接
  useEffect(() => {
    const savedAddress = localStorage.getItem('walletAddress');
    if (savedAddress && state.provider) {
      setState(prev => ({ ...prev, address: savedAddress }));
    }
  }, [state.provider]);

  // 连接钱包
  const connect = useCallback(async () => {
    if (!state.provider) {
      setState(prev => ({ ...prev, error: 'Provider未初始化' }));
      return;
    }

    setState(prev => ({ ...prev, isConnecting: true, error: '' }));

    try {
      const accounts = await state.provider.send('eth_requestAccounts', []);
      if (accounts.length === 0) {
        throw new Error('没有获取到账户');
      }

      const userAddress = accounts[0];
      const userSigner = await state.provider.getSigner();
      const network = await state.provider.getNetwork();
      const userChainId = Number(network.chainId);

      localStorage.setItem('walletAddress', userAddress);
      localStorage.setItem('walletChainId', userChainId.toString());

      setState(prev => ({
        ...prev,
        address: userAddress,
        signer: userSigner,
        chainId: userChainId,
        isConnecting: false,
      }));
    } catch (err: any) {
      let errorMsg = '连接钱包失败';
      if (err.code === 4001) errorMsg = '用户拒绝了连接请求';
      else if (err.code === -32002) errorMsg = '请先处理MetaMask中的其他请求';
      else if (err.message) errorMsg = err.message;

      setState(prev => ({ ...prev, error: errorMsg, isConnecting: false }));
    }
  }, [state.provider]);

  // 断开连接
  const disconnect = useCallback(() => {
    localStorage.removeItem('walletAddress');
    localStorage.removeItem('walletChainId');
    localStorage.removeItem('authToken');
    setState(prev => ({
      ...prev,
      address: '',
      signer: null,
      chainId: 0,
      error: '',
    }));
  }, []);

  // 监听事件
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnect();
      } else {
        setState(prev => ({ ...prev, address: accounts[0] }));
        localStorage.setItem('walletAddress', accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      const newChainId = parseInt(chainIdHex, 16);
      setState(prev => ({ ...prev, chainId: newChainId }));
      localStorage.setItem('walletChainId', newChainId.toString());
      // 重新获取signer
      if (state.provider) {
        state.provider.getSigner().then(signer => {
          setState(prev => ({ ...prev, signer }));
        });
      }
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    };
  }, [state.provider, disconnect]);

  // 签名消息
  const signMessage = useCallback(async (message: string): Promise<string> => {
    if (!state.signer) {
      throw new Error('请先连接钱包');
    }
    try {
      return await state.signer.signMessage(message);
    } catch (err: any) {
      if (err.code === 4001) throw new Error('用户取消了签名');
      throw new Error('签名失败: ' + err.message);
    }
  }, [state.signer]);

  return {
    ...state,
    connect,
    disconnect,
    signMessage,
  };
}

使用示例:

// App.tsx
import { useWallet } from './hooks/useWallet';

function App() {
  const { address, chainId, error, isConnecting, connect, disconnect, signMessage } = useWallet();

  const handleLogin = async () => {
    try {
      // 假设后端返回nonce
      const signature = await signMessage('登录nonce: 123456');
      // 发送signature到后端验证
      console.log('签名结果:', signature);
    } catch (err: any) {
      console.error(err.message);
    }
  };

  return (
    <div>
      {address ? (
        <div>
          <p>已连接: {address.slice(0, 6)}...{address.slice(-4)}</p>
          <p>链ID: {chainId}</p>
          <button onClick={handleLogin}>签名登录</button>
          <button onClick={disconnect}>断开连接</button>
        </div>
      ) : (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录:我实际遇到的4个问题

1. ethers.getDefaultProvider() 在浏览器端报错

  • 报错信息:Error: network error: The method eth_getBlockByNumber does not exist/is not available
  • 原因:getDefaultProvider() 会尝试连接以太坊节点,但在浏览器端,应该使用MetaMask注入的provider。
  • 解决:统一使用 new BrowserProvider(window.ethereum)

2. 用户拒绝连接后,再次点击连接按钮无反应

  • 现象:用户第一次点击"连接钱包"时取消了MetaMask弹窗,再次点击按钮,弹窗不出现了。
  • 原因:MetaMask检测到已有挂起的请求,返回错误码-32002
  • 解决:在catch中处理这个错误,提示用户"请先处理MetaMask中的其他请求",并建议用户刷新页面。

3. 签名时MetaMask弹窗不显示消息内容

  • 现象:调用signer.signMessage()时,MetaMask弹窗只显示"签名请求",没有显示具体的消息内容。
  • 原因:消息是纯字符串,没有格式化为可读的EIP-712类型数据。
  • 解决:对于简单登录,可以使用personal_sign方法,或者确保消息中包含可读的上下文(如"登录XXX")。

4. 链切换后,signer对象失效

  • 现象:用户在MetaMask中切换了网络,但调用signer.getAddress()时返回了旧地址。
  • 原因:signer是在连接时创建的,链变化后需要重新获取signer。
  • 解决:在chainChanged事件监听中,重新调用provider.getSigner()

小结

连接MetaMask实现钱包登录,核心就三点:正确处理用户授权、严格管理状态变化、做好异常处理。不要被"一行代码"的错觉迷惑,真正的坑都在细节里——比如错误码、链ID格式、事件监听的生命周期。

如果你继续深入,可以研究一下:

  • 使用wagmiRainbowKit等库简化钱包连接
  • 实现多链支持,让用户在不同链之间切换
  • 集成EIP-712类型数据签名,提升用户体验

希望这篇文章能帮你少踩一些坑。如果你在实现过程中遇到了其他问题,欢迎在评论区交流。