天天看點

小小裝飾器

前言

裝飾器可對指定的類/類方法/屬性/參數進行裝飾,擴充其功能。

它本質是一個函數,底層實作上依賴Object.defineProperty。

逐漸發展至今,裝飾器不再隻是簡化代碼的文法糖,還可作為某些特定場景的解決方案(比如鑒權)。

保持裝飾器的單一職責,通過靈活組合,可以讓代碼更為簡潔明了,讓開發更為高效。

下面以一個ts項目為例,介紹下常用類型裝飾器的基本使用和業務場景。

項目初始化

  1. 全局安裝ts

    npm install -g typescript

  2. 建立檔案夾并進入
  3. 生成package.json

    npm init -y

  4. 生成tsconfig.json

    tsc --init

  5. 項目中安裝ts和ts-node

    npm i typescript ts-node

  6. 項目中安裝nodemon

    npm i nodemon

  7. tsconfig中設定experimentalDecorators為true,添加裝飾器支援
  8. package.json中添加運作腳本 start:

    nodemon -e ts --exec ts-node src/app

  9. 啟動項目

    npm start

  • ts-node将ts編譯成js檔案并執行
  • nodemon檢測到目标檔案發生更改後自動重新開機
  • nodemon -e 表示添加支援的檔案擴充, --exec表示執行指定指令
  • 如果覺得隐式any 很不爽,可以在tsconfig中設定noImplicitAny為false

基本使用

類裝飾器

類裝飾器作用在指定類上,target拿到的就是類的構造函數。

拿到target後可以做很多事,比如增加額外的方法和屬性。

const logName: ClassDecorator = target => {
  // 反射api,形如target[prop]
  // 下文還會用到反射
  // npm上的 reflect-metadata也可以看看
  const name = Reflect.get(target, 'name');
  console.log(name);
};

@logName 
class User {}
           

類方法裝飾器

類方法裝飾器作用在類的方法上,有三個參數,分别是類的構造函數(靜态方法)或者原型對象(執行個體方法),屬性名和該屬性的描述對象。

const check: MethodDecorator = (target, key, descriptor: PropertyDescriptor) => {
 // 緩存舊函數,實際上就是裝飾器作用的目标對象
 // 這裡指的是say方法
 const fn = descriptor.value;
 // 重寫該方法,自定義一些邏輯
 // 裝飾要保留原有功能,是以最後要調用之前的舊方法
  descriptor.value = function () {
    if (target.constructor.name !== 'User') {
      console.error('method say must called by class User');
      return;
    }
    fn.call(this);
  };
 // 傳回屬性描述對象
  return descriptor;
};

class User {
  @check // 使用類方法裝飾器
  say() {
    console.log('hi~');
  }
}

class Cat {
  @check // 使用類方法裝飾器
  say() {
    console.log('hi~');
  }
}

new User().say();// hi~
new Cat().say();// method say must called by class User
           

上述使用的裝飾器都是不接收參數的。如果需要接收參數,就再包裝一層函數(利用了閉包)。

// auth是一個簡單的權限裝飾器,限定某方法隻有管理者可執行
const auth =
  (isAdmin = false) =>
    (target, key, descriptor: PropertyDescriptor) => {
      const fn = descriptor.value;
      descriptor.value = function () {
        if (!isAdmin) {
          console.error('no auth');
          return;
        }
        fn.call(this);
      };

      return descriptor;
    };

class User {
  @auth(true) // auth方法調用,傳回一個類方法裝飾器
  edit() {
    console.log('edit');
  }
}

// auth入參為真值,列印 edit
// 反之,列印 no auth
new User().edit();
           

多個裝飾器可以作用同一個目标對象.

class UserCtrl {

  @auth(['admin'])// 鑒權
  @get('/api/user/list') // 設定請求方法
  listUser(){
    // xxx
  }

}           

業務場景

一個比較經典的場景是借助反射和裝飾器實作server端路由的自動裝載。

下面以一個koa項目為例,介紹下這部分功能的具體實作。

koa環境搭建

  1. 安裝koa和koa-router

    npm i koa koa-router

  2. 安裝koa相關類型聲明

    npm i @types/koa @types/koa-router

  3. 根目錄src/app.js寫入如下代碼
import Koa from 'koa';
import Router from 'koa-router';

const app = new Koa();
const router = new Router();
router.get('/', ctx => {
  ctx.body = 'hello';
});
app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3000, () => {
  console.log('run server...');
});
           
  1. npm start

  2. 可以浏覽器打開 http://localhost:3000 端口測試一下

預期效果

上邊的代碼還沒有做有優化,業務邏輯都耦合在一起。

我們需要一個更為清晰,更可維護的代碼組織方式。

向下面這樣:

@controller('/main')
export default class MainCtrl {
  @get('/index')
  async index(ctx) {
    ctx.body = 'hello world';
  }
  @get('/home')
  async home(ctx) {
    ctx.body = 'hello home';
  }
}

// 我們希望上述代碼等價于如下寫法

router.get('/main/index',ctx=>{
   ctx.body = 'hello world';
})
router.get('/main/home',ctx=>{
   ctx.body = 'hello home';
})           

實作思路

我們的最終目标是拼出controller中包含的路由資訊并完成注冊,這其實是一個資料set和get的過程。

通過裝飾器在相應controller的原型對象上設定請求字首和路由資訊。

然後周遊所有controller并執行個體化,借助反射擷取到原型上存儲的資料,完成路由注冊。

裝飾器decorator.ts

export const controller =
  (prefix = ''): ClassDecorator =>
    (target: any) => {
      target.prototype.prefix = prefix;
    };

type Method = 'get' | 'post' | 'delete' | 'options' | 'put' | 'head';

export interface RouteDefinition {
  path: string;
  requestMethod: Method;
  methodName: string;
}

const creatorFactory =
  (requestMethod: Method) =>
    (path: string): MethodDecorator =>
      (target, name) => {
        if (!Reflect.has(target.constructor, 'routes')) {
          Reflect.defineProperty(target.constructor, 'routes', {
            value: [],
          });
        }
        const routes = Reflect.get(target.constructor, 'routes');
        routes.push({
          requestMethod,
          path,
          methodName: name,
        });
      };

export const get = creatorFactory('get');
// export const post = creatorFactory('post');
// export const del = creatorFactory('delete');
// export const put = creatorFactory('put');
// export const options = creatorFactory('options');
// export const head = creatorFactory('head');
           

注冊路由app.ts

import Koa from 'koa';
import Router from 'koa-router';
import MainCtrl from './main-ctrl';
const app = new Koa();
const router = new Router();
router.get('/', ctx => {
  ctx.body = 'hello';
});

[MainCtrl].forEach(controller => {
  const instance: any = new controller();
  const { prefix } = instance;
  const routes = Reflect.get(controller, 'routes');
  routes.forEach(route => {
    router[route.requestMethod](prefix + route.path, ctx => {
      instance[route.methodName](ctx);
    });
  });
});

app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3000, () => {
  console.log('run server...');
});
           

控制器main-ctrl.ts

import { controller, get } from './decorator';
@controller('/main')
export default class MainCtrl {
  @get('/index')
  async index(ctx) {
    ctx.body = 'hello world';
  }
  @get('/home')
  async home(ctx) {
    ctx.body = 'hello home';
  }
}
           

自動掃描

上述實作還存在一個弊端,如果控制器特别多,每次都需要手動導入,很麻煩。

一種更優雅的方式是批量掃描,使用glob掃描指定的控制器目錄(比如controllers)。

  1. 安裝glob

    npm i glob

  2. 根目錄建立load.ts,寫入如下代碼
import * as glob from 'glob';
import path from 'path';
export default (folder: string, router: any) => {
  // 掃描指定檔案夾下所有ts檔案
  glob.sync(path.join(folder, '**/*.ts')).forEach(item => {
    // 加載controller
    const controller = require(item).default;
    // 執行個體化
    const instance: any = new controller();
    const { prefix } = instance;
    const routes = Reflect.get(controller, 'routes');
    routes.forEach(route => {
      router[route.requestMethod](prefix + route.path, ctx => {
        instance[route.methodName](ctx);
      });
    });
  });
};           
  1. 修改app.ts
import Koa from 'koa';
import Router from 'koa-router';
import path from 'path';
import load from './load';
const app = new Koa();
const router = new Router();
router.get('/', ctx => {
  ctx.body = 'hello';
});
load(path.resolve(__dirname, './controllers'), router);
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
  console.log('run server...');
});           
  1. 将之前的main-ctrl.ts移動到建立的controllers目錄中
  2. 浏覽器通路對應路徑,測試

源碼位址

decorator

再會

情如風雪無常,

ts