Typescript注解使用案例
自上次分享了「TS - 装饰器与注解」后,有几个小伙伴私信问我在在真实的工作中是如何使用这种写法的,在了解到很多小伙伴并没有接触到相关使用后,专门整理这份使用案例来分享给大家,希望能帮助到有需要的开发者
需要强调的一点是在Typescript中不是注解而是称为装饰器,由于在Java中注解说习惯了,这里我就顺便这样写了,二者还是有很大区别的,可以查看我的往期文章了解更多
注解意义
注解本身就是一种设计模式的提现,它的存在当然有很必要的意义的
试想一下,你正在开发一个应用,面对大量重复的配置和硬编码逻辑,难免感到心烦意乱。这时,注解就像一位贴心的助手,它帮你标记出关键点,自动完成那些繁琐的操作。你只需专注于业务逻辑,其余的交给它来处理——既省时又省力,让开发变得更加优雅!
注解是一种以元数据形式描述代码行为的设计模式,通过在类、方法、属性或参数上标记特定信息,实现逻辑的扩展或控制。它不仅能提升代码的语义化表达,让逻辑更清晰易读,还可以解耦业务逻辑与配置逻辑,避免繁琐的手动操作。注解的广泛应用,使复杂功能的实现更加灵活高效,例如依赖注入、权限校验、日志记录等场景,都能通过注解轻松完成
总之可以总结为以下几点:
- 提高代码清晰度:通过语义化的注解,让代码更加直观易读
- 降低耦合度:解耦核心逻辑和配置逻辑,使代码更灵活可扩展
- 增强代码复用性:将通用功能封装为注解,减少重复代码
- 支持动态功能扩展:通过注解结合反射和 AOP 实现动态编程
- 简化框架使用:降低开发者使用框架的学习曲线,提高开发效率
- 便于维护和扩展:通过调整注解元数据,快速适应需求变化
使用约束
在 TypeScript 中使用装饰器有一些限制和约束,这是由于 TypeScript 的设计规范以及装饰器本身的特性决定的。以下是使用装饰器时需要注意的主要约束:
- 装饰器的目标有限,不支持在函数外独立使用装饰器,只能在类、方法、属性、参数中使用
- 由于是在Runtime阶段的动态修改,因此不支持类型的静态检查
- 装饰器只能为属性设置元数据,而不能直接访问或修改其值
- 结合Reflect元数据可能会对应能有一定的影响
接下来我们就进入正文,通过一些案例来了解如何使用它
NestJS
对于大多数人而言注解使用通常都会在NestJS中开始的,它被称为前端版本的Spring框架,正因为NestJS框架优秀的设计模式,它也是严格意义上的NodeJS框架;这里我们以它作为入手参考
在NestJS中会看到很多注解使用形式,以下代码为简化代码:
定义控制器:
@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);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
定义service:
@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;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
使用NestJS不光要学会如何使用,知道其背后的执行逻辑意义更大;上面代码分别定义了一个控制器和服务层,控制器用来定义路由功能,服务层用来查询数据,通过将他们分开写可以提高健壮性,实现低耦合,当然这也是后端传统的MC写法方式
我们知道以上代码就完成了路由注册,那么你知道NestJS是怎么知道的吗❓
其实,它很简单,如果你看了我上一篇的文章,那么应该就会明白,肯定有一个存放路由配置的容器,然后NestJS从中拿到对应的配置就可以,大体逻辑就是这样。那么实现逻辑就在 对应的注解逻辑中,如:Controller
、Get
等等,下面我们用伪代码简单实现下:
// 存放模块名
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';
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
上面已经把Controller
和Get
的核心定义好了,但是存储的路由还是不能用的,需要简单的进行处理后才可以交给应用进行处理
// 这是维护的应用路由表
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
通过上面的代码我们就可以真正的做到了路由的自动注册和获取,接下来我们来模拟一下他的效果:
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');
2
3
4
5
6
7
8
9
10
11
12
结果符合预期‼️ 以上只是简单的模拟路由的处理思路,当然有很多细节还是要处理的。读者可以将以上代码全部拷贝到一个文件中,运行就可以看到结果
总之可以发现以上使用注解让程序设计更加简单,通过上面案例小伙伴们应该也了解了它的优势,接下来我们就来看看其他项目中如何使用
HTTP请求
在项目中通常都会将同模块的内容放在一起,网络请求也是可以的。我们可以模仿Spring的Controller,同类的Controller放在一个class中,里面放当前模块的RESTful,这也是最佳的实践。有了Controller类后,注解就可以无限发挥了
RESTful
以下为一个简单的user
模块的请求:
@Controller("/user")
export class UserController extends BaseRequest {
@Get("/:id")
async getUserInfo(id: number, config?: IHttpRequestConfig) {
return this.request({ urlPath: { id }, captureError: false, ...config });
}
}
2
3
4
5
6
7
在上文已经对NestJS的路由实现已经做了简单的实现了,这里详细实现就不做演示了,思路都是一致的,以上request
实例为axios
CancelToken
CancelToken主要是用来取消掉重复的请求的,减少不必要的网络请求。它是axios赋予的功能,其本质原理还是AbortController的作用
@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;
}
};
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
以上CancelToken
类主要逻辑就是来存放当前请求的唯一标识,这里不展开了;当上面的Token加上后当请求的内容一模一样时前面的请求就会被终止掉
思考:终止后的请求服务器还会收到吗?
错误码
每个接口的请求除了真正意义上的错误外,通常为了方便提示用户都会有相应的错误码,客户端可以根据错误码来提示不同的文案
@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];
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
上面就是通用的处理错误码的逻辑,接着继续完善下错误码的提示:
/** 上传用户头像状态码,包含了多语言 */
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);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
实体类
了解后端框架的同学应该都知道ORM思想,通过这种方式可以快速方便的将数据转换成目标对象,并且可以做一些格式处理,那么在前端如如何使用呢?
相信大家在工作中都是使用以下方式进行数据的处理的
// 定义接口数据模型
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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
以上便是一个最基础的页面表单的提交过程,然而实际情况会面临很多复杂的情况,如格式转换、辅助属性等等,如果字段变多后表单的处理将会变得非常臃肿,而且使用interface
方式后还得重新写一遍所有的字段属性,效率上大打折扣
接下来我们通过以下几个步骤来简化使用
- 使用
class
代替interface
class User {
name: string;
age: number;
date: string;
cars: Car[];
}
class Car {
name: string;
color: string;
}
// 创建表单对象直接new即可
const user = new User();
2
3
4
5
6
7
8
9
10
11
12
13
14
但直接通过new
方式不能准确获得类型提示,并且默认属性值都是undefined
,如果有要特殊处理的初始值将会变得麻烦,因此我们可以通过代理工厂来创建目标对象
export function createEntity<T extends ICtor>(Ctor: T, initValue?: Partial<InstanceType<T>>): InstanceType<T> {
return new Ctor(initValue);
}
const user = createEntity(User, { /* 这里就有了类型提示 */ });
2
3
4
5
通过以上的方式就有了基本的实体类,有了类就可以通过注解施加一点魔法了,通过注解我们慢慢来补充一些通用的功能:
@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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
上面简单的补充下了一些注解,对于其内部的逻辑其实也很简单,只需要创建相应的容器存放对应的逻辑即可,这里不做具体介绍;希望读者可以自己仔细研究下如何实现,这里有个IOC在线演示
日志
日志就比较常见了,通过注解也可以很方便的做一些log了,这里简单的演示下,读者可以自行脑补
function Logger(...args) {
return function (target: any, property: string) {
console.log(target, property);
};
}
class UserConstroller {
@Logger()
getUserInfo() {}
}
2
3
4
5
6
7
8
9
10
其他
以上我们介绍了很多前端项目的注解使用场景,除了这些还有很多地方可以使用,如在设计一些框架或库时,注解模式也是一种很巧妙的设计实现方式;其发挥的作用还是很广泛的,取决于每一位开发者的设计思想
总结
本篇文章通过介绍不同的场景下使用ts的注解功能,大大提升了编码效率。装饰器是 TypeScript 提供的一种用于扩展类、方法、属性或参数行为的语法,主要通过元编程的方式实现功能增强。它是一种声明性语法,可以通过标注的方式减少重复代码,实现逻辑的分离和灵活性扩展