Web3 全栈开发完整指南

Web3 全栈开发完整指南

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

本教程有相应视频,在此处获得

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

教程的应用程序的代码库位于此处

我们要部署的主要网络是 Polygon。我选择 Polygon 是因为它的交易成本低,区块时间快,以及也是目前广泛采用的网络。
也就是说,我们将在 以太坊虚拟机(EVM) 上进行构建,所以你也可以应用这些技能为其他几十个区块链网络进行构建,包括以太坊CeloAvalanche和其他许多网络。
本教程将构建的应用是一个全栈博客也是内容管理系统(CMS),你将拥有一个开放的、公共的、可组合的后端,可以在任何地方转移和重用。
在本教程结束时,您应该对现代 web3 技术栈中最重要的部分以及如何构建高性能、可扩展、全栈去中心化区块链应用程序有一个很好的理解。
本文是我的「全栈」web3 系列中的第四篇指南,其他的文章是:

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

Web3 技术栈

Web3 全栈开发完整指南

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

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

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

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

让我们开始吧!

:heavy_exclamation_mark: 前提条件

  • 在你的本地机器上安装 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.jsHardhat 配置,使用以下代码更新此文件:

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

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

Web3 全栈开发完整指南

这些是为我们创建的 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,启用测试网络:

Web3 全栈开发完整指南
接下来,更新网络为 Localhost 8545:

Web3 全栈开发完整指南
接下来,在 MetaMask 中点击账户菜单中的 Import Account

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

Web3 全栈开发完整指南

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

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

Next.js 应用

接下来,让我们编写前端应用的代码。
我们要做的第一件事是设置几个环境变量,用来在本地测试环境、Mumbai 测试网和 Polygon 主网之间切换。
在项目根部创建一个名为 .env.local 的新文件,并添加以下配置,以开始使用:

ENVIRONMENT="local"
NEXT_PUBLIC_ENVIRONMENT="local"

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

context.js

接下来,让我们创建应用程序 contextContext将为我们提供一种简单的方法来分享整个应用程序的状态。
创建一个名为 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;
  padding-right: 20px;
`

const postTitle = css`
  font-size: 30px;
  font-weight: bold;
  cursor: pointer;
  margin: 0;
  padding: 20px;
`

const linkStyle = css`
  border: 1px solid #ddd;
  margin-top: 20px;
  border-radius: 8px;
  display: flex;
`

const postList = css`
  width: 700px;
  margin: 0 auto;
  padding-top: 50px;  
`

const container = css`
  display: flex;
  justify-content: center;
`

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

const arrow = css`
  width: 35px;
  margin-left: 30px;
`

const smallArrow = css`
  width: 25px;
`

创建帖子

接下来,在 pages 目录下创建一个新文件,名为 create-post.js
这将包含允许我们创建帖子并将其保存到网络路由上。
我们还可以选择上传和保存封面图片到 IPFS,ipfs 上传的哈希值与其他最数据锚定在链上。
在这个文件中添加以下代码:

/* pages/create-post.js */
import { useState, useRef, useEffect } from 'react' // new
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import { create } from 'ipfs-http-client'

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

import Blog from '../artifacts/contracts/Blog.sol/Blog.json'

/* define the ipfs endpoint */
const client = create('https://ipfs.infura.io:5001/api/v0')

/* configure the markdown editor to be client-side import */
const SimpleMDE = dynamic(
  () => import('react-simplemde-editor'),
  { ssr: false }
)

const initialState = { title: '', content: '' }

function CreatePost() {
  /* configure initial state to be used in the component */
  const [post, setPost] = useState(initialState)
  const [image, setImage] = useState(null)
  const [loaded, setLoaded] = useState(false)

  const fileRef = useRef(null)
  const { title, content } = post
  const router = useRouter()

  useEffect(() => {
    setTimeout(() => {
      /* delay rendering buttons until dynamic import is complete */
      setLoaded(true)
    }, 500)
  }, [])

  function onChange(e) {
    setPost(() => ({ ...post, [e.target.name]: e.target.value }))
  }

  async function createNewPost() {   
    /* saves post to ipfs then anchors to smart contract */
    if (!title || !content) return
    const hash = await savePostToIpfs()
    await savePost(hash)
    router.push(`/`)
  }

  async function savePostToIpfs() {
    /* save post metadata to ipfs */
    try {
      const added = await client.add(JSON.stringify(post))
      return added.path
    } catch (err) {
      console.log('error: ', err)
    }
  }

  async function savePost(hash) {
    /* anchor post to smart contract */
    if (typeof window.ethereum !== 'undefined') {
      const provider = new ethers.providers.Web3Provider(window.ethereum)
      const signer = provider.getSigner()
      const contract = new ethers.Contract(contractAddress, Blog.abi, signer)
      console.log('contract: ', contract)
      try {
        const val = await contract.createPost(post.title, hash)
        /* optional - wait for transaction to be confirmed before rerouting */
        /* await provider.waitForTransaction(val.hash) */
        console.log('val: ', val)
      } catch (err) {
        console.log('Error: ', err)
      }
    }    
  }

  function triggerOnChange() {
    /* trigger handleFileChange handler of hidden file input */
    fileRef.current.click()
  }

  async function handleFileChange (e) {
    /* upload cover image to ipfs and save hash to state */
    const uploadedFile = e.target.files[0]
    if (!uploadedFile) return
    const added = await client.add(uploadedFile)
    setPost(state => ({ ...state, coverImage: added.path }))
    setImage(uploadedFile)
  }

  return (
    <div className={container}>
      {
        image && (
          <img className={coverImageStyle} src={URL.createObjectURL(image)} />
        )
      }
      <input
        onChange={onChange}
        name='title'
        placeholder='Give it a title ...'
        value={post.title}
        className={titleStyle}
      />
      <SimpleMDE
        className={mdEditor}
        placeholder="What's on your mind?"
        value={post.content}
        onChange={value => setPost({ ...post, content: value })}
      />
      {
        loaded && (
          <>
            <button
              className={button}
              type='button'
              onClick={createNewPost}
            >Publish</button>
            <button
              onClick={triggerOnChange}
              className={button}
            >Add cover image</button>
          </>
        )
      }
      <input
        id='selectImage'
        className={hiddenInput} 
        type='file'
        onChange={handleFileChange}
        ref={fileRef}
      />
    </div>
  )
}

const hiddenInput = css`
  display: none;
`

const coverImageStyle = css`
  max-width: 800px;
`

const mdEditor = css`
  margin-top: 40px;
`

const titleStyle = css`
  margin-top: 40px;
  border: none;
  outline: none;
  background-color: inherit;
  font-size: 44px;
  font-weight: 600;
  &::placeholder {
    color: #999999;
  }
`

const container = css`
  width: 800px;
  margin: 0 auto;
`

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

export default CreatePost

查看一个帖子

现在我们有了创建帖子的能力,那么我们如何导航和查看帖子呢?我们希望能够在一个看起来像myapp.com/post/some-post-id的路由中查看帖子。
可以用 next.js动态路由 以几种不同的方式来实现这一点。
我们将使用 getStaticPathsgetStaticProps 来利用服务器端的数据获取,它将在构建时使用从网络上查询的帖子数组来创建这些页面。
为了实现这一点,在 pages 目录下创建一个名为 post 的新文件夹,并在该文件夹中创建一个名为 [id].js 的文件,添加以下代码:

/* pages/post/[id].js */
import ReactMarkdown from 'react-markdown'
import { useContext } from 'react'
import { useRouter } from 'next/router'
import Link from 'next/link'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import { AccountContext } from '../../context'

/* import contract and owner addresses */
import {
  contractAddress, ownerAddress
} from '../../config'
import Blog from '../../artifacts/contracts/Blog.sol/Blog.json'

const ipfsURI = 'https://ipfs.io/ipfs/'

export default function Post({ post }) {
  const account = useContext(AccountContext)
  const router = useRouter()
  const { id } = router.query

  if (router.isFallback) {
    return <div>Loading...</div>
  }

  return (
    <div>
      {
        post && (
          <div className={container}>
            {
              /* if the owner is the user, render an edit button */
              ownerAddress === account && (
                <div className={editPost}>
                  <Link href={`/edit-post/${id}`}>
                    <a>
                      Edit post
                    </a>
                  </Link>
                </div>
              )
            }
            {
              /* if the post has a cover image, render it */
              post.coverImage && (
                <img
                  src={post.coverImage}
                  className={coverImageStyle}
                />
              )
            }
            <h1>{post.title}</h1>
            <div className={contentContainer}>
              <ReactMarkdown>{post.content}</ReactMarkdown>
            </div>
          </div>
        )
      }
    </div>
  )
}

export async function getStaticPaths() {
  /* here we fetch the posts from the network */
  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()

  /* then we map over the posts and create a params object passing */
  /* the id property to getStaticProps which will run for ever post */
  /* in the array and generate a new page */
  const paths = data.map(d => ({ params: { id: d[2] } }))

  return {
    paths,
    fallback: true
  }
}

export async function getStaticProps({ params }) {
  /* using the id property passed in through the params object */
  /* we can us it to fetch the data from IPFS and pass the */
  /* post data into the page as props */
  const { id } = params
  const ipfsUrl = `${ipfsURI}/${id}`
  const response = await fetch(ipfsUrl)
  const data = await response.json()
  if(data.coverImage) {
    let coverImage = `${ipfsURI}/${data.coverImage}`
    data.coverImage = coverImage
  }

  return {
    props: {
      post: data
    },
  }
}

const editPost = css`
  margin: 20px 0px;
`

const coverImageStyle = css`
  width: 900px;
`

const container = css`
  width: 900px;
  margin: 0 auto;
`

const contentContainer = css`
  margin-top: 60px;
  padding: 0px 40px;
  border-left: 1px solid #e7e7e7;
  border-right: 1px solid #e7e7e7;
  & img {
    max-width: 900px;
  }
`

编辑帖子

我们需要创建的最后一个页面是一个编辑现有帖子的方法。
这个页面将继承 pages/create-post.js 以及 pages/post/[id].js 的一些功能。我们将能够在查看和编辑帖子之间进行切换。
pages 目录下创建一个名为 edit-post 的新文件夹,并创建一个名为 [id].js 的文件。接下来,添加以下代码:

/* pages/edit-post/[id].js */
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import ReactMarkdown from 'react-markdown'
import { css } from '@emotion/css'
import dynamic from 'next/dynamic'
import { ethers } from 'ethers'
import { create } from 'ipfs-http-client'

import {
  contractAddress
} from '../../config'
import Blog from '../../artifacts/contracts/Blog.sol/Blog.json'

const ipfsURI = 'https://ipfs.io/ipfs/'
const client = create('https://ipfs.infura.io:5001/api/v0')

const SimpleMDE = dynamic(
  () => import('react-simplemde-editor'),
  { ssr: false }
)

export default function Post() {
  const [post, setPost] = useState(null)
  const [editing, setEditing] = useState(true)
  const router = useRouter()
  const { id } = router.query

  useEffect(() => {
    fetchPost()
  }, [id])
  async function fetchPost() {
    /* we first fetch the individual post by ipfs hash from the network */
    if (!id) return
    let provider
    if (process.env.NEXT_PUBLIC_ENVIRONMENT === 'local') {
      provider = new ethers.providers.JsonRpcProvider()
    } else if (process.env.NEXT_PUBLIC_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 val = await contract.fetchPost(id)
    const postId = val[0].toNumber()

    /* next we fetch the IPFS metadata from the network */
    const ipfsUrl = `${ipfsURI}/${id}`
    const response = await fetch(ipfsUrl)
    const data = await response.json()
    if(data.coverImage) {
      let coverImagePath = `${ipfsURI}/${data.coverImage}`
      data.coverImagePath = coverImagePath
    }
    /* finally we append the post ID to the post data */
    /* we need this ID to make updates to the post */
    data.id = postId;
    setPost(data)
  }

  async function savePostToIpfs() {
    try {
      const added = await client.add(JSON.stringify(post))
      return added.path
    } catch (err) {
      console.log('error: ', err)
    }
  }

  async function updatePost() {
    const hash = await savePostToIpfs()
    const provider = new ethers.providers.Web3Provider(window.ethereum)
    const signer = provider.getSigner()
    const contract = new ethers.Contract(contractAddress, Blog.abi, signer)
    await contract.updatePost(post.id, post.title, hash, true)
    router.push('/')
  }

  if (!post) return null

  return (
    <div className={container}>
      {
      /* editing state will allow the user to toggle between */
      /*  a markdown editor and a markdown renderer */
      }
      {
        editing && (
          <div>
            <input
              onChange={e => setPost({ ...post, title: e.target.value })}
              name='title'
              placeholder='Give it a title ...'
              value={post.title}
              className={titleStyle}
            />
            <SimpleMDE
              className={mdEditor}
              placeholder="What's on your mind?"
              value={post.content}
              onChange={value => setPost({ ...post, content: value })}
            />
            <button className={button} onClick={updatePost}>Update post</button>
          </div>
        )
      }
      {
        !editing && (
          <div>
            {
              post.coverImagePath && (
                <img
                  src={post.coverImagePath}
                  className={coverImageStyle}
                />
              )
            }
            <h1>{post.title}</h1>
            <div className={contentContainer}>
              <ReactMarkdown>{post.content}</ReactMarkdown>
            </div>
          </div>
        )
      }
      <button className={button} onClick={() => setEditing(editing ? false : true)}>{ editing ? 'View post' : 'Edit post'}</button>
    </div>
  )
}

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

const titleStyle = css`
  margin-top: 40px;
  border: none;
  outline: none;
  background-color: inherit;
  font-size: 44px;
  font-weight: 600;
  &::placeholder {
    color: #999999;
  }
`

const mdEditor = css`
  margin-top: 40px;
`

const coverImageStyle = css`
  width: 900px;
`

const container = css`
  width: 900px;
  margin: 0 auto;
`

const contentContainer = css`
  margin-top: 60px;
  padding: 0px 40px;
  border-left: 1px solid #e7e7e7;
  border-right: 1px solid #e7e7e7;
  & img {
    max-width: 900px;
  }
`

测试

我们现在可以测试它了。
要做到这一点,请确保你已经在前面的步骤中把合约部署到了网络上,并且你的本地网络仍然在运行。
打开一个新的终端窗口,启动 Next.js 应用程序:

npm run dev

当应用程序启动时,你应该能够连接钱包并与应用程序交互:

Web3 全栈开发完整指南
你也应该能够创建一个新的帖子:

Web3 全栈开发完整指南
你可能会注意到,该应用程序的速度并不快,但 Next.js 在生产中的速度快得惊人。
要构建一个生产环境下版本,请运行以下命令:

npm run build && npm start

部署到Polygon

现在我们的项目已经开始运行,并在本地进行了测试,让我们把它部署到 Polygon。我们将首先部署到 Mumbai,即Polygon的测试网络。
我们需要做的第一件事是将我们钱包中的一个 私钥 设置为环境变量。
要获得私钥,你可以直接从 MetaMask 中导出它们。

Web3 全栈开发完整指南

私钥在任何情况下都不能公开分享。我们建议不要在文件中硬编码私钥。如果你选择这样做,请确保使用测试钱包,并且在任何情况下都不要将包含私钥的文件推送到源码控制中,或将其公开暴露。

如果你使用的是Mac,你可以在命令行中这样设置环境变量(请确保在同一终端和会话中运行部署脚本)。

export pk="your-private-key"

配置网络

接下来,我们需要从本地测试网络切换到 Mumbai Testnet
要做到这一点,我们需要创建和设置网络配置。
首先,打开 MetaMask,点击 Settings

Web3 全栈开发完整指南
接下来,点击 Networks,然后点击Add Network

Web3 全栈开发完整指南

在这里,我们将为 Mumbai 测试网络添加以下配置,如 这里 所列。

网络名称:Mumbai TestNet
New RPC URL:rpc-mumbai.matic.today/
Chain ID:80001
Currency Symbol:Matic

点「保存」,然后你应该可以切换到并使用新的网络!
最后,你将需要一些测试网 Polygon 代币,以便与应用程序交互,要获得这些,你可以访问Polygon Faucet,输入你想申请代币的钱包地址。

部署到 Polygon 网络中

现在你已经有了一些代币,你可以在 Polygon 网络上进行部署了!
要做到这一点,请确保与你部署合约的私钥相关的地址已经收到一些代币,以便支付交易的 Gas 费用。
接下来,反注释 hardhat.config.js 中的 mumbai 配置:

mumbai: {
  url: "https://rpc-mumbai.matic.today",
  accounts: [process.env.pk]
},

为了部署到Polygon testnet,运行以下命令:

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

如果你遇到这个错误:ProviderError: RPCError,公共 RPC 可能会出现拥堵。在生产中,建议使用 InfuraAlchemyQuicknode 等 RPC 提供者。

接下来,将 .env.local 中的环境变量更新为 testnet:

ENVIRONMENT="testnet"
NEXT_PUBLIC_ENVIRONMENT="testnet"

接下来,重新启动服务器以应用环境变量的变化:

npm run dev

现在你应该可以在新的网络上测试应用程序了 🎉!
如果你在连接公共 Mumbai RPC 端点时有任何问题,可以考虑使用 RPC 提供者的端点来替换你的应用程序中的端点,如 InfuraAlchemyQuicknode

创建一个 subgraphAPI

默认情况下,我们唯一的数据访问模式是我们写进合约的两个函数:fetchPostfetchPosts
这是一个很好的开始,但当你的应用程序开始扩展时,你可能会发现自己需要一个更灵活和可扩展的API。
例如,如果我们想让用户能够搜索帖子,获取某个用户创建的帖子,或者按照帖子的创建日期进行排序,会怎么样?
我们可以通过使用 The Graph 协议将所有这些功能构建到一个 API 中,让我们看看如何做到这一点。

在The Graph中创建项目

先访问 The Graph托管服务 并登录或创建一个新账户。
接下来,进入 仪表板,点击 添加subgraph,创建一个新的 subgraph,用以下属性配置你的 subgraph:

  • subgraph名称 - Blogcms
  • 副标题 - 用于查询帖子数据的 subgraph
  • 可选的 - 填写描述和 GITHUB URL 属性

一旦 subgraph 被创建,我们将使用 Graph CLI 在本地初始化 subgraph。

使用Graph CLI初始化一个新的subgraph

接下来,安装Graph CLI:

$ npm install -g @graphprotocol/graph-cli

# or

$ yarn global add @graphprotocol/graph-cli

一旦 Graph CLI 被安装,你就可以用 Graph CLI 的init命令来初始化一个新的 subgraph,由于我们已经将合约部署到网络上,我们可以通过使用--from-contract参数传递合约地址来初始化。
这个地址可以在 config.js 中作为 contractAddress 应用:

$ graph init --from-contract your-contract-address \
--network mumbai --contract-name Blog --index-events

? Protocol: ethereum
? Product for which to initialize › hosted-service
? Subgraph name › your-username/blogcms
? Directory to create the subgraph in › blogcms
? Ethereum network › mumbai
? Contract address › your-contract-address
? ABI file (path) › artifacts/contracts/Blog.sol/Blog.json
? Contract Name › Blog

该命令将根据作为--from-contract参数传递的合约地址生成一个基本 subgraph。通过使用这个合约地址,CLI 将在你的项目中初始化一些内容,让你可以更好的开始工作(包括获取abis并保存在 abis 目录中)。

通过传入 –index-events,CLI将根据合约发出的事件,在 schema.graphql 和src/mapping.ts 中自动为我们填充一些代码。

subgraph的主要配置和定义存在于 subgraph.yaml 文件中。subgraph 的代码库由几个文件组成:

  • subgraph.yaml:一个包含 subgraph 清单的 YAML 文件。
  • schema.graphql:一个 GraphQL 模式,定义了 subgraph 存储了哪些数据,以及如何通过 GraphQL 查询这些数据。
  • AssemblyScript Mappings:AssemblyScript 代码,将以太坊中的事件数据转换为模式中定义的实体(例如,本教程中的 mapping.ts)。

subgraph.yaml中包含要处理的内容:

  • description(可选):关于 subgraph 是什么的可读描述。当 subgraph 被部署到托管服务时,该描述将由 Graph 浏览器显示。
  • 代码库(可选):可以找到 subgraph 清单的储存库的 URL。也会被 Graph Explorer 所显示。
  • dataSources.source:subgraph来源的智能合约的地址,以及要使用的智能合约的ABI。地址是可选的;省略它则可以索引所有合约的匹配事件。
  • dataSources.source.startBlock(可选):数据源开始索引的区块的编号。在大多数情况下,我们建议使用创建合约的区块。
  • dataSources.mapping.entities:数据源写入存储的实体。每个实体的模式在schema.graphql文件中定义。
  • dataSources.mapping.abis:一个或多个命名的ABI文件,用于源合约以及你在映射中与之交互的任何其他智能合约。
  • dataSources.mapping.eventHandlers:列出该subgraph处理的智能合约事件和映射中的处理程序–在例子中是./src/mapping.ts–将这些事件转化为存储中的实体。

定义实体

通过 The Graph,你在 schema.graphql 中定义实体类型,Graph Node将生成顶层字段,用于查询该实体类型的单个实例和集合。每个应该成为实体的类型都需要用@entity指令来注释。
我们要索引的实体/数据是TokenUser。这样,我们就可以对用户创建的Token以及用户本身进行索引。
用以下代码更新 schema.graphql,来实现这一点:

type _Schema_
  @fulltext(
    name: "postSearch"
    language: en
    algorithm: rank
    include: [{ entity: "Post", fields: [{ name: "title" }, { name: "postContent" }] }]
  )

type Post @entity {
  id: ID!
  title: String!
  contentHash: String!
  published: Boolean!
  postContent: String!
  createdAtTimestamp: BigInt!
  updatedAtTimestamp: BigInt!
}

现在我们已经为应用程序创建了 GraphQL schema,我们可以在本地生成实体,以便在 CLI 创建的 mappings 中使用:

graph codegen

为了使智能合约、事件和实体的工作变得简单和类型安全,Graph CLI从 subgraph 的 GraphQL schema 和数据源中包含的合约ABI的组合中产生 AssemblyScript 类型。

用实体和映射更新 subgraph

现在我们可以配置 subgraph.yaml,以使用我们刚刚创建的实体并配置相应 mappings:
为此,首先用UserToken实体更新dataSources.mapping.entities字段:

entities:
  - Post

接下来我们需要找到部署合约的区块(可选)。我们需要这个,这样就可以为索引器设置开始同步的块,这样它就不需要从创世块开始同步。你可以通过访问 https://mumbai.polygonscan.com/,并粘贴合约地址来找到起始块。
最后,更新配置,添加 startBlock:

source:
  address: "your-contract-adddress"
  abi: Blog
  startBlock: your-start-block

Assemblyscript mappings

接下来,打开 src/mappings.ts,写入我们在 subgraph eventHandlers中定义的映射,用下面的代码更新该文件:

import {
  PostCreated as PostCreatedEvent,
  PostUpdated as PostUpdatedEvent
} from "../generated/Blog/Blog"
import {
  Post
} from "../generated/schema"
import { ipfs, json } from '@graphprotocol/graph-ts'

export function handlePostCreated(event: PostCreatedEvent): void {
  let post = new Post(event.params.id.toString());
  post.title = event.params.title;
  post.contentHash = event.params.hash;
  let data = ipfs.cat(event.params.hash);
  if (data) {
    let value = json.fromBytes(data).toObject()
    if (value) {
      const content = value.get('content')
      if (content) {
        post.postContent = content.toString()
      }
    }
  }
  post.createdAtTimestamp = event.block.timestamp;
  post.save()
}

export function handlePostUpdated(event: PostUpdatedEvent): void {
  let post = Post.load(event.params.id.toString());
  if (post) {
    post.title = event.params.title;
    post.contentHash = event.params.hash;
    post.published = event.params.published;
    let data = ipfs.cat(event.params.hash);
    if (data) {
      let value = json.fromBytes(data).toObject()
      if (value) {
        const content = value.get('content')
        if (content) {
          post.postContent = content.toString()
        }
      }
    }
    post.updatedAtTimestamp = event.block.timestamp;
    post.save()
  }
}

这些映射将处理创建新帖子和更新帖子时的事件。当这些事件发生时,这些映射将把数据保存到subgraph中。

运行构建

接下来,让我们运行一次构建,以确保一切配置正确。为此,运行 build 命令:

$ graph build

如果构建成功,你应该看到在根目录下生成了一个新的 build 文件夹。

部署 subgraph

要进行部署,我们可以运行deploy命令。要部署,你首先需要复制账户的 Access token,可在 Graph Dashboard 上找到:

Web3 全栈开发完整指南
接下来,运行以下命令:

$ graph auth
✔ Product for which to initialize · hosted-service
✔ Deploy key · ********************************

部署subgraph:

$ yarn deploy

一旦 subgraph 被部署,你应该看到它显示在仪表板上。
当你点击 subgraph 时,它应该打开 subgraph 的详细信息:

Web3 全栈开发完整指南

查询数据

现在我们已经在仪表盘中了,等待一段时间同步后,我们就可以开始查询数据了。运行下面的查询来获得一个帖子的列表:

{
  posts {
    id
    title
    contentHash
    published
    postContent
  }
}

我们还可以按创建日期顺序方向:

{
  posts(
    orderBy: createdAtTimestamp
    orderDirection: desc
  ) {
    id
    title
    contentHash
    published
    postContent
  }
}

我们还可以对文章标题或内容进行全文搜索:

{
  postSearch(
    text: "Hello"
  ) {
    id
    title
    contentHash
    published
    postContent
  }
}

恭喜你,你现在已经建立了一个更灵活的 API,你可以用它来查询你的应用程序!
要学习如何在你的应用程序中使用 API 端点(endpoint),请查看文档 这里 或视频 这里

下一步

如果您想要挑战,考虑添加一些功能,允许您更新 pages/edit-post/[id].js 中的封面图像。
如果您想将代码部署到 web3/分散式 git 托管协议,请查看 Radicle 上的此视频
如果您想部署您的应用程序并使其上线,请查看 Vercel
如果您喜欢本教程,请务必查看我的其他三个综合指南:

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

参考文档:Web3 全栈开发指南

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 5

表示一样看不懂

1年前 评论

very good !?

1年前 评论

兄弟可以加个微信或者tg交流一下

1年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!