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

背景

上个月,我接手了一个新的 NFT 铸造平台前端项目。项目要求很简单:用户点击一个"连接钱包"按钮,弹出 MetaMask 进行连接和授权,然后前端获取到用户的以太坊地址并显示出来。这听起来是 Web3 开发的"Hello World",对吧?我心想,用老伙计 ethers.js 应该分分钟搞定。毕竟我之前在 DeFi 项目里用过很多次了。于是,我自信满满地开始敲代码,没想到接下来的几个小时,我几乎把 ethers.js 连接钱包的常见坑全踩了一遍。从 window.ethereumundefined 到账户切换监听失效,再到网络切换时的状态混乱,整个过程堪称一部小型历险记。

问题分析

我最开始的思路非常直接:在 React 组件的 useEffect 里,或者在一个按钮的点击事件中,直接调用 ethers.providers.Web3Provider 并传入 window.ethereum,然后调用 provider.send("eth_requestAccounts") 来请求账户。代码大概长这样:

javascript 复制代码
const connectWallet = async () => {
  if (window.ethereum) {
    const provider = new ethers.providers.Web3Provider(window.ethereum);
    const accounts = await provider.send("eth_requestAccounts", []);
    setAccount(accounts[0]);
  } else {
    alert('请安装 MetaMask!');
  }
};

但一运行就出问题了。首先,开发服务器热更新时,有时会报错 window.ethereum is undefined。其次,连接成功后,我切换到 MetaMask 的另一个账户,前端页面上的地址并没有自动更新。最后,当用户在 MetaMask 里切换网络(比如从 Goerli 切到 Mainnet),我的应用完全感知不到,还显示着旧网络下的状态。

我意识到,我把问题想得太简单了。一个健壮的钱包连接模块,至少需要处理三件事:1. Provider 的可靠获取 (处理未安装钱包、页面加载时机);2. 账户变化的监听 ;3. 网络变化的监听。而我最初的代码,只完成了最基础的"一次性连接"功能。

核心实现

第一步:安全地获取 Provider 并连接账户

首先,我们不能直接假设 window.ethereum 存在。用户可能没安装 MetaMask,或者我们的代码在服务器端渲染(SSR)时执行。所以,获取 Provider 的逻辑必须放在客户端生命周期内,并且做好错误处理。

这里有个坑: window.ethereum 的类型。在 TypeScript 中,直接访问会报错。我们需要扩展 Window 接口。同时,MetaMask 注入的 ethereum 对象有一个 request 方法,但 ethers.jsWeb3Provider 封装得很好,我们通常用 provider.sendprovider.getSigner

我的思路是:创建一个自定义 Hook,比如叫 useEthereumProvider,来安全地创建和管理 Provider 实例。

typescript 复制代码
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useEffect, useState } from 'react';

// 扩展 Window 接口以包含 ethereum
declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useEthereumProvider = () => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);

  useEffect(() => {
    // 确保在客户端环境下执行
    if (typeof window !== 'undefined' && window.ethereum) {
      // 注意:ethers v6 中,Web3Provider 已更名为 BrowserProvider
      const ethersProvider = new BrowserProvider(window.ethereum);
      setProvider(ethersProvider);

      // 尝试获取已连接的账户
      ethersProvider.getSigner().then(s => setSigner(s)).catch(console.error);
    }
  }, []); // 空依赖数组,仅初始化一次

  return { provider, signer };
};

注意,我用了 ethers v6 的 BrowserProvider。如果你还在用 v5,请使用 ethers.providers.Web3Provider。这个 Hook 在组件挂载时安全地初始化 Provider。

第二步:实现连接钱包函数

有了 Provider,接下来实现具体的连接函数。这个函数需要处理用户点击"连接钱包"按钮的动作。

typescript 复制代码
const [account, setAccount] = useState<string>('');
const { provider } = useEthereumProvider();

const handleConnect = async () => {
  if (!provider) {
    alert('未检测到钱包Provider,请确认MetaMask已安装');
    return;
  }

  try {
    // 请求账户访问权限。这里会弹出MetaMask授权窗口。
    const accounts = await provider.send('eth_requestAccounts', []);
    if (accounts && accounts[0]) {
      setAccount(accounts[0]);
      // 获取 Signer 实例,用于后续签名交易
      const signer = await provider.getSigner();
      // 你可以将 signer 存储到状态或 context 中
    }
  } catch (error: any) {
    console.error('连接钱包失败:', error);
    // 用户拒绝了请求
    if (error.code === 4001) {
      alert('您拒绝了连接请求。');
    }
  }
};

注意这个细节: provider.send('eth_requestAccounts', []) 是触发 MetaMask 弹出授权窗口的关键调用。它返回一个 Promise,用户授权后 resolve,拒绝后 reject 并带有错误码 4001

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

这是让应用"活"起来的关键。MetaMask 允许用户随时切换账户或网络,我们的前端需要实时响应。

window.ethereum 对象提供了 on 方法用于监听事件。主要监听两个事件:'accountsChanged''chainChanged'

typescript 复制代码
useEffect(() => {
  // 确保 ethereum 对象存在
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    console.log('accountsChanged', accounts);
    if (accounts.length === 0) {
      // 用户断开了连接,或者锁定了钱包
      setAccount('');
      alert('请连接您的钱包。');
    } else if (accounts[0] !== account) {
      // 切换到了新账户
      setAccount(accounts[0]);
      // 通常这里需要重新获取 Signer,因为账户变了
      if (provider) {
        provider.getSigner().then(newSigner => {
          // 更新 signer 状态
        });
      }
    }
  };

  const handleChainChanged = (_chainId: string) => {
    // _chainId 是十六进制字符串,例如 '0x1' (Mainnet)
    console.log('chainChanged', _chainId);
    // 当网络切换时,MetaMask 建议页面重载
    // 但为了更好体验,我们可以不重载,而是更新应用内的网络状态,并重置相关数据
    window.location.reload(); // 简单粗暴但有效
    // 更优方案:更新 networkId 状态,并重新初始化合约实例等
  };

  // 添加监听
  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, provider]); // 依赖 account 和 provider

这里有个大坑: 关于 chainChanged 事件的处理。MetaMask 官方文档早期建议在 chainChanged 时刷新页面,因为许多 dApp 的状态(如合约实例)依赖于网络。虽然不刷新也可以,但你需要手动更新所有依赖网络的状态。为了简单可靠,我选择了刷新页面。在更复杂的应用中,你可能需要设计一个状态管理系统来优雅地处理网络切换。

第四步:获取当前网络信息

除了账户,我们通常还需要知道用户当前连接到了哪个网络。

typescript 复制代码
const [chainId, setChainId] = useState<number | null>(null);
const { provider } = useEthereumProvider();

useEffect(() => {
  if (!provider) return;

  const fetchNetwork = async () => {
    try {
      const network = await provider.getNetwork();
      // network.chainId 是 BigInt 类型 (ethers v6)
      setChainId(Number(network.chainId));
    } catch (error) {
      console.error('获取网络信息失败:', error);
    }
  };

  fetchNetwork();
  // 注意:provider.getNetwork() 可能不会随 chainChanged 自动更新。
  // 所以我们依赖上一步的 chainChanged 事件,触发重新获取或页面刷新。
}, [provider]);

完整代码

下面是一个整合了以上所有功能的、可直接运行的 React 组件示例。

typescript 复制代码
// WalletConnector.tsx
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import React, { useEffect, useState } from 'react';

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

const WalletConnector: React.FC = () => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number | null>(null);
  const [loading, setLoading] = useState<boolean>(false);

  // 1. 初始化 Provider
  useEffect(() => {
    if (typeof window !== 'undefined' && window.ethereum) {
      const ethersProvider = new BrowserProvider(window.ethereum);
      setProvider(ethersProvider);
      // 尝试静默获取已连接的账户
      ethersProvider.getSigner()
        .then(s => {
          setSigner(s);
          s.getAddress().then(addr => setAccount(addr));
        })
        .catch(() => {/* 用户未连接,忽略错误 */});
    }
  }, []);

  // 2. 获取初始网络
  useEffect(() => {
    if (!provider) return;
    provider.getNetwork().then(network => {
      setChainId(Number(network.chainId));
    });
  }, [provider]);

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

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('账户变更:', accounts);
      if (accounts.length === 0) {
        // 断开连接
        setAccount('');
        setSigner(null);
        alert('钱包已断开。');
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
        // 更新 signer
        provider?.getSigner().then(s => setSigner(s));
      }
    };

    const handleChainChanged = (_chainId: string) => {
      console.log('网络变更:', _chainId);
      // 简单处理:刷新页面
      window.location.reload();
    };

    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, provider]);

  // 4. 连接钱包函数
  const handleConnect = async () => {
    if (!provider) {
      alert('请安装 MetaMask 钱包扩展!');
      return;
    }
    setLoading(true);
    try {
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      setAccount(currentAccount);
      const currentSigner = await provider.getSigner();
      setSigner(currentSigner);
      // 获取并更新网络
      const network = await provider.getNetwork();
      setChainId(Number(network.chainId));
    } catch (error: any) {
      console.error('连接失败:', error);
      if (error.code === 4001) {
        alert('连接请求被拒绝。');
      }
    } finally {
      setLoading(false);
    }
  };

  // 5. 断开连接 (MetaMask 没有真正的"断开",这里只是清除本地状态)
  const handleDisconnect = () => {
    setAccount('');
    setSigner(null);
    alert('已断开本地连接。如需完全断开,请在 MetaMask 中操作。');
  };

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h3>钱包连接状态</h3>
      {!provider ? (
        <p>⚠️ 未检测到钱包Provider。请确保 MetaMask 已安装并启用。</p>
      ) : (
        <>
          <p>
            <strong>网络ID:</strong> {chainId ? `0x${chainId.toString(16)}` : '未知'}
          </p>
          <p>
            <strong>当前账户:</strong> {account ? `${account.substring(0, 6)}...${account.substring(account.length - 4)}` : '未连接'}
          </p>
          <div>
            {!account ? (
              <button onClick={handleConnect} disabled={loading}>
                {loading ? '连接中...' : '连接 MetaMask'}
              </button>
            ) : (
              <div>
                <button onClick={handleDisconnect} style={{ marginLeft: '10px' }}>
                  断开连接
                </button>
              </div>
            )}
          </div>
          {signer && (
            <p style={{ marginTop: '10px', color: 'green' }}>
              ✅ Signer 已就绪,可进行签名操作。
            </p>
          )}
        </>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum is undefined (Next.js/SSR 环境)

    • 现象: 在 Next.js 项目中,组件首次渲染或热更新时控制台报错。
    • 原因: 代码在服务端或构建时执行,window 对象不存在。
    • 解决: 所有访问 window.ethereum 的代码都必须包裹在 if (typeof window !== 'undefined') 条件判断中,或放在 useEffect、事件处理函数等客户端生命周期钩子中。
  2. 账户切换后页面不更新

    • 现象: 在 MetaMask 里切换了账户,但 dApp 页面上显示的地址还是旧的。
    • 原因: 没有监听 accountsChanged 事件。
    • 解决: 按照上文所述,正确添加 window.ethereum.on('accountsChanged', callback) 监听,并在回调中更新 React 状态。注意: 当用户断开连接时,accounts 数组为空,需要处理这个情况。
  3. 网络切换后合约调用出错

    • 现象: 用户从 Goerli 切换到 Mainnet,dApp 仍尝试在旧网络的合约地址上调用,导致 RPC 错误。
    • 原因: 没有监听 chainChanged 事件,或监听后没有更新依赖网络的合约实例等状态。
    • 解决: 监听 chainChanged 事件。采用简单方案(刷新页面)或复杂方案(更新全局网络状态并重新初始化所有网络相关的对象,如 Provider、Signer、合约实例等)。
  4. ethers v5 与 v6 的 API 差异

    • 现象: 照着旧教程写代码,发现 Web3Provider 等类找不到。
    • 原因: 项目安装的是 ethers v6,其 API 有重大变更。
    • 解决: 查阅官方升级指南。关键变化:ethers.providers.Web3Provider 变为 ethers.BrowserProviderprovider.getSigner().getAddress() 返回 Promise;chainId 是 BigInt 类型。务必检查你使用的版本。

小结

通过这次实践,我深刻体会到 Web3 前端开发中"细节决定成败"。一个看似简单的钱包连接,需要考虑 Provider 的生命周期、用户交互的多种可能(授权、拒绝、切换)以及钱包状态的持续同步。最终稳定可用的代码,是初始化、请求授权、事件监听和状态管理四部分紧密协作的结果。如果你要在此基础上继续深挖,下一步可以考虑将钱包状态管理抽象为 Context 或使用状态管理库(如 Zustand、Jotai),以支持多组件共享,并集成更多钱包类型(如 WalletConnect)以实现更好的用户体验。

相关推荐
Pedantic22 分钟前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘38 分钟前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆1 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师2 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆2 小时前
VSCode自动格式化三要素
前端
爱勇宝3 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen3 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518135 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode5 小时前
Redis 在生产项目的使用
前端·后端