深入理解技术探索与实践:一个成都斜杠程序员的区块链+JS踩坑实录

知识库管理员
2025-12-15 18:24
阅读 327

上个月底,我在成都一家咖啡馆里改完最后一行代码,抬头一看窗外锦江边已经开始飘桂花香了。这节奏,舒服得我都快忘了自己其实是个“白天在大厂写CRUD,晚上接外包肝副业”的斜杠码农。

平时除了维护几个老项目,我最大的乐趣就是翻开源项目的源码——不是为了装逼,是真的觉得看别人怎么组织代码、处理边界情况特别爽。最近一次让我半夜三点还兴奋地拍大腿的,是一次硬核的技术探索:把 JavaScript 和区块链结合起来搞点新东西。这篇文章就来聊聊这段又哭又笑的经历。


起因:产品经理一句话,我熬了三个通宵

事情要从去年双11前说起。我们组接了个外包需求:给一家做数字藏品的小公司开发一个“链上资产查询 + 链下展示”的轻量级前端系统。客户要求不高(他们说的):用户输入钱包地址,就能看到他在某条公链上的 NFT 列表,并且能实时刷新。

听起来不难?但问题来了——他们用的是自建的私有链,基于 Ethereum 的 Geth 客户端魔改了一堆逻辑,文档基本等于没有。更骚的是,后端团队甩锅说:“链上数据我们没法直接查,你前端自己连节点吧。”

我当时心里一万只羊驼奔腾而过。但转念一想:这不正好是个深入研究区块链底层 + JS 交互的好机会?顺便还能加到我的副业作品集里。于是咬牙接了下来。

小插曲:第二天晨会,测试同学幽幽地说:“上次你改的那个 Web3.js 版本,把 staging 环境搞崩了三次。”
我:“……那是因为他们节点没开 CORS 啊!”


技术选型:为什么不用现成的 SDK?

一开始我想偷懒,直接用 web3.jsethers.js。但很快发现两个问题:

  1. 客户的私有链 RPC 接口返回格式和标准以太坊不完全一致(比如某些字段叫 token_id 而不是 tokenId
  2. 他们链上合约用了非标准的 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝