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

项目方案与 MVP 结构
这个项目分三块:
- 合约:一个最小打卡合约
- 前端:React + Vite
- 钱包与链交互: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 限流分分钟教你做人。
第三步:发交易,别把“点击按钮”当成“已经成功”
交易流程和普通表单提交差别很大。 正常顺序是:
- 用户点击按钮
- 钱包弹窗确认
- 用户签名
- 交易广播
- 等链上打包
- 交易成功或失败
- 前端刷新状态
这里最容易犯的错,就是钱包一返回 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>
)
}
第五步:把页面串起来,形成完整流程
真实开发顺序就该这样:
- 先连钱包
- 再读当前状态
- 再决定按钮是否可点
- 成功交易后刷新状态
- 同时监听事件流做实时更新
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、投票、签到、领取奖励、报名上链活动,基本都能套:
- 先拿钱包状态
- 再查链上只读数据
- 明确区分钱包确认中和链上确认中
- 用事件流做实时 UI
- 用主动 refetch 保证关键状态准确
- 用缓存减少重复 RPC
- 用 bigint 和单位转换规避精度坑
- 对链切换、重复事件、错误提示做生产兜底
一句话压轴:Web3 前端不是“会调合约”就完事,真正难的是把不稳定的链上异步世界,包成一个用户能顺手点、能看懂反馈、不会一脸懵的正常产品。
到这里,这个教程的 MVP、事件监听思路和生产优化骨架已经齐了。
