为你的合约构建用户界面
你在以太坊生态系统中发现了一个我们需要的功能。你编写了智能合约来实现它,甚至可能还编写了一些在链下运行的相关代码。这太棒了!不幸的是,如果没有用户界面,你就不会有任何用户,而且你上一次写网站时,人们还在使用拨号调制解调器,JavaScript 还是个新鲜事物。
这篇文章就是为你准备的。我假设你懂编程,也许还懂一点 JavaScript 和 HTML,但你的用户界面技能已经生疏且过时了。我们将一起探讨一个简单的现代应用,让你了解如今是如何实现这些的。
为什么这很重要
理论上,你可以直接让人们使用 Etherscan (opens in a new tab) 或 Blockscout (opens in a new tab) 与你的合约进行交互。这对于经验丰富的以太坊用户来说很棒。但我们正试图服务另外十亿人 (opens in a new tab)。如果没有出色的用户体验,这是不可能实现的,而友好的用户界面是其中的重要组成部分。
Greeter 应用
现代 UI 的工作原理背后有很多理论,并且有许多优秀的网站 (opens in a new tab)对此进行了解释 (opens in a new tab)。我不会重复这些网站所做的出色工作,而是假设你更喜欢在实践中学习,并从一个你可以把玩的应用开始。你仍然需要理论知识来完成工作,我们稍后会讲到——我们将逐个查看源文件,并在遇到问题时进行讨论。
安装
-
该应用使用 Sepolia (opens in a new tab) 测试网络。如有必要,请获取 Sepolia 测试 ETH 并将 Sepolia 添加到你的钱包 (opens in a new tab)。
-
克隆 GitHub 仓库并安装必要的包。
git clone https://github.com/qbzzt/260301-modern-ui-web3.git cd 260301-modern-ui-web3 npm install -
该应用使用免费的接入点,这些接入点存在性能限制。如果你想使用节点即服务 (Node as a service) 提供商,请替换
src/wagmi.ts中的 URL。 -
启动应用。
npm run dev -
浏览应用显示的 URL。在大多数情况下,它是 http://localhost:5173/ (opens in a new tab)。
-
你可以在区块链浏览器上 (opens in a new tab)查看合约源代码,这是 Hardhat 的 Greeter 的修改版本。
文件演练
index.html
除了导入脚本文件的这一行之外,此文件是一个标准的 HTML 样板。
<script type="module" src="/src/main.tsx"></script>
src/main.tsx
文件扩展名表明这是一个用 TypeScript (opens in a new tab) 编写的 React 组件 (opens in a new tab),TypeScript 是支持类型检查 (opens in a new tab)的 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 (opens in a new tab) 配置,其中包括区块链配置。
const queryClient = new QueryClient()
创建 React Query (opens in a new tab) 缓存管理器的新实例。此对象将存储:
- 缓存的 RPC 调用
- 合约读取
- 后台重新获取状态
我们需要缓存管理器,因为 Wagmi v3 在内部使用了 React Query。
ReactDOM.createRoot(document.getElementById('root')!).render(
创建根 React 组件。render 的参数是 JSX (opens in a new tab),这是一种同时使用 HTML 和 JavaScript/TypeScript 的扩展语言。这里的感叹号告诉 TypeScript 组件:“你不知道 document.getElementById('root') 会是 ReactDOM.createRoot 的有效参数,但别担心——我是开发者,我告诉你它会是的”。
<React.StrictMode>
该应用将放在 React.StrictMode 组件 (opens in a new tab)内。此组件告诉 React 库插入额外的调试检查,这在开发过程中非常有用。
<WagmiProvider config={config}>
该应用也位于 WagmiProvider 组件 (opens in a new tab)内。Wagmi(我们将要创建它)库 (opens in a new tab)将 React UI 定义与用于编写以太坊去中心化应用 (dapp) 的 Viem 库 (opens in a new tab)连接起来。
<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 (opens in a new tab) 获取与当前连接相关的信息,例如地址和 chainId。
按照惯例,在 React 中名为 use... 的函数是钩子 (hooks) (opens in a new tab)。这些函数不仅向组件返回数据;它们还确保在数据更改时重新渲染组件(再次执行组件函数,其输出替换 HTML 中的前一个输出)。
const { connectors, connect, status, error } = useConnect()
使用 useConnect (opens in a new tab) 获取有关钱包连接的信息。
const { disconnect } = useDisconnect()
这个钩子 (opens in a new tab)为我们提供了断开钱包连接的函数。
const { switchChain } = useSwitchChain()
这个钩子 (opens in a new tab)让我们能够切换链。
useEffect(() => {
React 钩子 useEffect (opens in a new tab) 允许你在变量值更改时运行一个函数,以同步外部系统。
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 中,{<expression>} 表示将表达式作为 JavaScript 进行求值。
{connection.status === 'connected' && (
语法 {<condition> && <value>} means "if the condition is true, evaluate to the value; if it isn't, evaluate to false`”。
这是在 JSX 中放置 if 语句的标准方法。
<div>
<Greeter />
<hr />
JSX 遵循 XML 标准,该标准比 HTML 更严格。如果标签没有相应的结束标签,它必须在末尾有一个斜杠 (/) 来终止它。
这里我们有两个这样的标签,<Greeter />(它实际上包含与合约通信的 HTML 代码)和表示水平线的 <hr /> (opens in a new tab)。
<button type="button" onClick={disconnect}>
Disconnect
</button>
</div>
)}
如果用户点击此按钮,则调用 disconnect 函数。
{connection.status !== 'connected' && (
如果我们未连接,则显示连接到钱包的必要选项。
<div>
<h2>Connect</h2>
{connectors.map((connector) => (
在 connectors 中,我们有一个连接器列表。我们使用 map (opens in a new tab) 将其转换为要显示的 JSX 按钮列表。
<button
key={connector.uid}
在 JSX 中,“兄弟”标签(从同一个父级派生的标签)必须具有不同的标识符。
onClick={() => connect({ connector })}
type="button"
>
{connector.name}
</button>
))}
连接器按钮。
<div>{status}</div>
<div>{error?.message}</div>
</div>
)}
提供其他信息。表达式语法 <variable>?.<field> 告诉 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 库 (opens in a new tab)为我们提供了各种以太坊数据类型的 TypeScript 定义,例如 AddressType (opens in a new tab)。
let greeterABI = [
{ "type": "function", "name": "greet", ... },
{ "type": "function", "name": "setGreeting", ... },
{ "type": "event", "name": "SetGreeting", ... },
] as const // greeterABI
Greeter 合约的 ABI。
如果你同时开发合约和 UI,通常会将它们放在同一个仓库中,并将 Solidity 编译器生成的 ABI 作为应用中的文件使用。但是,这里没有必要这样做,因为合约已经开发完毕且不会更改。
我们使用 as const (opens in a new tab) 告诉 TypeScript 这是一个真正的常量。通常,当你在 JavaScript 中指定 const x = {"a": 1} 时,你可以更改 x 中的值,只是不能对其进行赋值。
type AddressPerBlockchainType = {
[key: number]: AddressType
}
TypeScript 是强类型的。我们使用此定义来指定 Greeter 合约在不同链上部署的地址。键是一个数字(chainId),值是一个 AddressType(一个地址)。
const contractAddrs : AddressPerBlockchainType = {
// Sepolia
11155111: '0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA'
}
合约在 Sepolia (opens in a new tab) 上的地址。
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 (opens in a new tab) 函数允许我们安排一个函数定期运行。在这种情况下,每秒运行一次。该函数调用 setNow 来更新状态,因此 Timer 组件将被重新渲染。我们将它包装在具有空依赖项列表的 useEffect (opens in a new tab) 中,这样它只会发生一次,而不是每次渲染组件时都发生。
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 (opens in a new tab) 提供。因为这是一个钩子 (use...),所以每当此信息更改时,组件都会重新渲染。
const greeterAddr = chainId && contractAddrs[chainId]
Greeter 合约的地址,如果我们没有链信息,或者我们在没有该合约的链上,则该地址为 undefined。
const readResults = useReadContract({
address: greeterAddr,
abi: greeterABI,
functionName: "greet", // 无参数
})
useReadContract 钩子 (opens in a new tab)调用合约 (opens in a new tab)的 greet 函数。
const [ currentGreeting, setCurrentGreeting ] =
useState("Please wait while we fetch the greeting from the blockchain...")
const [ newGreeting, setNewGreeting ] = useState("")
React 的 useState 钩子 (opens in a new tab)允许我们指定一个状态变量,其值在组件的多次渲染之间保持不变。初始值是参数,在本例中为空字符串。
useState 钩子返回一个包含两个值的列表:
- 状态变量的当前值。
- 一个在需要时修改状态变量的函数。由于这是一个钩子,每次调用它时都会再次渲染组件。
在这种情况下,我们使用一个状态变量来存储用户想要设置的新问候语。
const [ lastSetterAddress, setLastSetterAddress ] = useState("")
如果多个用户同时使用同一个合约,他们可能会覆盖彼此的问候语。在用户看来,这就像是应用出现了故障。如果应用显示最后是谁设置了问候语,用户就会知道是其他人设置的,并且应用运行正常。
const [ status, setStatus ] = useState("")
const [ statusTime, setStatusTime ] = useState(new Date())
用户喜欢看到他们的操作立即生效。然而,在区块链上情况并非如此。这些状态变量让我们至少可以向用户显示一些内容,以便他们知道他们的操作正在进行中。
useEffect(() => {
if (readResults.data) {
setCurrentGreeting(readResults.data)
setStatus("Greeting fetched from blockchain")
}
}, [readResults.data])
如果上面的 readResults 更改了数据并且它没有设置为假值(例如 undefined),则将当前问候语更新为从区块链读取的问候语。同时,更新状态。
useWatchContractEvent({
address: greeterAddr,
abi: greeterABI,
eventName: 'SetGreeting',
chainId,
监听 SetGreeting 事件。
enabled: !!greeterAddr,
!!<value> 表示如果值为 false,或者求值为假的任何值(例如 undefined、0 或空字符串),则整个表达式为 false。对于任何其他值,它为 true。这是一种将值转换为布尔值的方法,因为如果没有 greeterAddr,我们就不想监听事件。
onLogs: logs => {
const greetingFromContract = logs[0].args.greeting
setCurrentGreeting(greetingFromContract)
setLastSetterAddress(logs[0].args.sender)
updateStatus("Greeting updated by event")
},
})
当我们看到日志时(当我们看到新事件时会发生这种情况),这意味着问候语已被修改。在这种情况下,我们可以将 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 (opens in a new tab),但支持更好的状态更新。
const simulation = useSimulateContract({
address: greeterAddr,
abi: greeterABI,
functionName: 'setGreeting',
args: [newGreeting],
account: account.address
})
从客户端的角度来看,提交区块链交易的过程如下:
- 使用
eth_estimateGas(opens in a new tab) 将交易发送到区块链中的节点。 - 等待节点的响应。
- 收到响应后,要求用户通过钱包签署交易。此步骤必须在收到节点响应后发生,因为在签署交易之前会向用户显示交易的 gas 成本。
- 等待用户批准。
- 再次发送交易,这次使用
eth_sendRawTransaction(opens in a new tab)。
第 2 步可能会花费可感知的时间,在此期间用户可能会想知道用户界面是否收到了他们的命令,以及为什么还没有要求他们签署交易。这会造成糟糕的用户体验 (UX)。
一种解决方案是每次参数更改时发送 eth_estimateGas。然后,当用户实际想要发送交易时(在本例中通过按 Update greeting),gas 成本是已知的,用户可以立即看到钱包页面。
return (
现在我们终于可以创建要返回的实际 HTML 了。
<>
<h2>Greeter</h2>
{currentGreeting}
显示当前问候语。
{lastSetterAddress && (
<p>Last updated by {
lastSetterAddress === account.address ? "you" : lastSetterAddress
}</p>
)}
如果我们知道最后是谁设置了问候语,则显示该信息。Greeter 不会跟踪此信息,并且我们不想回头查找 SetGreeting 事件,因此我们只有在运行期间更改问候语时才能获取它。
<hr />
<input type="text"
value={newGreeting}
onChange={greetingChange}
/>
<br />
这是用户可以设置新问候语的输入文本字段。每次用户按下一个键时,我们都会调用 greetingChange,它会调用 setNewGreeting。由于 setNewGreeting 来自 useState,它会导致 Greeter 组件重新渲染。这意味着:
- 我们需要指定
value来保留新问候语的值,否则它将恢复为默认值,即空字符串。 - 每次
newGreeting更改时,simulation也会更新,这意味着我们将获得具有正确问候语的模拟。这可能是相关的,因为 gas 成本取决于调用数据的大小,而调用数据的大小取决于字符串的长度。
<button disabled={!simulation.data}
只有当我们拥有发送交易所需的信息时,才启用该按钮。
onClick={async () => {
updateStatus("Please confirm in wallet...")
更新状态。此时,用户需要在钱包中进行确认。
await writeContractAsync(simulation.data.request)
updateStatus("Transaction sent, waiting for greeting to change...")
}}
>
Update greeting
</button>
writeContractAsync 仅在交易实际发送后返回。这让我们能够向用户显示交易等待被包含在区块链中的时间。
<h4>Status: {status}</h4>
<p>Updated <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 配置包括此应用支持的链。你可以查看可用链的列表 (opens in a new tab)。
connectors: [
injected(),
],
这个连接器 (opens in a new tab)让我们能够与安装在浏览器中的钱包进行通信。
transports: {
[sepolia.id]: http()
Viem 附带的默认 HTTP 端点已经足够好了。如果我们想要不同的 URL,我们可以使用 http("https:// hostname ") 或 webSocket("wss:// hostname ")。
},
multiInjectedProviderDiscovery: false,
})
添加另一个区块链
如今有很多 L2 扩容解决方案 (opens in a new tab),你可能想要支持一些 Viem 尚未支持的解决方案。为此,你需要修改 src/wagmi.ts。这些说明解释了如何添加 Optimism Sepolia (opens in a new tab)。
-
编辑
src/wagmi.tsA. 从 Viem 导入
defineChain类型。import { defineChain } from 'viem'B. 添加网络定义。你实际上不需要为 Optimism Sepolia 执行此操作,它已经在
viem中了 (opens in a new tab),但通过这种方式,你可以学习如何添加不在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 (opens in a new tab) 并点击表格右侧的按钮之一,将该链添加到你的钱包中。
B. 在应用中,断开连接 (Disconnect),然后重新连接以更改区块链。有更好的方法来处理这个问题,但它们需要更改应用。
结论
当然,你并不真正关心为 Greeter 提供用户界面。你想为你自己的合约创建用户界面。要创建你自己的应用,请执行以下步骤:
-
指定创建一个 Wagmi 应用。
npm create wagmi -
输入
y继续。 -
命名该应用。
-
选择 React 框架。
-
选择 Vite 变体。
现在去让你的合约在广阔的世界中可用吧。
在这里查看我的更多作品 (opens in a new tab)。
页面最后更新: 2026年3月3日