用 ethers.js 连 MetaMask 做钱包登录,我踩了三个坑才搞定跨页面状态同步

背景

上个月我在做一个 DeFi 资产管理面板,核心功能是让用户连接 MetaMask 钱包后,查看自己的代币余额和交易历史。这个项目用的是 React + TypeScript 技术栈,团队选定了 ethers.js 作为区块链交互库,因为大家对它比较熟悉,而且文档相对完善。

项目启动时,我天真地以为钱包登录就是调几个 API 的事------毕竟 MetaMask 都发展这么多年了,连接钱包这种基础功能应该很成熟。但真正动手做的时候,我发现事情远没那么简单。特别是用户刷新页面后,钱包连接状态丢失,需要重新点击连接按钮,这种体验在 Web3 应用里简直不能忍。我当时就在想:总不能每次刷新都让用户重新授权吧?那跟传统 Web2 的 session 过期有啥区别?

问题分析

我最初的思路非常直接:在 React 组件里用 window.ethereum 对象,调用 eth_requestAccounts 方法获取用户地址,然后存到 React 的 useState 里。代码大概长这样:

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

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

这段代码在首次连接时确实能跑通,但问题很快就暴露了:

  1. 刷新页面后状态丢失useState 是内存状态,页面一刷新就没了。用户需要重新点击连接按钮,体验极差。
  2. 用户主动断开连接无法感知 :MetaMask 允许用户在插件里断开网站连接,但 window.ethereum 不会主动通知前端。用户断开后,前端还显示已连接,导致后续调用全部报错。
  3. 链切换后签名验证失败:用户如果在不同链(比如从 Ethereum 主网切到 Polygon)签名,生成的签名格式不同,后端验证时容易出问题。

我当时排查这些问题的过程很痛苦。先是怀疑 ethers.js 的版本问题,试了 v5 和 v6 的不同写法;然后又怀疑 React 的渲染机制,加了 useEffect 监听账户变化;最后才发现,核心问题在于钱包连接状态需要持久化到 localStorage,并且要监听 MetaMask 的事件来实时更新状态

核心实现

第一步:封装 Provider 和 Signer,解决初始化问题

首先要明确一个概念:ethers.js 里的 Provider 和 Signer 是两个不同的东西。Provider 只负责读数据(比如查询余额),Signer 才能签名和发送交易。连接钱包时,我们既需要 Provider 来获取链上信息,也需要 Signer 来执行写操作。

我当时踩的第一个坑是:直接用 new ethers.providers.Web3Provider(window.ethereum) 创建 Provider,但没考虑用户还没授权的情况。如果用户还没点击连接,这个 Provider 虽然创建成功了,但调用 getSigner() 会直接报错。

正确的做法是:先检查用户是否已经授权,再决定如何创建 Provider。

这里有个坑ethers.providers.Web3Provider 在 v5 和 v6 的导入方式不同。我用的是 v5,导入路径是 ethers.providers;如果是 v6,要改成 ethers.BrowserProvider。项目里统一用 v5 避免混淆。

typescript 复制代码
// utils/wallet.ts
import { ethers } from 'ethers';

// 检查 MetaMask 是否安装
export const isMetaMaskInstalled = (): boolean => {
  return typeof window.ethereum !== 'undefined' && window.ethereum.isMetaMask;
};

// 获取 Provider(只读)
export const getProvider = (): ethers.providers.Web3Provider | null => {
  if (!isMetaMaskInstalled()) return null;
  return new ethers.providers.Web3Provider(window.ethereum);
};

// 获取 Signer(需要用户授权)
export const getSigner = async (): Promise<ethers.Signer | null> => {
  const provider = getProvider();
  if (!provider) return null;
  try {
    // 关键:先请求账户授权,再获取 Signer
    await provider.send('eth_requestAccounts', []);
    return provider.getSigner();
  } catch (error) {
    console.error('用户拒绝授权:', error);
    return null;
  }
};

第二步:持久化连接状态,解决刷新丢失问题

我决定把钱包地址存到 localStorage,同时监听 MetaMask 的 accountsChanged 事件来实时更新。这样用户刷新页面后,从 localStorage 读取地址,再重新创建 Provider 和 Signer。

这里有个坑 :localStorage 里存的地址必须是 checksum 格式(即正确的大小写),否则 ethers.js 的一些方法会报 invalid address 错误。所以我统一用 ethers.utils.getAddress() 做格式化。

typescript 复制代码
// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
import { getProvider, getSigner, isMetaMaskInstalled } from '../utils/wallet';

const STORAGE_KEY = 'wallet_address';

export const useWallet = () => {
  const [account, setAccount] = useState<string>('');
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);

  // 从 localStorage 恢复连接状态
  useEffect(() => {
    const savedAccount = localStorage.getItem(STORAGE_KEY);
    if (savedAccount && isMetaMaskInstalled()) {
      // 重新初始化 Provider 和 Signer
      const prov = getProvider();
      if (prov) {
        setProvider(prov);
        // 注意:这里不能直接 getSigner,因为用户可能已经断开连接
        // 先尝试获取账户,如果失败就清除 localStorage
        prov
          .send('eth_accounts', [])
          .then((accounts: string[]) => {
            if (accounts.length > 0 && accounts[0] === savedAccount) {
              setAccount(ethers.utils.getAddress(accounts[0]));
              setSigner(prov.getSigner());
            } else {
              // 用户已断开连接,清除本地状态
              localStorage.removeItem(STORAGE_KEY);
            }
          })
          .catch(() => {
            localStorage.removeItem(STORAGE_KEY);
          });
      }
    }
  }, []);

  // 连接钱包
  const connect = useCallback(async () => {
    if (!isMetaMaskInstalled()) {
      alert('请安装 MetaMask 浏览器插件');
      return;
    }
    setIsConnecting(true);
    try {
      const signer = await getSigner();
      if (signer) {
        const address = await signer.getAddress();
        const formattedAddress = ethers.utils.getAddress(address);
        setAccount(formattedAddress);
        setSigner(signer);
        setProvider(getProvider());
        // 持久化到 localStorage
        localStorage.setItem(STORAGE_KEY, formattedAddress);
      }
    } catch (error) {
      console.error('连接失败:', error);
    } finally {
      setIsConnecting(false);
    }
  }, []);

  // 断开连接
  const disconnect = useCallback(() => {
    setAccount('');
    setSigner(null);
    setProvider(null);
    localStorage.removeItem(STORAGE_KEY);
  }, []);

  return { account, provider, signer, chainId, isConnecting, connect, disconnect };
};

第三步:监听 MetaMask 事件,实现实时状态同步

这是最容易被忽略的部分。MetaMask 会触发三个关键事件:accountsChanged(账户切换)、chainChanged(链切换)、disconnect(断开连接)。如果不监听这些事件,用户切换到其他账户时,前端还显示旧账户信息。

这里有个坑accountsChanged 事件在用户断开连接时会返回空数组 [],而不是 nullundefined。我当时没注意这个细节,导致断开后地址还是旧值。

typescript 复制代码
// 在 useWallet hook 中添加事件监听
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    if (accounts.length === 0) {
      // 用户断开了连接
      disconnect();
    } else {
      // 用户切换了账户
      const newAccount = ethers.utils.getAddress(accounts[0]);
      setAccount(newAccount);
      localStorage.setItem(STORAGE_KEY, newAccount);
      // 重新创建 Signer
      const prov = getProvider();
      if (prov) {
        setProvider(prov);
        setSigner(prov.getSigner());
      }
    }
  };

  const handleChainChanged = (chainIdHex: string) => {
    // 链 ID 是十六进制字符串,转为十进制数字
    const newChainId = parseInt(chainIdHex, 16);
    setChainId(newChainId);
    // 链切换后需要重新创建 Provider 和 Signer
    const prov = getProvider();
    if (prov) {
      setProvider(prov);
      setSigner(prov.getSigner());
    }
  };

  const handleDisconnect = () => {
    disconnect();
  };

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

  return () => {
    window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum.removeListener('chainChanged', handleChainChanged);
    window.ethereum.removeListener('disconnect', handleDisconnect);
  };
}, [disconnect]);

第四步:签名验证,确保用户真实拥有钱包

登录不仅仅是连接钱包,还要验证用户确实拥有这个地址。常见的做法是让用户签名一段消息,后端验证签名是否匹配。

这里有个坑 :签名时一定要指定 chainId,否则在不同链上签名生成的 v 值不同,导致验证失败。我用的是 eth_signTypedData_v4 标准,但为了兼容性,团队决定用简单的 personal_sign

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

// 生成登录消息
export const createLoginMessage = (address: string, nonce: string): string => {
  return `欢迎登录 DeFi Dashboard\n地址: ${address}\nNonce: ${nonce}\n时间戳: ${Date.now()}`;
};

// 用户签名
export const signMessage = async (
  signer: ethers.Signer,
  message: string
): Promise<string> => {
  try {
    const signature = await signer.signMessage(message);
    return signature;
  } catch (error) {
    console.error('签名失败:', error);
    throw error;
  }
};

// 前端验证签名(简单验证,生产环境应在后端做)
export const verifySignature = (
  address: string,
  message: string,
  signature: string
): boolean => {
  try {
    const recoveredAddress = ethers.utils.verifyMessage(message, signature);
    return recoveredAddress.toLowerCase() === address.toLowerCase();
  } catch {
    return false;
  }
};

完整代码

以下是一个完整的 React 组件示例,集成了上述所有逻辑。复制到项目中,安装 ethers@5react 即可运行。

typescript 复制代码
// App.tsx
import React, { useEffect } from 'react';
import { useWallet } from './hooks/useWallet';
import { createLoginMessage, signMessage, verifySignature } from './utils/signature';

const App: React.FC = () => {
  const { account, provider, signer, isConnecting, connect, disconnect } = useWallet();
  const [loginStatus, setLoginStatus] = React.useState<string>('');

  const handleLogin = async () => {
    if (!signer || !account) return;
    try {
      // 生成随机 nonce(生产环境应由后端生成)
      const nonce = Math.random().toString(36).substring(2, 15);
      const message = createLoginMessage(account, nonce);
      const signature = await signMessage(signer, message);

      // 验证签名
      const isValid = verifySignature(account, message, signature);
      if (isValid) {
        setLoginStatus(`登录成功!签名已验证。`);
        // 这里应该把 signature 发送给后端进行进一步验证
      } else {
        setLoginStatus('签名验证失败,请重试。');
      }
    } catch (error) {
      setLoginStatus('签名过程出错,请重试。');
    }
  };

  return (
    <div style={{ padding: '2rem' }}>
      <h1>DeFi Dashboard</h1>
      {!account ? (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      ) : (
        <div>
          <p>已连接账户: {account}</p>
          <button onClick={handleLogin}>签名登录</button>
          <button onClick={disconnect} style={{ marginLeft: '1rem' }}>
            断开连接
          </button>
          {loginStatus && <p>{loginStatus}</p>}
        </div>
      )}
    </div>
  );
};

export default App;

踩坑记录

  1. eth_requestAccountseth_accounts 的区别 :前者会弹出 MetaMask 授权窗口,后者只返回当前已授权的账户。我一开始在 useEffect 里用了 eth_requestAccounts,导致每次刷新都弹出授权窗口,用户体验极差。后来改成 eth_accounts 才解决。

  2. ethers v5 和 v6 的 API 不兼容 :项目初期我混用了 v5 和 v6 的写法,比如 provider.getSigner() 在 v5 里是同步的,但在 v6 里变成了异步。后来统一锁定 ethers@5.7.2 才避免了这些奇怪的问题。

  3. 签名时的 chainId 问题 :用户如果在 Polygon 链上签名,生成的签名 v 值是 0 或 1,而不是标准的 27 或 28。后端验证时需要做特殊处理,否则会验证失败。我们的解决方案是在后端统一用 ethers.utils.verifyMessage 验证,它内部会处理 v 值的兼容。

  4. localStorage 跨域问题 :在开发环境用 localhost,生产环境用正式域名,localStorage 的 key 是隔离的。用户从开发环境切换到生产环境需要重新连接。这个问题无解,只能提醒用户注意。

小结

通过这个项目,我深刻体会到 Web3 前端的核心难点不在于 API 调用,而在于状态管理和事件监听。钱包连接不是一次性的操作,而是一个需要持续维护的会话。如果你也在做类似功能,建议优先考虑使用 wagmi 或 RainbowKit 这类封装更完善的库,它们已经帮你处理了大部分边界情况。但如果想深入理解底层原理,自己用 ethers.js 实现一遍,绝对值得。下一步我打算研究如何用 viem 替代 ethers.js,据说它在类型安全和性能上更有优势。

相关推荐
饺子不吃醋1 小时前
深入理解 Vue 3 的 setup(含 Composition API)
前端·vue.js
阿星做前端1 小时前
重度 AI 编程用户的一天:我怎么把 Claude Code / Codex 工作流搬进浏览器工作台
前端·javascript·后端
风止何安啊1 小时前
手写 URL 解析器,面试官到底想考什么?
前端·javascript·面试
yingyima2 小时前
踩坑亲历:一次因 JSON 格式问题导致的宕机,及工具救赎
前端
kyriewen2 小时前
我开发的 Chrome 扒图浏览器插件又更新了❗
前端·chrome·浏览器
程序员祥云2 小时前
Prompt项目说明文档
前端
一勺菠萝丶2 小时前
如何在 Linux 服务器上使用 Speedtest 官方 CLI 测试带宽(小白教程)
java·服务器·前端
DianSan_ERP2 小时前
京东订单接口集成中如何处理消费者敏感信息的安全与合规问题?
前端·数据库·后端·团队开发·运维开发
TEC_INO2 小时前
Linux50:ROCKX+RV1126视频流检测人脸
开发语言·前端·javascript