跳转至主要内容

为您的合约构建用户界面

typescript
react
vite
wagmi
前端
初学者
Ori Pomerantz
2023年11月1日
20 分钟阅读

您在以太坊生态系统中找到了我们需要的功能。 您编写了智能合约来实现它,甚至可能编写了一些在链下运行的相关代码。 太好了! 遗憾的是,如果没有用户界面,您就不会有任何用户,而且您上一次编写网站时,人们还在使用拨号调制解调器,JavaScript 还是个新事物。

本文就是为您准备的。 我假设您了解编程,或许还了解一些 JavaScript 和 HTML,但您的用户界面技能已经生疏和过时了。 我们将一起学习一个简单的现代应用,这样您就会知道现在是如何做的了。

为何这很重要

理论上,您可以让人们使用 Etherscanopens in a new tabBlockscoutopens in a new tab 与您的合约进行交互。 对于经验丰富的以太坊用户来说,这很好。 但我们正努力为另外十亿人opens in a new tab服务。 如果没有出色的用户体验,这是不可能实现的,而友好的用户界面是其中的重要组成部分。

Greeter 应用

现代 UI 的工作原理背后有很多理论,也有很多不错的网站opens in a new tab对此进行了解释opens in a new tab。 我不会重复那些网站已经做得很好的工作,而是假设您更喜欢通过实践来学习,并从一个您可以上手的应用开始。 您仍然需要理论来完成工作,我们稍后会讲到这一点——我们将逐个源文件进行讲解,并在讲到时进行讨论。

安装

  1. 如有必要,请将 Holesky 区块链opens in a new tab 添加到您的钱包并获取测试 ETHopens in a new tab

  2. 克隆 github 仓库。

    1git clone https://github.com/qbzzt/20230801-modern-ui.git
  3. 安装必要的软件包。

    1cd 20230801-modern-ui
    2pnpm install
  4. 启动应用。

    1pnpm dev
  5. 浏览到应用显示的 URL。 在大多数情况下,该 URL 为 http://localhost:5173/opens in a new tab

  6. 您可以在区块链浏览器opens in a new tab上看到合约源代码,它是 Hardhat Greeter 的略微修改版本。

文件演练

index.html

该文件是标准的 HTML 样板文件,但导入脚本文件的这一行除外。

1<script type="module" src="/src/main.tsx"></script>

src/main.tsx

文件扩展名告诉我们,这是一个用 TypeScriptopens in a new tab 编写的 React 组件opens in a new tab,TypeScript 是 JavaScript 的一个扩展,支持类型检查opens in a new tab。 TypeScript 被编译成 JavaScript,因此我们可以用它来进行客户端执行。

1import '@rainbow-me/rainbowkit/styles.css'
2import { RainbowKitProvider } from '@rainbow-me/rainbowkit'
3import * as React from 'react'
4import * as ReactDOM from 'react-dom/client'
5import { WagmiConfig } from 'wagmi'
6import { chains, config } from './wagmi'

导入我们需要的库代码。

1import { App } from './App'

导入实现该应用的 React 组件(见下文)。

1ReactDOM.createRoot(document.getElementById('root')!).render(

创建根 React 组件。 render 的参数是 JSXopens in a new tab,这是一种同时使用 HTML 和 JavaScript/TypeScript 的扩展语言。 这里的感叹号告诉 TypeScript 组件:“你不知道 document.getElementById('root') 是否会是 ReactDOM.createRoot 的有效参数,但别担心——我是开发者,我告诉你它会是”。

1 <React.StrictMode>

该应用将放在 一个 React.StrictMode 组件opens in a new tab中。 该组件告诉 React 库插入额外的调试检查,这在开发过程中很有用。

1 <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连接起来,用于编写以太坊去中心化应用程序。

1 <RainbowKitProvider chains={chains}>

最后,还有一个 RainbowKitProvider 组件opens in a new tab。 此组件处理登录以及钱包和应用之间的通信。

1 <App />

现在我们可以为应用提供组件,该组件实际实现了 UI。 组件末尾的 /> 告诉 React,根据 XML 标准,该组件内部没有任何定义。

1 </RainbowKitProvider>
2 </WagmiConfig>
3 </React.StrictMode>,
4)

当然,我们必须关闭其他组件。

src/App.tsx

1import { ConnectButton } from '@rainbow-me/rainbowkit'
2import { useAccount } from 'wagmi'
3import { Greeter } from './components/Greeter'
4
5export function App() {

这是创建 React 组件的标准方法——定义一个函数,每次需要渲染时都会调用该函数。 这个函数通常在顶部有一些 TypeScript 或 JavaScript 代码,然后是一个返回 JSX 代码的 return 语句。

1 const { isConnected } = useAccount()

这里我们使用 useAccountopens in a new tab 来检查我们是否通过钱包连接到区块链。

按照惯例,在 React 中,名为 use... 的函数是返回某种数据的 hooksopens in a new tab。 当您使用此类挂钩时,您的组件不仅会获取数据,而且当该数据更改时,组件会使用更新后的信息重新渲染。

1 return (
2 <>

React 组件的 JSX _必须_返回一个组件。 当我们有多个组件并且没有任何东西可以"自然地"包装它们时,我们使用一个空组件 (<> ... </>) 将它们变成单个组件。

1 <h1>Greeter</h1>
2 <ConnectButton />

我们从 RainbowKit 中获取 ConnectButton 组件opens in a new tab。 当我们未连接时,它会给我们一个“连接钱包”按钮,该按钮会打开一个模式窗口,解释什么是钱包,并让您选择使用哪一个。 当我们连接时,它会显示我们使用的区块链、我们的帐户地址和我们的 ETH 余额。 我们可以使用这些显示来切换网络或断开连接。

1 {isConnected && (

当我们需要将实际的 JavaScript(或将被编译成 JavaScript 的 TypeScript)插入到 JSX 中时,我们使用括号({})。

语法 a && b 是 [a ? 的缩写 b : a](https://www.w3schools.com/react/react_es6_ternary.asp)。 也就是说,如果 a为真,则其值为b,否则其值为 a(可以是 false0` 等)。 这是一种告诉 React 组件只应在满足特定条件时才显示的简单方法。

在这种情况下,我们只想在用户连接到区块链时向用户显示 Greeter

1 <Greeter />
2 )}
3 </>
4 )
5}

src/components/Greeter.tsx

该文件包含大部分 UI 功能。 它包含通常会放在多个文件中的定义,但由于这是一个教程,程序被优化为第一次易于理解,而不是为了性能或易于维护。

1import { useState, ChangeEventHandler } from 'react'
2import { useNetwork,
3 useReadContract,
4 usePrepareContractWrite,
5 useContractWrite,
6 useContractEvent
7 } from 'wagmi'

我们使用这些库函数。 同样,在使用它们的地方,下面会进行解释。

1import { AddressType } from 'abitype'

abitypeopens in a new tab为我们提供了各种以太坊数据类型的 TypeScript 定义,例如 AddressTypeopens in a new tab

1let greeterABI = [
2 .
3 .
4 .
5] as const // greeterABI

Greeter 合约的 ABI。 如果您同时开发合约和 UI,通常会将它们放在同一个仓库中,并使用 Solidity 编译器生成的 ABI 作为应用中的文件。 然而,这里没有必要这样做,因为合约已经开发完毕,不会再改变。

1type AddressPerBlockchainType = {
2 [key: number]: AddressType
3}

TypeScript 是强类型的。 我们使用这个定义来指定 Greeter 合约在不同链上部署的地址。 键是数字(chainId),值是 AddressType(一个地址)。

1const contractAddrs: AddressPerBlockchainType = {
2 // Holesky
3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',
4
5 // Sepolia
6 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'
7}

合约在两个受支持网络上的地址:Holeskyopens in a new tabSepoliaopens in a new tab

注意:实际上还有第三个定义,针对 Redstone Holesky,将在下面解释。

1type ShowObjectAttrsType = {
2 name: string,
3 object: any
4}

此类型用作 ShowObject 组件的参数(稍后解释)。 它包括对象的名称及其值,用于调试目的。

1type ShowGreetingAttrsType = {
2 greeting: string | undefined
3}

在任何时候,我们都可能知道问候语是什么(因为我们从区块链中读取了它),也可能不知道(因为我们还没有收到它)。 因此,拥有一个可以是字符串或空的类型是很有用的。

Greeter 组件
1const Greeter = () => {

最后,我们来定义这个组件。

1 const { chain } = useNetwork()

我们使用的链的信息,由 wagmiopens in a new tab 提供。 因为这是一个钩子 (use...),所以每当此信息发生变化时,组件都会被重新绘制。

1 const greeterAddr = chain && contractAddrs[chain.id]

Greeter 合约的地址,它因链而异(如果我们没有链信息或者我们在没有该合约的链上,它就是 undefined)。

1 const readResults = useReadContract({
2 address: greeterAddr,
3 abi: greeterABI,
4 functionName: "greet" , // No arguments
5 watch: true
6 })

useReadContract 钩子opens in a new tab 从合约中读取信息。 您可以在 UI 中展开 readResults 来确切地看到它返回了什么信息。 在这种情况下,我们希望它继续观察,以便在问候语更改时得到通知。

**注意:**我们可以监听 setGreeting 事件opens in a new tab来了解问候语何时更改并以这种方式进行更新。 然而,虽然这样可能更高效,但它并不适用于所有情况。 当用户切换到不同的链时,问候语也会改变,但这种改变并不伴随事件。 我们可以让一部分代码监听事件,另一部分识别链的变化,但这会比仅仅设置 watch 参数opens in a new tab更复杂。

1 const [ newGreeting, setNewGreeting ] = useState("")

React 的 useState 钩子opens in a new tab允许我们指定一个状态变量,其值在组件的多次渲染之间保持不变。 初始值是参数,在本例中为空字符串。

useState 钩子返回一个包含两个值的列表:

  1. 状态变量的当前值。
  2. 一个在需要时修改状态变量的函数。 由于这是一个钩子,所以每次调用它时,组件都会重新渲染。

在这种情况下,我们使用一个状态变量来表示用户想要设置的新问候语。

1 const greetingChange : ChangeEventHandler<HTMLInputElement> = (evt) =>
2 setNewGreeting(evt.target.value)

这是新问候语输入字段发生变化时的事件处理程序。 类型 ChangeEventHandler<HTMLInputElement>opens in a new tab 指定这是 HTML 输入元素值变化的处理器。 之所以使用 <HTMLInputElement> 部分,是因为这是一个泛型类型opens in a new tab

1 const preparedTx = usePrepareContractWrite({
2 address: greeterAddr,
3 abi: greeterABI,
4 functionName: 'setGreeting',
5 args: [ newGreeting ]
6 })
7 const workingTx = useContractWrite(preparedTx.config)

这是从客户端角度提交区块链交易的过程:

  1. 使用 eth_estimateGasopens in a new tab 将交易发送到区块链中的一个节点。
  2. 等待节点的响应。
  3. 收到响应后,请用户通过钱包签署交易。 这一步_必须_在收到节点响应后进行,因为在签名之前,用户会看到交易的 gas 成本。
  4. 等待用户批准。
  5. 再次发送交易,这次使用 eth_sendRawTransactionopens in a new tab

第 2 步可能会花费一段可感知的时间,在此期间,用户会想知道他们的命令是否真的被用户界面接收到,以及为什么还没有要求他们签署交易。 这会造成糟糕的用户体验 (UX)。

解决方案是使用准备钩子opens in a new tab。 每次参数更改时,立即向节点发送 eth_estimateGas 请求。 然后,当用户实际想要发送交易时(在本例中,通过按更新问候语),gas 成本是已知的,用户可以立即看到钱包页面。

1 return (

现在我们终于可以创建要返回的实际 HTML 了。

1 <>
2 <h2>Greeter</h2>
3 {
4 !readResults.isError && !readResults.isLoading &&
5 <ShowGreeting greeting={readResults.data} />
6 }
7 <hr />

创建一个 ShowGreeting 组件(在下面解释),但前提是问候语已成功从区块链中读取。

1 <input type="text"
2 value={newGreeting}
3 onChange={greetingChange}
4 />

这是用户可以设置新问候语的输入文本字段。 每次用户按下一个键,我们都会调用 greetingChange,它会调用 setNewGreeting。 由于 setNewGreeting 来自 useState 钩子,它会导致 Greeter 组件再次渲染。 这意味着:

  • 我们需要指定 value 来保留新问候语的值,否则它将变回默认值,即空字符串。
  • 每次 newGreeting 更改时都会调用 usePrepareContractWrite,这意味着它在准备好的交易中总是会有最新的 newGreeting
1 <button disabled={!workingTx.write}
2 onClick={workingTx.write}
3 >
4 更新问候语
5 </button>

如果没有 workingTx.write,那么我们仍在等待发送问候语更新所需的信息,因此按钮是禁用的。 如果有一个 workingTx.write 值,那么这就是调用以发送交易的函数。

1 <hr />
2 <ShowObject name="readResults" object={readResults} />
3 <ShowObject name="preparedTx" object={preparedTx} />
4 <ShowObject name="workingTx" object={workingTx} />
5 </>
6 )
7}

最后,为了帮助您了解我们在做什么,请显示我们使用的三个对象:

  • readResults
  • preparedTx
  • workingTx
ShowGreeting 组件

此组件显示

1const ShowGreeting = (attrs : ShowGreetingAttrsType) => {

组件函数接收一个包含组件所有属性的参数。

1 return <b>{attrs.greeting}</b>
2}
ShowObject 组件

为了提供信息,我们使用 ShowObject 组件来显示重要的对象(readResults 用于读取问候语,preparedTxworkingTx 用于我们创建的交易)。

1const ShowObject = (attrs: ShowObjectAttrsType ) => {
2 const keys = Object.keys(attrs.object)
3 const funs = keys.filter(k => typeof attrs.object[k] == "function")
4 return <>
5 <details>

我们不希望所有信息都杂乱地显示在 UI 中,因此为了能够查看或关闭它们,我们使用 detailsopens in a new tab 标签。

1 <summary>{attrs.name}</summary>
2 <pre>
3 {JSON.stringify(attrs.object, null, 2)}

大多数字段都是使用 JSON.stringifyopens in a new tab 显示的。

1 </pre>
2 { funs.length > 0 &&
3 <>
4 Functions:
5 <ul>

唯一的例外是函数,它不属于 JSON 标准opens in a new tab,因此必须单独显示。

1 {funs.map((f, i) =>

在 JSX 中,{ 花括号 } 内的代码被解释为 JavaScript。 然后,( 圆括号 ) 内的代码再次被解释为 JSX。

1 (<li key={i}>{f}</li>)
2 )}

React 要求 DOM 树opens in a new tab中的标签具有不同的标识符。 这意味着同一标签(在本例中为无序列表opens in a new tab)的子标签需要不同的 key 属性。

1 </ul>
2 </>
3 }
4 </details>
5 </>
6}

结束各种 HTML 标签。

最终的 export
1export { Greeter }

Greeter 组件是我们需要为应用导出的组件。

src/wagmi.ts

最后,与 WAGMI 相关的各种定义都在 src/wagmi.ts 中。 我不会在这里解释所有内容,因为其中大部分是您不太可能需要更改的样板代码。

这里的代码与在 github 上opens in a new tab的代码不完全相同,因为在文章后面我们添加了另一条链(Redstone Holeskyopens in a new tab)。

1import { getDefaultWallets } from '@rainbow-me/rainbowkit'
2import { configureChains, createConfig } from 'wagmi'
3import { holesky, sepolia } from 'wagmi/chains'

导入应用支持的区块链。 您可以在 viem githubopens in a new tab 中查看支持的链的列表。

1import { publicProvider } from 'wagmi/providers/public'
2
3const walletConnectProjectId = 'c96e690bb92b6311e8e9b2a6a22df575'

要使用 WalletConnectopens in a new tab,您需要为您的应用提供一个项目 ID。 您可以在 cloud.walletconnect.comopens in a new tab 上获取。

1const { chains, publicClient, webSocketPublicClient } = configureChains(
2 [ holesky, sepolia ],
3 [
4 publicProvider(),
5 ],
6)
7
8const { connectors } = getDefaultWallets({
9 appName: 'My wagmi + RainbowKit App',
10 chains,
11 projectId: walletConnectProjectId,
12})
13
14export const config = createConfig({
15 autoConnect: true,
16 connectors,
17 publicClient,
18 webSocketPublicClient,
19})
20
21export { chains }
显示全部

添加另一个区块链

如今有很多 L2 扩容解决方案,您可能想支持一些 viem 尚不支持的方案。 要做到这一点,您需要修改 src/wagmi.ts。 这些说明解释了如何添加 Redstone Holeskyopens in a new tab

  1. 从 viem 导入 defineChain 类型。

    1import { defineChain } from 'viem'
  2. 添加网络定义。

    1const redstoneHolesky = defineChain({
    2 id: 17_001,
    3 name: 'Redstone Holesky',
    4 network: 'redstone-holesky',
    5 nativeCurrency: {
    6 decimals: 18,
    7 name: 'Ether',
    8 symbol: 'ETH',
    9 },
    10 rpcUrls: {
    11 default: {
    12 http: ['https://rpc.holesky.redstone.xyz'],
    13 webSocket: ['wss://rpc.holesky.redstone.xyz/ws'],
    14 },
    15 public: {
    16 http: ['https://rpc.holesky.redstone.xyz'],
    17 webSocket: ['wss://rpc.holesky.redstone.xyz/ws'],
    18 },
    19 },
    20 blockExplorers: {
    21 default: { name: 'Explorer', url: 'https://explorer.holesky.redstone.xyz' },
    22 },
    23})
    显示全部
  3. 将新链添加到 configureChains 调用中。

    1 const { chains, publicClient, webSocketPublicClient } = configureChains(
    2 [ holesky, sepolia, redstoneHolesky ],
    3 [ publicProvider(), ],
    4 )
  4. 确保应用知道您在新网络上的合约地址。 在这种情况下,我们修改 src/components/Greeter.tsx

    1const contractAddrs : AddressPerBlockchainType = {
    2 // Holesky
    3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',
    4
    5 // Redstone Holesky
    6 17001: '0x4919517f82a1B89a32392E1BF72ec827ba9986D3',
    7
    8 // Sepolia
    9 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'
    10}
    显示全部

结论

当然,您并不真正关心为 Greeter 提供用户界面。 您想为自己的合约创建用户界面。 要创建您自己的应用,请执行以下步骤:

  1. 指定创建 wagmi 应用。

    1pnpm create wagmi
  2. 为应用命名。

  3. 选择 React 框架。

  4. 选择 Vite 变体。

  5. 您可以添加 Rainbow kitopens in a new tab

现在去为您的合约创造一个全世界都能使用的界面吧。

点击此处查看我的更多作品opens in a new tab

页面最后更新: 2025年9月22日

本教程对你有帮助吗?