Bài 14.3: Decorators trong TypeScript

Bài 14.3: Decorators trong TypeScript
Chào mừng bạn đến với một trong những tính năng mạnh mẽ và đôi khi bí ẩn nhất của TypeScript: Decorators! Nếu bạn đã từng làm việc với các framework hiện đại như Angular hay NestJS, chắc chắn bạn đã thấy ký hiệu @
xuất hiện ở khắp mọi nơi, đứng trước các class
, method
, property
, hay thậm chí là parameter
. Đó chính là Decorators.
Vậy chính xác thì Decorators là gì và tại sao chúng lại quan trọng đến vậy? Hãy cùng vén màn bí ẩn này!
Decorators về cơ bản là một loại khai báo (declaration
) có thể được đính kèm vào một khai báo class, method, accessor, property, hoặc parameter. Chúng là các hàm được gọi tại thời điểm định nghĩa (compile-time hoặc load-time, tùy cách bạn hình dung) với thông tin về thứ đang được trang trí. Mục đích chính của chúng là thêm metadata (dữ liệu mô tả về dữ liệu khác) hoặc thay đổi hành vi của khai báo mà chúng trang trí mà không cần sửa đổi trực tiếp code bên trong khai báo đó.
Hãy coi Decorators như những "nhãn dán" hoặc "chú thích" đặc biệt mà bạn dán lên các phần của code để cho hệ thống (hoặc các framework) biết thêm thông tin hoặc để tự động áp dụng logic nào đó.
Mặc dù Decorators trong TypeScript vẫn đang ở giai đoạn thử nghiệm (experimental), chúng đã được áp dụng rộng rãi và trở thành một phần không thể thiếu trong nhiều framework TypeScript phổ biến.
Cú pháp cơ bản của Decorators
Cú pháp của Decorators rất đơn giản: đó là một biểu thức (expression
) đứng trước khai báo mà nó trang trí, bắt đầu bằng ký hiệu @
. Biểu thức này phải đánh giá thành một hàm sẽ được gọi lúc runtime.
@someDecorator
class MyClass {
@anotherDecorator
myMethod() {
// ...
}
}
@someDecorator
và @anotherDecorator
ở đây là các Decorator. Khi code này được xử lý, TypeScript (hoặc môi trường runtime khi sử dụng reflect-metadata) sẽ gọi hàm tương ứng với các decorator này và truyền vào thông tin về MyClass
hoặc myMethod
.
Decorator Factories: Khi bạn cần truyền tham số
Thông thường, bạn muốn Decorator của mình có thể nhận các tham số cấu hình. Để làm điều này, bạn cần tạo ra một Decorator Factory. Đây là một hàm trả về hàm Decorator thực tế.
// Đây là Decorator Factory
function logMessage(message: string) {
// Đây là Decorator thực tế
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log(`LOG: ${message} - Decorating method: ${String(propertyKey)}`);
// Bạn có thể làm gì đó với descriptor ở đây
};
}
class Service {
@logMessage("Starting operation") // Sử dụng Decorator Factory với tham số
doOperation() {
console.log("Operation is running...");
}
}
const service = new Service();
service.doOperation();
// Output: LOG: Starting operation - Decorating method: doOperation
// Operation is running...
Trong ví dụ này, logMessage("Starting operation")
không phải là Decorator, mà nó là lời gọi đến Decorator Factory. Kết quả trả về của logMessage("Starting operation")
mới là hàm Decorator thực tế được áp dụng cho doOperation
. Điều này cho phép chúng ta truyền chuỗi "Starting operation"
làm tham số cấu hình cho Decorator.
Các loại Decorators trong TypeScript
TypeScript hỗ trợ 5 loại Decorators khác nhau, áp dụng cho 5 loại khai báo khác nhau:
- Class Decorators
- Method Decorators
- Accessor Decorators
- Property Decorators
- Parameter Decorators
Mỗi loại Decorator nhận các tham số khác nhau khi được gọi và có khả năng thay đổi khác nhau đối với khai báo mà nó trang trí.
1. Class Decorators
Áp dụng cho toàn bộ class
. Hàm Decorator nhận một tham số duy nhất:
constructor: Function
: Constructor của class được trang trí.
Class Decorators có thể được sử dụng để quan sát, sửa đổi, hoặc thậm chí thay thế định nghĩa class.
// Decorator: In thông tin về class
function reportClass(constructor: Function) {
console.log(`Decorating class: ${constructor.name}`);
// Bạn có thể thêm các thuộc tính tĩnh, phương thức tĩnh vào constructor ở đây
// constructor.prototype.extraMethod = () => console.log("Extra!");
}
// Decorator Factory: Thêm một thuộc tính tĩnh vào class
function addTimestamp(key: string) {
return function<T extends {new(...args: any[]): {}}>(constructor: T) {
// Trả về một class mới kế thừa từ class gốc
return class extends constructor {
// Thêm thuộc tính tĩnh
static [key] = new Date().toISOString();
}
}
}
@reportClass // Áp dụng Decorator
@addTimestamp("creationDate") // Áp dụng Decorator Factory
class UserProfile {
name: string;
constructor(name: string) {
this.name = name;
}
}
console.log((UserProfile as any).creationDate); // Truy cập thuộc tính tĩnh được thêm vào
// Giải thích:
// @reportClass chỉ đơn giản là log tên class khi nó được định nghĩa.
// @addTimestamp là một factory trả về một decorator. Decorator này nhận constructor của UserProfile,
// sau đó trả về một *class mới* kế thừa từ UserProfile và thêm thuộc tính tĩnh `creationDate`.
// Về cơ bản, @addTimestamp đã *thay thế* class UserProfile gốc bằng class mới này.
2. Method Decorators
Áp dụng cho các phương thức của class
. Hàm Decorator nhận ba tham số:
target: Object
: Đối với phương thức instance, đó là prototype của class. Đối với phương thức static, đó là constructor của class.propertyKey: string | symbol
: Tên của phương thức.descriptor: PropertyDescriptor
: Descriptor của phương thức (chứavalue
,writable
,enumerable
,configurable
). Bạn có thể sửa đổidescriptor
để thay đổi hành vi của phương thức.
Method Decorators rất mạnh mẽ, cho phép bạn thay đổi cách phương thức hoạt động (ví dụ: thêm logic log, xử lý lỗi, memoization, thay đổi giá trị trả về...).
// Decorator: Làm cho phương thức chỉ đọc (không thể gán lại)
function readOnly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.writable = false; // Ngăn chặn việc gán lại hàm khác vào tên phương thức này
return descriptor; // Trả về descriptor đã chỉnh sửa
}
// Decorator: Đo thời gian thực thi của phương thức
function timing(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value; // Lưu lại hàm gốc
descriptor.value = function(...args: any[]) {
const start = performance.now();
const result = originalMethod.apply(this, args); // Gọi hàm gốc với đúng context (this) và đối số
const end = performance.now();
console.log(`Method "${String(propertyKey)}" executed in ${end - start}ms`);
return result; // Trả về kết quả của hàm gốc
};
return descriptor; // Trả về descriptor mới
}
class Calculator {
@readOnly
add(a: number, b: number): number {
return a + b;
}
@timing
multiply(a: number, b: number): number {
// Giả lập công việc tốn thời gian
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += a * b;
}
return a * b;
}
}
const calc = new Calculator();
console.log(calc.add(2, 3)); // Output: 5
// Thử gán lại add (sẽ báo lỗi nếu ở strict mode hoặc không có decorator)
// calc.add = () => 10; // Lỗi hoặc không có tác dụng do @readOnly
calc.multiply(4, 5); // Output: Method "multiply" executed in XXXms, rồi 20
Trong ví dụ timing
, chúng ta đã thay thế hàm multiply
gốc bằng một hàm mới. Hàm mới này bao bọc (wrap) hàm gốc, thực hiện logic đo thời gian trước và sau khi gọi hàm gốc. Đây là một mô hình rất phổ biến khi sử dụng Decorators (Aspect-Oriented Programming - Lập trình hướng khía cạnh).
3. Accessor Decorators
Áp dụng cho các get
hoặc set
accessors. Hàm Decorator nhận ba tham số, giống hệt Method Decorators:
target: Object
propertyKey: string | symbol
: Tên của accessor.descriptor: PropertyDescriptor
: Descriptor của accessor (chứaget
,set
,enumerable
,configurable
).
Bạn có thể sửa đổi descriptor
để thay đổi logic của getter/setter. Lưu ý rằng bạn không thể áp dụng Decorator đồng thời cho getter và setter của cùng một property; bạn phải áp dụng nó cho từng cái riêng biệt nếu cần.
// Decorator: In giá trị mới khi setter được gọi
function logSet(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalSetter = descriptor.set; // Lưu setter gốc
descriptor.set = function(value: any) {
console.log(`Setting ${String(propertyKey)} to: ${value}`);
if (originalSetter) { // Đảm bảo gọi setter gốc nếu có
originalSetter.call(this, value);
}
};
return descriptor;
}
class Point {
private _x: number = 0;
private _y: number = 0;
get x(): number { return this._x; }
@logSet // Áp dụng cho setter
set x(value: number) { this._x = value; }
get y(): number { return this._y; }
set y(value: number) { this._y = value; }
}
const p = new Point();
p.x = 10; // Output: Setting x to: 10
p.y = 20; // Không có output log vì setter y không được trang trí
Ví dụ này cho thấy chúng ta có thể "chen" logic vào setter bằng cách bao bọc nó tương tự như method decorator.
4. Property Decorators
Áp dụng cho các thuộc tính của class
. Hàm Decorator nhận hai tham số:
target: Object
: Đối với thuộc tính instance, đó là prototype của class. Đối với thuộc tính static, đó là constructor của class.propertyKey: string | symbol
: Tên của thuộc tính.
Điểm khác biệt quan trọng: Property Decorators không nhận tham số descriptor
. Điều này có nghĩa là Property Decorators không thể trực tiếp thay đổi giá trị khởi tạo của thuộc tính hoặc cấu hình writable
, enumerable
, configurable
tại thời điểm định nghĩa.
Property Decorators chủ yếu được sử dụng để thêm metadata về thuộc tính. Các framework sau đó có thể đọc metadata này lúc runtime để thực hiện các tác vụ như validation, serialization, dependency injection, v.v.
// Decorator Factory: Thêm metadata về kiểu dữ liệu
function Type(type: Function) {
return function(target: any, propertyKey: string | symbol) {
// Lưu metadata về kiểu dữ liệu của property
// Cần thư viện reflect-metadata để làm việc này hiệu quả
// import "reflect-metadata";
Reflect.defineMetadata("design:type", type, target, propertyKey);
console.log(`Metadata added for property "${String(propertyKey)}": Type is ${type.name}`);
};
}
// Decorator Factory: Đánh dấu thuộc tính là bắt buộc
function Required(target: any, propertyKey: string | symbol) {
console.log(`Metadata added for property "${String(propertyKey)}": Required`);
// Lưu metadata "required"
if (!target.__requiredProperties) {
target.__requiredProperties = [];
}
target.__requiredProperties.push(propertyKey);
}
class Product {
@Required // Đánh dấu thuộc tính 'id' là bắt buộc
@Type(Number) // Thêm metadata kiểu dữ liệu
id: number;
@Required
@Type(String)
name: string;
@Type(Number) // Không bắt buộc
price: number;
constructor(id: number, name: string, price: number) {
this.id = id;
this.name = name;
this.price = price;
}
}
// Giả lập việc đọc metadata (framework sẽ làm điều này)
function validate(instance: any) {
const requiredProps = instance.constructor.prototype.__requiredProperties || [];
console.log(`Validating instance of ${instance.constructor.name}. Required properties:`, requiredProps);
for (const propName of requiredProps) {
if (instance[propName] === undefined || instance[propName] === null) {
console.error(`Error: Property "${String(propName)}" is required.`);
}
}
// Đọc metadata kiểu dữ liệu (cần reflect-metadata)
// const type = Reflect.getMetadata("design:type", instance, "id");
// console.log(`Design type of 'id' is`, type); // Output: Design type of 'id' is function Number() { [native code] }
}
const validProduct = new Product(1, "Laptop", 1200);
validate(validProduct); // Validating instance..., không có lỗi
const invalidProduct = new Product(null as any, "Mouse", 25);
validate(invalidProduct); // Validating instance..., Error: Property "id" is required., Error: Property "name" is required.
Ví dụ này minh họa cách Property Decorators được dùng để "đính kèm" thông tin vào thuộc tính. Logic thực thi (như validation) sẽ được xử lý ở nơi khác dựa trên thông tin này.
5. Parameter Decorators
Áp dụng cho các tham số của phương thức hoặc accessor. Hàm Decorator nhận ba tham số:
target: Object
: Prototype của class (cho phương thức instance/accessor instance) hoặc constructor của class (cho phương thức static/accessor static).propertyKey: string | symbol
: Tên của phương thức/accessor chứa tham số.parameterIndex: number
: Index của tham số trong danh sách đối số của hàm.
Parameter Decorators chỉ được dùng để thêm metadata về tham số đó. Bạn không thể dùng Parameter Decorator để thay đổi tên hoặc kiểu dữ liệu của tham số, cũng không thể thay đổi hành vi của phương thức/accessor chứa nó (điều này thuộc về Method/Accessor Decorator).
// Decorator: Đánh dấu tham số này là một service cần được inject
function Inject(token: any) {
return function(target: any, propertyKey: string | symbol, parameterIndex: number) {
console.log(`Marking parameter ${parameterIndex} of method "${String(propertyKey)}" as injectable with token`, token);
// Lưu metadata: Tham số thứ parameterIndex của phương thức propertyKey cần dependency với token này
if (!target.__injectableParams) {
target.__injectableParams = {};
}
if (!target.__injectableParams[propertyKey]) {
target.__injectableParams[propertyKey] = [];
}
target.__injectableParams[propertyKey][parameterIndex] = token;
};
}
// Giả lập một vài service
class LoggerService { log(msg: string) { console.log(`[Logger]: ${msg}`); } }
class DatabaseService { save(data: any) { console.log(`[DB]: Saving`, data); } }
// Giả lập một DI container đơn giản
const services = {
LoggerService: new LoggerService(),
DatabaseService: new DatabaseService()
};
class UserService {
// Constructor được trang trí với parameter decorators (ví dụ trong framework như Angular)
// @Inject(LoggerService) private logger: LoggerService
// constructor(logger: LoggerService) { this.logger = logger; }
// Hoặc trong phương thức (ví dụ trong framework khác)
saveUser(userData: any, @Inject(DatabaseService) dbService: DatabaseService, @Inject(LoggerService) loggerService: LoggerService) {
loggerService.log("Attempting to save user...");
dbService.save(userData);
loggerService.log("User saved.");
}
}
// Framework/Container sẽ đọc metadata và truyền đúng instance vào
// Giả lập gọi phương thức với DI
const userService = new UserService();
const userToSave = { name: "Alice", id: 123 };
// Trong thực tế, framework sẽ tự động tìm các service và truyền vào
// userService.saveUser(userToSave, services.DatabaseService, services.LoggerService);
// Để chạy ví dụ này đơn giản hơn, ta gọi thủ công
userService.saveUser(userToSave, services.DatabaseService, services.LoggerService);
// Giải thích:
// Decorators @Inject đánh dấu tham số `dbService` và `loggerService` cần dependency.
// Khi một framework nhìn thấy metadata này (ví dụ trên UserService.prototype.__injectableParams['saveUser']),
// nó sẽ biết cần phải tìm instance của DatabaseService và LoggerService
// rồi truyền chúng vào khi gọi phương thức saveUser.
Parameter Decorators là nền tảng cho cơ chế Dependency Injection (DI) trong nhiều framework như Angular và NestJS.
Thứ tự thực thi của Decorators
Khi nhiều Decorators được áp dụng cho cùng một khai báo, chúng sẽ được thực thi theo một thứ tự cụ thể:
- Tham số (Parameters) của một khai báo được trang trí sẽ chạy trước.
- Tiếp theo là Decorator của Property, sau đó Accessor, rồi Method của cùng một khai báo.
- Cuối cùng là Decorator của Class.
Nếu có nhiều Decorator trên cùng một mục (ví dụ: nhiều Decorator trên cùng một phương thức), chúng sẽ chạy từ dưới lên trên (hoặc từ phải sang trái nếu viết trên một dòng).
function A() { console.log("A"); return (target: any, key: string, desc?: PropertyDescriptor) => { console.log("A applied"); }; }
function B() { console.log("B"); return (target: any, key: string, desc?: PropertyDescriptor) => { console.log("B applied"); }; }
class MyClass {
@A()
@B() // B được định nghĩa sau A trong code, nhưng factory của B chạy trước factory của A, và decorator B chạy trước decorator A.
myMethod() {}
}
// Output khi định nghĩa class:
// B <-- Factory của B chạy trước (từ dưới lên/phải sang trái)
// A <-- Factory của A chạy sau
// B applied <-- Decorator B chạy trước (vì factory của nó chạy trước)
// A applied <-- Decorator A chạy sau
Việc hiểu thứ tự này là quan trọng khi bạn viết hoặc sử dụng nhiều Decorators cùng nhau, vì chúng có thể ảnh hưởng lẫn nhau.
Decorators và Metadata (Reflect-metadata)
Như bạn đã thấy trong ví dụ Property Decorator và Parameter Decorator, chúng thường được dùng để lưu metadata. Để làm việc này hiệu quả, bạn thường sẽ cần cài đặt và import thư viện reflect-metadata
:
npm install reflect-metadata --save
Sau đó, import nó ở đầu entry file của ứng dụng (thường là main.ts
):
import "reflect-metadata";
// Rest of your application code
Thư viện này cung cấp các hàm như Reflect.defineMetadata
, Reflect.getMetadata
cho phép bạn đính kèm và đọc metadata từ các khai báo bằng Decorators một cách tiêu chuẩn.
Comments