React应用迁移到Cadence与Flow并不意味着推倒重来,而是把前端的状态管理、认证逻辑和持久化方案逐步替换为链上资源模型与钱包交互。对于NFT应用,关键差异在于资产的所有权表示:React组件中的NFT对象只是内存里的副本,刷新即丢,而Cadence资源在Flow账户存储中具有唯一位置。下面围绕资源模型、FCL接入、脚本查询和铸造交易展开。

一、Cadence资源模型与React状态模型的根本差异
React开发者习惯用state保存数据,比如把NFT列表放在组件状态里,通过展开运算符复制对象来更新界面。这种模式在纯前端场景下没有问题,因为JavaScript对象可以任意复制,即使复制出多个副本,内存回收机制最终会处理掉多余引用。但NFT的本质是不可复制的所有权凭证,如果链上合约允许像React那样复制资产,就会出现同一个NFT同时属于两个地址的严重漏洞。Cadence通过线性类型和移动语义强制阻止这种行为,资源一旦创建就不能被隐式复制,只能被移动,移动后原位置的引用自动失效。
下面是一段Cadence资源定义,它展示了NFT合约的基本结构。注意资源使用pub resource声明,而不是普通的struct。资源内部可以包含任意字段,但关键区别在于对资源实例的赋值和传递必须使用移动运算符<-,而不是普通的等号。
pub contract CryptoKitty {
pub resource NFT {
pub let id: UInt64
pub let genes: String
init(id: UInt64, genes: String) {
self.id = id
self.genes = genes
}
}
pub fun createNFT(id: UInt64, genes: String): @NFT {
return <- create NFT(id: id, genes: genes)
}
}
对比React中常见的写法const nftCopy = {...nft},Cadence编译器会直接拒绝任何试图复制资源的代码。例如let copy = nft在资源类型下无法通过编译,必须写成let moved <- nft。这种设计让React开发者必须改变思维:链上NFT资产永远只有一个真实归属,前端展示的只是引用和缓存。迁移时建议把React中代表NFT的完整对象拆分成两部分,链上资源ID保留在合约和账户存储中,前端仅保存ID和可变的展示元数据。这样即使前端缓存被篡改,也不会影响链上真实所有权。
另一个容易忽略的差异是生命周期。React组件的卸载会销毁组件内所有state,但Cadence资源在交易结束后依然存在于Flow账户存储中,只有显式调用销毁或转移函数才会消失。因此,迁移时需要把原本放在useEffect清理函数中的销毁逻辑,替换成对Cadence合约中destroy函数的调用,否则会出现链上资产泄漏。
二、用FCL替换React认证与会话层
传统React应用通常使用JWT或第三方登录服务维持会话,把令牌保存在localStorage中,并在每次请求时附加到请求头。迁移到Flow之后,认证的本质从证明用户身份变成了证明账户持有者授权了某笔链上操作。FCL(Flow Client Library)封装了钱包发现、用户登录和交易签名,前端不再需要管理刷新令牌或纠结跨域Cookie。FCL会把用户重定向到钱包页面,用户授权后返回一个包含地址的user对象。
在React项目里引入FCL只需要做一次全局配置。下面代码展示了如何设置测试网接入节点和钱包发现服务。配置完成后,所有链上交互都会使用统一的账户签名逻辑。
import * as fcl from "@onflow/fcl";
fcl.config()
.put("accessNode.api", "https://rest-testnet.onflow.org")
.put("discovery.wallet", "https://fcl-discovery.onflow.org/testnet/authn")
.put("app.detail.title", "NFT React App")
.put("app.detail.icon", "https://placekitten.com/g/200/200");
登录态管理可以直接用React的useState和useEffect配合FCL的订阅接口。钱包登录成功后,currentUser.subscribe会推送最新的用户对象,React组件据此更新导航栏和地址展示。相比自建认证系统,FCL免去了私钥存储风险,同时天然支持多个钱包,用户可以选择Blocto、Lilico或Ledger。
const [user, setUser] = useState({ loggedIn: false, addr: "" });
useEffect(() => {
fcl.currentUser().subscribe((currentUser) => {
setUser(currentUser);
});
}, []);
const login = async () => {
await fcl.authenticate();
};
需要注意,FCL的登录状态不等于React路由的认证守卫。用户可能已经连接钱包但没有登录,或者登录后主动断开连接。迁移时应把user.loggedIn作为路由保护条件,并在currentUser.subscribe回调中同步更新路由状态。对于原先依赖后端会话的接口,现在可以直接用用户地址作为查询参数,因为地址本身是公开信息,不泄露隐私。
三、从React调用Cadence脚本与交易
React前端与Flow链的交互分为查询和交易两类。查询用来读取链上数据,比如获取某个地址持有的NFT ID列表,这类操作不需要用户签名,也不会消耗Gas。交易用来改变链上状态,比如铸造NFT或转移资产,必须由用户签名并支付少量手续费。FCL分别提供了fcl.query和fcl.mutate两个接口,React代码里可以像调用普通异步函数一样使用它们。
查询NFT列表的Cadence脚本通常放在模板字符串中,通过fcl.query发送到接入节点。下面的示例展示了如何借用账户中的NFT集合能力,并返回所有NFT ID。脚本中的borrow操作需要指定能力类型,这在Cadence里用尖括号加引用类型表示,所有HTML特殊字符在代码块中都需要转义。
const fetchNFTs = async (address) => {
const cadence = `
import NonFungibleToken from 0x1d7e57aa55817448
import ExampleNFT from 0xf8d6e0586b0a20c7
pub fun main(owner: Address): [UInt64] {
let collectionRef = getAccount(owner)
.getCapability(ExampleNFT.CollectionPublicPath)
.borrow<&{ExampleNFT.NFTReceiver}>() ?? panic("Could not borrow collection")
return collectionRef.getIDs()
}
`;
const result = await fcl.query({
cadence,
args: (arg, t) => [arg(address, t.Address)]
});
return result;
};
交易调用比查询多出签名授权部分。下面代码展示了一个铸造NFT并存入接收者集合的交易。它在prepare阶段借用合约中的铸造引用,在execute阶段创建资源并将其移动给接收者。React前端需要传入接收地址和元数据字符串,同时指定payer、proposer和authorizations为当前用户签名。
const mintNFT = async (recipient, metadata) => {
const transactionId = await fcl.mutate({
cadence: `
import ExampleNFT from 0xf8d6e0586b0a20c7
transaction(recipient: Address, metadata: String) {
let minter: &ExampleNFT.NFTMinter
prepare(signer: AuthAccount) {
self.minter = signer.borrow<&ExampleNFT.NFTMinter>(from: /storage/ExampleNFTMinter)
?? panic("Could not borrow minter")
}
execute {
let nft <- self.minter.mintNFT(metadata: metadata)
let recipientAccount = getAccount(recipient)
let collectionRef = recipientAccount
.getCapability(ExampleNFT.CollectionPublicPath)
.borrow<&{ExampleNFT.NFTReceiver}>() ?? panic("Could not borrow receiver")
collectionRef.deposit(token: <-nft)
}
}
`,
args: (arg, t) => [
arg(recipient, t.Address),
arg(metadata, t.String)
],
payer: fcl.authz,
proposer: fcl.authz,
authorizations: [fcl.authz],
limit: 100
});
return transactionId;
};
交易提交后不会立即生效,需要等待Flow网络打包并封装区块。React中常见做法是返回交易ID后显示“交易已提交”状态,然后调用fcl.tx(transactionId).onceSealed()等待最终确认。这个过程通常持续几秒到十几秒,比React本地状态更新慢得多,因此界面设计上必须增加加载和错误重试状态。
四、NFT铸造与二级市场流转的React状态管理
把铸造逻辑封装成自定义Hook可以让React组件保持简洁。下面代码定义了一个useMintNFT Hook,它维护交易状态、交易ID和错误信息,组件只需调用mint函数并根据status渲染按钮文案。这种模式与普通异步请求Hook非常相似,区别在于最终确认不是HTTP响应,而是链上封存事件。
function useMintNFT(recipient) {
const [status, setStatus] = useState("idle");
const [txId, setTxId] = useState("");
const mint = async (metadata) => {
setStatus("submitting");
try {
const currentTxId = await mintNFT(recipient, metadata);
setTxId(currentTxId);
setStatus("sealing");
await fcl.tx(currentTxId).onceSealed();
setStatus("success");
} catch (error) {
setStatus("error");
}
};
return { mint, status, txId };
}
铸造成功后需要刷新NFT列表。React开发者通常习惯用setState立即更新UI,但链上数据不会自动推送。最简单的方案是使用setInterval轮询查询接口,每隔几秒拉取一次最新的NFT ID列表。下面代码展示了在useEffect中设置轮询,并在组件卸载或地址变化时清理定时器。这种方式适合测试网和小规模应用,主网高并发场景建议改为订阅Flow事件或使用Webhook。
useEffect(() => {
const interval = setInterval(async () => {
if (!user.addr) return;
const ids = await fetchNFTs(user.addr);
setNftIds(ids);
}, 5000);
return () => clearInterval(interval);
}, [user.addr]);
二级市场流转涉及挂单、购买和版税分配,React前端的核心变化是把“用户主动修改数据库”替换为“用户签名授权合约转移资源”。例如在市场上购买NFT时,前端会先读取卖家的挂单信息,然后构造一个购买交易,其中包含买家签名和卖家收款地址。交易成功后,合约自动将NFT从卖家集合移动到买家集合,并完成代币支付。React组件不需要维护买卖双方的临时状态,所有关键操作都由Cadence资源移动保证原子性。
迁移完成后,React应用变成Flow链的前端壳层,重业务逻辑下沉到Cadence合约。这种架构让NFT资产真正归用户所有,即使前端停止服务或更换域名,用户依然可以通过钱包直接调用合约转移资产。对于已经熟悉React组件模型的开发者来说,最大的收获不是学会了新的链语言,而是理解了不可变资源与可复制前端状态之间的边界在哪里。