How to Build an Ethereum DApp from Scratch using React.
作者:禅与计算机程序设计艺术
1.简介
在本文中, 我旨在指导您搭建一个基于ReactJS的Ethereum DApp. 您将能够从头开始学习如何编写ReactJS代码, 并开发属于自己的去中心化应用(DApp)。
如果对于React或其他任何开发技术尚不熟悉,并且对Solidity、Web3.js、Truffle等技术细节存有疑问,请不要感到焦虑!本文详尽地阐述了整个流程,在此您可以按照步骤逐一实现目标。
2.准备工作
首先,您需要安装以下工具:
如果您想掌握前端开发的基础知识,请学习包括但不限于HTML、CSS和JavaScript等技术。然而,在本文中将不涵盖这些知识点。但对于那些希望深入学习的朋友,则建议参考相关的高级教程或资源。
3.项目结构
├── app
│ ├── src
│ │ └── components
│ │ ├── App.tsx # 根组件
│ │ ├── Button.tsx # 按钮组件
│ │ ├── Card.tsx # 卡片组件
│ │ └── Input.tsx # 输入框组件
│ ├── index.css # 全局样式文件
│ ├── index.html # HTML模板文件
│ └── index.tsx # 入口文件
└── test # 测试用例目录
└── contracts # Solidity合约源文件目录
├── SimpleStorage.sol # 简单的存储合约
代码解读
我们将会创建一个React项目,这个项目主要由三个部分组成:
src目录:主要用于存储所有React组件的源代码文件;index.*文件:主要用于存储静态页面模板、全局样式表以及项目的入口文件;test目录:用于存储测试用例,并后续将阐述如何编写测试用例。
4.项目环境配置
接下来,我们将设置项目的开发环境。
创建项目
首先,我们需要创建一个空目录,然后进入该目录执行如下命令:
npm init -y
touch README.md package.json tsconfig.json.gitignore
mkdir app && cd app
mkdir src && touch src/.gitkeep
echo "module.exports = { \"compilerOptions\": { \"esModuleInterop\": true } }" > jsconfig.json
代码解读
上述命令执行后,会生成如下文件:
-
package.json: 作为项目的包清单存在;.gitignore: 列出了应被排除于Git版本控制之外的文件夹或目录。- README.md: 项目详细技术说明文档。
- tsconfig.json: 存储TypeScript编译所需的配置信息。
- jsconfig.json: JavaScript项目的编译设置存放于此。
- app/: 应用项目的源码管理位于此目录内。
- src/: 源码的主要开发部分存放在该目录中。
- .gitkeep: 防止Git误判空目录的一个占位符文件。
-
在索引位置设置的[HTML|CSS]标记表示网页模板和统一样式文档的位置;
-
指定为项目入口点的[.tsx]标记位于项目根目录中,并指示用于构建和运行React应用程序的位置。
安装依赖
为了使项目正常运行,我们需要安装一些必要的依赖包:
npm install --save react react-dom @types/react @types/react-dom typescript webpack webpack-cli html-webpack-plugin css-loader style-loader sass-loader node-sass @babel/core babel-loader @babel/preset-env eslint eslint-plugin-react @typescript-eslint/parser @typescript-eslint/eslint-plugin
代码解读
上述命令会安装以下依赖包:
react和react-dom: 负责构建用户界面组件。@types/react和@types/react-dom: 包含React相关的类型定义文件。typescript: 提供静态类型编译器。webpack和webpack-cli: 提供打包引擎用于构建Web应用。html-webpack-plugin: 提供功能用于生成用于构建Web应用的HTML文件插件。css-loader/style-loader: 提供功能用于处理CSS文件。sass-loader/node-sass: 支持SCSS格式的样式表文件导入与解析。@babel/core和babel-loader: 提供功能作为JavaScript转ES5兼容模式工具。@babel/preset-env: 提供特定规则集作为JavaScript转ES5迁移方案指南。eslint和eslint-plugin-react: 提供功能用于执行代码格式化检查以确保代码可读性。@typescript-eslint/parser和@typescript-eslint/eslint-plugin: 提供功能用于执行代码风格检查以确保项目遵循统一编码规范。
初始化项目
接下来,我们初始化项目,新建一下配置文件:
npx truffle unbox metacoin
mv contracts/*./src/contracts
rm -rf build contracts migrations
sed's/SimpleStorage/MyEthereumDApp/' -i $(grep -rl SimpleStorage.)
代码解读
上述命令执行后,会自动完成以下几件事情:
- 转移该Solidity代码至指定存储位置。
- 移除不必要的文件。
- 在所有引用中更换相关变量名。
配置webpack
在打包项目时建议采用Webpack,并在其中修改webpack.config.js文件如下所示
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/index.tsx', // 入口文件
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'), // 输出路径
},
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
module: {
rules: [
{
test: /\.(j|t)sx?$/,
exclude: /node_modules/,
use: ['babel-loader'],
},
{
test: /\.scss$/,
use: ['style-loader', 'css-loader','sass-loader']
},
],
},
plugins: [new HtmlWebpackPlugin({ template: './public/index.html' })],
devServer: {
contentBase: './dist', // 静态文件目录
open: true, // 启动浏览器
hot: true, // 启用热更新
},
};
代码解读
该配置决定了项目入口文件的位置,并将打包后的输出目录一并指定。借助babelClassLoader解析jsx代码,并分别利用styleClassLoader、cssClassLoader和sassClassLoader导入scss样式文件。
设置Babel
为了实现对最新JavaScript特性的支持,在开发过程中需要借助Babel技术将当前项目中的ES6代码转换为兼容的ES5代码。编辑.babelrc文件如下:
{
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": []
}
代码解读
上述配置指定了ES6->ES5的转换规则和插件。
设置ESLint
"extends": [
"schema": "https://eslint.org/schemas/v6.0.0/east-gcc Kraken.json",
"ignore": ["**/*.ts"], // 不显示类型脚本文件
"rules": {
"indent": {
"functionName": "mustBeAtLeast",
"minimumValue": 2,
"maximumValue": 8
},
// 添加其他相关规则
}
]
{
"env": {
"browser": true,
"commonjs": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"globals": {},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"]
}
代码解读
该参数设置决定了项目运行环境、推荐规则集、解析器设置以及插件组件。
添加脚本命令
为了让大家更为简便地执行各种任务, 我们将向项目中添加一些脚本命令至.... 编辑完成后, 文件内容如上所示.
{
"name": "my-ethereum-dapp",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack serve",
"build": "webpack --mode=production",
"lint": "eslint src/**/*.{ts,tsx}"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.5",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.19",
"@types/react": "^16.9.53",
"@types/react-dom": "^16.9.8",
"babel-loader": "^8.2.2",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^5.0.2",
"eslint": "^7.17.0",
"eslint-plugin-react": "^7.22.0",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^6.1.0",
"html-webpack-plugin": "^5.0.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"mini-css-extract-plugin": "^1.3.6",
"node-sass": "^5.0.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-loader": "^5.0.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"rimraf": "^3.0.2",
"sass-loader": "^10.1.0",
"style-loader": "^2.0.0",
"terser-webpack-plugin": "^5.0.3",
"ts-jest": "^26.4.4",
"ts-loader": "^8.0.11",
"typescript": "^4.1.3",
"url-loader": "^4.1.1",
"webpack": "^5.11.0",
"webpack-cli": "^4.2.0",
"webpack-dev-server": "^3.11.2"
},
"dependencies": {}
}
代码解读
上述配置增加了两个新的命令:
start: 启动开发服务器,实时刷新浏览器;build: 打包生产环境的代码。
5.编写React组件
现在,我们已经设置好了开发环境,接下来就可以编写React组件了。
编写Card组件
我们先编写一个简单的卡片组件,显示当前账户的地址和余额。
import React, { useState, useEffect } from'react';
import Web3 from 'web3';
interface Props {
account?: string;
}
function Card(props: Props) {
const web3 = new Web3();
return (
<div className="card">
<p>Address:</p>
<code>{props.account}</code>
<br />
<p>Balance:</p>
<code>{web3.utils.fromWei(String(props.balance), 'ether')} ETH</code>
</div>
);
}
export default Card;
代码解读
在这里, 我们通过调用Web3.js的方法获取当前账户的地址和余额. 该组件定义了一个名为Props的接口, 传入的对象必须包含account属性. 利用JavaScript Web APIs自动生成对应的交易信息, 并将其绑定到组件变量上.
编写Button组件
我们再编写一个按钮组件,用来连接钱包和发布合约。
import React, { useState, useEffect } from'react';
import Web3 from 'web3';
interface Props {
onClick: () => void;
}
function Button(props: Props) {
const [isConnected, setIsConnected] = useState<boolean>(false);
const web3 = new Web3(window.ethereum);
useEffect(() => {
async function connect() {
if (!window.ethereum) {
alert('Metamask is not installed!');
return;
}
try {
await window.ethereum.request({ method: 'eth_requestAccounts' });
setIsConnected(true);
} catch (error) {
console.log(error);
}
}
connect();
}, []);
return (
<button disabled={!isConnected || props.onClick === undefined} onClick={() => props.onClick?.()}>
Connect Wallet and Publish Contract
</button>
);
}
export default Button;
代码解读
在这里, 我们创建了一个名为Props的接口, 其中包含了 onClick 这个回调函数. 当用户点击按钮时, 我们会通过调用 Web3() 函数来构造一个 Web3 对象, 从而实现与钱包的连接. 为了记录当前是否已连接钱包, 我们定义了一个 useState 钩子. 在 useEffect 钩子中, 我们会尝试连接钱包. 如果连接成功的话, 则会将 isConnected 状态设置为 true; 如果出现错误则会打印错误日志. 最后我们会返回一个按钮元素, 并根据 isConnected 和 onClick 属性来判断按钮是否被禁用. 当按钮被点击时, 如果对应的 onClick 回叫函数存在, 则该回叫函数将会被执行.
编写Input组件
我们再编写一个输入框组件,用来保存合约发布者的名称。
import React, { useState } from'react';
interface Props {
value?: string;
onChange: (event: React.FormEvent<HTMLInputElement>) => void;
}
function Input(props: Props) {
return (
<input type="text" value={props.value} placeholder="Enter a name for your contract" onChange={props.onChange} />
);
}
export default Input;
代码解读
在这里的基础上,我们创建了一个名为Props的接口类型。该接口包含了数值属性以及一个响应文本修改事件的回调函数。它将被用来绑定到某个文本编辑区域,并在编辑发生时触发特定的行为。该组件生成一个带有placeholder字段的输入区域,并将通过props onChange机制来处理编辑后的反馈信息。
编写ContractInfo组件
我们再编写一个展示合约信息的组件。
import React, { useState, useEffect } from'react';
import Web3 from 'web3';
interface Props {
address: string | null;
}
function ContractInfo(props: Props) {
const [contractName, setContractName] = useState('');
const web3 = new Web3();
useEffect(() => {
if (!props.address) {
return;
}
const getContractName = async () => {
try {
const provider = new web3.providers.WebsocketProvider('ws://localhost:8545');
const instance = new web3.eth.Contract(
JSON.parse(
'{"abi":[{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"string","name":"","type":"string"}],"name":"SetMessage","type":"event"}]}'
),
props.address,
{
from: (await provider.listAccounts())[0],
gasPrice: web3.utils.toHex((await provider.getGasPrice()) * 1.5),
}
);
const name = await instance.methods.getMessage().call();
setContractName(name as any);
} catch (error) {
console.log(error);
}
};
getContractName();
}, [props]);
return (
<div className="card">
<h2>Contract Info</h2>
<p><strong>Address:</strong></p>
<code>{props.address}</code>
<br />
<p><strong>Name:</strong></p>
<code>{contractName}</code>
</div>
);
}
export default ContractInfo;
代码解读
在这里, 我们创建了一个名为Props的接口, 其中包含有一个address属性, 该属性指定与要展示信息相关联的合约地址. 我们注册了两个useState hook, 分别用于保存合约名称以及当前账户的余额. 在异步操作中, 我们加载了合约ABI数据并构建了完整的合约对象. 接下来, 我们调用钩子函数完成操作, 获取存储于链上字符串并将其保存到contractName状态. 最后, 我们渲染一个Card组件以显示该合约地址及其名称.
