装饰器模式在生产环境中的实践

吐槽君 分类:javascript
前言

最近在 review 项目的代码,发现很多对表单的 submit 处理函数中,经常会对取到的表单数据做特殊处理以符合后端接口对数据格式的要求,比如把时间选择器拿到的数据转成 unix 时间戳,再或者把多选拿到的值做一次 join 操作拼接成字符串等等。如果每次有类似需求的时候,都在 submit 回调中处理数据,很繁琐,也带来一个很不爽的体验,比如我要再此使用表单数据回填,或者在前端内部流转,在数据使用的地方,我又得时刻记得这个数据是被处理过的,如此在前端的项目里数据流将变得不纯粹和简单。

问题探究

既然不爽,那就解决呗,首先想到的是,让表单控件来支持数据格式化,比如日期选择器,让其支持 value 格式为时间戳,不只是取值的时候,赋值也支持,对于日期选择器我也的确做了这样的封装。但是,对于 Select 类组件,如果让一个多选 Select 返回的数据是个字符串而不是符合自觉的数组,显然不是很好的组件化思路,既不语义也不符合关注点分离原则。

最终,回归数据,这是 UI 层产生的数据,它也应该以服务 UI 层为核心任务,只有当它需要通过接口传递给后端的时候,它才需要改变,它就是服务于UI表现层的业务模型?。

生产实践

按着上述的思路,我为表单数据定义了一个 DTO 类(PS:其实这不是真正意义上 DTO 类,接口描述的数据结构才是 DTO 类,因为后续会基于这个对象进行数据结构转换,加之,常规的理解DTO类是服务于表现层的,所以这里我也定义它为 DTO 类),看示例代码:

export class ExampleQueryDto extends PageQuery {
  // 编号
  code?: string
  // 操作员
  operator?: {
    name: string
    code: string
  }
  // 创建日期 - 日期区间
  createDate?: number[]
  // 状态 - 多选
  status?: number[]
}
 

忽略它继承的那个 PageQuery, 这是一个简单的列表查询表单的数据结构,在传递给接口之前,我就希望在前端内部,它始终以这样的定义在流转,符合 UI 组件对数据的要求。

OK,现在来解决下一个问题,后端接口要求 operator 传 code,要求 status 传拼接的字符串,日期区间分 2 个参数传,很烦,我不想每次都要在接口这里 A.b = C.d 的这样写,我需要有个地方来配置这层转换关系,然后用一个通用函数来执行转换,好了,到此标题里的主角登场了?‍♂️,借助装饰器来实现附加元数据,达到类似 Java 中注解(实际上装饰器更为强大)的效果。看示例代码:

export class ExampleQueryDto extends PageQuery {
  // 编号
  code?: string
  // 操作员
  @Mapping({ name: 'operatorCode', transfer: (value) => value.code })
  operator?: {
    name: string
    code: string
  }
  // 创建日期 - 日期区间
  @Mapping([
    {
      name: 'createTimeStart',
      transfer: (value) => value[0],
    },
    {
      name: 'createTimeEnd',
      transfer: (value) => value[1],
    },
  ])// 这样的配置,可以将源对象的一个参数赋值给目标对象的多个参数上
  createDate?: number[]
  // 状态 - 多选
  @Mapping({ name: 'statusList', transfer: (value) => value.join(',') })
  status?: number[]
}
 

加上装饰器之后,最终的 DTO 类就定义好了,下面是 Mapping 这个装饰器的实现:

// 记录映射参数到源对象属性上
export function Mapping(options: MappingOptions[]): PropertyDecorator
export function Mapping(options: MappingOptions): PropertyDecorator
export function Mapping(options): any {
  return Reflect.metadata(MappingMetaDataKey, options)
}
 

这是一个装饰器工厂函数,只是为了语义化这个装饰器,并通过函数重栽来实现参数的不同类型支持,返回的才是装饰器实现(后续会介绍这个 Reflect.metadata),配置项只有:name —— 目标对象的目标属性名,transfer —— 自定义转换函数,OK,下面进行转换,看转换函数的实现:

/**
 * 转换一个前端的目标业务对象到接口需要的简单对象
 * 结合 {@link Mapping} 可以支持 属性名变更和转换
 * @param target 待转换的数据
 * @param targetClz 待转换的数据类
 */
export function conversion<T>(target: T, targetClz?: ClassConstructor<T>): Record<string, any> {
  const result: Record<string, any> = {}
  if (!target) return null
  const realTarget: T = targetClz
    ? isReallyInstanceOf<T>(targetClz, target)
      ? target
      : plainToClass<T, any>(targetClz, target)
    : target
  Object.keys(realTarget).forEach((k) => {
    const options: MappingOptions[] | MappingOptions | undefined = Reflect.getMetadata(
      MappingMetaDataKey,
      realTarget,
      k,
    )
    const sourceValue: any = realTarget[k]
    if (options) {
      if (Array.isArray(options)) {
        options.forEach((o) => setValue2Result(result, sourceValue, o))
      } else {
        setValue2Result(result, sourceValue, options)
      }
    } else {
      result[k] = sourceValue
    }
  })
  return result
}
 

忽略其中的实现细节,本质上只是根据从元数据中读取的配置信息,进行 get 和 set,这其中解决了些易用性和边际问题,后面我会详细介绍使用到的技术点,先看这个转换函数的使用:

export function query(query: ExampleQueryDto): Promise<PageResult<ExampleListDto>> {
  const params = conversion(query, ExampleQueryDto) // 这里执行转换
  return fetch('v2/order/supplier',{data: params},'get')
}
 

转换效果会类似这样:

// from
{
  code: 'imcode',
  operator: { name: '操作员', code: 'operator-code'},
  createDate: [ 1615442220288, 1615442280288 ],
  status: [ 1, 2 ],
}
// to
{
  code: 'imcode',
  operatorCode: 'operator-code',
  createTimeStart: 1615442220288,
  createTimeEnd: 1615442280288,
  status: '1,2'
}
 

到此为止,我想要的东西大致OK了,基本让我不需要为了接口,而在 UI 层代码中去处理数据。

涉及的技术点

装饰器

tc39/proposal-decorators

详细定义和标准可看?。简单理解就是一个作用于对象或其属性或其属性方法上的高阶函数(需要注意的是这个高阶函数的执行时机,是在你所标注的目标的 definition 期间),可以对目标进行元编程或着扩展它的功能,体现的则是面向对象编程领域中的装饰器模式或者叫 Wrapper 模式。

元数据

元数据是有关实际数据的额外信息。比如我们在用 Object.defineProperty(obj, prop, descriptor) 对一个 obj 设置属性 prop 时,descriptor 中对属性的配置,我们可以视为是一种元数据,是对属性的描述,日常是不可见的,除非你主动去看,这种看就是对元数据的读取,前面的 defineProperty 就是对元数据的修改。但这里仅限对 descriptor 的操作,我们还想能够对类或者类属性、类方法添加自定义的元数据,目前语言层面还不支持类似 Java Annotation 这种声明性语法来附加自定义元数据,同时 JS 目前的 Reflect 也没有读取自定义元数据的能力,所以在项目中要使用上述功能,我们需要在 tsConfig 中开启 emitDecoratorMetadata,并引入 reflect-metadata 这个库,这样我们就可以在目标对象上描述我们的自定义元数据。

简单介绍下 reflect-metadata 这个库,该库主要是对 Reflect 进行了扩展,定义了 defineMetadata 方法添加元数据,和 getMetadata 来从目标对象上读取元数据,并提供 Reflect.metadata 这个装饰器,该库本质是一个 polyfill,而它所实现的设计目前还是 ES7 的一个提案。

简单对象和类实例

如果仔细看上面转换函数的实现,可以看到里面有个 plainToClass 函数,这个函数的作用是把一个简单对象(对象字面量或 new Object())转换成具体的 Class 实例,看代码:

/**
 * @param clz 对象的构造函数
 * @param source 源数据 也就是 Plain Object
 */
export function plainToClass<T extends Record<string, any>, S>(
  clz: ClassConstructor<T>,
  source: S,
): T {
  const result: any = new clz()
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor') {
      continue
    }
    result[key] = source[key]
  }
  return result
}
 

为什么这么做呢,首先 TS 的类型约束是在开发时,运行时依然是 JS,所以在开发时传给转换函数的对象,很大概率是通过对象字面量的形式定义的,其本质是个普通 Object 实例,而不是我定义的 Class 的实例,将无法使用 Class 定义的类中函数或者附加的元数据,因此这里需要把一个普通对象,转换成对应的类的实例。

延伸思考 —— DTO类的价值

应该能看的出来,虽然不用在 form 的 submit 处理函数中,去重复的格式化数据,但是因为 DTO 类的出现,并没有明显减少代码量(当然如果 Query 类的 DTO 是可以继承和共用的),并且既然 request params 通过定义 DTO 进行转换了,那 response body 自然也想用 DTO 类来承接,好处:

  1. 开发时的智能提示和类型约束更加完整,符合一个强类型语言该有的样子,借助 IDE,可以降低重构和维护成本,不然你用啥 TypeScript,JS 无约束,无范式不香吗?
  2. 定义 DTO 类的过程,也是一个对接口和业务的理解过程,这应该成为开发者第一次也是最后一次对接口(别杆,我知道还要联调,包括接口变动的情况),当然每次为接口入参和返回值定义对象,是挺累的,但谁说一定要人来干的,我们有接口描述文档,机器能干的事自然交给机器?
  3. 回头看上面那个 QueryDTO,如果我给它实现类似如下使用的装饰器:
// SearchForm 是一个组件 
// 使用改装饰器 给目标对象 输出可渲染的表单组件的能力
// 别提 no-code 或 less-code, 不同场景 ?
@FormCreator(SearchForm)
export class ExampleQueryDto extends PageQuery {
  @Field({label: '编号'})
  code?: string
  @Field({label: '操作员', type: 'select'})
  operator?: {
    name: string
    code: string
  }
  @Field({label: '创建日期', type: 'rangePicker'})
  createDate?: number[]
  @Field({label:'状态', type: 'status'})
  status?: number[]
}
 

我想表达的是,既然到处都有类似这种描述性或者说声明式定义配置,我们是否可以把它们集中在一个地方,然后在不同的功能点使用同一份描述。

结语

以上就是由一次对代码的 review 而引发的思考和实践,其实对我而言更深层次的思考是关于面向UI的领域模型设计,一个结合业务与交互需求的,框架无关的模型定义,以此来驱动开发。?

回复

我来回复
  • 暂无回复内容