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

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

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

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


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

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

核心需求其实就三个:

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

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


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

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

typescript 复制代码
// 第一版代码,天真到不行
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------这是与区块链交互的底层对象。

typescript 复制代码
// 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里打印错误信息,后来发现不同错误类型需要不同处理方式。

typescript 复制代码
// 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)的消息,后端用公钥验证签名。

typescript 复制代码
// 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保存地址,在页面加载时尝试恢复。

typescript 复制代码
// 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,你可以直接复制到项目中使用。

typescript 复制代码
// 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,
  };
}

使用示例:

tsx 复制代码
// 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类型数据签名,提升用户体验

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

相关推荐
heyCHEEMS1 小时前
如何用 Recast 实现静态配置文件源码级读写
前端·node.js
心连欣1 小时前
从零开始,学习所有指令!
前端·javascript·vue.js
review445431 小时前
大模型和function calling分别是如何工作的
前端
东东同学1 小时前
耗时一个月,我把 Nuxt 首屏性能排障经验做成了一个 AI Skill
前端·agent
冴羽2 小时前
超越 Vibe Coding —— AI 辅助编程指南
前端·ai编程·vibecoding
梦想的颜色3 小时前
一天一个SKILL——前端最佳自动化测试 webapp-testing
前端·web app
SoaringHeart3 小时前
Flutter进阶:放弃 MediaQuery.of(context) 使用 NScreenManager
前端·flutter
openKaka_3 小时前
从 scheduleUpdateOnFiber 到 Root 微任务调度:React 如何把更新交给调度系统
开发语言·前端·javascript
CoovallyAIHub4 小时前
铁路环境障碍物检测新框架:YOLOv11+MiDaS+LiDAR 深度融合,距离估计MAE低至0.63米
前端