Перейти до основного контенту

Створення інтерфейсу користувача для вашого контракту

TypeScript
React
Vite
Wagmi
використання
Початківець
Ori Pomerantz
1 листопада 2023 р.
13 читається за хвилину

Ви знайшли функцію, яка потрібна нам в екосистемі Ethereum. Ви написали смарт-контракти для її реалізації, а можливо, навіть якийсь пов'язаний код, що працює offchain. Це чудово! На жаль, без інтерфейсу користувача у вас не буде користувачів, а останній раз, коли ви писали вебсайт, люди використовували модеми з комутованим доступом, а 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.

    1git clone https://github.com/qbzzt/20230801-modern-ui.git
  3. Встановіть необхідні пакети.

    1cd 20230801-modern-ui
    2pnpm install
  4. Запустіть додаток.

    1pnpm dev
  5. Перейдіть за URL-адресою, що відображається додатком. У більшості випадків це http://localhost:5173/ (opens in a new tab).

  6. Ви можете переглянути вихідний код контракту, трохи змінену версію Greeter від Hardhat, у оглядачі блокчейну (opens in a new tab).

Огляд файлів

index.html

Цей файл є стандартним шаблоном HTML, за винятком цього рядка, який імпортує файл скрипту.

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

src/main.tsx

Розширення файлу говорить нам про те, що цей файл є компонентом React (opens in a new tab), написаним на TypeScript (opens in a new tab), розширенні 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) з'єднує визначення UI React з бібліотекою viem (opens in a new tab) для написання децентралізованого додатка Ethereum.

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'
4
5export function App() {

Це стандартний спосіб створення компонента React — визначити функцію, яка викликається щоразу, коли її потрібно відрендерити. Ця функція зазвичай має деякий код TypeScript або JavaScript на початку, за яким слідує оператор return, що повертає код JSX.

1 const { isConnected } = useAccount()

Тут ми використовуємо useAccount (opens in a new tab), щоб перевірити, чи підключені ми до блокчейну через гаманець.

За домовленістю, у React функції, що називаються use..., є хуками (opens in a new tab), які повертають певні дані. Коли ви використовуєте такі хуки, ваш компонент не тільки отримує дані, але й коли ці дані змінюються, компонент перемальовується з оновленою інформацією.

1 return (
2 <>

JSX компонента React повинен повертати один компонент. Коли ми маємо кілька компонентів і не маємо нічого, що обгортає їх "природно", ми використовуємо порожній компонент (<> ... </>) to make them into a single component.

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

Ми отримуємо компонент ConnectButton (opens in a new tab) з RainbowKit. Коли ми не підключені, він надає нам кнопку Connect Wallet, яка відкриває модальне вікно, що пояснює що таке гаманці та дозволяє вибрати, який з них ви використовуєте. Коли ми підключені, він відображає блокчейн, який ми використовуємо, адресу нашого облікового запису та наш баланс ETH. Ми можемо використовувати ці дисплеї для перемикання мережі або для відключення.

1 {isConnected && (

Коли нам потрібно вставити справжній JavaScript (або TypeScript, який буде компілюватися в JavaScript) в JSX, ми використовуємо дужки ({}).

Синтаксис a && b є скороченням для [a ? b : a](https://www.w3schools.com/react/react_es6_ternary.asp). Тобто, якщо aістинне, вираз обчислюється як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 useContractEvent
7 } from 'wagmi'

Ми використовуємо ці бібліотечні функції. Знову ж таки, вони пояснюються нижче, там, де вони використовуються.

1import { AddressType } from 'abitype'

Бібліотека abitype (opens in a new tab) надає нам визначення TypeScript для різних типів даних Ethereum, таких як AddressType (opens in a new tab).

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

ABI для контракту Greeter. Якщо ви розробляєте контракти та UI одночасно, ви зазвичай розміщуєте їх в одному репозиторії та використовуєте ABI, згенерований компілятором Solidity, як файл у вашому додатку. Однак тут це не потрібно, оскільки контракт уже розроблено і він не буде змінюватися.

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

TypeScript є строго типізованою мовою. Ми використовуємо це визначення, щоб вказати адресу, за якою контракт Greeter розгорнуто в різних мережах. Ключем є число (chainId), а значенням — AddressType (адреса).

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

Адреса контракту на двох підтримуваних мережах: Holesky (opens in a new tab) та Sepolia (opens in a new tab).

Примітка: насправді існує третє визначення для Redstone Holesky, яке буде пояснено нижче.

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

Цей тип використовується як параметр для компонента ShowObject (пояснено пізніше). Він містить назву об'єкта та його значення, які відображаються для цілей налагодження.

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

У будь-який момент часу ми можемо або знати, що таке привітання (оскільки ми прочитали його з блокчейну), або не знати (оскільки ми його ще не отримали). Тому корисно мати тип, який може бути або рядком, або нічим.

Компонент Greeter
1const Greeter = () => {

Нарешті, ми можемо визначити компонент.

1 const { chain } = useNetwork()

Інформація про мережу, яку ми використовуємо, надається wagmi (opens in a new tab). Оскільки це хук (use...), щоразу, коли ця інформація змінюється, компонент перемальовується.

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

Адреса контракту Greeter, яка залежить від мережі (і яка є undefined, якщо у нас немає інформації про мережу або ми знаходимося в мережі без цього контракту).

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

Хук useReadContract (opens in a new tab) зчитує інформацію з контракту. Ви можете побачити, яку саме інформацію він повертає, розгорнувши readResults в UI. У цьому випадку ми хочемо, щоб він продовжував відстежувати, щоб ми були поінформовані, коли привітання зміниться.

Примітка: Ми могли б слухати події setGreeting (opens in a new tab), щоб знати, коли змінюється привітання, і оновлювати його таким чином. Однак, хоча це може бути ефективніше, це не застосовується в усіх випадках. Коли користувач перемикається на іншу мережу, привітання також змінюється, але ця зміна не супроводжується подією. Ми могли б мати одну частину коду, що слухає події, а іншу — для ідентифікації змін мережі, але це було б складніше, ніж просто встановити параметр watch (opens in a new tab).

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

Хук useState (opens in a new tab) від React дозволяє нам вказати змінну стану, значення якої зберігається від одного відтворення компонента до іншого. Початковим значенням є параметр, у цьому випадку — порожній рядок.

Хук useState повертає список з двох значень:

  1. Поточне значення змінної стану.
  2. Функція для зміни змінної стану за потреби. Оскільки це хук, щоразу, коли він викликається, компонент відтворюється знову.

У цьому випадку ми використовуємо змінну стану для нового привітання, яке хоче встановити користувач.

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)

Це процес надсилання транзакції блокчейну з точки зору клієнта:

  1. Надішліть транзакцію на вузол у блокчейні за допомогою eth_estimateGas (opens in a new tab).
  2. Зачекайте на відповідь від вузла.
  3. Коли відповідь отримано, попросіть користувача підписати транзакцію через гаманець. Цей крок повинен відбутися після отримання відповіді від вузла, оскільки користувачеві показується вартість газу транзакції перед її підписанням.
  4. Зачекайте на схвалення користувача.
  5. Надішліть транзакцію ще раз, цього разу використовуючи eth_sendRawTransaction (opens in a new tab).

Крок 2, ймовірно, займе помітну кількість часу, протягом якого користувачі будуть дивуватися, чи дійсно їхня команда була отримана інтерфейсом користувача і чому їх ще не просять підписати транзакцію. Це створює поганий досвід користувача (UX).

Рішенням є використання хуків підготовки (opens in a new tab). Кожного разу, коли параметр змінюється, негайно надсилайте запит eth_estimateGas на вузол. Потім, коли користувач дійсно хоче надіслати транзакцію (у цьому випадку, натиснувши Оновити привітання), вартість газу відома, і користувач може негайно побачити сторінку гаманця.

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, це призводить до повторного рендерингу компонента Greeter. Це означає, що:

  • Нам потрібно вказати value, щоб зберегти значення нового привітання, оскільки інакше воно повернеться до значення за замовчуванням — порожнього рядка.
  • usePrepareContractWrite викликається щоразу, коли змінюється newGreeting, що означає, що він завжди матиме останнє значення newGreeting у підготовленій транзакції.
1 <button disabled={!workingTx.write}
2 onClick={workingTx.write}
3 >
4 Update greeting
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}

Нарешті, щоб допомогти вам зрозуміти, що ми робимо, покажемо три об'єкти, які ми використовуємо:

  • readResults
  • preparedTx
  • workingTx
Компонент 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 Functions:
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.

Остаточний експорт
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'

Імпортуйте блокчейни, які підтримує додаток. Ви можете переглянути список підтримуваних мереж на github viem (opens in a new tab).

1import { publicProvider } from 'wagmi/providers/public'
2
3const 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)
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 }
Показати все

Додавання ще одного блокчейну

Сьогодні існує багато рішень для масштабування L2, і ви можете захотіти підтримати деякі, які viem ще не підтримує. Для цього вам потрібно змінити src/wagmi.ts. Ці інструкції пояснюють, як додати Redstone Holesky (opens in a new tab).

  1. Імпортуйте тип defineChain з viem.

    1import { defineChain } from 'viem'
  2. Додайте визначення мережі.

    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. Додайте нову мережу до виклику configureChains.

    1 const { chains, publicClient, webSocketPublicClient } = configureChains(
    2 [ holesky, sepolia, redstoneHolesky ],
    3 [ publicProvider(), ],
    4 )
  4. Переконайтеся, що додаток знає адресу для ваших контрактів у новій мережі. У цьому випадку ми змінюємо 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}
    Показати все

Висновок

Звичайно, ви не дуже дбаєте про надання інтерфейсу користувача для Greeter. Ви хочете створити інтерфейс користувача для своїх власних контрактів. Щоб створити власний додаток, виконайте ці кроки:

  1. Вкажіть, щоб створити додаток wagmi.

    1pnpm create wagmi
  2. Назвіть додаток.

  3. Виберіть фреймворк React.

  4. Виберіть варіант Vite.

  5. Ви можете додати Rainbow kit (opens in a new tab).

Тепер ідіть і зробіть свої контракти доступними для всього світу.

Більше моїх робіт дивіться тут (opens in a new tab).

Останні оновлення сторінки: 3 березня 2026 р.

Чи була ця інструкція корисною?