- 前端
- CMS
【免费下载链接】wp-calypso
The JavaScript and API powered WordPress.com
导读
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_为前缀,与旧 Mock(STORE_前缀)在命名空间上天然隔离,避免命名冲突,同时直观标识“新一代”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表示按月计费 |
currencyCode | string | 货币代码,如USD、EUR |
introOffer | PlanIntroductoryOffer \| null | 推介优惠(首次购买折扣),无优惠时为null |
originalPrice | { monthly, full } | 原始价格,monthly为月价,full为整期总价,二者可为null |
discountedPrice | { monthly, full } | 折后价,null表示无折扣 |
PlanIntroductoryOffer的字段(types.ts):
formattedPrice:格式化价格字符串(已标注@deprecated,建议改用formatCurrency基于monthly/full计算);rawPrice.monthly/rawPrice.full:按最小货币单位(分)表示的原始价格;intervalUnit:'year'或'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_PERSONAL
export 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.00),introOffer与discountedPrice均为null,是测试「无优惠」路径的标准夹具。
4.2 站点套餐:NEXT_STORE_SITE_PLAN_BUSINESS
export 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_CURRENT
export 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_BUSINESS
export 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: 365:PlanNext的PlanPricing携带计费周期,而SitePlan的SitePlanPricing将其移除(因为站点套餐的计费周期由站点上下文决定,不需要在价格对象中重复携带)。
另外,NEXT_STORE_PLAN_BUSINESS的introOffer价格为$300.00(full: 30000),与NEXT_STORE_SITE_PLAN_BUSINESS的$150.00(full: 15000)不同——这正是两个 Mock 各自代表的不同 API 来源:PlanNext对应全局/plans端点,SitePlan对应/sites/[siteId]/plans端点,同一套餐在两个端点上可能返回不同的优惠价格,而useIntroOffers的优先级逻辑正是依赖这一差异设计的。
五、Mock 数据的真实消费链路:useIntroOffers与测试验证
5.1 Hook 实现:SitePlans 优先于 Plans
mock/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优惠 |
| 无优惠返回 null | Personal(无优惠) | 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 其他消费方:useCurrentPlan
use-current-plan.ts 测试 同样引用NEXT_STORE_*常量。NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT(currentPlan: true)正是为这类“查询当前生效套餐”的 Hook 设计的夹具,它验证了currentPlan布尔标记如何驱动useCurrentPlan从站点套餐列表中筛选出当前套餐。
六、新旧 Mock 对照:从STORE_到NEXT_STORE_的差异一览
| 维度 | 旧 Mock(mock/store) | 新 Mock(mock/next/store) |
|---|---|---|
| 前缀 | STORE_(如STORE_PLAN_PREMIUM) | NEXT_STORE_(如NEXT_STORE_PLAN_BUSINESS) |
| 数据接口 | Plan(types.ts:title、description、features、storage等展示型字段) | PlanNext/SitePlan(productSlug、productId、pricing等价格型字段) |
| 价格结构 | PlanProduct(rawPrice、price、annualPrice、annualDiscount)与Plan分离 | PlanPricing(originalPrice、discountedPrice、introOffer)内聚在pricing对象中 |
| 推介优惠 | 无独立结构(旧PlanProduct仅有annualDiscount) | PlanIntroductoryOffer(formattedPrice、rawPrice、intervalUnit、intervalCount、isOfferComplete) |
| 免费套餐 | STORE_PRODUCT_FREE(productId: 1、billPeriod: 'ANNUALLY'、rawPrice: 0) | 新 Mock 中未定义免费套餐(billPeriod: -1的语义保留在 types.ts 的PlanPricing类型中) |
旧 Mock 的STORE_PRODUCT_PREMIUM_ANNUALLY(store/products.ts)通过annualDiscount: 42表达“按年付省 42%”的折扣,而新 Mock 用introOffer+discountedPrice两个维度更精细地表达优惠。从源码结构看,新结构将「原价、折后价、推介优惠」统一收拢进pricing对象,使价格逻辑集中、类型安全,这正是它要逐步取代旧Plan/PlanProduct分离式结构的原因。
七、实践指南:如何基于mock/next编写与演进测试 Mock
7.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-calypso
The JavaScript and API powered WordPress.com
相关推荐
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辅助生成(AIGC),仅供参考