发布文章

前端程序员如何玩转Web3轻松开发DApp全栈项目(React+TS)

作者
  • avatar
    作者
    Jack Chen @懒人码农

前言

大家好,我是虚竹,在讲解 React 全栈项目之前,想让大家对区块链 DApp 技术栈有个快速的了解或认知,可以先去我的博客看看这篇《DApp 开发快速入门教程》,最近还写了一篇 Vue3 版的 DApp 项目也可以看下《手把手教你从零搭建一款dApp全栈项目(Vue3+TS)》,跟着我的教程一步一步操作,轻松上手那都不是事,学完后可以掌握 DApp 研发的基础知识。

此教程主要面向有一定前端开发基础的同学,帮助你从 Web2 迈向 Web3,获得 DApp(去中心化应用)的研发能力。

在区块链项目中,往往不会直接使用ethers.js或者web3.js来与小狐狸交互,因为这样会比较麻烦,为了解决更快的与链上连接和交互,衍生出了很多优秀的第三方库web3-reactweb3-modalrainbow-kit(基于wagmi进行封装)wagmi,当然还有很多,小编主推这些。

效果截图

image.png

image.png

image.png

image.png

技术栈

  • React:是一个用于构建用户界面的 JavaScript 库,它具有组件化、虚拟 DOM、单项数据流、JSX 语法等特点。
  • Typescript:是由微软进行开发和维护的一种开源的编程语言。TypeScript 是 JavaScript 的严格语法超集,提供了可选的静态类型检查。
  • Vite:是一种新型前端构建工具,能够显著提升前端开发体验。
  • Antd:是基于 Ant Design 设计体系的 React UI 组件库,主要用于研发企业级中后台产品。
  • Rainbowkit:是一个 React 库,它为我们提供了用几行代码构建 Connect Wallet UI 的组件。 它支持许多钱包,包括 Metamask、Rainbow、Coinbase Wallet、WalletConnect 等等。 它也是高度可定制的,并带有令人惊叹的内置主题。
  • Wagmi:是以太坊的 React Hooks 库。支持最流行和最常用的开箱即用以太坊功能,具有 40+ 个用于账户、钱包、合约、交易、签名、ENS 等的 React Hooks。Wagmi 还通过其官方连接器、EIP-6963 支持和可扩展的 API 支持几乎所有钱包。
  • Viem:是以太坊的低级 TypeScript 接口,使开发人员能够与以太坊区块链进行交互,包括:JSON-RPC API 抽象,智能合约交互,钱包和签名实现,编码/解析实用程序等。Wagmi Core 本质上是 Viem 的包装器,它通过 Wagmi Config 提供多链功能,并通过连接器提供自动账户管理。
  • Hardhat:是一个用于开发以太坊智能合约和 dApp 的开发框架和工具套件。提供了一套功能强大的工具,用于编译、部署、测试和调试智能合约,并与以太坊测试网络进行交互。
  • Ganache:是一个以太坊的个人开发环境,你可以在上面部署合约、开发程序和进行测试。它有桌面版本和命令行工具版本,同时提供对 windows 、Mac 和 Linux 的支持。
  • Solidity:是一种高级的、面向合约的编程语言,用于在以太坊虚拟机 (EVM) 上构建智能合约。它受到 C++、Python 和 JavaScript 的影响,并与这些语言共享语法和编程概念。
  • Metamask(小狐狸):是用于与以太坊区块链进行交互的软件加密货币钱包。它可以通过浏览器扩展程序或移动应用程序让用户访问其以太坊钱包,与去中心化应用进行交互。

项目结构

image.png

功能需求

  • 多钱包登录
  • 查询余额
  • 我要提款
  • 发布消息
  • 消息列表

准备工作

环境搭建

构建项目

yarn create vite react-dapp-todo

image.png

cd react-dapp-todo
yarn
yarn dev

image.png

image.png

安装依赖

安装 RainbowKit 工具套件的依赖项, wagmi 和 viem

yarn add @rainbow-me/rainbowkit wagmi viem@2.x @tanstack/react-query

导入配置

安装成功后,在src/main.tsx文件中导入依赖 RainbowKit 和 wagmi。

// src/main.tsx
import '@rainbow-me/rainbowkit/styles.css'

import { getDefaultConfig, RainbowKitProvider } from '@rainbow-me/rainbowkit'
import { WagmiProvider } from 'wagmi'
import { mainnet, polygon, optimism, arbitrum, base, sepolia } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

配置所需的链并生成需要的连接器,还需要设置一个wagmi配置。

注意:现在依赖 WalletConnect 的每一个 DApp 都需要从 WalletConnect Cloud 获得一个 projectId。 这完全免费,只需要几分钟。

// src/main.tsx
const config = getDefaultConfig({
  appName: 'RainbowKit app',
  projectId: 'YOUR_PROJECT_ID',
  chains: [mainnet, polygon, optimism, arbitrum, base, sepolia],
})

用 RainbowKitProviderWagmiProvider 和 QueryClientProvider 包裹 App 组件。

// src/main.tsx
const queryClient = new QueryClient()

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <App />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>,
)

添加按钮

src/App.tsx文件中,导入并渲染 ConnectButton 组件。

import { ConnectButton } from '@rainbow-me/rainbowkit'

function App() {
  return (
    <>
      <ConnectButton />
    </>
  )
}

export default App

查看交互界面效果,如下图所示可以处理用户的钱包选择,显示钱包/交易信息并处理网络/钱包切换。

image.png

image.png

image.png

image.png

image.png

image.png

初始化合约

在根目录新建文件夹my_contact,作为合约项目文件夹。安装hardhat依赖需进入此文件夹后运行如下命令:

cd my_contact
yarn add -D hardhat

image.png

安装成功后,接下来创建简单的hardhat项目,在之前创建好的my_contact文件夹中运行如下命令:

npx hardhat init

让我们创建 JavaScript 或 TypeScript 项目,并完成这些步骤来编译、测试和部署示例合约。我们建议使用 TypeScript,但如果您不熟悉它,只需选择 JavaScript。

image.png

初始化hardhat项目完成后,再运行npx hardhat & npx hardhat help命令,会看到相关帮助信息如下图所示:

image.png

  • contracts:存放智能合约文件的目录
  • ignition:存放部署智能合约文件的目录
  • test:存放单元测试智能合约文件的目录
  • hardhat.config.ts:Hardhat 配置文件,查看详细配置信息请移步到此:https://hardhat.org/hardhat-runner/docs/config
# 编译合约
npx hardhat compile

image.png

# 测试合约
npx hardhat test

image.png

# 部署合约
npx hardhat ignition deploy ./ignition/modules/Lock.ts

image.png

# 强制编译合约
npx hardhat compile --force

# 清除编译缓存
npx hardhat clean

将 Metamask 或 Dapp 连接到 Hardhat 网络

测试网部署

下载安装 Ganache

image.png

修改 RPC 端口号http://127.0.0.1:8545

image.png

image.png

如需详细了解Ganache工具的安装和使用,请移步到此:https://54web3.cc/blog/induction-tutorial/ganache-installation-use

修改配置文件hardhat.config.ts

image.png

代码实现

编写合约

// 创建合约文件:contract/TodoContract.sol

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.24;

// 合约名:TodoContract
contract TodoContract {
    struct Todo {
        uint256 id;
        address author;
        string message;
        uint256 timestamp;
    }

    // 事件:NewTodo,用于记录新创建的待办事项
    event NewTodo(
        uint256 todoID,
        address indexed from,
        string message,
        uint256 timestamp
    );

    // 事件:SendMoneyToContract,用于记录向合约发送以太币的事件
    event SendMoneyToContract(
        uint256 todoID,
        address receiver,
        string message,
        uint256 timestamp
    );

    // 状态变量:todoID,用于记录待办事项的ID
    // 状态变量:todoList,用于存储待办事项列表
    // 状态变量:owner,用于记录合约的拥有者
    uint256 public todoID;
    Todo[] public todoList;
    address payable public owner;

    // 构造函数:初始化合约时设置合约的拥有者
    constructor() payable {
        owner = payable(msg.sender);
    }

    // 修饰符:onlyOwner,用于限制只有合约的拥有者才能调用该函数
    modifier onlyOwner() {
        require(
            msg.sender == owner,
            "\u5fc5\u987b\u662f\u5408\u7ea6\u6240\u6709\u8005"
        );
        _;
    }

    // 函数:withdraw,用于提取合约中的以太币
    function withdraw() public payable onlyOwner {
        uint256 balance = address(this).balance;
        payable(msg.sender).transfer(balance);
    }

    // 函数:getBalance,用于查询合约中的以太币余额
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }

    // 函数:getTodoList,用于获取待办事项列表
    function getTodoList() public view returns (Todo[] memory) {
        return todoList;
    }

    // 函数:published,用于发布新的待办事项
    function published(string memory _message) public payable {
        todoID += 1;
        Todo memory item = Todo(todoID, msg.sender, _message, block.timestamp);
        todoList.push(item);
        emit NewTodo(todoID, msg.sender, _message, block.timestamp);

        uint256 payAmount = 0.1 ether;
        require(msg.value >= payAmount, "\u4f59\u989d\u4e0d\u8db3");
        (bool success, ) = payable(address(this)).call{value: payAmount}("");
        require(success, "\u5411\u5408\u7ea6\u6c47\u6b3e\u5931\u8d25");
        emit SendMoneyToContract(todoID, msg.sender, _message, block.timestamp);
    }

    // 函数:receive,用于接收以太币
    receive() external payable {}
}

编译合约

npx hardhat compile

通过编译合约文件,我们会得到 Bytecode(字节码)和 ABI( Application Binary Interface)通过 ABI 可与已经部署的合约进行交互。

image.png

创建部署文件

// ignition/modules/TodoContract.ts

import { buildModule } from '@nomicfoundation/hardhat-ignition/modules'

const TodoContractModule = buildModule('TodoContractModule', (m) => {
  const todoContract = m.contract('TodoContract')

  return { todoContract }
})

export default TodoContractModule

部署合约命令

npx hardhat ignition deploy ./ignition/modules/TodoContract.ts --network ganache

image.png

在线测试合约

在线打开Remix IDE 以太坊智能合约开发工具,创建合约文件TodoContract.sol,将写好的合约代码复制粘贴进来,保存后会自动编译。

image.png

image.png

image.png

前端开发

引入 antd

yarn add antd

修改 src/App.tsx,引入 antd 的按钮组件。

import './App.css'
import { Button } from 'antd'

function App() {
  return (
    <div className="App">
      <Button type="primary">查询余额</Button>
    </div>
  )
}

export default App

好了,现在你应该能看到页面上已经有了 antd 的蓝色按钮组件,接下来就可以继续选用其他组件开发应用了。

我们现在已经把 antd 组件成功运行起来了,开始开发你的应用吧!

多钱包登录

RainbowKit是连接钱包的最佳方式 🌈,大大节省连接钱包的开发时间,可定制。

直接引入ConnectButton组件,主要是负责渲染连接/断开连接钱包按钮,以及切换链的界面。

// src/components/Header.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit'

function Header() {
  return (
    <div className="header">
      <div className="wallet">
        <ConnectButton
          accountStatus={{
            smallScreen: 'avatar',
            largeScreen: 'full',
          }}
        />
      </div>
    </div>
  )
}

export default Header

查询余额(合约账户余额)

// src/components/Todo.tsx
import { ReactNode, useCallback, useEffect, useState } from 'react'
import { parseEther, formatEther } from 'viem'
import { readContract } from '@wagmi/core'
import contractABI from '../artifacts/contracts/TodoContract.sol/TodoContract.json'

function Todo() {
    const contractAddress = import.meta.env.VITE_CONTRACT_ADDRESS

    // 读取合约账户余额
    const getBalance = useCallback(async () => {
        try {
          const result = await readContract(config, {
            address: contractAddress, // 合约地址
            abi: contractABI.abi, // ABI文件
            functionName: 'getBalance',
          })
          console.log('读取合约账户余额', result)
          const amount = result?.toString() ? formatEther(result as bigint) || '0.0' : '0.0'
          setBalance(amount)
        } catch (error) {
          console.error('error', error)
        }
    }, [contractAddress])
}

export default Todo

我要提款

import { message } from 'antd'
import { writeContract, waitForTransactionReceipt } from '@wagmi/core'
import contractABI from '../artifacts/contracts/TodoContract.sol/TodoContract.json'

function Todo() {
  const { address, isConnected, chainId } = useAccount()
  const [isWithdraw, setWithdraw] = useState<boolean>(false)
  const [messageApi, contextHolder] = message.useMessage()

  // 我要提款
  const getWithdraw = async () => {
    setWithdraw(true)
    try {
      const result = await writeContract(config, {
        abi: contractABI.abi,
        address: contractAddress,
        functionName: 'withdraw',
      })
      console.log("发起提款", result)
      const txReceipt = await waitForTransactionReceipt(config, { hash: result })
      console.log("等待提款", txReceipt)
      if (txReceipt.status === 'success') {
        messageApi.open({
          type: 'success',
          duration: 4,
          content: '提款成功',
        })
      }
      setWithdraw(false)
    } catch (error) {
      setWithdraw(false)
      console.error('error', error)
      messageApi.open({
        type: 'error',
        duration: 4,
        content: `提款失败:${(error as BaseError)?.details}`,
      })
    }
  }

  return (
      <>
      {contextHolder}
      <Button type="primary" onClick={getWithdraw} loading={isWithdraw} disabled={!isConnected}>我要提款</Button>
      </>
  )
}

export default Todo

发布消息

import { type BaseError, useBalance, useWaitForTransactionReceipt, useWriteContract, useAccount } from 'wagmi'
import { parseEther, formatEther } from 'viem'
import contractABI from '../artifacts/contracts/TodoContract.sol/TodoContract.json'
import { message } from 'antd'

interface DataType {
  id: string;
  key: string;
  author: string;
  message: number;
  timestamp: string;
}

const columns: TableProps<DataType>['columns'] = [
  {
    title: '编号',
    dataIndex: 'id',
    key: 'id',
    render: (text) => <span>{text.toString()}</span>,
  },
  {
    title: '接收者',
    dataIndex: 'author',
    key: 'author',
  },
  {
    title: '消息',
    dataIndex: 'message',
    key: 'message',
  },
  {
    title: '时间戳',
    dataIndex: 'timestamp',
    key: 'timestamp',
    render: (text) => (<span>{timestampToDate(Number(text))}</span>),
  },
];

function Todo() {
  const [msg, setMsg] = useState<string>('')
  const { address, isConnected, chainId } = useAccount()
  const [messageApi, contextHolder] = message.useMessage()
  const contractAddress = import.meta.env.VITE_CONTRACT_ADDRESS

  const { data: hash, error, isPending, writeContractAsync } = useWriteContract()
  const { isLoading: isConfirming, isSuccess: isConfirmed } =
    useWaitForTransactionReceipt({
      hash,
    })
  // 发布消息
  const publishMsg = async () => {
    setIsLoading(true)
    try {
      const result = await writeContractAsync({
        abi: contractABI.abi,
        address: contractAddress,
        functionName: 'published',
        args: [msg.trim()],
        value: parseEther('0.1'),
      })
      setMsg('')
      console.log("发布消息", result)
      messageApi.open({
        type: 'success',
        content: '发布消息成功',
      })
    } catch (error) {
      console.error('error', error)
      messageApi.open({
        type: 'error',
        duration: 4,
        content: `发布消息失败:${(error as BaseError)?.details}`,
      })
    }
  }

  // 监听消息发布
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setMsg(e.target.value)
  }

  return (
      <>
        {contextHolder}
        <div className="item">
        <Input value={msg} count={{
          show: true,
          max: 50,
        }} maxLength={50} placeholder="请输入消息内容" onInput={handleChange}></Input>
        <Button type='primary' loading={isLoading && isPending} onClick={publishMsg} disabled={!isConnected || !msg}>{isPending ? '确认中...' : '发布消息'}</Button>
      </div>
      </>
  )
}

export default Todo

消息列表

// src/components/Todo.tsx
import { readContract } from '@wagmi/core'
import { config } from '../config'
import contractABI from '../artifacts/contracts/TodoContract.sol/TodoContract.json'
import { message } from 'antd'

function Todo() {
    const { address, isConnected, chainId } = useAccount()
    const [messageApi, contextHolder] = message.useMessage()

    const contractAddress = import.meta.env.VITE_CONTRACT_ADDRESS

    // 读取消息列表
    const getTodoList = useCallback(async () => {
        setLoading(true)
        setTodoList([])
        try {
          const result = await readContract(config, {
            address: contractAddress,
            abi: contractABI.abi,
            functionName: 'getTodoList',
          })
          console.log('读取消息列表', result)
          const arr = (result as DataType[])
          if (arr && arr.length) {
            const dataSource = arr.map((item: DataType) => {
              return {
                key: item.id.toString(),
                id: item.id.toString(),
                author: item.author,
                message: item.message,
                timestamp: item.timestamp.toString(),
              }
            }).sort((a, b) => Number(b.timestamp) - Number(a.timestamp))

            setTodoList(dataSource)
          }
          setLoading(false)
        } catch (error) {
          setLoading(false)
          console.error('error', error)
          messageApi.open({
            type: 'error',
            duration: 4,
            content: `读取消息列表失败:${(error as BaseError)?.details || (error as BaseError)?.shortMessage}`,
          })
        }
    }, [contractAddress, messageApi])

    return (
        <>
        {contextHolder}
        <Table loading={loading} columns={columns} dataSource={isConnected && chainId === 1337 ? todoList : []} pagination={{ pageSize: 6, showTotal }} />
        </>
    )
}

export default Todo

结语

DApp 开发是一项综合性的工作,需要掌握多个领域的知识。通过学习不同平台和工具的文档、参与实际项目,以及不断改进和总结经验,你可以逐步成为一名优秀的 DApp 开发者。后续会输出一系列完整的 DApp 实战项目开发练习教程,涉及到技术栈有 React、Next.js、Wagmi、Vue3、Nuxt.js 等等。如果此文对看官们有一丢丢帮助,请点个赞👍或分享支持一下。

github 仓库:https://github.com/jackchen0120/web3-dapp-all-example

参考资料