项目 / PROJECT

AvoDex 前端

AvoDex 部署于 Arbitrum,前端由行情、现货、合约、资产中心与对话助手五个 Next.js 站点构成,公共能力沉淀为 24 个私有包。负责全部前端,从架构与页面开发到发版收敛。

2026.09.14 前端负责人 · 架构与全站开发
  • Next.js 12 → 16
  • React 18 / 19
  • pnpm + Turborepo
  • TypeScript
  • wagmi / viem

#项目背景

AvoDex 部署于 Arbitrum,产品覆盖现货、合约、资产、定投、预测市场与策略跟单。前端最初按业务线拆成五个独立应用,公共能力以 24 个私有包跨仓复用 —— 一个包变更需在五个仓库分别修改版本号、重装依赖、重新构建,任一环节遗漏都不报错,最终只表现为各站界面不一致。

项目因此推进向新站迁移:新站自始建于单一 monorepo 之内,旧站按页面逐步下线,迁移期间不停服。四个主站合计约 22.9 万行,目前两代形态并存。

#项目角色

负责五个站点的前端 —— 行情首页、现货交易、合约交易、资产中心、定投、预测市场、策略跟单、AVO 解锁与 openClaw 对话助手,从架构分层、公共层取舍、页面开发与维护,到多仓发版链路的迁移。

其中改造最重的一块是工程结构:24 个按历史划分的包合并为 3 个按职责划分的工作区包,五个仓库独立发版改为单 workspace 一次构建。合约交互与后端服务由对应团队承担,文中只在前端依赖其边界处提及。

#技术方案

#整体架构

结构演进的结果是两代形态并存,而非一次性重写。旧站持续在线,新站自始即建于 monorepo 之内,旧站按页面逐步下线,因此同一时刻仓库中既有五个独立应用,也有一个 workspace,两者消费同一批源码级包。

AvoDex 整体架构:应用、共享包与两代站点的关系

旧结构为五个独立仓库,各自维护 lockfile 与部署流程,共用 24 个 @az/* 包。这些包以源码形式交付,消费侧通过 next-transpile-modules 一并编译 —— 省去每个包的构建环节,代价是编译期延长。

新结构为单一 pnpm workspace:packages/ui 收敛组件、hooks、数据客户端与语言资源,另外两个包分别收敛 ESLint 与 TypeScript 配置;apps/web 为 Next.js 16 App Router 应用,通过 workspace:* 引用三个包,构建顺序由 Turborepo 的 ^build 保证,一次构建产出全部产物。应用层与能力层的代码量之比约为 1 : 24。

#方案对比

选择替代方案理由
pnpm workspace + Turborepo继续新增独立仓库一致性问题源于跨仓边界,增加仓库只会加厚该边界
共享包以 workspace:* 引用私有源发版 + 版本号版本号是跨仓交付的凭据;取消交付环节即无需凭据
24 个包合并为 3 个原样迁入 packages/原包按编写先后划分而非职责划分,原样迁移会继承其耦合
Next.js 16 App Router沿用 12 的 Pages Router新站无历史包袱,服务端渲染使行情首屏直接成形
交易对按需生成 + 增量缓存全量预渲染 / 全量服务端渲染全量预渲染的构建时长随市场数线性增长;全量服务端渲染放弃缓存

选型中不包含「合并五个旧站」:旧站持续在线,实际路径是新站自始建于 monorepo 内、旧站按页面逐步下线,而非先合仓再改造架构。

#核心业务

行情首页(/market、/dashboard/*、/referral):交易对列表、涨跌幅与个人看板,页面静态生成,实时价格由客户端轮询补充。

现货交易(/trade/[symbol]):盘口、K 线与下单面板,交易对按需生成并缓存;下单前完成余额、报价与授权校验。

合约交易(/futures/trade/[symbolId]):仓位、委托与风险提示,五个应用中体量最大。

资产中心(/accounts/assets/wallet/[accType]、/apikey):多账户类型的资产视图与划转,以及 API Key 管理。

定投:按周期定额买入的委托管理,含计划创建与执行记录。

预测市场:以事件结果作为结算依据的交易市场,页面承担市场浏览、下单与持仓查看。

策略跟单:策略市场列表与策略详情,含跟随配置。

AVO 解锁:锁仓额度按日解锁,页面呈现可领额度与领取入口。

openClaw 对话助手(/openclaw):用户以自然语言下单。前端只承担两件事 —— 确认开通权限、以 iframe 嵌入对话端;交易意图的解析不在前端,助手可独立迭代,不受 DEX 发版节奏约束。

#难点与解决方案

#难点一:公共包发版同步无法仅靠流程保障

问题:公共包变更后,五个站点需分别修改版本号、重装依赖并重新构建,任一环节遗漏均不产生报错。发布脚本按环境决定正式版或预发版:

deployEnv=prod  → 2.0.1          # 正式
deployEnv=test  → 2.0.1-beta     # 预发
bash ../../update.sh && npm publish --registry .../npm-local/

结果是同一时刻五个站点运行两个版本:

ssr-web-index        "@az/base": "2.0.1"        "@az/Style": "2.0.0"
其余四个站            "@az/base": "2.0.1-beta"   "@az/Style": "2.0.0-beta"

CI 保持通过 —— beta 版本同样可运行,只是正式版中的修复未覆盖其余四个站点。此类偏差不触发告警,仅在界面表现不一致时暴露。问题的根源不在执行纪律,而在版本号本身:它是跨仓交付的唯一凭据,凭据的更新依赖人记得。

解决方案:不修补流程,而是取消该环节。包不再发版,改为 workspace 内引用,修改即生效;同时将 24 个按历史划分的包合并为 3 个按职责划分的包 —— ui 收敛组件与客户端,其余两个收敛 lint 与 TypeScript 配置。合并边界按职责划定,而非按原包名归并。

结果:5 个仓库收敛为 1 个 workspace,仅一份 lockfile、一次依赖升级;24 个包合并为 3 个,组件变更不再需要发版。代价亦明确:仓库体积增大、clone 变慢;构建需经由 Turborepo 依赖图,^build 配置错误将静默使用旧产物;单仓权限无法再按站点划分。

#难点二:下单按钮的状态由六项条件共同决定

问题:下单是一个多前置条件的动作,而不是一次点击。用户可能在未连接钱包时点、在错误链上点、在余额不足时点、在报价尚未返回时点、在授权未完成时点、在价格影响超过阈值时点。每一种情况的正确反馈都不同:有的要引导连接,有的要切换网络,有的要先去授权,有的只能提示等待。若把判断分散在各个组件里,同一个按钮会出现文案互相矛盾、禁用态与可点态并存的状态;且这类问题无法通过页面测试穷举 —— 六项条件的组合远超人工用例的覆盖能力。

解决方案:把判定收敛为一个纯函数 deriveSwapState(params),输入连接状态、链 ID、输入金额、余额、报价与价格影响阈值,输出 12 个状态中的一个;组件只按状态渲染,不再持有判断逻辑。状态按判定优先级排列,短路返回第一个不满足的条件:

disconnected → wrong_chain → no_input → insufficient_balance
→ fetching_quote → no_liquidity → high_price_impact
→ needs_approval → approving → ready_to_swap → swapping → confirmed

结果:下单链路的状态不可达组合被消除 —— 任一时刻只有一个状态成立,按钮文案与可点性由同一处决定。判定可单测:六个下单相关组件(SwapCard、SwapCTA、SwapPreviewDialog、SlippageSettings、TokenSelector、TransactionToast)各有用例,无需起浏览器即可覆盖全部分支。此外,写操作前的链校验也被固定在同一位置 —— 链不一致时不发交易,先切链。

#难点三:同一份行情数据重复请求 91 次

问题:/trade 页面挂载多个轮询器 —— useUnifiedMarketData ×5、useAssetCtx ×3、useEnginePairs、useEngineTickerData,各自持有 setInterval 并独立调用 getTickers()。packages/ui 中这类轮询钩子共 12 个,各自服务不同区块,本意是让每个区块独立刷新;副作用是它们请求同一份数据,仅触发时机不同 —— 数据源是同一个,定时器却有多个。单次加载因此产生 91 个 GET /v1/tickers 与 10 个 GET /v1/markets。浏览器对同源仅开放 6 条连接,请求排队后单请求响应劣化至约 2.6 秒,K 线首帧请求位于队尾。

解决方案:收敛分两层,互不依赖。第一层在客户端,实现 in-flight 共享与短 TTL 缓存:

function sharedFlight<T>(holder: FlightHolder<T>, ttlMs: number, fetcher: () => Promise<T>) {
  const now = Date.now();
  if (holder.value !== null && now - holder.ts < ttlMs) return Promise.resolve(holder.value);
  if (holder.inflight) return holder.inflight;

  holder.inflight = fetcher()
    .then((v) => { holder.value = v; holder.ts = Date.now(); return v; })
    .finally(() => { holder.inflight = null; });
  return holder.inflight;
}

const TICKERS_TTL_MS = 3000;   // 与轮询周期同量级,界面不显迟滞
const MARKETS_TTL_MS = 15000;

第二层在 BFF,只缓存公开 GET,按路径给不同 TTL:tickers 1.5 秒、markets 5 秒、candles 2 秒,响应头带 public, s-maxage 与 stale-while-revalidate=5;私有路径与非 GET 一律绕过。

结果:/trade 单次加载的 91 个行情请求与 10 个市场请求各收敛为 1 个,K 线请求不再位于队尾。两层不叠加:前端每 TTL 窗口只发一次,BFF 是否命中取决于同窗口内是否有其他请求,并发越高收益越大。客户端一层是可靠边界 —— beta 的 ingress 把 /api/engine 直连后端、绕过 Next 代理,在这种拓扑下 BFF 缓存并不参与,去重只能由客户端完成。

#项目成果

  • 结构:5 个独立仓库收敛为 1 个 workspace,24 个发布包合并为 3 个工作区包,应用层与能力层代码量之比 1 : 24,构建期生成交易对页面 0 个
  • 性能:/trade 单次加载 91 个行情请求与 10 个市场请求各收敛为 1 个,单请求不再因排队劣化;缓存分客户端与 BFF 两层,各自独立生效
  • 正确性:下单状态由纯函数派生,12 个状态互斥,六个下单组件各有单测;packages/ui 共 52 个测试文件,lib/ 的 29 个纯函数模块配有 18 个测试文件
  • 安全边界:20 个 BFF 入口中 3 个承担签名,密钥仅存在于服务端;会话经同源 Cookie 落地,浏览器不持密钥、不跨域
  • 交付:24 条路由、20 个 /api/* 入口、155 条包导出;五个站点按页面逐步切换,迁移期间未停服
  • 工程质量:根脚本统一 turbo build / typecheck / lint / test / format;lint:colors 以脚本拦截新增硬编码颜色,防止样式随迭代再次漂移;typescript-config 使所有包继承同一份 strict 配置

#项目链接

源码托管于公司私有 Git 仓库,受知识产权约束,不对外公开。替代说明材料为五个站点的部分页面截图,以及本页的技术方案与难点复盘。

项目截图 / SCREENSHOTS

Dex 首页
01 Dex 首页
现货、机器人策略
02 现货、机器人策略
Avo 解锁
03 Avo 解锁
预测市场
04 预测市场
策略跟单市场
05 策略跟单市场
资产
06 资产
从旧版本 SSR 站点迁移资产至新版本
07 从旧版本 SSR 站点迁移资产至新版本
dca 定投交易详情
08 dca 定投交易详情
旧结构:五个站点各自成仓,24 个包独立发版并出现版本漂移
09 旧结构:五个站点各自成仓,24 个包独立发版并出现版本漂移
升级后:apps/web 通过 workspace 引用三个工作区包
10 升级后:apps/web 通过 workspace 引用三个工作区包
openClaw 的开通状态机与两代不同的承载方式
11 openClaw 的开通状态机与两代不同的承载方式
91 个重复请求排队于同源 6 条连接
12 91 个重复请求排队于同源 6 条连接

返回作品集