广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何将React应用迁移到EIP8670 + Materials虚拟材料NFT?

把 React 应用从普通 NFT 展示迁移到 EIP8670 加 Materials 的虚拟材料体系,表面上是换了合约接口,实际上要动的是前端的数据模型。旧代码通常把 NFT 当成一张图片加一个名称,元数据从 IPFS 或中心化服务器取回来直接塞进卡片。虚拟材料 NFT 不同,它的关键属性必须留在链上,比如基础材料类型、纯度、耐久、元素配比,以及可合成的目标材料 ID。前端如果继续沿用以前的 metadata JSON 结构,React 组件很快会被各种条件判断撑爆。

如何将React应用迁移到EIP8670 + Materials虚拟材料NFT?

文章接下来会先梳理 EIP8670 暴露的只读接口和 Materials 包的定位,然后给出一个可落地的 React Hook 封装方案,最后讨论缓存和批量查询这些迁移中容易忽略的问题。代码基于 ethers.js v6 和 React 18,但思路同样适用于 wagmi 或 viem 项目。

一、EIP8670 接口与 Materials 包定位

EIP8670 并未重新发明 NFT,而是在 ERC-721/1155 之上增加一组标准化的材料查询接口。合约需要实现 getMaterialProperties(uint256 tokenId),返回材料的类型、等级、纯度、耐久和元素构成;同时还要提供 getComposition(uint256 tokenId),把合成该材料所用的原材料 tokenId 列表暴露出来。这个设计的核心价值是让前端不用再猜测某个 NFT 的 metadata 里到底有没有 material 字段,也不用担心不同项目把属性命名成 element 还是 composition。

Materials 是配合 EIP8670 的 TypeScript SDK,内部封装了 ethers.js 的合约调用,并增加了语义化类型。它并不是一个 UI 库,不会帮你画卡片,但会返回结构清晰的 MaterialProperties 对象,避免组件里出现大量 any。如果你已经使用 viem,也可以用 Materials 提供的 ABI 定义自行封装,差别不大。

// 安装 Materials SDK
// npm install @eip8670/materials ethers
import { MaterialsClient, MaterialProperties } from '@eip8670/materials';
import { ethers } from 'ethers';
const provider = new ethers.BrowserProvider(window.ethereum);
const contractAddress = '0x你的材料合约地址';
const client = new MaterialsClient(provider, contractAddress);
// 获取单个材料属性
const props: MaterialProperties = await client.getMaterialProperties(42);
console.log(props.materialType, props.purity, props.durability);

上面的代码展示了 Materials 最容易上手的部分。MaterialsClient 构造时接收 provider 和合约地址,后续所有方法都自动处理 ABI 编码和返回值格式化。注意它默认不会做缓存,每次调用都发起链上请求,这在列表页会造成性能问题。所以迁移时最好在 React 层做一层缓存,这个后面会详细说明。

二、迁移前的数据层盘点与改造路径

旧 React 应用多半有一个 useNFTList 或 fetchNFTs 函数,里面先调用合约的 balanceOf 拿到数量,再循环调用 tokenURI 获取元数据地址,最后用 axios 或 fetch 拉 JSON。如果元数据里没有材料字段,那么前端只能展示图片和编号,无法支持合成、分解、配方匹配等玩法。

迁移的第一步不是直接改 UI,而是新增一个 useMaterialNFT 模块,把链上属性读取和旧元数据读取合并。这样可以让旧的图片渲染继续工作,同时为材料属性建立独立的加载状态。模块内部先拿到用户持有的 tokenId 列表,再用 Promise.all 并发请求 getMaterialProperties,最后把结果映射成渲染模型。

import { useCallback, useEffect, useState } from 'react';
import { MaterialsClient, MaterialProperties } from '@eip8670/materials';
import { ethers } from 'ethers';
interface MaterialNFT extends MaterialProperties {
  tokenId: number;
  image: string;
  name: string;
}
export function useMaterialNFT(contractAddress: string, account?: string) {
  const [items, setItems] = useState<MaterialNFT[]>([]);
  const [loading, setLoading] = useState(false);
  const load = useCallback(async () => {
    if (!account || !window.ethereum) return;
    setLoading(true);
    try {
      const provider = new ethers.BrowserProvider(window.ethereum);
      const client = new MaterialsClient(provider, contractAddress);
      const signer = await provider.getSigner();
      const address = account || (await signer.getAddress());
      // 假设原合约提供 balanceOf 和 tokenOfOwnerByIndex
      const balance = await client.contract.balanceOf(address);
      const tokenIds: number[] = [];
      for (let i = 0; i < balance; i++) {
        const id = await client.contract.tokenOfOwnerByIndex(address, i);
        tokenIds.push(Number(id));
      }
      const propsList = await Promise.all(
        tokenIds.map(async (tokenId) => {
          const props = await client.getMaterialProperties(tokenId);
          return { tokenId, ...props };
        })
      );
      // 兼容旧 metadata,只取图片和名称
      const enriched = await Promise.all(
        propsList.map(async (item) => {
          const uri = await client.contract.tokenURI(item.tokenId);
          const meta = await fetch(uri).then((r) => r.json());
          return { ...item, image: meta.image, name: meta.name };
        })
      );
      setItems(enriched);
    } finally {
      setLoading(false);
    }
  }, [contractAddress, account]);
  useEffect(() => {
    load();
  }, [load]);
  return { items, loading, reload: load };
}

这段代码的关键在于把 getMaterialProperties 和 tokenURI 拆成两个阶段。第一阶段获取链上材料属性,第二阶段补齐旧版元数据里的图片和名称。这样即使某些 token 尚未按照 EIP8670 标准填充完整属性,也不会导致整个列表崩溃。你可以在 catch 块里对单个失败 token 返回一个默认属性对象,而不是让 Promise.all 整体失败。

迁移中另一个容易忽略的点是 tokenOfOwnerByIndex 的枚举方式。如果原有合约为大批量 mint 做过优化,可能没有实现这个枚举接口。此时需要依赖事件日志或子图索引,否则会因为方法不存在而报错。Materials 包本身不解决枚举问题,它只负责单个 token 的属性读取,所以这一块要保留原有逻辑。

三、MaterialCard 组件渲染与合成交互

拿到结构化数据后,React 组件可以专注于展示材料属性,而不用关心字段来源。一个典型的材料卡片需要展示纯度、耐久、元素构成和可合成目标。用条件渲染把纯度低于阈值的材料标灰,或者把耐久不足的提示放在按钮上。这里不要继续沿用原来的 NFT 卡片,因为虚拟材料的视觉重心是属性条而不是图片。

下面给出一个简单的 MaterialCard 组件示例。它接收 MaterialNFT 对象,除了显示材料和图片外,还提供一个合成按钮。合成按钮的点击逻辑会调用 combineMaterials,这需要用户签名交易,所以按钮状态要区分未连接、确认中和已提交。

import { useState } from 'react';
import { MaterialsClient } from '@eip8670/materials';
import { ethers } from 'ethers';
interface Props {
  item: MaterialNFT;
  contractAddress: string;
}
export default function MaterialCard({ item, contractAddress }: Props) {
  const [txHash, setTxHash] = useState<string | null>(null);
  const [pending, setPending] = useState(false);
  const handleCombine = async () => {
    if (!window.ethereum) return;
    setPending(true);
    try {
      const provider = new ethers.BrowserProvider(window.ethereum);
      const signer = await provider.getSigner();
      const client = new MaterialsClient(provider, contractAddress);
      // 假设目标材料 ID 从属性里解析,合成至少需要两个材料
      const targetId = item.compositionTargetIds?.[0];
      if (!targetId) return;
      const tx = await client.combineMaterials(signer, [item.tokenId], targetId);
      await tx.wait();
      setTxHash(tx.hash);
    } finally {
      setPending(false);
    }
  };
  return (
    <div className="material-card">
      <img src={item.image} alt={item.name} />
      <h3>{item.name}</h3>
      <p>类型:{item.materialType} / 纯度:{item.purity}%</p>
      <p>耐久:{item.durability} / 等级:{item.level}</p>
      <button onClick={handleCombine} disabled={pending || !item.compositionTargetIds?.length}>
        {pending ? '确认中...' : '合成'}
      </button>
      {txHash && <p>交易:{txHash}</p>}
    </div>
  );
}

上述 JSX 代码在 pre 中已经做了 HTML 转义,实际项目中直接写 <div> 即可。组件里把 combineMaterials 的第二个参数设为 [item.tokenId],实际业务里需要传入多个原材料 tokenId,且合成规则可能要求不同材料的配比。EIP8670 不限制配比表达方式,常见的做法是在合约内部维护配方映射,前端通过 getRecipe(uint256 targetId) 查询所需材料列表,再让用户选择。

合成按钮的交互还涉及授权和 gas 预估。由于合成操作可能消耗较多 gas,建议在点击后先调用 estimateGas 并显示给用户,否则钱包弹出的确认页会出现高额费用,用户可能直接取消。Materials 客户端的 combineMaterials 默认使用 signer 发送交易,如果你使用 viem,需要自己构造 writeContract 调用。

四、缓存、批量查询与常见陷阱

链上属性的读取不能每次都打到节点。一个持有 200 个材料的钱包,如果每张卡片都单独调 getMaterialProperties,RPC 请求会很快被限流。迁移后必须建立两层缓存:内存缓存按 tokenId 存储属性对象,持久化缓存可以用 localStorage 或 IndexedDB 存储最近一次的链上结果。需要注意的是,链上属性会随合成和分解发生变化,因此缓存要配合事件监听或交易确认后的主动刷新。

批量查询方面,如果你的合约实现了 getBatchMaterialProperties 或类似的多 token 查询方法,优先使用它。否则只能前端分组发送请求,每组控制在 10 到 15 个并发,避免浏览器连接池耗尽。React 中可以使用 p-limit 之类的库控制并发,也可以自己写一个简单的信号量。

  • 不要用 Promise.all 无脑并发超过 50 个请求。
  • 元数据服务器不稳定时,给 fetch 设置超时和重试。
  • 合成成功后,立刻使缓存中的相关 tokenId 失效。
  • 如果使用 SSR 或 Next.js,确保 window.ethereum 只在客户端访问。

最后提醒一点,EIP8670 目前还在草案讨论阶段,不同实现可能对返回结构有细微差异。迁移时最好在你的代码里封装一层归一化函数,把合约返回的原始数据统一成前端需要的形状。这样即使后续标准冻结、字段重命名,也只需要改适配层,不会牵连到所有组件。

广告位 · 文章底部(后台-模块管理替换此区域)