跳到文章正文
06 / 应用

事件监听与全栈优化

做一个打卡 DApp,处理事件、交易状态、缓存和网络切换。

约 27 分钟阅读
第 6 课:事件监听与全栈优化
本文为作者已发布教程的站内版本。在 X 查看原文与讨论

学习目标: 构建打卡 DApp,处理事件、交易状态、缓存和网络切换。

上一课

本课目录

  • Web2 到 Web3 的直觉映射
  • 项目方案与 MVP 结构
  • 环境安装与最小依赖
  • 合约:先搞一个够用的,不追求花哨
  • 前端核心配置
  • 第一步:先连钱包
  • 第二步:读链上状态,别一上来就自己手搓请求
  • 第三步:发交易,别把“点击按钮”当成“已经成功”
  • 第四步:事件监听,做实时更新
  • 第五步:把页面串起来,形成完整流程
  • BigInt、精度转换、异步时序,这几个坑必须讲透
  • 全栈优化:把用户体验从“能用”拉到“顺手”
  • 生产环境一定会遇到的坑
  • 坑 1:事件重复触发,列表里同一条记录出现两次
  • 坑 2:用户切换网络后,页面还显示旧链数据
  • 坑 3:事件到了,但“我的状态”没更新
  • 再补一刀:更像线上项目的改法
  • 一版更稳的页面组织思路
  • 这篇项目里你真正学到的东西

Web2 到 Web3 的直觉映射

别急着记术语,先把脑子里的模型切过去。

事件监听与全栈优化 · 配图 1

项目方案与 MVP 结构

这个项目分三块:

  1. 合约:一个最小打卡合约
  2. 前端:React + Vite
  3. 钱包与链交互:wagmi + viem

技术栈我不搞花活,选现在前端接 Web3 最稳的一套:

  • React
  • Vite
  • TypeScript
  • wagmi
  • viem
  • TanStack Query

项目效果

页面上只做 4 件事:

  • 连接钱包
  • 展示当前地址今天是否打卡
  • 点击按钮发起打卡
  • 实时展示最新打卡记录流

环境安装与最小依赖

先起一个 React 项目。

npm create vite@latest onchain-checkin-board -- --template react-ts
cd onchain-checkin-board
npm i wagmi viem @tanstack/react-query

如果你想顺手把样式也带上:

npm i -D tailwindcss @tailwindcss/vite

这篇重点不是样式,就不展开了。


合约:先搞一个够用的,不追求花哨

前端教程也得有个能交互的目标,所以先给一个最小 Solidity 合约。 你不需要立刻会写 Solidity,只需要知道事件和状态长啥样。

合约功能

  • 每个地址每天只能打卡一次
  • 合约记录某人某天是否打过卡
  • 成功打卡时抛出事件 CheckedIn(address user, uint256 day, uint256 timestamp)

Solidity 合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract CheckIn {
    mapping(address => mapping(uint256 => bool)) public checkedIn;

    event CheckedIn(address indexed user, uint256 indexed day, uint256 timestamp);

    function checkIn() external {
        uint256 day = block.timestamp / 1 days;
        require(!checkedIn[msg.sender][day], "Already checked in today");

        checkedIn[msg.sender][day] = true;
        emit CheckedIn(msg.sender, day, block.timestamp);
    }

    function hasCheckedIn(address user, uint256 day) external view returns (bool) {
        return checkedIn[user][day];
    }

    function currentDay() external view returns (uint256) {
        return block.timestamp / 1 days;
    }
}

部署你可以用 Remix,省事。 部署到测试网,比如 Sepolia。

部署完你会拿到两个东西:

  • 合约地址
  • ABI

前端核心配置

先配钱包和链。

src/web3/config.ts

import { createConfig, http } from 'wagmi'
import { sepolia } from 'wagmi/chains'

export const config = createConfig({
  chains: [sepolia],
  transports: {
    [sepolia.id]: http(), // 默认公共 RPC 先跑通,生产环境别偷懒,自己配稳定 RPC
  },
})

src/main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { WagmiProvider } from 'wagmi'
import App from './App'
import { config } from './web3/config'

const queryClient = new QueryClient()

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>,
)

合约信息:src/web3/checkin.ts

export const CHECKIN_ADDRESS = '0xYourContractAddressHere'

export const checkInAbi = [
  {
    type: 'function',
    name: 'checkIn',
    stateMutability: 'nonpayable',
    inputs: [],
    outputs: [],
  },
  {
    type: 'function',
    name: 'hasCheckedIn',
    stateMutability: 'view',
    inputs: [
      { name: 'user', type: 'address' },
      { name: 'day', type: 'uint256' },
    ],
    outputs: [{ name: '', type: 'bool' }],
  },
  {
    type: 'function',
    name: 'currentDay',
    stateMutability: 'view',
    inputs: [],
    outputs: [{ name: '', type: 'uint256' }],
  },
  {
    type: 'event',
    name: 'CheckedIn',
    inputs: [
      { name: 'user', type: 'address', indexed: true },
      { name: 'day', type: 'uint256', indexed: true },
      { name: 'timestamp', type: 'uint256', indexed: false },
    ],
    anonymous: false,
  },
] as const

第一步:先连钱包

Web3 页面第一步永远不是“请求接口”,而是拿到当前用户地址和连接状态。

钱包连接组件

// src/components/WalletSection.tsx
import { useAccount, useConnect, useDisconnect } from 'wagmi'

export function WalletSection() {
  const { address, isConnected, chain } = useAccount()
  const { connect, connectors, isPending } = useConnect()
  const { disconnect } = useDisconnect()

  if (isConnected) {
    return (
      <div>
        <p>当前地址:{address}</p>
        <p>当前网络:{chain?.name ?? '未知网络'}</p>
        <button onClick={() => disconnect()}>断开钱包</button>
      </div>
    )
  }

  return (
    <div>
      {connectors.map((connector) => (
        <button
          key={connector.uid}
          onClick={() => connect({ connector })}
          disabled={isPending}
        >
          连接 {connector.name}
        </button>
      ))}
    </div>
  )
}

这里有两个新手常踩坑

1)connectors 不一定只有一个

你本来以为只有 MetaMask。 结果钱包环境不同,浏览器里可能出现多个 connector。别写死。

2)连接成功不代表网络对了

用户可能挂在主网、Polygon、BSC。 你合约部署在 Sepolia,那就得提示切网,不然读写都废。


第二步:读链上状态,别一上来就自己手搓请求

这一步要读两个值:

  • 当前链上的 day
  • 当前地址在这一天是否已打卡

为什么先读 currentDay

因为“今天”这个概念在链上不是你本地时间。 你本地 Date 算出来的今天,和链上 block.timestamp / 1 days 不一定一致。 只要规则写在链上,前端就跟链走。

src/hooks/useCheckInStatus.ts

import { useAccount, useReadContract } from 'wagmi'
import { CHECKIN_ADDRESS, checkInAbi } from '../web3/checkin'

export function useCheckInStatus() {
  const { address, isConnected } = useAccount()

  const { data: currentDay, isLoading: isDayLoading } = useReadContract({
    address: CHECKIN_ADDRESS,
    abi: checkInAbi,
    functionName: 'currentDay',
    query: {
      staleTime: 10_000, // 10 秒内认为数据新鲜,别每次渲染都去打链
    },
  })

  const { data: hasCheckedIn, isLoading: isStatusLoading, refetch } = useReadContract({
    address: CHECKIN_ADDRESS,
    abi: checkInAbi,
    functionName: 'hasCheckedIn',
    args: address && currentDay !== undefined ? [address, currentDay] : undefined,
    query: {
      enabled: isConnected && !!address && currentDay !== undefined,
      staleTime: 5_000,
    },
  })

  return {
    currentDay,
    hasCheckedIn,
    isLoading: isDayLoading || isStatusLoading,
    refetch,
  }
}

这里为什么这么写

enabled 很关键

很多人刚上手会直接传 args: [address, currentDay]。 结果地址还没拿到,currentDay 也没回来,请求先炸了。

enabled 的作用就是: 条件没齐之前,不发请求。

staleTime 不是摆设

链上读请求不是纯本地计算。 你页面里多个组件都读同一个状态,如果不配缓存,切换一下页面、状态抖一下,就会重复打 RPC。 本地开发你感觉不出来,线上用户一多,RPC 限流分分钟教你做人。


第三步:发交易,别把“点击按钮”当成“已经成功”

交易流程和普通表单提交差别很大。 正常顺序是:

  1. 用户点击按钮
  2. 钱包弹窗确认
  3. 用户签名
  4. 交易广播
  5. 等链上打包
  6. 交易成功或失败
  7. 前端刷新状态

这里最容易犯的错,就是钱包一返回 hash,你就把 UI 当成功了。 不行。那只是“交易发出去了”,还没确认。

打卡按钮组件

// src/components/CheckInAction.tsx
import { useEffect } from 'react'
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { CHECKIN_ADDRESS, checkInAbi } from '../web3/checkin'

type Props = {
  disabled?: boolean
  onSuccess?: () => void
}

export function CheckInAction({ disabled, onSuccess }: Props) {
  const { data: hash, writeContract, isPending, error } = useWriteContract()

  const {
    isLoading: isConfirming,
    isSuccess,
    error: receiptError,
  } = useWaitForTransactionReceipt({
    hash,
  })

  useEffect(() => {
    if (isSuccess) {
      onSuccess?.()
    }
  }, [isSuccess, onSuccess])

  return (
    <div>
      <button
        disabled={disabled || isPending || isConfirming}
        onClick={() =>
          writeContract({
            address: CHECKIN_ADDRESS,
            abi: checkInAbi,
            functionName: 'checkIn',
          })
        }
      >
        {isPending ? '钱包确认中...' : isConfirming ? '链上确认中...' : '今日打卡'}
      </button>

      {error && <p>发起失败:{error.shortMessage || error.message}</p>}
      {receiptError && <p>上链失败:{receiptError.message}</p>}
      {hash && <p>交易哈希:{hash}</p>}
    </div>
  )
}

这里的状态要分清

  • isPending:钱包层还在处理,可能用户还没点确认
  • isConfirming:交易已经发出去,等链上打包
  • isSuccess:至少拿到 receipt,链上确认了

这三个阶段混了,用户体验就很烂。 按钮文案也别偷懒,都写成“提交中...”,用户根本不知道卡在哪一步。


第四步:事件监听,做实时更新

这篇的主题核心就是这个。 打卡成功后,不只是当前用户状态要更新,全站最新打卡列表也要实时变。

为什么不用纯轮询

当然能轮询。 但轮询会带来几个问题:

  • 请求频繁
  • 延迟固定
  • 用户多时 RPC 压力大
  • 页面看起来不“活”

事件监听更像 Web2 里的消息订阅。 合约一发 CheckedIn,前端就能收到。

监听打卡事件

// src/hooks/useCheckInEvents.ts
import { useState } from 'react'
import { useWatchContractEvent } from 'wagmi'
import { CHECKIN_ADDRESS, checkInAbi } from '../web3/checkin'

type CheckInEventItem = {
  user: string
  day: bigint
  timestamp: bigint
  txHash?: string
}

export function useCheckInEvents() {
  const [events, setEvents] = useState<CheckInEventItem[]>([])

  useWatchContractEvent({
    address: CHECKIN_ADDRESS,
    abi: checkInAbi,
    eventName: 'CheckedIn',
    onLogs(logs) {
      const parsed = logs.map((log) => ({
        user: log.args.user!,
        day: log.args.day!,
        timestamp: log.args.timestamp!,
        txHash: log.transactionHash,
      }))

      setEvents((prev) => {
        const merged = [...parsed, ...prev]

        // 这里很容易踩坑:事件可能重复到达,必须按 txHash 去重
        const uniqueMap = new Map<string, CheckInEventItem>()

        for (const item of merged) {
          const key = `${item.txHash}-${item.user}-${item.day.toString()}`
          if (!uniqueMap.has(key)) {
            uniqueMap.set(key, item)
          }
        }

        return Array.from(uniqueMap.values()).slice(0, 20)
      })
    },
  })

  return events
}

事件列表组件

// src/components/LiveFeed.tsx
import { useCheckInEvents } from '../hooks/useCheckInEvents'

export function LiveFeed() {
  const events = useCheckInEvents()

  return (
    <div>
      <h3>最新打卡</h3>

      {events.length === 0 ? (
        <p>还没有监听到新事件</p>
      ) : (
        <ul>
          {events.map((item, index) => (
            <li key={`${item.txHash}-${index}`}>
              地址 {item.user} 在 day={item.day.toString()} 完成打卡,时间戳 {item.timestamp.toString()}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

第五步:把页面串起来,形成完整流程

真实开发顺序就该这样:

  1. 先连钱包
  2. 再读当前状态
  3. 再决定按钮是否可点
  4. 成功交易后刷新状态
  5. 同时监听事件流做实时更新

src/App.tsx

import { WalletSection } from './components/WalletSection'
import { CheckInAction } from './components/CheckInAction'
import { LiveFeed } from './components/LiveFeed'
import { useCheckInStatus } from './hooks/useCheckInStatus'

function App() {
  const { currentDay, hasCheckedIn, isLoading, refetch } = useCheckInStatus()

  return (
    <div style={{ padding: 24 }}>
      <h1>Onchain Check-in Board</h1>

      <WalletSection />

      <hr />

      <section>
        <h2>我的打卡状态</h2>

        {isLoading ? (
          <p>状态加载中...</p>
        ) : (
          <>
            <p>当前链上 day:{currentDay?.toString() ?? '-'}</p>
            <p>今日是否已打卡:{hasCheckedIn ? '已打卡' : '未打卡'}</p>
          </>
        )}

        <CheckInAction
          disabled={!!hasCheckedIn}
          onSuccess={() => {
            // 交易确认后主动刷新一次
            // 事件监听能更新列表,但“我是否已打卡”这个状态最好手动 refetch,别等事件碰运气
            refetch()
          }}
        />
      </section>

      <hr />

      <LiveFeed />
    </div>
  )
}

export default App

到这里,MVP 就通了。


BigInt、精度转换、异步时序,这几个坑必须讲透

Web3 前端不是难在 API 多,而是难在你以前在 Web2 里不太需要这么警惕这些细节。

BigInt 不是 Number 的平替

链上数值基本都是大整数。 只要是 uint256,前端拿到大概率就是 bigint。

错误写法

const day = Number(currentDay)

如果值很大,或者后面你处理金额,直接有精度风险。 打卡项目里 day 问题不大,但你养成这个坏习惯,后面做余额、价格、代币数量一定翻车。

正确思路

  • 展示给用户看时:转字符串
  • 参与链上参数时:保持 bigint
  • 涉及代币金额:用 parseUnits / formatUnits

比如:

import { formatUnits, parseUnits } from 'viem'

const rawAmount = parseUnits('1.5', 18) // bigint
const display = formatUnits(rawAmount, 18) // "1.5"

异步时序别乱

你会同时面对这些状态:

  • 钱包连接成功
  • 地址变化
  • 链切换
  • 读请求返回
  • 交易发出
  • receipt 返回
  • 事件到达

这些状态顺序不固定。 所以代码里一定要有几个原则:

原则 1:依赖没齐,不发请求

靠 enabled。

原则 2:交易成功后,主动刷新关键状态

不要只信事件。

原则 3:事件流只做“增量更新”,权威状态靠读接口校准

这跟 Web2 很像: 消息队列推过来的数据很快,但最终一致性还是要有落库查询兜底。


全栈优化:把用户体验从“能用”拉到“顺手”

这部分才是项目真正有价值的地方。 能跑不稀奇,跑得稳、交互顺,才像线上东西。

1)做乐观更新,但别装过头

用户点了打卡后,理论上可以立刻把 UI 切成“已提交”。 但不能直接切成“已成功打卡”,因为链上还没确认。

比较稳的做法:

  • 钱包确认后:显示“提交成功,等待上链”
  • receipt 成功后:切成“已打卡”
  • 同时按钮锁住,防止重复点

2)缓存链上读请求

staleTime 前面已经配了。 再加一个思路:如果多个组件都需要当前地址状态,不要每个组件自己查。 抽成 hook,统一读,统一缓存。

这就是 Web2 里你做状态层的老思路,放 Web3 里一样成立。

3)事件列表别无限堆

事件流如果无限 append,页面用一会儿就开始卡。 前端只保留最近 20~50 条够了。 老数据真要查,做分页历史查询,不要指望一个监听列表包打天下。


生产环境一定会遇到的坑

这部分不讲,你上线肯定挨揍。


坑 1:事件重复触发,列表里同一条记录出现两次

这个很常见。 原因可能有这些:

  • 钱包/节点重连
  • React 严格模式开发环境触发两次副作用
  • 多个订阅实例同时存在
  • 节点返回重复日志

解决办法

前端按唯一键去重

最稳的是:

  • transactionHash
  • logIndex
  • 再加业务字段兜底

如果 logIndex 能拿到,就优先用它。

const key = `${log.transactionHash}-${log.logIndex}`

不要在多个地方重复监听同一事件

很多人一个页面组件监听一次,弹窗里又监听一次。 这就属于自己给自己上强度。 同一个事件源,尽量集中管理。


坑 2:用户切换网络后,页面还显示旧链数据

这类 bug 很阴。 表面看页面有数据,实际查的是旧链缓存,发交易却发不出去。

解决办法

监听链 ID 变化,主动失效相关缓存

如果你项目复杂一点,建议把 query key 带上 chainId 和 address。

比如:

  • ['checkin-status', chainId, address]
  • ['current-day', chainId]

链一变,缓存天然隔离。 别把 Sepolia 和主网的数据混在一锅里。


坑 3:事件到了,但“我的状态”没更新

新手特别容易问这个: “我都监听到 CheckedIn 了,为什么页面上还是未打卡?”

因为事件流更新的是你的列表状态,不一定自动刷新你那个 `hasCheckedIn` 查询缓存。

解决办法

交易成功后,手动 refetch 关键读请求。 你可以把它理解成:

  • 事件监听:负责快
  • 主动 refetch:负责准

两个都要。别二选一。


再补一刀:更像线上项目的改法

如果你真要把这个 demo 往生产方向推,建议再补这几件事。

1)加网络校验和切网提示

当前示例里只是展示网络。 真实业务里要明确判断:

  • 如果不是 Sepolia,禁用打卡按钮
  • 给出“请切换到 Sepolia”的明确提示
  • 有条件的话直接调用切网能力

2)初始化时拉一段历史事件

现在这个监听列表只能看到“页面打开之后的新事件”。 如果你想让用户一进来就看到最近打卡记录,要在初始化时拉一次历史日志,再和实时订阅拼起来。

思路一般是:

  • 页面加载时 getLogs
  • 页面运行中 watchEvent
  • 两部分合并去重

这套组合拳比只靠监听稳得多。

3)错误提示说人话

钱包拒绝签名、用户余额不够、网络不对、合约 revert,这些错误最好分开提示。 不要一律给用户看原始报错,那玩意儿很多时候像天书。

比如:

  • 用户取消签名:你取消了这笔交易
  • 网络不对:请先切换到 Sepolia 测试网
  • 今日已打卡:今天已经打过卡了
  • RPC 挂了:网络开小差了,稍后重试

一版更稳的页面组织思路

到这一步,你应该能感觉到一个规律:

  • 钱包状态是一层
  • 链上读状态是一层
  • 交易写状态是一层
  • 事件流状态又是一层

所以真实项目里,别把所有逻辑都堆到 App.tsx。 推荐拆法:

src/
  components/
    WalletSection.tsx
    CheckInAction.tsx
    CheckInStatus.tsx
    LiveFeed.tsx
  hooks/
    useCheckInStatus.ts
    useCheckInEvents.ts
    useCheckInAction.ts
  web3/
    config.ts
    checkin.ts
  pages/
    Home.tsx

这样后面你加“连续打卡天数”“排行榜”“个人历史记录”,不会改两下就烂掉。


这篇项目里你真正学到的东西

不是“写了一个打卡按钮”。 真正有用的是下面这套顺序,你后面做 Mint、投票、签到、领取奖励、报名上链活动,基本都能套:

  1. 先拿钱包状态
  2. 再查链上只读数据
  3. 明确区分钱包确认中和链上确认中
  4. 用事件流做实时 UI
  5. 用主动 refetch 保证关键状态准确
  6. 用缓存减少重复 RPC
  7. 用 bigint 和单位转换规避精度坑
  8. 对链切换、重复事件、错误提示做生产兜底

一句话压轴:Web3 前端不是“会调合约”就完事,真正难的是把不稳定的链上异步世界,包成一个用户能顺手点、能看懂反馈、不会一脸懵的正常产品。

到这里,这个教程的 MVP、事件监听思路和生产优化骨架已经齐了。

关于作者

,现居湖北武汉,拥有 10 多年经验的全栈开发工程师,分享 AI 编程、Web3 与独立开发实践。

X 原文