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

20 阅读1分钟

背景

上个月,我接手了一个新的 DeFi 项目前端开发。第一个核心功能就是用户钱包登录。团队技术栈是 React + TypeScript,并且决定使用 ethers.js 这个老牌库来处理区块链交互,而不是较新的 viem。理由是我们的合约交互模式相对复杂,团队对 ethers 的 API 更熟悉,而且项目需要尽快上线。

“连接 MetaMask 嘛,不就是 window.ethereum.request({ method: 'eth_requestAccounts' }) 一下?” 我一开始也是这么想的,觉得这应该是最快完成的任务之一。然而,当我真正开始动手,试图构建一个在生产环境下稳定、用户体验良好的登录流程时,才发现里面门道不少,坑是一个接一个。

问题分析

我最开始的思路非常简单粗暴:在用户点击“连接钱包”按钮时,直接尝试获取 window.ethereum 对象,然后调用 request 方法。代码大概长这样:

const connectWallet = async () => {
  if (window.ethereum) {
    const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
    setAccount(accounts[0]);
  } else {
    alert('请安装 MetaMask!');
  }
};

很快,问题就来了:

  1. 类型错误:在 TypeScript 中,window 对象默认没有 ethereum 属性,直接使用会报错。
  2. Provider 注入时机:MetaMask 的 Provider 并不是页面一加载就立刻注入到 window.ethereum 的。如果用户安装了 MetaMask 但页面加载太快,脚本可能检测不到,误判为用户未安装。
  3. 事件监听缺失:用户切换账户、切换网络时,前端页面没有任何反应,状态不同步。
  4. 连接状态持久化:页面刷新后,登录状态丢失,用户需要重新点击连接。

这显然离“可用”还差得远。我意识到,我需要一个更系统的方法,来处理 Provider 的检测、账户和网络的监听、以及状态的持久化。我的目标升级为:实现一个类似 useWeb3Reactwagmi 提供的、封装良好的自定义 Hook。

核心实现

第一步:安全地获取 Provider 并检测钱包安装

首先,要解决 TypeScript 的类型问题和 Provider 的异步注入问题。我决定在 window 上扩展 ethereum 的类型定义。

这里有个坑:MetaMask 的 Provider 类型在不断演进。直接使用 any 类型会失去类型安全,最好从 @metamask/providersethers 库中引入正确的类型。

我选择在项目根目录创建一个 types/global.d.ts 文件进行类型声明:

// types/global.d.ts
import { MetaMaskInpageProvider } from '@metamask/providers';

declare global {
  interface Window {
    ethereum?: MetaMaskInpageProvider;
  }
}

然后,我创建了一个自定义 Hook useEthereum 来安全地处理和访问 Provider。关键点在于,不能只在组件挂载时检查一次 window.ethereum,因为 MetaMask 可能稍后才注入。一个更健壮的做法是监听 ethereum#initialized 事件(尽管这个事件并非所有版本都稳定),或者设置一个短暂的延迟重试机制。但在实践中,我发现对于大多数情况,在 useEffect 中检查并结合一个“安装 MetaMask”的引导按钮就足够了。

第二步:连接钱包并获取账户信息

连接钱包的核心是 eth_requestAccounts 方法,它会触发 MetaMask 的授权弹窗。但仅仅获取账户地址还不够,我们通常还需要获取当前链的 ID(网络)。

我封装了一个 connect 函数:

import { BrowserProvider } from 'ethers';

const connect = async (): Promise<{ account: string; chainId: bigint }> => {
  if (!window.ethereum) {
    throw new Error('MetaMask 未安装');
  }

  // 1. 请求账户访问权限
  const accounts = await window.ethereum.request({
    method: 'eth_requestAccounts',
  });

  if (!accounts || accounts.length === 0) {
    throw new Error('用户拒绝了连接请求或未选择账户');
  }
  const account = accounts[0];

  // 2. 获取当前网络链ID
  const chainIdHex = await window.ethereum.request({
    method: 'eth_chainId',
  });
  const chainId = BigInt(chainIdHex);

  return { account, chainId };
};

注意这个细节eth_chainId 返回的是十六进制字符串(如 “0x1”),而 ethers.js v6 在很多地方使用 bigint 类型来表示链 ID,所以这里进行了转换。

第三步:监听账户和网络变化

这是让应用状态与钱包状态保持同步的关键。MetaMask 的 Provider 提供了 accountsChangedchainChanged 事件。

import { useEffect } from 'react';

const useWalletEvents = (provider: any, setAccount: (acc: string) => void, setChainId: (id: bigint) => void) => {
  useEffect(() => {
    if (!provider) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('账户变更:', accounts);
      // 如果用户切换了账户,accounts[0] 是新账户
      // 如果用户在 MetaMask 中锁定了钱包或断开连接,accounts 会是空数组
      if (accounts.length === 0) {
        // 处理用户断开连接的情况
        setAccount('');
      } else {
        setAccount(accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      console.log('网络变更:', chainIdHex);
      // **重要!** 当网络变更时,MetaMask 建议页面重载
      // 但为了更好体验,我们可以只更新状态,并提示用户或重置相关合约实例
      // window.location.reload(); // 简单粗暴的方法
      const newChainId = BigInt(chainIdHex);
      setChainId(newChainId);
      // 通常这里还需要根据新的 chainId 更新 RPC Provider 和合约实例
    };

    provider.on('accountsChanged', handleAccountsChanged);
    provider.on('chainChanged', handleChainChanged);

    // 组件卸载时清理监听器
    return () => {
      provider.removeListener('accountsChanged', handleAccountsChanged);
      provider.removeListener('chainChanged', handleChainChanged);
    };
  }, [provider, setAccount, setChainId]);
};

这里有个大坑chainChanged 事件发生时,MetaMask 的官方文档建议直接 window.location.reload()。这是因为早期很多 dApp 的状态(特别是合约实例)严重依赖当前网络,不重载容易出错。但在现代前端架构中,我们可以通过更新状态、重新初始化 Provider 和合约来避免整页刷新,提供更流畅的体验。不过,这要求你的状态管理足够健壮。

第四步:状态持久化与初始化检查

用户刷新页面后,我们如何知道他之前已经连接过钱包?MetaMask 不会自动重新弹出授权窗口,但我们可以尝试获取已连接的账户。

我们可以使用 eth_accounts 方法,它不会弹出授权框,只会返回当前已授权的账户列表(如果用户已连接)。这非常适合在应用初始化时静默恢复登录状态。

const trySilentConnect = async (): Promise<{ account: string; chainId: bigint } | null> => {
  if (!window.ethereum) return null;

  try {
    const accounts = await window.ethereum.request({ method: 'eth_accounts' });
    if (accounts.length > 0) {
      const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
      return {
        account: accounts[0],
        chainId: BigInt(chainIdHex),
      };
    }
  } catch (err) {
    console.error('静默连接失败:', err);
  }
  return null;
};

在应用加载时(例如在 App.tsxuseEffect 或自定义 Hook 的初始化中)调用这个函数,就能实现“刷新页面保持登录状态”。

完整代码

下面是一个整合了以上所有思路的、相对完整的自定义 React Hook useMetaMask 示例:

// hooks/useMetaMask.ts
import { useEffect, useState, useCallback } from 'react';

export const useMetaMask = () => {
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<bigint>(0n);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 获取 Provider 的辅助函数
  const getProvider = () => {
    if (typeof window !== 'undefined' && window.ethereum) {
      return window.ethereum;
    }
    return null;
  };

  // 静默连接(用于初始化)
  const trySilentConnect = useCallback(async () => {
    const provider = getProvider();
    if (!provider) return;

    try {
      const accounts = await provider.request({ method: 'eth_accounts' });
      if (accounts.length > 0) {
        const chainIdHex = await provider.request({ method: 'eth_chainId' });
        setAccount(accounts[0]);
        setChainId(BigInt(chainIdHex));
        console.log('静默连接成功:', accounts[0]);
      }
    } catch (err) {
      console.error('静默连接失败:', err);
    }
  }, []);

  // 主动连接(用户点击按钮)
  const connect = useCallback(async () => {
    setError('');
    setIsConnecting(true);
    const provider = getProvider();
    if (!provider) {
      setError('请安装 MetaMask 浏览器扩展。');
      setIsConnecting(false);
      return;
    }

    try {
      // 请求账户
      const accounts = await provider.request({
        method: 'eth_requestAccounts',
      });
      if (!accounts || accounts.length === 0) {
        throw new Error('用户拒绝了连接请求。');
      }
      const newAccount = accounts[0];

      // 获取当前网络
      const chainIdHex = await provider.request({ method: 'eth_chainId' });
      const newChainId = BigInt(chainIdHex);

      setAccount(newAccount);
      setChainId(newChainId);
      console.log('连接成功:', newAccount, '网络:', newChainId);
    } catch (err: any) {
      console.error('连接失败:', err);
      setError(err.message || '连接钱包时发生未知错误。');
      // 可选:重置状态
      setAccount('');
      setChainId(0n);
    } finally {
      setIsConnecting(false);
    }
  }, []);

  // 断开连接(本质上是前端清除状态,因为 MetaMask 没有真正的“断开”RPC调用)
  const disconnect = useCallback(() => {
    setAccount('');
    setChainId(0n);
    setError('');
    console.log('已断开钱包连接(前端状态)');
  }, []);

  // 监听账户和网络变化
  useEffect(() => {
    const provider = getProvider();
    if (!provider) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('accountsChanged:', accounts);
      if (accounts.length === 0) {
        // 用户锁定了钱包或切走了所有账户
        disconnect(); // 调用我们自己的断开函数
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      console.log('chainChanged:', chainIdHex);
      // 更新链 ID,并可以在这里触发网络变更的副作用(如更新合约实例)
      setChainId(BigInt(chainIdHex));
      // 可以添加一个 toast 提示:“网络已切换至 xxx”
    };

    provider.on('accountsChanged', handleAccountsChanged);
    provider.on('chainChanged', handleChainChanged);

    // 应用启动时尝试静默连接
    trySilentConnect();

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

  return {
    account,
    chainId,
    isConnecting,
    error,
    connect,
    disconnect,
    isInstalled: !!getProvider(),
  };
};
// components/WalletConnector.tsx
import React from 'react';
import { useMetaMask } from '../hooks/useMetaMask';

const WalletConnector: React.FC = () => {
  const { account, chainId, isConnecting, error, connect, disconnect, isInstalled } = useMetaMask();

  // 将 bigint 链 ID 转换为可读名称
  const getNetworkName = (id: bigint) => {
    const map: Record<string, string> = {
      '0x1': '以太坊主网',
      '0xaa36a7': 'Sepolia测试网',
      '0x89': 'Polygon',
      '0x13881': 'Mumbai测试网',
    };
    return map[id.toString(16)] || `未知网络 (${id.toString()})`;
  };

  if (!isInstalled) {
    return (
      <div>
        <p>未检测到 MetaMask。请安装后刷新页面。</p>
        <a href="https://metamask.io/download/" target="_blank" rel="noreferrer">
          下载 MetaMask
        </a>
      </div>
    );
  }

  return (
    <div>
      {error && <div style={{ color: 'red' }}>错误: {error}</div>}

      {!account ? (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接 MetaMask'}
        </button>
      ) : (
        <div>
          <p>
            <strong>已连接账户:</strong> {`${account.slice(0, 6)}...${account.slice(-4)}`}
          </p>
          <p>
            <strong>当前网络:</strong> {getNetworkName(chainId)}
          </p>
          <button onClick={disconnect}>断开连接</button>
        </div>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum 类型错误:一开始在 TS 里直接写 window.ethereum 满屏红字。解决方案就是通过声明文件 (global.d.ts) 扩展 Window 接口。记得安装 @metamask/providers 包来获取准确类型。
  2. chainChanged 事件导致无限循环:早期版本中,我在 handleChainChanged 里更新了 chainId 状态,而这个状态又被用在 useEffect 的依赖数组中,导致状态更新 -> 副作用重新执行 -> 重新绑定事件... 形成了一个循环。后来我将事件处理函数用 useCallback 包裹,并确保依赖项正确,才解决了这个问题。
  3. 账户断开状态处理不当:当用户在 MetaMask 中点击“断开与此站点的连接”时,accountsChanged 事件会触发,并传入一个空数组 []。我最开始只是简单地 setAccount(accounts[0] || ''),这会导致 UI 显示空地址但其他状态还保留着。正确的做法是像上面代码一样,触发一个完整的“断开连接”流程,清除所有相关状态。
  4. BigInt 序列化问题:在 React 状态中直接存储 bigint 类型的 chainId 是没问题的,但如果你想把它存到 localStorage 或者通过 API 发送,就会遇到序列化错误(BigInt 不能直接 JSON.stringify)。我后来在需要持久化的地方,都将其转换为字符串 chainId.toString() 或十六进制 '0x' + chainId.toString(16)

小结

通过这一轮折腾,我深刻体会到,即使是一个看似简单的“连接钱包”功能,要做得健壮、用户体验好,也需要考虑 Provider 检测、异步连接、事件监听、状态持久化和错误处理等多个环节。封装成一个自定义 Hook 大大提升了代码的复用性和可维护性。下一步,我可以考虑在这个 Hook 基础上,集成 ethers.js 的 BrowserProvider 来直接提供签名和合约调用能力,或者加入对 WalletConnect 等其他连接方式的支持。