데코레이터는 추가적인 구성과 추가적인 로직을 더 할 수 있게 합니다.
먼저 tsconfig.json 파일에서 이 부분을 수정해야 데코레이터를 문제없이 쓸 수 있습니다.
"experimentalDecorators": true
데코레이터 만들기
function Logger (constructor: Function) {
console.log('Logging...');
console.log(constructor);
}
// 여기
@Logger
class Person {
name = 'KIM';
constructor() {
console.log('Creating person object...');
}
}
const pers = new Person();
console.log(pers);
데코레이터 함수는 관행적으로 대문자로 시작하며 클래스 앞에 @함수를 써 적용합니다.
이렇게 작성하고 브라우저 콘솔창을 확인해 봅시다.
이렇게 데코레이터는 실체화되기 전 클래스가 정의만 돼도 실행됩니다.
자바스크립트에서 클래스 및 컨스트럭터의 함수 정의만 입력되면 데코레이터가 돌아갑니다.
데코레이터 팩토리
function Logger (logString: string) {
return function(constructor: Function) {
console.log(logString);
console.log(constructor);
}
}
@Logger('LOGGING - PERSON')
class Person {
name = 'KIM';
constructor() {
console.log('Creating person object...');
}
}
그냥 데코레이터와 다르게 반환 함수가 되었으므로 데코레이터를 적용할 때 함수를 실행해야 합니다.
여기서 우리는 데코레이터 함수를 실행하려는 게 아니라 데코레이터 함수와 같은 걸 반환해 줄 함수를 실행하는 겁니다.
팩토리 화하여 인자를 받아들일 수 있게 되고 데코레이터를 더 커스터마이즈 할 수 있습니다.
이렇게 데코레이터 팩토리를 사용하면 데코레이터 내부 설정에 보다 더 많은 영향력과 가능성을 펼칠 수 있습니다.
데코레이터 발전시키기
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Understanding TypeScript</title>
<script src="dist/app.js" defer></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
html에 다음과 같이 id가 app인 태그를 만들고
app.ts
function WithTemplate(template: string, hookId: string) {
return function(_: Function) {
const hookEl = document.getElementById(hookId);
if (hookEl) {
hookEl.innerHTML = template;
}
}
}
@WithTemplate('<h1>My Person Object<h1>', 'app')
class Person {
name = 'KIM';
constructor() {
console.log('Creating person object...');
}
}
이렇게 데코레이터를 만들고 적용하면
화면에 이렇게 렌더링 된 것을 볼 수 있습니다.
여기서 _는 존재는 알지만 쓰지 않겠다고 명시하는 것입니다.
function WithTemplate(template: string, hookId: string) {
return function(constructor: any) {
const hookEl = document.getElementById(hookId);
const p = new constructor();
if (hookEl) {
// hookEl.innerHTML = template;
hookEl.querySelector('h1')!.textContent = p.name;
}
}
}
@WithTemplate('<h1>My Person Object<h1>', 'app')
class Person {
name = 'KIM';
constructor() {
console.log('Creating person object...');
}
}
이렇게 코드를 바꿔 화면에 렌더링 되는 문자를 바꿀 수 도 있습니다.
여러 데코레이터 추가하기
function Logger (logString: string) {
console.log('LOGGER FACTORY');
return function(constructor: Function) {
console.log(logString);
console.log(constructor);
}
}
function WithTemplate(template: string, hookId: string) {
console.log('TEMPLATE FACTORY');
return function(constructor: any) {
console.log('Rendering template');
const hookEl = document.getElementById(hookId);
const p = new constructor();
if (hookEl) {
hookEl.innerHTML = template;
hookEl.querySelector('h1')!.textContent = p.name;
}
}
}
@Logger('LOGGING')
@WithTemplate('<h1>My Person Object<h1>', 'app')
class Person {
name = 'KIM';
constructor() {
// console.log('Creating person object...');
}
}
이렇게 코드를 작성하고 브라우저 콘솔창의 결과를 확인해 보면 다음과 같이 출력됩니다.
우리가 팩토리 함수를 명시한 순서 대로 실 데코레이터가 나타납니다.
하지만 실 데코레이터 함수의 실행은 밑에서부터 일어납니다.
속성 데코레이터
function Log(target: any, propertyName: string | Symbol) {
console.log('Property Decorator!');
console.log(target, propertyName);
}
class Product {
@Log
title: string;
private _price: number;
set price(val: number) {
if(val > 0) {
this._price = val;
} else {
throw new Error('Invalid price - should be positive!');
}
}
constructor(t: string, p: number) {
this.title = t;
this._price = p;
}
getPriceWIthTax(tax: number) {
return this._price * (1 + tax);
}
}
이렇게 코드를 작성하고 콘솔창을 보면 다음과 같이 출력됩니다.
target인 오브젝트의 프로토타입과 propertyName인 작업하는 프로퍼티 이름을 볼 수 있습니다.
이 역시 클래스가 정의될 때 더 자세히는 자바스크립트에 프로퍼티를 정의했을 때 실행되게 됩니다.
접근자 데코레이터
function Log2(target: any, name: string, descriptor: PropertyDescriptor) {
console.log('Accessor Decorator!');
console.log(target);
console.log(name);
console.log(descriptor);
}
class Product {
title: string;
private _price: number;
@Log2
set price(val: number) {
if(val > 0) {
this._price = val;
} else {
throw new Error('Invalid price - should be positive!');
}
}
constructor(t: string, p: number) {
this.title = t;
this._price = p;
}
getPriceWIthTax(tax: number) {
return this._price * (1 + tax);
}
}
이렇게 코드를 작성하고 콘솔창을 보면 다음과 같이 출력됩니다.
메서드 데코레이터
function Log3(target: any, name: string | Symbol, descriptor: PropertyDescriptor) {
console.log('Method Decorator!');
console.log(target);
console.log(name);
console.log(descriptor);
}
class Product {
title: string;
private _price: number;
set price(val: number) {
if(val > 0) {
this._price = val;
} else {
throw new Error('Invalid price - should be positive!');
}
}
constructor(t: string, p: number) {
this.title = t;
this._price = p;
}
@Log3
getPriceWIthTax(tax: number) {
return this._price * (1 + tax);
}
}
이렇게 코드를 작성하고 콘솔창을 보면 다음과 같이 출력됩니다.
매개변수 데코레이터
function Log4(target: any, name: string | Symbol, position: number) {
console.log('Parameter Decorator!');
console.log(target);
console.log(name);
console.log(position);
}
class Product {
title: string;
private _price: number;
set price(val: number) {
if(val > 0) {
this._price = val;
} else {
throw new Error('Invalid price - should be positive!');
}
}
constructor(t: string, p: number) {
this.title = t;
this._price = p;
}
getPriceWIthTax(@Log4 tax: number) {
return this._price * (1 + tax);
}
}
이렇게 코드를 작성하고 콘솔창을 보면 다음과 같이 출력됩니다.
프로퍼티, 메서드, 액세서, 매개 변수 등 어떤 데코레이터든 클래스를 정의되었을 때 실행됩니다.
클래스 데코레이터에서 클래스 반환
function WithTemplate(template: string, hookId: string) {
console.log('TEMPLATE FACTORY');
return function<T extends {new(...args: any[]): {name: string}}>(
originalConstructor: T
) {
// 컨스트럭터 함수를 만드는 syntactic sugar => class
return class extends originalConstructor {
constructor(..._: any[]) {
// 오리지널 함수와 클래스를 저장
super();
console.log('Rendering template');
const hookEl = document.getElementById(hookId);
if (hookEl) {
hookEl.innerHTML = template;
hookEl.querySelector('h1')!.textContent = this.name;
}
}
}
}
}
데코레이터 함수로 기존 클래스를 확장하는 새 클래스를 반환할 수 도 있습니다.
데코레이터 함수에서 반환되는 컨스트럭터 함수는 오리지널 클래스, 오리지널 컨스트럭터 함수를 대체합니다.
기타 데코레이터 반환 타입
뭔가를 반환할 수 있는 데코레이터는 클래스와 더불어 메서드에 추가한 데코레이터나 접근자에 추가한 데코레이터입니다.
즉 어떤 내용을 반환하고 타입스크립트가 그 값을 사용할 것입니다.
프로퍼티와 매개변수에 있는 데코레이터도 당연히 어떤 값을 반환하지만 타입스크립트는 이 값들을 무시할 것입니다.
따라서 반환값이 그곳에서는 지원되지 않거나 더 정확히 말하면 사용되지 않습니다.
Auotobind 데코레이터 만들기
html 파일에 버튼을 하나 만들고 시작합니다.
class Printer {
message = 'This works!';
showMessage() {
console.log(this.message);
}
}
const p = new Printer();
p.showMessage(); // 여기랑
const button = document.querySelector('button')!;
button.addEventListener('click', p.showMessage); // 여기
그 버튼을 클릭하면 메시지를 출력하게 코드를 이렇게 구상해 보았습니다.
하지만 이 코드는 메시지를 출력하지 못하고 undefined가 나오고 메시지를 출력하지 못합니다.
그 이유는 이벤트 리스너를 이용했을 때 함수를 지목하면 그 함수가 실행되어야 하는데 그 함수 안에 있는 this 키워드의 콘텍스트나 레퍼런스가 p.showMessage를 호출했을 때와 동일하지 않기 때문입니다.
즉 이 시나리오에서 이벤트 리스너를 사용하면 this는 이벤트의 대상을 참조할 것입니다.
왜냐하면 addEventListener가 결국 실행되어야 하는 함수 안에 있는 this를 이벤트의 대상과 바인딩하기 때문입니다.
class Printer {
message = 'This works!';
showMessage() {
console.log(this.message);
}
}
const p = new Printer();
const button = document.querySelector('button')!;
button.addEventListener('click', p.showMessage.bind(p));
차선책으로 showMessage의 this를 p에 바인딩하여, 실행했을 때 this가 이벤트 리스너가 참조하려는 것을 참조하지 않고 다시 p를 참조하게 하는 것입니다.
function Autobind(_: any, _2: string, descriptor: PropertyDescriptor) {
// 기존 메서드에 접근
const originalMethod = descriptor.value;
const adjDescriptor: PropertyDescriptor = {
configurable: true,
enumerable: false,
// 게터
get() {
// 여기서 this는 getter 메서드를 트리거하는 것은 뭐든지 참조함
const boundFn = originalMethod.bind(this);
return boundFn;
}
}
return adjDescriptor;
}
class Printer {
message = 'This works!';
@Autobind
showMessage() {
console.log(this.message);
}
}
const p = new Printer();
const button = document.querySelector('button')!;
button.addEventListener('click', p.showMessage);
이렇게 데코레이터로 자동으로 바인드 되게 구성할 수 있습니다.
자바스크립트 바인드 설명은 아래!
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
Function.prototype.bind() - JavaScript | MDN
bind() 메소드가 호출되면 새로운 함수를 생성합니다. 받게되는 첫 인자의 value로는 this 키워드를 설정하고, 이어지는 인자들은 바인드된 함수의 인수에 제공됩니다.
developer.mozilla.org
데코레이터의 게터 메서드의 this는 우리가 정의한 객체를 항상 참조할 것입니다.
이 this는 addEventListener로 재정의되지 않습니다.
왜냐하면 getter는 실행되고 있는 함수와 그것이 속한 객체, 그리고 이벤트 리스너 사이의 추가적인 계층 같은 것이기 때문입니다.
따라서 이 thissms 원래 메서드에 안전하게 바인딩하고 원래 메서드 안에 있는 this는 모두 정확히 동일한 객체를 참조하게 할 수 있습니다.
유효성 검사용 데코레이터
타이틀과 금액을 입력받아 그걸 출력하는 코드를 만들고 시작합니다.
index.html
...
<form>
<input type="text" placeholder="Course title" id="title">
<input type="text" placeholder="Course price" id="price">
<button type="submit">Save</button>
</form>
...
app.ts
class Course {
title: string;
price: number;
constructor(t: string, p: number) {
this.title = t;
this.price = p;
}
}
const courseForm = document.querySelector("form")!;
courseForm.addEventListener("submit", (event) => {
event.preventDefault();
const titleEl = document.getElementById("title") as HTMLInputElement;
const priceEl = document.getElementById("price") as HTMLInputElement;
const title = titleEl.value;
const price = +priceEl.value;
const createdCourse = new Course(title, price);
console.log(createdCourse);
});
이는 러프하게 구성되었고 어떠한 유효성 검사도 없습니다.
이벤트 리스너 함수에 로직을 추가할 수도 있지만 데코레이터를 이용할 수 도 있습니다.
interface ValidatorConfig {
[property: string]: {
[validatableProp: string]: string[]; // ['required', 'positive']
};
}
const registeredValidators: ValidatorConfig = {};
function Required(target: any, propName: string) {
registeredValidators[target.constructor.name] = {
...registeredValidators[target.constructor.name],
[propName]: ['required']
};
}
function PositiveNumber(target: any, propName: string) {
registeredValidators[target.constructor.name] = {
...registeredValidators[target.constructor.name],
[propName]: ['positive']
};
}
function validate(obj: any) {
const objValidatorConfig = registeredValidators[obj.constructor.name];
if (!objValidatorConfig) {
return true;
}
let isValid = true;
for (const prop in objValidatorConfig) {
for (const validator of objValidatorConfig[prop]) {
switch (validator) {
case 'required':
isValid = isValid && !!obj[prop];
break;
case 'positive':
isValid = isValid && obj[prop] > 0;
break;
}
}
}
return isValid;
}
class Course {
@Required
title: string;
@PositiveNumber
price: number;
constructor(t: string, p: number) {
this.title = t;
this.price = p;
}
}
const courseForm = document.querySelector('form')!;
courseForm.addEventListener('submit', event => {
event.preventDefault();
const titleEl = document.getElementById('title') as HTMLInputElement;
const priceEl = document.getElementById('price') as HTMLInputElement;
const title = titleEl.value;
const price = +priceEl.value;
const createdCourse = new Course(title, price);
if (!validate(createdCourse)) {
alert('Invalid input, please try again!');
return;
}
console.log(createdCourse);
});
바로 이렇게 말이죠... 처음이라 이해도 힘들고 멘탈이 나갔습니다.
매 번 저렇게 구현해야 하나 말이 되나 싶을 때쯤 직접 만들지 말고 보다 정교하게 구현한 패키지를 찾아 슬쩍 숟가락을 얹읍시다.
https://github.com/typestack/class-validator
GitHub - typestack/class-validator: Decorator-based property validation for classes.
Decorator-based property validation for classes. Contribute to typestack/class-validator development by creating an account on GitHub.
github.com