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

背景

上个月,我接手了一个新的 NFT 铸造平台前端开发。项目要求很简单:用户进入网站,点击"连接钱包"按钮,用 MetaMask 登录,然后页面显示其钱包地址和 ETH 余额。这听起来是 Web3 开发的"Hello World",对吧?我心想,用老伙计 ethers.js 分分钟搞定。毕竟之前参与 DeFi 项目时也用过,感觉轻车熟路。于是,我新建了一个 React 项目,安装好 ethers,开始撸代码。没想到,就是这个看似简单的任务,让我在接下来的几个小时里,跟各种奇怪的错误和浏览器行为斗智斗勇。

问题分析

我最开始的思路非常直接:检查 window.ethereum 是否存在(这是 MetaMask 注入的对象),然后用 ethers.providers.Web3Provider 包装它,最后调用 provider.send('eth_requestAccounts') 来请求账户授权。代码一气呵成,运行,点击按钮------控制台一片寂静,页面毫无反应。

我第一反应是 MetaMask 没安装?检查了一下,扩展明明好好的。然后我加了一堆 console.log,发现 window.ethereum 确实是存在的。那问题出在哪?我仔细阅读了 ethers.js 文档,发现了一个关键点:MetaMask 从 v8 开始,window.ethereum 的 API 发生了变化,它现在是一个 EIP-1193 规范的 Provider,而 ethers.jsWeb3Provider 正是为了适配这种规范而设计的。我的思路没错啊。

接着,我尝试在按钮点击事件里直接写:

javascript 复制代码
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });

这次弹窗出来了!这说明基础连接请求是通的。那么问题就锁定在 ethers.js 的用法上了。我意识到,我可能忽略了异步状态和 React 生命周期的配合,以及一些错误处理的边界情况。是时候重新梳理,一步步构建一个健壮的连接流程了。

核心实现

第一步:检测 Provider 与浏览器兼容性

首先,我们不能假设用户一定安装了 MetaMask。因此,连接之前必须先做检测。同时,现代 MetaMask 也可能同时注入 window.ethereum 和旧的 window.web3,我们应该优先使用新的。

javascript 复制代码
// 检测函数
const checkIfMetaMaskInstalled = () => {
  // 检查是否有 EIP-1193 规范的 provider
  if (window.ethereum && window.ethereum.isMetaMask) {
    return true;
  }
  // 如果用户使用的是非常老的版本,可能只有 window.web3
  if (window.web3 && window.web3.currentProvider) {
    console.warn('检测到旧版 MetaMask,建议用户升级。');
    // 这里可以做一些降级处理,但为了简单,我们先返回false引导用户
    return false;
  }
  return false;
};

这里有个坑 :仅仅检查 window.ethereum 是不够的,因为其他钱包(如 Coinbase Wallet)也可能注入这个对象。所以加上 window.ethereum.isMetaMask 属性判断更准确。但注意,这个属性是 MetaMask 特有的。

第二步:初始化 Ethers Provider 和 Signer

检测通过后,我们需要初始化 ethers 的核心对象:Provider 和 Signer。Provider 是连接区块链的抽象,Signer 代表一个有签名权限的账户。

typescript 复制代码
import { ethers } from 'ethers';

const initializeProviderAndSigner = async () => {
  // 再次确认,避免竞态条件
  if (!window.ethereum) {
    throw new Error('请安装 MetaMask!');
  }

  // 1. 创建 Web3Provider
  // 注意:ethers v5 和 v6 的导入方式不同,这里是 v5
  const provider = new ethers.providers.Web3Provider(window.ethereum, 'any'); // 'any' 表示支持任何网络

  // 2. 请求账户授权,这会弹出 MetaMask 窗口
  await provider.send('eth_requestAccounts', []);

  // 3. 获取 Signer
  const signer = provider.getSigner();

  // 4. 获取当前账户地址
  const address = await signer.getAddress();

  return { provider, signer, address };
};

注意这个细节new ethers.providers.Web3Provider(window.ethereum, 'any') 中的第二个参数 'any'。这是网络配置,'any' 允许任何网络。如果你只支持特定网络(如主网),可以传入 'homestead'。使用 'any' 能让用户在切换网络(比如从以太坊主网切换到 Polygon)时,我们的 provider 能自动适应,而不会报错。

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

用户可能在连接后切换 MetaMask 账户,或者切换网络。如果我们的前端没有监听这些事件,状态就会不同步,导致显示错误的地址或余额。

typescript 复制代码
const setupEventListeners = (provider: ethers.providers.Web3Provider, updateAccountCallback: (address: string) => void) => {
  // 监听 accountsChanged 事件(用户切换账户)
  window.ethereum.on('accountsChanged', (accounts: string[]) => {
    if (accounts.length === 0) {
      // 用户锁定了钱包或断开了所有账户
      console.log('请连接钱包');
      updateAccountCallback('');
    } else {
      // 账户切换了
      console.log('当前账户变为:', accounts[0]);
      updateAccountCallback(accounts[0]);
      // 注意:这里不需要再次请求授权(eth_requestAccounts)
    }
  });

  // 监听 chainChanged 事件(用户切换网络)
  window.ethereum.on('chainChanged', (_chainId: string) => {
    // 链ID是十六进制字符串,例如"0x1"(主网)
    console.log('网络切换,新的Chain ID:', _chainId);
    // 页面完全重载是最简单的方式,因为很多合约实例、provider都需要重新初始化
    window.location.reload();
  });
};

这里有个大坑chainChanged 事件触发后,简单的更新状态可能不够。因为网络变了,之前初始化的 Provider 实例内部可能还缓存着旧网络的 RPC 信息,直接使用可能导致后续的 RPC 调用发往错误的网络。最稳妥的办法是刷新页面,让所有组件重新初始化。虽然体验略有中断,但能保证状态绝对干净。在更复杂的 DApp 中,你可能需要设计一个更精细的状态管理方案来优雅地处理网络切换。

第四步:获取账户余额并整合到 React 状态

最后,我们把上面的功能整合到一个 React 组件中,并获取账户的 ETH 余额。

typescript 复制代码
import { useState, useEffect, useCallback } from 'react';

const useMetaMask = () => {
  const [account, setAccount] = useState<string>('');
  const [balance, setBalance] = useState<string>('');
  const [isConnecting, setIsConnecting] = useState<boolean>(false);
  const [error, setError] = useState<string>('');

  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError('');
    try {
      if (!checkIfMetaMaskInstalled()) {
        throw new Error('未检测到 MetaMask,请安装后重试。');
      }

      const { provider, signer, address } = await initializeProviderAndSigner();
      setAccount(address);

      // 获取余额
      const balanceRaw = await provider.getBalance(address);
      const balanceFormatted = ethers.utils.formatEther(balanceRaw);
      setBalance(balanceFormatted);

      // 设置事件监听
      setupEventListeners(provider, (newAddress) => {
        setAccount(newAddress);
        if (newAddress) {
          // 如果切换到了新账户,重新获取余额
          provider.getBalance(newAddress).then(bal => setBalance(ethers.utils.formatEther(bal)));
        } else {
          setBalance('');
        }
      });

    } catch (err: any) {
      console.error('连接钱包失败:', err);
      setError(err.message || '连接失败');
      setAccount('');
      setBalance('');
    } finally {
      setIsConnecting(false);
    }
  }, []); // 依赖项为空,因为这个函数只在初始化时定义一次

  // 组件卸载时,移除事件监听(避免内存泄漏)
  useEffect(() => {
    return () => {
      if (window.ethereum && window.ethereum.removeListener) {
        // 注意:ethers provider 包装后,事件源还是 window.ethereum
        window.ethereum.removeAllListeners('accountsChanged');
        window.ethereum.removeAllListeners('chainChanged');
      }
    };
  }, []);

  return { account, balance, isConnecting, error, connectWallet };
};

注意这个细节 :获取的余额是 BigNumber 类型,单位是 wei(1 ETH = 10^18 wei)。必须用 ethers.utils.formatEther 将其转换为可读的 ETH 单位字符串。另外,错误处理非常重要,要把 MetaMask 抛出的错误(比如用户拒绝连接)友好地展示给用户。

完整代码

下面是一个可以直接在 React 项目中使用的完整组件示例:

tsx 复制代码
// MetaMaskConnector.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

// 类型声明
declare global {
  interface Window {
    ethereum?: any;
    web3?: any;
  }
}

const MetaMaskConnector: React.FC = () => {
  const [account, setAccount] = useState<string>('');
  const [balance, setBalance] = useState<string>('');
  const [isConnecting, setIsConnecting] = useState<boolean>(false);
  const [error, setError] = useState<string>('');
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);

  // 1. 检测 MetaMask
  const checkIfMetaMaskInstalled = useCallback((): boolean => {
    return !!(window.ethereum && window.ethereum.isMetaMask);
  }, []);

  // 2. 初始化
  const initializeWallet = useCallback(async () => {
    if (!window.ethereum) throw new Error('未安装 MetaMask');

    const prov = new ethers.providers.Web3Provider(window.ethereum, 'any');
    await prov.send('eth_requestAccounts', []);
    const signer = prov.getSigner();
    const address = await signer.getAddress();

    return { prov, address };
  }, []);

  // 3. 连接钱包主函数
  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError('');
    try {
      if (!checkIfMetaMaskInstalled()) {
        throw new Error('请安装 MetaMask 浏览器扩展。');
      }

      const { prov, address } = await initializeWallet();
      setProvider(prov);
      setAccount(address);

      // 获取余额
      const balanceRaw = await prov.getBalance(address);
      setBalance(ethers.utils.formatEther(balanceRaw));

    } catch (err: any) {
      console.error('连接失败:', err);
      setError(err.message || '未知错误');
      setAccount('');
      setBalance('');
    } finally {
      setIsConnecting(false);
    }
  }, [checkIfMetaMaskInstalled, initializeWallet]);

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

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        // 用户锁定了钱包
        setAccount('');
        setBalance('');
        setError('钱包已断开连接。');
      } else if (accounts[0] !== account) {
        // 切换了账户
        setAccount(accounts[0]);
        provider.getBalance(accounts[0]).then(bal => {
          setBalance(ethers.utils.formatEther(bal));
        });
      }
    };

    const handleChainChanged = () => {
      // 网络切换,建议刷新页面
      window.location.reload();
    };

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

    // 清理函数
    return () => {
      if (window.ethereum && window.ethereum.removeListener) {
        window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
        window.ethereum.removeListener('chainChanged', handleChainChanged);
      }
    };
  }, [provider, account]);

  // 5. 页面加载时尝试自动连接(可选,谨慎使用)
  useEffect(() => {
    const tryAutoConnect = async () => {
      if (checkIfMetaMaskInstalled() && window.ethereum.isConnected()) {
        // 检查是否已经授权过
        const accounts = await window.ethereum.request({ method: 'eth_accounts' });
        if (accounts.length > 0) {
          // 自动连接
          connectWallet();
        }
      }
    };
    tryAutoConnect();
  }, [checkIfMetaMaskInstalled, connectWallet]);

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h2>MetaMask 钱包连接示例</h2>
      {!account ? (
        <div>
          <button
            onClick={connectWallet}
            disabled={isConnecting}
            style={{
              padding: '10px 20px',
              fontSize: '16px',
              cursor: isConnecting ? 'wait' : 'pointer',
            }}
          >
            {isConnecting ? '连接中...' : '连接 MetaMask'}
          </button>
          {error && <p style={{ color: 'red' }}>错误: {error}</p>}
        </div>
      ) : (
        <div>
          <p><strong>连接成功!</strong></p>
          <p><strong>账户地址:</strong> {`${account.substring(0, 6)}...${account.substring(account.length - 4)}`}</p>
          <p><strong>ETH 余额:</strong> {parseFloat(balance).toFixed(4)} ETH</p>
          <button
            onClick={() => {
              setAccount('');
              setBalance('');
              setError('');
            }}
            style={{ marginTop: '10px', padding: '5px 10px' }}
          >
            断开连接(前端)
          </button>
          <p style={{ fontSize: '12px', color: '#666' }}>
            (注意:这只是前端清除状态,MetaMask 中的连接授权仍需在其界面内管理)
          </p>
        </div>
      )}
      {!checkIfMetaMaskInstalled() && (
        <p style={{ color: 'orange', marginTop: '10px' }}>
          未检测到 MetaMask。请
          <a href="https://metamask.io/download/" target="_blank" rel="noopener noreferrer">下载安装</a>
          后刷新页面。
        </p>
      )}
    </div>
  );
};

export default MetaMaskConnector;

踩坑记录

  1. window.ethereumundefined,但 MetaMask 已安装。

    • 问题 :在 Next.js 或 SSR 框架中,代码可能在服务器端执行,那里没有 window 对象。
    • 解决 :所有对 window.ethereum 的访问都必须放在 useEffect 中或通过 typeof window !== 'undefined' 进行保护。
  2. 用户拒绝连接后,再次点击按钮无效。

    • 问题 :MetaMask 会记住用户的拒绝操作,短时间内再次调用 eth_requestAccounts 不会弹出窗口。
    • 解决:引导用户点击 MetaMask 扩展图标,在弹出界面中手动重置已拒绝的站点授权。这是 MetaMask 的用户体验设计,前端无法绕过。
  3. accountsChanged 事件在初次连接时也触发了。

    • 问题 :有些版本的 MetaMask 在用户授权账户后,会立即触发一次 accountsChanged 事件,导致事件处理函数和初始化逻辑重复执行。
    • 解决:在事件处理函数中,通过对比新旧账户地址来判断是初次连接还是主动切换。如果旧地址为空字符串,新地址有值,可以视为初次连接的一部分,避免不必要的状态更新或重复请求。
  4. 余额显示为巨大的整数。

    • 问题 :直接 console.logprovider.getBalance() 获取的结果,显示为一个包含 hex 属性的对象或一个巨大的数字。
    • 解决 :这是 ethers.jsBigNumber 类型。必须使用 ethers.utils.formatEther 进行单位转换。我差点自己写转换函数,幸好查了文档。

小结

通过这次实践,我深刻体会到 Web3 前端开发中"细节决定成败"------Provider的初始化参数、事件监听的绑定与清理、异步错误处理,每一个环节疏忽都可能导致功能失效。完整的钱包连接不仅仅是弹出授权窗口,更要考虑用户后续的所有操作路径。下一步,我可以在此基础上集成合约调用、签名消息等功能,并考虑用 wagmi 这样的高阶库来管理更复杂的状态。

相关推荐
猩猩程序员2 小时前
Pretext:一个绕过 DOM 的纯 JavaScript 排版引擎
前端
神舟之光2 小时前
jwt权限控制简单总结(乡村意见簿-vue-express-mongdb)
前端·vue.js·express
铭毅天下2 小时前
EasySearch Rules 规则语法速查手册
开发语言·前端·javascript·ecmascript
GISer_Jing2 小时前
AI Agent操作系统架构师:Harness Engineer解析
前端·人工智能·ai·aigc
英俊潇洒美少年2 小时前
css中专门用来提升渲染性能、减少重排重绘的属性
前端·css
天若有情6732 小时前
前端HTML精讲01:别再乱 div 一把抓,吃透语义化标签才是进阶第一步
前端·html
Highcharts.js2 小时前
React 开发者的图表库生态:Highcharts React
前端·react.js·前端框架
阿部多瑞 ABU2 小时前
文明文化悖论
前端·人工智能·ai写作
钛态3 小时前
Flutter 三方库 react 泛前端核心范式框架鸿蒙原生层生态级双向超能适配:跨时空重塑响应式单向数据流拓扑与高度精密生命周期树引擎解耦视图渲染控制中枢(适配鸿蒙 HarmonyOS ohos)
前端·flutter·react.js