跳转到主要内容

为你的合约构建用户界面

typescript
react
vite
wagmi
前端
初级
奥里·波梅兰茨
2023年11月1日
26 分钟阅读

你在以太坊生态系统中发现了一个我们需要的功能。你编写了智能合约来实现它,甚至可能还编写了一些在链下运行的相关代码。这太棒了!不幸的是,如果没有用户界面,你就不会有任何用户,而且你上一次写网站时,人们还在使用拨号调制解调器,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)。我不会重复这些网站所做的出色工作,而是假设你更喜欢在实践中学习,并从一个你可以把玩的应用开始。你仍然需要理论知识来完成工作,我们稍后会讲到——我们将逐个查看源文件,并在遇到问题时进行讨论。

安装

  1. 该应用使用 Sepolia (opens in a new tab) 测试网络。如有必要,请获取 Sepolia 测试 ETH将 Sepolia 添加到你的钱包 (opens in a new tab)

  2. 克隆 GitHub 仓库并安装必要的包。

    git clone https://github.com/qbzzt/260301-modern-ui-web3.git
    cd 260301-modern-ui-web3
    npm install
    
  3. 该应用使用免费的接入点,这些接入点存在性能限制。如果你想使用节点即服务 (Node as a service) 提供商,请替换 src/wagmi.ts 中的 URL。

  4. 启动应用。

    npm run dev
    
  5. 浏览应用显示的 URL。在大多数情况下,它是 http://localhost:5173/ (opens in a new tab)

  6. 你可以在区块链浏览器上 (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

导入我们需要的库,以及 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 组件。当我们有多个组件并且不需要容器来包装它们时,我们使用一个空组件 (<> ... </>) 将它们组合成一个组件。

提供有关当前连接的信息。在 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 { 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 钩子返回一个包含两个值的列表:

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

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

  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,或者求值为假的任何值(例如 undefined0 或空字符串),则整个表达式为 false。对于任何其他值,它为 true。这是一种将值转换为布尔值的方法,因为如果没有 greeterAddr,我们就不想监听事件。

    onLogs: logs => {
      const greetingFromContract = logs[0].args.greeting
      setCurrentGreeting(greetingFromContract)
      setLastSetterAddress(logs[0].args.sender)
      updateStatus("Greeting updated by event")
    },
  })

当我们看到日志时(当我们看到新事件时会发生这种情况),这意味着问候语已被修改。在这种情况下,我们可以将 currentGreetinglastSetterAddress 更新为新值。此外,我们还想更新状态显示。

  const updateStatus = (newStatus: string) => {
    setStatus(newStatus)
    setStatusTime(new Date())
  }

当我们更新状态时,我们想做两件事:

  1. 更新状态字符串 (status)
  2. 将上次状态更新的时间 (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    
  })

从客户端的角度来看,提交区块链交易的过程如下:

  1. 使用 eth_estimateGas (opens in a new tab) 将交易发送到区块链中的节点。
  2. 等待节点的响应。
  3. 收到响应后,要求用户通过钱包签署交易。此步骤必须在收到节点响应后发生,因为在签署交易之前会向用户显示交易的 gas 成本。
  4. 等待用户批准。
  5. 再次发送交易,这次使用 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)

  1. 编辑 src/wagmi.ts

    A. 从 Viem 导入 defineChain 类型。

    import { defineChain } from 'viem'
    

    B. 添加网络定义。你实际上不需要为 Optimism Sepolia 执行此操作,它已经在 viem 中了 (opens in a new tab),但通过这种方式,你可以学习如何添加不在 viem 中的区块链。

    C. 将新链添加到 createConfig 调用中。

  2. 编辑 src/App.tsx 以注释掉自动切换到 Sepolia 的代码。在生产系统上,你可能会显示带有指向你支持的每个区块链的链接的按钮。

  3. 编辑 src/Greeter.tsx 以确保应用知道你的合约在新网络上的地址。

    const contractAddrs: AddressPerBlockchainType = {
      // Optimism Sepolia
      11155420: "0x4dd85791923E9294E934271522f63875EAe5806f",
    
      // Sepolia
      11155111: "0x7143d5c190F048C8d19fe325b748b081903E3BF0",
    }
    
  4. 在你的浏览器中。

    A. 浏览到 ChainList (opens in a new tab) 并点击表格右侧的按钮之一,将该链添加到你的钱包中。

    B. 在应用中,断开连接 (Disconnect),然后重新连接以更改区块链。有更好的方法来处理这个问题,但它们需要更改应用。

结论

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

  1. 指定创建一个 Wagmi 应用。

    npm create wagmi
    
  2. 输入 y 继续。

  3. 命名该应用。

  4. 选择 React 框架。

  5. 选择 Vite 变体。

现在去让你的合约在广阔的世界中可用吧。

在这里查看我的更多作品 (opens in a new tab)

页面最后更新: 2026年3月3日