从“连接失败”到丝滑登录:我用 ethers.js v6 搞定 MetaMask 钱包连接的全过程

0 阅读1分钟

背景

上个月,我接手了一个 NFT 艺术平台的 MVP 开发。核心功能很简单:用户连接钱包,查看自己的 NFT,并进行铸造。产品经理说:“登录就用最经典的 MetaMask 连接,简单点。” 我想,这还不简单?用 ethers.js 几行代码的事。结果,从“简单连接”到“稳定可用的登录流程”,我花了整整一天半的时间,踩了好几个意想不到的坑。这篇文章,就是把我解决问题的过程原原本本地记录下来。

问题分析

我的第一版代码非常“教科书”:

import { ethers } from 'ethers';

const connectWallet = async () => {
  if (window.ethereum) {
    const provider = new ethers.BrowserProvider(window.ethereum);
    const signer = await provider.getSigner();
    const address = await signer.getAddress();
    console.log('Connected:', address);
    return address;
  } else {
    alert('请安装 MetaMask!');
  }
};

看起来没问题,对吧?但在实际测试中,问题接踵而至:

  1. 第一次点击连接,弹窗一闪而过,但状态没更新。 第二次点击才能成功。
  2. 用户如果拒绝了连接请求,页面没有任何反馈,就像什么都没发生。
  3. 用户在 MetaMask 里切换了账户或网络,我的前端页面完全感知不到,显示的还是旧信息。
  4. 代码里到处是 window.ethereum 的类型断言 as any,TypeScript 疯狂报红。

我意识到,我实现的只是一个“一次性连接动作”,而不是一个“可持续管理的钱包连接状态”。真正的生产环境需要的是一个健壮的、能应对各种用户操作和钱包状态变化的登录系统。

核心实现

第一步:安全地获取 Provider 和 处理类型

首先,要解决 window.ethereum 的类型问题。直接使用 any 会丢失类型安全和 IDE 提示。ethers.js v6 推荐从 window.ethereum 创建 BrowserProvider

这里有个坑: window.ethereum 可能不存在(用户没装钱包),也可能是数组(多个钱包注入)。我们需要安全地处理。

// utils/ethers.ts
import { BrowserProvider, Eip1193Provider } from 'ethers';

// 声明全局的 ethereum 类型
declare global {
  interface Window {
    ethereum?: Eip1193Provider;
  }
}

/**
 * 获取安全的 Ethers BrowserProvider
 * @returns {BrowserProvider | null} 返回 Provider 或 null
 */
export const getEthersProvider = (): BrowserProvider | null => {
  // 检查 window.ethereum 是否存在
  if (typeof window !== 'undefined' && window.ethereum) {
    try {
      // ethers v6 使用 BrowserProvider 包装 window.ethereum
      return new BrowserProvider(window.ethereum);
    } catch (error) {
      console.error('创建 Provider 失败:', error);
      return null;
    }
  }
  console.warn('未检测到钱包扩展(如 MetaMask)。');
  return null;
};

第二步:实现核心连接函数,处理用户拒绝

连接钱包的核心是请求账户访问权限。provider.send('eth_requestAccounts', []) 这个方法会触发 MetaMask 弹窗。这里有个关键细节: 必须妥善处理用户点击“拒绝”的情况。

// hooks/useWallet.ts
import { useState, useCallback } from 'react';
import { getEthersProvider } from '../utils/ethers';

export const useWallet = () => {
  const [account, setAccount] = useState<string | null>(null);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError(null); // 清除旧错误

    const provider = getEthersProvider();
    if (!provider) {
      setError('请安装 MetaMask 钱包扩展。');
      setIsConnecting(false);
      return;
    }

    try {
      // 关键步骤:请求账户访问,这会弹出 MetaMask 授权窗口
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      
      if (currentAccount) {
        setAccount(currentAccount);
        console.log('钱包连接成功:', currentAccount);
      } else {
        setError('未获取到有效账户。');
      }
    } catch (err: any) {
      // **重点:处理用户拒绝等错误**
      console.error('连接钱包失败:', err);
      if (err.code === 4001) {
        // 4001 是用户拒绝连接的错误码
        setError('您拒绝了钱包连接请求。');
      } else {
        setError(`连接失败: ${err.message || '未知错误'}`);
      }
    } finally {
      setIsConnecting(false);
    }
  }, []);

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

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

用户不会一直待在同一个账户或网络上。他们可能在 MetaMask 里切换账户,或者从以太坊主网切换到 Polygon。我们的前端必须能实时响应这些变化。

注意这个细节: 监听事件要在连接成功后设置,并且在组件卸载时清理,防止内存泄漏。

// 在 useWallet 的 connectWallet 函数成功连接后,添加监听逻辑
const setupEventListeners = useCallback((provider: BrowserProvider) => {
  // 注意:ethers v6 的 provider 底层是 EIP-1193 的 provider
  const ethereum = window.ethereum;
  if (!ethereum) return;

  // 监听账户变化
  const handleAccountsChanged = (accounts: string[]) => {
    console.log('账户变化:', accounts);
    if (accounts.length === 0) {
      // 用户锁定了钱包或切换了所有账户
      setAccount(null);
      setError('钱包已断开连接。');
    } else if (accounts[0] !== account) {
      // 切换到新账户
      setAccount(accounts[0]);
    }
  };

  // 监听链ID变化(网络切换)
  const handleChainChanged = (_chainId: string) => {
    // 根据规范,当链发生变化时,应重置页面状态或重新加载
    // 一个常见的做法是提示用户或自动刷新
    console.log('网络已切换,链ID:', _chainId);
    // 简单处理:直接重置账户,需要用户重新连接(或设计更优雅的流程)
    setAccount(null);
    window.location.reload(); // 许多 DApp 选择刷新页面
  };

  // 添加监听
  ethereum.on('accountsChanged', handleAccountsChanged);
  ethereum.on('chainChanged', handleChainChanged);

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

// 然后在 connectWallet 成功连接后调用
// const cleanup = setupEventListeners(provider);
// 注意:需要在 React useEffect 或组件卸载逻辑中执行 cleanup()

在实际的 React Hook 实现中,我们需要使用 useEffect 来管理这些副作用的生命周期。

第四步:整合成可复用的 React Hook

将以上所有逻辑整合到一个完整的、易于使用的自定义 Hook 中。

// hooks/useWallet.ts (完整版)
import { useState, useCallback, useEffect, useRef } from 'react';
import { BrowserProvider } from 'ethers';
import { getEthersProvider } from '../utils/ethers';

export const useWallet = () => {
  const [account, setAccount] = useState<string | null>(null);
  const [chainId, setChainId] = useState<bigint | null>(null);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  // 使用 ref 存储清理函数,避免重复绑定事件
  const cleanupRef = useRef<(() => void) | null>(null);

  // 1. 初始化:检查是否已授权连接
  useEffect(() => {
    const checkIfWalletIsConnected = async () => {
      const provider = getEthersProvider();
      if (!provider) return;

      try {
        const accounts = await provider.send('eth_accounts', []); // 静默获取,不弹窗
        if (accounts.length > 0) {
          setAccount(accounts[0]);
          const network = await provider.getNetwork();
          setChainId(network.chainId);
          // 为已连接的账户设置监听
          setupEventListeners(provider);
        }
      } catch (err) {
        console.warn('检查已连接账户时出错:', err);
      }
    };

    checkIfWalletIsConnected();
  }, []);

  // 2. 设置事件监听器的函数
  const setupEventListeners = useCallback((provider: BrowserProvider) => {
    const ethereum = window.ethereum;
    if (!ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('accountsChanged:', accounts);
      if (accounts.length === 0) {
        setAccount(null);
        setError('钱包已断开。');
      } else {
        setAccount(accounts[0]);
        setError(null);
      }
    };

    const handleChainChanged = (_chainId: string) => {
      console.log('chainChanged:', _chainId);
      // 网络切换后,建议刷新页面或重新获取所有数据
      window.location.reload();
    };

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

    // 存储清理函数
    cleanupRef.current = () => {
      ethereum.removeListener('accountsChanged', handleAccountsChanged);
      ethereum.removeListener('chainChanged', handleChainChanged);
    };
  }, []);

  // 3. 核心连接函数
  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError(null);

    const provider = getEthersProvider();
    if (!provider) {
      setError('请安装 MetaMask。');
      setIsConnecting(false);
      return;
    }

    // 先清理旧监听(如果存在)
    if (cleanupRef.current) {
      cleanupRef.current();
      cleanupRef.current = null;
    }

    try {
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      
      if (currentAccount) {
        setAccount(currentAccount);
        const network = await provider.getNetwork();
        setChainId(network.chainId);
        // 设置新监听
        setupEventListeners(provider);
      }
    } catch (err: any) {
      console.error('连接失败:', err);
      if (err.code === 4001) {
        setError('连接请求被拒绝。');
      } else {
        setError(err.message || '未知连接错误');
      }
    } finally {
      setIsConnecting(false);
    }
  }, [setupEventListeners]);

  // 4. 断开连接(对于 MetaMask,更多是前端状态清除)
  const disconnectWallet = useCallback(() => {
    setAccount(null);
    setChainId(null);
    setError(null);
    if (cleanupRef.current) {
      cleanupRef.current();
      cleanupRef.current = null;
    }
    console.log('钱包已断开(前端状态)');
    // 注意:MetaMask 无法通过代码真正“断开”,只能前端清除状态。
    // 用户需要自己在 MetaMask 中切换账户或锁定钱包。
  }, []);

  // 5. 组件卸载时清理监听
  useEffect(() => {
    return () => {
      if (cleanupRef.current) {
        cleanupRef.current();
      }
    };
  }, []);

  return {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
    isConnected: !!account, // 便捷的布尔状态
  };
};

完整代码

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

// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';

const WalletConnector: React.FC = () => {
  const {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
    isConnected,
  } = useWallet();

  // 格式化地址:0x1234...5678
  const formatAddress = (addr: string | null) => {
    if (!addr) return '';
    return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`;
  };

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>Web3 钱包连接示例</h2>
      
      {error && (
        <div style={{ color: 'red', marginBottom: '10px', padding: '10px', background: '#ffe6e6' }}>
          <strong>错误:</strong> {error}
        </div>
      )}

      <div style={{ marginBottom: '15px' }}>
        <strong>连接状态:</strong> 
        {isConnected ? (
          <span style={{ color: 'green' }}>已连接</span>
        ) : (
          <span style={{ color: 'orange' }}>未连接</span>
        )}
      </div>

      {isConnected && account ? (
        <div>
          <div style={{ marginBottom: '10px' }}>
            <strong>账户地址:</strong> 
            <code>{formatAddress(account)}</code> ({account})
          </div>
          <div style={{ marginBottom: '15px' }}>
            <strong>当前链ID:</strong> 
            <code>{chainId?.toString() || '未知'}</code>
          </div>
          <button
            onClick={disconnectWallet}
            style={{
              padding: '10px 20px',
              background: '#ff6b6b',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            断开连接
          </button>
          <p style={{ fontSize: '0.9em', color: '#666', marginTop: '10px' }}>
            (提示:此操作仅清除前端状态。如需完全断开,请在 MetaMask 中锁定钱包。)
          </p>
        </div>
      ) : (
        <button
          onClick={connectWallet}
          disabled={isConnecting}
          style={{
            padding: '12px 24px',
            background: isConnecting ? '#ccc' : '#4CAF50',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: isConnecting ? 'not-allowed' : 'pointer',
            fontSize: '16px',
          }}
        >
          {isConnecting ? '连接中...' : '连接 MetaMask 钱包'}
        </button>
      )}

      {!window.ethereum && (
        <div style={{ marginTop: '20px', padding: '15px', background: '#fff3cd', borderRadius: '4px' }}>
          <p>⚠️ 未检测到 Web3 钱包。</p>
          <p>
            请安装 <a href="https://metamask.io/" target="_blank" rel="noopener noreferrer">MetaMask</a> 或其他兼容的以太坊钱包扩展。
          </p>
        </div>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. ethers.providers.Web3Provider 在 v6 中已废弃

    • 报错: ethers.providers.Web3Provider is not a constructor
    • 原因: 我一开始照着 v5 的文档写,但项目安装的是 v6。
    • 解决: 在 ethers.js v6 中,应使用 new ethers.BrowserProvider(window.ethereum)
  2. 用户拒绝连接后,再次连接无反应

    • 现象: 用户第一次点击“拒绝”后,再次点击连接按钮,MetaMask 不再弹窗。
    • 原因: MetaMask 会“记住”用户的拒绝操作。eth_requestAccounts 在用户拒绝后,短时间内再次调用不会触发弹窗。
    • 解决: 在 UI 上明确提示用户“您已拒绝,如需连接请刷新页面或手动在 MetaMask 中授权”,或者引导用户点击 MetaMask 扩展图标重新授权。这是一个产品层面的设计选择。
  3. 事件监听器重复绑定导致内存泄漏和多次触发

    • 现象: 切换账户时,控制台打印了多次 accountsChanged 日志。
    • 原因: 每次调用 connectWallet 或组件重新渲染时,没有清理旧的事件监听器,导致同一个函数被绑定了多次。
    • 解决: 使用 useRef 存储清理函数,在绑定新监听前执行旧的清理函数,并在组件卸载时确保清理。
  4. TypeScript 类型 window.ethereum 报错

    • 报错: Property 'ethereum' does not exist on type 'Window & typeof globalThis'.
    • 解决: 在全局声明文件中(或当前文件顶部)使用 declare global 扩展 Window 接口,并赋予其 Eip1193Provider 类型(这是 ethers v6 推荐的类型)。这提供了完美的类型安全和代码提示。

小结

通过这次实战,我深刻体会到,一个生产级的 Web3 钱包连接,远不止调用一个 API 那么简单。它需要健壮的错误处理、实时的状态监听、清晰的用户反馈和安全的类型定义。现在,我把这个打磨好的 useWallet Hook 放进了我的项目工具箱里,下次遇到类似需求,就能从容应对了。当然,这只是一个起点,后续还可以在此基础上集成更多功能,比如自动切换至指定测试网、获取用户签名消息、与后端进行登录验证等。