从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

13 阅读6分钟

背景

上个月,我接手了一个新的 NFT 铸造平台前端开发。项目要求很简单:用户点击“连接钱包”按钮,弹出 MetaMask 授权,连接成功后显示用户地址和余额。作为有几年经验的 Web3 开发者,我心想这还不是手到擒来?直接上 ethers.js 这个老伙计,几行代码搞定。于是,我新建了一个 React 组件,信心满满地开始敲代码。没想到,就是这个看似基础的功能,让我在接下来的一天里,跟各种奇怪的报错和边界情况斗智斗勇。

问题分析

我最开始的思路非常直接:在组件挂载时,检查 window.ethereum 是否存在(即用户是否安装了 MetaMask),然后调用 ethereum.request({ method: 'eth_requestAccounts' }) 请求账户授权,最后用 new ethers.providers.Web3Provider(window.ethereum) 创建 provider 来读取链上数据。

第一版代码跑起来,点击按钮,MetaMask 确实弹出来了,授权也很顺利。控制台打印出了地址,我正准备庆祝,问题就来了。

  1. 页面刷新后,登录状态丢失:用户需要重新点击连接。这体验太差了,我们的产品经理第一个不答应。
  2. 切换 MetaMask 账户时,前端页面没反应:用户在钱包里换了账号,但我们的网站显示的依然是旧地址。
  3. 切换网络时页面卡住:用户从以太坊主网切换到 Polygon,页面有时会卡死,需要手动刷新。

我意识到,我把问题想简单了。一个生产级的钱包连接,不仅仅是“弹出授权框拿到地址”,它必须是一个有状态、能响应变化、并且持久化的连接。我需要监听钱包的各种事件(账户变化、网络变化),并妥善管理这些状态,使其与 React 组件的状态同步。

核心实现

第一步:检测 Provider 与初始化状态

首先,我们不能假设用户一定装了 MetaMask。所以,检测 window.ethereum 是第一步,并且最好在组件生命周期早期进行。

这里有个坑:window.ethereum 的类型在 TypeScript 中是 anyunknown。为了更好的类型安全,我将其断言为 ethers.providers.ExternalProvider,但更严谨的做法是使用 ethers 提供的类型工具,或者直接检查必要的方法是否存在。

我决定在自定义 Hook (useWallet) 的初始化阶段完成检测和基础设置。

import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

// 声明全局的 ethereum 类型
declare global {
  interface Window {
    ethereum?: ethers.providers.ExternalProvider & {
      isMetaMask?: boolean;
      request: (args: { method: string; params?: any[] }) => Promise<any>;
      on: (event: string, callback: (...args: any[]) => void) => void;
      removeListener: (event: string, callback: (...args: any[]) => void) => void;
    };
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 初始化:检查是否已连接
  useEffect(() => {
    const checkIfWalletIsConnected = async () => {
      if (!window.ethereum) {
        setError('请安装 MetaMask 钱包扩展!');
        return;
      }

      try {
        // 尝试获取已授权的账户
        const accounts = await window.ethereum.request({
          method: 'eth_accounts',
        });
        if (accounts.length > 0) {
          // 如果已有授权账户,直接初始化 provider 和 signer
          await initProviderAndSigner(accounts[0]);
        }
        // 获取当前网络ID
        const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
        setChainId(parseInt(chainIdHex, 16));
      } catch (err) {
        console.error('初始化检查钱包连接失败:', err);
      }
    };

    checkIfWalletIsConnected();
  }, []);
}

eth_accounts 这个方法是关键,它不会弹出授权框,而是静默返回已被当前 DApp 授权的账户列表。如果列表不为空,说明用户之前已经连接过,我们可以直接恢复状态。这是解决“刷新后状态丢失”问题的核心。

第二步:实现连接与断开功能

连接功能就是主动弹出授权请求。这里要注意错误处理,特别是用户拒绝授权的情况。

const connectWallet = useCallback(async () => {
  if (!window.ethereum) {
    setError('请安装 MetaMask 钱包扩展!');
    return;
  }

  setIsConnecting(true);
  setError('');
  try {
    // 1. 请求账户授权,这会弹出 MetaMask 窗口
    const accounts = await window.ethereum.request({
      method: 'eth_requestAccounts',
    });
    // 2. 用获取到的第一个账户初始化
    await initProviderAndSigner(accounts[0]);
    // 3. 获取当前网络
    const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
    setChainId(parseInt(chainIdHex, 16));
  } catch (err: any) {
    // 用户拒绝授权是最常见的错误
    if (err.code === 4001) {
      setError('您拒绝了钱包连接请求。');
    } else {
      setError(`连接失败: ${err.message}`);
    }
    console.error('连接钱包失败:', err);
  } finally {
    setIsConnecting(false);
  }
}, []);

const disconnectWallet = useCallback(() => {
  // 注意:ethers.js 和 MetaMask 没有真正的“断开连接”API。
  // 所谓的断开,只是清除我们本地应用的状态。
  setProvider(null);
  setSigner(null);
  setAccount('');
  setChainId(0);
  setError('');
  // 在实际项目中,你可能还需要清除 localStorage/SessionStorage 中的相关状态
}, []);

这里有个大坑:很多新手(包括当时的我)会寻找 disconnectlogout 方法。但实际上,MetaMask 的权限模型是“一次授权,持续有效”,直到用户在其钱包界面手动移除站点权限。所以前端的“断开”只是前端自己清空状态,下次用 eth_accounts 检查时,如果用户没移除权限,还是会拿到地址。这是一个重要的认知点。

第三步:监听钱包事件(关键!)

这是让应用“活”起来,响应外部变化的核心。我们需要监听 accountsChangedchainChanged 事件。

// 初始化 provider 和 signer 的辅助函数
const initProviderAndSigner = useCallback(async (accountAddress: string) => {
  if (!window.ethereum) return;
  // 创建 Provider
  const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
  setProvider(web3Provider);
  // 创建 Signer
  const web3Signer = web3Provider.getSigner();
  setSigner(web3Signer);
  setAccount(accountAddress);
}, []);

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

  const handleAccountsChanged = (accounts: string[]) => {
    console.log('accountsChanged 事件触发:', accounts);
    if (accounts.length === 0) {
      // 用户在所有界面断开了连接,或者切换到了一个没有权限的账户
      disconnectWallet();
      setError('请连接您的钱包账户。');
    } else if (accounts[0] !== account) {
      // 用户切换了账户
      initProviderAndSigner(accounts[0]);
    }
  };

  const handleChainChanged = (_chainId: string) => {
    // 注意:chainId 是十六进制字符串
    console.log('chainChanged 事件触发:', _chainId);
    // 当网络切换时,MetaMask 建议刷新页面,因为许多链上数据可能失效。
    // 但为了更好体验,我们可以只重置部分状态并重新获取链ID。
    window.location.reload();
    // 更优雅的做法:不刷新,只更新 chainId 并重新初始化 provider(可能需要新的 RPC 配置)
    // setChainId(parseInt(_chainId, 16));
    // initProviderAndSigner(account); // 重新初始化,因为网络变了
  };

  // 绑定监听器
  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  // 组件卸载时清理监听器,防止内存泄漏
  return () => {
    if (window.ethereum) {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    }
  };
}, [account, disconnectWallet, initProviderAndSigner]);

注意这个细节chainChanged 事件的处理。早期文档和很多教程都建议直接 window.location.reload(),因为网络切换后,旧的 provider 实例可能指向错误的 RPC。虽然刷新简单粗暴,但体验不好。更优的方案是:更新 chainId,然后基于新的 chainId 创建一个新的 provider 实例(如果你配置了多链 RPC 的话)。我这里为了代码清晰,先用了刷新方案。

第四步:获取余额与完善 UI

有了 provideraccount,获取余额就很简单了。但要注意异步操作和错误处理。

const [balance, setBalance] = useState<string>('0');

// 获取余额的函数
const fetchBalance = useCallback(async () => {
  if (!provider || !account) {
    setBalance('0');
    return;
  }
  try {
    const balanceWei = await provider.getBalance(account);
    // 格式化为 Ether 单位,保留4位小数
    const balanceEth = ethers.utils.formatEther(balanceWei);
    setBalance(parseFloat(balanceEth).toFixed(4));
  } catch (err) {
    console.error('获取余额失败:', err);
    setBalance('0');
  }
}, [provider, account]);

// 当 account 或 provider 变化时,重新获取余额
useEffect(() => {
  fetchBalance();
}, [fetchBalance]);

最后,将这些状态和方法暴露给组件,一个基础但健壮的钱包连接 Hook 就完成了。

完整代码

以下是一个整合了上述所有功能的 React 组件示例:

// WalletConnector.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: ethers.providers.ExternalProvider & {
      isMetaMask?: boolean;
      request: (args: { method: string; params?: any[] }) => Promise<any>;
      on: (event: string, callback: (...args: any[]) => void) => void;
      removeListener: (event: string, callback: (...args: any[]) => void) => void;
    };
  }
}

const WalletConnector: React.FC = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [balance, setBalance] = useState<string>('0');
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  const initProviderAndSigner = useCallback(async (accountAddress: string) => {
    if (!window.ethereum) return;
    const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
    const web3Signer = web3Provider.getSigner();
    setProvider(web3Provider);
    setSigner(web3Signer);
    setAccount(accountAddress);
  }, []);

  const fetchBalance = useCallback(async () => {
    if (!provider || !account) {
      setBalance('0');
      return;
    }
    try {
      const balanceWei = await provider.getBalance(account);
      const balanceEth = ethers.utils.formatEther(balanceWei);
      setBalance(parseFloat(balanceEth).toFixed(4));
    } catch (err) {
      console.error('获取余额失败:', err);
      setBalance('0');
    }
  }, [provider, account]);

  const connectWallet = useCallback(async () => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展!');
      return;
    }
    setIsConnecting(true);
    setError('');
    try {
      const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
      await initProviderAndSigner(accounts[0]);
      const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
      setChainId(parseInt(chainIdHex, 16));
    } catch (err: any) {
      if (err.code === 4001) {
        setError('连接请求被拒绝。');
      } else {
        setError(`连接失败: ${err.message}`);
      }
    } finally {
      setIsConnecting(false);
    }
  }, [initProviderAndSigner]);

  const disconnectWallet = useCallback(() => {
    setProvider(null);
    setSigner(null);
    setAccount('');
    setChainId(0);
    setBalance('0');
    setError('');
  }, []);

  // 初始化检查与事件监听
  useEffect(() => {
    if (!window.ethereum) {
      setError('未检测到 Web3 钱包。请安装 MetaMask。');
      return;
    }

    const checkInitialConnection = async () => {
      try {
        const accounts = await window.ethereum!.request({ method: 'eth_accounts' });
        if (accounts.length > 0) {
          await initProviderAndSigner(accounts[0]);
        }
        const chainIdHex = await window.ethereum!.request({ method: 'eth_chainId' });
        setChainId(parseInt(chainIdHex, 16));
      } catch (err) {
        console.error('初始连接检查出错:', err);
      }
    };

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnectWallet();
        setError('账户已断开。');
      } else if (accounts[0] !== account) {
        initProviderAndSigner(accounts[0]);
      }
    };

    const handleChainChanged = (_chainId: string) => {
      // 简单处理:刷新页面
      window.location.reload();
    };

    checkInitialConnection();

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

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

  // 余额监听
  useEffect(() => {
    fetchBalance();
  }, [fetchBalance]);

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>钱包连接状态</h2>
      {error && <p style={{ color: 'red' }}>错误: {error}</p>}
      
      {!account ? (
        <button onClick={connectWallet} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接 MetaMask'}
        </button>
      ) : (
        <div>
          <p><strong>连接地址:</strong> {`${account.substring(0, 6)}...${account.substring(account.length - 4)}`}</p>
          <p><strong>网络 ID:</strong> {chainId}</p>
          <p><strong>余额:</strong> {balance} ETH</p>
          <button onClick={disconnectWallet} style={{ marginTop: '10px' }}>
            断开连接(前端)
          </button>
          <p style={{ fontSize: '0.8em', color: '#666', marginTop: '5px' }}>
            (注:需在 MetaMask 中移除站点权限才能完全断开)
          </p>
        </div>
      )}
      
      <div style={{ marginTop: '20px', fontSize: '0.9em', color: '#333' }}>
        <p>试试以下操作,观察页面变化:</p>
        <ul>
          <li>在 MetaMask 中切换账户</li>
          <li>在 MetaMask 中切换网络(如 Goerli 测试网)</li>
          <li>刷新页面</li>
        </ul>
      </div>
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum 类型错误:在 TypeScript 中直接使用 window.ethereum 会报类型错误。我一开始用 (window as any).ethereum 粗暴解决,后来发现这不利于代码维护。最终通过扩展 global 接口提供了更精确的类型定义,并检查必要方法是否存在。
  2. accountsChanged 事件在断开时触发空数组:我最初只监听新账户,没处理 accounts.length === 0 的情况。导致用户在 MetaMask 里断开连接后,我的应用界面还显示着旧地址。加上这个判断后,体验才正常。
  3. 网络切换后 Provider 失效:这是我遇到最棘手的问题。用户切换网络后,旧的 provider 实例发出的请求可能仍发往旧的 RPC 节点,导致各种 UNSUPPORTED_OPERATION 或网络错误。我尝试过在 chainChanged 事件里创建新的 provider,但有时会碰到异步时序问题。最后,对于这个简单 demo,我采用了 MetaMask 官方早期文档推荐的页面刷新方案。在真实复杂项目中,需要结合项目状态管理库(如 Redux、Zustand)和自定义的多链 RPC 配置来更优雅地处理。
  4. 余额显示单位问题provider.getBalance() 返回的是 BigNumber 类型的 wei 单位。直接 toString() 会显示一长串数字。必须用 ethers.utils.formatEther() 进行单位转换。同时要注意转换后的精度显示,避免出现过多小数位。

小结

这次折腾让我彻底明白,一个稳定的钱包连接不仅仅是调用一个 API,而是一个需要持续维护状态、监听外部事件、并妥善处理各种边界情况的完整功能模块。虽然现在有 wagmiRainbowKit 这样优秀的封装库,但理解其底层原理,亲手用 ethers.js 实现一遍,对于排查复杂问题和构建定制化需求依然至关重要。下一步,我可以在此基础上集成多链支持、钱包连接缓存(localStorage)以及更优雅的网络切换处理逻辑。