為你的合約建立一個使用者介面
你找到了一個我們在以太坊生態系統中需要的功能。 你編寫了智能合約來實作它,甚至可能編寫了一些在鏈外執行的相關程式碼。 這太棒了! 不幸的是,如果沒有使用者介面,你就不會有任何使用者。而且在你上一次寫網站的時候,人們還在使用撥接數據機,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)。 與其重複那些網站已經完成的出色工作,我假設你更喜歡從做中學,從一個你可以實際操作的應用程式開始。 你仍然需要理論來完成工作,我們也會談到它——我們將逐一檢視原始檔,並在遇到問題時進行討論。
安裝
-
如有需要,請將 Holesky 區塊鏈 (opens in a new tab) 新增到你的錢包,並 取得測試 ETH (opens in a new tab)。
-
複製 GitHub 儲存庫。
1git clone https://github.com/qbzzt/20230801-modern-ui.git -
安裝必要的套件。
1cd 20230801-modern-ui2pnpm install -
啟動應用程式。
1pnpm dev -
瀏覽應用程式顯示的 URL。 在大多數情況下,它是 http://localhost:5173/ (opens in a new tab)。
-
你可以在 區塊鏈瀏覽器 (opens in a new tab) 上看到合約的原始碼,它是 Hardhat 的 Greeter 的一個稍作修改的版本。
檔案走查
index.html
這個檔案是標準的 HTML 樣板,除了這一行,它匯入了腳本檔案。
1<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,所以我們可以用它來進行用戶端執行。
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 的參數是 JSX (opens 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'45export function App() {這是建立 React 元件的標準方法——定義一個函式,每次需要渲染時都會呼叫它。 這個函式通常在頂部有一些 TypeScript 或 JavaScript 程式碼,後面跟著一個回傳 JSX 程式碼的 return 陳述式。
1 const { isConnected } = useAccount()這裡我們使用 useAccount (opens in a new tab) 來檢查我們是否透過錢包連接到區塊鏈。
按照慣例,在 React 中,名為 use... 的函式是回傳某種資料的 hook (opens in a new tab)。 當你使用這樣的 hook 時,你的元件不僅會取得資料,而且當該資料變更時,元件會用更新後的資訊重新渲染。
1 return (2 <>React 元件的 JSX _必須_回傳一個元件。 當我們有多個元件,並且沒有任何東西可以「自然地」包裝它們時,我們使用一個空元件(<> ... </>)來將它們變成單一元件。
1 <h1>Greeter</h1>2 <ConnectButton />我們從 RainbowKit 取得 ConnectButton 元件 (opens in a new tab)。 當我們未連接時,它會提供一個 Connect Wallet 按鈕,開啟一個說明錢包的強制回應視窗,讓你選擇使用哪一個錢包。 當我們連接時,它會顯示我們使用的區塊鏈、我們的帳戶地址和我們的 ETH 餘額。 我們可以使用這些顯示來切換網路或中斷連接。
1 {isConnected && (當我們需要將實際的 JavaScript(或將被編譯為 JavaScript 的 TypeScript)插入 JSX 時,我們使用大括號({})。
a && b 語法是 a ? b : a 的簡寫 (opens in a new tab)。 也就是說,如果 a為 true,它的評估結果為b,否則它的評估結果為 a(可以是 false、0 等)。 這是一種簡單的方法,可以告訴 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 useContractEvent7 } from 'wagmi'我們使用這些庫函式。 同樣,它們在下面使用到的地方會進行解釋。
1import { AddressType } from 'abitype'abitype 庫 (opens in a new tab) 為我們提供了各種以太坊資料型別的 TypeScript 定義,例如 AddressType (opens in a new tab)。
1let greeterABI = [2 .3 .4 .5] as const // greeterABIGreeter 合約的 ABI。
如果你同時開發合約和 UI,通常會將它們放在同一個儲存庫中,並將 Solidity 編譯器產生的 ABI 作為一個檔案用在你的應用程式中。 然而,在這裡這不是必要的,因為合約已經開發完成,不會再變更。
1type AddressPerBlockchainType = {2 [key: number]: AddressType3}TypeScript 是強型別的。 我們使用這個定義來指定 Greeter 合約在不同鏈上部署的地址。 鍵是一個數字(chainId),值是一個 AddressType(一個地址)。
1const contractAddrs: AddressPerBlockchainType = {2 // Holesky3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',45 // Sepolia6 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'7}合約在兩個支援的網路上的地址:Holesky (opens in a new tab) 和 Sepolia (opens in a new tab)。
注意:實際上還有第三個定義,針對 Redstone Holesky,下面將會解釋。
1type ShowObjectAttrsType = {2 name: string,3 object: any4}這個型別被用作 ShowObject 元件(稍後解釋)的參數。 它包含物件的名稱和其值,這些是用於偵錯目的而顯示的。
1type ShowGreetingAttrsType = {2 greeting: string | undefined3}在任何時候,我們可能知道問候語是什麼(因為我們從區塊鏈讀取了它),也可能不知道(因為我們還沒有收到它)。 所以有一個可以是字串或什麼都沒有的型別是很有用的。
Greeter 元件
1const Greeter = () => {最後,我們來定義元件。
1 const { chain } = useNetwork()關於我們正在使用的鏈的資訊,由 wagmi (opens in a new tab) 提供。
因為這是一個 hook (use...),所以每次這個資訊變更時,元件都會被重新繪製。
1 const greeterAddr = chain && contractAddrs[chain.id]Greeter 合約的地址,它會因鏈而異(如果我們沒有鏈的資訊,或者我們在沒有該合約的鏈上,則為 undefined)。
1 const readResults = useReadContract({2 address: greeterAddr,3 abi: greeterABI,4 functionName: "greet" , // 無引數5 watch: true6 })useReadContract hook (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 hook (opens in a new tab) 讓我們可以指定一個狀態變數,其值在元件的多次渲染之間保持不變。 初始值是參數,此處為空字串。
useState hook 回傳一個包含兩個值的清單:
- 狀態變數的目前值。
- 一個在需要時修改狀態變數的函式。 因為這是一個 hook,所以每次呼叫它時,元件都會重新渲染。
在這種情況下,我們使用一個狀態變數來儲存使用者想要設定的新問候語。
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)這是從用戶端角度提交區塊鏈交易的過程:
- 使用
eth_estimateGas(opens in a new tab) 將交易傳送到區塊鏈中的一個節點。 - 等待節點的回應。
- 收到回應後,要求使用者透過錢包簽署交易。 這一步驟_必須_在收到節點回應後進行,因為使用者在簽署交易前會看到交易的 gas 成本。
- 等待使用者核准。
- 再次傳送交易,這次使用
eth_sendRawTransaction(opens in a new tab)。
步驟 2 可能會花費一段可觀的時間,在此期間,使用者會想知道他們的指令是否真的被使用者介面接收到,以及為什麼還沒有被要求簽署交易。 這會造成不好的使用者體驗(UX)。
解決方案是使用 prepare hook (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 hook,它會導致 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}最後,為了幫助你了解我們在做什麼,顯示我們使用的三個物件:
readResultspreparedTxworkingTx
ShowGreeting 元件
此元件顯示
1const ShowGreeting = (attrs : ShowGreetingAttrsType) => {一個元件函式接收一個包含該元件所有屬性的參數。
1 return <b>{attrs.greeting}</b>2}ShowObject 元件
為了提供資訊,我們使用 ShowObject 元件來顯示重要的物件(readResults 用於讀取問候語,preparedTx 和 workingTx 用於我們建立的交易)。
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,所以為了可以檢視或關閉它們,我們使用了 details (opens in a new tab) 標籤。
1 <summary>{attrs.name}</summary>2 <pre>3 {JSON.stringify(attrs.object, null, 2)}大部分的欄位都是使用 JSON.stringify (opens in a new tab) 來顯示的。
1 </pre>2 { funs.length > 0 &&3 <>4 函式: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 Holesky (opens in a new tab))。
1import { getDefaultWallets } from '@rainbow-me/rainbowkit'2import { configureChains, createConfig } from 'wagmi'3import { holesky, sepolia } from 'wagmi/chains'匯入應用程式支援的區塊鏈。 你可以在 viem 的 github (opens in a new tab) 中看到支援的鏈的清單。
1import { publicProvider } from 'wagmi/providers/public'23const walletConnectProjectId = 'c96e690bb92b6311e8e9b2a6a22df575'要能使用 WalletConnect (opens in a new tab),你的應用程式需要一個專案 ID。 你可以在 cloud.walletconnect.com (opens in a new tab) 上取得它。
1const { chains, publicClient, webSocketPublicClient } = configureChains(2 [ holesky, sepolia ],3 [4 publicProvider(),5 ],6)78const { connectors } = getDefaultWallets({9 appName: 'My wagmi + RainbowKit App',10 chains,11 projectId: walletConnectProjectId,12})1314export const config = createConfig({15 autoConnect: true,16 connectors,17 publicClient,18 webSocketPublicClient,19})2021export { chains }顯示全部新增另一條區塊鏈
現今有很多 L2 擴展解決方案,你可能想要支援一些 viem 尚未支援的方案。 要做到這點,你需要修改 src/wagmi.ts。 這些說明解釋了如何新增 Redstone Holesky (opens in a new tab)。
-
從 viem 匯入
defineChain型別。1import { defineChain } from 'viem' -
新增網路定義。
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})顯示全部 -
將新鏈新增到
configureChains呼叫中。1 const { chains, publicClient, webSocketPublicClient } = configureChains(2 [ holesky, sepolia, redstoneHolesky ],3 [ publicProvider(), ],4 ) -
確保應用程式知道你的合約在新網路上的地址。 在這種情況下,我們修改
src/components/Greeter.tsx:1const contractAddrs : AddressPerBlockchainType = {2 // Holesky3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',45 // Redstone Holesky6 17001: '0x4919517f82a1B89a32392E1BF72ec827ba9986D3',78 // Sepolia9 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'10}顯示全部
結論
當然,你並不是真的在乎為 Greeter 提供使用者介面。 你想要為你自己的合約建立一個使用者介面。 要建立你自己的應用程式,請執行以下步驟:
-
指定建立一個 wagmi 應用程式。
1pnpm create wagmi -
為應用程式命名。
-
選擇 React 框架。
-
選擇 Vite 變體。
現在去讓你的合約為廣大世界所用吧。
在此查看我的更多作品 (opens in a new tab)。
頁面最後更新時間: 2026年3月3日