#项目背景
AvoDex 部署于 Arbitrum,产品覆盖现货、合约、资产、定投、预测市场与策略跟单。前端最初按业务线拆成五个独立应用,公共能力以 24 个私有包跨仓复用 —— 一个包变更需在五个仓库分别修改版本号、重装依赖、重新构建,任一环节遗漏都不报错,最终只表现为各站界面不一致。
项目因此推进向新站迁移:新站自始建于单一 monorepo 之内,旧站按页面逐步下线,迁移期间不停服。四个主站合计约 22.9 万行,目前两代形态并存。
#项目角色
负责五个站点的前端 —— 行情首页、现货交易、合约交易、资产中心、定投、预测市场、策略跟单、AVO 解锁与 openClaw 对话助手,从架构分层、公共层取舍、页面开发与维护,到多仓发版链路的迁移。
其中改造最重的一块是工程结构:24 个按历史划分的包合并为 3 个按职责划分的工作区包,五个仓库独立发版改为单 workspace 一次构建。合约交互与后端服务由对应团队承担,文中只在前端依赖其边界处提及。
#技术方案
#整体架构
结构演进的结果是两代形态并存,而非一次性重写。旧站持续在线,新站自始即建于 monorepo 之内,旧站按页面逐步下线,因此同一时刻仓库中既有五个独立应用,也有一个 workspace,两者消费同一批源码级包。
旧结构为五个独立仓库,各自维护 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 仓库,受知识产权约束,不对外公开。替代说明材料为五个站点的部分页面截图,以及本页的技术方案与难点复盘。