ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入解读 wp-calypso plans 数据存储的 next Mock 体系:从旧版 Plan 到 PlanNext 的渐进式迁移

深入解读 wp-calypso plans 数据存储的 next Mock 体系:从旧版 Plan 到 PlanNext 的渐进式迁移 前端CMS【免费下载链接】wp-calypsoThe JavaScript and API powered WordPress.com项目地址https://gitcode.com/gh_mirrors/wp/wp-calypso点击查看免费下载导读packages/data-stores/src/plans/mock/next/README.md只有一句纲领性描述/mock/next下的 Mock 数据将逐步演进以通知并覆盖inform override/mock目录下的旧版 Mock。这篇技术文章将以这一迁移策略为主线结合automattic/data-storesplans 模块的源码与测试带你完整理解mock/next中SitePlan/PlanNext两个新数据结构、价格与推介优惠intro offer的字段语义以及它们如何被useIntroOffers、useCurrentPlan等 Hook 消费最终掌握为 WordPress.com 套餐Plan相关功能编写、演进测试 Mock 数据的方法。一、为什么需要mock/next新旧 Mock 的演进背景wp-calypso 的 plans 数据存储位于 packages/data-stores/src/plans其中mock/目录承载了大量用于测试的假数据。当 API 数据结构发生演进时直接原地修改旧 Mock 会破坏既有测试的稳定性因此仓库引入了mock/next目录作为新结构 Mock 的孵化区旧 Mock 位于 mock/由index.ts统一导出新 Mock 位于 mock/next/包含独立的README.md、index.ts与store/plans.ts二者的汇合点位于 mock/index.ts其中通过export * from ./next将新 Mock 一并导出形成“新数据先共存、后覆盖旧数据”的迁移路径。从源码结构看mock/next的演进目标是用新的PlanNext接口逐步取代旧的Plan接口。在 types.ts 中明确写着This is the new interface for API Plans that will replace the existing Plan interface above. The existing Plan interface will be removed once this interface is fully implemented.也就是说mock/next不只是新增几个常量它承载了 plans 模块「接口换代」的完整过渡策略新 Mock 先与旧 Mock 并行存在测试逐步切换到新数据结构最终移除旧接口。二、mock/next目录结构与导出机制mock/next的目录结构非常精简packages/data-stores/src/plans/mock/next/ ├── README.md # 迁移说明 ├── index.ts # 统一导出入口 └── store/ └── plans.ts # 核心 Mock 数据定义index.ts 只有一行export * from ./store/plans将store/plans.ts中定义的所有常量NEXT_STORE_SITE_PLAN_PERSONAL、NEXT_STORE_SITE_PLAN_BUSINESS、NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT、NEXT_STORE_PLAN_PERSONAL、NEXT_STORE_PLAN_BUSINESS作为命名导出暴露出去所有导出的常量统一以NEXT_STORE_为前缀与旧 MockSTORE_前缀在命名空间上天然隔离避免命名冲突同时直观标识“新一代”Mock 数据命名约定中SITE_PLAN对应SitePlan接口PLAN对应PlanNext接口。这种「子目录 独立 index 命名前缀」的组织方式与旧 Mock 中 store/plans.ts、store/products.ts、store/features.ts 的STORE_前缀风格一脉相承保持了整个 mock 体系的阅读一致性。三、核心数据结构SitePlan与PlanNext的字段语义mock/next/store/plans.ts中的所有 Mock 常量都严格受 types.ts 中 TypeScript 接口的约束。理解这些接口是正确使用 Mock 数据的前提。3.1 新PlanNext接口export interface PlanNext { /* START: Same SitePlan/PlanNext props */ planSlug: PlanSlugFromProducts; productSlug: PlanSlugFromProducts; productId: number; pricing: PlanPricing; /* END: Same SitePlan/PlanNext props */ productNameShort: string; pathSlug?: string; }其中pricing使用PlanPricing类型types.ts核心字段为字段类型说明billPeriod-1 \| (typeof PERIOD_LIST)[number]计费周期-1表示免费计划365表示按年计费31表示按月计费currencyCodestring货币代码如USD、EURintroOfferPlanIntroductoryOffer \| null推介优惠首次购买折扣无优惠时为nulloriginalPrice{ monthly, full }原始价格monthly为月价full为整期总价二者可为nulldiscountedPrice{ monthly, full }折后价null表示无折扣PlanIntroductoryOffer的字段types.tsformattedPrice格式化价格字符串已标注deprecated建议改用formatCurrency基于monthly/full计算rawPrice.monthly/rawPrice.full按最小货币单位分表示的原始价格intervalUnityear或month代码中对此做了硬性假设以计算月价/总价intervalCount计费间隔数量isOfferComplete优惠是否已完整呈现用于 UI 判断展示形态。3.2 站点套餐SitePlan接口export interface SitePlan { planSlug: PlanSlugFromProducts; productSlug: PlanSlugFromProducts; productId: number; pricing: SitePlanPricing; currentPlan?: boolean; // 是否为站点当前生效套餐 hasRedeemedDomainCredit?: boolean; expiry?: string; // 仅当前套餐返回到期时间 purchaseId?: number; // 当前套餐的购买 ID由接口 id 重映射而来 }SitePlanPricing在PlanPricing基础上移除了billPeriod并增加了hasSaleCoupon?: boolean是否存在销售优惠券costOverrides?: CostOverride[]成本覆盖如促销/优惠券导致的改价CostOverride包含doesOverrideOriginalCost、firstUnitOnly、newPrice、oldPrice、overrideCode、percentage等字段types.ts。值得注意的是 types.ts 特意用/* START: Same SitePlan/PlanNext props */注释标注了SitePlan与PlanNext共有的属性块说明二者在设计上刻意保持对齐为后续统一接口做准备。四、store/plans.tsMock 数据逐条拆解4.1 站点套餐NEXT_STORE_SITE_PLAN_PERSONALexport const NEXT_STORE_SITE_PLAN_PERSONAL: SitePlan { planSlug: personal-bundle, productSlug: personal-bundle, productId: 1, pricing: { currencyCode: USD, introOffer: null, // 无推介优惠 originalPrice: { monthly: 400, full: 4800 }, discountedPrice: { monthly: null, full: null }, // 无折扣 }, };它代表了「Personal 站点套餐」在没有促销、没有优惠券场景下的标准数据月价 400即 $4.00按最小货币单位计年付总价 4800$48.00introOffer与discountedPrice均为null是测试「无优惠」路径的标准夹具。4.2 站点套餐NEXT_STORE_SITE_PLAN_BUSINESSexport const NEXT_STORE_SITE_PLAN_BUSINESS: SitePlan { planSlug: business-bundle, productSlug: business-bundle, productId: 2, pricing: { introOffer: { formattedPrice: $150.00, rawPrice: { monthly: 1250, full: 15000 }, intervalUnit: year, intervalCount: 1, isOfferComplete: false, }, originalPrice: { monthly: 2500, full: 30000 }, discountedPrice: { monthly: null, full: null }, currencyCode: USD, }, };Business 套餐的 Mock 展示了带推介优惠的完整价格形态原始年价full: 30000$300.00、月价2500$25.00推介优惠价rawPrice.full: 15000$150.00恰好是原价的一半formattedPrice: $150.00与之对应intervalUnit: year、intervalCount: 1表示“按年计费、一次性优惠”isOfferComplete: false表示该优惠对象尚未完整展示通常用于 UI 渐变呈现场景。4.3 当前套餐NEXT_STORE_SITE_PLAN_BUSINESS_CURRENTexport const NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT: SitePlan { ...NEXT_STORE_SITE_PLAN_BUSINESS, currentPlan: true, };通过对象展开继承NEXT_STORE_SITE_PLAN_BUSINESS的全部字段仅将currentPlan置为true。这是最小化数据变体minimal variant的经典写法只需表达「当前生效的 Business 套餐」这一差异其余价格信息全部复用父常量避免重复维护两份几乎相同的价格数据。4.4 全局套餐NEXT_STORE_PLAN_PERSONAL与NEXT_STORE_PLAN_BUSINESSexport const NEXT_STORE_PLAN_PERSONAL: PlanNext { planSlug: personal-bundle, productSlug: personal-bundle, productId: 1, productNameShort: Personal, pricing: { billPeriod: 365, currencyCode: USD, introOffer: null, originalPrice: { monthly: 400, full: 4800 }, discountedPrice: { monthly: null, full: null }, }, }; export const NEXT_STORE_PLAN_BUSINESS: PlanNext { planSlug: business-bundle, productSlug: business-bundle, productId: 2, productNameShort: Business, pricing: { billPeriod: 365, currencyCode: USD, introOffer: { formattedPrice: $300.00, rawPrice: { monthly: 2500, full: 30000 }, intervalUnit: year, intervalCount: 1, isOfferComplete: false, }, originalPrice: { monthly: 2500, full: 30000 }, discountedPrice: { monthly: null, full: null }, }, };与对应的SitePlan版本相比PlanNext增加了两个关键差异productNameShort如Personal、Business直接来自 plans 详情接口的product_name_short字段用于展示套餐简称pricing.billPeriod: 365PlanNext的PlanPricing携带计费周期而SitePlan的SitePlanPricing将其移除因为站点套餐的计费周期由站点上下文决定不需要在价格对象中重复携带。另外NEXT_STORE_PLAN_BUSINESS的introOffer价格为$300.00full: 30000与NEXT_STORE_SITE_PLAN_BUSINESS的$150.00full: 15000不同——这正是两个 Mock 各自代表的不同 API 来源PlanNext对应全局/plans端点SitePlan对应/sites/[siteId]/plans端点同一套餐在两个端点上可能返回不同的优惠价格而useIntroOffers的优先级逻辑正是依赖这一差异设计的。五、Mock 数据的真实消费链路useIntroOffers与测试验证5.1 Hook 实现SitePlans 优先于 Plansmock/next的数据并非闲置它们被 plans 模块的 React Hooks 直接消费。use-intro-offers.ts 的实现逻辑是同时调用useSitePlans({ siteId })与usePlans({ coupon })获取两路数据取两者 planSlug 的并集逐 slug 读取价格中的pricing.introOffer若同一 slug 在 SitePlans 与 Plans 中都存在sitePlans.data优先源码中??运算符实现sitePlans?.data?.[ planSlug ] ?? plans?.data?.[ planSlug ]无优惠时返回null数据未加载完成时返回undefined。这解释了为什么NEXT_STORE_SITE_PLAN_BUSINESS$150 优惠与NEXT_STORE_PLAN_BUSINESS$300 优惠刻意设置了不同价格——正是为了测试“站点套餐的优惠覆盖全局套餐优惠”这一优先级行为。5.2 测试用例优先级语义的验证use-intro-offers.ts 测试 通过jest.mock分别注入useSitePlans与usePlans的返回值并直接引用MockData.NEXT_STORE_*常量作为夹具测试场景SitePlans 注入Plans 注入断言结果SitePlans 优惠优先Business$150 优惠Business$300 优惠取 SitePlans 的$150优惠无优惠返回 nullPersonal无优惠Personal无优惠{ personal-bundle: null }仅 Plans 有优惠data: {}Business$300 优惠取 Plans 的$300优惠并集 优先级合并Business$150 优惠Personal无优惠 Business$300 优惠{ personal-bundle: null, business-bundle: $150 优惠 }注意测试文件从../../mock导入MockData而 mock/index.ts 已通过export * from ./next把NEXT_STORE_*常量全部导出——这意味着新 Mock 在旧 mock 入口即可见测试无需修改导入路径这也是mock/next与旧 mock 共存策略带来的直接红利。5.3 其他消费方useCurrentPlanuse-current-plan.ts 测试 同样引用NEXT_STORE_*常量。NEXT_STORE_SITE_PLAN_BUSINESS_CURRENTcurrentPlan: true正是为这类“查询当前生效套餐”的 Hook 设计的夹具它验证了currentPlan布尔标记如何驱动useCurrentPlan从站点套餐列表中筛选出当前套餐。六、新旧 Mock 对照从STORE_到NEXT_STORE_的差异一览维度旧 Mockmock/store新 Mockmock/next/store前缀STORE_如STORE_PLAN_PREMIUMNEXT_STORE_如NEXT_STORE_PLAN_BUSINESS数据接口Plantypes.tstitle、description、features、storage等展示型字段PlanNext/SitePlanproductSlug、productId、pricing等价格型字段价格结构PlanProductrawPrice、price、annualPrice、annualDiscount与Plan分离PlanPricingoriginalPrice、discountedPrice、introOffer内聚在pricing对象中推介优惠无独立结构旧PlanProduct仅有annualDiscountPlanIntroductoryOfferformattedPrice、rawPrice、intervalUnit、intervalCount、isOfferComplete免费套餐STORE_PRODUCT_FREEproductId: 1、billPeriod: ANNUALLY、rawPrice: 0新 Mock 中未定义免费套餐billPeriod: -1的语义保留在 types.ts 的PlanPricing类型中旧 Mock 的STORE_PRODUCT_PREMIUM_ANNUALLYstore/products.ts通过annualDiscount: 42表达“按年付省 42%”的折扣而新 Mock 用introOfferdiscountedPrice两个维度更精细地表达优惠。从源码结构看新结构将「原价、折后价、推介优惠」统一收拢进pricing对象使价格逻辑集中、类型安全这正是它要逐步取代旧Plan/PlanProduct分离式结构的原因。七、实践指南如何基于mock/next编写与演进测试 Mock7.1 引入方式在测试文件中直接引用常量import * as MockData from ../../mock; // 站点套餐带推介优惠 const businessSitePlan MockData.NEXT_STORE_SITE_PLAN_BUSINESS; // 当前生效的 Business 套餐 const currentBusinessPlan MockData.NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT; // 全局套餐无优惠 const personalPlan MockData.NEXT_STORE_PLAN_PERSONAL;若需要更贴近真实的 API 返回形状可直接使用 mock/apis/plans.ts 中的API_PLAN_PRICE_*如API_PLAN_PRICE_FREE、API_PLAN_PRICE_PREMIUM_ANNUALLY、API_PLAN_PRICE_PREMIUM_MONTHLY——它们模拟了public-api.wordpress.com的/plans与/sites/[siteId]/plans端点返回的 snake_case 字段包括product_slug、bill_period、raw_price_integer、orig_cost_integer、currency_code等。7.2 新 Mock 的演进规则在mock/next中新增常量命名遵循NEXT_STORE_前缀字段严格对齐 types.ts 中的SitePlan/PlanNext接口通过store/index.ts或mock/index.ts的export *链暴露新增常量自动随 mock/index.ts 的export * from ./next导出无需修改任何消费方导入路径用对象展开减少重复对仅需微调差异的场景如currentPlan复用既有常量...NEXT_STORE_SITE_PLAN_BUSINESS测试逐步切换先让新 Mock 与旧 Mock 共存新测试优先引用NEXT_STORE_*待旧Plan接口全面移除后旧STORE_*Mock 即可删除——这正是 README 所述「inform override」的完整闭环。7.3 适用范围与前提mock/next是 plans 数据存储的测试专用数据不参与运行时数据请求生产环境的价格数据仍来自usePlans/useSitePlans查询见 queries/Mock 数据中的价格单位是最小货币单位整数分与 types.ts 中raw_price_integer的语义一致浮点计算已标注废弃mock/next目前仅覆盖 Personal 与 Business 两个套餐免费套餐、月付变体等场景仍需结合 mock/apis/plans.ts 中的旧 API Mock 或自行扩展。结语mock/next是 wp-calypso plans 数据存储「接口换代」的过渡孵化器它以NEXT_STORE_前缀的新PlanNext/SitePlan数据结构通过pricing对象内聚原价、折后价与推介优惠与旧Plan/PlanProduct分离式结构并存再由useIntroOffers等 Hook 以「SitePlans 优先」的规则消费。理解这条演进链路你就能在automattic/data-stores中写出与官方测试同构、随新接口迁移的健壮 Mock 数据。赞分享前端CMS【免费下载链接】wp-calypsoThe JavaScript and API powered WordPress.com项目地址https://gitcode.com/gh_mirrors/wp/wp-calypso点击查看免费下载相关推荐wp-calypso 数据存储包automattic/data-stores演进史从 WordPress 数据仓库到 TanStack Query 查询体系wp calypso 数据存储包automattic/data stores演进史从 WordPress 数据仓库到 TanStack Query 查询前端CMSQMK 键盘固件实操指南刷写、改键与层切换三里程碑完整版QMK 键盘固件实操指南刷写、改键与层切换三里程碑完整版 想让 Enter 兼管删除、某个键一按就输出一整段文字、按住一个键整层布局换掉这些效果靠 QMK嵌入式固件驱动开发硬件开发store.js渐进式迁移从传统存储到现代方案store.js渐进式迁移从传统存储到现代方案 你是否仍在为浏览器存储兼容性问题头疼用户数据在Safari私有模式下丢失旧版IE无法使用localStor前端上一篇为什么你的AI Agent需要treg告别逐个注册API账号的终极理由下一篇从 LLT 到 FUZZchardet4cj 四层测试体系完整揭秘与用例编写指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进