Przejdź do głównej zawartości

Tworzenie interfejsu użytkownika dla Twojego kontraktu

TypeScript
React
Vite
Wagmi
frontend
Początkujący
Ori Pomerantz
1 listopada 2023
13 minuta czytania

Znalazłeś funkcję, której potrzebujemy w ekosystemie Ethereum. Napisałeś inteligentne kontrakty, aby go zaimplementować, a może nawet powiązany kod, który działa offchain. To świetnie! Niestety, bez interfejsu użytkownika nie będziesz mieć żadnych użytkowników, a ostatnim razem, gdy pisałeś stronę internetową, ludzie używali modemów dial-up, a JavaScript był nowością.

Ten artykuł jest dla Ciebie. Zakładam, że znasz programowanie i może trochę JavaScript i HTML, ale Twoje umiejętności w zakresie interfejsu użytkownika są przestarzałe i nieaktualne. Razem przeanalizujemy prostą, nowoczesną aplikację, abyś zobaczył, jak się to robi w dzisiejszych czasach.

Dlaczego jest to ważne

Teoretycznie możesz po prostu kazać ludziom używać Etherscan (opens in a new tab) lub Blockscout (opens in a new tab), aby wchodzić w interakcję z Twoimi kontraktami. To będzie świetne dla doświadczonych Eterean. Ale my staramy się służyć kolejnemu miliardowi ludzi (opens in a new tab). Nie stanie się to bez wspaniałego doświadczenia użytkownika, a przyjazny interfejs użytkownika jest tego dużą częścią.

Aplikacja Greeter

Istnieje wiele teorii na temat działania nowoczesnego interfejsu użytkownika i wiele dobrych stron (opens in a new tab), które to wyjaśniają (opens in a new tab). Zamiast powtarzać świetną pracę wykonaną przez te strony, założę, że wolisz uczyć się przez działanie i zaczniesz od aplikacji, którą możesz się pobawić. Nadal potrzebujesz teorii, aby załatwić sprawy, i do tego dojdziemy - po prostu przejdziemy plik źródłowy po pliku źródłowym i omówimy sprawy, gdy do nich dojdziemy.

Instalacja

  1. W razie potrzeby dodaj blockchain Holesky (opens in a new tab) do swojego portfela i pobierz testowe ETH (opens in a new tab).

  2. Sklonuj repozytorium na GitHubie.

    1git clone https://github.com/qbzzt/20230801-modern-ui.git
  3. Zainstaluj niezbędne pakiety.

    1cd 20230801-modern-ui
    2pnpm install
  4. Uruchom aplikację.

    1pnpm dev
  5. Przejdź do adresu URL wyświetlanego przez aplikację. W większości przypadków jest to http://localhost:5173/ (opens in a new tab).

  6. Możesz zobaczyć kod źródłowy kontraktu, nieco zmodyfikowaną wersję Greeter od Hardhat, na eksploratorze blockchain (opens in a new tab).

Przegląd plików

index.html

Ten plik jest standardowym szablonem HTML, z wyjątkiem tej linii, która importuje plik skryptu.

1<script type="module" src="/src/main.tsx"></script>

src/main.tsx

Rozszerzenie pliku mówi nam, że ten plik jest komponentem React (opens in a new tab) napisanym w TypeScript (opens in a new tab), rozszerzeniem JavaScriptu, które obsługuje sprawdzanie typów (opens in a new tab). TypeScript jest kompilowany do JavaScriptu, więc możemy go używać do wykonywania po stronie klienta.

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'

Importuj kod biblioteki, którego potrzebujemy.

1import { App } from './App'

Importuj komponent React, który implementuje aplikację (patrz poniżej).

1ReactDOM.createRoot(document.getElementById('root')!).render(

Utwórz główny komponent React. Parametr render to JSX (opens in a new tab), język rozszerzeń, który używa zarówno HTML, jak i JavaScript/TypeScript. Wykrzyknik tutaj mówi komponentowi TypeScript: "nie wiesz, że document.getElementById('root') będzie prawidłowym parametrem dla ReactDOM.createRoot, ale nie martw się - jestem deweloperem i mówię ci, że będzie".

1 <React.StrictMode>

Aplikacja znajduje się wewnątrz komponentu React.StrictMode (opens in a new tab). Ten komponent informuje bibliotekę React o wstawieniu dodatkowych kontroli debugowania, co jest przydatne podczas tworzenia.

1 <WagmiConfig config={config}>

Aplikacja znajduje się również wewnątrz komponentu WagmiConfig (opens in a new tab). Biblioteka wagmi (we are going to make it) (opens in a new tab) łączy definicje interfejsu użytkownika React z biblioteką viem (opens in a new tab) do pisania zdecentralizowanej aplikacji Ethereum.

1 <RainbowKitProvider chains={chains}>

I wreszcie komponent RainbowKitProvider (opens in a new tab). Ten komponent obsługuje logowanie i komunikację między portfelem a aplikacją.

1 <App />

Teraz możemy mieć komponent dla aplikacji, który faktycznie implementuje interfejs użytkownika. Znak /> na końcu komponentu informuje React, że ten komponent nie ma w sobie żadnych definicji, zgodnie ze standardem XML.

1 </RainbowKitProvider>
2 </WagmiConfig>
3 </React.StrictMode>,
4)

Oczywiście musimy zamknąć pozostałe komponenty.

src/App.tsx

1import { ConnectButton } from '@rainbow-me/rainbowkit'
2import { useAccount } from 'wagmi'
3import { Greeter } from './components/Greeter'
4
5export function App() {

To jest standardowy sposób tworzenia komponentu React – zdefiniowanie funkcji, która jest wywoływana za każdym razem, gdy musi zostać wyrenderowana. Ta funkcja zazwyczaj ma na górze kod TypeScript lub JavaScript, po którym następuje instrukcja return, która zwraca kod JSX.

1 const { isConnected } = useAccount()

Tutaj używamy useAccount (opens in a new tab), aby sprawdzić, czy jesteśmy połączeni z blockchainem przez portfel, czy nie.

Zgodnie z konwencją, w React funkcje o nazwie use...hookami (opens in a new tab), które zwracają pewien rodzaj danych. Kiedy używasz takich hooków, nie tylko Twój komponent otrzymuje dane, ale gdy te dane się zmieniają, komponent jest ponownie renderowany z zaktualizowanymi informacjami.

1 return (
2 <>

JSX komponentu React musi zwrócić jeden komponent. Gdy mamy wiele komponentów i nie mamy niczego, co "naturalnie" je opakowuje, używamy pustego komponentu (<> ... </>), aby uczynić je pojedynczym komponentem.

1 <h1>Greeter</h1>
2 <ConnectButton />

Komponent ConnectButton (opens in a new tab) otrzymujemy z RainbowKit. Gdy nie jesteśmy połączeni, daje nam przycisk Connect Wallet, który otwiera okno modalne, które wyjaśnia działanie portfeli i pozwala wybrać, którego używasz. Gdy jesteśmy połączeni, wyświetla używany przez nas blockchain, adres naszego konta i saldo ETH. Możemy użyć tych wyświetlaczy, aby przełączyć sieć lub się rozłączyć.

1 {isConnected && (

Gdy musimy wstawić rzeczywisty JavaScript (lub TypeScript, który zostanie skompilowany do JavaScriptu) do JSX, używamy nawiasów ({}).

Składnia a && b jest skrótem od [a ? b : a](https://www.w3schools.com/react/react_es6_ternary.asp). Oznacza to, że jeśli ajest prawdziwe, wynikiem jestb, a w przeciwnym razie wynikiem jest a(które może byćfalse, 0` itp.). Jest to łatwy sposób, aby powiedzieć React, że komponent powinien być wyświetlany tylko wtedy, gdy spełniony jest określony warunek.

W tym przypadku chcemy pokazać użytkownikowi Greeter tylko wtedy, gdy użytkownik jest połączony z blockchainem.

1 <Greeter />
2 )}
3 </>
4 )
5}

src/components/Greeter.tsx

Ten plik zawiera większość funkcjonalności interfejsu użytkownika. Zawiera definicje, które normalnie znajdowałyby się w wielu plikach, ale ponieważ jest to samouczek, program jest zoptymalizowany pod kątem łatwości zrozumienia za pierwszym razem, a nie wydajności czy łatwości konserwacji.

1import { useState, ChangeEventHandler } from 'react'
2import { useNetwork,
3 useReadContract,
4 usePrepareContractWrite,
5 useContractWrite,
6 useContractEvent
7 } from 'wagmi'

Używamy tych funkcji bibliotecznych. Ponownie, są one wyjaśnione poniżej, w miejscu ich użycia.

1import { AddressType } from 'abitype'

Biblioteka abitype (opens in a new tab) dostarcza nam definicje TypeScript dla różnych typów danych Ethereum, takich jak AddressType (opens in a new tab).

1let greeterABI = [
2 .
3 .
4 .
5] as const // greeterABI

ABI - binarny interfejs aplikacji dla kontraktu Greeter. Jeśli tworzysz kontrakty i interfejs użytkownika w tym samym czasie, normalnie umieszczasz je w tym samym repozytorium i używasz ABI wygenerowanego przez kompilator Solidity jako pliku w swojej aplikacji. Jednak nie jest to tutaj konieczne, ponieważ kontrakt jest już opracowany i nie ulegnie zmianie.

1type AddressPerBlockchainType = {
2 [key: number]: AddressType
3}

TypeScript jest silnie typowany. Używamy tej definicji do określenia adresu, na którym kontrakt Greeter jest wdrożony na różnych łańcuchach. Kluczem jest liczba (chainId), a wartością jest AddressType (adres).

1const contractAddrs: AddressPerBlockchainType = {
2 // Holesky
3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',
4
5 // Sepolia
6 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'
7}

Adres kontraktu na dwóch obsługiwanych sieciach: Holesky (opens in a new tab) i Sepolia (opens in a new tab).

Uwaga: W rzeczywistości istnieje trzecia definicja, dla Redstone Holesky, zostanie ona wyjaśniona poniżej.

1type ShowObjectAttrsType = {
2 name: string,
3 object: any
4}

Ten typ jest używany jako parametr komponentu ShowObject (wyjaśnionego później). Zawiera nazwę obiektu i jego wartość, które są wyświetlane w celach debugowania.

1type ShowGreetingAttrsType = {
2 greeting: string | undefined
3}

W dowolnym momencie możemy wiedzieć, jakie jest powitanie (ponieważ odczytaliśmy je z blockchainu) lub nie wiedzieć (ponieważ jeszcze go nie otrzymaliśmy). Dlatego warto mieć typ, który może być albo ciągiem znaków, albo niczym.

Komponent Greeter
1const Greeter = () => {

Wreszcie możemy zdefiniować komponent.

1 const { chain } = useNetwork()

Informacje o łańcuchu, którego używamy, dzięki uprzejmości wagmi (opens in a new tab). Ponieważ jest to hak (use...), za każdym razem, gdy ta informacja się zmienia, komponent jest ponownie rysowany.

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

Adres kontraktu Greeter, który różni się w zależności od łańcucha (i który jest undefined, jeśli nie mamy informacji o łańcuchu lub jesteśmy na łańcuchu bez tego kontraktu).

1 const readResults = useReadContract({
2 address: greeterAddr,
3 abi: greeterABI,
4 functionName: "greet" , // Brak argumentów
5 watch: true
6 })

Hak useReadContract (opens in a new tab) odczytuje informacje z kontraktu. Możesz zobaczyć dokładnie, jakie informacje zwraca, rozwijając readResults w interfejsie użytkownika. W tym przypadku chcemy, aby nadal szukał, abyśmy byli informowani o zmianie powitania.

Uwaga: Moglibyśmy nasłuchiwać zdarzeń setGreeting (opens in a new tab), aby wiedzieć, kiedy zmienia się powitanie i aktualizować je w ten sposób. Jednak, chociaż może to być bardziej wydajne, nie będzie miało zastosowania we wszystkich przypadkach. Gdy użytkownik przełącza się na inny łańcuch, powitanie również się zmienia, ale tej zmianie nie towarzyszy zdarzenie. Moglibyśmy mieć jedną część kodu nasłuchującą zdarzeń, a drugą do identyfikowania zmian łańcucha, ale byłoby to bardziej skomplikowane niż tylko ustawienie parametru watch (opens in a new tab).

1 const [ newGreeting, setNewGreeting ] = useState("")

Hook useState (opens in a new tab) React pozwala nam zdefiniować zmienną stanu, której wartość utrzymuje się od jednego renderowania komponentu do drugiego. Wartością początkową jest parametr, w tym przypadku pusty ciąg znaków.

Hook useState zwraca listę z dwiema wartościami:

  1. Bieżąca wartość zmiennej stanu.
  2. Funkcja do modyfikowania zmiennej stanu w razie potrzeby. Ponieważ jest to hak, za każdym razem, gdy jest wywoływany, komponent jest ponownie renderowany.

W tym przypadku używamy zmiennej stanu dla nowego powitania, które użytkownik chce ustawić.

1 const greetingChange : ChangeEventHandler<HTMLInputElement> = (evt) =>
2 setNewGreeting(evt.target.value)

Jest to procedura obsługi zdarzeń dla zmiany pola wprowadzania nowego powitania. Typ ChangeEventHandler<HTMLInputElement> (opens in a new tab) określa, że jest to procedura obsługi zmiany wartości elementu wejściowego HTML. Część <HTMLInputElement> jest używana, ponieważ jest to typ generyczny (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)

To jest proces przesyłania transakcji blockchain z perspektywy klienta:

  1. Wyślij transakcję do węzła w łańcuchu bloków za pomocą eth_estimateGas (opens in a new tab).
  2. Poczekaj na odpowiedź z węzła.
  3. Po otrzymaniu odpowiedzi, poproś użytkownika o podpisanie transakcji za pośrednictwem portfela. Ten krok musi nastąpić po otrzymaniu odpowiedzi węzła, ponieważ użytkownikowi wyświetlany jest koszt gazu transakcji przed jej podpisaniem.
  4. Poczekaj na zatwierdzenie przez użytkownika.
  5. Wyślij transakcję ponownie, tym razem za pomocą eth_sendRawTransaction (opens in a new tab).

Krok 2 prawdopodobnie zajmie zauważalną ilość czasu, podczas którego użytkownicy zastanawialiby się, czy ich polecenie zostało naprawdę odebrane przez interfejs użytkownika i dlaczego nie są jeszcze proszeni o podpisanie transakcji. To powoduje złe doświadczenie użytkownika (UX).

Rozwiązaniem jest użycie hooków przygotowawczych (opens in a new tab). Za każdym razem, gdy parametr się zmienia, natychmiast wysyłaj do węzła żądanie eth_estimateGas. Następnie, gdy użytkownik faktycznie chce wysłać transakcję (w tym przypadku przez naciśnięcie Update greeting), koszt gazu jest znany, a użytkownik może natychmiast zobaczyć stronę portfela.

1 return (

Teraz możemy wreszcie utworzyć rzeczywisty kod HTML do zwrócenia.

1 <>
2 <h2>Greeter</h2>
3 {
4 !readResults.isError && !readResults.isLoading &&
5 <ShowGreeting greeting={readResults.data} />
6 }
7 <hr />

Utwórz komponent ShowGreeting (wyjaśniony poniżej), ale tylko wtedy, gdy powitanie zostało pomyślnie odczytane z blockchainu.

1 <input type="text"
2 value={newGreeting}
3 onChange={greetingChange}
4 />

Jest to pole tekstowe, w którym użytkownik może ustawić nowe powitanie. Za każdym razem, gdy użytkownik naciśnie klawisz, wywołujemy greetingChange, które wywołuje setNewGreeting. Ponieważ setNewGreeting pochodzi z hooka useState, powoduje to ponowne renderowanie komponentu Greeter. Oznacza to, że:

  • Musimy określić value, aby zachować wartość nowego powitania, ponieważ w przeciwnym razie powróciłoby ono do wartości domyślnej, czyli pustego ciągu znaków.
  • usePrepareContractWrite jest wywoływane za każdym razem, gdy newGreeting się zmienia, co oznacza, że zawsze będzie miało najnowsze newGreeting w przygotowanej transakcji.
1 <button disabled={!workingTx.write}
2 onClick={workingTx.write}
3 >
4 Update greeting
5 </button>

Jeśli nie ma workingTx.write, oznacza to, że wciąż czekamy na informacje niezbędne do wysłania aktualizacji powitania, więc przycisk jest wyłączony. Jeśli istnieje wartość workingTx.write, to jest to funkcja do wywołania w celu wysłania transakcji.

1 <hr />
2 <ShowObject name="readResults" object={readResults} />
3 <ShowObject name="preparedTx" object={preparedTx} />
4 <ShowObject name="workingTx" object={workingTx} />
5 </>
6 )
7}

Na koniec, aby pomóc Ci zobaczyć, co robimy, pokaż trzy obiekty, których używamy:

  • readResults
  • preparedTx
  • workingTx
Komponent ShowGreeting

Ten komponent pokazuje

1const ShowGreeting = (attrs : ShowGreetingAttrsType) => {

Funkcja komponentu otrzymuje parametr ze wszystkimi atrybutami komponentu.

1 return <b>{attrs.greeting}</b>
2}
Komponent ShowObject

W celach informacyjnych używamy komponentu ShowObject do pokazania ważnych obiektów (readResults do odczytywania powitania oraz preparedTx i workingTx do transakcji, które tworzymy).

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>

Nie chcemy zaśmiecać interfejsu użytkownika wszystkimi informacjami, więc aby umożliwić ich przeglądanie lub zamykanie, używamy znacznika details (opens in a new tab).

1 <summary>{attrs.name}</summary>
2 <pre>
3 {JSON.stringify(attrs.object, null, 2)}

Większość pól jest wyświetlana za pomocą JSON.stringify (opens in a new tab).

1 </pre>
2 { funs.length > 0 &&
3 <>
4 Functions:
5 <ul>

Wyjątkiem są funkcje, które nie są częścią standardu JSON (opens in a new tab), więc muszą być wyświetlane osobno.

1 {funs.map((f, i) =>

Wewnątrz JSX kod w nawiasach klamrowych { } jest interpretowany jako JavaScript. Następnie kod w nawiasach zwykłych ( ) jest ponownie interpretowany jako JSX.

1 (<li key={i}>{f}</li>)
2 )}

React wymaga, aby znaczniki w drzewie DOM (opens in a new tab) miały odrębne identyfikatory. Oznacza to, że dzieci tego samego znacznika (w tym przypadku lista nieuporządkowana (opens in a new tab)) potrzebują różnych atrybutów key.

1 </ul>
2 </>
3 }
4 </details>
5 </>
6}

Zakończ różne znaczniki HTML.

Ostateczny export
1export { Greeter }

Komponent Greeter jest tym, który musimy wyeksportować dla aplikacji.

src/wagmi.ts

Na koniec, różne definicje związane z WAGMI znajdują się w src/wagmi.ts. Nie będę tutaj wszystkiego wyjaśniać, ponieważ większość z tego to szablon, którego prawdopodobnie nie będziesz musiał zmieniać.

Kod tutaj nie jest dokładnie taki sam jak na GitHubie (opens in a new tab), ponieważ w dalszej części artykułu dodajemy kolejny łańcuch (Redstone Holesky (opens in a new tab)).

1import { getDefaultWallets } from '@rainbow-me/rainbowkit'
2import { configureChains, createConfig } from 'wagmi'
3import { holesky, sepolia } from 'wagmi/chains'

Importuj łańcuchy bloków, które obsługuje aplikacja. Listę obsługiwanych łańcuchów można zobaczyć w viem github (opens in a new tab).

1import { publicProvider } from 'wagmi/providers/public'
2
3const walletConnectProjectId = 'c96e690bb92b6311e8e9b2a6a22df575'

Aby móc korzystać z WalletConnect (opens in a new tab), potrzebujesz identyfikatora projektu dla swojej aplikacji. Możesz go uzyskać na stronie cloud.walletconnect.com (opens in a new tab).

1const { chains, publicClient, webSocketPublicClient } = configureChains(
2 [ holesky, sepolia ],
3 [
4 publicProvider(),
5 ],
6)
7
8const { connectors } = getDefaultWallets({
9 appName: 'My wagmi + RainbowKit App',
10 chains,
11 projectId: walletConnectProjectId,
12})
13
14export const config = createConfig({
15 autoConnect: true,
16 connectors,
17 publicClient,
18 webSocketPublicClient,
19})
20
21export { chains }

Dodawanie kolejnego blockchaina

Obecnie istnieje wiele rozwiązań skalujących L2 i możesz chcieć obsługiwać niektóre, których viem jeszcze nie obsługuje. Aby to zrobić, zmodyfikuj src/wagmi.ts. Te instrukcje wyjaśniają, jak dodać Redstone Holesky (opens in a new tab).

  1. Zaimportuj typ defineChain z viem.

    1import { defineChain } from 'viem'
  2. Dodaj definicję sieci.

    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})
  3. Dodaj nowy łańcuch do wywołania configureChains.

    1 const { chains, publicClient, webSocketPublicClient } = configureChains(
    2 [ holesky, sepolia, redstoneHolesky ],
    3 [ publicProvider(), ],
    4 )
  4. Upewnij się, że aplikacja zna adres Twoich kontraktów w nowej sieci. W tym przypadku modyfikujemy src/components/Greeter.tsx:

    1const contractAddrs : AddressPerBlockchainType = {
    2 // Holesky
    3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',
    4
    5 // Redstone Holesky
    6 17001: '0x4919517f82a1B89a32392E1BF72ec827ba9986D3',
    7
    8 // Sepolia
    9 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'
    10}

Wnioski

Oczywiście, tak naprawdę nie zależy Ci na udostępnieniu interfejsu użytkownika dla Greeter. Chcesz stworzyć interfejs użytkownika dla własnych kontraktów. Aby utworzyć własną aplikację, wykonaj następujące kroki:

  1. Określ, aby utworzyć aplikację wagmi.

    1pnpm create wagmi
  2. Nazwij aplikację.

  3. Wybierz framework React.

  4. Wybierz wariant Vite.

  5. Możesz dodać zestaw Rainbow (opens in a new tab).

Teraz idź i spraw, aby Twoje kontrakty były użyteczne dla całego świata.

Zobacz więcej mojej pracy tutaj (opens in a new tab).

Strona ostatnio zaktualizowana: 3 marca 2026

Czy ten samouczek był pomocny?