深入理解技术探索与实践:一个成都斜杠程序员的区块链+JS踩坑实录
上个月底,我在成都一家咖啡馆里改完最后一行代码,抬头一看窗外锦江边已经开始飘桂花香了。这节奏,舒服得我都快忘了自己其实是个“白天在大厂写CRUD,晚上接外包肝副业”的斜杠码农。
平时除了维护几个老项目,我最大的乐趣就是翻开源项目的源码——不是为了装逼,是真的觉得看别人怎么组织代码、处理边界情况特别爽。最近一次让我半夜三点还兴奋地拍大腿的,是一次硬核的技术探索:把 JavaScript 和区块链结合起来搞点新东西。这篇文章就来聊聊这段又哭又笑的经历。
起因:产品经理一句话,我熬了三个通宵
事情要从去年双11前说起。我们组接了个外包需求:给一家做数字藏品的小公司开发一个“链上资产查询 + 链下展示”的轻量级前端系统。客户要求不高(他们说的):用户输入钱包地址,就能看到他在某条公链上的 NFT 列表,并且能实时刷新。
听起来不难?但问题来了——他们用的是自建的私有链,基于 Ethereum 的 Geth 客户端魔改了一堆逻辑,文档基本等于没有。更骚的是,后端团队甩锅说:“链上数据我们没法直接查,你前端自己连节点吧。”
我当时心里一万只羊驼奔腾而过。但转念一想:这不正好是个深入研究区块链底层 + JS 交互的好机会?顺便还能加到我的副业作品集里。于是咬牙接了下来。
小插曲:第二天晨会,测试同学幽幽地说:“上次你改的那个 Web3.js 版本,把 staging 环境搞崩了三次。”
我:“……那是因为他们节点没开 CORS 啊!”
技术选型:为什么不用现成的 SDK?
一开始我想偷懒,直接用 web3.js 或 ethers.js。但很快发现两个问题:
- 客户的私有链 RPC 接口返回格式和标准以太坊不完全一致(比如某些字段叫
token_id而不是tokenId) - 他们链上合约用了非标准的 ERC721 扩展,
balanceOf返回的是字符串而不是 uint256
这意味着直接调用 ethers.Contract.balanceOf(addr) 会炸。我试了试强行 hack,结果在 parseUnits 时直接报错:
Error: [number-to-bn] while converting number "18446744073709551615" to BN.js instance, error: invalid number value. Value must be an integer, hex string, BN or BigNumber instance.
好家伙,溢出到 uint64 max 了。这时候我才意识到:封装得太好的 SDK,在非标场景下反而成了枷锁。
于是我决定:自己封装一层轻量级的 RPC 调用器,用原生 JavaScript 直接发 JSON-RPC 请求。虽然多写了点代码,但可控性高,调试也方便。
动手干:从零构建链交互层
第一步:搞定跨域 & 认证
私有链节点部署在客户的内网服务器上,前端直连肯定跨域。我本来想让运维加个 Nginx 反代,结果对方回我:“我们运维周末去青城山泡温泉了,下周再说。”
得,自己动手。我在本地用 Express 写了个超简单的代理中间件:
// proxy.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
app.use('/rpc', createProxyMiddleware({
target: 'https://internal-chain-client.example.com',
changeOrigin: true,
headers: {
'Authorization': 'Bearer ' + process.env.CHAIN_API_KEY,
'X-Custom-Auth': 'my-secret-sauce'
}
}));
app.listen(3001, () => console.log('Proxy running on :3001'));
吐槽:客户居然用 Basic Auth + 自定义 Header 做双重认证,搞得我差点以为在对接银行系统。
第二步:手撸 JSON-RPC 调用器
核心逻辑其实就几十行,但得处理重试、错误分类、参数序列化:
// chainClient.js
class ChainRPC {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.id = 0;
}
async call(method, params = []) {
const payload = {
jsonrpc: "2.0",
method,
params,
id: ++this.id
};
try {
const res = await fetch(`${this.baseUrl}/rpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
// 处理链返回的错误(比如 gas 不足、nonce 重复等)
if (data.error) {
throw new ChainError(data.error.message, data.error.code);
}
return data.result;
} catch (err) {
// 网络错误 or 解析失败
if (err instanceof SyntaxError) {
console.error("Chain returned invalid JSON:", err);
}
throw err;
}
}
// 专门处理他们那个魔改的 balanceOf
async getNftBalance(address) {
const raw = await this.call('eth_call', [{
to: CONTRACT_ADDR,
data: encodeFunctionData('balanceOf', [address]) // 自己实现的 ABI 编码
}, 'latest']);
// 他们的链返回的是十六进制字符串,但可能带前导零
const hexStr = raw.startsWith('0x') ? raw.slice(2) : raw;
return BigInt(`0x${hexStr}`).toString(); // 转成十进制字符串避免精度丢失
}
}
关键点:
- 用
BigInt处理大整数,避免 JS Number 精度问题 - 自己写
encodeFunctionData而不是依赖 ethers.utils,因为 ABI 格式也不标准 - 错误分类:区分“链上业务错误”和“网络/解析错误”,方便前端提示
踩坑实录:那些让我想砸键盘的瞬间
坑 1:时间戳 vs 区块号
客户说“实时刷新”,我以为是轮询最新区块。结果他们链上有个特性:交易确认需要 50 个区块(为了防分叉)。我一开始用 eth_blockNumber 拿最新高度,然后查这个区块里的交易,结果永远查不到刚 mint 的 NFT。
后来翻他们 Geth 的日志才发现,得用 eth_getBlockByNumber("pending", false) 才能看到 pending tx。但这个接口不稳定,偶尔返回 null。
解决方案:改成混合策略——先查 pending 区块,如果没有,再 fallback 到 latest - 5。
坑 2:内存泄漏警告
因为要做“自动刷新”,我用了 setInterval 每 5 秒调一次链。结果 Chrome DevTools 警告:
⚠️ Possible EventEmitter memory leak detected. 11 listeners added. Use emitter.setMaxListeners() to increase limit.
查了半天,发现是 fetch 在某些浏览器下不会自动释放连接(特别是当响应慢的时候)。最后加了 AbortController:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
坑 3:测试环境 vs 生产环境
最离谱的是,测试链返回的 token URI 是 ipfs://xxx,但生产链居然是 https://cdn.xxx.com/nft/{id}.json。我前端统一用 IPFS gateway 解析,结果上线当天图片全挂。
后来在代码里加了环境判断:
function resolveTokenUri(uri) {
if (uri.startsWith('ipfs://')) {
return `https://ipfs.io/ipfs/${uri.slice(7)}`;
}
// 兼容他们的 CDN 格式
if (process.env.NODE_ENV === 'production' && uri.includes('{id}')) {
return uri.replace('{id}', tokenId);
}
return uri;
}
性能优化:从 3s 到 300ms
最初版本,查一个地址的 NFT 列表要 3 秒以上(要查 balance → 遍历 tokenOfOwnerByIndex → 查每个 tokenURI)。用户反馈“卡得像 PPT”。
优化思路:
| 优化手段 | 效果 | 说明 |
|---|---|---|
| 并行请求 | 1.8s | 用 Promise.all 同时查多个 tokenURI |
| 本地缓存 | 0.9s | 对已查过的 tokenID 做 localStorage 缓存(带 TTL) |
| 分页加载 | 0.5s | 首屏只加载前 10 个,滚动再加载 |
| 预取机制 | 0.3s | 用户 hover 地址输入框时,提前解析 ENS 或检测格式 |
最关键的是缓存策略。我参考了 SWR 的思路,但简化了:
const CACHE_TTL = 5 * 60 * 1000; // 5分钟
function getCached(key) {
const cached = localStorage.getItem(`chain_cache_${key}`);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > CACHE_TTL) {
localStorage.removeItem(`chain_cache_${key}`);
return null;
}
return data;
}
async function fetchWithCache(key, fetchFn) {
const cached = getCached(key);
if (cached) return cached;
const data = await fetchFn();
localStorage.setItem(`chain_cache_${key}`, JSON.stringify({
data,
timestamp: Date.now()
}));
return data;
}
上线后,Lighthouse 性能分从 42 直接飙到 89。PM 终于闭嘴了。
心得体会:技术探索不是炫技,是解决问题
这次经历让我深刻体会到:所谓“深入理解”,不是背诵白皮书,而是在真实约束下找到可行解。
- 区块链不是银弹,私有链的坑比公链多十倍
- JavaScript 虽然灵活,但在处理大数、异步控制流时要格外小心
- 最佳实践永远服务于业务场景——有时候“脏一点”的方案反而是最优解
现在这个项目已经稳定运行三个月,没出过 P0 事故(虽然有两次半夜被 PagerDuty 叫醒,但都是客户自己改合约没通知我们 😅)。
更重要的是,我把这套轻量级链交互层抽象出来,做成了一个内部 npm 包,现在组里其他项目也在用。上周五下班前,leader 还拍我肩膀说:“你这玩意儿,比之前外包团队写的靠谱多了。”
那一刻,我觉得在成都这座慢城市里,当个既能接外包又能深挖技术的斜杠程序员,真挺爽的。
最后的小建议
如果你也想搞区块链+JS,别一上来就啃 Solidity。先问清楚:
- 链是公有还是私有?
- RPC 接口是否标准?
- 数据规模有多大?(别等到要查百万级 NFT 才想性能)
记住:技术是工具,不是目的。我们写代码,是为了让产品跑起来,让用户少骂一句“这破网站又卡了”。
好了,我去泡杯茶,继续看 Tendermint 的源码了。下次接外包要是再遇到这种需求……嗯,加钱的话也不是不能考虑 😉

评论 0