React家谱应用迁移到链上,最容易误判的一点是把ERC-721当成万能的资产容器。实际上ERC-721只关心令牌是否存在、属于谁、元数据URI是什么,它不会回答这个令牌的父亲节点是哪一个、配偶关系是否被合约承认。如果继续沿用中心化数据库里那张 relationships 表,链上资产和亲属关系就会分裂,前端需要同时维护两个数据源。EIP8920与Genealogy的思路是把关系也写进智能合约,让每个家庭成员对应一个NFT,亲属连接通过链上映射和事件对外暴露。React端的迁移重点因此不再是简单替换API,而是围绕合约的事件与查询函数重构数据获取、状态更新和权限展示。

一、先厘清EIP8920和Genealogy到底解决什么
EIP8920是一套面向家谱场景的NFT扩展接口。它没有重新发明令牌标准,而是继承ERC-721,增加亲属关系相关的枚举、事件和只读查询函数。Genealogy可以理解为EIP8920的参考实现合约,它把铸造出来的每个家谱成员NFT通过映射结构关联起来。比如一个令牌可以拥有多个父母、多个子女以及一个配偶,这些关系不写在元数据JSON里,而是直接存储在合约的链上映射中。
这样做的好处是关系本身具备链上最终性,不依赖任何中心化数据库。前端查询某个令牌的血缘链时,不再需要先查关系表再关联令牌,只要调用合约的 getParents、getChildren、getLineage 等函数即可得到链上确认的结果。对比传统ERC-721迁移方案,EIP8920把家谱应用的核心逻辑从后端服务下沉到了合约层,React前端因此可以做到更薄的业务封装。
下面是一个简化的Genealogy接口,它展示了合约需要暴露的最小能力。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
interface IGenealogy is IERC721 {
enum RelationType { Parent, Child, Spouse }
event RelationLinked(uint256 indexed fromToken, uint256 indexed toToken, RelationType relation);
event RelationUnlinked(uint256 indexed fromToken, uint256 indexed toToken, RelationType relation);
function linkRelation(uint256 fromToken, uint256 toToken, RelationType relation) external;
function unlinkRelation(uint256 fromToken, uint256 toToken, RelationType relation) external;
function getParents(uint256 tokenId) external view returns (uint256[] memory);
function getChildren(uint256 tokenId) external view returns (uint256[] memory);
function getSpouse(uint256 tokenId) external view returns (uint256);
function getLineage(uint256 tokenId) external view returns (uint256[] memory);
}
前端在迁移前需要先拿到这套ABI,并且确认部署的Genealogy合约地址。很多开发者会忽略接口版本差异,例如有的实现把配偶关系设计成双向映射,有的只保留单向。迁移过程中最好把ABI文件纳入前端仓库版本管理,避免在构建时从第三方地址动态拉取导致不确定性。
二、React数据层迁移:从单资产展示到关系图
传统React家谱应用通常靠REST接口返回树形JSON,组件拿到数据后直接渲染。迁到EIP8920之后,数据来源变成合约查询。一个常见错误是在组件的 useEffect 里直接对每个节点分别发起链上请求,导致节点数量稍多时出现大量重复调用和可见的加载延迟。合理的做法是把合约读取封装成自定义Hook,统一拉取全部令牌并批量解析关系。
下面是一个基础版本的 useGenealogyTree。它先读取总供应量,再按索引遍历令牌,把父母关系和配偶关系抽成边结构。示例中保留了取消标志,防止React严格模式或组件卸载后继续设置状态。
import { useEffect, useState } from 'react';
import { ethers } from 'ethers';
export function useGenealogyTree(contractAddress, provider) {
const [nodes, setNodes] = useState([]);
const [edges, setEdges] = useState([]);
useEffect(function () {
let cancelled = false;
const contract = new ethers.Contract(contractAddress, GENEALOGY_ABI, provider);
async function loadTree() {
const totalSupply = await contract.totalSupply();
const nextNodes = [];
const nextEdges = [];
for (let i = 0; i < totalSupply.toNumber(); i += 1) {
const tokenId = await contract.tokenByIndex(i);
const parents = await contract.getParents(tokenId);
const spouse = await contract.getSpouse(tokenId);
const parentList = parents.map(function (parentId) {
return parentId.toString();
});
nextNodes.push({ id: tokenId.toString(), parents: parentList });
parentList.forEach(function (parentId) {
nextEdges.push({ source: parentId, target: tokenId.toString() });
});
if (!spouse.isZero()) {
nextEdges.push({ source: tokenId.toString(), target: spouse.toString(), type: 'spouse' });
}
}
if (!cancelled) {
setNodes(nextNodes);
setEdges(nextEdges);
}
}
loadTree();
return function () {
cancelled = true;
};
}, [contractAddress, provider]);
return { nodes, edges };
}
这个Hook本身只处理链上关系,不关心元数据里的姓名、头像、生平信息。实践中应该把元数据URI也读取出来,再通过去中心化网关或IPFS缓存层获取JSON。迁移时建议在React状态中保留两类数据:一类是链上关系,一类是链下元数据,两者以tokenId为主键进行合并。这样即使IPFS网关响应慢,家谱树结构仍然可以先渲染出来。
另一个要注意的是Provider稳定引用。很多React迁移项目把 new ethers.providers.Web3Provider(window.ethereum) 写在组件内部,每次渲染都生成新的provider实例,导致Hook依赖项变化,反复触发合约读取。可以把provider提升到Context或模块单例中,只在钱包或网络切换时重建。
三、批量铸造与关系绑定的事务设计
已有家谱数据迁移到链上不能指望一笔交易完成。每个成员要先铸造为NFT,拿到链上tokenId,再对其他成员执行关系绑定。原因很简单:关系绑定函数的参数是目标tokenId,而不是姓名或数据库主键。如果前端把旧系统的自增ID直接当tokenId使用,会与合约实际铸造顺序脱节,导致绑定到错误令牌。
迁移脚本应该分两个阶段。第一阶段循环铸造全部成员,并把交易回执中的tokenId保存到数组。第二阶段再遍历旧关系表,根据数组索引找到父母、子女或配偶对应的tokenId,逐条调用 linkRelation。顺序上建议先绑定父母子女,再绑定配偶,因为配偶关系通常只在核心里呈现,血缘链才是家谱树的主体。
import { ethers } from 'ethers';
const FAMILY_MEMBERS = [
{ name: '曾祖父', metadata: 'ipfs://Qm.../1.json' },
{ name: '祖父', metadata: 'ipfs://Qm.../2.json', parents: [0] }
];
async function migrate() {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const contract = new ethers.Contract(process.env.REACT_APP_GENEALOGY_ADDRESS, GENEALOGY_ABI, signer);
const tokenIds = [];
for (let i = 0; i < FAMILY_MEMBERS.length; i += 1) {
const tx = await contract.mint(signer.getAddress(), FAMILY_MEMBERS[i].metadata);
const receipt = await tx.wait();
const event = receipt.events.find(function (e) {
return e.event === 'Minted';
});
tokenIds.push(event.args.tokenId);
}
for (let i = 0; i < FAMILY_MEMBERS.length; i += 1) {
const parents = FAMILY_MEMBERS[i].parents || [];
for (let j = 0; j < parents.length; j += 1) {
const parentTokenId = tokenIds[parents[j]];
const tx = await contract.linkRelation(parentTokenId, tokenIds[i], 0);
await tx.wait();
}
}
}
真实迁移时,交易之间最好保留短暂间隔,并在每笔交易后更新前端进度条。批量铸造阶段如果有一笔失败,前端要能记录失败索引,避免整批状态丢失。Gas消耗方面,关系绑定通常比单纯铸造便宜,但大量小交易仍会产生可观的费用。如果合约支持批量接口,应优先使用批量关系绑定,减少前端循环等待和MetaMask弹窗次数。
四、渲染关系图与权限控制
拿到链上节点和边之后,React可以借用现成图形库渲染家谱树。状态结构中的nodes数组对应每一个NFT成员,edges数组对应父母子女连接以及配偶连接。把这些数据映射成图形库需要的格式即可。以下是一个不依赖JSX的轻量示例,仅演示如何将节点数组转换为React元素。
import React from 'react';
function FamilyTree({ nodes, edges }) {
const elements = nodes.map(function (node) {
return React.createElement('div', { key: node.id, className: 'tree-node' }, node.id);
});
return React.createElement('div', { className: 'tree-container' }, elements);
}
实际渲染远比这个复杂,因为家谱不是简单的平铺列表。你需要根据edges重新计算层级坐标,或者直接使用支持树布局的组件。迁移时不要尝试在合约读取循环中直接操作DOM,应把关系数据与视图节点分离。比如用一个纯函数把链上edges转换成图形库需要的拓扑结构,这样后续合约接口变化时只改数据层。
权限控制是React迁移中容易遗漏的部分。EIP8920合约中的 linkRelation 和 unlinkRelation 通常只允许合约管理员或具有特定角色的地址调用。前端在渲染编辑按钮之前,必须先读取当前账户是否拥有权限。否则普通用户点击添加亲属关系后,交易会在链上模拟阶段被拒,MetaMask却已经弹出,体验很差。更稳妥的做法是在用户连接钱包后,立即查询角色信息,把只读模式和编辑模式在UI上区分开。
事件订阅同样要谨慎。合约在关系变更时会发出 RelationLinked 和 RelationUnlinked 事件,React端可以订阅并增量更新图,而不需要每次重新拉取全量数据。但React 18的严格模式会让Effect执行两次,导致重复监听。应该在Effect返回的清理函数中调用 contract.off 移除监听,并用ref保存最新回调,避免闭包捕获旧状态。
五、迁移后测试与常见故障
本地测试阶段可以用Hardhat部署Genealogy合约,再把React应用的合约地址指向本地节点。注意浏览器的MetaMask需要添加本地网络,并且链ID要与部署时一致。迁移脚本中的 process.env.REACT_APP_GENEALOGY_ADDRESS 在本地和生产环境通常不同,最好通过环境配置文件区分。
一个常见故障是关系重复绑定形成环。例如把祖父同时绑定为父亲的子女,又把父亲绑定为祖父的子女,前端关系图会无限展开。合约层未必会校验这种逻辑矛盾,因此React端在渲染前需要做一次环检测。可以先忽略配偶关系,只对父母子女边构建有向图,检查是否出现同一令牌既作为后代又作为祖先的情况。
另一个高频问题是元数据URI无法访问。合约里存储的 ipfs:// 地址并不能直接被所有浏览器识别,React需要把 ipfs:// 转换成网关URL,例如通过环境变量配置的IPFS网关前缀。迁移后还应保留旧系统数据导出的JSON字段命名,避免元数据中姓名键从 name 变成 fullName 导致前端渲染空白。完成这些检查后,React家谱应用就能以NFT为节点、链上关系为边,提供真正可验证的血缘图谱。