跳至主要內容

為你的合約建立一個使用者介面

TypeScript
React
vite
wagmi
前端
新手
Ori Pomerantz
2023年11月1日
21 分鐘閱讀

你找到了一個我們在以太坊生態系統中需要的功能。 你編寫了智能合約來實作它,甚至可能編寫了一些在鏈外執行的相關程式碼。 這太棒了! 不幸的是,如果沒有使用者介面,你就不會有任何使用者。而且在你上一次寫網站的時候,人們還在使用撥接數據機,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. 如有需要,請將 Holesky 區塊鏈 (opens in a new tab) 新增到你的錢包,並 取得測試 ETH (opens in a new tab)

  2. 複製 GitHub 儲存庫。

    git clone https://github.com/qbzzt/20230801-modern-ui.git
    
  3. 安裝必要的套件。

    cd 20230801-modern-ui
    pnpm install
    
  4. 啟動應用程式。

    pnpm 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 是 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... 的函式是回傳某種資料的 hook (opens in a new tab)。 當你使用這樣的 hook 時,你的元件不僅會取得資料,而且當該資料變更時,元件會用更新後的資訊重新渲染。

  return (
    <>

React 元件的 JSX _必須_回傳一個元件。 當我們有多個元件,並且沒有任何東西可以「自然地」包裝它們時,我們使用一個空元件(<> ... </>)來將它們變成單一元件。

      <h1>Greeter</h1>
      <ConnectButton />

我們從 RainbowKit 取得 ConnectButton 元件 (opens in a new tab)。 當我們未連接時,它會提供一個 Connect Wallet 按鈕,開啟一個說明錢包的強制回應視窗,讓你選擇使用哪一個錢包。 當我們連接時,它會顯示我們使用的區塊鏈、我們的帳戶地址和我們的 ETH 餘額。 我們可以使用這些顯示來切換網路或中斷連接。

      {isConnected && (

當我們需要將實際的 JavaScript(或將被編譯為 JavaScript 的 TypeScript)插入 JSX 時,我們使用大括號({})。

a && b 語法是 a ? b : a 的簡寫 (opens in a new tab)。 也就是說,如果 a為 true,它的評估結果為b,否則它的評估結果為 a(可以是 false0 等)。 這是一種簡單的方法,可以告訴 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) 提供。 因為這是一個 hook (use...),所以每次這個資訊變更時,元件都會被重新繪製。

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

Greeter 合約的地址,它會因鏈而異(如果我們沒有鏈的資訊,或者我們在沒有該合約的鏈上,則為 undefined)。

  const readResults = useReadContract({
    address: greeterAddr,
    abi: greeterABI,
    functionName: "greet" , // 無引數
    watch: true
  })

useReadContract hook (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 hook (opens in a new tab) 讓我們可以指定一個狀態變數,其值在元件的多次渲染之間保持不變。 初始值是參數,此處為空字串。

useState hook 回傳一個包含兩個值的清單:

  1. 狀態變數的目前值。
  2. 一個在需要時修改狀態變數的函式。 因為這是一個 hook,所以每次呼叫它時,元件都會重新渲染。

在這種情況下,我們使用一個狀態變數來儲存使用者想要設定的新問候語。

  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)

這是從用戶端角度提交區塊鏈交易的過程:

  1. 使用 eth_estimateGas (opens in a new tab) 將交易傳送到區塊鏈中的一個節點。
  2. 等待節點的回應。
  3. 收到回應後,要求使用者透過錢包簽署交易。 這一步驟_必須_在收到節點回應後進行,因為使用者在簽署交易前會看到交易的 gas 成本。
  4. 等待使用者核准。
  5. 再次傳送交易,這次使用 eth_sendRawTransaction (opens in a new tab)

步驟 2 可能會花費一段可觀的時間,在此期間,使用者會想知道他們的指令是否真的被使用者介面接收到,以及為什麼還沒有被要求簽署交易。 這會造成不好的使用者體驗(UX)。

解決方案是使用 prepare hook (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 hook,它會導致 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} />
    </>
  )
}

最後,為了幫助你了解我們在做什麼,顯示我們使用的三個物件:

  • readResults
  • preparedTx
  • workingTx
ShowGreeting 元件

此元件顯示

const ShowGreeting = (attrs : ShowGreetingAttrsType) => {

一個元件函式接收一個包含該元件所有屬性的參數。

  return <b>{attrs.greeting}</b>
}
ShowObject 元件

為了提供資訊,我們使用 ShowObject 元件來顯示重要的物件(readResults 用於讀取問候語,preparedTxworkingTx 用於我們建立的交易)。

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 &&
        <>
          函式:
          <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) 上取得它。

新增另一條區塊鏈

現今有很多 L2 擴展解決方案,你可能想要支援一些 viem 尚未支援的方案。 要做到這點,你需要修改 src/wagmi.ts。 這些說明解釋了如何新增 Redstone Holesky (opens in a new tab)

  1. 從 viem 匯入 defineChain 型別。

    import { defineChain } from 'viem'
    
  2. 新增網路定義。

  3. 將新鏈新增到 configureChains 呼叫中。

     const { chains, publicClient, webSocketPublicClient } = configureChains(
       [ holesky, sepolia, redstoneHolesky ],
       [ publicProvider(), ],
     )
    
  4. 確保應用程式知道你的合約在新網路上的地址。 在這種情況下,我們修改 src/components/Greeter.tsx

結論

當然,你並不是真的在乎為 Greeter 提供使用者介面。 你想要為你自己的合約建立一個使用者介面。 要建立你自己的應用程式,請執行以下步驟:

  1. 指定建立一個 wagmi 應用程式。

    pnpm create wagmi
    
  2. 為應用程式命名。

  3. 選擇 React 框架。

  4. 選擇 Vite 變體。

  5. 你可以 新增 Rainbow kit (opens in a new tab)

現在去讓你的合約為廣大世界所用吧。

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

頁面最後更新: 2026年3月3日

這篇教學對您有幫助嗎?