为您的合约构建用户界面
您在以太坊生态系统中找到了我们需要的功能。 您编写了智能合约来实现它,甚至可能编写了一些在链下运行的相关代码。 太好了! 遗憾的是,如果没有用户界面,您就不会有任何用户,而且您上一次编写网站时,人们还在使用拨号调制解调器,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)。 我不会重复那些网站已经做得很好的工作,而是假设您更喜欢通过实践来学习,并从一个您可以上手的应用开始。 您仍然需要理论来完成工作,我们稍后会讲到这一点——我们将逐个源文件进行讲解,并在讲到时进行讨论。
安装
-
如有必要,请将 Holesky 区块链 (opens in a new tab) 添加到您的钱包并获取测试 ETH (opens in a new tab)。
-
克隆 github 仓库。
git clone https://github.com/qbzzt/20230801-modern-ui.git -
安装必要的软件包。
cd 20230801-modern-ui pnpm install -
启动应用。
pnpm dev -
浏览到应用显示的 URL。 在大多数情况下,该 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 是 JavaScript 的一个扩展,支持类型检查 (opens in a new tab)。 TypeScript 被编译成 JavaScript,因此我们可以用它来进行客户端执行。
import '@rainbow-me/rainbowkit/styles.css'
import { RainbowKitProvider } from '@rainbow-me/rainbowkit'
import * as React from 'react'
import * as ReactDOM from 'react-dom/client'
import { WagmiConfig } from 'wagmi'
import { chains, config } from './wagmi'
导入我们需要的库代码。
import { App } from './App'
导入实现该应用的 React 组件(见下文)。
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 库插入额外的调试检查,这在开发过程中很有用。
<WagmiConfig config={config}>
该应用也位于 一个 WagmiConfig 组件 (opens in a new tab)中。 Wagmi (we are going to make it) 库 (opens in a new tab) 将 React UI 定义与 viem 库 (opens in a new tab)连接起来,用于编写以太坊去中心化应用程序。
<RainbowKitProvider chains={chains}>
最后,还有一个 RainbowKitProvider 组件 (opens in a new tab)。 此组件处理登录以及钱包和应用之间的通信。
<App />
现在我们可以为应用提供组件,该组件实际实现了 UI。 组件末尾的 /> 告诉 React,根据 XML 标准,该组件内部没有任何定义。
</RainbowKitProvider>
</WagmiConfig>
</React.StrictMode>,
)
当然,我们必须关闭其他组件。
src/App.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit'
import { useAccount } from 'wagmi'
import { Greeter } from './components/Greeter'
export function App() {
这是创建 React 组件的标准方法——定义一个函数,每次需要渲染时都会调用该函数。 这个函数通常在顶部有一些 TypeScript 或 JavaScript 代码,然后是一个返回 JSX 代码的 return 语句。
const { isConnected } = useAccount()
这里我们使用 useAccount (opens in a new tab) 来检查我们是否通过钱包连接到区块链。
按照惯例,在 React 中,名为 use... 的函数是返回某种数据的 hooks (opens in a new tab)。 当您使用此类挂钩时,您的组件不仅会获取数据,而且当该数据更改时,组件会使用更新后的信息重新渲染。
return (
<>
React 组件的 JSX _必须_返回一个组件。 当我们有多个组件并且没有任何东西可以"自然地"包装它们时,我们使用一个空组件 (<> ... </>) 将它们变成单个组件。
<h1>Greeter</h1>
<ConnectButton />
我们从 RainbowKit 中获取 ConnectButton 组件 (opens in a new tab)。 当我们未连接时,它会给我们一个“连接钱包”按钮,该按钮会打开一个模式窗口,解释什么是钱包,并让您选择使用哪一个。 当我们连接时,它会显示我们使用的区块链、我们的帐户地址和我们的 ETH 余额。 我们可以使用这些显示来切换网络或断开连接。
{isConnected && (
当我们需要将实际的 JavaScript(或将被编译成 JavaScript 的 TypeScript)插入到 JSX 中时,我们使用括号({})。
语法 a && b 是 [a ? 的缩写 b : a](https://www.w3schools.com/react/react_es6_ternary.asp)。 也就是说,如果 a为真,则其值为b,否则其值为 a(可以是 false、0` 等)。 这是一种告诉 React 组件只应在满足特定条件时才显示的简单方法。
在这种情况下,我们只想在用户连接到区块链时向用户显示 Greeter。
<Greeter />
)}
</>
)
}
src/components/Greeter.tsx
该文件包含大部分 UI 功能。 它包含通常会放在多个文件中的定义,但由于这是一个教程,程序被优化为第一次易于理解,而不是为了性能或易于维护。
import { useState, ChangeEventHandler } from 'react'
import { useNetwork,
useReadContract,
usePrepareContractWrite,
useContractWrite,
useContractEvent
} from 'wagmi'
我们使用这些库函数。 同样,在使用它们的地方,下面会进行解释。
import { AddressType } from 'abitype'
abitype 库 (opens in a new tab)为我们提供了各种以太坊数据类型的 TypeScript 定义,例如 AddressType (opens in a new tab)。
let greeterABI = [
.
.
.
] as const // greeterABI
Greeter 合约的 ABI。
如果您同时开发合约和 UI,通常会将它们放在同一个仓库中,并使用 Solidity 编译器生成的 ABI 作为应用中的文件。 然而,这里没有必要这样做,因为合约已经开发完毕,不会再改变。
type AddressPerBlockchainType = {
[key: number]: AddressType
}
TypeScript 是强类型的。 我们使用这个定义来指定 Greeter 合约在不同链上部署的地址。 键是数字(chainId),值是 AddressType(一个地址)。
const contractAddrs: AddressPerBlockchainType = {
// Holesky
17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',
// Sepolia
11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'
}
合约在两个受支持网络上的地址:Holesky (opens in a new tab) 和 Sepolia (opens in a new tab)。
注意:实际上还有第三个定义,针对 Redstone Holesky,将在下面解释。
type ShowObjectAttrsType = {
name: string,
object: any
}
此类型用作 ShowObject 组件的参数(稍后解释)。 它包括对象的名称及其值,用于调试目的。
type ShowGreetingAttrsType = {
greeting: string | undefined
}
在任何时候,我们都可能知道问候语是什么(因为我们从区块链中读取了它),也可能不知道(因为我们还没有收到它)。 因此,拥有一个可以是字符串或空的类型是很有用的。
Greeter 组件
const Greeter = () => {
最后,我们来定义这个组件。
const { chain } = useNetwork()
我们使用的链的信息,由 wagmi (opens in a new tab) 提供。
因为这是一个钩子 (use...),所以每当此信息发生变化时,组件都会被重新绘制。
const greeterAddr = chain && contractAddrs[chain.id]
Greeter 合约的地址,它因链而异(如果我们没有链信息或者我们在没有该合约的链上,它就是 undefined)。
const readResults = useReadContract({
address: greeterAddr,
abi: greeterABI,
functionName: "greet" , // No arguments
watch: true
})
useReadContract 钩子 (opens in a new tab) 从合约中读取信息。 您可以在 UI 中展开 readResults 来确切地看到它返回了什么信息。 在这种情况下,我们希望它继续观察,以便在问候语更改时得到通知。
**注意:**我们可以监听 setGreeting 事件 (opens in a new tab)来了解问候语何时更改并以这种方式进行更新。 然而,虽然这样可能更高效,但它并不适用于所有情况。 当用户切换到不同的链时,问候语也会改变,但这种改变并不伴随事件。 我们可以让一部分代码监听事件,另一部分识别链的变化,但这会比仅仅设置 watch 参数 (opens in a new tab)更复杂。
const [ newGreeting, setNewGreeting ] = useState("")
React 的 useState 钩子 (opens in a new tab)允许我们指定一个状态变量,其值在组件的多次渲染之间保持不变。 初始值是参数,在本例中为空字符串。
useState 钩子返回一个包含两个值的列表:
- 状态变量的当前值。
- 一个在需要时修改状态变量的函数。 由于这是一个钩子,所以每次调用它时,组件都会重新渲染。
在这种情况下,我们使用一个状态变量来表示用户想要设置的新问候语。
const greetingChange : ChangeEventHandler<HTMLInputElement> = (evt) =>
setNewGreeting(evt.target.value)
这是新问候语输入字段发生变化时的事件处理程序。 类型 ChangeEventHandler<HTMLInputElement> (opens in a new tab) 指定这是 HTML 输入元素值变化的处理器。 之所以使用 <HTMLInputElement> 部分,是因为这是一个泛型类型 (opens in a new tab)。
const preparedTx = usePrepareContractWrite({
address: greeterAddr,
abi: greeterABI,
functionName: 'setGreeting',
args: [ newGreeting ]
})
const workingTx = useContractWrite(preparedTx.config)
这是从客户端角度提交区块链交易的过程:
- 使用
eth_estimateGas(opens in a new tab) 将交易发送到区块链中的一个节点。 - 等待节点的响应。
- 收到响应后,请用户通过钱包签署交易。 这一步_必须_在收到节点响应后进行,因为在签名之前,用户会看到交易的 gas 成本。
- 等待用户批准。
- 再次发送交易,这次使用
eth_sendRawTransaction(opens in a new tab)。
第 2 步可能会花费一段可感知的时间,在此期间,用户会想知道他们的命令是否真的被用户界面接收到,以及为什么还没有要求他们签署交易。 这会造成糟糕的用户体验 (UX)。
解决方案是使用准备钩子 (opens in a new tab)。 每次参数更改时,立即向节点发送 eth_estimateGas 请求。 然后,当用户实际想要发送交易时(在本例中,通过按更新问候语),gas 成本是已知的,用户可以立即看到钱包页面。
return (
现在我们终于可以创建要返回的实际 HTML 了。
<>
<h2>Greeter</h2>
{
!readResults.isError && !readResults.isLoading &&
<ShowGreeting greeting={readResults.data} />
}
<hr />
创建一个 ShowGreeting 组件(在下面解释),但前提是问候语已成功从区块链中读取。
<input type="text"
value={newGreeting}
onChange={greetingChange}
/>
这是用户可以设置新问候语的输入文本字段。 每次用户按下一个键,我们都会调用 greetingChange,它会调用 setNewGreeting。 由于 setNewGreeting 来自 useState 钩子,它会导致 Greeter 组件再次渲染。 这意味着:
- 我们需要指定
value来保留新问候语的值,否则它将变回默认值,即空字符串。 - 每次
newGreeting更改时都会调用usePrepareContractWrite,这意味着它在准备好的交易中总是会有最新的newGreeting。
<button disabled={!workingTx.write}
onClick={workingTx.write}
>
更新问候语
</button>
如果没有 workingTx.write,那么我们仍在等待发送问候语更新所需的信息,因此按钮是禁用的。 如果有一个 workingTx.write 值,那么这就是调用以发送交易的函数。
<hr />
<ShowObject name="readResults" object={readResults} />
<ShowObject name="preparedTx" object={preparedTx} />
<ShowObject name="workingTx" object={workingTx} />
</>
)
}
最后,为了帮助您了解我们在做什么,请显示我们使用的三个对象:
readResultspreparedTxworkingTx
ShowGreeting 组件
此组件显示
const ShowGreeting = (attrs : ShowGreetingAttrsType) => {
组件函数接收一个包含组件所有属性的参数。
return <b>{attrs.greeting}</b>
}
ShowObject 组件
为了提供信息,我们使用 ShowObject 组件来显示重要的对象(readResults 用于读取问候语,preparedTx 和 workingTx 用于我们创建的交易)。
const ShowObject = (attrs: ShowObjectAttrsType ) => {
const keys = Object.keys(attrs.object)
const funs = keys.filter(k => typeof attrs.object[k] == "function")
return <>
<details>
我们不希望所有信息都杂乱地显示在 UI 中,因此为了能够查看或关闭它们,我们使用 details (opens in a new tab) 标签。
<summary>{attrs.name}</summary>
<pre>
{JSON.stringify(attrs.object, null, 2)}
大多数字段都是使用 JSON.stringify (opens in a new tab) 显示的。
</pre>
{ funs.length > 0 &&
<>
Functions:
<ul>
唯一的例外是函数,它不属于 JSON 标准 (opens in a new tab),因此必须单独显示。
{funs.map((f, i) =>
在 JSX 中,{ 花括号 } 内的代码被解释为 JavaScript。 然后,( 圆括号 ) 内的代码再次被解释为 JSX。
(<li key={i}>{f}</li>)
)}
React 要求 DOM 树 (opens in a new tab)中的标签具有不同的标识符。 这意味着同一标签(在本例中为无序列表 (opens in a new tab))的子标签需要不同的 key 属性。
</ul>
</>
}
</details>
</>
}
结束各种 HTML 标签。
最终的 export
export { Greeter }
Greeter 组件是我们需要为应用导出的组件。
src/wagmi.ts
最后,与 WAGMI 相关的各种定义都在 src/wagmi.ts 中。 我不会在这里解释所有内容,因为其中大部分是您不太可能需要更改的样板代码。
这里的代码与在 github 上 (opens in a new tab)的代码不完全相同,因为在文章后面我们添加了另一条链(Redstone Holesky (opens in a new tab))。
import { getDefaultWallets } from '@rainbow-me/rainbowkit'
import { configureChains, createConfig } from 'wagmi'
import { holesky, sepolia } from 'wagmi/chains'
导入应用支持的区块链。 您可以在 viem github (opens in a new tab) 中查看支持的链的列表。
import { publicProvider } from 'wagmi/providers/public'
const walletConnectProjectId = 'c96e690bb92b6311e8e9b2a6a22df575'
要使用 WalletConnect (opens in a new tab),您需要为您的应用提供一个项目 ID。 您可以在 cloud.walletconnect.com (opens in a new tab) 上获取。
const { chains, publicClient, webSocketPublicClient } = configureChains(
[ holesky, sepolia ],
[
publicProvider(),
],
)
const { connectors } = getDefaultWallets({
appName: 'My wagmi + RainbowKit App',
chains,
projectId: walletConnectProjectId,
})
export const config = createConfig({
autoConnect: true,
connectors,
publicClient,
webSocketPublicClient,
})
export { chains }
添加另一个区块链
如今有很多 L2 扩容解决方案,您可能想支持一些 viem 尚不支持的方案。 要做到这一点,您需要修改 src/wagmi.ts。 这些说明解释了如何添加 Redstone Holesky (opens in a new tab)。
-
从 viem 导入
defineChain类型。import { defineChain } from 'viem' -
添加网络定义。
const redstoneHolesky = defineChain({ id: 17_001, name: 'Redstone Holesky', network: 'redstone-holesky', nativeCurrency: { decimals: 18, name: 'Ether', symbol: 'ETH', }, rpcUrls: { default: { http: ['https://rpc.holesky.redstone.xyz'], webSocket: ['wss://rpc.holesky.redstone.xyz/ws'], }, public: { http: ['https://rpc.holesky.redstone.xyz'], webSocket: ['wss://rpc.holesky.redstone.xyz/ws'], }, }, blockExplorers: { default: { name: 'Explorer', url: 'https://explorer.holesky.redstone.xyz' }, }, }) -
将新链添加到
configureChains调用中。const { chains, publicClient, webSocketPublicClient } = configureChains( [ holesky, sepolia, redstoneHolesky ], [ publicProvider(), ], ) -
确保应用知道您在新网络上的合约地址。 在这种情况下,我们修改
src/components/Greeter.tsx:const contractAddrs : AddressPerBlockchainType = { // Holesky 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8', // Redstone Holesky 17001: '0x4919517f82a1B89a32392E1BF72ec827ba9986D3', // Sepolia 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0' }
结论
当然,您并不真正关心为 Greeter 提供用户界面。 您想为自己的合约创建用户界面。 要创建您自己的应用,请执行以下步骤:
-
指定创建 wagmi 应用。
pnpm create wagmi -
为应用命名。
-
选择 React 框架。
-
选择 Vite 变体。
现在去为您的合约创造一个全世界都能使用的界面吧。
点击此处查看我的更多作品 (opens in a new tab)。
页面最后更新: 2026年3月3日