Typescript註解使用案例

ihengshuai發表於2024-12-04

自上次分享了「TS - 裝飾器與註解」後,有幾個小夥伴私信問我在在真實的工作中是如何使用這種寫法的,在瞭解到很多小夥伴並沒有接觸到相關使用後,專門整理這份使用案例來分享給大家,希望能幫助到有需要的開發者

需要強調的一點是在Typescript中不是註解而是稱為裝飾器,由於在Java中註解說習慣了,這裡我就順便這樣寫了,二者還是有很大區別的,可以檢視我的往期文章瞭解更多

文章首發公眾號,掃碼檢視等多優質內容

註解意義

註解本身就是一種設計模式的提現,它的存在當然有很必要的意義的

試想一下,你正在開發一個應用,面對大量重複的配置和硬編碼邏輯,難免感到心煩意亂。這時,註解就像一位貼心的助手,它幫你標記出關鍵點,自動完成那些繁瑣的操作。你只需專注於業務邏輯,其餘的交給它來處理——既省時又省力,讓開發變得更加優雅!

註解是一種以後設資料形式描述程式碼行為的設計模式,透過在類、方法、屬性或引數上標記特定資訊,實現邏輯的擴充套件或控制。它不僅能提升程式碼的語義化表達,讓邏輯更清晰易讀,還可以解耦業務邏輯與配置邏輯,避免繁瑣的手動操作。註解的廣泛應用,使複雜功能的實現更加靈活高效,例如依賴注入、許可權校驗、日誌記錄等場景,都能透過註解輕鬆完成

總之可以總結為以下幾點:

  1. 提高程式碼清晰度:透過語義化的註解,讓程式碼更加直觀易讀
  2. 降低耦合度:解耦核心邏輯和配置邏輯,使程式碼更靈活可擴充套件
  3. 增強程式碼複用性:將通用功能封裝為註解,減少重複程式碼
  4. 支援動態功能擴充套件:透過註解結合反射和 AOP 實現動態程式設計
  5. 簡化框架使用:降低開發者使用框架的學習曲線,提高開發效率
  6. 便於維護和擴充套件:透過調整註解後設資料,快速適應需求變化

使用約束

在 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);
  }
}

定義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;
  }
}

使用NestJS不光要學會如何使用,知道其背後的執行邏輯意義更大;上面程式碼分別定義了一個控制器和服務層,控制器用來定義路由功能,服務層用來查詢資料,透過將他們分開寫可以提高健壯性,實現低耦合,當然這也是後端傳統的MC寫法方式

我們知道以上程式碼就完成了路由註冊,那麼你知道NestJS是怎麼知道的嗎❓

其實,它很簡單,如果你看了我上一篇的文章,那麼應該就會明白,肯定有一個存放路由配置的容器,然後NestJS從中拿到對應的配置就可以,大體邏輯就是這樣。那麼實現邏輯就在 對應的註解邏輯中,如:ControllerGet等等,下面我們用虛擬碼簡單實現下:

// 存放模組名
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的核心定義好了,但是儲存的路由還是不能用的,需要簡單的進行處理後才可以交給應用進行處理

// 這是維護的應用路由表
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;
}

透過上面的程式碼我們就可以真正的做到了路由的自動註冊和獲取,接下來我們來模擬一下他的效果:

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模組的請求:

@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的作用

@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加上後當請求的內容一模一樣時前面的請求就會被終止掉

思考:終止後的請求伺服器還會收到嗎?

錯誤碼

每個介面的請求除了真正意義上的錯誤外,通常為了方便提示使用者都會有相應的錯誤碼,客戶端可以根據錯誤碼來提示不同的文案

@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];
  }
}

上面就是通用的處理錯誤碼的邏輯,接著繼續完善下錯誤碼的提示:

/** 上傳使用者頭像狀態碼,包含了多語言 */
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思想,透過這種方式可以快速方便的將資料轉換成目標物件,並且可以做一些格式處理,那麼在前端如如何使用呢?

相信大家在工作中都是使用以下方式進行資料的處理的

// 定義介面資料模型
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
class User {
  name: string;
  age: number;
  date: string;
  cars: Car[];
}

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

// 建立表單物件直接new即可
const user = new User();

但直接透過new方式不能準確獲得型別提示,並且預設屬性值都是undefined,如果有要特殊處理的初始值將會變得麻煩,因此我們可以透過代理工廠來建立目標物件

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

const user = createEntity(User, { /* 這裡就有了型別提示 */ });

透過以上的方式就有了基本的實體類,有了類就可以透過註解施加一點魔法了,透過註解我們慢慢來補充一些通用的功能:

@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了,這裡簡單的演示下,讀者可以自行腦補

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

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

其他

以上我們介紹了很多前端專案的註解使用場景,除了這些還有很多地方可以使用,如在設計一些框架或庫時,註解模式也是一種很巧妙的設計實現方式;其發揮的作用還是很廣泛的,取決於每一位開發者的設計思想

總結

本篇文章透過介紹不同的場景下使用ts的註解功能,大大提升了編碼效率。裝飾器是 TypeScript 提供的一種用於擴充套件類、方法、屬性或引數行為的語法,主要透過超程式設計的方式實現功能增強。它是一種宣告性語法,可以透過標註的方式減少重複程式碼,實現邏輯的分離和靈活性擴充套件

相關文章