你的第一个 DApp

在本教程中,你将学习如何在 Aptos 区块链上建立一个 DApp。一个 DApp 通常由一个用 JavaScript 编写的用户界面组成,它与一个或多个 Move 模块进行交互。

我们将使用你的第一个 Move 模块中使用的 Move 模块 HelloBlockchain,并着重于构建用户界面。

我们将使用以下工具:

最终的结果是一个让用户在 Aptos 区块链上发布和分享文本片段的 DApp。

本教程的完整源代码可在此获得

前置准备

Aptos 钱包

在开始教程之前,我们应当先安装 Aptos 钱包扩展程序。

在你安装完钱包之后

  1. 打开钱包并且点击 Create a new wallet,然后点击 Create account 来创建一个 Aptos 的钱包账户。

  2. 复制私钥,你将会在后续章节中的用到它来设置 Aptos CLI

Aptos CLI

  1. 安装 Aptos CLI.

  2. 运行 aptos init 命令,当屏幕提示你输入私钥时,把之前你从钱包复制过来的私钥粘贴进去。这将让 Aptos CLI 程序以与你钱包的相同的账户初始化。

  3. 运行 aptos account list 命令来确认 Aptos CLI 是正常运行的

第 1 步:初始化一个单页应用

我们现在将为我们的 DApp 设置前端的用户界面。在本教程中,我们将使用create-react-app来初始化应用,但 React 和 create-react-app 都不是必需的。你可以选择使用你喜欢的 JavaScript 框架。

$ npx create-react-app first-dapp --template typescript
$ cd first-dapp
$ npm start

至此我们将有一个可以在浏览器中运行的基础 React 应用。

第 2 步:集成 Aptos Wallet Web3 API

Aptos 钱包通过 window.aptos 接口为 dApp 提供了一个 Web3 API。你可以在第一步中由 npm start 打开的浏览器 console 中运行 await window.aptos.account() 来了解它是如何工作的(注意一定要是通过 npm start 打开的浏览器来调用 window.aptos 接口)。它将显示出与你在 Aptos 钱包中设置的帐户相对应的地址,如下图所示:

接下来,我们将更新我们的应用程序,使用这个 API 来显示钱包帐户的地址。

等待 window.aptos 接口被注入到 window DOM 下

在浏览器中集成 window.aptos API 的第一步是在 window.onload 事件触发之后,才开始渲染你的前端界面。

打开 src/index.tsx,更改以下代码片段:

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

window.addEventListener('load', () => {
  root.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
});

这一更改将确保在我们渲染前端元素时 window.aptos API 已经初始化(如果我们渲染得太早,钱包扩展可能还没有机会初始化 API,因此 window.aptos 将变成 undefiend)。

(可选)TypeScript 语言下 window.aptos 的设置

如果你使用的是 TypeScript 语言,你可能还需要通知编译器 window.aptos API 的存在。将以下代码添加到 src/index.tsx中:

declare global {
  interface Window { aptos: any; }
}

这将使我们不再依赖 (window as any).aptos 命令,而直接可以调用 window.aptos API

在应用中展示 window.aptos.account() 结果

我们的应用现在已经可以调用 window.aptos API,我们将改动 src/App.tsx 的代码从而在初始化渲染时获取到window.aptos.account()(你的钱包账户)的值,存储到 React state 下,然后把它展示在前端,代码改动如下:

import React from 'react';
import './App.css';

function App() {
// Retrieve aptos.account on initial render and store it.
  const [address, setAddress] = React.useState<string | null>(null);
  React.useEffect(() => {
    window.aptos.account().then((data : {address: string}) => setAddress(data.address));
  }, []);

  return (
    <div className="App">
      <p><code>{ address }</code></p>
    </div>
  );
}

export default App;

刷新页面即可看到您的帐户地址。

增加 CSS 样式

接下来,替换 src/App.css 代码

a, input, textarea {
  display: block;
}

textarea {
    margin: 4px;
    padding: 8px;
    border: solid 1px #333;
    width: 80%;
}

我们将在测试页面看到自己的 Aptos 钱包地址:

第 3 步:通过 SDK 获取链上数据

现在我们的应用已经集成了钱包的能力,下一步,我们将集成链上数据来获取链上数据。我们将使用 Aptos SDK 来获取我们账户的链上信息并且将其展示在前端页面上

package.json 中增加 aptos 依赖

首先,在工程依赖中增加 Aptos SDK 相关依赖:

$ npm install --save aptos

你将会在 package.json 中看到 "aptos": "^0.0.20" (或者类似的依赖信息)。

创建一个 AptosClient

现在我们可以引入 SDK 并且创建一个可以与 Aptos 区块链交互的 AptosClient 客户端 (准确来说是与 Aptos REST API 交互,这才是直接与 Aptos 区块链交互的基础设施)

只要我们的钱包在测试网(devnet)下,我们就可以搭建一个 AptosClient 来与测试网环境交互,在 src/App.tsx 文件下,增加以下代码:

import React from 'react';
import './App.css';
import { Types, AptosClient } from 'aptos';

// Create an AptosClient to interact with devnet.
const client = new AptosClient('<https://fullnode.devnet.aptoslabs.com/v1>');

function App() {
  // Retrieve aptos.account on initial render and store it.
  const [address, setAddress] = React.useState<string | null>(null);
  React.useEffect(() => {
    window.aptos.account().then((data : {address: string}) => setAddress(data.address));
  }, []);

  // Use the AptosClient to retrieve details about the account.
  const [account, setAccount] = React.useState<Types.AccountData | null>(null);
  React.useEffect(() => {
    if (!address) return;
    client.getAccount(address).then(setAccount);
  }, [address]);

  return (
    <div className="App">
      <p><code>address: { address }</code></p>
      <p><code>sequence: { account?.sequence_number }</code></p>
    </div>
  );
}

export default App;

现在,前端界面除了展示账户地址,还会展示账户的 sequence_number。该 sequence_number 代表了下一笔交易的 sequence_number ,以用于避免重放攻击。随着你通过该账户执行的交易的增加,该 sequence_number 也会随之增加。

在这一步结束后,你将得到以下的页面:

第 4 步:发布一个 Move 模块

我们的 DApp 现在已经可以读取链上数据,下一步则是在链上写入数据。为了完成这个目标,我们将在自己的账户地址上,发布一个 Move 模块。

Move 模块提供了(我们将要写入的数据)存储的空间。具体来说,我们将使用你的第一个 Move 模块中的 hello_blockchain 模块,它提供了一个名为 MessageHolder 的资源,该资源持有一个字符串(称为 message)。

使用 Aptos CLI 发布 hello_blockchain 模块

我们将使用 Aptos CLI 来编译并发布 hello_blockchain 模块。

  1. 接着(在终端)调用 aptos move publish 命令(记得把 /path/to/hello_blockchain/ 替换成你的下载路径,并且将 <address> 替换为你的 Aptos 账户地址;同时以最新版本的 move-examples/hello_blockchain/sources/hello_blockchain.move 下的命名地址为准,比如 Aptos CLI 0.3.2 版本下的命名地址即为 hello_blockchain

$ aptos move publish --package-dir /path/to/hello_blockchain/ --named-addresses hello_blockchain=<address>

比如:

$ aptos move publish --package-dir ~/code/aptos-core/aptos-move/move-examples/hello_blockchain/ --named-addresses HelloBlockchain=0x5af503b5c379bd69f46184304975e1ef1fa57f422dd193cdad67dc139d532481

—name-addresses 将会把 hello_blockchain.move 中的命名地址 hello_blockchain 替换为用户在命令行中指定的地址。举个例子,如果我们指定 -named-addresses hello_blockchain=0x5af503b5c379bd69f46184304975e1ef1fa57f422dd193cdad67dc139d532481,那么以下代码:

module hello_blockchain::Message {

将等价为:

module 0x5af503b5c379bd69f46184304975e1ef1fa57f422dd193cdad67dc139d532481::Message {

这使得它可以在指定的账户地址上(在这种情况下是我们的钱包账户地址)发布模块。

假设你的账户有足够的资金来执行交易,你现在可以在你的账户中发布 HelloBlockchain 模块。如果你刷新你的 DApp 前端页面,你会看到账户的序列号已经从 0 增加到 1。

你也可以通过进入 Aptos Explorer 查找你的账户来验证该模块是否已发布。如果你向下滚动到账户模块部分,你应该看到类似以下内容。

{
  "address": "0x5af503b5c379bd69f46184304975e1ef1fa57f422dd193cdad67dc139d532481",
  "name": "Message",
  "friends": [],
  "exposedFunctions": [
    {
      "name": "get_message",
      "visibility": "public",
      "genericTypeParams": [],
      "params": [
        "address"
      ],
      "_return": [
        "0x1::string::String"
      ]
    },
    {
      "name": "set_message",
      "visibility": "script",
      "genericTypeParams": [],
      "params": [
        "signer",
        "vector"
      ],
      "_return": []
    }
  ],
  "structs": [
    {
      "name": "MessageChangeEvent",
      "isNative": false,
      "abilities": [
        "drop",
        "store"
      ],
      "genericTypeParams": [],
      "fields": [
        {
          "name": "from_message",
          "type": "0x1::string::String"
        },
        {
          "name": "to_message",
          "type": "0x1::string::String"
        }
      ]
    },
    {
      "name": "MessageHolder",
      "isNative": false,
      "abilities": [
        "key"
      ],
      "genericTypeParams": [],
      "fields": [
        {
          "name": "message",
          "type": "0x1::string::String"
        },
        {
          "name": "message_change_events",
          "type": "0x1::event::EventHandle<0x5af503b5c379bd69f46184304975e1ef1fa57f422dd193cdad67dc139d532481::Message::MessageChangeEvent>"
        }
      ]
    }
  ]
}

记下 "name”: "message",我们将在下一节使用它。

在 DApp 中增加模块发布命令

为了方便用户,如果模块不存在,我们可以在 DApp 界面上显示 aptos move publish 命令。为此,我们将使用 Aptos SDK 来检索账户模块,并寻找其中 [module.abi.name](<http://module.abi.name>) 等于 "Message "的模块(即我们在 Aptos Explorer 中看到的 "name": "message")。

将以下代码更新到 src/App.tsx 中:

import React from 'react';
import './App.css';
import { Types, AptosClient } from 'aptos';

// Create an AptosClient to interact with devnet.
const client = new AptosClient('<https://fullnode.devnet.aptoslabs.com/v1>');

/** Convert string to hex-encoded utf-8 bytes. */
function stringToHex(text: string) {
  const encoder = new TextEncoder();
  const encoded = encoder.encode(text);
  return Array.from(encoded, (i) => i.toString(16).padStart(2, "0")).join("");
}

function App() {
  // Retrieve aptos.account on initial render and store it.
  const [address, setAddress] = React.useState<string | null>(null);
  React.useEffect(() => {
    window.aptos.account().then((data : {address: string}) => setAddress(data.address));
  }, []);

  // Use the AptosClient to retrieve details about the account.
  const [account, setAccount] = React.useState<Types.AccountData | null>(null);
  React.useEffect(() => {
    if (!address) return;
    client.getAccount(address).then(setAccount);
  }, [address]);

  // Check for the module; show publish instructions if not present.
  const [modules, setModules] = React.useState<Types.MoveModuleBytecode[]>([]);
  React.useEffect(() => {
    if (!address) return;
    client.getAccountModules(address).then(setModules); 
  }, [address]);

  const hasModule = modules.some((m) => m.abi?.name === 'message');
  const publishInstructions = (
    <pre>
      Run this command to publish the module:
      <br />
      aptos move publish --package-dir /path/to/hello_blockchain/
      --named-addresses hello_blockchain={address}
    </pre>
  );
  const hasModuleText = (
    <pre>
      you have a module called "message" defined!
    </pre>
  )

  return (
    <div className="App">
      <p><code>address: { address }</code></p>
      <p><code>sequence: { account?.sequence_number }</code></p>
      {hasModule ? (
        hasModuleText
      ) : publishInstructions}
    </div>
  );
}

export default App;

此时,如果你的钱包地址下已经发布了名称为 message 的模块,你将会看到:

如果没有发布过对应名称的模块,则会提醒你使用以下命令去发布一个 Move 模块:

第 5 步:在链上写入消息

现在我们的 Move 模块已经发布到链上,我们已经可以在链上写入数据了!在这一步我们将使用模块中暴露的 set_message 函数来在链上写入一条消息

一笔调用了 set_message 函数的交易

set_message 的签名如下所示:

public entry fun set_message(account: signer, message: string::String)
    acquires MessageHolder

为了调用这个函数,我们需要使用钱包提供的 window.aptos API来提交一个交易。具体来说,我们将创建一个 entry_function_payload 交易,看起来像这样。

{
  type: "entry_function_payload",
  function: "<address>::Message::set_message",
  arguments: ["message>"],
  type_arguments: []
}

不需要提供 account: signer 参数。Aptos 钱包会自动提供它。

使用 window.aptos API 提交 set_message 交易

现在我们明白了如何使用交易来调用 set_message 函数,接下来我们使用window.aptos.signAndSubmitTransaction() 从我们的 DApp 中调用这个函数。

我们将增加如下功能:

  • 一个可以输入信息详情的 <textarea>

  • 一个 <button>,用 `<textarea>的内容调用set_message函数。

更新 src/App.tsx 的代码:

import React from 'react';
import './App.css';
import { Types, AptosClient } from 'aptos';

// Create an AptosClient to interact with devnet.
const client = new AptosClient('<https://fullnode.devnet.aptoslabs.com/v1>');

const connectToWallet = async() =>{
  const status = await(window as any).aptos.isConnected();
  if (!status){
    await window.aptos.connect();
  }
}

function App() {
  // Retrieve aptos.account on initial render and store it.
  const [address, setAddress] = React.useState<string | null>(null);
  React.useEffect(() => {
    connectToWallet().then(() => {
      window.aptos.account().then((data : {address: string}) => setAddress(data.address));
    })
  }, []);

  // Use the AptosClient to retrieve details about the account.
  const [account, setAccount] = React.useState<Types.AccountData | null>(null);

  // Check for the module; show publish instructions if not present.
  const [modules, setModules] = React.useState<Types.MoveModuleBytecode[]>([]);
  React.useEffect(() => {
    if (!address) return;
    client.getAccount(address).then(setAccount);
    client.getAccountModules(address).then(setModules); 
  }, [address]);

  const hasModule = modules.some((m) => m.abi?.name === 'message');
  const publishInstructions = (
    <pre>
      Run this command to publish the module:
      <br />
      aptos move publish --package-dir /path/to/hello_blockchain/
      --named-addresses hello_blockchain={address}
    </pre>
  );

  // Call set_message with the textarea value on submit.
  const ref = React.createRef<HTMLTextAreaElement>();
  const [isSaving, setIsSaving] = React.useState(false);
  const handleSubmit = async (e: any) => {
    e.preventDefault();
    if (!ref.current) return;

    const message = ref.current.value;
    const transaction = {
      type: "entry_function_payload",
      function: `${address}::message::set_message`,
      arguments: [message],
      type_arguments: [],
    };

    try {
      setIsSaving(true);
      await window.aptos.signAndSubmitTransaction(transaction);
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <div className="App">
      <p><code>address: { address }</code></p>
      <p><code>sequence: { account?.sequence_number }</code></p>
      {hasModule ? (
        <form onSubmit={handleSubmit}>
          <textarea ref={ref} />
          <input disabled={isSaving} type="submit" />
        </form>
      ) : publishInstructions}
    </div>
  );
}

export default App;

运行你的 DApp 之后,你将看到以下的界面:

测试你的 DApp:

  • <textarea> 中输入一些信息并且点击 提交 按钮提交该表单

  • Aptos Explorer 中搜索你的账户地址,现在你会在账户资源下看到一个 MessageHolder 资源,上面有你刚刚提交的信息。

如果你没有看到,请尝试一个较短的信息。长信息可能会导致交易失败,因为长信息需要消耗更多的 gas 费用。

第 6 步:在 DApp 中展示刚刚设置的信息

现在我们已经创建了 MessageHolder 资源,可以使用 Aptos SDK 来获取它并显示消息。

获取钱包地址下的信息

为了获取这条信息,我们将:

  • 首先使用 AptosClient.getAccountResources() 函数获取该地址下的资源,并且将它缓存在前端的 state 中

  • 接下来我们要寻找 typeMessageHolder 的资源,该类型的完整路径地址为 $address::message::MessageHolder,是 $address::message 模块的一部分,在我们的例子中,完整的路径地址为:

    0xaedd72868bc24fcc42eb481b874a1aadd0a648779aaacb69498b69c5c10fc47a::message::MessageHolder
  • 我们将使用这个地址下的现值作为 DApp 的 <textarea> 的默认缺省值

src/App.tsx 代码更新为:

import React from 'react';
import './App.css';
import { Types, AptosClient } from 'aptos';

// Create an AptosClient to interact with devnet.
const client = new AptosClient('<https://fullnode.devnet.aptoslabs.com/v1>');

const connectToWallet = async() =>{
  const status = await(window as any).aptos.isConnected();
  if (!status){
    await window.aptos.connect();
  }
}

function App() {
  // Retrieve aptos.account on initial render and store it.
  const [address, setAddress] = React.useState<string | null>(null);
  React.useEffect(() => {
    connectToWallet().then(() => {
      window.aptos.account().then((data : {address: string}) => setAddress(data.address));
    })
  }, []);

  // Use the AptosClient to retrieve details about the account.
  const [account, setAccount] = React.useState<Types.AccountData | null>(null);

  // Check for the module; show publish instructions if not present.
  const [modules, setModules] = React.useState<Types.MoveModuleBytecode[]>([]);
  React.useEffect(() => {
    if (!address) return;
    client.getAccount(address).then(setAccount);
    client.getAccountModules(address).then(setModules); 
  }, [address]);

  const hasModule = modules.some((m) => m.abi?.name === 'message');
  const publishInstructions = (
    <pre>
      Run this command to publish the module:
      <br />
      aptos move publish --package-dir /path/to/hello_blockchain/
      --named-addresses hello_blockchain={address}
    </pre>
  );

  const [resources, setResources] = React.useState<Types.MoveResource[]>([]);
  React.useEffect(() => {
    if (!address) return;
    client.getAccountResources(address).then(setResources);
  }, [address]);
  const resourceType = `${address}::message::MessageHolder`;
  const resource = resources.find((r) => r.type === resourceType);
  const data = resource?.data as {message: string} | undefined;
  const message = data?.message;

  // Call set_message with the textarea value on submit.
  const ref = React.createRef<HTMLTextAreaElement>();
  const [isSaving, setIsSaving] = React.useState(false);
  const handleSubmit = async (e: any) => {
    e.preventDefault();
    if (!ref.current) return;

    const message = ref.current.value;
    const transaction = {
      type: "entry_function_payload",
      function: `${address}::message::set_message`,
      arguments: [message],
      type_arguments: [],
    };

    try {
      setIsSaving(true);
      await window.aptos.signAndSubmitTransaction(transaction);
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <div className="App">
      <p><code>address: { address }</code></p>
      <p><code>sequence: { account?.sequence_number }</code></p>
      {hasModule ? (
        <form onSubmit={handleSubmit}>
          <textarea ref={ref} defaultValue={message} />
          <input disabled={isSaving} type="submit" />
        </form>
      ) : publishInstructions}
    </div>
  );
}

export default App;

为了验证我们的代码功能:

  • 刷新 DApp 前端页面,我们将会看到我们之前写入的信息被填进 <textarea>

  • 更新 <textarea> 中的文字,并且提交表单,最后再刷新一下页面。你将看到 <textarea> 更新成了你刚刚提交的新信息

这也验证了你正在读取/写入 Aptos 区块链上的信息。

展示其他账户上存储的信息

到目前为止,我们已经建立了一个 "只有一个用户的" DApp,你可以在你自己的账户上阅读和编写信息。接下来,我们将使其他人也能阅读信息,包括没有安装 Aptos 钱包的人。

我们将对其进行设置,以便进入地址尾缀为 /<账户地址> 的页面显示存储在 <账户地址> 下的信息(如果它存在的话)。

  • 如果应用程序在 /<账户地址> 处加载,我们也将禁止编辑。

  • 如果编辑功能被启用,我们将显示一个"获取公共URL"的链接,以便你可以分享你的信息。

更新 src/App.tsx:

import React from 'react';
import './App.css';
import { Types, AptosClient } from 'aptos';

// Create an AptosClient to interact with devnet.
const client = new AptosClient('<https://fullnode.devnet.aptoslabs.com/v1>');

const connectToWallet = async() =>{
  const status = await(window as any).aptos.isConnected();
  if (!status){
    await window.aptos.connect();
  }
}

function App() {

  // Retrieve address first on URL
  const urlAddress = window.location.pathname.slice(1);
  const isEditable = !urlAddress;

  // Retrieve aptos.account on initial render and store it.
  const [address, setAddress] = React.useState<string | null>(null);
  React.useEffect(() => {
    if (urlAddress) {
      setAddress(urlAddress)
    } else {
      connectToWallet().then(() => {
        window.aptos.account().then((data : {address: string}) => setAddress(data.address));
      })
    }
  }, [urlAddress]);

  // Use the AptosClient to retrieve details about the account.
  const [account, setAccount] = React.useState<Types.AccountData | null>(null);

  // Check for the module; show publish instructions if not present.
  const [modules, setModules] = React.useState<Types.MoveModuleBytecode[]>([]);
  React.useEffect(() => {
    if (!address) return;
    client.getAccount(address).then(setAccount);
    client.getAccountModules(address).then(setModules); 
  }, [address]);

  const hasModule = modules.some((m) => m.abi?.name === 'message');
  const publishInstructions = (
    <pre>
      Run this command to publish the module:
      <br />
      aptos move publish --package-dir /path/to/hello_blockchain/
      --named-addresses hello_blockchain={address}
    </pre>
  );

  const [resources, setResources] = React.useState<Types.MoveResource[]>([]);
  React.useEffect(() => {
    if (!address) return;
    client.getAccountResources(address).then(setResources);
  }, [address]);
  const resourceType = `${address}::message::MessageHolder`;
  const resource = resources.find((r) => r.type === resourceType);
  const data = resource?.data as {message: string} | undefined;
  const message = data?.message;

  // Call set_message with the textarea value on submit.
  const ref = React.createRef<HTMLTextAreaElement>();
  const [isSaving, setIsSaving] = React.useState(false);
  const handleSubmit = async (e: any) => {
    e.preventDefault();
    if (!ref.current) return;

    const message = ref.current.value;
    const transaction = {
      type: "entry_function_payload",
      function: `${address}::message::set_message`,
      arguments: [message],
      type_arguments: [],
    };

    try {
      setIsSaving(true);
      await window.aptos.signAndSubmitTransaction(transaction);
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <div className="App">
      <p><code>address: { address }</code></p>
      <p><code>sequence: { account?.sequence_number }</code></p>
      {hasModule ? (
        <form onSubmit={handleSubmit}>
          <textarea ref={ref} defaultValue={message} readOnly={!isEditable} disabled={!isEditable}/>
          {isEditable && (<input disabled={isSaving} type="submit" />)}
          {isEditable && (<a href={address!}>Get public URL</a>)}
        </form>
      ) : publishInstructions}
    </div>
  );
}

export default App;

我们可以看到对于加了账户地址后缀的 url,进入时 <textarea> 被禁用,且展示了当前账户地址下的 message 信息:

而没有加账户地址后缀时,则依然可以输入信息,提交表单,修改链上当前钱包账户地址下的数据。

至此,我们的 DApp 内容结束 🎉

Last updated