用 ethers.js 连接 MetaMask 实现钱包登录:一个真实项目中的完整踩坑记录

背景:一个看似简单的需求

上个月,我接了一个 DeFi 流动性看板项目的前端任务。需求很明确:用户通过 MetaMask 钱包登录后,可以查看自己在不同链上的流动性池状态。团队后端用 Node.js,前端是 React + TypeScript,钱包交互部分交给我负责。

我心想,这不就是调用 window.ethereum 然后 eth_requestAccounts 吗?网上教程铺天盖地,半天应该能搞定。结果,我花了整整两天,踩了至少 5 个坑,才真正跑通一个生产可用的登录流程。

问题分析:为什么简单的连接会翻车

最初我按常规思路写:

typescript 复制代码
// 错误示例 - 直接调 MetaMask
const accounts = await window.ethereum.request({ 
  method: 'eth_requestAccounts' 
});

本地测试时一切正常,但一放到生产环境就出问题:

  1. 没有检测 MetaMask 是否安装 :用户如果没装 MetaMask,直接调用 window.ethereum 会报 Cannot read properties of undefined
  2. 链 ID 没处理:用户连接的是以太坊主网,但我的项目需要的是 BSC,结果交易一直失败。
  3. 账号切换没监听:用户换了钱包账号,前端还显示旧地址。
  4. 签名验证太随意 :我用 eth_sign 直接签字符串,用户根本不知道在签什么。

这些坑在教程里很少被提及,但实际项目中每一个都能让你 debug 到怀疑人生。

核心实现:从零搭建一个稳健的钱包登录系统

1. 检测 MetaMask 安装与浏览器兼容

第一步不是连接,而是确认环境。我封装了一个 isMetaMaskInstalled 函数,处理了三种情况:未安装、版本过低、非浏览器环境。

typescript 复制代码
// utils/wallet.ts
export function isMetaMaskInstalled(): { installed: boolean; error?: string } {
  // 非浏览器环境
  if (typeof window === 'undefined') {
    return { installed: false, error: '当前非浏览器环境' };
  }
  
  // 检查 ethereum 对象
  if (!window.ethereum) {
    return { installed: false, error: '请安装 MetaMask 钱包' };
  }
  
  // 检查是否为 MetaMask(避免其他钱包冒充)
  if (!window.ethereum.isMetaMask) {
    return { installed: false, error: '请使用 MetaMask 钱包' };
  }
  
  // 检查版本(可选,建议 >= 10.0.0)
  const version = window.ethereum.version || '';
  if (version && parseFloat(version) < 10) {
    return { installed: false, error: 'MetaMask 版本过低,请升级' };
  }
  
  return { installed: true };
}

这里有个坑 :不要直接用 window.ethereum 判断,因为某些钱包(如 Coinbase Wallet)也会注入 ethereum 对象。用 isMetaMask 属性可以精准判断。我当时就因为没加这个判断,用户装了 Coinbase Wallet 却提示 MetaMask 已安装,导致后续连接失败。

2. 连接钱包与获取账号

检测通过后,才是真正的连接。我用 ethers.jsBrowserProvider 来封装连接逻辑,这样后续的合约交互也统一。

typescript 复制代码
// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useCallback } from 'react';

export function useWallet() {
  const [address, setAddress] = useState<string>('');
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [chainId, setChainId] = useState<number>(0);
  const [error, setError] = useState<string>('');

  const connect = useCallback(async () => {
    try {
      // 1. 检测安装
      const check = isMetaMaskInstalled();
      if (!check.installed) {
        setError(check.error || '');
        return;
      }

      // 2. 创建 BrowserProvider(ethers v6 的新 API)
      const browserProvider = new BrowserProvider(window.ethereum);
      setProvider(browserProvider);

      // 3. 请求账号授权
      // 注意:这里会弹出 MetaMask 授权窗口
      const accounts = await browserProvider.send('eth_requestAccounts', []);
      if (accounts.length === 0) {
        setError('用户取消了授权');
        return;
      }

      // 4. 获取签名者(Signer)
      const walletSigner = await browserProvider.getSigner();
      setSigner(walletSigner);

      // 5. 获取地址和链 ID
      const userAddress = await walletSigner.getAddress();
      const network = await browserProvider.getNetwork();
      
      setAddress(userAddress);
      setChainId(Number(network.chainId));
      setError('');

      // 6. 监听账号切换
      window.ethereum.on('accountsChanged', (accounts: string[]) => {
        if (accounts.length === 0) {
          // 用户断开了连接
          disconnect();
        } else {
          // 更新地址
          setAddress(accounts[0]);
        }
      });

      // 7. 监听链切换
      window.ethereum.on('chainChanged', (chainIdHex: string) => {
        setChainId(parseInt(chainIdHex, 16));
        // 注意:链切换后需要重新创建 provider 和 signer
        // 因为 ethers v6 的 BrowserProvider 会缓存网络信息
        window.location.reload(); // 简单粗暴但有效
      });

    } catch (err: any) {
      // 处理用户拒绝连接
      if (err.code === 4001) {
        setError('用户拒绝了连接请求');
      } else {
        setError(err.message || '连接失败');
      }
    }
  }, []);

  const disconnect = useCallback(() => {
    setAddress('');
    setProvider(null);
    setSigner(null);
    setChainId(0);
    setError('');
    // 移除监听
    window.ethereum?.removeAllListeners('accountsChanged');
    window.ethereum?.removeAllListeners('chainChanged');
  }, []);

  return { address, provider, signer, chainId, error, connect, disconnect };
}

注意这个细节BrowserProvider 是 ethers v6 新增的,替代了 v5 的 Web3Provider。如果你还在用 v5,写法会不同。我当时项目用的 v6,结果参考了 v5 的文档,卡了半天才发现 API 变了。

3. 链切换:让用户一键跳转到目标链

连接成功后,用户可能不在我们期望的链上。比如我的 DeFi 看板主要支持 BSC(链 ID 56),但用户连接的是以太坊主网(链 ID 1)。这时候需要主动切换。

typescript 复制代码
// utils/chain.ts
export const SUPPORTED_CHAINS = {
  56: {
    chainId: '0x38',
    chainName: 'Binance Smart Chain',
    nativeCurrency: { name: 'BNB', symbol: 'BNB', decimals: 18 },
    rpcUrls: ['https://bsc-dataseed.binance.org/'],
    blockExplorerUrls: ['https://bscscan.com/']
  },
  // 可以添加更多链
};

export async function switchChain(chainId: number): Promise<boolean> {
  try {
    // 尝试切换
    await window.ethereum.request({
      method: 'wallet_switchEthereumChain',
      params: [{ chainId: `0x${chainId.toString(16)}` }],
    });
    return true;
  } catch (switchError: any) {
    // 如果目标链不存在,则添加
    if (switchError.code === 4902) {
      try {
        const chainConfig = SUPPORTED_CHAINS[chainId];
        if (!chainConfig) {
          throw new Error('不支持的链 ID');
        }
        await window.ethereum.request({
          method: 'wallet_addEthereumChain',
          params: [chainConfig],
        });
        return true;
      } catch (addError) {
        console.error('添加链失败', addError);
        return false;
      }
    }
    console.error('切换链失败', switchError);
    return false;
  }
}

这里有个坑wallet_switchEthereumChainchainId 参数必须是十六进制字符串,且不带 0x 前缀会报错。我当时传了十进制数字 56,结果 MetaMask 弹窗提示"无效参数",debug 了半小时才发现。

4. 签名验证:安全登录的核心

连接钱包只是第一步,真正的"登录"需要通过签名来验证用户对地址的所有权。我使用的是 eth_signTypedData_v4,它比 eth_sign 更安全,用户能看到签名内容。

typescript 复制代码
// utils/signature.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';

// 生成签名消息
export function createSignMessage(address: string, nonce: string): string {
  return JSON.stringify({
    domain: {
      name: 'DeFi Dashboard',
      version: '1',
      chainId: 1, // 这里可以动态获取
    },
    message: {
      action: '登录 DeFi Dashboard',
      address: address,
      nonce: nonce, // 后端生成的随机数,防止重放攻击
      timestamp: Date.now(),
    },
    primaryType: 'Login',
    types: {
      EIP712Domain: [
        { name: 'name', type: 'string' },
        { name: 'version', type: 'string' },
        { name: 'chainId', type: 'uint256' },
      ],
      Login: [
        { name: 'action', type: 'string' },
        { name: 'address', type: 'address' },
        { name: 'nonce', type: 'string' },
        { name: 'timestamp', type: 'uint256' },
      ],
    },
  });
}

// 签名并返回签名结果
export async function signLoginMessage(
  signer: JsonRpcSigner,
  nonce: string
): Promise<string> {
  const address = await signer.getAddress();
  const message = createSignMessage(address, nonce);
  
  // 使用 eth_signTypedData_v4 签名
  const signature = await signer.signTypedData(
    JSON.parse(message).domain,
    JSON.parse(message).types,
    JSON.parse(message).message
  );
  
  return signature;
}

注意这个细节signer.signTypedData 是 ethers v6 的方法,它会自动调用 eth_signTypedData_v4。如果你直接用 window.ethereum.request 调用,需要手动指定版本。我当时用 eth_sign 签名,用户看到一串乱码直接拒绝,改用结构化数据后,用户能看到清晰的登录信息,体验好很多。

完整代码:可直接运行的 React 组件

下面是一个完整的 LoginButton 组件,集成了上述所有逻辑:

typescript 复制代码
// components/LoginButton.tsx
import React, { useState, useEffect } from 'react';
import { useWallet } from '../hooks/useWallet';
import { switchChain } from '../utils/chain';
import { signLoginMessage } from '../utils/signature';

export const LoginButton: React.FC = () => {
  const { address, provider, signer, chainId, error, connect, disconnect } = useWallet();
  const [isLoading, setIsLoading] = useState(false);
  const [loginStatus, setLoginStatus] = useState<string>('');

  // 目标链 ID(示例为 BSC)
  const TARGET_CHAIN_ID = 56;

  const handleLogin = async () => {
    setIsLoading(true);
    setLoginStatus('');

    try {
      // 1. 连接钱包
      await connect();
      if (!signer) {
        setLoginStatus('连接失败');
        return;
      }

      // 2. 检查链 ID,不在目标链则切换
      if (chainId !== TARGET_CHAIN_ID) {
        const switched = await switchChain(TARGET_CHAIN_ID);
        if (!switched) {
          setLoginStatus('请切换到 BSC 链');
          return;
        }
      }

      // 3. 从后端获取 nonce(这里模拟)
      const nonce = await fetchNonce(address);
      
      // 4. 签名
      const signature = await signLoginMessage(signer, nonce);
      
      // 5. 发送签名到后端验证
      const loginResult = await verifySignature(address, signature, nonce);
      if (loginResult.success) {
        setLoginStatus('登录成功');
        // 保存 token 到 localStorage
        localStorage.setItem('auth_token', loginResult.token);
      } else {
        setLoginStatus('签名验证失败');
      }

    } catch (err: any) {
      setLoginStatus(err.message || '登录失败');
    } finally {
      setIsLoading(false);
    }
  };

  // 模拟后端接口
  const fetchNonce = async (address: string): Promise<string> => {
    // 实际项目中调用后端 API
    return `nonce_${Date.now()}_${address}`;
  };

  const verifySignature = async (
    address: string,
    signature: string,
    nonce: string
  ): Promise<{ success: boolean; token?: string }> => {
    // 实际项目中调用后端 API
    console.log('发送验证请求:', { address, signature, nonce });
    return { success: true, token: 'mock_jwt_token' };
  };

  // 监听错误
  useEffect(() => {
    if (error) {
      setLoginStatus(error);
    }
  }, [error]);

  return (
    <div style={{ padding: '20px' }}>
      {address ? (
        <div>
          <p>已连接:{address.slice(0, 6)}...{address.slice(-4)}</p>
          <p>链 ID:{chainId}</p>
          <button onClick={disconnect}>断开连接</button>
        </div>
      ) : (
        <button onClick={handleLogin} disabled={isLoading}>
          {isLoading ? '连接中...' : '连接 MetaMask 登录'}
        </button>
      )}
      {loginStatus && <p style={{ color: loginStatus.includes('失败') ? 'red' : 'green' }}>{loginStatus}</p>}
    </div>
  );
};

踩坑记录:那些让我熬夜的报错

坑 1:ethers.BrowserProvider is not a constructor

  • 现象 :引入 ethers 后,new BrowserProvider(window.ethereum) 报错。
  • 原因:我项目同时装了 ethers v5 和 v6,TypeScript 解析到了 v5 的版本。
  • 解决 :检查 package.json,确保只有 "ethers": "^6.0.0",并删除 node_modules 重新安装。

坑 2:MetaMask - RPC Error: Internal JSON-RPC error

  • 现象 :调用 signTypedData 时,MetaMask 弹窗然后报这个错。
  • 原因 :我签名的消息中 domain.chainId 传的是十进制数字,但 MetaMask 期望十六进制字符串。
  • 解决 :将 chainId: 1 改为 chainId: '0x1'

坑 3:用户切换账号后,前端不更新

  • 现象:用户手动在 MetaMask 切换账号,但前端仍然显示旧地址。
  • 原因 :没有监听 accountsChanged 事件。
  • 解决 :在 connect 函数中添加 window.ethereum.on('accountsChanged', callback)

坑 4:链切换后交易一直 pending

  • 现象:用户从以太坊切换到 BSC 后,发起交易一直 pending。
  • 原因BrowserProvider 缓存了旧的网络信息,需要重新创建。
  • 解决 :在 chainChanged 监听中调用 window.location.reload() 刷新页面。

小结

这次经历让我深刻体会到,Web3 前端开发不仅仅是调几个 API,更多是处理各种边界情况和用户交互细节。核心收获是:永远假设用户会做出最意外的操作,并做好对应的错误处理 。如果你正在做类似功能,建议进一步研究 EIP-1193 标准,以及如何用 wagmiWeb3-Onboard 等库来简化钱包管理------不过那是另一个故事了。

相关推荐
虚惊一场16 小时前
在浏览器里跑 Prettier:格式化 Markdown 的四个难点
前端·javascript·vue.js
前端炒粉16 小时前
手撕小汇总
java·前端·javascript
r_oo_ki_e_16 小时前
vue快速入门
前端·vue.js
虚惊一场16 小时前
把 CodeMirror 6 调教成 Markdown 编辑器:扩展、装饰与门面
前端·javascript·vue.js
lauo16 小时前
掌心核爆:iQOO首款小平板搭载2nm骁龙8E6,开启AI原生计算的移动终端新纪元
前端·人工智能·智能手机·重构·电脑·ai-native
虚惊一场16 小时前
一条 Markdown 渲染管线的全部细节:Worker、源行标注与按需加载
前端·javascript·vue.js
CoovallyAIHub16 小时前
当能源行业遇上 AI 智能体:Coco 为什么选择留在本地
前端·agent
程序员黑豆17 小时前
鸿蒙开发入门:以 Text 组件为例,掌握内置组件用法
前端·harmonyos
爱勇宝17 小时前
AI不会淘汰所有人,但会淘汰这6种人
前端·后端·程序员