这篇是在什么情境下写的?

出来工作的第五个年头,大公司、小公司都呆过,各种东西都在变,维度是后端的 API 永恒不变 ———— 一直都很随便。
每逢有一点风吹草动(项目需求变动大),你都得重新修改被引用接口部分的代码,如果碰上了一个文件上千行代码就真的很头疼(加油)。如果还碰上 解构+别名 就更痛苦了。

我每每遇上这些问题,我都会想去写下这篇文章,但每次下笔前我都会想:会不会是我太菜了,明明改个代码就好了,是自己的阅读代码能力太弱了。但自从我的道德素质下降了之后,这 TMD 都是后端的问题,不接受任何反驳!

我最新的理解是:前后端的联动基本都在 API 的层次上,其实我们不应该太过关注后端返回的数据模型的 类型、结构 。(前提是满足当前业务需求),而前端应该以 AOP(类似 graphQL) 的方式去处理它,在其它相关的业务代码里不应关心它,或者说不值得为了这接口写一大堆辅助方法去尝试调整成所需要的模型结构。这些统统都在 DTO 文件里实现,这让业务代码变得清爽不少。

以前学过 Java 和 Spring ,里面的 Java Bean + 注解 可以提高生产力,然后还有 DTO 的概念,正好也发现 class-transform 可以实现以上的功能。这才有了这篇文章。

为什么要使用 class-transformer ?

  1. 应对各种突发的状况。

比如:某个 key 对应的值为空、或者直接缺少某个 key 。
这些都会导致你的代码在运行时环境产生意料之外的异常 TypeError: Cannot read properties of undefined
这种是很烦人的,一般类型异常直接让你的代码无法再正常的运行下去。

当然,你会说用 optional chaining(可选链) 不就没有这个问题了吗?
虽然 可选链 很完美,但在 TS 项目里,明明类型声明已经确定了某个 属性 的存在,却还要用 可选链 去访问。
这其实是很怪的。(你没有办法去预知运行时的环境)
还不如用 class-transformer 提早设好一个默认值,大胆的去引用属性岂不是更好

  1. 内容格式转换(在不依赖别的接口情况下)

大部分后端都爱说:格式转换的事不应该后端做,太费性能了,就该前端转。(只是懒而已)
在这里,我们可以集中把转换方法和转换类代码聚合在一起,让实际的业务代码看起来更清爽。
例子:常见的时间格式的转换、枚举类型的转换

  1. 类型提示

在 TS 项目里,我们要经常为接口编写并更新类型以提供代码提示。
有可能你的类型对象会写在任意一个业务文件里,被到处 import (一般都会偷懒不把类型对象写在指定的声明文件上)。
又或者是新手老是会忘记给对象标注类型,那么可以直接用转换成 class 的方式去提供类型提示,而不用去手动做类型标注。类型声明全在 class 里一次搞定。

  1. 文档的说明

讲真大中小公司的后端都不太乐意写一份像样的 api 文档。当你写完了正转换类的文件时,其实就是一个可读性不错的文档了。天然的代码即文档

常见的场景以及对应的示例代码

  1. 内容格式转换 与 默认值(当前接口没返回时,提供一个默认值)
    比较常见的就是时间戳的转换(这里将用 dayjs 作为时间转换)
class Person {
  // Expose 是强制显示该字段,即使没有传数据也会显示。
  // 建议所有字段都加一个 Expose 装饰器,以免最后在转换数据模型时出现字段丢失
  @Expose()
  name: string = "User Name"; // Expose 是强制显示该字段,即使没有传数据也会显示 // 建议所有字段都加一个 Expose 装饰器,以免最后在转换数据模型时出现字段丢失

  @Expose() // Transform 将当前属性值进行转换
  @Transform(({ value }) => dayjs(Number(value || 0)).format("YYYY-MM-DD"))
  registed_date: string = "";
}

const data = {
  registed_date: "1686476286000"
};
const p = plainToClass(Person, data);
// p =>
// {
//     "name": "User Name",
//     "registed_date": "2023-06-11"
// }
  1. 数据换 key 或 key 要换成其他层级(深度)的 key
    由于业务原因,你得换一个 key,但是项目有很多地方都用了这个 key 或 因为设置了别名的原因导致无法快速的切换,这个方案就很不错了,通过 aop 的方式快速无痛修改

比如:后端返回的数据如下

{
  "price": 100,
  "symbol": "$"
}

前端只能按照这种结构在业务代码里应用,一旦该结构遭到修改,你就得修改业务代码部分。在这里我们用 AOP 的方式对 key 与 key 之间进行映射。
现在后端返回的数据如下:

{
  "currency": {
    "price": 100,
    "symbol": "$"
  }
}

DTO 如下:

type Currency = {
  price: number;
  symbol: string;
};

class CurrencyDTO {
  @Expose({ name: 'newPrice' })
  @Transform(({ obj }: { obj: CurrencyDTO }) => obj.currency.price)
  price: number = 0;

  @Expose()
  @Transform(({ obj }: { obj: CurrencyDTO }) => obj.currency.symbol)
  symbol: string = ""; // 原始数据的部分。即:后端返回的数据先临时保存在这里

  currency: Currency = {
    price: 0,
    symbol: ""
  };
}

plainToClass(CurrencyDTO, {
  currency: {
    price: 100,
    symbol: "$"
  }
});

// {
//     "price": 100,
//     "symbol": "$",
//     这里可以根据需要自行控制是否显示 currency
//     "currency": {
//         "price": 100,
//         "symbol": "$"
//     }
// }
  1. 调整数据模型的结构。
    在使用 mvvm 框架下,ui 和数据是强绑定的,一般数据模型的结构决定了 ui,但往往后端的数据总是不如意的,我们一般拿到数据都要转换过才能使用。
    情景:用户评论列表下,需要将多层次的数据依次铺平最后,或者某一些值是需要参与计算的,最后得出一个 ui 可用的 list 。
    ps:实际工作中你可能会认为这应该直接在 ui 层通过访问符直接去引用即可,不用这么麻烦。(这只是一个简单的例子,实际可能会比这更复杂,但方法依然通用)

PS: 个人建议这种缩短调用链的方法最好是用 lodash.get 这种方法可以安全的获取 value ,而不用通过可选链的方式去访问。这让代码变得更清爽一些。

[
  {
    // 需要计算出 评论列表 commentList 的长度
    "commentList": [
      {
        "msg": "hello world"
      }
    ],
    // 缩短读取 点赞数 的链路。(铺平)
    "count": {
      "likeCount": {
        "value": 10
      }
    }
  }
]
type Comment = {
  msg: string;
};

type Count = {
  likeCount: { value: number };
}

type CommentInfo = {
  commentList: Comment[];
  count: Count;
}

class Community {
  // 缩短调用链
  @Expose()
  @Transform(({ obj }: { obj: CommentInfo }) => obj.count.likeCount.value || 0)
  likeCount: number = 0;
  // 统计评论列表数
  @Expose()
  @Transform(({ obj }: { obj: CommentInfo }) => obj.commentList.length || 0)
  commentCount: number = 0;
  // 原始的数据,可以保证遇到预料之外的情况还能正常使用原先的数据。
  @Expose()
  @Transform(({ obj }: { obj: CommentInfo }) => obj)
  originData: CommentInfo[] = [];
}
// plainToInstance 在文档上没能体现他的用法,其实 plainToClass 实际就是在调用 plainToInstance 。
// 直接使用 plainToInstance 会报错。
console.log(plainToInstance(Community, data, { excludeExtraneousValues: true}));
// [
//   {
//     likeCount: 10,
//     commentCount: 1,
//     originData: {
//       commentList: [
//         {
//           msg: "hello world"
//         }
//       ],
//       count: {
//         likeCount: {
//           value: 10
//         }
//       }
//     }
//   }
// ];

他的一些缺点

如果愿意花点时间去学习和编写 DTO ,直观上应该没有什么缺点。(从耗时和使用难度这两大成本来说都比较低)

  1. 与 vue 的配合度略低一些,转换后的对象不具备响应式。

可以通过类装饰器去解决

function DataReactive<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    constructor(...args: any[]) {
      super(...args);
      return reactive(this);
    }
  };
}

@DataReactive
class Person {
  a: string = "hello";
}
  1. 不算缺点的缺点。在使用 Transform 装饰器时,你可能会写一堆嵌套类型去表达后端 api,但其实后端都不讲武德了,你也可以不写类型声明了,因为你不知道什么时候后端会改 api 。

总结

我们主要会常用 Expose、Transform 这两个装饰器为主,但实际官方提供了很多的装饰器和方法,需要按需自取。
关于 DTO Class 的编写,最好是都带上 originData 这个属性去保留原有的 api 数据(比如:示例 3),以备不时之需。

相关文档

官方文档,不多。 https://github.com/typestack/class-transformer

ES6 装饰器相关 https://es6.ruanyifeng.com/#docs/decorator



DTO 前后端交互

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!