Gerrad Zhang
第一个完整 DApp:给合约装上可点击的前端

第一个完整 DApp:给合约装上可点击的前端

阶段 3 收官:上一篇部署到测试网结尾说「合约就位,等一个前端」,本篇补上它。内容:DApp 到底是什么(前端界面 + 链上智能合约作后端,规则上链、无人可单方面下架——回到阶段 3 开篇的平台封号痛点);前端三件套逐个写——连接 MetaMask(window.ethereum 检测 + eth_requestAccounts)、读合约状态(view 调用不发交易不花 gas)、发写交易(increment → tx.wait(1) 等确认 → 复读 → 从 receipt 回读 CountChanged 事件),代码全部来自 dapp/frontend/ 真实文件

Gerrad Zhang
Wuhan, China
2 min read

合约就位了,然后呢

上一篇(部署到测试网)的结尾停在:合约已经对外可见,但用户不可能用命令行跟它打交道——接下来写第一个完整 DApp ,连钱包、读 count、点按钮调 increment。本篇兑现这个预告。

回到阶段 3 开篇的痛点:中心化平台说封号就封号、说改规则就改规则。DApp 的回答是把「规则」从公司的服务器里搬出来,写进 智能合约 :前端只是界面,后端逻辑和状态都在链上——没有人能单方面下架它、篡改它。本篇要做的东西拆开极简单:一个 HTML 页面 + 一份 JS,ethers.js 从公共 CDN 引入,零构建、零框架,放在 dapp/frontend/ 下两个文件。

先说清验证口径:核心链路(读状态 → 发写交易 → 等 1 个确认 → 事件回读)已由冒烟脚本 dapp/scripts/local-e2e.js 在 localhost + hardhat network 上实跑验证通过(exit 0);浏览器 + MetaMask 的点击路径是用户侧操作指引,步骤与工程实况逐项一致。


前端三件套

第一件:连接钱包

浏览器里的网页默认没有身份。DApp 的身份由钱包提供:MetaMask 这类浏览器扩展会向页面注入一个 window.ethereum 对象,前端检测到它就能请求账户授权:

async function connect() {
  if (typeof window.ethereum === "undefined") {
    setStatus("未检测到钱包:请先安装 MetaMask 浏览器扩展。", true);
    return;
  }
  // eth_requestAccounts:MetaMask 弹窗,用户确认后返回账户列表
  const accounts = await window.ethereum.request({ method: "eth_requestAccounts" });
  provider = new ethers.BrowserProvider(window.ethereum);
  signer = await provider.getSigner();
  counter = new ethers.Contract(CONTRACT_ADDRESS, COUNTER_ABI, signer);
  $("account-display").textContent = "已连接:" + accounts[0];
  await refreshCount();
}

注意权限的方向:不是网页拿到私钥,而是网页请求钱包替它签名——私钥永远躺在钱包里,每一笔需要花钱的操作都要钱包主人点头。这就是为什么连接之后页面能「代表你」发交易,却偷不走你的资产。

第二件:读合约状态

count 是一次 view 函数调用——只查状态、不改状态,所以不发交易、不花 Gas ,像一次带共识保障的远程数据库查询:

async function refreshCount() {
  const count = await counter.getCount();
  $("count-display").textContent = count.toString();
}

合约地址和 ABI 放在单独的 frontend/config.js 里,是整个前端唯一需要改的配置位:

const CONTRACT_ADDRESS = "0x0000000000000000000000000000000000000000"; // 填部署输出的地址
const COUNTER_ABI = [
  "function increment() external",
  "function decrement() external",
  "function getCount() view returns (uint256)",
  "function count() view returns (uint256)",
  "event CountChanged(address indexed who, uint256 newValue)",
];

ABI 用 human-readable 形式内嵌在页面里,而不是引用 artifacts/ 里的编译产物——artifacts 是 gitignore 的构建文件,含完整字节码,冗长且不适合静态页面稳定引用。

第三件:发写交易并等它落账

increment() 改状态,是一次真正的交易:要签名、要付 gas、要等打包。前端把这三件事串起来,再加一步事件回读:

const tx = await counter.increment();      // 发交易,返回 TransactionResponse
const receipt = await tx.wait(1);           // 等待 1 个确认,返回 TransactionReceipt
const after = await counter.getCount();     // 确认后复读链上状态

// 从 receipt 日志回读 CountChanged(address indexed who, uint256 newValue)
for (const log of receipt.logs) {
  const parsed = counter.interface.parseLog(log);
  if (parsed && parsed.name === "CountChanged") {
    // parsed.args.who / parsed.args.newValue
  }
}

为什么要「复读」和「事件回读」两个动作?复读 count 是向链求证最终状态;解析 CountChanged 事件则是拿到变更过程——谁(who)在哪个交易里把它改成了多少(newValue)。事件日志是 EVM 提供的原生「变更流水」,前端可以拿它驱动界面更新(比如刷新历史记录列表)。


一张图看懂完整数据流

点一下「increment +1」按钮,背后发生的事贯穿了阶段 1-3 学过的所有概念:

┌──────────┐  ① 构造调用数据(ABI 编码 increment())
│  浏览器   │ ────────────────────────────────┐
│ 页面/JS  │  ② MetaMask 弹窗请求签名          │
└──────────┘ ────────────────────────────────┤

        签名后的交易 { to: 合约地址, data: 调用数据, nonce, gas 上限 }


┌──────────┐  ③ provider(RPC)           ┌──────────┐
│ 本地节点/ │ ◄─────────────────────────── │  内存池   │ 交易先在这里候车
│ 测试网    │  ④ 验证者打包出块             └──────────┘
└──────────┘ ────────────────────────────────┬─────
   ⑤ EVM 执行合约字节码:count = count + 1     │
      emit CountChanged(msg.sender, count)    ▼
   ⑥ 确认后:receipt(hash/block/日志)→ 前端复读 count

逐条对回阶段 1-3:

  • ① ABI 编码increment() 被编码成函数选择符 + 参数,拼进交易的 data 字段——这是阶段 3 Solidity 篇的 ABI 概念在前端的落点。
  • ② 签名:钱包用私钥对交易签名,证明「发起者是我」;交易的 nonce 是我账户的第几笔——账户模型Nonce 防重放的直接应用(阶段 2)。
  • ③④ 广播与打包:签好名的交易经 provider(RPC 端点)进入 内存池 ,被下一个区块收编——阶段 1 讲过的打包流程。
  • ⑤ 执行:打包者运行合约的 字节码 ,EVM 完成加法、写存储、发出事件——每一存储写和计算步都计 gas,由发起账户按 Gas 单价付费(阶段 2)。
  • ⑥ 回读:确认到达后前端拿 receipt 和复读结果更新界面——「读」到的不是谁的口头承诺,是链上共识后的状态。

一笔 +1,把三个阶段的概念全部过了一遍。


本地联调:把整条链路跑起来

以下步骤在 dapp/ 工程内操作(已在仓库实跑验证)。

1. 起本地节点(终端 1,保持运行):

$ npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/
# 打印 20 个开发账户(地址 + 私钥),链 ID 31337

为了让 --network localhost 可用,hardhat.config.js 里补了一行网络别名:

networks: {
  localhost: { url: "http://127.0.0.1:8545" },
  sepolia: { /* ... */ },
},

2. 部署到本地节点(终端 2),把输出的地址填进 frontend/config.jsCONTRACT_ADDRESS

$ npx hardhat run scripts/deploy.js --network localhost
Deployer address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Counter deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3

3. 起静态服务并打开页面(终端 2):

$ cd frontend && python3 -m http.server 8788
# 浏览器打开 http://127.0.0.1:8788/index.html

4. 配置 MetaMask:添加自定义网络——RPC 端点 http://127.0.0.1:8545、链 ID 31337、货币符号 ETH;再「导入账户」,粘贴节点启动时打印的 #0 账户私钥0xf39F... 开头地址对应的那行)。

5. 点起来:连接 MetaMask → 读 count(0)→ increment +1 → 钱包确认 → 状态区显示交易 hash、count 0 → 1、CountChanged 事件:who = 0xf39F..., newValue = 1

想换到 测试网 Sepolia?只改两处:config.js 里换成测试网部署的合约地址,MetaMask 切到 Sepolia 网络。页面代码一行不用动。


总结:阶段 3 闭环

四篇走完:Solidity 写出 Counter → Hardhat 给它工程化和测试 → 部署让它对外可见 → 本篇给它装上前端。回头看阶段 3 的主线,是从「理解」到「动手写」:智能合约 不再是概念,是 dapp/ 里能部署、能测试、能在浏览器里点的的东西;DApp 不再是名词,是「一个 HTML 页面 + 一份合约」这样具体到不需要敬畏的东西。

下一步进入阶段 4:DeFi 。已经具备的这块积木——可以在链上无条件执行的规则——正是金融市场需要的最小单元:把借贷、兑换、做市这些金融逻辑写成合约互相组合,就是去中心化金融。从「一个能点的计数器」到「一个能用的交易所」,距离没有想象中远。

相关文章:

Comments

Link copied to clipboard!