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

Создание пользовательского интерфейса для вашего контракта

TypeScript
React
Vite
Wagmi
фронтенд
Для начинающих
Ори Померанц
1 ноября 2023 г.
16 минут на чтение

Вы нашли функцию, которая нужна в экосистеме Эфириума. Вы написали смарт-контракты для ее реализации и, возможно, даже некоторый связанный код, который работает офчейн. Это здорово! К сожалению, без пользовательского интерфейса у вас не будет пользователей, а в последний раз, когда вы писали веб-сайт, люди использовали dial-up модемы, и JavaScript был в новинку.

Эта статья для вас. Я предполагаю, что вы умеете программировать и, возможно, немного знаете JavaScript и HTML, но ваши навыки создания пользовательских интерфейсов заржавели и устарели. Вместе мы рассмотрим простое современное приложение, чтобы вы увидели, как это делается в наши дни.

Почему это важно

В теории, вы могли бы просто предложить людям использовать Etherscan (открывается в новой вкладке) или Blockscout (открывается в новой вкладке) для взаимодействия с вашими контрактами. Это отлично подходит для опытных пользователей Эфириума. Но мы пытаемся охватить еще один миллиард человек (открывается в новой вкладке). Этого не произойдет без отличного пользовательского опыта, и дружелюбный пользовательский интерфейс — большая часть этого.

Приложение Greeter

Существует много теории о том, как работает современный пользовательский интерфейс, и множество хороших сайтов (открывается в новой вкладке), которые это объясняют (открывается в новой вкладке). Вместо того чтобы повторять прекрасную работу, проделанную этими сайтами, я предполагаю, что вы предпочитаете учиться на практике и начать с приложения, с которым можно поэкспериментировать. Вам все еще нужна теория, чтобы доводить дело до конца, и мы к ней перейдем — мы просто будем разбирать исходный файл за исходным файлом и обсуждать все по мере поступления.

Установка

  1. Приложение использует тестовую сеть Sepolia (открывается в новой вкладке). При необходимости получите тестовые ETH в Sepolia и добавьте Sepolia в свой кошелек (открывается в новой вкладке).

  2. Клонируйте репозиторий GitHub и установите необходимые пакеты.

    git clone https://github.com/qbzzt/260301-modern-ui-web3.git
    cd 260301-modern-ui-web3
    npm install
    
  3. Приложение использует бесплатные точки доступа, которые имеют ограничения по производительности. Если вы хотите использовать провайдера узлов как услугу (Node as a service), замените URL-адреса в src/wagmi.ts.

  4. Запустите приложение.

    npm run dev
    
  5. Перейдите по URL-адресу, показанному приложением. В большинстве случаев это http://localhost:5173/ (открывается в новой вкладке).

  6. Вы можете посмотреть исходный код контракта, модифицированную версию Greeter от Hardhat, в обозревателе блокчейна (открывается в новой вкладке).

Обзор файлов

index.html

Этот файл представляет собой стандартный шаблон HTML, за исключением этой строки, которая импортирует файл скрипта.

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

src/main.tsx

Расширение файла указывает на то, что это компонент React (открывается в новой вкладке), написанный на TypeScript (открывается в новой вкладке), расширении JavaScript, которое поддерживает проверку типов (открывается в новой вкладке). 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 (открывается в новой вкладке), которая включает конфигурацию блокчейна.

const queryClient = new QueryClient()

Создает новый экземпляр менеджера кэша React Query (открывается в новой вкладке). Этот объект будет хранить:

  • Кэшированные вызовы RPC
  • Чтения контрактов
  • Состояние фонового повторного запроса данных

Нам нужен менеджер кэша, потому что Wagmi v3 использует React Query внутри.

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

Создаем корневой компонент React. Параметром для render является JSX (открывается в новой вкладке), язык расширения, который использует как HTML, так и JavaScript/TypeScript. Восклицательный знак здесь говорит компоненту TypeScript: «ты не знаешь, что document.getElementById('root') будет допустимым параметром для ReactDOM.createRoot, но не волнуйся — я разработчик, и я говорю тебе, что так и будет».

  <React.StrictMode>

Приложение помещается внутрь компонента React.StrictMode (открывается в новой вкладке). Этот компонент указывает библиотеке React вставлять дополнительные проверки отладки, что полезно во время разработки.

    <WagmiProvider config={config}>

Приложение также находится внутри компонента WagmiProvider (открывается в новой вкладке). Библиотека Wagmi (мы собираемся ее создать) (открывается в новой вкладке) связывает определения пользовательского интерфейса React с библиотекой Viem (открывается в новой вкладке) для написания децентрализованного приложения (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 (открывается в новой вкладке) для получения информации, связанной с текущим подключением, такой как адрес и chainId.

По соглашению, в React функции, начинающиеся с use..., называются хуками (открывается в новой вкладке). Эти функции не просто возвращают данные компоненту; они также гарантируют его повторный рендеринг (функция компонента выполняется снова, и ее вывод заменяет предыдущий в HTML) при изменении этих данных.

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

Используйте useConnect (открывается в новой вкладке) для получения информации о подключении кошелька.

  const { disconnect } = useDisconnect()

Этот хук (открывается в новой вкладке) предоставляет нам функцию для отключения от кошелька.

  const { switchChain } = useSwitchChain()

Этот хук (открывается в новой вкладке) позволяет нам переключать цепи.

  useEffect(() => {

Хук React useEffect (открывается в новой вкладке) позволяет запускать функцию всякий раз, когда изменяется значение переменной, для синхронизации внешней системы.

    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 /> для горизонтальной линии (открывается в новой вкладке).

          <button type="button" onClick={disconnect}>
            Disconnect
          </button>
 
</div>
      )}

Если пользователь нажимает эту кнопку, вызывается функция disconnect.

      {connection.status !== 'connected' && (

Если мы не подключены, показываем необходимые параметры для подключения к кошельку.

        <div>
          <h2>Connect</h2>
          {connectors.map((connector) => (

В connectors у нас есть список коннекторов. Мы используем map (открывается в новой вкладке), чтобы превратить его в список кнопок 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 (открывается в новой вкладке) предоставляет нам определения TypeScript для различных типов данных Эфириума, таких как AddressType (открывается в новой вкладке).

let greeterABI = [
  { "type": "function", "name": "greet", ... },
  { "type": "function", "name": "setGreeting", ... },
  { "type": "event", "name": "SetGreeting", ... },
] as const   // greeterABI

ABI для контракта Greeter. Если вы разрабатываете контракты и пользовательский интерфейс одновременно, вы обычно помещаете их в один репозиторий и используете ABI, сгенерированный компилятором Solidity, в качестве файла в вашем приложении. Однако здесь это не обязательно, поскольку контракт уже разработан и не изменится.

Мы используем as const (открывается в новой вкладке), чтобы сообщить TypeScript, что это настоящая константа. Обычно, когда вы указываете в JavaScript const x = {"a": 1}, вы можете изменить значение в x, вы просто не можете присвоить ему новое значение.

type AddressPerBlockchainType = {
  [key: number]: AddressType
}

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

const contractAddrs : AddressPerBlockchainType = {
  // Sepolia
    11155111: '0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA'
}

Адрес контракта в Sepolia (открывается в новой вкладке).

Компонент 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 (открывается в новой вкладке) позволяет нам запланировать периодическое выполнение функции. В данном случае — каждую секунду. Функция вызывает setNow для обновления состояния, поэтому компонент Timer будет отрендерен повторно. Мы оборачиваем это в useEffect (открывается в новой вкладке) с пустым списком зависимостей, чтобы это произошло только один раз, а не каждый раз при рендеринге компонента.

  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 (открывается в новой вкладке). Поскольку это хук (use...), компонент рендерится повторно всякий раз, когда эта информация изменяется.

  const greeterAddr = chainId && contractAddrs[chainId] 

Адрес контракта Greeter, который равен undefined, если у нас нет информации о цепи или мы находимся в цепи без этого контракта.

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

Хук useReadContract (открывается в новой вкладке) вызывает функцию greet контракта (открывается в новой вкладке).

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

Хук useState (открывается в новой вкладке) в 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 (открывается в новой вкладке), но обеспечивает лучшее обновление статуса.

  const simulation = useSimulateContract({
    address: greeterAddr,
    abi: greeterABI,
    functionName: 'setGreeting',
    args: [newGreeting],
    account: account.address    
  })

Вот процесс отправки транзакции в блокчейн с точки зрения клиента:

  1. Отправить транзакцию на узел в блокчейне с помощью eth_estimateGas (открывается в новой вкладке).
  2. Дождаться ответа от узла.
  3. Когда ответ получен, попросить пользователя подписать транзакцию через кошелек. Этот шаг должен произойти после получения ответа от узла, потому что пользователю показывается стоимость газа для транзакции перед ее подписанием.
  4. Дождаться одобрения пользователя.
  5. Отправить транзакцию снова, на этот раз используя eth_sendRawTransaction (открывается в новой вкладке).

Шаг 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 включает цепи, поддерживаемые этим приложением. Вы можете посмотреть список доступных цепей (открывается в новой вкладке).

  connectors: [
    injected(),
  ],

Этот коннектор (открывается в новой вкладке) позволяет нам взаимодействовать с кошельком, установленным в браузере.

  transports: {
    [sepolia.id]: http()

Конечной точки HTTP по умолчанию, которая поставляется с Viem, вполне достаточно. Если мы хотим использовать другой URL-адрес, мы можем использовать http("https:// hostname ") или webSocket("wss:// hostname ").

  },
  multiInjectedProviderDiscovery: false,
})

Добавление другого блокчейна

В наши дни существует множество решений для масштабирования второго уровня (L2) (открывается в новой вкладке), и вы, возможно, захотите поддерживать некоторые из них, которые Viem еще не поддерживает. Для этого вы изменяете src/wagmi.ts. Эти инструкции объясняют, как добавить Optimism Sepolia (открывается в новой вкладке).

  1. Отредактируйте src/wagmi.ts

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

    import { defineChain } from 'viem'
    

    B. Добавьте определение сети. На самом деле вам не нужно делать это для Optimism Sepolia, она уже есть в viem (открывается в новой вкладке), но таким образом вы узнаете, как добавить блокчейн, которого нет в 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 (открывается в новой вкладке) и нажмите одну из кнопок в правой части таблицы, чтобы добавить цепь в свой кошелек.

    B. В приложении нажмите Disconnect (Отключиться), а затем снова подключитесь, чтобы изменить блокчейн. Есть более изящные способы справиться с этим, но они потребуют изменений в приложении.

Заключение

Конечно, вас не особо волнует предоставление пользовательского интерфейса для Greeter. Вы хотите создать пользовательский интерфейс для своих собственных контрактов. Чтобы создать собственное приложение, выполните следующие действия:

  1. Укажите создание приложения Wagmi.

    npm create wagmi
    
  2. Введите y, чтобы продолжить.

  3. Назовите приложение.

  4. Выберите фреймворк React.

  5. Выберите вариант Vite.

Теперь идите и сделайте свои контракты пригодными для использования во всем мире.

Смотрите здесь больше моих работ (открывается в новой вкладке).