Xây dựng giao diện người dùng cho hợp đồng của bạn
Bạn đã tìm thấy một tính năng mà chúng tôi cần trong hệ sinh thái Ethereum. Bạn đã viết các hợp đồng thông minh để triển khai nó và thậm chí có thể một số mã liên quan chạy ngoài chuỗi. Điều này thật tuyệt! Thật không may, nếu không có giao diện người dùng, bạn sẽ không có bất kỳ người dùng nào, và lần cuối cùng bạn viết một trang web là khi mọi người dùng modem quay số và JavaScript vẫn còn mới mẻ.
Bài viết này là dành cho bạn. Tôi cho rằng bạn biết lập trình, và có thể một chút về JavaScript và HTML, nhưng kỹ năng về giao diện người dùng của bạn đã lỗi thời. Chúng ta sẽ cùng nhau xem qua một ứng dụng hiện đại đơn giản để bạn có thể thấy cách thực hiện trong thời đại ngày nay.
Tại sao điều này lại quan trọng
Về lý thuyết, bạn có thể chỉ cần để mọi người sử dụng Etherscan (opens in a new tab) hoặc Blockscout (opens in a new tab) để tương tác với các hợp đồng của bạn. Điều đó sẽ rất tuyệt vời đối với những người dùng Ethereum có kinh nghiệm. Nhưng chúng tôi đang cố gắng phục vụ thêm một tỷ người nữa (opens in a new tab). Điều này sẽ không xảy ra nếu không có trải nghiệm người dùng tuyệt vời, và giao diện người dùng thân thiện là một phần quan trọng trong đó.
Ứng dụng Greeter
Có rất nhiều lý thuyết đằng sau cách hoạt động của một giao diện người dùng hiện đại và rất nhiều trang web hay (opens in a new tab) giải thích về nó (opens in a new tab). Thay vì lặp lại những công việc tốt đẹp đã được thực hiện bởi các trang web đó, tôi sẽ cho rằng bạn thích học bằng cách thực hành và bắt đầu với một ứng dụng mà bạn có thể mày mò. Bạn vẫn cần lý thuyết để hoàn thành công việc, và chúng ta sẽ tìm hiểu về nó - chúng ta sẽ chỉ xem qua từng tệp nguồn một và thảo luận về mọi thứ khi chúng ta tiếp cận chúng.
Cài đặt
-
Nếu cần, hãy thêm chuỗi khối Holesky (opens in a new tab) vào ví của bạn và nhận ETH thử nghiệm (opens in a new tab).
-
Sao chép kho lưu trữ github.
1git clone https://github.com/qbzzt/20230801-modern-ui.git -
Cài đặt các gói cần thiết.
1cd 20230801-modern-ui2pnpm install -
Khởi động ứng dụng.
1pnpm dev -
Duyệt đến URL được hiển thị bởi ứng dụng. Trong hầu hết các trường hợp, đó là http://localhost:5173/ (opens in a new tab).
-
Bạn có thể xem mã nguồn của hợp đồng, một phiên bản Greeter của Hardhat đã được sửa đổi một chút, trên một trình khám phá chuỗi khối (opens in a new tab).
Xem qua tệp
index.html
Tệp này là bản mẫu HTML tiêu chuẩn ngoại trừ dòng này, dòng này nhập tệp script.
1<script type="module" src="/src/main.tsx"></script>src/main.tsx
Phần mở rộng tệp cho chúng ta biết rằng tệp này là một thành phần React (opens in a new tab) được viết bằng TypeScript (opens in a new tab), một phần mở rộng của JavaScript hỗ trợ kiểm tra loại (opens in a new tab). TypeScript được biên dịch thành JavaScript, vì vậy chúng ta có thể sử dụng nó để thực thi phía máy khách.
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'Nhập mã thư viện chúng ta cần.
1import { App } from './App'Nhập thành phần React triển khai ứng dụng (xem bên dưới).
1ReactDOM.createRoot(document.getElementById('root')!).render(Tạo thành phần React gốc. Tham số cho render là JSX (opens in a new tab), một ngôn ngữ mở rộng sử dụng cả HTML và JavaScript/TypeScript. Dấu chấm than ở đây báo cho thành phần TypeScript biết: "bạn không biết rằng document.getElementById('root') sẽ là một tham số hợp lệ cho ReactDOM.createRoot, nhưng đừng lo - tôi là nhà phát triển và tôi nói với bạn rằng nó sẽ có".
1 <React.StrictMode>Ứng dụng sẽ nằm bên trong một thành phần React.StrictMode (opens in a new tab). Thành phần này yêu cầu thư viện React chèn thêm các kiểm tra gỡ lỗi, điều này hữu ích trong quá trình phát triển.
1 <WagmiConfig config={config}>Ứng dụng cũng nằm bên trong một thành phần WagmiConfig (opens in a new tab). Thư viện wagmi (we are going to make it) (opens in a new tab) kết nối các định nghĩa giao diện người dùng React với thư viện viem (opens in a new tab) để viết một ứng dụng phi tập trung Ethereum.
1 <RainbowKitProvider chains={chains}>Và cuối cùng là một thành phần RainbowKitProvider (opens in a new tab). Thành phần này xử lý việc đăng nhập và giao tiếp giữa ví và ứng dụng.
1 <App />Bây giờ chúng ta có thể có thành phần cho ứng dụng, thành phần này thực sự triển khai giao diện người dùng. Dấu /> ở cuối thành phần báo cho React biết rằng thành phần này không có bất kỳ định nghĩa nào bên trong nó, theo tiêu chuẩn XML.
1 </RainbowKitProvider>2 </WagmiConfig>3 </React.StrictMode>,4)Tất nhiên, chúng ta phải đóng các thành phần khác.
src/App.tsx
1import { ConnectButton } from '@rainbow-me/rainbowkit'2import { useAccount } from 'wagmi'3import { Greeter } from './components/Greeter'45export function App() {Đây là cách tiêu chuẩn để tạo một thành phần React - xác định một hàm được gọi mỗi khi nó cần được hiển thị. Hàm này thường có một số mã TypeScript hoặc JavaScript ở trên cùng, theo sau là một câu lệnh return trả về mã JSX.
1 const { isConnected } = useAccount()Ở đây, chúng ta sử dụng useAccount (opens in a new tab) để kiểm tra xem chúng ta có được kết nối với chuỗi khối thông qua ví hay không.
Theo quy ước, trong React, các hàm được gọi là use... là các hook (opens in a new tab) trả về một số loại dữ liệu. Khi bạn sử dụng các hook như vậy, thành phần của bạn không chỉ nhận được dữ liệu mà khi dữ liệu đó thay đổi, thành phần sẽ được hiển thị lại với thông tin được cập nhật.
1 return (2 <>JSX của một thành phần React phải trả về một thành phần. Khi chúng ta có nhiều thành phần và không có gì bao bọc "một cách tự nhiên", chúng ta sử dụng một thành phần trống (<> ... </>) để biến chúng thành một thành phần duy nhất.
1 <h1>Greeter</h1>2 <ConnectButton />Chúng ta nhận được thành phần ConnectButton (opens in a new tab) từ RainbowKit. Khi chúng ta không kết nối, nó cung cấp cho chúng ta một nút Kết nối Ví mở ra một cửa sổ giải thích về các ví và cho phép bạn chọn ví nào bạn sử dụng. Khi chúng ta được kết nối, nó sẽ hiển thị chuỗi khối chúng ta sử dụng, địa chỉ tài khoản và số dư ETH của chúng ta. Chúng ta có thể sử dụng các màn hình này để chuyển đổi mạng hoặc ngắt kết nối.
1 {isConnected && (Khi chúng ta cần chèn JavaScript thực tế (hoặc TypeScript sẽ được biên dịch thành JavaScript) vào JSX, chúng ta sử dụng dấu ngoặc ({}).
Cú pháp a && b là viết tắt của a ? b : a (opens in a new tab). Tức là, nếu a là true, nó sẽ đánh giá thành b, ngược lại nó sẽ đánh giá thành a (có thể là false, 0, v.v.). Đây là một cách dễ dàng để báo cho React biết rằng một thành phần chỉ nên được hiển thị nếu một điều kiện nhất định được đáp ứng.
Trong trường hợp này, chúng tôi chỉ muốn hiển thị Greeter cho người dùng nếu người dùng được kết nối với một chuỗi khối.
1 <Greeter />2 )}3 </>4 )5}src/components/Greeter.tsx
Tệp này chứa hầu hết các chức năng của giao diện người dùng. Nó bao gồm các định nghĩa thường sẽ nằm trong nhiều tệp, nhưng vì đây là một hướng dẫn nên chương trình được tối ưu hóa để dễ hiểu trong lần đầu tiên, thay vì hiệu suất hoặc dễ bảo trì.
1import { useState, ChangeEventHandler } from 'react'2import { useNetwork,3 useReadContract,4 usePrepareContractWrite,5 useContractWrite,6 useContractEvent7 } from 'wagmi'Chúng tôi sử dụng các hàm thư viện này. Một lần nữa, chúng được giải thích bên dưới nơi chúng được sử dụng.
1import { AddressType } from 'abitype'Thư viện abitype (opens in a new tab) cung cấp cho chúng ta các định nghĩa TypeScript cho các loại dữ liệu Ethereum khác nhau, chẳng hạn như AddressType (opens in a new tab).
1let greeterABI = [2 .3 .4 .5] as const // greeterABIGiao diện nhị phân ứng dụng cho hợp đồng Greeter.
Nếu bạn đang phát triển các hợp đồng và giao diện người dùng cùng một lúc, bạn thường sẽ đặt chúng trong cùng một kho lưu trữ và sử dụng giao diện nhị phân ứng dụng được tạo bởi trình biên dịch Solidity làm tệp trong ứng dụng của bạn. Tuy nhiên, điều này không cần thiết ở đây vì hợp đồng đã được phát triển và sẽ không thay đổi.
1type AddressPerBlockchainType = {2 [key: number]: AddressType3}TypeScript được định kiểu mạnh. Chúng tôi sử dụng định nghĩa này để chỉ định địa chỉ mà hợp đồng Greeter được triển khai trên các chuỗi khác nhau. Khóa là một số (chainId), và giá trị là một AddressType (một địa chỉ).
1const contractAddrs: AddressPerBlockchainType = {2 // Holesky3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',45 // Sepolia6 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'7}Địa chỉ của hợp đồng trên hai mạng được hỗ trợ: Holesky (opens in a new tab) và Sepolia (opens in a new tab).
Lưu ý: Thực tế có một định nghĩa thứ ba, cho Redstone Holesky, nó sẽ được giải thích bên dưới.
1type ShowObjectAttrsType = {2 name: string,3 object: any4}Loại này được sử dụng làm tham số cho thành phần ShowObject (sẽ được giải thích sau). Nó bao gồm tên của đối tượng và giá trị của nó, được hiển thị cho mục đích gỡ lỗi.
1type ShowGreetingAttrsType = {2 greeting: string | undefined3}Tại bất kỳ thời điểm nào, chúng ta có thể biết lời chào là gì (vì chúng ta đọc nó từ chuỗi khối) hoặc không biết (vì chúng ta chưa nhận được). Vì vậy, rất hữu ích khi có một loại có thể là một chuỗi hoặc không có gì.
Thành phần Greeter
1const Greeter = () => {Cuối cùng, chúng ta đi đến định nghĩa thành phần.
1 const { chain } = useNetwork()Thông tin về chuỗi chúng ta đang sử dụng, được cung cấp bởi wagmi (opens in a new tab).
Bởi vì đây là một hook (use...), mỗi khi thông tin này thay đổi, thành phần sẽ được vẽ lại.
1 const greeterAddr = chain && contractAddrs[chain.id]Địa chỉ của hợp đồng Greeter, thay đổi theo chuỗi (và là undefined nếu chúng ta không có thông tin chuỗi hoặc chúng ta đang ở trên một chuỗi không có hợp đồng đó).
1 const readResults = useReadContract({2 address: greeterAddr,3 abi: greeterABI,4 functionName: "greet" , // Không có đối số5 watch: true6 })Hook useReadContract (opens in a new tab) đọc thông tin từ một hợp đồng. Bạn có thể xem chính xác thông tin mà nó trả về bằng cách mở rộng readResults trong giao diện người dùng. Trong trường hợp này, chúng tôi muốn nó tiếp tục tìm kiếm để chúng tôi sẽ được thông báo khi lời chào thay đổi.
Lưu ý: Chúng ta có thể lắng nghe các sự kiện setGreeting (opens in a new tab) để biết khi nào lời chào thay đổi và cập nhật theo cách đó. Tuy nhiên, mặc dù nó có thể hiệu quả hơn, nó sẽ không áp dụng trong mọi trường hợp. Khi người dùng chuyển sang một chuỗi khác, lời chào cũng thay đổi, nhưng sự thay đổi đó không đi kèm với một sự kiện. Chúng ta có thể có một phần mã lắng nghe các sự kiện và một phần khác để xác định các thay đổi chuỗi, nhưng điều đó sẽ phức tạp hơn là chỉ cần đặt tham số watch (opens in a new tab).
1 const [ newGreeting, setNewGreeting ] = useState("")Hook useState (opens in a new tab) của React cho phép chúng ta chỉ định một biến trạng thái, giá trị của biến này tồn tại từ lần hiển thị này sang lần hiển thị khác của thành phần. Giá trị ban đầu là tham số, trong trường hợp này là chuỗi rỗng.
Hook useState trả về một danh sách với hai giá trị:
- Giá trị hiện tại của biến trạng thái.
- Một hàm để sửa đổi biến trạng thái khi cần thiết. Vì đây là một hook, mỗi lần nó được gọi, thành phần sẽ được hiển thị lại.
Trong trường hợp này, chúng ta đang sử dụng một biến trạng thái cho lời chào mới mà người dùng muốn đặt.
1 const greetingChange : ChangeEventHandler<HTMLInputElement> = (evt) =>2 setNewGreeting(evt.target.value)Đây là trình xử lý sự kiện khi trường nhập lời chào mới thay đổi. Loại ChangeEventHandler<HTMLInputElement> (opens in a new tab), chỉ định rằng đây là trình xử lý cho một sự thay đổi giá trị của một phần tử đầu vào HTML. Phần <HTMLInputElement> được sử dụng vì đây là một loại chung (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)Đây là quá trình gửi một giao dịch chuỗi khối từ góc độ máy khách:
- Gửi giao dịch đến một nút trong chuỗi khối bằng cách sử dụng
eth_estimateGas(opens in a new tab). - Chờ phản hồi từ nút.
- Khi nhận được phản hồi, yêu cầu người dùng ký giao dịch thông qua ví. Bước này phải xảy ra sau khi nhận được phản hồi của nút vì người dùng được hiển thị chi phí gas của giao dịch trước khi ký.
- Chờ người dùng phê duyệt.
- Gửi lại giao dịch, lần này sử dụng
eth_sendRawTransaction(opens in a new tab).
Bước 2 có thể sẽ mất một khoảng thời gian đáng kể, trong đó người dùng sẽ tự hỏi liệu lệnh của họ có thực sự được giao diện người dùng nhận được không và tại sao họ chưa được yêu cầu ký giao dịch. Điều đó tạo ra trải nghiệm người dùng (UX) tồi tệ.
Giải pháp là sử dụng prepare hooks (opens in a new tab). Mỗi khi một tham số thay đổi, ngay lập tức gửi cho nút yêu cầu eth_estimateGas. Sau đó, khi người dùng thực sự muốn gửi giao dịch (trong trường hợp này bằng cách nhấn Cập nhật lời chào), chi phí gas đã được biết và người dùng có thể thấy trang ví ngay lập tức.
1 return (Bây giờ chúng ta cuối cùng có thể tạo HTML thực tế để trả về.
1 <>2 <h2>Greeter</h2>3 {4 !readResults.isError && !readResults.isLoading &&5 <ShowGreeting greeting={readResults.data} />6 }7 <hr />Tạo một thành phần ShowGreeting (được giải thích bên dưới), nhưng chỉ khi lời chào được đọc thành công từ chuỗi khối.
1 <input type="text"2 value={newGreeting}3 onChange={greetingChange}4 />Đây là trường nhập văn bản nơi người dùng có thể đặt lời chào mới. Mỗi khi người dùng nhấn một phím, chúng ta gọi greetingChange, hàm này gọi setNewGreeting. Vì setNewGreeting đến từ hook useState, nó khiến thành phần Greeter được hiển thị lại. Điều này có nghĩa là:
- Chúng ta cần chỉ định
valueđể giữ giá trị của lời chào mới, bởi vì nếu không nó sẽ trở về mặc định, chuỗi rỗng. usePrepareContractWriteđược gọi mỗi khinewGreetingthay đổi, có nghĩa là nó sẽ luôn cónewGreetingmới nhất trong giao dịch đã chuẩn bị.
1 <button disabled={!workingTx.write}2 onClick={workingTx.write}3 >4 Cập nhật lời chào5 </button>Nếu không có workingTx.write, chúng ta vẫn đang chờ thông tin cần thiết để gửi cập nhật lời chào, vì vậy nút bị vô hiệu hóa. Nếu có giá trị workingTx.write, đó là hàm cần gọi để gửi giao dịch.
1 <hr />2 <ShowObject name="readResults" object={readResults} />3 <ShowObject name="preparedTx" object={preparedTx} />4 <ShowObject name="workingTx" object={workingTx} />5 </>6 )7}Cuối cùng, để giúp bạn thấy những gì chúng ta đang làm, hãy hiển thị ba đối tượng mà chúng ta sử dụng:
readResultspreparedTxworkingTx
Thành phần ShowGreeting
Thành phần này hiển thị
1const ShowGreeting = (attrs : ShowGreetingAttrsType) => {Một hàm thành phần nhận một tham số với tất cả các thuộc tính của thành phần.
1 return <b>{attrs.greeting}</b>2}Thành phần ShowObject
Vì mục đích thông tin, chúng ta sử dụng thành phần ShowObject để hiển thị các đối tượng quan trọng (readResults để đọc lời chào và preparedTx và workingTx cho các giao dịch chúng ta tạo).
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>Chúng tôi không muốn làm lộn xộn giao diện người dùng với tất cả thông tin, vì vậy để có thể xem hoặc đóng chúng, chúng tôi sử dụng thẻ details (opens in a new tab).
1 <summary>{attrs.name}</summary>2 <pre>3 {JSON.stringify(attrs.object, null, 2)}Hầu hết các trường được hiển thị bằng JSON.stringify (opens in a new tab).
1 </pre>2 { funs.length > 0 &&3 <>4 Hàm:5 <ul>Ngoại lệ là các hàm, không phải là một phần của tiêu chuẩn JSON (opens in a new tab), vì vậy chúng phải được hiển thị riêng biệt.
1 {funs.map((f, i) =>Trong JSX, mã bên trong dấu ngoặc nhọn { } được hiểu là JavaScript. Sau đó, mã bên trong dấu ngoặc đơn ( ) lại được hiểu là JSX.
1 (<li key={i}>{f}</li>)2 )}React yêu cầu các thẻ trong Cây DOM (opens in a new tab) phải có các mã định danh riêng biệt. Điều này có nghĩa là các thẻ con của cùng một thẻ (trong trường hợp này là danh sách không có thứ tự (opens in a new tab)), cần các thuộc tính key khác nhau.
1 </ul>2 </>3 }4 </details>5 </>6}Kết thúc các thẻ HTML khác nhau.
export cuối cùng
1export { Greeter }Thành phần Greeter là thành phần chúng ta cần xuất cho ứng dụng.
src/wagmi.ts
Cuối cùng, các định nghĩa khác nhau liên quan đến WAGMI nằm trong src/wagmi.ts. Tôi sẽ không giải thích mọi thứ ở đây, vì hầu hết chúng là bản mẫu mà bạn không có khả năng cần phải thay đổi.
Mã ở đây không hoàn toàn giống như trên github (opens in a new tab) vì sau này trong bài viết, chúng ta sẽ thêm một chuỗi khác (Redstone Holesky (opens in a new tab)).
1import { getDefaultWallets } from '@rainbow-me/rainbowkit'2import { configureChains, createConfig } from 'wagmi'3import { holesky, sepolia } from 'wagmi/chains'Nhập các chuỗi khối mà ứng dụng hỗ trợ. Bạn có thể xem danh sách các chuỗi được hỗ trợ trong viem github (opens in a new tab).
1import { publicProvider } from 'wagmi/providers/public'23const walletConnectProjectId = 'c96e690bb92b6311e8e9b2a6a22df575'Để có thể sử dụng WalletConnect (opens in a new tab), bạn cần một ID dự án cho ứng dụng của mình. Bạn có thể lấy nó trên 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 }Hiện tất cảThêm chuỗi khối khác
Ngày nay có rất nhiều giải pháp mở rộng L2, và bạn có thể muốn hỗ trợ một số giải pháp mà viem chưa hỗ trợ. Để làm điều đó, bạn sửa đổi src/wagmi.ts. Những hướng dẫn này giải thích cách thêm Redstone Holesky (opens in a new tab).
-
Nhập loại
defineChaintừ viem.1import { defineChain } from 'viem' -
Thêm định nghĩa mạng.
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})Hiện tất cả -
Thêm chuỗi mới vào lệnh gọi
configureChains.1 const { chains, publicClient, webSocketPublicClient } = configureChains(2 [ holesky, sepolia, redstoneHolesky ],3 [ publicProvider(), ],4 ) -
Đảm bảo rằng ứng dụng biết địa chỉ cho các hợp đồng của bạn trên mạng mới. Trong trường hợp này, chúng tôi sửa đổi
src/components/Greeter.tsx:1const contractAddrs : AddressPerBlockchainType = {2 // Holesky3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',45 // Redstone Holesky6 17001: '0x4919517f82a1B89a32392E1BF72ec827ba9986D3',78 // Sepolia9 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'10}Hiện tất cả
Kết luận
Tất nhiên, bạn không thực sự quan tâm đến việc cung cấp giao diện người dùng cho Greeter. Bạn muốn tạo giao diện người dùng cho các hợp đồng của riêng mình. Để tạo ứng dụng của riêng bạn, hãy chạy các bước sau:
-
Chỉ định tạo một ứng dụng wagmi.
1pnpm create wagmi -
Đặt tên cho ứng dụng.
-
Chọn framework React.
-
Chọn biến thể Vite.
-
Bạn có thể thêm bộ Rainbow (opens in a new tab).
Bây giờ hãy đi và làm cho các hợp đồng của bạn có thể sử dụng được cho cả thế giới.
Xem thêm công việc của tôi tại đây (opens in a new tab).
Lần cập nhật trang lần cuối: 3 tháng 3, 2026