如何在以太坊区块链上使用 React 和 Solidity 创建 dApp 恭喜,您的第一个 dApp 已准备就绪

2025-05-27

如何在以太坊区块链上使用 React 和 Solidity 创建 dApp

恭喜,你的第一个 dApp 已经准备好了

在本教程中,我将向您展示如何构建一个可在以太坊和许多 Layer 2 平台(Polygon、Celo 等)上运行的全栈 dApp。
我们将从后端部分开始,借助 Hardhat 和 Solidity 编写智能合约。
之后,我们将使用 React 和 Ethers.js 构建前端,以便与智能合约进行交互。我们还将借助 Pinata API 使用 IPFS。

🎬视频版:https://youtu.be/1dWxCE_RfaE

图片描述
通过NewDevsOnTheBlock与其他 web3 开发者建立联系,提升您的 web3 职业生涯

什么是 dApp?

dApp 代表去中心化应用。传统意义上,应用运行在一台服务器上(后端可能在另一台服务器上)。在去中心化的世界里,前端部分将由 IPFS 提供(由分布式网络中的节点提供文件服务),而后端则运行在去中心化网络节点上的智能合约或程序上。

Startup Grind Gib (@StartupGrindGib) / Twitter

你必须那么高才能骑

我知道大家都对 Web3 很感兴趣,这真的很棒!但 Web3 是 Web2 的扩展,所以在继续学习本教程之前,请确保你了解 Web 开发的基础知识。

我们正在使用的工具

现在我们已经了解了 dApp 的大致含义,下面是我们将要使用的工具来构建 dApp。

前端部分:
React
Ether.js(用于与智能合约通信)

后端部分:
Solidity
Hardhat(轻松编写、测试和部署 Solidity 代码的环境)

入门模板

我将在本教程中使用此入门模板,您可以在此处获取:https://github.com/XamHans/React-Solidity-Typescript-Starter 完成的项目可以在此处找到:https://github.com/XamHans/image-contest
在此处输入图片描述


我们将构建什么🔫

我们将开发一个投票应用。用户可以上传图片并进行投票。


后端部分

让我们看看为了实现这个 dApp 的目标,后端需要做什么。我们需要一种方法来


  • 创建一个候选人(候选人就是上传了图像的用户)

  • 获取所有候选人及其图像

  • 如果用户喜欢某位
    候选人的图片,则增加该候选人的投票数

前往 /backend/contracts/ExmapleContract.sol
删除其中的样板/示例代码,并将文件合同重命名为 VoteManager。

定义候选人的结构

我们将使用结构体(类似于类但没有任何实现逻辑)来定义候选人的属性。

struct Candidate {
uint id;
uint totalVote;
string name;
string imageHash;
address candidateAddress;
}

  • totalVote 跟踪当前候选人的投票情况

  • imageHash 将存储图像的 IPFS 哈希值

  • candidatesAddress 是候选人的公钥地址

让我们从一些逻辑开始,创建一个候选人

mapping(address => Candidate) private candidates;
mapping(uint=> address) private accounts;

function registerCandidate(string calldata _name, string calldata _imageHash) external {
    require(msg.sender != address(0), "Sender address must be valid"); 
    candidatesIds.increment();
    uint candidateId = candidatesIds.current();
    address _address = address(msg.sender);
    Candidate memory newCandidate = Candidate(candidateId, 0, _name, _imageHash, _address);  
    candidates[_address] = newCandidate;  
    accounts[candidateId] = msg.sender;
    emit candidateCreated(_address, _name);
    }
Enter fullscreen mode Exit fullscreen mode

registerCandidate是一个外部函数,这意味着该函数只能从合约外部调用。你也可以将其标记为公共函数,但这会降低 Gas 效率。

该函数接受两个参数:名称和来自候选节点的镜像(ipfs hash)。这两个参数来自内存类型的 calldata。

calldata
是存储函数参数的不可修改、非持久区域

我们使用require(msg.sender != address(0));来检查函数的调用者是否真的存在。

Require 的作用类似于提前退出,它会检查括号内的条件。如果条件为假,函数就会停止并返回错误消息。

在接下来的两行中,我们使用 openzeppelin 计数器来管理我们的 ID。使用candidatesIds.increment();将值加 1,并使用candidatesIds.current();获取当前值。为了使用调用者的地址,我们需要在使用前对其进行“解析”,这可以通过address(msg.sender)轻松实现。

OpenZeppelin Contracts 通过使用经过实战检验的以太坊和其他区块链智能合约库帮助您最大限度地降低风险,了解更多信息:https://openzeppelin.com/contracts/

现在,我们可以通过传递所有必要的参数来创建一个新的 Candidate 。

memory newCandidate = Candidate(candidateId, 0, _name, _imageHash, _address);

请注意“newCandidate”前面的memory关键字。在 Solidity 中,如果要创建新对象,必须明确设置存储类型。memory 类型的存储会在函数执行期间一直有效,如果需要永久存储,请使用存储类型。

candidates[_address] = newCandidate;  
Enter fullscreen mode Exit fullscreen mode

这里我们在候选映射中创建了一个新的键->值赋值。键是调用者(候选对象)的地址,值是新创建的候选对象。我们使用此映射来组织候选对象,由于它是一个状态变量,因此此映射将永久存储在区块链上。

  • 状态变量- 其值永久存储在合同存储中的变量。

  • 局部变量- 函数执行时其值一直存在的变量。

accounts[candidateId] = msg.sender;
Enter fullscreen mode Exit fullscreen mode

同样的玩法,但以候选 ID 作为键,调用者地址作为值。你可能会问我们到底为什么需要这种映射,但请耐心等待,很快就会明白的 :)

现在让我们实现投票功能

function vote(address _forCandidate) external {
    candidates[_forCandidate].totalVote += 1;
    emit Voted(_forCandidate, msg.sender, candidates[_forCandidate].totalVote);
}
Enter fullscreen mode Exit fullscreen mode

投票功能非常简单。我们传入将要收到选票的候选人的地址。

candidates[_forCandidate].totalVote += 1;
Enter fullscreen mode Exit fullscreen mode

在候选人映射中,我们使用地址作为键来获取候选人对象,并将总投票数加一。
之后,我们将发出一个事件

 emit Voted(_forCandidate, candidates[_forCandidate].totalVote);
Enter fullscreen mode Exit fullscreen mode

该事件将作为响应。它包含我们将在前端用来更新 UI 的信息。

最后一个函数,获取所有候选人

function fetchCandidates() external  view  returns ( Candidate[] memory) {
 uint itemCount = candidatesIds.current();
 Candidate[] memory candidatesArray = new Candidate[](itemCount);
    for (uint i = 0; i < itemCount; i++) {
        uint currentId = i + 1;
        Candidate memory currentCandidate = candidates[accounts[currentId]];
        candidatesArray[i] = currentCandidate;
    }
    return candidatesArray;
 }
Enter fullscreen mode Exit fullscreen mode

也许你看到这段代码会问,嘿嘿,为什么不直接返回映射关系呢?我之前也想过这个问题,后来谷歌了一下,发现不行。所以我们需要一个辅助数组来存储候选对象。我们用下面的代码获取当前 id(一个简单的数字):

candidatesIds.current();
Enter fullscreen mode Exit fullscreen mode

好的,现在我们知道了迭代的最大值,并将其存储在名为itemCount的变量中,我们还使用这个变量来创建辅助数组candidatesArray。在这里我们将使用辅助映射accounts

accounts
| 0 | 0x1234.. |
|--|--|
| 1 | 0x8521.. |

候选人
| 0x1234.. | {...} |
|--|--|
| 0x8521.. | {...} |

否则,我们将无法迭代候选人,因为我们不知道要迭代的键(候选人的地址)。我们可以使用 ID 作为候选人映射的键,但投票函数会更加复杂。

哇,上一节里有好多“woulds”。
稍事休息一下,我们继续部署智能合约。


部署部分

1)启动本地测试网
首先,我们需要启动本地以太坊区块链。使用模板启动器,您可以简单地使用
npm run testnet或使用npx hardhat node
2)编译合约
在部署合约之前,我们需要先编译它。打开一个新的终端并写入
npm run compilenpx hardhat compile
这也将创建 ABI。ABI 对于其他程序(如我们的前端)与合约通信至关重要。它定义了可以使用相应参数调用哪些函数。
3)部署合约
首先转到部署脚本(backend/scripts/deploy.ts)并确保ethers.getContractFactory抓取正确的合约最后使用npm run deploynpx hardhat run --network localhost scripts/deploy.ts 将 votemanager 合约部署到本地测试网 复制已部署合约的地址,我们之后会需要它。
在此处输入图片描述



在此处输入图片描述

将 MetaMask 连接到本地测试网络

如果您已经启动了本地测试网络,您将看到如下输出: 复制其中一个私钥并转到 MetaMask --> 单击个人资料图片 --> 导入帐户 将私钥粘贴到输入字段并确保已设置本地网络。
在此处输入图片描述


在此处输入图片描述


前端部分

前往 frontend/App.tsx 并创建这些状态变量

const [contract, setContract] = useState()
const [selectedImage, setSelectedImage] = useState()
const [candidates, setCandidates] = useState<>([])
const [candidateFormData, setCandidateFormData] = useState({ name: '', imageHash: '' })
const contractAddress = "0xf899d9772a6BB9b251865ed33dc2CC733Ab4Bd65"
Enter fullscreen mode Exit fullscreen mode

将复制的地址粘贴到 contractAddress 变量中。
在此处输入图片描述

现在复制这个 useEffect 并将其粘贴到变量部分下方。

useEffect(() => {
    setContract(getContract(contractAddress))
}, [])
Enter fullscreen mode Exit fullscreen mode

在这个 useEffect 中,我们借助辅助函数getContract来分配合约变量。该函数返回智能合约的合约抽象,我们可以使用它来与智能合约进行交互。让我们看看它是如何实现的。

import { Contract, ethers } from  "ethers";
import VoteManagerContract from  '../../../../backend/artifacts/contracts/VoteManager.sol/VoteManager.json'

export  default  function getContract(contractAddress: string): Contract {
    const provider = new ethers.providers.Web3Provider( (window as any).ethereum);
    const signer = provider.getSigner();

    const contract = new ethers.Contract(
    contractAddress,
    VoteManagerContract.abi,
    signer
    );

    return contract;
}
Enter fullscreen mode Exit fullscreen mode

Ethers.js 词汇
首先,我们需要创建一个以太坊提供商。提供商是连接到区块链(在本例中是以太坊)的抽象。MetaMask 使用 向网站注入了一个全局 API window.ethereum。此 API 允许网站请求用户的以太坊账户,读取用户所连接的区块链的数据等等。Ethers.js 将此 API 包装在其提供商 API 中。我知道你在想什么……

我们从提供商处获取签名者,然后就可以创建合约了(请参阅上图了解 Ethers.js 术语)。
为了创建合约,我们需要将 ABI 作为第二个参数传入。ABI 是一个 JSON 文件,定义了智能合约的功能及其调用方式(即每个函数的参数)。由于入门模板是一个 monorepo,我们可以轻松地从 artifacts/contracts 目录导入 VoteManager ABI。就这样,我们的合约抽象就创建好了,我们将其返回到 App.tsx 文件,用于调用合约。

创建候选人

我们需要一个表单,包含候选人姓名和照片的输入框。前端部分
我使用了mui ,但您可以根据自己的需求进行修改。

<Container  maxWidth="md" sx={ marginY: "2rem" }>
    <Box component="form">
        <Stack  direction="row"  alignItems="center"  spacing={2}  mb={4}>
            <TextField  id="filled-basic"       
            label="Name"  variant="filled"
                name="name"
                value={candidateFormData.name}   
                onChange={handleChange}  />
                <label  htmlFor="contained-button-file">
                <input type="file" accept="image/*" onChange={(e) => setSelectedImage(e.target?.files[0])} />
            </label>

            <Button  variant="contained"  component="span" 
             onClick={() => registerCandidate()}>    
                Register as Candidate  
            </Button>
        </Stack>
    </Box>
</Container>
Enter fullscreen mode Exit fullscreen mode

在此处输入图片描述
这里没什么特别的,让我们直接注册到候选逻辑,奇迹就会发生

async function registerCandidate() {
    // get the name from formdata
    const name = candidateFormData.name; 

    // getting the IPFS Image Hash from the Pinata API Service
    const ipfsImageHash = await IPFSUploadHandler() 

   // call the VoteManager registerCandidate Contract Function
    contract.registerCandidate(name, ipfsImageHash); 

    // response from the contract / the candidateCreated Event
    contract.on("candidateCreated", async  function (evt) {
        getAllCandidates()
    })
}
Enter fullscreen mode Exit fullscreen mode

首先,我们获取第一个输入的名称。其次,我们使用图片调用Pinata IPFS API来获取该图片的 IPFS 图像哈希值。

请查看 Github 仓库https://github.com/XamHans/image-contest中的 services 文件夹,了解更多关于 IPFSUploadHandler 和 Pinata API 函数调用的信息。
如果您需要更多关于 IPFS 的信息,请查看我关于 IPFS 的幻灯片https://drive.google.com/drive/folders/11qKP4BydqOytD5ZCn7W9pMSi1XiU5hj7?usp=sharing 。

然后,我们将使用合约变量(已在 useEffect 中使用辅助函数设置)来调用registerCandidate函数。
使用on,我们订阅由合约触发的事件。

发出候选创建(_address,_name)

contract.on("candidateCreated", async  function (event) {
            getAllCandidates()
        })
Enter fullscreen mode Exit fullscreen mode

第一个参数是事件的名称,第二个参数是处理函数。如果我们收到该事件,我们将调用getAllCAndidates()函数来获取所有候选对象,包括我们刚刚创建的最新候选对象 :)

获取所有候选人

async  function getAllCandidates() {  
    const retrievedCandidates = await contract.fetchCandidates();
    const tempArray = [] 

    retrievedCandidates.forEach(candidate => {
        tempArray.push({    
        id: candidate.id, 
        name: candidate.name, 
        totalVote: candidate.totalVote, 
        imageHash: candidate.imageHash,
        candidateAddress: candidate.candidateAddress 
        })
    }) 
    setCandidates(tempArray)
}
Enter fullscreen mode Exit fullscreen mode

非常简单,我们从合约中调用fetchCandidates函数,响应如下所示: 我们看到获取的属性值加倍了,我不知道为什么。如果您知道原因,请告诉我! 我们创建一个临时数组,遍历响应,并用候选对象填充临时数组。最后,我们将 tempArray 赋值给候选状态变量。 让我们显示候选人及其图像,因此将其粘贴到注册候选人部分的下方。
getAllCandidates 函数的响应


 {candidates.length > 0 && (<Container  sx={ bgcolor: "#F0F3F7" }>
    <Box  sx={ flexGrow: 1, paddingY: "3rem", paddingX: "2rem" }}>
        <Grid  container  spacing={ xs: 2, md: 3 }  columns={ xs: 4, sm: 8, md: 12 }>  
        { 
        candidates.map((candidate, index) =>
            <Grid  item  sm={4}  key={index}>
               <Card>
                    <CardMedia  component="img"  image={candidate.imageHash     alt="candidate image"     />
                    <CardContent>
                        <Typography  gutterBottom  component="div">
                            Total votes: {(candidate.totalVote as BigNumber).toNumber()} 
                        </Typography>
                        <Typography  variant="body2"  color="text.secondary">
                            {candidate.name}
                        </Typography> 
                        <Typography  variant="body2"  color="text.secondary">  
                            {candidate.candidateAddress}
                        </Typography>
                    </CardContent>
                    <CardActions  disableSpacing  sx={paddingTop: "0"}>
                        <IconButton  aria-label="like picture"  sx={bgcolor: 'info.contrastText', color: 'info.main'}
                        onClick={() => vote(candidate.candidateAddress)}>
                        <FavoriteIcon/>
                        </IconButton>
                    </CardActions>
                    </Card>
            </Grid>)
            }
        </Grid>
    </Box>
    )}
Enter fullscreen mode Exit fullscreen mode

考生卡

快完成了!还差投票功能。

function vote(address: string) {
    if (!address) {   
        throw Error("no address defined")
    }
    contract.vote(address);
    contract.on("Voted", function (event) {
        getAllCandidates()
    })
}
Enter fullscreen mode Exit fullscreen mode

这个很简单。在我们对候选人的迭代中,我们有一个“赞”按钮:

onClick={() => vote(candidate.candidateAddress)}>
Enter fullscreen mode Exit fullscreen mode

因此,我们将候选人的地址传递给此函数,然后检查该地址是否为空。之后,我们用候选人的地址调用合约的vote()
函数。 如果投票完成,我们将监听“Voted”事件,然后为了简单起见,我们再次获取所有候选人以显示更新值。

这种方式注册事件处理程序更简洁,因为它只
在合同发生变化时发生,而不是每次函数调用时发生

useEffect(() => {
  if (contract) {
  contract.on("Voted", async  function () {
      getAllCandidates()
  })
  contract.on("candidateCreated", async  function () {
  getAllCandidates()
  })
}}, [contract])

恭喜,你的第一个 dApp 已经准备好了

你做到了,你感受到力量了吗?


我们在本教程中涵盖了很多主题。
你现在知道

  • solidity 的内存类型 calldata、memory 和 storage
  • Openzeppelin 是什么以及如何导入其合约
  • 使用require作为早期退出标准,以提高代码和 gas 效率
  • 如何借助 Pinata 服务将图像存储在 IPFS 上。
  • 您可以从智能合约发送事件作为对前端的一种响应。
  • ABI 定义了你的智能合约,并且你可以使用 ethers.js 与以太坊区块链进行交互

感谢阅读

如果您喜欢此类内容或有任何疑问(我不是专家),请在 twitter 或 linkedin 上联系我们
https://twitter.com/XamHans |
https://www.linkedin.com/in/johannes-m%C3%BCller-6b8ba1198/

聚苯乙烯

你想在 Web3 领域工作吗?
快来看看我的最新项目 ➡️ https://www.newdevsontheblock.com/

文章来源:https://dev.to/hayerhans/how-to-create-a-dapp-with-react-solidity-on-ethereum-blockchain-1gg0
PREV
拒绝 Tailwind,拥抱纯 CSS
NEXT
34 个适用于 React、Vue 和 Angular 的 UI 库 React 的 UI 库 Vue 的 UI 库 Angular 的 UI 库 其他 UI