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

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

TypeScript
React
Vite
Wagmi
фронтенд
Для початківців
Орі Померанц
1 листопада 2023 р.
16 хвилин на читання

Ви знайшли функцію, яка потрібна в екосистемі Етеріум. Ви написали смарт-контракти для її реалізації, а можливо, навіть пов'язаний код, який працює позамережево. Це чудово! На жаль, без інтерфейсу користувача у вас не буде жодного користувача, а коли ви востаннє писали вебсайт, люди використовували комутовані модеми, а JavaScript був чимось новим.

Ця стаття для вас. Я припускаю, що ви знаєте програмування, а можливо, і трохи JavaScript та HTML, але ваші навички створення інтерфейсів користувача застаріли. Разом ми розглянемо простий сучасний застосунок, щоб ви побачили, як це робиться сьогодні.

Чому це важливо

Теоретично, ви могли б просто запропонувати людям використовувати Etherscan (opens in a new tab) або Blockscout (opens in a new tab) для взаємодії з вашими контрактами. Це чудово для досвідчених користувачів Етеріуму. Але ми намагаємося залучити ще мільярд людей (opens in a new tab). Цього не станеться без чудового користувацького досвіду, і зручний інтерфейс користувача є важливою його частиною.

Застосунок Greeter

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

Встановлення

  1. Застосунок використовує тестову мережу Sepolia (opens in a new tab). За необхідності отримайте тестові ETH у Sepolia та додайте 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. Застосунок використовує безкоштовні точки доступу, які мають обмеження продуктивності. Якщо ви хочете використовувати провайдера вузлів як послуги, замініть URL-адреси у src/wagmi.ts.

  4. Запустіть застосунок.

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

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

Огляд файлів

index.html

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

<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, тому ми можемо використовувати його на стороні клієнта.

Цей файл здебільшого пояснюється на випадок, якщо вам цікаво. Зазвичай ви не змінюєте цей файл, а лише 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 з бібліотекою Viem (opens in a new tab) для написання децентралізованого застосунку (dapp) Етеріуму.

      <QueryClientProvider client={queryClient}>

І нарешті, додаємо провайдер React Query, щоб будь-який компонент застосунку міг використовувати кешовані запити.

        <App />

Тепер ми можемо додати компонент для застосунку, який фактично реалізує інтерфейс користувача. /> в кінці компонента повідомляє React, що цей компонент не має жодних визначень всередині, відповідно до стандарту XML.

      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>,
)

Звісно, ми повинні закрити інші компоненти.

src/App.tsx

Імпортуємо необхідні бібліотеки, а також компонент Greeter.

const SEPOLIA_CHAIN_ID = 11155111

Ідентифікатор ланцюга Sepolia.

function App() {

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

  const connection = useConnection()

Використовуйте useConnection (opens in a new tab), щоб отримати інформацію, пов'язану з поточним підключенням, таку як адреса та chainId.

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

  const { connectors, connect, status, error } = useConnect()

Використовуйте useConnect (opens in a new tab), щоб отримати інформацію про підключення гаманця.

  const { disconnect } = useDisconnect()

Цей хук (opens in a new tab) надає нам функцію для відключення від гаманця.

  const { switchChain } = useSwitchChain()

Цей хук (opens in a new tab) дозволяє нам перемикати ланцюги.

  useEffect(() => {

Хук React 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 (
    <>

JSX компонента React повинен повертати один 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

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

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

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

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

Ми використовуємо 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). Оскільки це хук (use...), компонент рендериться повторно щоразу, коли ця інформація змінюється.

  const greeterAddr = chainId && contractAddrs[chainId] 

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

  const readResults = useReadContract({
    address: greeterAddr,
    abi: greeterABI,
    functionName: "greet", // Без аргументів
  })

Хук useReadContract (opens in a new tab) викликає функцію greet контракту (opens in a new tab).

  const [ currentGreeting, setCurrentGreeting ] = 
    useState("Please wait while we fetch the greeting from the blockchain...")
  const [ newGreeting, setNewGreeting ] = useState("")

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

Хук useState повертає список із двома значеннями:

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

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

  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 вище змінює дані і вони не встановлені на хибне значення (наприклад, undefined), оновлюємо поточне привітання на те, що зчитано з блокчейну. Також оновлюємо статус.

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

Слухаємо події SetGreeting.

    enabled: !!greeterAddr,

!!<value> означає, що якщо значення дорівнює false або значенню, яке обчислюється як хибне, наприклад undefined, 0 або порожній рядок, вираз загалом дорівнює false. Для будь-якого іншого значення він дорівнює true. Це спосіб перетворення значень на логічні, оскільки якщо немає greeterAddr, ми не хочемо слухати події.

    onLogs: logs => {
      const greetingFromContract = logs[0].args.greeting
      setCurrentGreeting(greetingFromContract)
      setLastSetterAddress(logs[0].args.sender)
      updateStatus("Greeting updated by event")
    },
  })

Коли ми бачимо журнали (що відбувається, коли ми бачимо нову подію), це означає, що привітання було змінено. У цьому випадку ми можемо оновити currentGreeting та lastSetterAddress на нові значення. Також ми хочемо оновити відображення статусу.

  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. Коли відповідь отримано, попросити користувача підписати транзакцію через гаманець. Цей крок повинен відбутися після отримання відповіді від вузла, оскільки користувачеві показується вартість газу для транзакції перед її підписанням.
  4. Дочекатися схвалення користувача.
  5. Надіслати транзакцію знову, цього разу використовуючи eth_sendRawTransaction (opens in a new tab).

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

Одне з рішень — надсилати eth_estimateGas щоразу, коли змінюється параметр. Тоді, коли користувач дійсно захоче надіслати транзакцію (у цьому випадку натиснувши Update greeting), вартість газу буде відома, і користувач зможе негайно побачити сторінку гаманця.

  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, щоб зберегти значення нового привітання, оскільки інакше воно повернулося б до значення за замовчуванням — порожнього рядка.
  • simulation також оновлюється щоразу, коли змінюється newGreeting, що означає, що ми отримаємо симуляцію з правильним привітанням. Це може бути важливим, оскільки вартість газу залежить від розміру даних виклику, який залежить від довжини рядка.
      <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()

Кінцевої точки HTTP за замовчуванням, яка постачається з Viem, цілком достатньо. Якщо ми хочемо іншу URL-адресу, ми можемо використовувати http("https:// hostname ") або webSocket("wss:// hostname ").

  },
  multiInjectedProviderDiscovery: false,
})

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

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

  1. Відредагуйте src/wagmi.ts

    A. Імпортуйте тип defineChain з Viem.

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