一、用生活化的理解打开ABI编解码的大门
很多刚接触区块链开发的开发者,第一次碰到合约交互时,总会卡在“明明代码没写错,为什么传参或取返回值就乱了”的问题。其实核心问题是没搞懂ABI到底是什么——你可以把ABI比作合约和前端(或Node.js)之间的“专属翻译官”:不管是你给合约传数字、字符串,还是合约返回的结构体、嵌套数组,都必须经过这个翻译官的“转码”,否则两边根本没法“沟通”。
举个简单例子:你和一个只会说英文的人说话,需要翻译官把你的中文转成英文,再把英文回复转成中文。合约和你的JavaScript代码之间,ABI就是这个翻译官,它定义了所有“可翻译的规则”:比如函数的参数类型、结构体的字段顺序、嵌套数组的层级,少一条规则,沟通就会出错。
1.1 为什么要重点处理结构体与嵌套数组?
普通类型的编解码很简单,比如传个数字或字符串,翻译官一眼就能认出来。但合约里经常会用到复杂结构:比如用户的订单里,除了订单ID、商品名称,还会有“商品ID数组”(嵌套数组),甚至订单结构体里套了商品结构体数组(嵌套结构体)。这时候如果用默认的编解码方式,就会出现“把数组当成数字返回”“嵌套层级被截断”的问题——这也是本篇要讲的核心:如何让这个翻译官准确识别复杂结构,不搞混数据。
二、ethers.js里的ABI编解码核心用法
ethers.js是当前区块链开发里最常用的工具库之一,它对ABI的处理做了很多封装,不用自己手动写底层的字节码解析,只要严格对应规则就能搞定。下面用完整的实战示例来演示,所有示例都基于ethers.js v5.x版本(当前主流稳定版本)。
2.1 普通类型的编解码示例
先从简单场景入手,理解基础逻辑,再过渡到复杂结构:
// 技术栈:ethers.js v5.x
const { ethers } = require("ethers");
// 1. 定义要交互的合约函数(ABI片段,只写我们需要的函数)
const funcAbi = ["function getUser(string calldata username, uint256 age) view returns (address, uint256)"];
// 2. 创建接口实例,相当于拿到这个函数的“翻译官”
const iface = new ethers.utils.Interface(funcAbi);
// 3. 编码调用参数:把JS里的"张三"和20转成合约能懂的二进制
const encodedCallData = iface.encodeFunctionData("getUser", ["张三", 20]);
console.log("编码后的调用数据(要发给链上的内容):", encodedCallData);
// 4. 解码返回值:把链上返回的二进制转成JS能懂的对象
// 模拟链上返回的原始二进制数据(实际开发中是provider获取的)
const rawReturnData = "0x0000000000000000000000001234567890abcdef1234567890abcdef123456700000000000000000000000000000000000000000000000000000000000000014";
const decodedResult = iface.decodeFunctionResult("getUser", rawReturnData);
console.log("解码后的返回值:", decodedResult);
这段代码里,普通类型的编解码完全没问题,返回的decodedResult是一个数组,第一个元素是用户的地址,第二个是年龄,和我们预期的一致。但如果碰到结构体,逻辑就会变。
2.2 结构体与嵌套数组的编解码示例
假设合约里有这样的复杂结构:一个订单结构体,包含订单ID、商品名称、嵌套的商品ID数组(uint256[]类型)。我们要在ethers.js里正确解析这个结构体:
// 技术栈:ethers.js v5.x
const { ethers } = require("ethers");
// 1. 对应合约的完整ABI片段(注意结构体的定义必须和合约完全一致,顺序、类型都不能错)
const orderAbi = [
"struct Order { uint256 orderId; string goodsName; uint256[] goodsIds; }",
"function getOrder(uint256 orderNo) view returns (Order memory)"
];
const iface = new ethers.utils.Interface(orderAbi);
// 2. 模拟链上返回的Order结构体原始二进制数据(实际开发中从provider获取)
// 简化示例:对应Order的三个字段:orderId=10,goodsName="手机",goodsIds=[1001,1002,1003]
const orderRawData = "0x000000000000000000000000000000000000000000000000000000000000000a // orderId的hex表示
0000000000000000000000000000000000000000000000000000000000000020 // string偏移,指向实际内容位置
0000000000000000000000000000000000000000000000000000000000000003 // 嵌套数组goodsIds的长度
00000000000000000000000000000000000000000000000000000000000003e9 // 1001的hex
0000000000000000000000000000000000000000000000000000000000003ea // 1002的hex
00000000000000000000000000000000000000000000000000000000000003eb // 1003的hex
456c6570686f6e65000000000000000000000000000000000000000000000000 // "手机"的utf8编码hex";
// 3. 解析结构体返回值,得到JS对象
const decodedOrder = iface.decodeFunctionResult("getOrder", orderRawData);
console.log("解析后的Order结构体:", decodedOrder);
// 输出结果:[ BigNumber { _hex: '0x0a' }, '手机', [ BigNumber { _hex: '0x3e9' }, ... ] ]
// 可以直接转成我们需要的格式:const orderObj = { orderId: decodedOrder[0].toNumber(), goodsName: decodedOrder[1], goodsIds: decodedOrder[2].map(id => id.toNumber()) };
这里的核心要点是:ABI里的结构体必须和合约里的字段顺序完全一致,嵌套数组的类型必须写对(这里是uint256[],不能写成uint256),否则解析会完全错误。
三、实战踩坑:结构体与嵌套数组的解析问题
实际开发中,很多人踩坑都是因为“想当然”写ABI,而不是严格对应合约的ABI。这里整理几个高频踩坑点:
3.1 坑1:结构体字段顺序写错
比如合约里的Order结构体顺序是orderId、goodsName、goodsIds,你写ABI的时候写成orderId、goodsIds、goodsName,这时候解析出来的goodsName会变成嵌套数组的长度,因为翻译官把第二个字段(原goodsIds)当成了第三个字段(原goodsName)——调试的时候一定要对着合约的ABI一行一行核对,不能凭记忆写。
3.2 坑2:混淆“直接调用合约”和“手动解析原始数据”
很多开发者分不清两种获取返回值的方式:
// 技术栈:ethers.js v5.x
const provider = new ethers.providers.JsonRpcProvider("https://rpc.ankr.com/eth");
const contractAddr = "0x123abc..."; // 实际合约地址
const abi = [...]; // 完整ABI(包含结构体和函数)
const contract = new ethers.Contract(contractAddr, abi, provider);
// 方式1:直接调用合约方法,返回已经解码后的结果(推荐)
const order1 = await contract.getOrder(10);
console.log("直接调用结果:", order1); // 已经是JS对象,不用手动解码
// 方式2:手动发送call并解码(适合特殊场景,比如自己组装数据)
const rawData = await provider.call({ to: contractAddr, data: iface.encodeFunctionData("getOrder", [10]) });
const order2 = iface.decodeFunctionResult("getOrder", rawData);
如果用方式1却手动调用decodeFunctionResult,会重复解析导致数据混乱;如果用方式2却忘记解码,得到的就是看不懂的二进制。
3.3 坑3:嵌套结构体的ABI缺失
如果结构体里嵌套了另一个结构体,比如Order里包含Goods[] goodsList,那ABI里必须同时定义Goods结构体,否则ethers.js会把goodsList当成普通数组,无法解析里面的结构体字段——这时候要把两个结构体都写进ABI片段里,不能只写外层的。
四、技术优缺点与注意事项
4.1 技术优缺点
- 优点:ethers.js的ABI封装非常智能,会自动把链上的BigNumber转成JS可操作的数字,结构体和嵌套数组的解析几乎不需要手动处理,适合快速开发DApp;
- 缺点:对复杂嵌套结构(超过3层的结构体嵌套)的容错率低,只要ABI少一个字段或顺序错了,就会解析失败,对新手不太友好。
4.2 注意事项
- 复制粘贴才是真理:不要手动写ABI,直接从etherscan上复制合约的完整ABI,或从合约源代码里提取结构体的定义,避免手写错误;
- BigNumber转普通类型:链上返回的数字都是BigNumber类型(防溢出),要转成JS数字或字符串时,用
toNumber()(小数字)或toString(),避免精度丢失; - 版本兼容:不同版本的ethers.js处理结构体的逻辑略有差异,本文示例是v5,如果你用v4,需要调整ABI的写法(比如结构体定义的格式);
- 空数组的处理:如果嵌套数组是空的,解析后会返回空数组,不会报错,放心使用。
五、实战总结
其实整个过程的核心就是两句话:第一,把ABI当成“合约的身份证”,必须和合约的实际结构完全匹配;第二,不要手动解析原始数据,优先用ethers.js的Contract对象调用方法,它会自动帮你处理编解码,尤其是结构体和嵌套数组这类复杂结构。
我自己写了3个DApp后才发现,最省心的方式就是:从etherscan复制完整ABI,直接用ethers.Contract创建实例,需要自定义结构体的时候,再核对一遍结构体的字段顺序和类型,只要这两步做到,几乎不会出解析错误。踩过两次坑之后,现在我写代码再也不会纠结复杂结构的编解码问题,把精力放在业务逻辑上就好。
评论
围绕“ethers.js的ABI编解码机制与合约方法返回值解析,正确处理自定义结构体与嵌套数组的实战心得”参与讨论