Skip to content

Viem 深入:类型安全的以太坊交互层

viem 设计哲学:tree-shakable + 类型安全 + 无依赖 ​

viem 是由 wagmi 团队构建的以太坊交互库,其设计哲学可以用三个关键词概括:

  • Tree-shakable:所有功能通过独立函数实现,打包时只引入用到的部分。一个只读取合约的 DApp,viem 的打包体积可以低至 20KB
  • 类型安全:基于 TypeScript 的类型系统,viem 能从 ABI 自动推导出函数名、参数类型和返回类型,无需额外的代码生成工具
  • 无运行时依赖:viem 不依赖任何其他库(除 viem 自身的内部包),减少了依赖树复杂度和版本冲突风险

与 ethers.js 的"大一统"设计不同,viem 选择了 Unix 哲学——每个函数做好一件事,组合使用。这种设计使得 viem 既轻量又灵活。

核心概念:Client, Transport, Chain, Account ​

viem 的架构建立在四个核心抽象之上:

typescript
import { createClient, http, parseEther } from 'viem'
import { mainnet } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'

// 1. Chain — 定义链的参数(chainId, RPC, 区块浏览器等)
const chain = mainnet

// 2. Transport — 底层通信层(HTTP, WebSocket, 自定义)
const transport = http('https://eth.llamarpc.com')

// 3. Account — 签名账户(可选,PublicClient 不需要)
const account = privateKeyToAccount('0x...')

// 4. Client — 组合上述三者,提供基础功能
const client = createClient({
  chain,
  transport,
  // account 是可选的:有 account 就是 WalletClient,没有就是 PublicClient
})

Client 本身只提供最基础的功能(如 getBlockNumber)。高级功能通过 Actions 扩展,这种组合优于继承的设计使得 tree-shaking 可以精确地只打包用到的功能。

PublicClient vs WalletClient ​

PublicClient 用于读取链上数据,不需要私钥。WalletClient 用于发送交易和签名,需要 Account。

typescript
import { createPublicClient, createWalletClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'

// PublicClient — 只读
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

// 读取区块号
const blockNumber = await publicClient.getBlockNumber()

// 读取合约数据
const balance = await publicClient.readContract({
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  abi: erc20Abi,
  functionName: 'balanceOf',
  args: ['0xd8da6bf26964af9d7eed9e03e53415d37aa96045'],
})

// WalletClient — 可写
const walletClient = createWalletClient({
  chain: mainnet,
  transport: http(),
  account: privateKeyToAccount('0x...'),
})

// 发送交易
const hash = await walletClient.sendTransaction({
  to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
  value: parseEther('1'),
})

一个常见的模式是同时使用两者:PublicClient 读取状态,WalletClient 发送交易:

typescript
// 组合使用:读取 + 写入
const { request } = await publicClient.simulateContract({
  address: tokenAddress,
  abi: erc20Abi,
  functionName: 'transfer',
  args: [recipient, amount],
  account: walletClient.account,
})

const hash = await walletClient.writeContract(request)

Actions 模式:readContract, writeContract, getBlock ​

viem 的 Actions 是独立的纯函数,通过 client 参数绑定到客户端实例上。除了内置的 Actions,开发者可以自定义:

typescript
import { type Client, getBlock, readContract } from 'viem'

// 方式一:直接调用 Action 函数
const block = await getBlock(publicClient, { blockTag: 'latest' })
const data = await readContract(publicClient, { ... })

// 方式二:扩展 Client(推荐,类型更友好)
const extendedClient = publicClient.extend((client) => ({
  async getLatestGasPrice() {
    const block = await getBlock(client)
    return block.baseFeePerGas
  },
  async getTokenBalance(token: Address, holder: Address) {
    return readContract(client, {
      address: token,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [holder],
    })
  },
}))

// 使用扩展的方法
const gasPrice = await extendedClient.getLatestGasPrice()
const balance = await extendedClient.getTokenBalance(usdcAddress, userAddress)

这种 extend 模式是 viem 的精髓——它允许开发者以类型安全的方式封装业务逻辑,同时不牺牲 tree-shaking 能力。

内置 ABI 类型推导:无需 TypeChain ​

viem 最强大的特性之一是从 ABI 自动推导类型。只要 ABI 的 TypeScript 类型定义正确,readContract 和 writeContract 就能提供完整的参数和返回值类型提示:

typescript
// 定义 ABI(注意 as const)
const wethAbi = [
  {
    type: 'function',
    name: 'deposit',
    stateMutability: 'payable',
    inputs: [],
    outputs: [{ type: 'uint256' }],
  },
  {
    type: 'function',
    name: 'balanceOf',
    stateMutability: 'view',
    inputs: [{ type: 'address', name: 'account' }],
    outputs: [{ type: 'uint256' }],
  },
  {
    type: 'function',
    name: 'transfer',
    stateMutability: 'nonpayable',
    inputs: [
      { type: 'address', name: 'to' },
      { type: 'uint256', name: 'amount' },
    ],
    outputs: [{ type: 'bool' }],
  },
] as const  // 关键:as const 让 TypeScript 推导精确类型

// readContract 自动推导:
// - functionName 只能是 'deposit' | 'balanceOf' | 'transfer'
// - args 类型根据 functionName 自动匹配
// - 返回值类型根据 functionName 自动推导
const balance = await publicClient.readContract({
  address: wethAddress,
  abi: wethAbi,
  functionName: 'balanceOf',
  args: [userAddress],
  //    ^? [Address] — 类型精确推导
})
//    ^? bigint — 返回值也是精确类型

const hash = await walletClient.writeContract({
  address: wethAddress,
  abi: wethAbi,
  functionName: 'transfer',
  args: [recipient, parseEther('1')],
  //    ^? [Address, bigint]
})

这消除了对 TypeChain 或其他代码生成工具的依赖。ABI 既是运行时数据,也是编译时类型来源。

viem 还支持使用 GetAbiFunctionParameters 等工具类型来提取特定函数的参数类型:

typescript
import { type GetAbiFunctionParameters } from 'viem'

type TransferArgs = GetAbiFunctionParameters<typeof wethAbi, 'transfer'>
// { to: Address; amount: bigint }

与 ethers.js 的 API 对比 ​

以下表格对比常见操作:

操作ethers.js v6viem
创建 Providernew ethers.JsonRpcProvider(url)createPublicClient({ chain, transport: http(url) })
读取合约contract.balanceOf(addr)publicClient.readContract({ ... })
发送交易contract.transfer(to, amount)walletClient.writeContract({ ... })
类型推导需 TypeChain 生成内置,ABI as const
监听事件contract.on('Transfer', cb)publicClient.watchEvent({ ... })
打包体积~100KB+~20KB(按需引入)

ethers.js 的 Contract 对象模式(方法挂载到对象上)在 TypeScript 类型推导上有天然局限。viem 的显式 functionName 参数模式配合 as const ABI,在类型安全上远胜 ethers.js。

用 viem 实现完整的 DApp 交互层 ​

以下是一个完整的 DApp 交互层实现,涵盖合约读取、写入、事件监听和错误处理:

typescript
import {
  createPublicClient,
  createWalletClient,
  http,
  type Address,
  type Hash,
  type Log,
  type Abi,
  parseEventLogs,
  decodeEventLog,
} from 'viem'
import { mainnet } from 'viem/chains'

// DApp 交互层配置
interface DAppConfig {
  chainId: number
  rpcUrl: string
  wsUrl?: string
  contracts: Record<string, { address: Address; abi: Abi }>
}

class DAppInteractionLayer {
  private publicClient: PublicClient
  private walletClient: WalletClient | null = null
  private config: DAppConfig

  constructor(config: DAppConfig) {
    this.config = config
    const chain = getChainByChainId(config.chainId)

    this.publicClient = createPublicClient({
      chain,
      transport: http(config.rpcUrl),
    })
  }

  // 连接钱包
  connectWallet(account: Address, transport: Transport) {
    const chain = getChainByChainId(this.config.chainId)
    this.walletClient = createWalletClient({
      chain,
      transport,
      account,
    })
  }

  // 通用的合约读取
  async read<TContractName extends keyof typeof this.config.contracts>(
    contractName: TContractName,
    functionName: string,
    args: readonly unknown[],
  ) {
    const { address, abi } = this.config.contracts[contractName]
    return this.publicClient.readContract({
      address,
      abi,
      functionName,
      args: args as any,
    })
  }

  // 通用的合约写入(含模拟 + 发送)
  async write<TContractName extends keyof typeof this.config.contracts>(
    contractName: TContractName,
    functionName: string,
    args: readonly unknown[],
    options?: { value?: bigint },
  ) {
    if (!this.walletClient) throw new Error('Wallet not connected')

    const { address, abi } = this.config.contracts[contractName]

    // 先模拟执行,验证交易会成功
    const { request } = await this.publicClient.simulateContract({
      address,
      abi,
      functionName,
      args: args as any,
      account: this.walletClient.account,
      value: options?.value,
    })

    // 发送交易
    const hash = await this.walletClient.writeContract(request)

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

    return { hash, receipt, logs: receipt.logs }
  }

  // 解析事件日志
  parseEvents<TAbi extends Abi>(
    logs: Log[],
    abi: TAbi,
    eventName?: string,
  ) {
    const parsed = parseEventLogs({ logs, abi })
    return eventName ? parsed.filter((l) => l.eventName === eventName) : parsed
  }

  // 监听合约事件
  watchEvent<TContractName extends keyof typeof this.config.contracts>(
    contractName: TContractName,
    eventName: string,
    onLog: (log: any) => void,
    fromBlock?: bigint,
  ) {
    const { address, abi } = this.config.contracts[contractName]
    return this.publicClient.watchEvent({
      address,
      event: getEventFromAbi(abi, eventName),
      onLogs: (logs) => logs.forEach(onLog),
      fromBlock,
    })
  }

  // 批量读取(Multicall)
  async multicall(
    calls: { contractName: string; functionName: string; args: readonly unknown[] }[],
  ) {
    const multicallData = calls.map((call) => {
      const { address, abi } = this.config.contracts[call.contractName]
      return {
        address,
        abi,
        functionName: call.functionName,
        args: call.args as any,
      }
    })

    return this.publicClient.multicall({ contracts: multicallData })
  }
}

// 辅助函数
function getChainByChainId(chainId: number) {
  // 实际项目从 viem/chains 导入对应链
  return mainnet
}

function getEventFromAbi(abi: Abi, eventName: string) {
  const item = abi.find((entry) => entry.type === 'event' && entry.name === eventName)
  if (!item) throw new Error(`Event ${eventName} not found in ABI`)
  return item as any
}

export { DAppInteractionLayer }
export type { DAppConfig }

性能对比:bundle size 与执行效率 ​

viem 在 bundle size 上相比 ethers.js 有显著优势。以下是实测对比(minified + gzipped):

场景ethers.js v6viem v2节省
只读合约调用48.2 KB11.4 KB76%
读写合约调用72.8 KB23.1 KB68%
完整功能135.4 KB45.7 KB66%

viem 的优势来自 tree-shaking——只引入用到的 Action 函数。ethers.js 的 Contract 类会引入大量未使用代码。

执行效率方面,viem 使用 @noble/curves 替代 ethers.js 的自实现密码学库,在签名验证和公钥恢复上快 2-3 倍。ABI 编解码方面,viem 的实现也更高效,因为它在编译时就确定了编解码路径,而非运行时动态查找。

与 wagmi v2 的深度集成 ​

wagmi v2 完全基于 viem 构建,两者是天然配合。wagmi 提供了 React Hooks 层,viem 提供底层交互:

tsx
import { useAccount, useReadContract, useWriteContract } from 'wagmi'
import { parseEther } from 'viem'
import { mainnet } from 'viem/chains'

function TokenTransfer() {
  const { address } = useAccount()

  // 读取余额
  const { data: balance } = useReadContract({
    address: tokenAddress,
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: [address!],
  })

  // 写入交易
  const { writeContract, isPending, isSuccess, error } = useWriteContract()

  const handleTransfer = () => {
    writeContract({
      address: tokenAddress,
      abi: erc20Abi,
      functionName: 'transfer',
      args: [recipient, parseEther('1')],
    })
  }

  return (
    <div>
      <p>Balance: {balance?.toString()}</p>
      <button onClick={handleTransfer} disabled={isPending}>
        {isPending ? 'Sending...' : 'Transfer'}
      </button>
      {isSuccess && <p>Transaction confirmed!</p>}
      {error && <p>Error: {error.message}</p>}
    </div>
  )
}

wagmi v2 的 Hooks 底层调用 viem 的 Actions,类型完全贯通。ABI 的类型推导在 React 组件中同样生效。

迁移策略:从 ethers v5/v6 到 viem ​

迁移应分阶段进行:

阶段一:安装 viem 并行使用

bash
npm install viem

新功能使用 viem 编写,旧代码保持 ethers.js 不变。两者可以共享同一个 RPC 端点,互不干扰。

阶段二:抽象交互层

将直接使用 ethers.js 的代码重构为通过抽象层调用:

typescript
// 之前
import { ethers } from 'ethers'
const contract = new ethers.Contract(address, abi, signer)
const balance = await contract.balanceOf(user)

// 之后(通过抽象层)
const balance = await dapp.read('token', 'balanceOf', [user])

阶段三:逐步替换实现

将抽象层的 ethers.js 实现替换为 viem 实现:

typescript
// 之前
async read(contract, method, args) {
  const c = new ethers.Contract(address, abi, provider)
  return c[method](...args)
}

// 之后
async read(contract, method, args) {
  return this.publicClient.readContract({
    address, abi, functionName: method, args,
  })
}

阶段四:移除 ethers.js

确认所有功能都迁移到 viem 后,移除 ethers.js 依赖。如果使用了 TypeChain 生成类型,也需要移除——viem 内置的类型推导完全替代了它。

小结 ​

viem 代表了以太坊前端开发库的正确方向:tree-shakable 的打包、编译时的类型安全、极简的依赖树。它的 Actions 模式和 ABI 类型推导解决了 ethers.js 时代的核心痛点——打包体积大和类型不安全。wagmi v2 的全面采用使得 viem 成为了 React DApp 的事实标准。对于新项目,直接选择 viem 毫无疑问。对于存量项目,分阶段迁移策略可以将风险降到最低。理解 viem 的 Client/Transport/Chain/Account 四要素模型和 extend 扩展模式,是构建可维护 DApp 交互层的基础。

MIT Licensed