飞猪海外酒店 H5 · 双仓合并 + 第三渠道适配方案
基于双仓真实代码抽样(env.ts / bridgeTool.ts / language.ts / mtopRequest.ts / package.json / pages 目录)+ 仓库 CLAUDE.md 约定产出。
★ 设计心法(给 1~2 人小团队)
没碰 → 公用,一份写两边都生效;碰了 → 丢进对应渠道目录。不用记架构图,就这一句。
代码分三类(按真实占比)
关键:90% 是公用的。双仓现在的痛苦,就是这 90% 被复制成两份——改 bug 改两遍、还容易漏。合并的全部收益就在这里。
代码该放哪 —— 唯一判断规则
如何保证「各自迭代 + 不互相干扰」
| 你要改的 | 放哪 | 影响谁 |
|---|---|---|
| AE 专属逻辑(跳转/容器) | channels/ae/ | 只有 AE。构建时 Lazada 包里根本不打进这段,物理隔离,改坏也只坏 AE |
| 公用业务(详情页加字段) | pages/detail | 两渠道同时生效 —— 这正是合并要的收益 |
| 渠道独占页(order_pc) | pages/channel-only/ | 只有 AE,构建按 __CHANNEL__ 裁剪 |
| 主题色 / 文案 | theme 变量 / i18n | 改配置,不碰任何逻辑 |
守住简洁的三条纪律(小团队专属)
小团队的敌人不是「不够优雅」,而是改一处要在五个文件间跳 和 改一处怕炸全局。所以刻意做减法:
① 只抽已分叉的点
适配层只抽现在真实存在的 5~6 个差异,绝不为「未来可能」预留接口。差异冒出来再加。
② 不上框架
就一个 channel 单例对象,getChannel().bridge.open() 直白调用。不要 DI、不要插件机制。
③ 公用层零判断
红线。一旦容忍 if(channel.id==='ae') 进业务代码,就退化回「满地 if」,比双仓更糟。
万一公用代码里真冒出渠道差异?(按顺序消化,别一上来写 if)
0 · 现状关键事实(决定方案的硬约束)
| 维度 | Lazada | AE |
|---|---|---|
| 容器 | WindVane(isWindVane) | window.adc.environment / isAeADC |
| i18n 来源 | @ali/rxpi-i18n-utils 同步取环境 | x-i18n-* header + hng cookie(原 SDK 逻辑被注释) |
| MTOP | 标准 MTop | @ali/pocket 的 I18nMTop,ttid:'12AE0000002',手写 hng |
| 跳转桥 | @ali/alitrip-bridge | 同库 + adc_manifest:'fliggy' / pha_manifest=default / 沉浸式 |
| 组件库 | 3 个 @ali/ihotel-material-* | 6 个(多 calendar/city-sug/occupancy-selector;mds-pro-span 版本不一致) |
| 独占页 | event / rule-guide | order_pc / hotel-domestic-map / hotel-domestic-translate-detail |
1 · 总体架构选型
| 维度 | A · 单仓多渠道 适配层 + 多入口构建 | B · Monorepo 核心包 + 渠道包 | C · 双仓 + 公共包 |
|---|---|---|---|
| 代码复用 | 高(共享业务页) | 高(核心包) | 中(仅 utils/组件) |
| 新渠道接入成本 | 低 | 中 | 高(再开仓) |
| 双侧同步成本 (当前痛点) | 几乎消失 | 低 | 高 |
| 构建/发布复杂度 | 中(多产物) | 高(turbo+版本联动) | 低(各自独立) |
| 改造风险 | 中(一次性合并) | 高(拆包+版本治理) | 低 |
| 回归爆炸半径 | 大(共享改动影响全渠道) | 中(按包灰度) | 小(仓间隔离) |
路线 A ✓ 推荐
业务代码共享一份,环境差异沉到 channel 层,按渠道多入口出包。
路线 B
收益/成本不划算;可作为 A 的演进终点。
路线 C
维持现状,双侧同步成本无解。
选 A 的理由
① 当前最大成本是「双侧同步」,AE 本就是 Lazada 的镜像分叉,A 直接消灭分叉。
② 差异是可枚举的环境接缝、非业务分歧,用接口收口即可,不必物理分包(B 太重)也别容忍复制(C 的痛)。
③ B 把业务页面发 npm 包会拖慢高频迭代(组件库已是此痛点)。
④ A 的 core/ 未来可平滑抽成 B,反向不可逆——先 A 后 B 风险可控。
2 · 核心抽象:渠道适配层(Channel Adapter)
业务层零渠道判断,统一通过 getChannel() 拿适配器。isAE / window.adc / WindVane / UA 正则只允许出现在 channels/ 内。
// core/channel/types.ts —— 渠道适配层总接口 interface ChannelAdapter { id: 'lazada' | 'ae' | string; bridge: BridgeAdapter; // 容器能力: open / setTitle / call / login env: EnvAdapter; // stage / 平台 / 域名 / cookie domain i18n: I18nAdapter; // getEnv()→{language,region,currency}; decorateRequest 注 x-i18n-*/hng router: RouterAdapter; // toDetail / toBooking ...; buildUrl 拼域名 + pha_manifest theme: ThemeAdapter; // tokens + 组件变体 + 根 class request: RequestAdapter; // MTOP ttid / 头 / cookie 装配 }
getEnv 走 SDK 同步取、decorateRequest 基本空操作;
AE:getEnv 从 window.adc.environment、decorateRequest 写 hng 并设 x-i18n-language/regionID/currency。装配入口(构建时注入)
// entry/ae.tsx —— AE 构建入口 import { setChannel } from '@/core/channel'; import { aeChannel } from '@/channels/ae'; setChannel(aeChannel); // 先装配渠道 import('./app'); // 业务入口,完全渠道无关
3 · 目录结构草案(路线 A)
src/ ├── core/ # ✅ 渠道无关: channel 接口 / service / hooks / utils / 共享组件 │ └── channel/ # types.ts + getChannel/setChannel ├── channels/ # ⚠ 唯一允许 window.adc / WindVane / UA 的地方 │ ├── lazada/ {bridge,env,i18n,router,theme,request}.ts │ ├── ae/ {... 含 ADC / pha_manifest / x-i18n-*} │ └── <third>/ # 第三渠道,新增即接入 ├── pages/ # ✅ 共享一份业务页 │ └── channel-only/ # 独占页(order_pc→ae / event→lazada),按构建 define 裁剪 ├── i18n/ styles/ # 主题 css vars + data-channel="ae" └── entry/ # ✅ 多入口: lazada.tsx / ae.tsx / <third>.tsx
core/ 与 pages/ 零渠道判断;独占页通过路由表 + 构建 __CHANNEL__ define 做 tree-shaking,避免把 AE 的 order_pc 打进 Lazada 包。
4 · 迁移步骤
两仓各自原地引入 channel 接口,把 env / bridgeTool / mtopRequest / language / jump 改成 channels/<self> 实现,业务层收敛散落的 isAE 判断。只重构不改业务,用现有 playwright E2E 做回归基线。
新建合并仓,以 Lazada 为基,搬入 AE channel + 独占页;对称页面逐页 diff 合成一份(差异下沉 channel/theme),配多入口构建。
风险缓解:隐性差异被合丢 → 逐页 diff + 双渠道 E2E 对拍 + 预发灰度;依赖并集冲突 → 动态 import 不进主 bundle;i18n key 冲突 → 并集去重。
按 §5 清单接第三渠道验证抽象闭环;若 core/ 需被外部复用,再抽 npm 包演进到路线 B。
pha_manifest=default)各跑一遍 列表→详情→下单→订单。构建成功 ≠ 迁移生效。5 · 第三渠道接入清单
接入一个新渠道 = 在 channels/<new>/ 实现一套适配 + 一个入口,不改 core / pages。
- EnvAdapter:域名 / stage 判定 / cookie domain
- BridgeAdapter:容器类型(WindVane/ADC/PHA/H5)+ 降级
- I18nAdapter:环境来源 / hng 写法 / header / medusaApp
- RequestAdapter:MTOP ttid / 必带头
- RouterAdapter:域名 + 渠道必带参数(pha_manifest)
- ThemeAdapter:主色 / tokens / 组件变体 / 根 class
- 构建入口:
entry/<new>.tsx+ 一条 channel 产物 - 页面裁剪:声明启用 / 独占页路由表
- 组件库依赖:所需
@ali/ihotel-material-*子集 - i18n 文案:走 formatjs extract → Medusa
- 埋点监控:ttid / SPM / 渠道维度
- E2E:核心链路用例挂该渠道跑预发
6 · 风险与权衡
| 领域 | 风险等级 | 要点与缓解 |
|---|---|---|
| i18n | 最高 | 两侧环境机制不同,必须由 I18nAdapter 完全吸收;共用 Medusa lazada_hotel 合仓前做 key 并集去重 + 冲突检测;货币/地区禁止从 URL 推断。 |
| 构建发布 | 中 | AE 独占依赖不能打进 Lazada 包(动态 import + tree-shaking,监控 bundle size);clam prepub/publish 扩展为按渠道发布,发 aone 用 aone MCP。 |
| 组件库依赖 | 中 | 版本不同(mds-pro-span AE ^1.0.9 vs Lazada ^1.0.15),先拉齐版本再合代码,分两步降低排查难度;禁止软链覆盖。 |
| 回归测试 | 中 | A 路线代价是爆炸半径变大 → 双渠道 E2E 对拍做合并门禁 + 预发灰度。 |
if (channel.id==='ae')。守住则方案成立且可演进到 B;守不住则等于把双仓的复制粘贴换成单仓的满地 if,得不偿失。h5-hotel-{ae,lazada}/src/utils/{env,bridgeTool,language}.ts、src/service/mtopRequest.ts、package.json。本方案由 workflow 综合 agent 基于双仓真实代码 + CLAUDE.md 约定产出,供技术方案评审使用。