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 交互层的基础。
