Skip to content

Typescript注解使用案例

自上次分享了「TS - 装饰器与注解」后,有几个小伙伴私信问我在在真实的工作中是如何使用这种写法的,在了解到很多小伙伴并没有接触到相关使用后,专门整理这份使用案例来分享给大家,希望能帮助到有需要的开发者

需要强调的一点是在Typescript中不是注解而是称为装饰器,由于在Java中注解说习惯了,这里我就顺便这样写了,二者还是有很大区别的,可以查看我的往期文章了解更多

注解意义

注解本身就是一种设计模式的提现,它的存在当然有很必要的意义的

试想一下,你正在开发一个应用,面对大量重复的配置和硬编码逻辑,难免感到心烦意乱。这时,注解就像一位贴心的助手,它帮你标记出关键点,自动完成那些繁琐的操作。你只需专注于业务逻辑,其余的交给它来处理——既省时又省力,让开发变得更加优雅!

注解是一种以元数据形式描述代码行为的设计模式,通过在类、方法、属性或参数上标记特定信息,实现逻辑的扩展或控制。它不仅能提升代码的语义化表达,让逻辑更清晰易读,还可以解耦业务逻辑与配置逻辑,避免繁琐的手动操作。注解的广泛应用,使复杂功能的实现更加灵活高效,例如依赖注入、权限校验、日志记录等场景,都能通过注解轻松完成

总之可以总结为以下几点:

  1. 提高代码清晰度:通过语义化的注解,让代码更加直观易读
  2. 降低耦合度:解耦核心逻辑和配置逻辑,使代码更灵活可扩展
  3. 增强代码复用性:将通用功能封装为注解,减少重复代码
  4. 支持动态功能扩展:通过注解结合反射和 AOP 实现动态编程
  5. 简化框架使用:降低开发者使用框架的学习曲线,提高开发效率
  6. 便于维护和扩展:通过调整注解元数据,快速适应需求变化

使用约束

在 TypeScript 中使用装饰器有一些限制和约束,这是由于 TypeScript 的设计规范以及装饰器本身的特性决定的。以下是使用装饰器时需要注意的主要约束:

  • 装饰器的目标有限,不支持在函数外独立使用装饰器,只能在类、方法、属性、参数中使用
  • 由于是在Runtime阶段的动态修改,因此不支持类型的静态检查
  • 装饰器只能为属性设置元数据,而不能直接访问或修改其值
  • 结合Reflect元数据可能会对应能有一定的影响

接下来我们就进入正文,通过一些案例来了解如何使用它

NestJS

对于大多数人而言注解使用通常都会在NestJS中开始的,它被称为前端版本的Spring框架,正因为NestJS框架优秀的设计模式,它也是严格意义上的NodeJS框架;这里我们以它作为入手参考

在NestJS中会看到很多注解使用形式,以下代码为简化代码:

定义控制器:

ts
@Controller("/api/material")
export class MaterialController {
  constructor(private materialService: MaterialService) {}

  @Get("categories")
  async findMaterialCategory(
    @Query() query: QueryMaterialCategoryDTO,
    @Query() pagination: PaginationDTO,
    @Query() order: OrderDTO
  ) {
    return await this.materialService.findMaterialCategory(query, pagination, order);
  }
}

定义service:

ts
@Injectable()
export class MaterialService {
  constructor(
    @InjectRepository(MaterialCategoryEntity)
    private materialCategoryRepository: Repository<MaterialCategoryEntity>,
    @InjectRepository(MaterialEntity)
    private materialRepository: Repository<MaterialEntity>
  ) {}

  async findMaterialCategory(query: QueryMaterialCategoryDTO, pagination: PaginationDTO, order: OrderDTO): IResult<IPager<MaterialCategory>> {
    return null;
  }
}

使用NestJS不光要学会如何使用,知道其背后的执行逻辑意义更大;上面代码分别定义了一个控制器和服务层,控制器用来定义路由功能,服务层用来查询数据,通过将他们分开写可以提高健壮性,实现低耦合,当然这也是后端传统的MC写法方式

我们知道以上代码就完成了路由注册,那么你知道NestJS是怎么知道的吗❓

其实,它很简单,如果你看了我上一篇的文章,那么应该就会明白,肯定有一个存放路由配置的容器,然后NestJS从中拿到对应的配置就可以,大体逻辑就是这样。那么实现逻辑就在 对应的注解逻辑中,如:ControllerGet等等,下面我们用伪代码简单实现下:

ts
// 存放模块名
const MODULE_ROUTES = new Map<string, string>();
// 存放具体定义的路由
const APP_ROUTES = new Map<string, { method: string; handler: Function }>();

export function Controller(modulePath: string = '') {
  return (target: Constructor) => {
    MODULE_ROUTES.set(target.name, modulePath);

    return target;
  };
}

export function Get(path: string = '') {
  return function (target: InstanceType<Constructor>, propertyKey: string)  {
    const moduleName = target.constructor.name;

    APP_ROUTES.set(`%${moduleName}%${path}`, {
      method: 'get',
      handler: target[propertyKey]
    });
  };
}


// 假设这是我们定义的APP路由模块
@Controller("/app")
export class AppController {
  @Get('/home')
  home() {
    return 'home';
  }
}

上面已经把ControllerGet的核心定义好了,但是存储的路由还是不能用的,需要简单的进行处理后才可以交给应用进行处理

ts
// 这是维护的应用路由表
const router: Record<string, {[key in string]: Function}> = {};

// 生成最终的路由表
function generateRouter() {
  for (const [moduleName, modulePath] of MODULE_ROUTES) {
    for (const [routePath, {method, handler}] of APP_ROUTES) {
      const routePrefix = `%${moduleName}%`;
      const isCurrentModule = routePath.startsWith(routePrefix);

      if (!isCurrentModule) continue;

      const currentPath = routePath.replace(routePrefix, modulePath);
      if (!router[currentPath]) router[currentPath] = {};

      router[currentPath][method] = handler;
    }
  }

  return router;
}

通过上面的代码我们就可以真正的做到了路由的自动注册和获取,接下来我们来模拟一下他的效果:

ts
const router = generateRouter();

// 假设这是HTTP请求的路径
const requestPath = '/app/home';
const requestMethod = 'get';

const matchedRoute = router[requestPath];
const result = matchedRoute[requestMethod]();

console.log(result)

expect(result).toBe('home');

结果符合预期‼️ 以上只是简单的模拟路由的处理思路,当然有很多细节还是要处理的。读者可以将以上代码全部拷贝到一个文件中,运行就可以看到结果

总之可以发现以上使用注解让程序设计更加简单,通过上面案例小伙伴们应该也了解了它的优势,接下来我们就来看看其他项目中如何使用

HTTP请求

在项目中通常都会将同模块的内容放在一起,网络请求也是可以的。我们可以模仿Spring的Controller,同类的Controller放在一个class中,里面放当前模块的RESTful,这也是最佳的实践。有了Controller类后,注解就可以无限发挥了

RESTful

以下为一个简单的user模块的请求:

ts
@Controller("/user")
export class UserController extends BaseRequest {
  @Get("/:id")
  async getUserInfo(id: number, config?: IHttpRequestConfig) {
    return this.request({ urlPath: { id }, captureError: false, ...config });
  }
}

在上文已经对NestJS的路由实现已经做了简单的实现了,这里详细实现就不做演示了,思路都是一致的,以上request实例为axios

CancelToken

CancelToken主要是用来取消掉重复的请求的,减少不必要的网络请求。它是axios赋予的功能,其本质原理还是AbortController的作用

ts
@Controller("/user")
export class UserController extends BaseRequest {
  @Get("/:id")
  @Token() // 这里增加Token,用来标识当前请求可以随时被取消
  async getUserInfo(id: number, config?: IHttpRequestConfig) {
    return this.request({ urlPath: { id }, captureError: false, ...config });
  }
}

// Token的实现
export function Token(signal?: AbortSignal) {
  return function (target: ICtor): InstanceType<typeof target> {
    return class extends target {
      async send() {
        if (!this.requestConfig.ignoreCancelToken) {
          this.requestConfig.signal = signal || this.requestConfig.signal;

          CancelToken.instance.register(this.requestConfig);
        }

        const res = await super.send();

        CancelToken.instance.cancel(this.requestConfig);

        return res;
      }
    };
  };
}

以上CancelToken类主要逻辑就是来存放当前请求的唯一标识,这里不展开了;当上面的Token加上后当请求的内容一模一样时前面的请求就会被终止掉

思考:终止后的请求服务器还会收到吗?

错误码

每个接口的请求除了真正意义上的错误外,通常为了方便提示用户都会有相应的错误码,客户端可以根据错误码来提示不同的文案

ts
@Controller("/user")
export class UserController extends BaseRequest {
  @Post("/:id/avatar")
  @CaptureError(UploadUserAvatarErrorMessage) // 新增错误码注解
  async uploadUserAvatar(id: number, config?: IHttpRequestConfig) {
    return await request({ urlPath: { id }, captureError: false, ...config });
  }
}

// 注解实现
export function CaptureError(errors: IDict) {
  return function (target: object, key: string, descriptor: PropertyDescriptor) {
    const origin = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      try {
        const res = await origin.call(this, ...args);
        return res;
      } catch (err) {
        throw new BaseError(errors, err);
      }
    };
  };
}

export class BaseError extends Error {
  message: string;
  constructor(errorCodeMessageMap: IDict, error: any) {
    super();
    const code = error.code;
    const codeMsg = errorCodeMessageMap[code];

    if (!code || !codeMsg) this.message = error.message;
    else this.message = errorCodeMessageMap[code];
  }
}

上面就是通用的处理错误码的逻辑,接着继续完善下错误码的提示:

ts
/** 上传用户头像状态码,包含了多语言 */
export const UploadUserAvatarErrorMessage = {
  404: i18n.global.t("error.NOT_FOUND"),
};


// 模拟页面请求
const userController = new UserController();
try {
  const res = await userController.uploadUserAvatar({ id: 1 });
  console.log(res);
} catch (err) {
  Toast(err.message);
}

实体类

了解后端框架的同学应该都知道ORM思想,通过这种方式可以快速方便的将数据转换成目标对象,并且可以做一些格式处理,那么在前端如如何使用呢?

相信大家在工作中都是使用以下方式进行数据的处理的

tsx
// 定义接口数据模型
interface IUser {
  name: string;
  age: number;
  date: string;
  cars: Car[];
}

interface Car {
  name: string;
  color: string;
}


// 创建表单对象
function createUserFormModel(): IUser {
  return {
    name: null,
    age: null,
    date: null,
    cars: null
  };
}

// 在页面中使用
const userModel = createUserFormModel();

<div>
  <Input value={userModel.name} />
  // ...
</div>

以上便是一个最基础的页面表单的提交过程,然而实际情况会面临很多复杂的情况,如格式转换、辅助属性等等,如果字段变多后表单的处理将会变得非常臃肿,而且使用interface方式后还得重新写一遍所有的字段属性,效率上大打折扣

接下来我们通过以下几个步骤来简化使用

  1. 使用class代替interface
ts
class User {
  name: string;
  age: number;
  date: string;
  cars: Car[];
}

class Car {
  name: string;
  color: string;
}

// 创建表单对象直接new即可
const user = new User();

但直接通过new方式不能准确获得类型提示,并且默认属性值都是undefined,如果有要特殊处理的初始值将会变得麻烦,因此我们可以通过代理工厂来创建目标对象

ts
export function createEntity<T extends ICtor>(Ctor: T, initValue?: Partial<InstanceType<T>>): InstanceType<T> {
  return new Ctor(initValue);
}

const user = createEntity(User, { /* 这里就有了类型提示 */ });

通过以上的方式就有了基本的实体类,有了类就可以通过注解施加一点魔法了,通过注解我们慢慢来补充一些通用的功能:

ts
@Entity // 标识这是个实体类
class User {
  name: string;

  @ToJSON<Student, "age">((_, v) => (v ? v >> 0 : undefined))
  age: number;

  @FromJSON<Student>((_, v) => (v ? dayjs(parse(v, "yy/MM-dd HH", new Date())) : null))
  date: string;

  @FieldType(Array(Car))
  cars: Car[];
}

@Entity
class Car {
  name: string;
  color: string;
}

上面简单的补充下了一些注解,对于其内部的逻辑其实也很简单,只需要创建相应的容器存放对应的逻辑即可,这里不做具体介绍;希望读者可以自己仔细研究下如何实现,这里有个IOC在线演示

日志

日志就比较常见了,通过注解也可以很方便的做一些log了,这里简单的演示下,读者可以自行脑补

ts
function Logger(...args) {
  return function (target: any, property: string) {
    console.log(target, property);
  };
}

class UserConstroller {
  @Logger()
  getUserInfo() {}
}

其他

以上我们介绍了很多前端项目的注解使用场景,除了这些还有很多地方可以使用,如在设计一些框架或库时,注解模式也是一种很巧妙的设计实现方式;其发挥的作用还是很广泛的,取决于每一位开发者的设计思想

总结

本篇文章通过介绍不同的场景下使用ts的注解功能,大大提升了编码效率。装饰器是 TypeScript 提供的一种用于扩展类、方法、属性或参数行为的语法,主要通过元编程的方式实现功能增强。它是一种声明性语法,可以通过标注的方式减少重复代码,实现逻辑的分离和灵活性扩展

感谢支持

再次感谢您的支持,您的支持将鼓励我继续创作。文章通常首发公众号,可以关注我的公众号,获取最新优质文章;同时如果你有 珠宝首饰之类 的需求,也可以微信扫码光临小店,种类多多欢迎来选👏🏻
大卫talk
 
aphrodite-u

若您在阅读过程中发现一些错误:如语句不通、文字、逻辑等错误,可以在评论区指出,我会及时调整修改,感谢您的阅读。同时您觉得这篇文章对您有帮助的话,可以打赏作者一笔作为鼓励,金额不限,感谢支持🤝。
支付宝捐赠   微信支付捐赠

Released under the MIT License.