纯 Viem 脚手架:最干净的链上交互方式

前言
在 Web3 开发领域,与以太坊等区块链网络进行交互是构建去中心化应用(DApp)的核心环节。传统的 Web3 开发框架如 wagmi 为开发者提供了便利的 React Hooks,但有时我们也需要更底层、更灵活的控制。
本文将介绍一个纯 Viem 脚手架项目,详细分析如何使用 Viem 库直接与 MetaMask 钱包和智能合约进行交互,不依赖 wagmi 等高级抽象库,让开发者更好地理解底层交互逻辑。Viem 作为下一代以太坊开发工具,相比传统的 ethers.js 和 web3.js,提供了更现代化的 TypeScript 接口和更轻量级的实现。
项目概述
这是一个使用 Next.js 和 Viem 构建的简单 DApp 脚手架项目,主要功能包括连接钱包、获取账户信息、读写智能合约以及监听钱包状态变化。该项目采用纯 Viem 实现,没有使用 wagmi 等第三方状态管理库。
为什么选择纯 Viem?
纯 Viem 方案在 Web3 开发中因其轻量级设计和底层控制能力,逐渐成为开发者调查中的新趋势,尤其适合追求灵活性和性能的场景。相较于 wagmi 等高级抽象库,纯 Viem 提供了以下显著优势:
更细粒度的控制
开发者可以直接操作每个链上请求,深入理解底层通信逻辑,便于调试和优化,无需受抽象层限制。
轻量级实现
不依赖额外的状态管理库,项目体积大幅减少(仅
viem比 wagmi 全家桶少 70%+),加载速度显著提升。灵活性更高
根据项目需求自由定制交互逻辑,不被高级库的预设框架束缚,适合复杂或定制化场景。
更好的 TypeScript 支持
Viem 的原生类型推断确保合约交互类型安全,降低运行时错误风险,成为开发者信赖的核心。
更直观的 API 设计
API 贴近以太坊原生操作,易于掌握区块链交互本质,减少学习曲线。
调试友好与未来趋势
出错时直接面对 Viem 的原始错误信息,无需解构 hook 问题,调试效率翻倍。同时,wagmi v2 已全面转向 Viem,纯 Viem 方案是未来 Web3 开发的标杆。
这种架构不仅适合初学者快速上手,也为高级开发者提供无限扩展空间,是链上开发的理想选择。
项目创建步骤
项目初始化
1# 1. 创建 Next.js 项目(App Router)
2npx create-next-app@latest simple-viem --typescript --tailwind --eslint --app --import-alias "@/*"
3
4cd simple-viem
5
6# 2. 安装核心依赖
7pnpm install \
8 viem@latest \
9 antd \
10 @ant-design/icons \
11 @ant-design/nextjs-registry \
12 dotenv
13
14# 3. 安装开发依赖
15pnpm install -D \
16 prettier
智能合约部分初始化
1# 在项目根目录初始化 Foundry(智能合约)
2forge init contracts
3cd contracts
4
5# 删除 foundry 的 git 仓库,统一使用上层的 git 仓库
6rm -rf .git
7
8# 安装 OpenZeppelin,不能使用 git 安装,否则会使仓库管理混乱
9forge install --no-git OpenZeppelin/openzeppelin-contracts
10
11# 生成 ABI 文件
12mkdir ../app/abis
13forge inspect Counter abi --json > ../app/abis/Counter.json
核心依赖分析
该项目的核心依赖包括:
- Next.js:React 框架,提供 SSR 和现代化的开发体验
- Viem:用于与以太坊区块链交互的 TypeScript 库,是项目的核心
- Ant Design:UI 组件库
- Foundry:以太坊开发工具链(智能合约部分)
配置文件
项目的 tsconfig.json、package.json 等配置文件均遵循 next.js 的最佳实践配置,同时 TypeScript 确保了代码的类型安全。
项目文件结构
simple-viem/
├── app/
│ ├── abis/ # 智能合约ABI文件
│ │ └── Counter.json # Counter合约的ABI
│ ├── favicon.ico # 网站图标
│ ├── globals.css # 全局样式
│ ├── layout.tsx # Next.js布局组件
│ ├── page.tsx # 主页面,包含所有交互逻辑
│ ├── providers.tsx # 提供者组件(空实现)
│ └── types/
│ └── ethereum.d.ts # TypeScript类型定义
├── contracts/ # 智能合约目录
│ ├── .env # 环境变量配置
│ ├── foundry.toml # Foundry配置文件
│ ├── lib/ # 依赖库(OpenZeppelin)
│ ├── out/ # 编译输出目录
│ ├── script/ # 部署脚本
│ ├── src/ # 合约源码
│ │ └── Counter.sol # Counter智能合约
│ └── test/ # 测试文件
├── .gitignore # Git忽略文件
├── .prettierrc.cjs # Prettier配置
├── eslint.config.mjs # ESLint配置
├── next-env.d.ts # Next.js类型声明
├── next.config.ts # Next.js配置
├── package.json # 项目依赖配置
├── postcss.config.mjs # PostCSS配置
├── public/ # 静态资源目录
│ ├── favicon.ico # 网站图标
│ └── vercel.svg # Vercel图标
└── tsconfig.json # TypeScript配置
核心代码实现
Page.tsx 页面的 Viem 操作分析
项目的核心交互逻辑集中在 app/page.tsx 文件中,下面详细分析其中的关键操作:
支持多链配置
1// 支持的链
2const SUPPORTED_CHAINS = [foundry, sepolia] as const;
3
4const COUNTER_ADDRESS_FOUNDRY = '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9';
5const COUNTER_ADDRESS_SEPOLIA = '0x7B6781B15b4f3eF8476af20Ed45Cf6d09e0Ef55F';
6
7function getCounterAddress(chainId: number) {
8 return chainId === foundry.id ? COUNTER_ADDRESS_FOUNDRY : COUNTER_ADDRESS_SEPOLIA;
9}
该项目支持多条链(Foundry 和 Sepolia 测试网),通过地址映射实现在不同网络上访问对应的合约实例。这种设计使得 DApp 能适应不同的开发和测试环境。
连接钱包
1const connectWallet = async () => {
2 if (typeof window.ethereum === 'undefined') {
3 alert('请安装 MetaMask');
4 return;
5 }
6 try {
7 setIsLoading(true);
8 const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });
9 const chainId = await window.ethereum.request({ method: 'eth_chainId' });
10 setAddress(address as `0x${string}`);
11 setChainId(Number(chainId));
12 setIsConnected(true);
13 } catch (error) {
14 console.error('连接钱包失败:', error);
15 } finally {
16 setIsLoading(false);
17 }
18};
连接钱包功能通过直接调用 window.ethereum.request 方法实现,请求 eth_requestAccounts 方法获取用户授权的账户地址,同时获取当前链 ID。这种方式绕过了 wagmi 等高级抽象,直接使用 EIP-1193 标准与钱包通信。
获取钱包信息
1// 读取余额
2const fetchBalance = useCallback(async () => {
3 if (!address || !chainId) return;
4 try {
5 const client = getPublicClient(chainId);
6 const bal = await client.getBalance({ address });
7 setBalance(formatEther(bal));
8 } catch (err) {
9 console.error('读取余额失败', err);
10 }
11}, [address, chainId]);
12
13function getPublicClient(chainId: number) {
14 const chain = SUPPORTED_CHAINS.find(c => c.id === chainId) ?? foundry;
15 return createPublicClient({
16 chain,
17 transport: http(),
18 }).extend(publicActions);
19}
获取钱包信息分为几个步骤:
- 创建 publicClient,用于与区块链进行只读交互
- 使用
client.getBalance方法获取指定地址的余额 - 使用
formatEther将 bigint 格式的余额转换为易读的 ETH 格式
读写智能合约
读取合约数据
1const fetchCounterNumber = useCallback(async () => {
2 if (!chainId) return;
3 try {
4 const client = getPublicClient(chainId);
5 const contract = getContract({
6 address: getCounterAddress(chainId),
7 abi: Counter_ABI,
8 client,
9 });
10 const num = (await contract.read.number()) as bigint;
11 setCounterNumber(num.toString());
12 } catch (err) {
13 console.error('读取 Counter 失败', err);
14 }
15}, [chainId]);
读取合约数据的步骤:
- 创建 publicClient
- 使用
getContract创建合约实例 - 调用
contract.read[functionName]方法读取合约状态
写入合约数据
1const handleIncrement = async () => {
2 if (!address || !window.ethereum || !chainId) return;
3 const walletClient = getWalletClient();
4 if (!walletClient) return alert('钱包未连接');
5 try {
6 setIsLoading(true);
7 const hash = await walletClient.writeContract({
8 address: getCounterAddress(chainId),
9 abi: Counter_ABI,
10 functionName: 'increment',
11 account: address,
12 });
13 console.log('Transaction hash:', hash);
14
15 const receipt = await walletClient.waitForTransactionReceipt({ hash: hash });
16 console.log(`交易状态: ${receipt.status === 'success' ? '成功' : '失败'}`);
17
18 // 更新数值显示
19 await fetchCounterNumber();
20 } catch (error) {
21 console.error('调用 increment 失败:', error);
22 } finally {
23 setIsLoading(false);
24 }
25};
26
27// 创建 walletClient(只在需要签名时创建)
28const getWalletClient = useCallback(() => {
29 if (!window.ethereum || !chainId || !address) return null;
30 const chain = SUPPORTED_CHAINS.find(c => c.id === chainId) ?? foundry;
31 return createWalletClient({
32 account: address,
33 chain,
34 transport: custom(window.ethereum),
35 }).extend(publicActions);
36}, [address, chainId]);
写入合约数据的步骤:
- 创建 walletClient,用于需要签名的交易
- 调用
writeContract方法发送交易到合约 - 使用
waitForTransactionReceipt等待交易确认 - 更新相关状态
时序图:
sequenceDiagram
participant User as 用户(浏览器)
participant Page as React 页面 (page.tsx)
participant WalletClient as Viem WalletClient
participant MetaMask as MetaMask 钱包
participant Node as 节点 (Infura/Alchemy/Anvil)
participant Chain as 区块链
participant publicClient as publicClient
User->>Page: 点击 “+1” 按钮
Page->>Page: 调用 handleIncrement()
Page->>Page: getWalletClient() 创建 walletClient
Page->>WalletClient: writeContract({ ..., functionName: 'increment' })
WalletClient->>MetaMask: eth_sendTransaction (签名请求弹窗)
MetaMask-->>User: 请确认交易…
User->>MetaMask: 点击【确认】
MetaMask->>WalletClient: 返回已签名的交易
WalletClient->>Node: 广播交易 (hash)
Node-->>Chain: 提交交易
WalletClient-->>Page: 返回 transaction hash
Page->>Page: console.log('Transaction hash:', hash)
Note over Page,Chain: 等待上链确认
Page->>WalletClient: waitForTransactionReceipt({ hash })
WalletClient->>Node: 轮询 receipt
Node-->>Chain: 区块已打包
Node-->>WalletClient: 返回 receipt (status: success)
WalletClient-->>Page: receipt
Page->>Page: console.log('交易成功')
Page->>Page: 调用 fetchCounterNumber()
Page->>publicClient: readContract({ functionName: 'number' })
publicClient->>Node: call (只读)
Node-->>publicClient: 返回最新 number
publicClient-->>Page: 返回新值
Page->>Page: setCount(新值) → 页面更新
Note over User,Chain: 整个过程用户只点了一次确认, 所有状态自动同步断开连接
1const disconnectWallet = useCallback(async () => {
2 if (!address || !window.ethereum || !chainId) return;
3 setIsConnected(false);
4 setAddress(undefined);
5 setChainId(undefined);
6 setBalance('0');
7 setCounterNumber('0');
8 try {
9 // 对于 MetaMask 10.28+
10 await window.ethereum.request({
11 method: 'wallet_revokePermissions',
12 params: [{ eth_accounts: {} }],
13 });
14 // 老版本 MM 会抛 4200 错误,捕获即可
15 } catch (e: unknown) {
16 if (typeof e === 'object' && e !== null && 'code' in e && (e as { code: unknown }).code === 4200) {
17 alert('请手动在钱包里断开本次连接');
18 }
19 }
20}, [address, chainId]);
断开连接功能不仅清空了本地状态,还通过 wallet_revokePermissions 方法撤销了对钱包的访问权限。
监听钱包操作
1useEffect(() => {
2 if (!window.ethereum) return;
3
4 const handleAccountsChanged = (accounts: string[]) => {
5 console.log('账户变化', accounts);
6 if (accounts.length === 0) {
7 disconnectWallet().catch(console.error);
8 } else {
9 setAddress(accounts[0] as `0x${string}`);
10 }
11 };
12
13 const handleChainChanged = (chainIdHex: string) => {
14 console.log('网络变化', chainIdHex);
15 setChainId(Number(chainIdHex));
16 };
17
18 window.ethereum.on('accountsChanged', handleAccountsChanged);
19 window.ethereum.on('chainChanged', handleChainChanged);
20
21 return () => {
22 window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
23 window.ethereum?.removeListener('chainChanged', handleChainChanged);
24 };
25}, [address, disconnectWallet, fetchBalance, fetchCounterNumber]);
通过监听 accountsChanged 和 chainChanged 事件,DApp 能够实时响应用户的账户切换和网络切换操作,保持应用状态与钱包状态的一致性。
Counter 智能合约分析
Counter 合约是一个简单的计数器合约,包含以下功能:
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4contract Counter {
5 uint256 public number;
6
7 function setNumber(uint256 newNumber) public {
8 number = newNumber;
9 }
10
11 function increment() public {
12 number++;
13 }
14}
该合约提供了两个主要功能:
number()- 读取当前计数值(getter function)increment()- 将计数值加 1setNumber()- 设置新的计数值
纯 Viem 脚手架项目的架构优势
更细粒度的控制
使用纯 Viem 相比于 wagmi 等抽象库,开发者能够更精确地控制每个操作,了解底层通信逻辑,便于调试和优化。
轻量级实现
不引入额外的状态管理库,减少了项目体积,提高了加载速度。
灵活性更高
可以根据项目需求定制特定的交互逻辑,而不受高级抽象库的限制。
更好的 TypeScript 支持
Viem 提供了优秀的 TypeScript 类型推断,确保合约交互的类型安全。
更直观的 API 设计
Viem 的 API 设计更接近以太坊的原生操作,便于理解区块链交互的本质。
对比 wagmi 的核心差异
| 功能 | wagmi 写法(抽象) | 纯 Viem 写法 |
|---|---|---|
| 连接钱包 | useConnect() | window.ethereum.request('eth_requestAccounts') |
| 切换链/账号自动更新 | wagmi 自动 | 手动监听 accountsChanged / chainChanged |
| 读余额 | useBalance() | publicClient.getBalance() |
| 读合约 | useReadContract() | publicClient.readContract() |
| 发交易 | useWriteContract() | walletClient.writeContract() |
| 等待确认 | 自动 | walletClient.waitForTransactionReceipt() |
完整代码示例
下面是一个完整的示例,展示了如何使用纯 Viem 构建一个功能完整的 DApp:
1'use client';
2
3import { useState, useEffect, useCallback } from 'react';
4import { createPublicClient, createWalletClient, http, formatEther, getContract, custom, publicActions } from 'viem';
5import { foundry, sepolia } from 'viem/chains';
6import Counter_ABI from './abis/Counter.json';
7
8// 支持的链
9const SUPPORTED_CHAINS = [foundry, sepolia] as const;
10
11// Counter 合约地址
12const COUNTER_ADDRESS_FOUNDRY = '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9';
13const COUNTER_ADDRESS_SEPOLIA = '0x7B6781B15b4f3eF8476af20Ed45Cf6d09e0Ef55F';
14
15function getCounterAddress(chainId: number) {
16 return chainId === foundry.id ? COUNTER_ADDRESS_FOUNDRY : COUNTER_ADDRESS_SEPOLIA;
17}
18
19function getPublicClient(chainId: number) {
20 const chain = SUPPORTED_CHAINS.find(c => c.id === chainId) ?? foundry;
21 return createPublicClient({
22 chain,
23 transport: http(),
24 }).extend(publicActions);
25}
26
27export default function Home() {
28 const [balance, setBalance] = useState<string>('0');
29 const [counterNumber, setCounterNumber] = useState<string>('0');
30 const [address, setAddress] = useState<`0x${string}` | undefined>();
31 const [isConnected, setIsConnected] = useState(false);
32 const [chainId, setChainId] = useState<number | undefined>();
33 const [isLoading, setIsLoading] = useState(false);
34
35 const currentChain = SUPPORTED_CHAINS.find(c => c.id === chainId);
36
37 // 创建 walletClient(只在需要签名时创建)
38 const getWalletClient = useCallback(() => {
39 if (!window.ethereum || !chainId || !address) return null;
40 const chain = SUPPORTED_CHAINS.find(c => c.id === chainId) ?? foundry;
41 return createWalletClient({
42 account: address,
43 chain,
44 transport: custom(window.ethereum),
45 }).extend(publicActions);
46 }, [address, chainId]);
47
48 // 获取 Counter 合约的数值
49 const fetchCounterNumber = useCallback(async () => {
50 if (!chainId) return;
51 try {
52 const client = getPublicClient(chainId);
53 const contract = getContract({
54 address: getCounterAddress(chainId),
55 abi: Counter_ABI,
56 client,
57 });
58 const num = (await contract.read.number()) as bigint;
59 setCounterNumber(num.toString());
60 } catch (err) {
61 console.error('读取 Counter 失败', err);
62 }
63 }, [chainId]);
64
65 // 读取余额
66 const fetchBalance = useCallback(async () => {
67 if (!address || !chainId) return;
68 try {
69 const client = getPublicClient(chainId);
70 const bal = await client.getBalance({ address });
71 setBalance(formatEther(bal));
72 } catch (err) {
73 console.error('读取余额失败', err);
74 }
75 }, [address, chainId]);
76
77 // 连接钱包
78 const connectWallet = async () => {
79 if (typeof window.ethereum === 'undefined') {
80 alert('请安装 MetaMask');
81 return;
82 }
83 try {
84 setIsLoading(true);
85 const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });
86 const chainId = await window.ethereum.request({ method: 'eth_chainId' });
87 setAddress(address as `0x${string}`);
88 setChainId(Number(chainId));
89 setIsConnected(true);
90 } catch (error) {
91 console.error('连接钱包失败:', error);
92 } finally {
93 setIsLoading(false);
94 }
95 };
96
97 // 断开连接
98 const disconnectWallet = useCallback(async () => {
99 if (!address || !window.ethereum || !chainId) return;
100 setIsConnected(false);
101 setAddress(undefined);
102 setChainId(undefined);
103 setBalance('0');
104 setCounterNumber('0');
105 try {
106 // 对于 MetaMask 10.28+
107 await window.ethereum.request({
108 method: 'wallet_revokePermissions',
109 params: [{ eth_accounts: {} }],
110 });
111 // 老版本 MM 会抛 4200 错误,捕获即可
112 } catch (e: unknown) {
113 if (typeof e === 'object' && e !== null && 'code' in e && (e as { code: unknown }).code === 4200) {
114 alert('请手动在钱包里断开本次连接');
115 }
116 }
117 }, [address, chainId]);
118
119 // 调用 increment 函数
120 const handleIncrement = async () => {
121 if (!address || !window.ethereum || !chainId) return;
122 const walletClient = getWalletClient();
123 if (!walletClient) return alert('钱包未连接');
124 try {
125 setIsLoading(true);
126 const hash = await walletClient.writeContract({
127 address: getCounterAddress(chainId),
128 abi: Counter_ABI,
129 functionName: 'increment',
130 account: address,
131 });
132 console.log('Transaction hash:', hash);
133
134 const receipt = await walletClient.waitForTransactionReceipt({ hash: hash });
135 console.log(`交易状态: ${receipt.status === 'success' ? '成功' : '失败'}`);
136
137 // 更新数值显示
138 await fetchCounterNumber();
139 } catch (error) {
140 console.error('调用 increment 失败:', error);
141 } finally {
142 setIsLoading(false);
143 }
144 };
145
146 // 全局监听(只添加一次)
147 useEffect(() => {
148 if (!window.ethereum) return;
149
150 const handleAccountsChanged = (accounts: string[]) => {
151 console.log('账户变化', accounts);
152 if (accounts.length === 0) {
153 disconnectWallet().catch(console.error);
154 } else {
155 setAddress(accounts[0] as `0x${string}`);
156 }
157 };
158
159 const handleChainChanged = (chainIdHex: string) => {
160 console.log('网络变化', chainIdHex);
161 setChainId(Number(chainIdHex));
162 };
163
164 window.ethereum.on('accountsChanged', handleAccountsChanged);
165 window.ethereum.on('chainChanged', handleChainChanged);
166
167 return () => {
168 window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
169 window.ethereum?.removeListener('chainChanged', handleChainChanged);
170 };
171 }, [address, disconnectWallet, fetchBalance, fetchCounterNumber]);
172
173 // 连接后自动读取数据
174 useEffect(() => {
175 if (address && chainId) {
176 console.log('连接后自动读取数据:', address);
177 fetchBalance().catch(console.error);
178 fetchCounterNumber().catch(console.error);
179 }
180 }, [address, chainId, fetchBalance, fetchCounterNumber]);
181
182 return (
183 <div className='min-h-screen flex flex-col items-center justify-center p-8'>
184 <h1 className='text-3xl font-bold mb-8'>Simple Viem Demo</h1>
185
186 <div className='bg-white p-6 rounded-lg shadow-lg w-full max-w-2xl'>
187 {!isConnected ? (
188 <button
189 onClick={connectWallet}
190 disabled={isLoading}
191 className='w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors'
192 >
193 {isLoading ? '连接中...' : '连接 MetaMask'}
194 </button>
195 ) : (
196 <div className='space-y-4'>
197 <div className='text-center'>
198 <p className='text-gray-600'>钱包地址:</p>
199 <p className='font-mono break-all'>{address}</p>
200 </div>
201 <div className='text-center'>
202 <p className='text-gray-600'>当前网络:</p>
203 <p className='font-mono'>
204 {currentChain?.name || '未知网络'} (Chain ID: {chainId})
205 </p>
206 </div>
207 <div className='text-center'>
208 <p className='text-gray-600'>余额:</p>
209 <p className='font-mono'>{balance} ETH</p>
210 </div>
211 <div className='text-center'>
212 <p className='text-gray-600'>Counter 数值:</p>
213 <p className='font-mono'>{counterNumber}</p>
214 <button
215 onClick={handleIncrement}
216 disabled={isLoading}
217 className='mt-2 w-full bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600 transition-colors'
218 >
219 {isLoading ? '交易进行中...' : '增加计数'}
220 </button>
221 </div>
222 <button
223 onClick={disconnectWallet}
224 className='w-full bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600 transition-colors'
225 >
226 断开连接
227 </button>
228 </div>
229 )}
230 </div>
231 </div>
232 );
233}
总结
本文详细分析了一个纯 Viem 脚手架项目,展示了如何使用 Viem 库直接与 MetaMask 钱包和智能合约进行交互,包括:
- 项目创建步骤和核心依赖
- 如何使用 Viem 实现多链支持
- 如何连接和断开 MetaMask 钱包
- 如何获取钱包信息和余额
- 如何读写智能合约
- 如何监听钱包状态变化
- 纯 Viem 实现相对于 wagmi 等库的优势
纯 Viem 方案为开发者提供了更底层的控制和更灵活的实现方式,适合需要深入了解区块链交互逻辑的开发者使用。这种架构不仅保持了代码的简洁性,还提供了更大的扩展空间。
参考资料
附录 - C4 架构模型
1. 系统上下文图 (System Context Diagram)
C4Context
Boundary(b0, "Simple Viem 应用", "Web3 DApp 应用") {
System(s1, "Web3 DApp 前端", "基于 Next.js 的 Web3 应用", "React + Next.js + Viem")
}
System_Ext(eth, "以太坊网络", "Ethereum Blockchain", "Ethereum Mainnet/Sepolia")
System_Ext(user, "用户", "MetaMask 钱包用户", "DApp 用户")
System_Ext(mm, "MetaMask", "浏览器钱包扩展", "钱包和交易签名")
Rel(user, mm, "使用", "浏览器扩展")
Rel(mm, s1, "连接", "JSON-RPC")
Rel(s1, eth, "读写合约 | 查询账户 | 发送交易", "JSON-RPC")2. 容器图 (Container Diagram)
C4Container
Boundary(b0, "Simple Viem 系统", "Web3 DApp 系统") {
Container(web, "Next.js Web 应用", "React/Next.js", "提供 Web3 DApp 用户界面", "Web 应用")
Container(ant, "Ant Design UI", "UI 组件库", "提供现代化 UI 组件", "UI 框架")
Container(viem, "Viem 客户端", "Viem", "Ethereum 客户端库", "区块链交互")
}
Boundary(b1, "Ethereum 网络", "区块链基础设施") {
Container(contract, "Counter 智能合约", "Solidity", "简单的计数器合约", "EVM 合约")
Container(eth_node, "Ethereum 节点", "JSON-RPC 节点", "提供区块链数据访问", "区块链节点")
}
System_Ext(user, "用户", "MetaMask 钱包用户", "DApp 用户")
System_Ext(mm, "MetaMask", "浏览器钱包扩展", "钱包和交易签名")
Rel(user, web, "使用", "HTTPS")
Rel(web, ant, "使用", "React 组件")
Rel(web, viem, "使用", "Viem API")
Rel(viem, mm, "连接", "JSON-RPC")
Rel(viem, eth_node, "查询/交易", "JSON-RPC")
Rel(eth_node, contract, "执行", "EVM")
Rel(contract, eth_node, "依赖", "区块链状态")3. 组件图 (Component Diagram)
C4Component
Boundary(b0, "Next.js Web 应用", "前端应用") {
Component(layout, "Root Layout", "React Component", "应用根布局", "Next.js Layout")
Component(home, "Home Component", "React Component", "主页面组件", "Next.js Page")
Component(providers, "Providers", "React Component", "应用上下文提供者", "React Context")
Component(viem_api, "Viem API 集成", "React Hooks", "区块链交互逻辑", "Viem 库")
Component(contract_api, "合约接口", "ABI", "合约接口定义", "JSON ABI")
}
Boundary(b1, "Viem 客户端", "区块链交互层") {
Component(pub_client, "Public Client", "Viem Client", "用于读取操作", "Viem")
Component(wal_client, "Wallet Client", "Viem Client", "用于写入操作", "Viem")
Component(contract_inst, "合约实例", "Viem Contract", "合约交互封装", "Viem")
}
Boundary(b2, "智能合约", "EVM 合约") {
Component(counter, "Counter 合约", "Solidity Contract", "计数器功能", "Solidity")
}
Rel(home, layout, "使用", "React 组合")
Rel(home, providers, "使用", "React Context")
Rel(home, viem_api, "调用", "React Hooks")
Rel(viem_api, pub_client, "创建", "Viem API")
Rel(viem_api, wal_client, "创建", "Viem API")
Rel(viem_api, contract_inst, "使用", "合约 ABI")
Rel(contract_api, contract_inst, "定义", "ABI")
Rel(contract_inst, counter, "交互", "Ethereum 调用")
Rel(pub_client, counter, "读取", "Ethereum 调用")
Rel(wal_client, counter, "写入", "Ethereum 调用")4. 代码图 (Code Diagram)
C4Component
Container_Boundary(NextjsApp, "Next.js 应用") {
Container(app, "app 目录", "Next.js App Router")
Container_Boundary(appAbis, "abis") {
Component(counterAbi, "Counter.json", "ABI 文件")
}
Container_Boundary(appTypes, "types") {
Component(ethD, "ethereum.d.ts", "类型定义")
}
Component(layout, "layout.tsx", "根布局")
Component(page, "page.tsx", "主页组件")
Component(providers, "providers.tsx", "提供者")
Component(pkg, "package.json", "项目依赖")
}
Container_Boundary(Contracts, "智能合约") {
Container(contracts, "contracts 目录", "Foundry")
Container_Boundary(src, "src") {
Component(counterSol, "Counter.sol", "计数器合约")
}
Component(foundryToml, "foundry.toml", "Foundry 配置")
}
Rel(page, counterAbi, "导入", "ABI")
Rel(page, counterSol, "对应", "合约接口")
Rel(pkg, page, "包含", "React 组件")
Rel(foundryToml, counterSol, "编译", "Foundry")5. 部署图 (Deployment Diagram)
C4Deployment
Deployment_Node(DevEnv, "开发环境") {
Deployment_Node(Browser, "浏览器") {
Deployment_Node(NextApp, "Next.js 应用") {
Container(NextjsApp, "Next.js Web 应用", "React & Next.js", "提供 Web3 DApp 用户界面")
Container(AntDesign, "Ant Design UI", "UI 组件库", "提供现代化 UI 组件")
Container(ViemClient, "Viem 客户端", "Viem", "Ethereum 客户端库")
}
Deployment_Node(Wallet, "MetaMask 钱包") {
Container(MetaMask, "MetaMask", "钱包扩展", "提供账户管理")
}
}
Deployment_Node(Blockchain, "区块链网络") {
Deployment_Node(LocalNode, "本地节点 [Foundry]", "Anvil", "本地开发链") {
Container(LocalContract, "Counter 合约", "Solidity", "Foundry 本地合约")
}
Deployment_Node(TestNode, "测试节点 [Sepolia]", "RPC", "Sepolia 测试网") {
Container(TestContract, "Counter 合约", "Solidity", "Sepolia 合约")
}
}
}
Rel(NextjsApp, MetaMask, "JSON-RPC 调用", "EIP-1193")
Rel(ViemClient, LocalContract, "读写调用", "HTTP")
Rel(ViemClient, TestContract, "读写调用", "HTTP")6. 交互时序图 (Sequence Diagram)
sequenceDiagram
participant U as 用户
participant W as MetaMask
participant A as Next.js 应用
participant V as Viem 客户端
participant C as Counter 合约
U->>+A: 访问 DApp
A->>+W: 请求连接钱包
W->>-A: 返回账户地址
A->>-U: 显示连接状态
U->>+A: 点击"增加计数"
A->>+V: 创建 walletClient
V->>+W: 请求交易签名
W->>-V: 返回签名
V->>+C: 发送 increment 交易
C-->>-V: 交易确认
V-->>-A: 交易完成
A->>-U: 更新计数值显示