Web3 全棧開發完整指南

playmaker發表於2022-11-01

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 協議》,轉載必須註明作者和本文連結

相關文章