用Viem替换ethers.js:一次合约交互的"减负"实战,我总算把TypeScript类型搞明白了

用Viem替换ethers.js:一次合约交互的"减负"实战,我总算把TypeScript类型搞明白了

摘要

我在开发一个多链DeFi看板时,被ethers.js的TypeScript类型推导折磨到快崩溃------合约返回值全是any,事件参数要手动解析,链切换时还得自己维护Provider。换了Viem之后,类型自动推导、链配置开箱即用,代码量直接砍半。这篇文章记录了我从ethers.js迁移到Viem的全过程,包括ABI处理、合约调用、事件监听等核心场景,以及我踩过的那些坑。

背景

上个月,我在做一个跨链DeFi收益聚合器的前端。项目需要同时连接以太坊、Polygon和Arbitrum三个链,实时展示用户在各链上的质押和奖励数据。一开始我用了ethers.js v5 + TypeScript,但写着写着就发现不对劲了:每次调用合约方法,返回值的类型都是any,我得手动写类型断言;事件监听更是噩梦,需要自己解析log。更烦的是,切换链时要手动维护Provider实例,代码越来越臃肿。

后来在Twitter上看到Viem这个库,说是ethers.js的现代替代品,TypeScript支持特别好。我抱着"试试看"的心态迁移了一个模块,结果发现------真香。这篇文章就是我把整个项目从ethers.js迁移到Viem的实战记录,希望给同样被类型问题困扰的Web3前端开发者一些参考。

问题分析:ethers.js的TypeScript体验为什么让我抓狂

先说说ethers.js的问题。我当时用的是v5,v6虽然改进了不少,但核心问题没变:

  1. 合约返回值类型丢失 :ethers.js的Contract类在调用方法时,返回类型是Promise<any>。比如我调用stakingContract.stakeInfo(userAddress),返回值是any,我必须手动写as StakeInfo。更坑的是,如果ABI写错了,运行时才报错。

  2. 事件监听要手动解析 :监听Staked事件时,ethers.js返回的Event对象里,argsResult类型,索引和键混在一起,每次都要写event.args.amount这种代码,而且没有类型提示。

  3. Provider管理繁琐:不同链需要不同的Provider,切换链时得重新创建并绑定到Contract。我写了一个Provider管理器,但代码量很大,而且容易出bug。

  4. Tree Shaking困难:ethers.js包体积大,而且不支持按需引入。我用的是v5,打包后光ethers就占了200KB+。

这些问题在小型项目里还能忍,但项目一复杂,类型安全就成了大问题。我当时就决定:必须换一个TypeScript支持更好的库。

我调研了几个选项:web3.js、ethers.js v6、Viem。web3.js的类型支持更差,直接排除。ethers.js v6虽然改进了类型,但本质上还是ethers.js的架构。Viem的设计思路完全不同------它从底层就是用TypeScript写的,类型推导是第一优先级。而且Viem体积小(压缩后约30KB),支持Tree Shaking,正好契合我的需求。

核心实现:从ethers.js到Viem的迁移实战

第一步:安装和初始化,注意Viem的"链"概念

Viem的安装很简单:

bash 复制代码
npm install viem

但这里有个坑:Viem不像ethers.js那样自动连接网络,你需要显式指定链。比如连接以太坊主网:

typescript 复制代码
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY')
})

对比ethers.js:

typescript 复制代码
// ethers.js
const provider = new ethers.providers.JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY')

看起来差不多,但Viem的chain参数是关键。它包含了链的ID、名称、原生货币、RPC URL等信息。当你调用方法时,Viem会自动校验链ID,如果传入的链ID和配置不一致,会直接报错。这个设计让我在切换链时少踩了很多坑。

注意这个细节 :Viem的createPublicClient只用于读取链上数据。如果要写交易,需要createWalletClient。这个分离设计很清晰,但刚迁移时我总忘记切换。

第二步:合约实例化------终于有了类型推导

这是迁移的核心。ethers.js里,合约实例化后所有方法返回any。Viem通过getContract函数,结合ABI,自动推导出所有方法的参数和返回值类型。

先看ethers.js的写法:

typescript 复制代码
// ethers.js - 类型全丢失
import { ethers } from 'ethers'
import StakingABI from './StakingABI.json'

const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
const stakingContract = new ethers.Contract(STAKING_ADDRESS, StakingABI, provider)

// 返回值是any
const stakeInfo = await stakingContract.stakeInfo(userAddress)
const amount: string = stakeInfo.amount // 必须手动断言

用Viem重写:

typescript 复制代码
// Viem - 类型自动推导
import { createPublicClient, getContract, http } from 'viem'
import { polygon } from 'viem/chains'
import { stakingABI } from './stakingABI' // 这里ABI必须是const断言

const publicClient = createPublicClient({
  chain: polygon,
  transport: http(POLYGON_RPC_URL)
})

const stakingContract = getContract({
  address: STAKING_ADDRESS as `0x${string}`,
  abi: stakingABI,
  client: publicClient
})

// 返回值类型自动推导为 { amount: bigint, startTime: bigint, ... }
const stakeInfo = await stakingContract.read.stakeInfo([userAddress])
console.log(stakeInfo.amount) // 类型是bigint,不是string

这里有个坑 :Viem的ABI必须用as const断言,否则类型推导会失效。比如:

typescript 复制代码
// 错误:类型推导失效
const stakingABI = [...] // 普通数组

// 正确:必须用as const
const stakingABI = [...] as const

我一开始没注意,结果stakeInfo返回的还是any,心态差点崩了。后来发现是ABI的问题。

Viem的合约调用分read和write两种。只读方法用contract.read.methodName([args]),写方法用contract.write.methodName([args])。这个设计比ethers.js的contract.methodName()更清晰,而且类型推导也更准确。

第三步:多链配置------用Viem的Chain对象管理不同网络

我的项目需要同时连接三个链,Viem的链配置让这件事变得很简单。

先看ethers.js的写法:

typescript 复制代码
// ethers.js - 手动管理Provider
const providers = {
  ethereum: new ethers.providers.JsonRpcProvider(ETH_RPC),
  polygon: new ethers.providers.JsonRpcProvider(POLYGON_RPC),
  arbitrum: new ethers.providers.JsonRpcProvider(ARBITRUM_RPC)
}

function getContractForChain(chain: string, address: string, abi: any) {
  return new ethers.Contract(address, abi, providers[chain])
}

用Viem重写:

typescript 复制代码
// Viem - 用Chain对象
import { createPublicClient, http } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'

const clients = {
  ethereum: createPublicClient({
    chain: mainnet,
    transport: http(ETH_RPC)
  }),
  polygon: createPublicClient({
    chain: polygon,
    transport: http(POLYGON_RPC)
  }),
  arbitrum: createPublicClient({
    chain: arbitrum,
    transport: http(ARBITRUM_RPC)
  })
}

// 获取合约时,直接传入对应的client
function getStakingContract(chain: keyof typeof clients) {
  return getContract({
    address: STAKING_ADDRESS_MAP[chain] as `0x${string}`,
    abi: stakingABI,
    client: clients[chain]
  })
}

Viem的viem/chains模块内置了所有主流链的配置,包括链ID、名称、原生货币等。你不需要手动写这些常量,直接导入就行。而且当你调用getContract时,Viem会自动验证链ID,如果合约地址所在的链和client配置的链不一致,会在编译时给出警告(通过类型检查)。

注意这个细节 :Viem的http传输方式支持自动重试和错误处理。我之前的ethers.js代码需要手动写重试逻辑,Viem内置了,省了不少事。

第四步:事件监听------不用再手动解析log

事件监听是我最头疼的部分。ethers.js里,事件回调的参数需要手动解析,而且类型全是any。Viem的watchContractEvent方法直接返回类型安全的事件参数。

ethers.js的写法:

typescript 复制代码
// ethers.js - 事件参数类型丢失
stakingContract.on('Staked', (user, amount, startTime, event) => {
  // user, amount, startTime 都是any
  console.log(user, amount.toString())
})

Viem的写法:

typescript 复制代码
// Viem - 事件参数类型自动推导
const unwatch = stakingContract.watchEvent.Staked(
  {
    // 可选:过滤特定用户的事件
    args: { user: userAddress }
  },
  {
    onLogs: (logs) => {
      // logs 的类型是 StakedEvent[],包含 user, amount, startTime 等字段
      logs.forEach(log => {
        console.log(log.args.user, log.args.amount.toString())
      })
    }
  }
)

// 停止监听
// unwatch()

这里有个坑 :Viem的watchContractEvent返回的是一个unwatch函数,不是像ethers.js那样返回void。如果你在React组件中监听事件,记得在useEffect的清理函数中调用unwatch(),否则会造成内存泄漏。

而且Viem的事件监听是基于WebSocket的,如果你用的是HTTP传输,它会自动降级为轮询。这个细节让我省了不少配置时间。

第五步:写交易------用WalletClient处理签名

写交易这部分,Viem和ethers.js的思路完全不同。ethers.js是把Provider和Signer混在一起,Viem则明确分离了PublicClient(只读)和WalletClient(写交易)。

ethers.js的写法:

typescript 复制代码
// ethers.js
const signer = provider.getSigner()
const stakingContract = new ethers.Contract(STAKING_ADDRESS, StakingABI, signer)
const tx = await stakingContract.stake(amount)
await tx.wait()

Viem的写法:

typescript 复制代码
// Viem
import { createWalletClient, custom } from 'viem'
import { polygon } from 'viem/chains'

// 创建WalletClient,使用window.ethereum作为传输
const walletClient = createWalletClient({
  chain: polygon,
  transport: custom(window.ethereum!)
})

// 获取用户地址
const [address] = await walletClient.requestAddresses()

// 写交易
const hash = await stakingContract.write.stake([amount], {
  account: address
})

// 等待交易确认
const receipt = await publicClient.waitForTransactionReceipt({ hash })

注意这个细节 :Viem的write方法返回的是交易哈希(hash),而不是像ethers.js那样返回一个TransactionResponse对象。你需要手动调用waitForTransactionReceipt来等待确认。这个设计更贴近底层,但刚开始用的时候我总忘记等receipt。

另外,Viem的walletClient.requestAddresses()会弹出MetaMask的钱包授权,而ethers.js的provider.send('eth_requestAccounts', [])则没那么直观。Viem的API命名更符合直觉。

完整代码:一个可运行的多链DeFi看板组件

下面是一个完整的React组件,展示如何用Viem读取用户在不同链上的质押数据:

typescript 复制代码
// StakingDashboard.tsx
import { useEffect, useState } from 'react'
import { createPublicClient, getContract, http, type Address } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'
import { stakingABI } from './stakingABI' // 必须用as const断言

// 各链的合约地址
const CONTRACT_ADDRESSES: Record<string, Address> = {
  ethereum: '0x...',
  polygon: '0x...',
  arbitrum: '0x...'
}

// 各链的RPC URL
const RPC_URLS: Record<string, string> = {
  ethereum: 'https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY',
  polygon: 'https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY',
  arbitrum: 'https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY'
}

// 链配置
const CHAINS = {
  ethereum: mainnet,
  polygon: polygon,
  arbitrum: arbitrum
}

// 创建PublicClient
function createClient(chainId: string) {
  return createPublicClient({
    chain: CHAINS[chainId],
    transport: http(RPC_URLS[chainId])
  })
}

// 获取合约实例
function getStakingContract(chainId: string, userAddress: Address) {
  const client = createClient(chainId)
  return getContract({
    address: CONTRACT_ADDRESSES[chainId],
    abi: stakingABI,
    client
  })
}

interface StakeInfo {
  amount: bigint
  startTime: bigint
  rewardDebt: bigint
}

export default function StakingDashboard({ userAddress }: { userAddress: Address }) {
  const [stakeInfos, setStakeInfos] = useState<Record<string, StakeInfo | null>>({})
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    async function fetchAllStakes() {
      try {
        setLoading(true)
        setError(null)

        const results: Record<string, StakeInfo | null> = {}

        // 并行读取三个链的数据
        await Promise.all(
          Object.keys(CONTRACT_ADDRESSES).map(async (chainId) => {
            try {
              const contract = getStakingContract(chainId, userAddress)
              // read方法返回类型安全的数据
              const info = await contract.read.stakeInfo([userAddress])
              results[chainId] = info as StakeInfo
            } catch (err) {
              console.error(`Failed to fetch stake info for ${chainId}:`, err)
              results[chainId] = null
            }
          })
        )

        setStakeInfos(results)
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error')
      } finally {
        setLoading(false)
      }
    }

    fetchAllStakes()
  }, [userAddress])

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>

  return (
    <div>
      <h2>Staking Dashboard</h2>
      {Object.entries(stakeInfos).map(([chainId, info]) => (
        <div key={chainId}>
          <h3>{chainId}</h3>
          {info ? (
            <ul>
              <li>Staked Amount: {info.amount.toString()} tokens</li>
              <li>Start Time: {new Date(Number(info.startTime) * 1000).toLocaleString()}</li>
              <li>Reward Debt: {info.rewardDebt.toString()}</li>
            </ul>
          ) : (
            <p>No staking data available</p>
          )}
        </div>
      ))}
    </div>
  )
}

踩坑记录:迁移过程中遇到的4个报错

  1. ABI必须用as const :一开始我没用as const,结果getContract返回的合约对象所有方法类型都是any。排查了半天才发现是ABI类型的问题。解决方法:导入ABI时加上as const,或者用typeof stakingABI

  2. 地址类型必须是0x${string} :Viem对地址类型要求很严格,必须是Address类型(即0x${string})。如果传入了普通的字符串,TypeScript会报错。我一开始从环境变量读取地址时没做类型转换,编译就过不了。解决方法:显式断言为as Address

  3. watchContractEvent的清理 :在React组件中监听事件时,我忘了在useEffect的清理函数中调用unwatch(),导致组件卸载后事件监听还在运行,造成内存泄漏。解决方法:在useEffect返回的清理函数中调用unwatch()

  4. 链ID不匹配 :Viem的createPublicClient需要传入chain参数,如果调用getContract时传入的client的链ID和合约地址所在的链不一致,Viem会在运行时抛出异常。我一开始没注意,把polygon的合约地址传给了mainnet的client,结果报错。解决方法:确保每个链的client和合约地址一一对应。

小结

用Viem替换ethers.js后,我的代码量减少了大概40%,TypeScript类型错误几乎降为零。Viem的类型推导、链配置和事件监听设计让我在开发多链DeFi项目时轻松了很多。如果你也在被ethers.js的类型问题困扰,不妨试试Viem。下一步我准备研究Viem的useContractRead等React Hooks,进一步简化代码。

相关推荐
To_OC1 小时前
一个让我懵了半小时的时钟 Bug,注重前端三权分立落地
前端·代码规范
归故里1 小时前
harmony-next.skills 为 AI 而生!
前端·后端·github
threelab1 小时前
Three.js 3D 热力图效果 | 三维可视化 / AI 提示词
开发语言·前端·javascript·人工智能·3d·着色器
vivo互联网技术1 小时前
VAPD AgentKit:可组合 Agent 前端通用库实践
前端·ai·架构·agent
lichenyang4532 小时前
鸿蒙聊天 Demo 练习 02:AI 回复打字机输出与 ForEach 刷新问题
前端
Hello--_--World2 小时前
利用CDN进行首屏优化。能不能看CDN与本地服务器谁快用谁?
运维·服务器·前端·javascript·vite
我的世界洛天依2 小时前
胡桃讲编程 | 外挂的另一种方法与防御 —— 对象(JS ES262)
开发语言·javascript·ecmascript
猫不易2 小时前
在 Warp + tmux 下使用 Claude Code:一次剪贴板踩坑记录
前端
sa100272 小时前
京东评论 API 实战:JSON 数据结构、字段含义与解析技巧
前端·数据结构·json