为智能合约构建用户界面
本文是一篇面向开发者的教程,介绍如何使用wagmi库为以太坊智能合约构建现代用户界面。通过一个Greeter合约的示例应用,作者一步步讲解了从项目安装、文件结构到核心代码实现的全过程,涵盖React、TypeScript、wagmi、React Query等工具的使用,并展示了如何链上读取、写入合约以及监听事件。文章还扩展了添加其他区块链(如Optimism Sepolia)的方法,强调良好的用户体验对Web3普及的重要性。适合已有编程基础、希望学习Web3前端开发的读者。
这篇文章是为你准备的。我假设你懂编程,可能还懂一点 JavaScript 和 HTML,不过你的用户界面技能已经生疏、过时了。我们将一起学习一个简单的现代应用程序,让你了解现在的做法。
为什么这很重要
理论上,你可以让人们直接使用 Etherscan 或 Blockscout 与你的合约进行交互。这对经验丰富的以太坊用户来说很不错。但我们正致力于服务 下一个十亿用户。没有出色的用户体验,这就不可能实现,而友好的用户界面是其中的重要组成部分。
Greeter 应用程序
现代 UI 的工作原理背后有很多理论,也有很多优秀的网站 对此进行了解释。我不会重复这些网站所做的出色工作,而是假设你更喜欢边做边学,从一个你可以动手操作的应用程序开始。你仍然需要理论来完成任务,我们会逐步讲解——我们将逐个查看源文件,并在遇到时进行讨论。
安装
-
该应用程序使用 Sepolia 测试网络。如有必要,获取 Sepolia 测试 ETH 并 将 Sepolia 添加到你的钱包中。
-
克隆 GitHub 仓库并安装必要的包。
git clone https://github.com/qbzzt/260301-modern-ui-web3.git cd 260301-modern-ui-web3 npm install -
应用程序使用免费的访问点,这些访问点有性能限制。如果你想使用 节点即服务 提供商,请替换
src/wagmi.ts中的 URL。 -
启动应用程序。
npm run dev -
浏览到应用程序显示的 URL。大多数情况下是 http://localhost:5173/。
-
你可以在 区块链浏览器 上查看合约源代码,它是 Hardhat 的 Greeter 的修改版。
文件遍历
index.html
这个文件是标准的 HTML 模板,除了这一行,它导入了脚本文件。
<script type="module" src="/src/main.tsx"></script>
src/main.tsx
文件扩展名表明这是一个用 TypeScript 编写的 React 组件,TypeScript 是 JavaScript 的扩展,支持 类型检查。TypeScript 被编译成 JavaScript,所以我们可以在客户端使用它。
解释这个文件主要是为了让你感兴趣。通常你不需要修改这个文件,而是修改 src/App.tsx 及其导入的文件。
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { WagmiProvider } from 'wagmi'
导入所需的库代码。
import App from './App.tsx'
导入实现应用程序的 React 组件(见下文)。
import { config } from './wagmi.ts'
导入 wagmi 配置,其中包括区块链配置。
const queryClient = new QueryClient()
创建一个新的 React Query 缓存管理器实例。该对象将存储:
- 缓存的 RPC 调用
- 合约读取
- 后台重新获取状态
我们需要缓存管理器,因为 wagmi v3 在内部使用了 React Query。
ReactDOM.createRoot(document.getElementById('root')!).render(
创建根 React 组件。render 的参数是 JSX,一种同时使用 HTML 和 JavaScript/TypeScript 的扩展语言。这里的感叹号告诉 TypeScript 组件:"你不知道 document.getElementById('root') 将是一个有效的参数给 ReactDOM.createRoot,但别担心——我是开发者,我告诉你它是有效的"。
<React.StrictMode>
应用程序被放在 一个 React.StrictMode 组件 内部。这个组件告诉 React 库插入额外的调试检查,这在开发期间很有用。
<WagmiProvider config={config}>
应用程序也被放在 一个 WagmiProvider 组件 内部。wagmi(我们即将实现它)库 将 React UI 定义与 viem 库 连接起来,用于编写以太坊去中心化应用程序。
<QueryClientProvider client={queryClient}>
最后,添加一个 React Query 提供者,以便任何应用程序组件都可以使用缓存查询。
<App />
现在我们可以拥有应用程序的组件,它实际实现了 UI。组件末尾的 /> 告诉 React 该组件内部没有任何定义,这是根据 XML 标准。
</QueryClientProvider>
</WagmiProvider>
</React.StrictMode>,
)
当然,我们必须关闭其他组件。
src/App.tsx
import {
useConnect,
useConnection,
useDisconnect,
useSwitchChain
} from 'wagmi'
import { useEffect } from 'react'
import { Greeter } from './Greeter'
导入我们需要的库,以及 Greeter 组件。
const SEPOLIA_CHAIN_ID = 11155111
Sepolia 链 ID。
function App() {
这是创建 React 组件的标准方式:定义一个函数,每当需要渲染时就会调用它。这个函数通常包含 TypeScript 或 JavaScript 代码,后跟一个返回 JSX 代码的 return 语句。
const connection = useConnection()
使用 useConnection 获取与当前连接相关的信息,例如地址和 chainId。
按照惯例,在 React 中称为 use... 的函数是 hooks。这些函数不仅向组件返回数据;它们还确保当数据发生变化时,组件会重新渲染(组件函数再次执行,其输出替换 HTML 中的前一个输出)。
const { connectors, connect, status, error } = useConnect()
使用 useConnect 获取钱包连接信息。
const { disconnect } = useDisconnect()
这个 hook 提供了断开钱包连接的函数。
const { switchChain } = useSwitchChain()
这个 hook 允许我们切换链。
useEffect(() => {
React 的 hook useEffect 允许你在变量值发生变化时运行一个函数,以同步外部系统。
if (connection.status === 'connected' &&
connection.chainId !== SEPOLIA_CHAIN_ID
) {
switchChain({ chainId: SEPOLIA_CHAIN_ID })
}
如果我们已连接,但不是在 Sepolia 区块链上,则切换到 Sepolia。
}, [connection.status, connection.chainId])
每次连接状态或连接 chainId 发生变化时重新运行该函数。
return (
<>
React 组件的 JSX 必须 返回单个 HTML 组件。当我们有多个组件且不需要容器来将它们包裹起来时,我们使用一个空组件(<> ... </>)将它们组合成一个组件。
<h2>Connection</h2>
<div>
status: {connection.status}
<br />
addresses: {JSON.stringify(connection.addresses)}
<br />
chainId: {connection.chainId}
</div>
提供当前连接的信息。在 JSX 中,{<表达式>} 表示将表达式作为 JavaScript 求值。
{connection.status === 'connected' && (
语法 {<条件> && <值>} 表示"如果条件为 true,求值为该值;否则求值为 false"。
这是将 if 语句放入 JSX 的标准方式。
<div>
<Greeter />
<hr />
JSX 遵循 XML 标准,这比 HTML 更严格。如果一个标签没有对应的结束标签,它 必须 在末尾有一个斜杠(/)来终止。
这里有两个这样的标签,<Greeter />(实际包含与合约对话的 HTML 代码)和 <hr /> 用于水平线。
<button type="button" onClick={disconnect}>
Disconnect
</button>
</div>
)}
如果用户点击此按钮,调用 disconnect 函数。
{connection.status !== 'connected' && (
如果 未 连接,则显示连接到钱包的必要选项。
<div>
<h2>Connect</h2>
{connectors.map((connector) => (
在 connectors 中我们有一个连接器列表。我们使用 map 将其转换为要显示的 JSX 按钮列表。
<button
key={connector.uid}
在 JSX 中,对于同级的标签(源自同一父级的标签),必须具有不同的标识符。
onClick={() => connect({ connector })}
type="button"
>
{connector.name}
</button>
))}
连接器按钮。
<div>{status}</div>
<div>{error?.message}</div>
</div>
)}
提供附加信息。表达式语法 <变量>?.<字段> 告诉 JavaScript,如果变量已定义,则求值为该字段。如果变量未定义,则此表达式求值为 undefined。
表达式 error.message 在没有错误时会引发异常。使用 error?.message 可以避免这个问题。
src/Greeter.tsx
这个文件包含了大部分 UI 功能。它包含通常会在多个文件中的定义,但因为这是一个教程,程序进行了优化,以便于第一次理解,而不是为了性能或易于维护。
import {
useState,
useEffect,
} from 'react'
import { useChainId,
useAccount,
useReadContract,
useWriteContract,
useWatchContractEvent,
useSimulateContract
} from 'wagmi'
我们使用这些库函数。同样,它们将在使用处进行解释。
import { AddressType } from 'abitype'
abitype 库 为我们提供了各种以太坊数据类型的 TypeScript 定义,例如 AddressType。
let greeterABI = [\
{ "type": "function", "name": "greet", ... },\
{ "type": "function", "name": "setGreeting", ... },\
{ "type": "event", "name": "SetGreeting", ... },\
] as const // greeterABI
Greeter 合约的 ABI。
如果你同时开发合约和 UI,通常会将它们放在同一个仓库中,并使用 Solidity 编译器生成的 ABI 作为应用程序中的文件。但这里不需要这样做,因为合约已经开发完成且不会更改。
我们使用 as const 来告诉 TypeScript 这是一个 真正的 常量。通常,当你在 JavaScript 中指定 const x = {"a": 1} 时,你可以更改 x 中的值,只是不能重新赋值给 x。
type AddressPerBlockchainType = {
[key: number]: AddressType
}
TypeScript 是强类型的。我们使用这个定义来指定 Greeter 合约在不同链上部署的地址。键是一个数字(chainId),值是一个 AddressType(地址)。
const contractAddrs : AddressPerBlockchainType = {
// Sepolia
11155111: '0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA'
}
合约在 Sepolia 上的地址。
Timer 组件
Timer 组件显示自某个给定时间以来的秒数。这对于可用性很重要。当用户执行操作时,他们期望立即得到响应。在区块链中,这通常是不可能的,因为在交易被放入区块之前不会发生任何事情。一种解决方案是显示自用户执行操作以来已经过去了多长时间,这样用户就可以决定所需的时间是否合理。
type TimerProps = {
lastUpdate: Date
}
Timer 组件接受一个参数 lastUpdate,即上次操作的时间。
const Timer = ({ lastUpdate }: TimerProps) => {
const [_, setNow] = useState(new Date())
我们需要有状态(一个绑定到组件的变量)并更新它以使组件正常工作。但我们永远不需要读取它,所以不需要创建一个变量。
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
setInterval 函数允许我们安排一个函数定期运行。在这里,每秒钟运行一次。该函数调用 setNow 来更新状态,因此 Timer 组件将重新渲染。我们将其包装在依赖列表为空的 useEffect 中,以便它只运行一次,而不是每次组件渲染时都运行。
const secondsSinceUpdate = Math.floor(
(Date.now() - lastUpdate.getTime()) / 1000
)
return (
<span>{secondsSinceUpdate} seconds ago</span>
)
}
计算自上次更新以来的秒数并返回。
Greeter 组件
const Greeter = () => {
最后,我们开始定义组件。
const chainId = useChainId()
const account = useAccount()
关于我们正在使用的链和账户的信息,由 wagmi 提供。因为这是一个 hook(use...),所以每当这些信息发生变化时,组件都会重新渲染。
const greeterAddr = chainId && contractAddrs[chainId]
Greeter 合约的地址,如果我们没有链信息,或者我们在一个没有该合约的链上,则为 undefined。
const readResults = useReadContract({
address: greeterAddr,
abi: greeterABI,
functionName: "greet", // 无参数
})
useReadContract hook 调用 合约 的 greet 函数。
const [ currentGreeting, setCurrentGreeting ] =
useState("请稍候,正在从区块链获取问候语...")
const [ newGreeting, setNewGreeting ] = useState("")
React 的 useState hook 允许我们定义一个状态变量,其值在组件的多次渲染之间保持不变。初始值是参数,这里是一个空字符串。
useState hook 返回一个包含两个值的列表:
- 状态变量的当前值。
- 一个函数,用于在需要时修改状态变量。因为这是一个 hook,每次调用它时,组件都会重新渲染。
在这个例子中,我们使用一个状态变量来存储用户想要设置的新问候语。
const [ lastSetterAddress, setLastSetterAddress ] = useState("")
如果多个用户同时使用同一个合约,他们可能会覆盖彼此的问候语。这对用户来说就像应用程序出现故障。如果应用程序显示谁最后设置了问候语,用户就会知道是其他人设置的,应用程序工作正常。
const [ status, setStatus ] = useState("")
const [ statusTime, setStatusTime ] = useState(new Date())
用户喜欢看到他们的操作立即产生效果。然而,在区块链上,情况并非如此。这些状态变量至少让我们可以向用户显示一些内容,让他们知道他们的操作正在进行中。
useEffect(() => {
if (readResults.data) {
setCurrentGreeting(readResults.data)
setStatus("已从区块链获取问候语")
}
}, [readResults.data])
如果上面的 readResults 更改了数据并且没有设置为假值(例如 undefined),则将当前问候语更新为从区块链读取的问候语。同时更新状态。
useWatchContractEvent({
address: greeterAddr,
abi: greeterABI,
eventName: 'SetGreeting',
chainId,
监听 SetGreeting 事件。
enabled: !!greeterAddr,
!!<值> 表示如果值为 false,或者求值为假的值,例如 undefined、0 或空字符串,则整个表达式为 false。对于任何其他值,则为 true。这是一种将值转换为布尔值的方法,因为如果没有 greeterAddr,我们就不想监听事件。
onLogs: logs => {
const greetingFromContract = logs[0].args.greeting
setCurrentGreeting(greetingFromContract)
setLastSetterAddress(logs[0].args.sender)
updateStatus("问候语已通过事件更新")
},
})
当我们看到日志(即看到新事件时),这意味着问候语已被修改。在这种情况下,我们可以将 currentGreeting 和 lastSetterAddress 更新为新值。同时,我们想要更新状态显示。
const updateStatus = (newStatus: string) => {
setStatus(newStatus)
setStatusTime(new Date())
}
当我们更新状态时,我们想做两件事:
- 更新状态字符串(
status) - 将上次状态更新时间(
statusTime)更新为当前时间。
const greetingChange = (evt) =>
setNewGreeting(evt.target.value)
这是对新问候语输入字段更改的事件处理程序。我们可以指定 evt 参数的类型,但 TypeScript 是一种可选类型语言。由于这个函数只被调用一次,在一个 HTML 事件处理程序中,我认为没必要。
const { writeContractAsync } = useWriteContract()
写入合约的函数。它类似于 writeContracts,但支持更好的状态更新。
const simulation = useSimulateContract({
address: greeterAddr,
abi: greeterABI,
functionName: 'setGreeting',
args: [newGreeting],
account: account.address
})
这是从客户端提交区块链交易的过程:
- 使用
eth_estimateGas将交易发送到区块链上的一个节点。 - 等待节点的响应。
- 收到响应后,要求用户通过钱包签署交易。这一步 必须 在节点响应之后进行,因为用户会在签署前看到交易的 Gas 成本。
- 等待用户批准。
- 再次发送交易,这次使用
eth_sendRawTransaction。
步骤 2 可能需要一段时间,在这段时间内,用户可能会想知道他们的命令是否被用户界面接收到,以及为什么还没有要求他们签署交易。这会造成糟糕的用户体验(UX)。
一种解决方案是,每当参数发生变化时,就发出 eth_estimateGas。然后,当用户实际想要发送交易(在这个例子中是通过按下 Update greeting)时,Gas 成本是已知的,用户可以立即看到钱包页面。
return (
现在终于可以创建要返回的实际 HTML 了。
<>
<h2>Greeter</h2>
{currentGreeting}
显示当前的问候语。
{lastSetterAddress && (
<p>上次更新由 {
lastSetterAddress === account.address ? "你" : lastSetterAddress
}</p>
)}
如果我们知道谁上次设置了问候语,则显示该信息。Greeter 不跟踪此信息,我们也不想回溯查找 SetGreeting 事件,所以我们只在运行期间问候语被更改时才获取它。
<hr />
<input type="text"
value={newGreeting}
onChange={greetingChange}
/>
<br />
这是用户可以设置新问候语的输入文本字段。每次用户按键,我们都会调用 greetingChange,它会调用 setNewGreeting。由于 setNewGreeting 来自 useState,它会导致 Greeter 组件重新渲染。这意味着:
- 我们需要指定
value来保留新问候语的值,否则它会恢复为默认值,即空字符串。 simulation也会在每次newGreeting改变时更新,这意味着我们将获得一个带有正确问候语的模拟。这可能很重要,因为 Gas 成本取决于调用数据的大小,而调用数据的大小又取决于字符串的长度。
<button disabled={!simulation.data}
只有当我们拥有发送交易所需的信息时,才启用按钮。
onClick={async () => {
updateStatus("请在钱包中确认...")
更新状态。此时,用户需要在钱包中确认。
await writeContractAsync(simulation.data.request)
updateStatus("交易已发送,等待问候语更改...")
}}
>
Update greeting
</button>
writeContractAsync 只有在交易实际发送后才返回。这让我们可以向用户显示交易等待被包含在区块链中的时间。
<h4>Status: {status}</h4>
<p>更新于 <Timer lastUpdate={statusTime} /> </p>
</>
)
}
显示状态以及自更新以来经过的时间。
export {Greeter}
导出组件。
src/wagmi.ts
最后,与 wagmi 相关的各种定义都在 src/wagmi.ts 中。我不会在这里解释所有内容,因为大部分是你不太可能需要更改的样板代码。
import { http, webSocket, createConfig, fallback } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { injected } from 'wagmi/connectors'
export const config = createConfig({
chains: [sepolia],
wagmi 配置包括此应用程序支持的链。你可以查看 可用链列表。
connectors: [\
injected(),\
],
这个连接器 允许我们与浏览器中安装的钱包通信。
transports: {
[sepolia.id]: http()
Viem 附带的默认 HTTP 端点已经足够好。如果我们想要不同的 URL,可以使用 http("https:// hostname ") 或 webSocket("wss:// hostname ")。
},
multiInjectedProviderDiscovery: false,
})
添加另一个区块链
现在有很多 L2 扩展解决方案,你可能想要支持一些 viem 尚未支持的方案。为此,你可以修改 src/wagmi.ts。以下说明解释了如何添加 Optimism Sepolia。
-
编辑
src/wagmi.tsA. 从 viem 导入
defineChain类型。import { defineChain } from 'viem'B. 添加网络定义。对于 Optimism Sepolia,你实际上不需要这样做,它已经在
viem中,但通过这种方式你可以学习如何添加一个不在viem中的区块链。const optimismSepolia = defineChain({ id: 11_155_420, name: 'OP Sepolia', nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: ['https://sepolia.optimism.io'], webSocket: ['wss://optimism-sepolia.drpc.org'], }, }, blockExplorers: { default: { name: 'Blockscout', url: 'https://optimism-sepolia.blockscout.com', apiUrl: 'https://optimism-sepolia.blockscout.com/api', } }, })C. 将新链添加到
createConfig调用中。export const config = createConfig({ chains: [sepolia, optimismSepolia], connectors: [\ injected(),\ ], transports: { [optimismSepolia.id]: http(), [sepolia.id]: http() }, multiInjectedProviderDiscovery: false, }) -
编辑
src/App.tsx以注释掉自动切换到 Sepolia 的代码。在生产系统上,你可能会显示带有你支持的每个区块链链接的按钮。/* useEffect(() => { if (connection.status === 'connected' && connection.chainId !== SEPOLIA_CHAIN_ID ) { switchChain({ chainId: SEPOLIA_CHAIN_ID }) } }, [connection.status, connection.chainId]) */ -
编辑
src/Greeter.tsx以确保应用程序知道你的合约在新网络上的地址。const contractAddrs: AddressPerBlockchainType = { // Optimism Sepolia 11155420: "0x4dd85791923E9294E934271522f63875EAe5806f", // Sepolia 11155111: "0x7143d5c190F048C8d19fe325b748b081903E3BF0", } -
在你的浏览器中。
A. 浏览到 ChainList 并点击表格右侧的按钮之一,将链添加到你的钱包。
B. 在应用程序中,Disconnect 然后重新连接以更改区块链。有更好的方法来处理这个问题,但它们需要更改应用程序。
结论
当然,你并不是真的关心为 Greeter 提供用户界面。你想为自己的合约创建一个用户界面。要创建你自己的应用程序,请运行以下步骤:
-
指定创建一个 wagmi 应用程序。
npm create wagmi -
输入
y继续。 -
命名应用程序。
-
选择 React 框架。
-
选择 Vite 变体。
现在去让你的合约可供广阔的世界使用吧。
+3
本教程对你有帮助吗?
- 原文链接: ethereum.org/developers/...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~