Web3 全栈开发实战:合约事件监听与实时更新

在上一篇文章中,我们搭建了一个完整的钱包集成流程,并且实现了对 ValueStore 合约的读写操作。但是,仅仅通过手动点击按钮去查询链上状态,并不够“实时”。真正的 DApp 通常需要主动监听链上事件,第一时间更新 UI,同时后端也需要索引这些事件来构建历史记录和数据分析。

今天这篇教程,我们就以 ValueStore 合约的 ValueChanged 事件为例,一步步完成:

  • 前端使用 useWatchContractEvent 实时接收事件并更新页面
  • 后端使用 viemwatchContractEvent 将事件持久化到 SQLite
  • 前端通过 REST API 查询历史事件,实现数据回放

全文代码可直接运行,你可以在上一篇项目的基础上继续添加。


1. 为什么需要监听合约事件

智能合约的 event 是日志(Log)的一种高层次封装。每当合约状态发生重要变更(比如转账、参数修改),就会触发相应的事件。监听事件有三大好处:

  1. 实时性:不用轮询,一旦上链就能立刻知道。
  2. 完整性:可以回溯所有历史事件,构建可靠的数据索引。
  3. 解耦:前端/后端只关心事件,不需要知道触发交易的具体逻辑。

我们将使用 ValueChanged(uint256 newValue) 事件,它由 setValue 触发,携带新值。


2. 前端:实时监听事件

2.1 回顾合约与 ABI

确保你的 frontend/src/contracts/valueStore.ts 包含事件定义(上一篇已经加了):

export const VALUE_STORE_ADDRESS = '0xYourContractAddress'

export const VALUE_STORE_ABI = [
  // ... 之前的函数定义
  {
    anonymous: false,
    inputs: [{ indexed: false, name: 'newValue', type: 'uint256' }],
    name: 'ValueChanged',
    type: 'event',
  },
] as const

2.2 使用 useWatchContractEvent

wagmi 提供了 useWatchContractEvent Hook,它底层封装了 viem 的 watchContractEvent,使用起来非常方便。我们创建一个新组件 EventWatcher.tsx

import { useWatchContractEvent, useAccount } from 'wagmi'
import { useState } from 'react'
import { VALUE_STORE_ADDRESS, VALUE_STORE_ABI } from './contracts/valueStore'
import { parseAbiItem } from 'viem'

function EventWatcher() {
  const [latestValue, setLatestValue] = useState<string>('')
  const [logs, setLogs] = useState<{ newValue: string; txHash: string }[]>([])

  useWatchContractEvent({
    address: VALUE_STORE_ADDRESS,
    abi: VALUE_STORE_ABI,
    eventName: 'ValueChanged',
    onLogs(logs) {
      for (const log of logs) {
        // log.args 里包含事件参数
        const newValue = log.args.newValue?.toString() ?? '0'
        setLatestValue(newValue)
        setLogs((prev) => [
          { newValue, txHash: log.transactionHash },
          ...prev,
        ])
      }
    },
  })

  return (
    <div>
      <h3>📡 实时事件监听</h3>
      <p>最新值:{latestValue || '暂无'}</p>
      <ul>
        {logs.slice(0, 5).map((log, i) => (
          <li key={i}>
            新值: {log.newValue} | 交易: {log.transactionHash.slice(0, 10)}...
          </li>
        ))}
      </ul>
    </div>
  )
}

export default EventWatcher

useWatchContractEvent 会在组件挂载后开始监听新产生的区块,一旦匹配到指定事件,onLogs 回调就会被触发。它默认监听从当前区块开始的新日志,非常适合实时 UI 更新。

2.3 集成到 App

App.tsx 中引入 EventWatcher,放在 ContractData 组件旁边:

import EventWatcher from './EventWatcher'

// ...
{isConnected && (
  <>
    <ContractData />
    <SetValue />
    <EventWatcher />
  </>
)}

现在,当你用 setValue 修改合约值后,EventWatcher 会几乎同步显示新的值,并且列出最近的事件记录。


3. 后端:事件索引与持久化

前端实时监听很酷,但页面上只能看到监听之后的新事件,历史记录无法获取。我们需要一个后端事件监听器,把每次事件都存进数据库。

3.1 初始化后端项目

我们复用上一篇的 backend 项目,如果没有则需要先创建。安装额外依赖:

cd backend
pnpm add better-sqlite3 viem
pnpm add -D @types/better-sqlite3

3.2 创建数据库与事件存储

新建 backend/src/db.ts

import Database from 'better-sqlite3'

const db = new Database('events.db')

db.exec(`
  CREATE TABLE IF NOT EXISTS value_changed_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    blockNumber INTEGER NOT NULL,
    transactionHash TEXT NOT NULL,
    newValue TEXT NOT NULL,
    timestamp INTEGER NOT NULL
  )
`)

export function insertValueChangedEvent(
  blockNumber: bigint,
  transactionHash: string,
  newValue: bigint,
  timestamp: number
) {
  const stmt = db.prepare(`
    INSERT INTO value_changed_events (blockNumber, transactionHash, newValue, timestamp)
    VALUES (?, ?, ?, ?)
  `)
  stmt.run(Number(blockNumber), transactionHash, newValue.toString(), timestamp)
}

export function getAllEvents(limit = 50) {
  return db.prepare('SELECT * FROM value_changed_events ORDER BY blockNumber DESC LIMIT ?').all(limit)
}

3.3 编写事件监听器

新建 backend/src/eventListener.ts

import { createPublicClient, http, parseAbiItem } from 'viem'
import { sepolia } from 'viem/chains'
import { VALUE_STORE_ADDRESS, VALUE_STORE_ABI } from './contracts' // 需共享 ABI 和地址
import { insertValueChangedEvent } from './db'

// 建议将合约配置提取到共享模块,这里假设已经存在
const client = createPublicClient({
  chain: sepolia,
  transport: http(),
})

export function startEventListener() {
  console.log('启动事件监听...')

  client.watchContractEvent({
    address: VALUE_STORE_ADDRESS,
    abi: VALUE_STORE_ABI,
    eventName: 'ValueChanged',
    onLogs(logs) {
      for (const log of logs) {
        const { blockNumber, transactionHash, args } = log
        if (!args.newValue) continue

        insertValueChangedEvent(
          blockNumber,
          transactionHash,
          args.newValue,
          Math.floor(Date.now() / 1000)
        )
        console.log(`已存储事件:${transactionHash} -> ${args.newValue}`)
      }
    },
  })
}

这里我们用 client.watchContractEvent 代替前端版本,因为它不依赖 React,可以直接在 Node 环境长期运行。

3.4 提供查询 API

backend/src/index.ts 中加入获取历史事件的接口:

import { getAllEvents } from './db'
import { startEventListener } from './eventListener'

// ... 原有代码 ...

app.get('/events', (req, res) => {
  const events = getAllEvents()
  res.json(events)
})

// 启动事件监听器(只启动一次)
startEventListener()

app.listen(3001, () => {
  console.log('后端运行在 http://localhost:3001')
})

3.5 共享合约配置

为了让前端和后端共享 VALUE_STORE_ADDRESSVALUE_STORE_ABI,最好把它们放到一个公共包里。简单的做法是:在项目根目录创建 packages/contracts 并导出,或者直接在 backend 里复制一份。这里为了简便,我们在 backend/src/contracts.ts 中复制相同的配置:

export const VALUE_STORE_ADDRESS = '0xYourContractAddress'

export const VALUE_STORE_ABI = [...] // 与前端保持一致

4. 前端集成历史事件接口

现在前端可以通过 API 获取所有历史事件,补全数据展示。

4.1 创建历史事件组件

import { useEffect, useState } from 'react'

interface EventRecord {
  blockNumber: number
  transactionHash: string
  newValue: string
  timestamp: number
}

function EventHistory() {
  const [events, setEvents] = useState<EventRecord[]>([])

  useEffect(() => {
    fetch('http://localhost:3001/events')
      .then(res => res.json())
      .then(setEvents)
  }, [])

  return (
    <div>
      <h3> 历史事件(来自后端)</h3>
      <ul>
        {events.map((e) => (
          <li key={e.transactionHash}>
            块 {e.blockNumber} - 值 {e.newValue} ({new Date(e.timestamp * 1000).toLocaleString()})
          </li>
        ))}
      </ul>
    </div>
  )
}

export default EventHistory

4.2 添加到页面

App.tsx 中引入:

import EventHistory from './EventHistory'

// ...
<EventHistory />

现在,前端同时拥有了实时推送EventWatcher)和历史回放EventHistory),体验提升了一个档次。


5. 运行与测试

  1. 启动后端cd backend && npx tsx src/index.ts
  2. 启动前端cd frontend && pnpm dev
  3. 打开 DApp,连接钱包,执行一次 setValue 操作。
  4. 观察前端实时显示新值,刷新页面后历史事件依然可见。

6. 优化与生产建议

  • 批量拉取历史:如果事件量很大,应该分页并实现懒加载。
  • WebSocket 推送:可以用 Socket.IO 将后端监听到的事件实时推送到前端,取代前端的直接监听,统一数据源。
  • 错误重连watchContractEvent 在 WebSocket 断开时会自动重试,但在 Node 环境中如果网络不稳定可以加上手动重试逻辑。
  • 索引器服务:对于复杂业务,推荐使用 The Graph、Subsquid 或自建 Envio 索引器,它们提供了更强大的查询能力。
  • 安全性:API 接口如果需要认证,记得加上上一篇实现的 JWT 验证。

7. 总结

通过这篇文章,你学会了如何在 Web3 全栈应用中处理合约事件:

  • 前端:使用 useWatchContractEvent 实时捕获链上日志,更新 UI。
  • 后端:用 viem 建立独立的事件监听进程,将数据持久化到 SQLite,并提供查询接口。
  • 数据流:实现了“链上事件 → 后端数据库 → 前端展示”的完整闭环。