Создание пользовательского интерфейса для вашего контракта
Вы нашли функцию, которая нам нужна в экосистеме Ethereum. Вы написали смарт-контракты для ее реализации и, возможно, даже какой-то связанный с этим код, который выполняется вне сети. Это здорово! К сожалению, без пользовательского интерфейса у вас не будет пользователей, а в последний раз, когда вы писали веб-сайт, люди пользовались модемами с коммутируемым доступом, а JavaScript был в новинку.
Эта статья для вас. Я предполагаю, что вы знаете программирование и, возможно, немного JavaScript и HTML, но ваши навыки работы с пользовательским интерфейсом устарели. Вместе мы рассмотрим простое современное приложение, чтобы вы увидели, как это делается в наши дни.
Почему это важно
Теоретически вы могли бы просто позволить людям использовать Etherscan (opens in a new tab) или Blockscout (opens in a new tab) для взаимодействия с вашими контрактами. Это будет отлично для опытных пользователей Ethereum. Но мы пытаемся обслужить еще миллиард человек (opens in a new tab). Этого не произойдет без отличного пользовательского опыта, а дружественный пользовательский интерфейс — большая его часть.
Приложение Greeter
Существует много теории о том, как работает современный пользовательский интерфейс, и много хороших сайтов (opens in a new tab), которые это объясняют (opens in a new tab). Вместо того чтобы повторять прекрасную работу, проделанную на этих сайтах, я предположу, что вы предпочитаете учиться на практике и начнете с приложения, с которым можно поиграть. Вам все еще нужна теория, чтобы все сделать, и мы до нее доберемся — мы просто будем разбирать исходный файл за исходным файлом и обсуждать все по мере их появления.
Установка
-
При необходимости добавьте блокчейн Holesky (opens in a new tab) в свой кошелек и получите тестовые ETH (opens in a new tab).
-
Клонируйте репозиторий github.
1git clone https://github.com/qbzzt/20230801-modern-ui.git -
Установить нужные пакеты.
1cd 20230801-modern-ui2pnpm install -
Запустите приложение.
1pnpm dev -
Перейдите по URL-адресу, указанному в приложении. В большинстве случаев это http://localhost:5173/ (opens in a new tab).
-
Вы можете увидеть исходный код контракта, немного измененную версию 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) соединяет определения пользовательского интерфейса React с библиотекой viem (opens in a new tab) для написания децентрализованного приложения Ethereum.
1 <RainbowKitProvider chains={chains}>И, наконец, компонент RainbowKitProvider (opens in a new tab). Этот компонент обрабатывает вход в систему и связь между кошельком и приложением.
1 <App />Теперь у нас может быть компонент для приложения, который фактически реализует пользовательский интерфейс. Символ /> в конце компонента говорит 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'45export 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 должен возвращать один компонент. Когда у нас несколько компонентов, и нет ничего, что оборачивает их "естественным образом", мы используем пустой компонент (<> ... </>`) чтобы сделать из них один компонент.
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
Этот файл содержит большую часть функциональности пользовательского интерфейса. Он включает определения, которые обычно находятся в нескольких файлах, но поскольку это учебное пособие, программа оптимизирована для простоты понимания с первого раза, а не для производительности или простоты обслуживания.
1import { useState, ChangeEventHandler } from 'react'2import { useNetwork,3 useReadContract,4 usePrepareContractWrite,5 useContractWrite,6 useContractEvent7 } 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 // greeterABIABI для контракта Greeter.
Если вы разрабатываете контракты и пользовательский интерфейс одновременно, вы обычно помещаете их в один репозиторий и используете ABI, сгенерированный компилятором Solidity, как файл в вашем приложении. Однако здесь это не обязательно, потому что контракт уже разработан и меняться не будет.
1type AddressPerBlockchainType = {2 [key: number]: AddressType3}TypeScript строго типизирован. Мы используем это определение, чтобы указать адрес, по которому контракт Greeter развернут в разных сетях. Ключ — это число (chainId), а значение — AddressType (адрес).
1const contractAddrs: AddressPerBlockchainType = {2 // Holesky3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',45 // Sepolia6 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'7}Адрес контракта в двух поддерживаемых сетях: Holesky (opens in a new tab) и Sepolia (opens in a new tab).
Примечание: на самом деле есть третье определение, для Redstone Holesky, оно будет объяснено ниже.
1type ShowObjectAttrsType = {2 name: string,3 object: any4}Этот тип используется в качестве параметра для компонента ShowObject (объяснение будет позже). Он включает имя объекта и его значение, которые отображаются в целях отладки.
1type ShowGreetingAttrsType = {2 greeting: string | undefined3}В любой момент времени мы можем либо знать, что такое приветствие (потому что мы прочитали его из блокчейна), либо не знать (потому что мы его еще не получили). Поэтому полезно иметь тип, который может быть либо строкой, либо ничем.
Компонент 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" , // Нет аргументов5 watch: true6 })Хук useReadContract (opens in a new tab) читает информацию из контракта. Вы можете точно увидеть, какую информацию он возвращает, раскрыв readResults в пользовательском интерфейсе. В этом случае мы хотим, чтобы он продолжал отслеживать, чтобы мы были проинформированы, когда приветствие изменится.
Примечание: Мы могли бы прослушивать события setGreeting (opens in a new tab), чтобы знать, когда меняется приветствие, и обновлять его таким образом. Однако, хотя это может быть более эффективно, это применимо не во всех случаях. Когда пользователь переключается на другую сеть, приветствие также меняется, но это изменение не сопровождается событием. Мы могли бы иметь одну часть кода, прослушивающую события, и другую для определения изменений сети, но это было бы сложнее, чем просто установить параметр watch (opens in a new tab).
1 const [ newGreeting, setNewGreeting ] = useState("")Хук useState из React (opens in a new tab) позволяет нам указать переменную состояния, значение которой сохраняется от одной отрисовки компонента к другой. Начальное значение — это параметр, в данном случае пустая строка.
Хук useState возвращает список с двумя значениями:
- Текущее значение переменной состояния.
- Функция для изменения переменной состояния при необходимости. Поскольку это хук, каждый раз при его вызове компонент отрисовывается заново.
В этом случае мы используем переменную состояния для нового приветствия, которое хочет установить пользователь.
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)Это процесс отправки транзакции в блокчейн с точки зрения клиента:
- Отправьте транзакцию узлу в блокчейне с помощью
eth_estimateGas(opens in a new tab). - Дождитесь ответа от узла.
- Когда ответ получен, попросите пользователя подписать транзакцию через кошелек. Этот шаг должен произойти после получения ответа от узла, потому что пользователю показывается стоимость газа транзакции перед ее подписанием.
- Дождитесь одобрения пользователя.
- Отправьте транзакцию еще раз, на этот раз используя
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 Обновить приветствие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}Наконец, чтобы помочь вам увидеть, что мы делаем, покажем три объекта, которые мы используем:
readResultspreparedTxworkingTx
Компонент 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>Мы не хотим загромождать пользовательский интерфейс всей информацией, поэтому, чтобы можно было просматривать или скрывать ее, мы используем тег 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 Функции: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-теги.
Финальный export
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'23const walletConnectProjectId = 'c96e690bb92b6311e8e9b2a6a22df575'Чтобы использовать WalletConnect (opens in a new tab), вам нужен идентификатор проекта для вашего приложения. Вы можете получить его на 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 }Показать всеДобавление другого блокчейна
В наши дни существует множество решений для масштабирования L2, и вы, возможно, захотите поддержать некоторые, которые viem еще не поддерживает. Для этого вы изменяете src/wagmi.ts. Эти инструкции объясняют, как добавить Redstone Holesky (opens in a new tab).
-
Импортируйте тип
defineChainиз viem.1import { defineChain } from 'viem' -
Добавьте определение сети.
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})Показать все -
Добавьте новую сеть в вызов
configureChains.1 const { chains, publicClient, webSocketPublicClient } = configureChains(2 [ holesky, sepolia, redstoneHolesky ],3 [ publicProvider(), ],4 ) -
Убедитесь, что приложение знает адрес ваших контрактов в новой сети. В этом случае мы изменяем
src/components/Greeter.tsx:1const contractAddrs : AddressPerBlockchainType = {2 // Holesky3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',45 // Redstone Holesky6 17001: '0x4919517f82a1B89a32392E1BF72ec827ba9986D3',78 // Sepolia9 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'10}Показать все
Заключение
Конечно, вас не особо волнует предоставление пользовательского интерфейса для Greeter. Вы хотите создать пользовательский интерфейс для своих собственных контрактов. Чтобы создать собственное приложение, выполните следующие шаги:
-
Укажите, что нужно создать приложение wagmi.
1pnpm create wagmi -
Назовите приложение.
-
Выберите фреймворк React.
-
Выберите вариант Vite.
-
Вы можете добавить Rainbow kit (opens in a new tab).
Теперь идите и сделайте свои контракты пригодными для использования во всем мире.
Больше моих работ смотрите здесь (opens in a new tab).
Последнее обновление страницы: 3 марта 2026 г.