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