大家好!在上一篇文章《浅谈ts中的enum类型》中,我们详细介绍了TypeScript中enum的基本概念、用法以及优缺点。我们了解到enum虽然提供了很好的类型安全和代码可读性,但也存在运行时开销、可变性问题和tree-shaking等缺点。

今天,作为系列的第二篇文章,我们将进一步探讨如何在实际项目中更优雅地使用枚举来定义和管理字典数据,同时结合装饰器来简化我们的代码。通过合理的封装,我们可以保留enum的优点,同时尽可能地规避其缺点。

为什么需要封装枚举?

在上一篇文章中,我们讨论了enum的几个主要问题:运行时开销、可变性和类型安全不够严格等。除此之外,在实际开发中,我们经常需要处理各种"字典数据",比如用户状态、订单类型、支付方式等。这些数据通常有以下特点:

  1. 有固定的键值对关系
  2. 在多个地方被重复使用
  3. 需要在前端展示对应的文本
  4. 可能需要根据不同场景显示不同的文本或样式

直接使用枚举虽然可以解决部分问题,但还是有一些不足:

// 直接使用枚举的方式
enum OrderStatus {
  Pending = 1,
  Processing = 2,
  Shipped = 3,
  Delivered = 4,
  Canceled = 5
}

// 如果要显示中文名称,还需要额外定义映射
const orderStatusMap = {
  [OrderStatus.Pending]: '待付款',
  [OrderStatus.Processing]: '处理中',
  [OrderStatus.Shipped]: '已发货',
  [OrderStatus.Delivered]: '已送达',
  [OrderStatus.Canceled]: '已取消'
};

// 使用时需要这样
const status = OrderStatus.Pending;
console.log(orderStatusMap[status]); // 输出: 待付款

这种方式有几个问题:

  1. 枚举和映射是分开定义的,不够内聚
  2. 每次添加新的枚举值,都需要记得更新映射
  3. 如果需要添加更多信息(如状态颜色、是否可编辑等),代码会变得更加复杂
  4. 没有解决上一篇文章中提到的enum的运行时开销问题

通过合理的封装,我们可以:

  • 将枚举值和相关属性(标签、颜色等)集中管理
  • 提供类型安全的API
  • 减少重复代码
  • 在某些情况下,减少运行时开销
  • 提高代码的可维护性和可读性

接下来,我们将分别在Vue3+TS和NestJS中探讨更优雅的解决方案。

在Vue3+TS中封装枚举

我们可以创建一个简洁而强大的枚举封装。以下是一个基于类的优雅实现:

1. 创建枚举基类

首先,我们创建一个通用的枚举基类:

// src/utils/enum/base.ts

// 枚举键类型
export type EnumKey = string | number | boolean;

/**
 * 枚举项基类
 */
export class EnumItem<K extends EnumKey = number> {
  /**
   * 枚举值
   */
  readonly key: K;

  /**
   * 显示标签
   */
  readonly label: string;

  /**
   * 颜色(可用于标签、图标等)
   */
  readonly color?: string;

  /**
   * 是否禁用
   */
  readonly disabled?: boolean;

  /**
   * 额外数据
   */
  readonly [key: string]: any;

  /**
   * 创建枚举项
   * @param key 枚举值
   * @param label 显示标签
   * @param options 额外选项
   */
  constructor(key: K, label: string, options: Record<string, any> = {}) {
    this.key = key;
    this.label = label;
    
    // 复制其他属性
    Object.assign(this, options);
  }
}

/**
 * 枚举类基类
 */
export class Enum {
  /**
   * 获取所有枚举项
   */
  static getItems<T extends EnumItem>(): T[] {
    return Object.values(this)
      .filter(item => item instanceof EnumItem) as T[];
  }

  /**
   * 获取枚举项
   * @param key 枚举值
   */
  static getItem<T extends EnumItem>(key: EnumKey): T | undefined {
    return this.getItems<T>().find(item => item.key === key);
  }

  /**
   * 获取标签
   * @param key 枚举值
   * @param defaultLabel 默认标签
   */
  static getLabel(key: EnumKey, defaultLabel: string = '-'): string {
    return this.getItem(key)?.label || defaultLabel;
  }

  /**
   * 获取颜色
   * @param key 枚举值
   * @param defaultColor 默认颜色
   */
  static getColor(key: EnumKey, defaultColor: string = ''): string {
    return this.getItem(key)?.color || defaultColor;
  }

  /**
   * 检查值是否存在
   * @param key 枚举值
   */
  static has(key: EnumKey): boolean {
    return !!this.getItem(key);
  }

  /**
   * 转换为下拉选项
   */
  static toOptions<T extends EnumItem>(): Array<{ value: EnumKey; label: string; disabled?: boolean }> {
    return this.getItems<T>().map(item => ({
      value: item.key,
      label: item.label,
      disabled: item.disabled
    }));
  }
}

2. 创建具体的枚举类

然后,我们可以创建具体的枚举类:

// src/utils/enum/order-status.ts
import { EnumItem, EnumKey } from './base';

/**
 * 订单状态枚举项
 */
export class OrderStatusItem extends EnumItem<number> {
  /**
   * 是否可编辑
   */
  readonly editable: boolean;

  constructor(key: number, label: string, options: { color?: string; editable?: boolean } = {}) {
    super(key, label, options);
    this.editable = options.editable || false;
  }
}

/**
 * 订单状态枚举
 */
export class OrderStatus extends Enum {
  static readonly Pending = new OrderStatusItem(1, '待付款', { 
    color: 'warning', 
    editable: true 
  });

  static readonly Processing = new OrderStatusItem(2, '处理中', { 
    color: 'primary', 
    editable: true 
  });

  static readonly Shipped = new OrderStatusItem(3, '已发货', { 
    color: 'info', 
    editable: false 
  });

  static readonly Delivered = new OrderStatusItem(4, '已送达', { 
    color: 'success', 
    editable: false 
  });

  static readonly Canceled = new OrderStatusItem(5, '已取消', { 
    color: 'danger', 
    editable: false 
  });

  /**
   * 获取所有订单状态
   */
  static override getItems(): OrderStatusItem[] {
    return super.getItems<OrderStatusItem>();
  }

  /**
   * 获取订单状态
   * @param status 状态值
   */
  static override getItem(status: number): OrderStatusItem | undefined {
    return super.getItem<OrderStatusItem>(status);
  }

  /**
   * 检查状态是否可编辑
   * @param status 状态值
   */
  static isEditable(status: number): boolean {
    return this.getItem(status)?.editable || false;
  }
}

3. 在Vue组件中使用

现在,我们可以在Vue组件中优雅地使用这个枚举:

<template>
  <div>
    <el-table :data="orders">
      <el-table-column prop="id" label="订单ID">
      <el-table-column prop="status" label="状态">
        <template #default="{ row }">
          <el-tag :type="OrderStatus.getColor(row.status)">
            {{ OrderStatus.getLabel(row.status) }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button v-if="OrderStatus.isEditable(row.status)" type="primary" size="small">
            编辑
          </el-button>
        </template>
      </el-table-column>
    </el-table-column></el-table>

    <!-- 筛选表单 -->
    <el-form>
      <el-form-item label="订单状态">
        <el-select v-model="filter.status">
          <el-option v-for="option in OrderStatus.toOptions()" :key="option.value" :label="option.label" :value="option.value" :disabled="option.disabled">
        </el-option></el-select>
      </el-form-item>
    </el-form>
  </div>
</template>

<script lang="ts" setup="">
import { ref } from 'vue';
import { OrderStatus } from '@/utils/enum/order-status';

// 组件内可以直接使用枚举类
const orders = ref([
  { id: 1001, status: OrderStatus.Pending.key },
  { id: 1002, status: OrderStatus.Processing.key },
  { id: 1003, status: OrderStatus.Shipped.key },
  { id: 1004, status: OrderStatus.Delivered.key },
  { id: 1005, status: OrderStatus.Canceled.key }
]);

const filter = ref({
  status: null as number | null
});
</script>

4. 创建更多枚举类

我们可以轻松创建更多的枚举类:

// src/utils/enum/payment-method.ts
import { EnumItem, Enum } from './base';

export class PaymentMethodItem extends EnumItem<number> {
  readonly icon: string;

  constructor(key: number, label: string, icon: string) {
    super(key, label, { icon });
    this.icon = icon;
  }
}

export class PaymentMethod extends Enum {
  static readonly Alipay = new PaymentMethodItem(1, '支付宝', 'alipay-icon');
  static readonly WechatPay = new PaymentMethodItem(2, '微信支付', 'wechat-icon');
  static readonly CreditCard = new PaymentMethodItem(3, '信用卡', 'credit-card-icon');
  static readonly BankTransfer = new PaymentMethodItem(4, '银行转账', 'bank-icon');

  static override getItems(): PaymentMethodItem[] {
    return super.getItems<PaymentMethodItem>();
  }

  static getIcon(method: number): string {
    return this.getItem<PaymentMethodItem>(method)?.icon || '';
  }
}

5. 使用组合式API封装枚举

如果你更喜欢组合式API的风格,我们也可以创建一个useEnum函数:

// src/composables/useEnum.ts
import { computed } from 'vue';
import type { Enum } from '@/utils/enum/base';

export function useEnum<T extends typeof Enum>(enumClass: T) {
  const items = computed(() => enumClass.getItems());
  const options = computed(() => enumClass.toOptions());

  return {
    enum: enumClass,
    items,
    options,
    getLabel: (key: any, defaultLabel?: string) => enumClass.getLabel(key, defaultLabel),
    getColor: (key: any, defaultColor?: string) => enumClass.getColor(key, defaultColor),
    getItem: (key: any) => enumClass.getItem(key),
    has: (key: any) => enumClass.has(key)
  };
}

在组件中使用:

import { useEnum } from '@/composables/useEnum';
import { OrderStatus } from '@/utils/enum/order-status';

const { options, getLabel, getColor, has } = useEnum(OrderStatus);

这种枚举封装的优势

这种基于类的枚举封装方案有以下优势:

  1. 类型安全:完全利用TypeScript的类型系统,提供严格的类型检查
  2. 集中管理:枚举值和相关属性(标签、颜色等)集中在一个地方定义
  3. 扩展性强:可以轻松添加自定义属性和方法
  4. 使用简单:静态方法使用方便,无需实例化
  5. 代码复用:基类提供通用功能,减少重复代码
  6. 可维护性高:添加新的枚举值只需在一个地方修改

与直接使用TypeScript枚举相比,这种方式虽然代码量稍多,但提供了更多功能和更好的类型安全,同时避免了enum的一些缺点。

在NestJS中使用装饰器封装字典

在后端开发中,特别是使用NestJS框架时,我们可以利用装饰器来优雅地处理字典数据。

1. 创建字典装饰器

首先,我们创建一个字典装饰器:

// src/common/decorators/dictionary.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';

// 字典项接口
export interface DictionaryItem {
  value: number | string;
  label: string;
  [key: string]: any;
}

// 字典接口
export interface Dictionary {
  name: string;
  items: DictionaryItem[];
}

// 字典注册表
const dictionaryRegistry: Record<string, Dictionary> = {};

// 注册字典
export function registerDictionary(name: string, items: DictionaryItem[]): void {
  dictionaryRegistry[name] = { name, items };
}

// 字典装饰器
export const Dict = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest<Request>();
    
    // 如果没有指定字典名称,返回所有字典
    if (!data) {
      return dictionaryRegistry;
    }
    
    // 返回指定的字典
    return dictionaryRegistry[data];
  }
);

// 字典值装饰器
export const DictItem = createParamDecorator(
  (data: { dict: string; value: any }, ctx: ExecutionContext) => {
    if (!data || !data.dict || data.value === undefined) {
      return null;
    }
    
    const dictionary = dictionaryRegistry[data.dict];
    if (!dictionary) {
      return null;
    }
    
    return dictionary.items.find(item => item.value === data.value) || null;
  }
);

2. 注册字典数据

然后,我们可以在应用启动时注册字典数据:

// src/common/dictionaries/index.ts
import { registerDictionary } from '../decorators/dictionary.decorator';

// 注册订单状态字典
export enum OrderStatus {
  Pending = 1,
  Processing = 2,
  Shipped = 3,
  Delivered = 4,
  Canceled = 5
}

registerDictionary('orderStatus', [
  { value: OrderStatus.Pending, label: '待付款', color: 'warning', editable: true },
  { value: OrderStatus.Processing, label: '处理中', color: 'primary', editable: true },
  { value: OrderStatus.Shipped, label: '已发货', color: 'info', editable: false },
  { value: OrderStatus.Delivered, label: '已送达', color: 'success', editable: false },
  { value: OrderStatus.Canceled, label: '已取消', color: 'danger', editable: false }
]);

// 注册支付方式字典
export enum PaymentMethod {
  Alipay = 1,
  WechatPay = 2,
  CreditCard = 3,
  BankTransfer = 4
}

registerDictionary('paymentMethod', [
  { value: PaymentMethod.Alipay, label: '支付宝' },
  { value: PaymentMethod.WechatPay, label: '微信支付' },
  { value: PaymentMethod.CreditCard, label: '信用卡' },
  { value: PaymentMethod.BankTransfer, label: '银行转账' }
]);

// 可以注册更多字典...

在应用的main.ts中导入这些字典:

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import './common/dictionaries'; // 导入字典注册

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

3. 在控制器中使用字典装饰器

现在,我们可以在控制器中使用这些装饰器:

// src/modules/dictionary/dictionary.controller.ts
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { Dict, DictItem } from '../../common/decorators/dictionary.decorator';
import { OrderStatus } from '../../common/dictionaries';

@Controller('dictionaries')
export class DictionaryController {
  // 获取所有字典
  @Get()
  getAllDictionaries(@Dict() dictionaries: any) {
    return dictionaries;
  }

  // 获取特定字典
  @Get(':name')
  getDictionary(@Param('name') name: string, @Dict(name) dictionary: any) {
    return dictionary;
  }

  // 获取特定字典项
  @Get(':dict/:value')
  getDictionaryItem(
    @Param('dict') dict: string,
    @Param('value', ParseIntPipe) value: number,
    @DictItem({ dict, value }) item: any
  ) {
    return item;
  }

  // 示例:获取订单状态标签
  @Get('order-status/:status')
  getOrderStatusLabel(
    @Param('status', ParseIntPipe) status: OrderStatus,
    @DictItem({ dict: 'orderStatus', value: status }) statusItem: any
  ) {
    return statusItem ? statusItem.label : '未知状态';
  }
}

4. 创建字典服务

为了在业务逻辑中更方便地使用字典,我们可以创建一个字典服务:

// src/modules/dictionary/dictionary.service.ts
import { Injectable } from '@nestjs/common';
import { Dictionary, DictionaryItem } from '../../common/decorators/dictionary.decorator';

@Injectable()
export class DictionaryService {
  private readonly dictionaries: Record<string, Dictionary>;

  constructor() {
    // 通过依赖注入获取字典注册表
    this.dictionaries = require('../../common/dictionaries');
  }

  // 获取所有字典
  getAllDictionaries(): Record<string, Dictionary> {
    return this.dictionaries;
  }

  // 获取特定字典
  getDictionary(name: string): Dictionary | null {
    return this.dictionaries[name] || null;
  }

  // 获取特定字典项
  getDictionaryItem(dict: string, value: any): DictionaryItem | null {
    const dictionary = this.getDictionary(dict);
    if (!dictionary) {
      return null;
    }
    
    return dictionary.items.find(item => item.value === value) || null;
  }

  // 获取字典项标签
  getLabel(dict: string, value: any): string {
    const item = this.getDictionaryItem(dict, value);
    return item ? item.label : '';
  }

  // 检查值是否有效
  isValidValue(dict: string, value: any): boolean {
    const dictionary = this.getDictionary(dict);
    if (!dictionary) {
      return false;
    }
    
    return dictionary.items.some(item => item.value === value);
  }
}

5. 使用装饰器进行参数验证

我们还可以创建一个自定义的验证装饰器,用于验证请求参数是否是有效的字典值:

// src/common/decorators/dict-validation.decorator.ts
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
import { DictionaryService } from '../../modules/dictionary/dictionary.service';

// 字典值验证装饰器
export function IsDictionaryValue(dictionaryName: string, validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isDictionaryValue',
      target: object.constructor,
      propertyName: propertyName,
      constraints: [dictionaryName],
      options: validationOptions,
      validator: {
        async validate(value: any, args: ValidationArguments) {
          const dictionaryService = new DictionaryService();
          return dictionaryService.isValidValue(args.constraints[0], value);
        },
        defaultMessage(args: ValidationArguments) {
          return `${args.property} must be a valid value in the ${args.constraints[0]} dictionary`;
        }
      }
    });
  };
}

然后在DTO中使用:

// src/modules/order/dto/create-order.dto.ts
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { IsDictionaryValue } from '../../../common/decorators/dict-validation.decorator';
import { OrderStatus } from '../../../common/dictionaries';

export class CreateOrderDto {
  @IsNotEmpty()
  @IsString()
  productId: string;

  @IsNotEmpty()
  @IsNumber()
  @IsDictionaryValue('orderStatus', { message: '无效的订单状态' })
  status: OrderStatus;

  @IsNotEmpty()
  @IsNumber()
  @IsDictionaryValue('paymentMethod', { message: '无效的支付方式' })
  paymentMethod: number;
}

总结

通过以上方法,我们可以在Vue3+TS和NestJS项目中更优雅地管理和使用字典数据:

  1. 在Vue3+TS中

    • 可以选择基于类的字典封装,适合复杂场景和传统项目
    • 也可以选择基于函数的字典创建方式,更符合Vue3组合式API风格
    • 封装常用方法,如获取标签、检查有效性等
    • 支持添加额外属性,如颜色、可编辑状态等
    • 提供全局字典管理器,方便在任何组件中使用
  2. 在NestJS中

    • 使用装饰器简化字典数据的获取和使用
    • 创建字典服务统一管理字典逻辑
    • 使用自定义验证装饰器验证请求参数
    • 集中注册和管理所有字典数据

这些方法不仅提高了代码的可维护性和可读性,还增强了类型安全,减少了重复代码。当需要添加新的字典项或修改现有字典时,只需在一个地方进行更改,所有使用该字典的地方都会自动更新。

希望这篇文章对你在TypeScript项目中管理字典数据有所帮助!如果你有其他更好的方法或建议,欢迎在评论区分享。