前言
裝飾器可對指定的類/類方法/屬性/參數進行裝飾,擴充其功能。
它本質是一個函數,底層實作上依賴Object.defineProperty。
逐漸發展至今,裝飾器不再隻是簡化代碼的文法糖,還可作為某些特定場景的解決方案(比如鑒權)。
保持裝飾器的單一職責,通過靈活組合,可以讓代碼更為簡潔明了,讓開發更為高效。
下面以一個ts項目為例,介紹下常用類型裝飾器的基本使用和業務場景。
項目初始化
- 全局安裝ts
npm install -g typescript
- 建立檔案夾并進入
- 生成package.json
npm init -y
- 生成tsconfig.json
tsc --init
- 項目中安裝ts和ts-node
npm i typescript ts-node
- 項目中安裝nodemon
npm i nodemon
- tsconfig中設定experimentalDecorators為true,添加裝飾器支援
- package.json中添加運作腳本 start:
nodemon -e ts --exec ts-node src/app
- 啟動項目
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環境搭建
- 安裝koa和koa-router
npm i koa koa-router
- 安裝koa相關類型聲明
npm i @types/koa @types/koa-router
- 根目錄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...');
});
-
npm start
- 可以浏覽器打開 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)。
- 安裝glob
npm i glob
- 根目錄建立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);
});
});
});
};
- 修改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...');
});
- 将之前的main-ctrl.ts移動到建立的controllers目錄中
- 浏覽器通路對應路徑,測試
源碼位址
decorator
再會
情如風雪無常,