跳至主要內容

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

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()

這個 hook (opens in a new tab) 提供了我們中斷錢包連線的函式。

  const { switchChain } = useSwitchChain()

這個 hook (opens in a new tab) 讓我們可以切換鏈。

  useEffect(() => {

React hook 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`」。

這是將 if 敘述放入 JSX 中的標準方法。

        <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) 提供。因為這是一個 hook (use...),所以每當此資訊變更時,元件都會重新渲染。

  const greeterAddr = chainId && contractAddrs[chainId] 

Greeter 合約的地址,如果我們沒有鏈資訊,或者我們所在的鏈沒有該合約,則為 undefined

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

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

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("Greeting fetched from blockchain")
    }
  }, [readResults.data])

如果上方的 readResults 變更了資料,且未設定為 false 值(例如 undefined),則將目前的問候語更新為從區塊鏈讀取的問候語。同時,更新狀態。

  useWatchContractEvent({
    address: greeterAddr,
    abi: greeterABI,
    eventName: 'SetGreeting',
    chainId,

監聽 SetGreeting 事件。

    enabled: !!greeterAddr,

!!<value> 表示如果值為 false,或求值為 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)