为智能合约构建用户界面

Ethereum.org 发布于 2023-11-01 阅读 123

本文是一篇面向开发者的教程,介绍如何使用wagmi库为以太坊智能合约构建现代用户界面。通过一个Greeter合约的示例应用,作者一步步讲解了从项目安装、文件结构到核心代码实现的全过程,涵盖React、TypeScript、wagmi、React Query等工具的使用,并展示了如何链上读取、写入合约以及监听事件。文章还扩展了添加其他区块链(如Optimism Sepolia)的方法,强调良好的用户体验对Web3普及的重要性。适合已有编程基础、希望学习Web3前端开发的读者。

这篇文章是为你准备的。我假设你懂编程,可能还懂一点 JavaScript 和 HTML,不过你的用户界面技能已经生疏、过时了。我们将一起学习一个简单的现代应用程序,让你了解现在的做法。

为什么这很重要

理论上,你可以让人们直接使用 EtherscanBlockscout 与你的合约进行交互。这对经验丰富的以太坊用户来说很不错。但我们正致力于服务 下一个十亿用户。没有出色的用户体验,这就不可能实现,而友好的用户界面是其中的重要组成部分。

Greeter 应用程序

现代 UI 的工作原理背后有很多理论,也有很多优秀的网站 对此进行了解释。我不会重复这些网站所做的出色工作,而是假设你更喜欢边做边学,从一个你可以动手操作的应用程序开始。你仍然需要理论来完成任务,我们会逐步讲解——我们将逐个查看源文件,并在遇到时进行讨论。

安装

  1. 该应用程序使用 Sepolia 测试网络。如有必要,获取 Sepolia 测试 ETH将 Sepolia 添加到你的钱包中

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

    git clone https://github.com/qbzzt/260301-modern-ui-web3.git
    cd 260301-modern-ui-web3
    npm install
    
  3. 应用程序使用免费的访问点,这些访问点有性能限制。如果你想使用 节点即服务 提供商,请替换 src/wagmi.ts 中的 URL。

  4. 启动应用程序。

    npm run dev
    
  5. 浏览到应用程序显示的 URL。大多数情况下是 http://localhost:5173/

  6. 你可以在 区块链浏览器 上查看合约源代码,它是 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 返回一个包含两个值的列表:

  1. 状态变量的当前值。
  2. 一个函数,用于在需要时修改状态变量。因为这是一个 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,或者求值为假的值,例如 undefined0 或空字符串,则整个表达式为 false。对于任何其他值,则为 true。这是一种将值转换为布尔值的方法,因为如果没有 greeterAddr,我们就不想监听事件。

    onLogs: logs => {
      const greetingFromContract = logs[0].args.greeting
      setCurrentGreeting(greetingFromContract)
      setLastSetterAddress(logs[0].args.sender)
      updateStatus("问候语已通过事件更新")
    },
  })

当我们看到日志(即看到新事件时),这意味着问候语已被修改。在这种情况下,我们可以将 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,但支持更好的状态更新。

  const simulation = useSimulateContract({
    address: greeterAddr,
    abi: greeterABI,
    functionName: 'setGreeting',
    args: [newGreeting],
    account: account.address
  })

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

  1. 使用 eth_estimateGas 将交易发送到区块链上的一个节点。
  2. 等待节点的响应。
  3. 收到响应后,要求用户通过钱包签署交易。这一步 必须 在节点响应之后进行,因为用户会在签署前看到交易的 Gas 成本。
  4. 等待用户批准。
  5. 再次发送交易,这次使用 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

  1. 编辑 src/wagmi.ts

    A. 从 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,
    })
    
  2. 编辑 src/App.tsx 以注释掉自动切换到 Sepolia 的代码。在生产系统上,你可能会显示带有你支持的每个区块链链接的按钮。

    /*
    useEffect(() => {
         if (connection.status === 'connected' &&
             connection.chainId !== SEPOLIA_CHAIN_ID
         ) {
           switchChain({ chainId: SEPOLIA_CHAIN_ID })
         }
    }, [connection.status, connection.chainId])
    */
    
  3. 编辑 src/Greeter.tsx 以确保应用程序知道你的合约在新网络上的地址。

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

    A. 浏览到 ChainList 并点击表格右侧的按钮之一,将链添加到你的钱包。

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

结论

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

  1. 指定创建一个 wagmi 应用程序。

    npm create wagmi
    
  2. 输入 y 继续。

  3. 命名应用程序。

  4. 选择 React 框架。

  5. 选择 Vite 变体。

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

在此查看更多我的工作

+3

本教程对你有帮助吗?

  • 原文链接: ethereum.org/developers/...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论