小白再写 TypeScript,我依然是这么魔幻

职场   2024-08-19 08:15   浙江  

unsetunset扶我起来,我还能继续学!unsetunset

大家好。我胡汉三又回来了。

是的,我仍然是个TypeScript小白,仍然在犯基础的 TypeScript 错误。

但是呢,我背后有同事啊,我那些聪明又好心的同事在我钻研 Open SaaS 的时候,慷慨大方地传授了我了很多非常棒的 TypeScript 技巧。

赠人玫瑰,手有余香。今天我要和大家分享的就是这些技巧。

在本系列《小白也能写 TypeScript,我就是这么魔幻》一文中,我们一起学习了satisfies关键字以及 TypeScript 的结构类型系统。

在今天的《后续》中,我们讲点更实际的,那就是,如何巧妙地在大型 app(如 SaaS app)中共享一组值,无论我们何时添加和更改新的值,都会随之更新 app 的其他部分。

用处非常之广,值得我们好好钻研。还是老规矩,我们边看代码边学。

unsetunset跟踪大型app中的值unsetunset

在 Open SaaS 中,如果我们想分配一些 payment plan 值,以便可以在整个 app,包括在前端和后端中使用,该怎么做呢?

很多时候,大多数 SaaS app 都会有一些不同的产品销售计划,例如:

  • 每月的Hobby订阅计划
  • 每月的Pro订阅计划
  • 以及一次性付款产品,为用户提供 10 个积分(Credits),积分可以在 app 中兑换。

那么,代码怎么写呢?聪明的我灵机一动,何不使用enum,同时传递这些 plan 值来保持值的一致性呢?

export enum PaymentPlanId {
  Hobby = 'hobby',
  Pro = 'pro',
  Credits10 = 'credits10',
}

然后,直接在价格页面以及服务器端函数中大大方方地使用枚举。

// ./client/PricingPage.tsx

import { PaymentPlanId } from '../payments/plans.ts'

export const planCards = [
  {
    name: 'Hobby',
    id: PaymentPlanId.Hobby,
    price: '$9.99',
    description: 'All you need to get started',
    features: ['Limited monthly usage''Basic support'],
  },
  {
    name: 'Pro',
    id: PaymentPlanId.Pro,
    price: '$19.99',
    description: 'Our most popular plan',
    features: ['Unlimited monthly usage''Priority customer support'],
  },
  {
    name: '10 Credits',
    id: PaymentPlanId.Credits10,
    price: '$9.99',
    description: 'One-time purchase of 10 credits for your account',
    features: ['Use credits for e.g. OpenAI API calls''No expiration date'],
  },
];

export function PricingPage(props{
  return (
    ...
      planCards.map(planCard => {
        <PlanCard card={planCard} />
      })
    ...
  )
}

Nice。看到了吗,我们在价格页面上将枚举用作 payment plan ID。然后将 ID 传递给按钮点击处理程序,根据请求发送到服务器,程序就知道应该处理哪个 payment plan 了。

// ./server/Payments.ts

export const stripePayment: StripePayment<PaymentPlanId, StripePaymentResult> = async (plan, context) => {
  let stripePriceId;
  if (plan === PaymentPlanId.Hobby) {
    stripePriceId = process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID!;
  } else if (plan === PaymentPlanId.Pro) {
    stripePriceId = process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID!;
  } else if (plan === PaymentPlanId.Credits10) {
    stripePriceId = process.env.STRIPE_CREDITS_PRICE_ID!;
  } else {
    throw new HttpError(404'Invalid plan');
  }

  //...

这里使用枚举的好处是,很容易保持整个 app 的一致性。

在上面的示例中,枚举用于将 price plan 映射到 price ID,price ID是我们在 Stripe 上创建产品时给出的,且已被保存为环境变量。

那么,问题来了,就当前这些代码,如果我们需要创建新的 plan,例如需要增加一个 50 积分的一次性付款产品,该怎么做呢?

export enum PaymentPlanId {
  Hobby = 'hobby',
  Pro = 'pro',
  Credits10 = 'credits10',
  Credits50 = 'credits50'
}

看吧,我们必须翻遍整个 app 的代码,找到使用PaymentPlanID的每个地方,添加对Credits50的引用。寻寻觅觅凄凄惨惨戚戚,找起来可真是太麻烦了!救命啊!

// ./client/PricingPage.tsx

import { PaymentPlanId } from '../payments/plans.ts'

export const planCards = [
  {
    name: 'Hobby',
    id: PaymentPlanId.Hobby,
    //...
  },
  {
    name: 'Pro',
    id: PaymentPlanId.Pro,
    price: '$19.99',
    //...
  },
  {
    name: '10 Credits',
    id: PaymentPlanId.Credits10,
    //...
  },
  {
    name: '50 Credits',
    id: PaymentPlanId.Credits50.
    //...
  }
];

export function PricingPage(props{
  return (
    ...
      planCards.map(planCard => {
        <PlanCard card={planCard} />
      })
    ...
  )
}

// ./server/Payments.ts

export const stripePayment: StripePayment<PaymentPlanId, StripePaymentResult> = async (plan, context) => {
  let stripePriceId;
  if (plan === PaymentPlanId.Hobby) {
    stripePriceId = process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID!;
  } else if (plan === PaymentPlanId.Pro) {
    //..
  } else if (plan === PaymentPlanId.Credits50) {
    stripePriceId = process.env.STRIPE_CREDITS_50_PRICE_ID!; // ✅
  } else {
    throw new HttpError(404'Invalid plan');
  }

虽然说难度不大,可行性也高,但假如我们需要在两个以上的文件中使用PaymentPlanId,该怎么办?

很大概率是,你一个稍不小心,就忘记了在某处引用新的 payment plan!直接完蛋!

这个时候,如果 TypeScript 能告诉我们哪里有忘记添加的地方,会不会超赞的?而这正是Record类型可以帮助我们解决的问题。

怎么做呢,请继续往下看。

unsetunset使用 Record 类型保持值同步unsetunset

首先要说明的是,Record是一种实用程序类型,可帮助我们输入对象。使用Record可以准确定义键和值确切的类型。

在对象上这样写:Record<X, Y>,表示“此对象文本必须为类型X的每个可能值定义一个类型Y的值”。换言之,Record强制执行编译时检查的详尽性。

在实际操作的时候,这意味着当有人向枚举PaymentPlanId添加新的值时,编译器不会让他忘记添加相应的映射。保证了对象映射的强大又安全。

哎哟喂,把自己都写感动了哇。

那么Record是如何与PaymentPlanId枚举协同合作的呢?首先我们来看看如何使用Record类型来确保价格页面始终包含所有的 Payment Plans:

export enum PaymentPlanId {
  Hobby = 'hobby',
  Pro = 'pro',
  Credits10 = 'credits10',
}

// ./client/PricingPage.tsx

export const planCards: Record<PaymentPlanId, PaymentPlanCard> = {
  [PaymentPlanId.Hobby]: {
    name: 'Hobby',
    price: '$9.99',
    description: 'All you need to get started',
    features: ['Limited monthly usage''Basic support'],
  },
  [PaymentPlanId.Pro]: {
    name: 'Pro',
    price: '$19.99',
    description: 'Our most popular plan',
    features: ['Unlimited monthly usage''Priority customer support'],
  },
  [PaymentPlanId.Credits10]: {
    name: '10 Credits',
    price: '$9.99',
    description: 'One-time purchase of 10 credits for your account',
    features: ['Use credits for e.g. OpenAI API calls''No expiration date'],
  }
};

export function PricingPage(props{
  return (
    ...
      planCards.map(planCard => {
        <PlanCard card={planCard} />
      })
    ...
  )
}

这时的planCardsRecord类型,键必须是 PaymentPlanId,值必须是包含 Payment Plan 信息的对象(PaymentPlanCard)。

当我们向枚举添加新的值,例如Credits50时,见证奇迹的时刻到了:

export enum PaymentPlanId {
  Hobby = 'hobby',
  Pro = 'pro',
  Credits10 = 'credits10',
  Credits50 = 'credits50'
}

哎呀,TypeScript 给出了一个编译时错误,Property '[PaymentPlanId.Credits50]' is missing...,我们就知道了价格页面不包含新 plan 的卡片。

体验了使用Record来保持值的一致性之后,是不是感觉既简单又便捷?但是我们不应该只用在前端,还有服务器端函数呢,别忘了我们还需要这个函数来为我们处理不同的 Payment Plan 呢:

// ./payments/plans.ts
export const paymentPlans: Record<PaymentPlanId, PaymentPlan> = {
  [PaymentPlanId.Hobby]: {
    stripePriceId: process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID,
    kind: 'subscription'
  },
  [PaymentPlanId.Pro]: {
    stripePriceId: process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID,
    kind: 'subscription'
  },
  [PaymentPlanId.Credits10]: {
    stripePriceId: process.env.STRIPE_CREDITS_PRICE_ID,
    kind: 'credits'
    amount: 10
  },
  [PaymentPlanId.Credits50]: {
    stripePriceId: process.env.STRIPE_CREDITS_PRICE_ID,
    kind: 'credits'
    amount: 50
  },
};

// ./server/Payments.ts
import { paymentPlans } from './payments/plans.ts'

export const stripePayment: StripePayment<PaymentPlanId, StripePaymentResult> = async (planId, context) => {
  const priceId = paymentPlans[planId].stripePriceId

  //...

这技术真正酷的地方在于,通过Record类型定义paymentPlans,使用PaymentPlanId枚举作为键值,我们就能始终确保不会遗漏任何 payment plan,也不会犯输错字母的错误。

伟大的 TypeScript 拯救了我们,赞美它。

此外,如果你对if else块看不顺眼,切换为单行代码也是分分钟的事:

const priceId = paymentPlans[planId].stripePriceId

太干净了!哇哦:)

如果有需要,我们甚至可以在代码的其他地方使用paymentPlans对象,使代码更简洁、更易于维护。感谢Record类型,实现了一个真正的三赢局面。

unsetunset映射优先Record,不要If elseunsetunset

为了进一步说明Record的优势,让我们来看另一个在客户端使用它来显示用户帐户信息的示例。

首先总结一下 app 中我们做了哪些事情:

  1. 我们定义了PaymentPlanId枚举来归拢 payment plan ID,使它们在整个 app 中保持一致。
  2. 我们在客户端和服务器代码中使用Record映射对象,确保所有的 payment plan 都存在于这些对象中。这样一来,如果我们后续需要添加新的 payment plan,就会收到 TypeScript 警告——新的 payment plan 也必须添加到这些对象中。

现在,我们在前端使用这些 ID ,将 ID传递给服务器端调用,以便在用户单击Buy Plan按钮时正确处理付款。当用户完成付款时,程序将保存此PaymentPlanId到数据库中用户模型的一个属性,例如user.paymentPlan中。

感觉有点头大了?哈哈。仔细捋一捋,宝宝你可以的。

搞明白了?好的,那我们继续。让我们再次使用这个值以及包含Record类型的映射对象,我们发现,比起使用if elseswitch块,检索帐户信息明显更清晰、更类型安全了:

// ./client/AccountPage.tsx

export function AccountPage({ user }: { user: User }{
  const paymentPlanIdToInfo: Record<PaymentPlanId, string> = {
    [PaymentPlanId.Hobby]: 'You are subscribed to the monthly Hobby plan.',
    [PaymentPlanId.Pro]: 'You are subscribed to the monthly Pro plan.',
    [PaymentPlanId.Credits10]: `You purchased the 10 Credits plan and have ${user.credits} left`,
    [PaymentPlanId.Credits50]: `You purchased the 50 Credits plan and have ${user.credits} left`
  };

  return (
    <div>{ paymentPlanIdToInfo[user.paymentPlan] }</div>
  )
}

同样的,我们只需要更新PaymentPlanId枚举,使枚举包含所有的 payment plan 即可,伟大的 TypeScript 会警告我们不要有任何遗漏。

相比之下,如果我们使用if else块,此类警告将与我们无缘。同样,我们也没有防止错别字的保护措施:

export function AccountPage({ user }: { user: User }{
  let infoMessage = '';

  if(user.paymentPlan === PaymentPlanId.Hobby) {
    infoMessage = 'You are subscribed to the monthly Hobby plan.';

  // ❌ We forgot the Pro plan here, but will get no warning from TS!

  } else if(user.paymentPlan === PaymentPlanId.Credits10) { 
    infoMessage = `You purchased the 10 Credits plan and have ${user.credits} left`;

  // ❌ Below we used the wrong user property to compare to PaymentPlanId.
  // Although it's acceptable code, it's not the correct type!
  } else if(user.paymentStatus === PaymentPlanId.Credits50) {
    infoMessage = `You purchased the 50 Credits plan and have ${user.credits} left`;
  }

  return (
    <div>{ infoMessage }</div>
  )
}

顺便说一句,如果我们需要更复杂的条件检查,需要有能力单独处理附带情况,那么此时,使用if elseswitch语句更佳。具体情况总是应该具体对待,聪明的程序员肯定懂。

虽然俗话说,鱼与熊掌,不可兼得。但我一定要兼得,能不能行?能不能既有与Record映射相同的类型检查彻底性,又具备if elseswitch的优势?

unsetunset试试使用 neverunsetunset

对于上面的问题,关键是我们需要检查switch语句中的详尽性。让我们继续一边看例子,一边理解:

// ./payments/Stripe.ts

  const plan = paymentPlans[planId];

  let subscriptionPlan: PaymentPlanId | undefined;
  let numOfCreditsPurchased: number | undefined;

  switch (plan.kind) {
    case 'subscription':
      subscriptionPlan = planId;
      break;
    case 'credits':
      numOfCreditsPurchased = plan.effect.amount;
      break;
  } 

上面的代码没有使用带Record类型的映射,而是使用了一个相对简单的switch语句,因为以此种方式分配subscriptionPlannumOfCreditsPurchased这两个变量的值,更清晰、更易于阅读。

但是这样一来,我们就失去了Record类型映射的详尽类型检查。换句话说,如果此时我们添加一个新的plan.kind,例如metered-usage,上面的switch语句不会发送任何警告。

幸运的是,有一个简单的解决方案。我们可以创建一个实用程序函数来进行检查:

export function assertUnreachable(x: never): never {
  throw Error('This code should be unreachable');
}

这可能看起来有点奇奇怪怪的,因为代码居然用到了never类型。它的作用是告诉 TypeScript 此值应该“永远不会”出现。

为了让大家理解这个实用程序函数是如何工作的,现在让我们继续添加新的 plan kind

// ./payments/plans.ts
export const paymentPlans: Record<PaymentPlanId, PaymentPlan> = {
  [PaymentPlanId.Hobby]: {
    stripePriceId: process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID,
    kind: 'subscription'
  },
  [PaymentPlanId.Pro]: {
    stripePriceId: process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID,
    kind: 'subscription'
  },
  [PaymentPlanId.Credits10]: {
    stripePriceId: process.env.STRIPE_CREDITS_PRICE_ID,
    kind: 'credits'
    amount: 10
  },
  // ✅ Our new payment plan kind
  [PaymentPlanId.MeteredUsage]: {
    stripePriceId: process.env.STRIPE_METERED_PRICE_ID,
    kind: 'metered-usage'
};

此时,如果我们添加assertUnreachable,看看会发生什么:

啊哈!我们得到了一个错误Argument of type '{ kind: "metered-usage"; }' is not assignable to parameter of type 'never'

Nice。我们在switch语句中引入了详尽的类型检查。实际上,这段代码从来就不是为了运行,只是为了提前提供友好的警告。

最后,为了让 TypeScript 在这种情况下不要误伤我们,我们所要做的就是:

  switch (plan.kind) {
    case 'subscription':
      subscriptionPlan = planId;
      break;
    case 'credits':
      numOfCreditsPurchased = plan.effect.amount;
      break;
    // ✅ Add our new payment plan kind
    case 'metered-usage'
      currentUsage = getUserCurrentUsage(user);
      break;
    default:
      assertUnreachable(plan.kind);
  } 

这太棒了。我们不但获得了在switch语句中处理复杂逻辑的好处,而且也确保了不会遗漏 app 中的所有plan.kind。两好处尽在我手,可把我得意坏了。

这就是我的学习方式:发现问题,解决问题,再看看解决方案有没有能够改进的地方,从而不断进步,不断在潜移默化中增强能力。累石成山,集腋成裘,盖莫如此。

与诸君共勉!

unsetunset继续 TypeScript 的故事unsetunset

这是本系列的第 2 部分,第 1 部分是《小白也能写 TypeScript,我就是这么魔幻!》。TypeScript,真的是一个非常棒的语言,赞美她!

关注「前端新世界」加星标,我有讲不完的前端故事
↓↓↓

END


精彩推荐

小白也能写 TypeScript,我就是这么魔幻!

万能的 Array.reduce() 你知道吗?

探索用于 3D Web 开发的 Three.js

构建大规模 Vue.js 3 应用程序的 6 个技巧

前端新世界
关注前端技术,分享互联网热点
 最新文章