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 的架構建立在四個核心抽象之上:
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。
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 發送交易:
// 組合使用:讀取 + 寫入
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,開發者可以自定義:
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 就能提供完整的參數和返回值類型提示:
// 定義 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 等工具類型來提取特定函數的參數類型:
import { type GetAbiFunctionParameters } from 'viem'
type TransferArgs = GetAbiFunctionParameters<typeof wethAbi, 'transfer'>
// { to: Address; amount: bigint }
與 ethers.js 的 API 對比
以下表格對比常見操作:
| 操作 | ethers.js v6 | viem |
|---|---|---|
| 創建 Provider | new 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 交互層實現,涵蓋合約讀取、寫入、事件監聽和錯誤處理:
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 v6 | viem v2 | 節省 |
|---|---|---|---|
| 只讀合約調用 | 48.2 KB | 11.4 KB | 76% |
| 讀寫合約調用 | 72.8 KB | 23.1 KB | 68% |
| 完整功能 | 135.4 KB | 45.7 KB | 66% |
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 提供底層交互:
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 並行使用
npm install viem
新功能使用 viem 編寫,舊代碼保持 ethers.js 不變。兩者可以共享同一個 RPC 端點,互不干擾。
階段二:抽象交互層
將直接使用 ethers.js 的代碼重構為通過抽象層調用:
// 之前
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 實現:
// 之前
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 交互層的基礎。
