Web3 全栈开发指南

使用Next.js、Polygon、Solidity、The Graph、IPFS和Hardhat构建全栈Web3应用。

使用Next.js、Polygon、Solidity、The Graph、IPFS和Hardhat构建全栈Web3应用,本教程有相应视频,在这里

在这个完整深入的web3教程中,将学习构建全栈web3应用程序使用到的工具、协议和框架,最重要的是 -- 如何把所有内容整合在一起,为将来构建自己的想法打下基础。

教程的应用程序的代码库位于这里

我们要部署的主要网络是Polygon。我选择Polygon是因为它的交易成本低,区块时间快,以及也是目前广泛采用的网络。

也就是说,我们将在以太坊虚拟机(EVM)上进行构建,所以你也可以应用这些技能为其他几十个区块链网络进行构建,包括以太坊、Celo、Avalanche和其他许多网络。

本教程将构建的应用是一个全栈博客也是内容管理系统(CMS),你将拥有一个开放的、公共的、可组合的后端,可以在任何地方转移和重用。

在本教程结束时,你应该很好地理解 web3技术栈的最重要部分,以及如何构建高性能、可扩展、全栈的去中心化区块链应用程序:

本文是我的 "全栈" web3系列中的第四个指南,其他的文章是:

  1. 全栈以太坊开发指南
  2. 用Polygon在以太坊上建立一个全栈NFT市场
  3. 使用React、Anchor、Rust和Phantom进行全栈Solana开发的完整指南

Web3技术栈

web3栈

定义web3协议栈文章中,我从开发者的角度,结合自己的个人经验以及过去一年在Edge & Node团队所做的研究,写了我对web3技术栈现状的理解。

这个应用使用技术栈的各个部分有:

  1. 区块链--Polygon(有可选的RPC提供者)
  2. 以太坊开发环境 - Hardhat
  3. 前端框架 - Next.js & React
  4. 以太坊网络客户端库 - Ethers.js
  5. 文件存储 - IPFS
  6. 索引和查询 - The Graph协议

通过学习如何使用这些构件,我们可以建立许多类型的应用程序,所以本教程的目标是展示它们各自的工作原理以及它们如何结合在一起。

让我们开始吧!

前提条件

  • 在你的本地机器上安装Node.js
  • 在浏览器中安装MetaMask Chrome插件

项目设置

在这里,我们将创建应用程序的模板,安装所有必要的依赖项,并配置该项目。

代码会被注释,以便让你了解正在发生的事情,我也会在整个教程中描述正在发生的事情。

为了开始,创建一个新的Next.js应用程序,并换到新的目录中。

npx create-next-app web3-blog

cd web3-blog

接下来,进入新目录,用npmyarnpnpm安装以下依赖项:

npm install ethers hardhat @nomiclabs/hardhat-waffle \
ethereum-waffle chai @nomiclabs/hardhat-ethers \
web3modal @walletconnect/web3-provider \
easymde react-markdown react-simplemde-editor \
ipfs-http-client @emotion/css @openzeppelin/contracts

解释一下,其中一些依赖项:

hardhat - 以太坊开发环境 web3modal - 一个易于使用的库,允许用户将他们的钱包连接到你的应用程序上 react-markdownsimplemde - CMS的markdown编辑器和markdown渲染器 @emotion/css - 一个出色的JS中的CSS库 @openzeppelin/contracts - 常用的智能合约标准和功能的开源实现

接下来,初始化本地智能合约开发环境:

npx hardhat

? What do you want to do? Create a basic sample project
? Hardhat project root: <Choose default path>

如果在引用README.md时出现错误,请删除README.md并再次运行npx hardhat

这是我们将使用的基本 Solidity 开发环境。你应该看到一些新的文件和文件夹被创建,包括 contracts, scripts, test, 和 hardhat.config.js

接下来,让我们更新一下hardhat.config.js的Hardhat配置,用下面的代码更新这个文件:

require("@nomiclabs/hardhat-waffle");

module.exports = {
  solidity: "0.8.4",
  networks: {
    hardhat: {
      chainId: 1337
    },
    // mumbai: {
    //   url: "https://rpc-mumbai.matic.today",
    //   accounts: [process.env.pk]
    // },
    // polygon: {
    //   url: "https://polygon-rpc.com/",
    //   accounts: [process.env.pk]
    // }
  }
};

在这里,已经配置了本地hardhat开发环境,以及设置了(并注释了)Polygon主网和Mumbai测试网环境,我们将使用这些环境来部署到Polygon。

接下来,添加一些基本的全局CSS,我们将需要这些CSS来为CMS的markdown编辑器设置样式。

打开styles/globals.css,在现有的css下面添加以下代码:

.EasyMDEContainer .editor-toolbar {
  border: none;
}

.EasyMDEContainer .CodeMirror {
  border: none !important;
  background: none;
}

.editor-preview {
  background-color: white !important;
}

.CodeMirror .cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word) {
  background-color: transparent !important;
}

pre {
  padding: 20px;
  background-color: #efefef;
}

blockquote {
  border-left: 5px solid #ddd;
  padding-left: 20px;
  margin-left: 0px;
}

接下来,我们将为应用程序的图片创建几个SVG文件,一个用于logo,一个作为箭头按钮。

public文件夹中创建logo.svgright-arrow.svg,并将链接的SVG代码分别复制到这些文件中。

智能合约

接下来,让我们创建一个智能合约,它将为我们的博客和CMS提供支持。

contracts文件夹中创建一个新文件,名为Blog.sol,在这里,添加以下代码:

// contracts/Blog.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

import "hardhat/console.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract Blog {
    string public name;
    address public owner;

    using Counters for Counters.Counter;
    Counters.Counter private _postIds;

    struct Post {
      uint id;
      string title;
      string content;
      bool published;
    }
    /* mappings can be seen as hash tables */
    /* here we create lookups for posts by id and posts by ipfs hash */
    mapping(uint => Post) private idToPost;
    mapping(string => Post) private hashToPost;

    /* events facilitate communication between smart contractsand their user interfaces  */
    /* i.e. we can create listeners for events in the client and also use them in The Graph  */
    event PostCreated(uint id, string title, string hash);
    event PostUpdated(uint id, string title, string hash, bool published);

    /* when the blog is deployed, give it a name */
    /* also set the creator as the owner of the contract */
    constructor(string memory _name) {
        console.log("Deploying Blog with name:", _name);
        name = _name;
        owner = msg.sender;
    }

    /* updates the blog name */
    function updateName(string memory _name) public {
        name = _name;
    }

    /* transfers ownership of the contract to another address */
    function transferOwnership(address newOwner) public onlyOwner {
        owner = newOwner;
    }

    /* fetches an individual post by the content hash */
    function fetchPost(string memory hash) public view returns(Post memory){
      return hashToPost[hash];
    }

    /* creates a new post */
    function createPost(string memory title, string memory hash) public onlyOwner {
        _postIds.increment();
        uint postId = _postIds.current();
        Post storage post = idToPost[postId];
        post.id = postId;
        post.title = title;
        post.published = true;
        post.content = hash;
        hashToPost[hash] = post;
        emit PostCreated(postId, title, hash);
    }

    /* updates an existing post */
    function updatePost(uint postId, string memory title, string memory hash, bool published) public onlyOwner {
        Post storage post =  idToPost[postId];
        post.title = title;
        post.published = published;
        post.content = hash;
        idToPost[postId] = post;
        hashToPost[hash] = post;
        emit PostUpdated(post.id, title, hash, published);
    }

    /* fetches all posts */
    function fetchPosts() public view returns (Post[] memory) {
        uint itemCount = _postIds.current();

        Post[] memory posts = new Post[](itemCount);
        for (uint i = 0; i < itemCount; i++) {
            uint currentId = i + 1;
            Post storage currentItem = idToPost[currentId];
            posts[i] = currentItem;
        }
        return posts;
    }

    /* this modifier means only the contract owner can */
    /* invoke the function */
    modifier onlyOwner() {
      require(msg.sender == owner);
    _;
  }
}

这个合约允许所有者创建和编辑帖子,并允许任何人取用帖子。

要使这个智能合约没有权限,你可以删除onlyOwner修改器,并使用The Graph按所有者索引和查询帖子。

接下来,让我们写一个基本的测试来测试将要使用的最重要的功能。

为此,打开test/sample-test.js,用以下代码更新它:

const { expect } = require("chai")
const { ethers } = require("hardhat")

describe("Blog", async function () {
  it("Should create a post", async function () {
    const Blog = await ethers.getContractFactory("Blog")
    const blog = await Blog.deploy("My blog")
    await blog.deployed()
    await blog.createPost("My first post", "12345")

    const posts = await blog.fetchPosts()
    expect(posts[0].title).to.equal("My first post")
  })

  it("Should edit a post", async function () {
    const Blog = await ethers.getContractFactory("Blog")
    const blog = await Blog.deploy("My blog")
    await blog.deployed()
    await blog.createPost("My Second post", "12345")

    await blog.updatePost(1, "My updated post", "23456", true)

    posts = await blog.fetchPosts()
    expect(posts[0].title).to.equal("My updated post")
  })

  it("Should add update the name", async function () {
    const Blog = await ethers.getContractFactory("Blog")
    const blog = await Blog.deploy("My blog")
    await blog.deployed()

    expect(await blog.name()).to.equal("My blog")
    await blog.updateName('My new blog')
    expect(await blog.name()).to.equal("My new blog")
  })
})

接下来,打开终端并运行以下命令来运行这个测试:

npx hardhat test

部署合约

现在,合约已经写好并经过了测试,让我们试着把它部署到本地测试网络。

为了启动本地网络,终端至少打开两个独立窗口。在一个窗口中,运行下面的脚本:

npx hardhat node

当我们运行这个命令时,你应该看到一个地址和私钥的列表:

账户

这些是为我们创建的20个测试账户和地址,可以用来部署和测试智能合约。每个账户也都“装”上了10,000个假的以太币。稍后,我们将学习如何将测试账户导入MetaMask,以便我们能够使用它。

接下来,我们需要将合约部署到测试网络中,首先将scripts/sample-script.js的名字更新为scripts/deploy.js

接下来,用以下新的部署脚本更新该文件:

/* scripts/deploy.js */
const hre = require("hardhat");
const fs = require('fs');

async function main() {
  /* these two lines deploy the contract to the network */
  const Blog = await hre.ethers.getContractFactory("Blog");
  const blog = await Blog.deploy("My blog");

  await blog.deployed();
  console.log("Blog deployed to:", blog.address);

  /* this code writes the contract addresses to a local */
  /* file named config.js that we can use in the app */
  fs.writeFileSync('./config.js', `
  export const contractAddress = "${blog.address}"
  export const ownerAddress = "${blog.signer.address}"
  `)
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

现在,在一个单独的窗口中(当本地网络仍在运行时),我们可以运行部署脚本,并给CLI命令一个选项参数,表示我们想部署到本地网络。

npx hardhat run scripts/deploy.js --network localhost

当合约被部署后,你应该在终端看到一些输出🎉。

将测试账户导入你的钱包中

为了向智能合约发送交易,我们需要用运行npx hardhat node时创建的一个账户连接MetaMask钱包。在hardhat命令终端中,你应该同时看到账号以及私钥

➜  react-dapp git:(main) npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Accounts
========
Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

...

我们可以把这个账户导入MetaMask,以便开始使用账号下的假Eth。

要做到这一点,首先打开MetaMask,启用测试网络

测试网络

接下来,更新网络为Localhost 8545:

选择网络

接下来,在MetaMask中点击账户菜单中的导入账户

导入帐户

复制并粘贴由命令终端的第一个Private key(私钥),然后点击Import(导入)。一旦账户被导入,你应该看到账户中的Eth。

导入的账户

确保你导入的是账户列表中的第一个账户(账户#0),因为这将是合约部署时默认使用的账户,即是合约所有者。

现在,我们已经部署了一个智能合约,并准备好使用一个账户,可以开始从Next.js应用程序中与合约交互。

Next.js 应用

接下来,让我们编写前端应用的代码。

我们要做的第一件事是设置几个环境变量,用来在本地测试环境、Mumbai 测试网和Polygon 主网之间切换。

在项目根部创建一个名为.env.local的新文件,并添加以下配置,以开始使用:

ENVIRONMENT="local"
NEXT_PUBLIC_ENVIRONMENT="local"

我们将能够在localtestnetmainnet之间切换这些变量。

这将使我们能够在客户端和服务器上都引用我们的环境。要了解更多关于Next.js中环境变量的工作原理,请查看这里文档。

context.js

接下来,让我们创建应用程序context。Context将为我们提供一种简单的方法来分享整个应用程序的状态。

创建一个名为context.js的文件并添加以下代码:

import { createContext } from 'react'

export const AccountContext = createContext(null)

布局和导航

接下来,让我们打开pages/_app.js。在这里,我们将更新代码,以包括导航、钱包连接、上下文和一些基本的风格设计。

这个页面可以作为应用程序其他部分的wrapper或布局:

/* pages/__app.js */
import '../styles/globals.css'
import { useState } from 'react'
import Link from 'next/link'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import Web3Modal from 'web3modal'
import WalletConnectProvider from '@walletconnect/web3-provider'
import { AccountContext } from '../context.js'
import { ownerAddress } from '../config'
import 'easymde/dist/easymde.min.css'

function MyApp({ Component, pageProps }) {
  /* create local state to save account information after signin */
  const [account, setAccount] = useState(null)
  /* web3Modal configuration for enabling wallet access */
  async function getWeb3Modal() {
    const web3Modal = new Web3Modal({
      cacheProvider: false,
      providerOptions: {
        walletconnect: {
          package: WalletConnectProvider,
          options: { 
            infuraId: "your-infura-id"
          },
        },
      },
    })
    return web3Modal
  }

  /* the connect function uses web3 modal to connect to the user's wallet */
  async function connect() {
    try {
      const web3Modal = await getWeb3Modal()
      const connection = await web3Modal.connect()
      const provider = new ethers.providers.Web3Provider(connection)
      const accounts = await provider.listAccounts()
      setAccount(accounts[0])
    } catch (err) {
      console.log('error:', err)
    }
  }

  return (
    <div>
      <nav className={nav}>
        <div className={header}>
          <Link href="/">
            <a>
              <img
                src='/logo.svg'
                alt="React Logo"
                style={{ width: '50px' }}
              />
            </a>
          </Link>
          <Link href="/">
            <a>
              <div className={titleContainer}>
                <h2 className={title}>Full Stack</h2>
                <p className={description}>WEB3</p>
              </div>
            </a>
          </Link>
          {
            !account && (
              <div className={buttonContainer}>
                <button className={buttonStyle} onClick={connect}>Connect</button>
              </div>
            )
          }
          {
            account && <p className={accountInfo}>{account}</p>
          }
        </div>
        <div className={linkContainer}>
          <Link href="/" >
            <a className={link}>
              Home
            </a>
          </Link>
          {
            /* if the signed in user is the contract owner, we */
            /* show the nav link to create a new post */
            (account === ownerAddress) && (
              <Link href="/create-post">
                <a className={link}>
                  Create Post
                </a>
              </Link>
            )
          }
        </div>
      </nav>
      <div className={container}>
        <AccountContext.Provider value={account}>
          <Component {...pageProps} connect={connect} />
        </AccountContext.Provider>
      </div>
    </div>
  )
}

const accountInfo = css`
  width: 100%;
  display: flex;
  flex: 1;
  justify-content: flex-end;
  font-size: 12px;
`

const container = css`
  padding: 40px;
`

const linkContainer = css`
  padding: 30px 60px;
  background-color: #fafafa;
`

const nav = css`
  background-color: white;
`

const header = css`
  display: flex;
  border-bottom: 1px solid rgba(0, 0, 0, .075);
  padding: 20px 30px;
`

const description = css`
  margin: 0;
  color: #999999;
`

const titleContainer = css`
  display: flex;
  flex-direction: column;
  padding-left: 15px;
`

const title = css`
  margin-left: 30px;
  font-weight: 500;
  margin: 0;
`

const buttonContainer = css`
  width: 100%;
  display: flex;
  flex: 1;
  justify-content: flex-end;
`

const buttonStyle = css`
  background-color: #fafafa;
  outline: none;
  border: none;
  font-size: 18px;
  padding: 16px 70px;
  border-radius: 15px;
  cursor: pointer;
  box-shadow: 7px 7px rgba(0, 0, 0, .1);
`

const link = css`
  margin: 0px 40px 0px 0px;
  font-size: 16px;
  font-weight: 400;
`

export default MyApp

入口

现在我们已经设置好了布局,接下来创建应用程序的入口。

这个页面将从网络上获取帖子列表,并在一个列表视图中呈现帖子的标题。当用户点击一个帖子时,将把他们导航到另一个页面来查看详情(详情页面将在接下来创建)。


/* pages/index.js */
import { css } from '@emotion/css'
import { useContext } from 'react'
import { useRouter } from 'next/router'
import { ethers } from 'ethers'
import Link from 'next/link'
import { AccountContext } from '../context'

/* import contract address and contract owner address */
import {
  contractAddress, ownerAddress
} from '../config'

/* import Application Binary Interface (ABI) */
import Blog from '../artifacts/contracts/Blog.sol/Blog.json'

export default function Home(props) {
  /* posts are fetched server side and passed in as props */
  /* see getServerSideProps */
  const { posts } = props
  const account = useContext(AccountContext)

  const router = useRouter()
  async function navigate() {
    router.push('/create-post')
  }

  return (
    <div>
      <div className={postList}>
        {
          /* map over the posts array and render a button with the post title */
          posts.map((post, index) => (
            <Link href={`/post/${post[2]}`} key={index}>
              <a>
                <div className={linkStyle}>
                  <p className={postTitle}>{post[1]}</p>
                  <div className={arrowContainer}>
                  <img
                      src='/right-arrow.svg'
                      alt='Right arrow'
                      className={smallArrow}
                    />
                  </div>
                </div>
              </a>
            </Link>
          ))
        }
      </div>
      <div className={container}>
        {
          (account === ownerAddress) && posts && !posts.length && (
            /* if the signed in user is the account owner, render a button */
            /* to create the first post */
            <button className={buttonStyle} onClick={navigate}>
              Create your first post
              <img
                src='/right-arrow.svg'
                alt='Right arrow'
                className={arrow}
              />
            </button>
          )
        }
      </div>
    </div>
  )
}

export async function getServerSideProps() {
  /* here we check to see the current environment variable */
  /* and render a provider based on the environment we're in */
  let provider
  if (process.env.ENVIRONMENT === 'local') {
    provider = new ethers.providers.JsonRpcProvider()
  } else if (process.env.ENVIRONMENT === 'testnet') {
    provider = new ethers.providers.JsonRpcProvider('https://rpc-mumbai.matic.today')
  } else {
    provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com/')
  }

  const contract = new ethers.Contract(contractAddress, Blog.abi, provider)
  const data = await contract.fetchPosts()
  return {
    props: {
      posts: JSON.parse(JSON.stringify(data))
    }
  }
}

const arrowContainer = css`
  display: flex;
  flex: 1;
  justify-content: flex-end;
  paddin...

剩余50%的内容购买后可查看

点赞 4
收藏 12
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO