学习来源:https://cryptozombies.io/zh/lesson/6
完成第五课以后,我们的僵尸 DApp 的 Solidity 合约部分就完成了。现在我们来做一个基本的网页好让你的用户能玩它。 要做到这一点,我们将使用以太坊基金发布的 JavaScript 库 —— Web3.js.
还记得么?以太坊网络是由节点组成的,每一个节点都包含了区块链的一份拷贝。当你想要调用一份智能合约的一个方法,你需要从其中一个节点中查找并告诉它:
以太坊节点只能识别一种叫做 JSON-RPC 的语言。这种语言直接读起来并不好懂。当你你想调用一个合约的方法的时候,需要发送的查询语句将会是这样的:
// 哈……祝你写所有这样的函数调用的时候都一次通过
// 往右边拉…… ==>
{"jsonrpc":"2.0","method":"eth_sendTransaction","params":[{"from":"0xb60e8dd61c5d32be8058bb8eb970870f07233155","to":"0xd46e8dd67c5d32be8058bb8eb970870f07244567","gas":"0x76c0","gasPrice":"0x9184e72a000","value":"0x9184e72a","data":"0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675"}],"id":1}
幸运的是 Web3.js 把这些令人讨厌的查询语句都隐藏起来了, 所以你只需要与方便易懂的 JavaScript 界面进行交互即可。
你不需要构建上面的查询语句,在你的代码中调用一个函数看起来将是这样:
CryptoZombies.methods.createRandomZombie("Vitalik Nakamoto ")
.send({ from: "0xb60e8dd61c5d32be8058bb8eb970870f07233155", gas:
"3000000" })
我们将在接下来的几章详细解释这些语句,不过首先我们来把 Web3.js 环境搭建起来。
准备好了么?
取决于你的项目工作流程和你的爱好,你可以用一些常用工具把 Web3.js 添加进来:
// 用 NPM
npm install web3
// 用 Yarn
yarn add web3
// 用 Bower
bower install web3
// ...或者其他。
甚至,你可以从 github 直接下载压缩后的 .js 文件 然后包含到你的项目文件中:
因为我们不想让你花太多在项目环境搭建上,在本教程中我们将使用上面的 script 标签来将 Web3.js 引入。
太棒了。现在我们的项目中有了Web3.js, 来初始化它然后和区块链对话吧。
首先我们需要 Web3 Provider.
要记住,以太坊是由共享同一份数据的相同拷贝的 节点 构成的。 在 Web3.js 里设置 Web3 的 Provider(提供者) 告诉我们的代码应该和 哪个节点 交互来处理我们的读写。这就好像在传统的 Web 应用程序中为你的 API 调用设置远程 Web 服务器的网址。
你可以运行你自己的以太坊节点来作为 Provider。 不过,有一个第三方的服务,可以让你的生活变得轻松点,让你不必为了给你的用户提供DApp而维护一个以太坊节点— Infura.
Infura 是一个服务,它维护了很多以太坊节点并提供了一个缓存层来实现高速读取。你可以用他们的 API 来免费访问这个服务。 用 Infura 作为节点提供者,你可以不用自己运营节点就能很可靠地向以太坊发送、接收信息。
你可以通过这样把 Infura 作为你的 Web3 节点提供者:
var web3 = new Web3(new Web3.providers.WebsocketProvider(“wss://mainnet.infura.io/ws”));
不过,因为我们的 DApp 将被很多人使用,这些用户不单会从区块链读取信息,还会向区块链 写 入信息,我们需要用一个方法让用户可以用他们的私钥给事务签名。
注意: 以太坊 (以及通常意义上的 blockchains )使用一个公钥/私钥对来对给事务做数字签名。把它想成一个数字签名的异常安全的密码。这样当我修改区块链上的数据的时候,我可以用我的公钥来 证明 我就是签名的那个。但是因为没人知道我的私钥,所以没人能伪造我的事务。
加密学非常复杂,所以除非你是个专家并且的确知道自己在做什么,你最好不要在你应用的前端中管理你用户的私钥。
不过幸运的是,你并不需要,已经有可以帮你处理这件事的服务了: Metamask.
Metamask 是 Chrome 和 Firefox 的浏览器扩展, 它能让用户安全地维护他们的以太坊账户和私钥, 并用他们的账户和使用 Web3.js 的网站互动(如果你还没用过它,你肯定会想去安装的——这样你的浏览器就能使用 Web3.js 了,然后你就可以和任何与以太坊区块链通信的网站交互了)
作为开发者,如果你想让用户从他们的浏览器里通过网站和你的DApp交互(就像我们在 CryptoZombies 游戏里一样),你肯定会想要兼容 Metamask 的。
注意: Metamask 默认使用 Infura 的服务器做为 web3 提供者。 就像我们上面做的那样。不过它还为用户提供了选择他们自己 Web3 提供者的选项。所以使用 Metamask 的 web3 提供者,你就给了用户选择权,而自己无需操心这一块。
使用 Metamask 的 web3 提供者
Metamask 把它的 web3 提供者注入到浏览器的全局 JavaScript对象web3中。所以你的应用可以检查 web3 是否存在。若存在就使用 web3.currentProvider 作为它的提供者。
这里是一些 Metamask 提供的示例代码,用来检查用户是否安装了MetaMask,如果没有安装就告诉用户需要安装MetaMask来使用我们的应用。
window.addEventListener('load', function() {
// 检查web3是否已经注入到(Mist/MetaMask)
if (typeof web3 !== 'undefined') {
// 使用 Mist/MetaMask 的提供者
web3js = new Web3(web3.currentProvider);
} else {
// 处理用户没安装的情况, 比如显示一个消息
// 告诉他们要安装 MetaMask 来使用我们的应用
}
// 现在你可以启动你的应用并自由访问 Web3.js:
startApp()
})
你可以在你所有的应用中使用这段样板代码,好检查用户是否安装以及告诉用户安装 MetaMask。
注意: 除了MetaMask,你的用户也可能在使用其他他的私钥管理应用,比如 Mist 浏览器。不过,它们都实现了相同的模式来注入 web3 变量。所以我这里描述的方法对两者是通用的。
现在,我们已经用 MetaMask 的 Web3 提供者初始化了 Web3.js。接下来就让它和我们的智能合约对话吧。
Web3.js 需要两个东西来和你的合约对话: 它的 地址 和它的 ABI。
在你写完了你的智能合约后,你需要编译它并把它部署到以太坊。我们将在下一课中详述部署,因为它和写代码是截然不同的过程,所以我们决定打乱顺序,先来讲 Web3.js。
在你部署智能合约以后,它将获得一个以太坊上的永久地址。如果你还记得第二课,CryptoKitties 在以太坊上的地址是 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d。
你需要在部署后复制这个地址以来和你的智能合约对话。
另一个 Web3.js 为了要和你的智能合约对话而需要的东西是 ABI。
ABI 意为应用二进制接口(Application Binary Interface)。 基本上,它是以 JSON 格式表示合约的方法,告诉 Web3.js 如何以合同理解的方式格式化函数调用。
当你编译你的合约向以太坊部署时(我们将在第七课详述), Solidity 编译器会给你 ABI,所以除了合约地址,你还需要把这个也复制下来。
因为我们这一课不会讲述部署,所以现在我们已经帮你编译了 ABI 并放在了名为cryptozombies_abi.js,文件中,保存在一个名为 cryptoZombiesABI 的变量中。
如果我们将cryptozombies_abi.js 包含进我们的项目,我们就能通过那个变量访问 CryptoZombies ABI 。
一旦你有了合约的地址和 ABI,你可以像这样来实例化 Web3.js。
// 实例化 myContract
var myContract = new web3js.eth.Contract(myABI, myContractAddress);
我们的合约配置好了!现在来用 Web3.js 和它对话。
Web3.js 有两个方法来调用我们合约的函数: call and send.
call 用来调用 view 和 pure 函数。它只运行在本地节点,不会在区块链上创建事务。
复习: view 和 pure 函数是只读的并不会改变区块链的状态。它们也不会消耗任何gas。用户也不会被要求用MetaMask对事务签名。
使用 Web3.js,你可以如下 call 一个名为myMethod的方法并传入一个 123 作为参数:
myContract.methods.myMethod(123).call()
send 将创建一个事务并改变区块链上的数据。你需要用 send 来调用任何非 view 或者 pure 的函数。
注意: send 一个事务将要求用户支付gas,并会要求弹出对话框请求用户使用 Metamask 对事务签名。在我们使用 Metamask 作为我们的 web3 提供者的时候,所有这一切都会在我们调用 send() 的时候自动发生。而我们自己无需在代码中操心这一切,挺爽的吧。
使用 Web3.js, 你可以像这样 send 一个事务调用myMethod 并传入 123 作为参数:
myContract.methods.myMethod(123).send()
语法几乎 call()一模一样。
来看一个使用 call 读取我们合约数据的真实例子
回忆一下,我们定义我们的僵尸数组为 公开(public):
Zombie[] public zombies;
在 Solidity 里,当你定义一个 public变量的时候, 它将自动定义一个公开的 “getter” 同名方法, 所以如果你像要查看 id 为 15 的僵尸,你可以像一个函数一样调用它: zombies(15).
这是如何在外面的前端界面中写一个 JavaScript 方法来传入一个僵尸 id,在我们的合同中查询那个僵尸并返回结果
注意: 本课中所有的示例代码都使用 Web3.js 的 1.0 版,此版本使用的是 Promises 而不是回调函数。你在线上看到的其他教程可能还在使用老版的 Web3.js。在1.0版中,语法改变了不少。如果你从其他教程中复制代码,先确保你们使用的是相同版本的Web3.js。
function getZombieDetails(id) {
return cryptoZombies.methods.zombies(id).call()
}
// 调用函数并做一些其他事情
getZombieDetails(15)
.then(function(result) {
console.log("Zombie 15: " + JSON.stringify(result));
});
我们来看看这里都做了什么
cryptoZombies.methods.zombies(id).call() 将和 Web3 提供者节点通信,告诉它返回从我们的合约中的 Zombie[] public zombies,id为传入参数的僵尸信息。
注意这是 异步的,就像从外部服务器中调用API。所以 Web3 在这里返回了一个 Promises. (如果你对 JavaScript的 Promises 不了解,最好先去学习一下这方面知识再继续)。
一旦那个 promise 被 resolve, (意味着我们从 Web3 提供者那里获得了响应),我们的例子代码将执行 then 语句中的代码,在控制台打出 result。
result 是一个像这样的 JavaScript 对象:
{
"name": "H4XF13LD MORRIS'S COOLER OLDER BROTHER",
"dna": "1337133713371337",
"level": "9999",
"readyTime": "1522498671",
"winCount": "999999999",
"lossCount": "0" // Obviously.
}
我们可以用一些前端逻辑代码来解析这个对象并在前端界面友好展示。
太棒了!你成功地写了一些前端代码来和你的第一个智能合约交互。
接下来我们综合一下——比如我们想让我们应用的首页显示用户的整个僵尸大军。
毫无疑问我们首先需要用 getZombiesByOwner(owner) 来查询当前用户的所有僵尸ID。
但是我们的 Solidity 合约需要 owner 作为 Solidity address。我们如何能知道应用用户的地址呢?
MetaMask 允许用户在扩展中管理多个账户。
我们可以通过这样来获取 web3 变量中激活的当前账户:
var userAccount = web3.eth.accounts[0]
因为用户可以随时在 MetaMask 中切换账户,我们的应用需要监控这个变量,一旦改变就要相应更新界面。例如,若用户的首页展示它们的僵尸大军,当他们在 MetaMask 中切换了账号,我们就需要更新页面来展示新选择的账户的僵尸大军。
我们可以通过 setInterval 方法来做:
var accountInterval = setInterval(function() {
// 检查账户是否切换
if (web3.eth.accounts[0] !== userAccount) {
userAccount = web3.eth.accounts[0];
// 调用一些方法来更新界面
updateInterface();
}
}, 100);
这段代码做的是,每100毫秒检查一次 userAccount 是否还等于 web3.eth.accounts[0] (比如:用户是否还激活了那个账户)。若不等,则将 当前激活用户赋值给 userAccount,然后调用一个函数来更新界面。
如果我们不向你展示如何显示你从合约获取的数据,那这个教程就太不完整了。
在实际应用中,你肯定想要在应用中使用诸如 React 或 Vue.js 这样的前端框架来让你的前端开发变得轻松一些。不过要教授 React 或者 Vue.js 知识的话,就大大超出了本教程的范畴——它们本身就需要几节课甚至一整个教程来教学。
所以为了让 CryptoZombies.io 专注于以太坊和智能合约,我们将使用 JQuery 来做一个快速示例,展示如何解析和展示从智能合约中拿到的数据。
我们已经在代码中添加了一个空的代码块
, 在 displayZombies 方法中也同样有一个。回忆一下在之前章节中我们在 startApp() 方法内部调用了 displayZombies 并传入了 call getZombiesByOwner 获得的结果,它将被传入一个僵尸ID数组,像这样:
[0, 13, 47]
因为我们想让我们的 displayZombies 方法做这些事:
再次声明,我们只用了 JQuery,没有任何模板引擎,所以会非常丑。不过这只是一个如何展示僵尸数据的示例而已。
// 在合约中查找僵尸数据,返回一个对象
getZombieDetails(id)
.then(function(zombie) {
// 用 ES6 的模板语法来向HTML中注入变量
// 把每一个都追加进 #zombies div
$("#zombies").append(`
- Name: ${zombie.name}
- DNA: ${zombie.dna}
- Level: ${zombie.level}
- Wins: ${zombie.winCount}
- Losses: ${zombie.lossCount}
- Ready Time: ${zombie.readyTime}
`);
});
在上面的例子中,我们只是简单地用字符串来显示 DNA。不过在你的 DApp 中,你将需要把 DNA 转换成图片来显示你的僵尸。
我们通过把 DNA 字符串分割成小的字符串来做到这一点,每2位数字代表一个图片,类似这样:
// 得到一个 1-7 的数字来表示僵尸的头:
var head = parseInt(zombie.dna.substring(0, 2)) % 7 + 1
// 我们有7张头部图片:
var headSrc = "../assets/zombieparts/head-" + i + ".png"
每一个模块都用 CSS 绝对定位来显示,在一个上面叠加另外一个。
如果你想看我们的具体实现,我们将用来展示僵尸形象的 Vue.js 模块开源了: 点击这里.
不过,因为那个文件中有太多行代码, 超出了本教程的讨论范围。我们依然还是使用上面超级简单的 JQuery 实现,把美化僵尸的工作作为家庭作业留给你了
这下我们的界面能检测用户的 MetaMask 账户,并自动在首页显示它们的僵尸大军了,有没有很棒?
现在我们来看看用 send 函数来修改我们智能合约里面的数据。
相对 call 函数,send 函数有如下主要区别:
所以在我们的代码中我们需要编写逻辑来处理这部分异步特性。
我们来看一个合约中一个新用户将要调用的第一个函数: createRandomZombie.
作为复习,这里是合约中的 Solidity 代码:
function createRandomZombie(string _name) public {
require(ownerZombieCount[msg.sender] == 0);
uint randDna = _generateRandomDna(_name);
randDna = randDna - randDna % 100;
_createZombie(_name, randDna);
}
这是如何在用 MetaMask 在 Web3.js 中调用这个函数的示例:
function createRandomZombie(name) {
// 这将需要一段时间,所以在界面中告诉用户这一点
// 事务被发送出去了
$("#txStatus").text("正在区块链上创建僵尸,这将需要一会儿...");
// 把事务发送到我们的合约:
return cryptoZombies.methods.createRandomZombie(name)
.send({ from: userAccount })
.on("receipt", function(receipt) {
$("#txStatus").text("成功生成了 " + name + "!");
// 事务被区块链接受了,重新渲染界面
getZombiesByOwner(userAccount).then(displayZombies);
})
.on("error", function(error) {
// 告诉用户合约失败了
$("#txStatus").text(error);
});
}
我们的函数 send 一个事务到我们的 Web3 提供者,然后链式添加一些事件监听:
注意:你可以在调用 send 时选择指定 gas 和 gasPrice, 例如: .send({ from: userAccount, gas: 3000000 })。如果你不指定,MetaMask 将让用户自己选择数值。
attack, changeName, 以及 changeDna 的逻辑将非常雷同,所以本课将不会花时间在上面。
实际上,在调用这些函数的时候已经有了非常多的重复逻辑。所以最好是重构代码把相同的代码写成一个函数。(并对txStatus使用模板系统——我们已经看到用类似 Vue.js 类的框架是多么整洁)
我们来看看另外一种 Web3.js 中需要特殊对待的函数 — payable 函数。
回忆一下在 ZombieHelper 里面,我们添加了一个 payable 函数,用户可以用来升级:
function levelUp(uint _zombieId) external payable {
require(msg.value == levelUpFee);
zombies[_zombieId].level++;
}
和函数一起发送以太非常简单,只有一点需要注意: 我们需要指定发送多少 wei,而不是以太。
一个 wei 是以太的最小单位 — 1 ether 等于 10^18 wei
太多0要数了,不过幸运的是 Web3.js 有一个转换工具来帮我们做这件事:
// 把 1 ETH 转换成 Wei
web3js.utils.toWei("1", "ether");
在我们的 DApp 里, 我们设置了 levelUpFee = 0.001 ether,所以调用 levelUp 方法的时候,我们可以让用户用以下的代码同时发送 0.001 以太:
cryptoZombies.methods.levelUp(zombieId)
.send({ from: userAccount, value: web3js.utils.toWei("0.001","ether") })
如你所见,通过 Web3.js 和合约交互非常简单直接——一旦你的环境建立起来, call 函数和 send 事务和普通的网络API并没有多少不同。
还有一点东西我们想要讲到——订阅合约事件
如果你还记得 zombiefactory.sol,每次新建一个僵尸后,我们会触发一个 NewZombie 事件:
event NewZombie(uint zombieId, string name, uint dna);
在 Web3.js里, 你可以 订阅 一个事件,这样你的 Web3 提供者可以在每次事件发生后触发你的一些代码逻辑:
cryptoZombies.events.NewZombie()
.on("data", function(event) {
let zombie = event.returnValues;
console.log("一个新僵尸诞生了!", zombie.zombieId, zombie.name, zombie.dna);
}).on('error', console.error);
注意这段代码将在 任何 僵尸生成的时候激发一个警告信息——而不仅仅是当前用用户的僵尸。如果我们只想对当前用户发出提醒呢?
为了筛选仅和当前用户相关的事件,我们的 Solidity 合约将必须使用 indexed 关键字,就像我们在 ERC721 实现中的Transfer 事件中那样:
event Transfer(address indexed _from, address indexed _to, uint256 _tokenId);
在这种情况下, 因为_from 和 _to 都是 indexed,这就意味着我们可以在前端事件监听中过滤事件
cryptoZombies.events.Transfer({ filter: { _to: userAccount } })
.on("data", function(event) {
let data = event.returnValues;
// 当前用户更新了一个僵尸!更新界面来显示
}).on('error', console.error);
看到了吧, 使用 event 和 indexed 字段对于监听合约中的更改并将其反映到 DApp 的前端界面中是非常有用的做法。
我们甚至可以用 getPastEvents 查询过去的事件,并用过滤器 fromBlock 和 toBlock 给 Solidity 一个事件日志的时间范围(“block” 在这里代表以太坊区块编号):
cryptoZombies.getPastEvents("NewZombie", { fromBlock: 0, toBlock: 'latest' })
.then(function(events) {
// events 是可以用来遍历的 `event` 对象
// 这段代码将返回给我们从开始以来创建的僵尸列表
});
因为你可以用这个方法来查询从最开始起的事件日志,这就有了一个非常有趣的用例: 用事件来作为一种更便宜的存储。
若你还能记得,在区块链上保存数据是 Solidity 中最贵的操作之一。但是用事件就便宜太多太多了。
这里的短板是,事件不能从智能合约本身读取。但是,如果你有一些数据需要永久性地记录在区块链中以便可以在应用的前端中读取,这将是一个很好的用例。这些数据不会影响智能合约向前的状态。
举个栗子,我们可以用事件来作为僵尸战斗的历史纪录——我们可以在每次僵尸攻击别人以及有一方胜出的时候产生一个事件。智能合约不需要这些数据来计算任何接下来的事情,但是这对我们在前端向用户展示来说是非常有用的东西。
上面的示例代码是针对 Web3.js 最新版1.0的,此版本使用了 WebSockets 来订阅事件。
但是,MetaMask 尚且不支持最新的事件 API (尽管如此,他们已经在实现这部分功能了, 点击这里 查看进度)
所以现在我们必须使用一个单独 Web3 提供者,它针对事件提供了WebSockets支持。 我们可以用 Infura 来像实例化第二份拷贝:
var web3Infura = new Web3(new Web3.providers.WebsocketProvider("wss://mainnet.infura.io/ws"));
var czEvents = new web3Infura.eth.Contract(cryptoZombiesABI, cryptoZombiesAddress);
然后我们将使用 czEvents.events.Transfer 来监听事件,而不再使用 cryptoZombies.events.Transfer。我们将继续在课程的其他部分使用 cryptoZombies.methods。
将来,在 MetaMask 升级了 API 支持 Web3.js 后,我们就不用这么做了。但是现在我们还是要这么做,以使用 Web3.js 更好的最新语法来监听事件。