JavaScript 中的装饰器

天魂
Autor 天魂 一个死磕 Angular 的逼粉

JavaScript 中的装饰器

本文中,将讨论 JavaScript 中的装饰器及其用例。通过实际示例来了解与装饰器有关的所有概念。

什么是装饰器?

装饰器就是函数的包装,可用于在不更改原函数的前提下增强函数的功能。

在 JavaScript 中,高阶函数的行为类似于装饰器。

JavaScript 中的函数

JavaScript 中的函数是“一等公民”,也就是说函数的行为就像对象。函数可以赋值给变量,函数可以作为参数传递给其他函数,函数也可以由另一个函数返回。

JavaScript 中的高阶函数

高阶函数是对其他函数进行操作的函数,操作可以是将它们作为参数,或者是返回它们。 简单来说,高阶函数是一个接收函数作为参数或将函数作为输出返回的函数。

1
2
3
4
5
6
7
8
9
10
11
function printMessage(message) {
	return function() {
		console.log(message);
	}
}

const printHello = printMessage("Hello");
printHello(); // Hello

const printHi = printMessage("Hi");
printHi(); // Hi

上面代码片段中,printMessage 是一个高阶函数,一个返回新函数的工厂函数。

再来一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* `handleException` 是一个高阶函数,它接受一个函数作为参数,在函数中添加异常处理功能 */
function handleException(funcAsParameter) {
	console.log("Inside handleException function")
	try {
		funcAsParameter()
	} catch(err) {
		console.log(err)
	}
}

function divideByZero() {
	result = 5 / 0
	if(!Number.isFinite(result)) {
		throw "Division by Zero not a good idea!!"
	}
	console.log("Result of division of 5 by zero is: " + result)
}

/**
 * 将 `divideByZero` 作为参数传递给 `handleException`,
 * 在 `handleException` 内部调用 `divideByZero` 函数并处理可能抛出的异常 
 */ 
handleException(divideByZero)

上面代码中的 handleException 函数可以用来处理任何函数的异常,只需将需要处理异常的函数作为参数传递给 handleException 函数,就可以中函数中捕获异常并处理。

其实高阶函数和装饰器的定义是相似的,而且行为也是同样的。装饰器也是向现有函数添加一些功能,而无需修改现有函数的代码。在上面的例子中 handleException 可以当做一个装饰器函数。

为什么需要装饰器

JavaScript的 Class 中引入了类方法,即 Class 内部定义的函数,在类方法上就不能用高阶函数充当装饰器了。了解为什么类方法不能与高阶函数一起使用之前先了解一下JavaScript 中的类

JavaScript 中的类

没有 class 关键字之前

在 ES2015 引入 class 关键字之前,需要使用 JavaScript 中的原型和构造函数才能创建一个类。

一个构造函数示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 构造函数
function Human(firstName, lastName) {
	this.firstName = firstName;
	this.lastName = lastName;
}

// Human 函数创建的所有对象将共享 getFullName 函数
Human.prototype.getFullName = function() {
	return `${this.firstName} ${this.lastName}`;
}

var person1 = new Human("Virat", "Kohli");
var person2 = new Human("Rohit", "Sharma");

console.log(person1);

Human 函数是具有 firstName 和 lastName 属性的构造函数,用这个函数可以创建新对象。新对象都会有 firstName、lastName 和 getFullName 属性。

每个构造函数都有一个 prototype 属性,它是一个对象。添加到 prototype 上的属性将在所有通过构造函数创建的对象之间共享。

也可以在 Human 构造函数中定义 getFullName,不这样做是因为在通过构造函数创建的 person1 和 person2 对象上将存在不同的 getFullname 副本,这样就导致冗余了,没必要这消耗额外的内存。

这只是原型的基础,如果想深入了解原型的概念,可以阅读本文

class 关键字

ES2015 中引入的 JavaScript 类与 Java,C# 或 Python 中的类不同,它只是基于原型行为的语法糖。这意味着 JavaScript 允许使用 class 关键字定义类,但实际上它仍然使用原型和构造函数来创建对象。

一个声明类的示例:

1
2
3
4
5
6
7
8
9
10
11
12
class Human {
	constructor(firstName, lastName) {
		this.firstName = firstName;
		this.lastName = lastName;
	}
	
	getFullName() {
		return `${this.firstName} ${this.lastName}`;
	}
}

typeof(Human); // function

上面代码示例中用 constructor 函数声明了一个 Human 类。在背后,JavaScript 会将此类转换为 Human 构造函数,类中定义的所有函数都将附加到构造函数的 prototype 属性上。

类方法和高级函数一起使用的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 这个装饰器函数接受一个函数作为参数
function log(functionAsParameter) {
	return function() {
		console.log("Execution of " + functionAsParameter.name + " begin");
		// 装饰器函数在内部调用传递的参数
		functionAsParameter();
		console.log("Execution of " + functionAsParameter.name + " end");
	}	
}

class Human {
	constructor(firstName, lastName) {
		this.firstName = firstName;
		this.lastName = lastName;
	}
	
	getFullName() {
		return `${this.firstName} ${this.lastName}`;
	}
}

var humanObj = new Human("Virat", "Kohli");

// Passing the class method getFullName to the log higher order function
var newGetFullNameFunc = log(humanObj.getFullName);
newGetFullNameFunc();

上面代码运行后将报错:Uncaught TypeError: Cannot read property 'firstName' of undefined;

这是因为在 newGetFullNameFunc 被调用时,实际上是在 log 装饰器函数内部调用了 getFullName 函数,这时候 getFullName 内部的 this 是 undefined。因此代码无法正常运行,导致报错了。

为了解决这个报错,对代码做了如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function log(classObj, functionAsParameter) {
	return function() {
		console.log("Execution of " + functionAsParameter.name + " begin");
		functionAsParameter.call(classObj);
		console.log("Execution of " + functionAsParameter.name + " end");
	}	
}

class Human2 {
	constructor(firstName, lastName) {
		this.firstName = firstName;
		this.lastName = lastName;
	}
	
	getFullName() {
		return `${this.firstName} ${this.lastName}`;
	}
}

var humanObj = new Human2("Virat", "Kohli");
var newGetFullNameFunc = log(humanObj, humanObj.getFullName);
newGetFullNameFunc();

在修改后的代码中,将 humanObj 对象作为参数传递给 log 装饰器函数。为了能在内部正确获取到 this 的值,使用 call 函数将调用 humanObj 对象上的 getFullName。在内部将 humanObj 绑定到 this 上,这样代码就能正常运行。

为了使装饰器更好用,JavaScript 中的装饰器提出来一种新的语法,该语法类似于其他语言中装饰器的语法。在了解装饰器新语法前先了解一下属性描述符。

属性描述符

JavaScript 中每个对象属性都有一个属性描述符,用于描述属性或属性的原数据。属性描述符是一个对象,以下是属性描述符对象的全部属性。

  1. value: 对象属性的当前值
  2. writable: 标识该属性是否可写 (true 或 false)
  3. enumerable: 标识该属性是否可枚举,即是否出现在 Object.keys() 的迭代中 (true 或 false)
  4. configurable:标识是否可以修改该属性的属性描述符 (true 或 false)

一个对象示例:

1
2
3
4
5
6
7
const humanObj = {
	'firstName': 'Virat',
	'lastName': 'Kohli',
	'getFullName': () => {
		return `${this.firstName} ${this.lastName}`;
	}
}

在 humanObj 对象中,每个属性都拥有自己的属性描述,可以利用 getOwnPropertyDescriptor 查看属性描述符的值:

1
2
3
4
5
6
7
8
9
10
Object.getOwnPropertyDescriptor(humanObj, 'firstName');
/* 
Output:
{
	value: "Virat", 
	writable: true, 
	enumerable: true, 
	configurable: true
} 
*/
object.defineProperty

object.defineProperty 可用于定义新属性或更新对象的现有属性。 object.defineProperty 有以下参数:

  1. object:要创建新属性或更新现有属性的对象
  2. name:属性名称
  3. descriptor:属性描述符对象

为 humanObj 对象定义一个新属性

1
2
3
4
5
6
7
8
9
10
11
Object.defineProperty(humanObj, 'age', {value: 10});
Object.getOwnPropertyDescriptor(humanObj, 'age');
/* 
Output:
{
	value: 10, 
	writable: true, 
	enumerable: true, 
	configurable: true
} 
*/

对于 age 属性,由于仅提供了一个 value 属性描述符作为 defineProperty 的参数,其他属性描述符将使用默认值。如果对象上已经存在该属性,Object.defineProperty 则会使用新的属性描述符覆盖该属性。

1
2
3
4
5
6
Object.defineProperty(humanObj, 'firstName', {value: "Rohit"});
console.log(humanObj);
/*
Output
	{firstName: "Rohit", lastName: "Kohli"}
*/

为了防止更改属性的 value,可以将 writable 设置为 false,使其成为只读属性。

1
Object.defineProperty(humanObj, 'firstName', {writable: false});

现在,如果尝试更改属性,则不会生效。

1
2
3
4
5
6
humanObj.firstName = 'Virat';
console.log(humanObj);
/*
Output
	{firstName: "Rohit", lastName: "Kohli"}
*/

如果不希望更改属性描述符,可以将 configurable 设置为 false。

在了解了以上所有概念之后,开始了解装饰器。

类方法装饰器

类方法装饰器用于通过在方法上添加额外的功能来修改类方法,它的行为类似于高阶函数。下面是装饰器的语法:

1
2
3
4
5
6
7
8
// Decorator function
function decoratorFunc(target, property, descriptor) {}

class DecoEx {
    // Syntax for adding decorator
    @decoratorFunc
    getFullName() {}
}

装饰器函数接受三个参数:

  1. target:定义装饰器的方法的类
  2. property:定义装饰器的方法的名称
  3. descriptor:定义装饰器的方法的属性描述符

装饰器函数返回属性描述符对象,可以使用属性描述符的 value 属性覆盖被装饰函数的定义。JavaScript 引擎遇到装饰器时会把被装饰的函数作为参数传递给装饰器函数并调用,在装饰器函数内部可以在被装饰的函数上定义新的函数以及添加一些代码。

一个典型的只读装饰器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 装饰器函数
function readonlyDecorator(target, property, descriptor) {
    console.log("Target: " );
    console.log(target);
    
    console.log("\nProperty name");
    console.log(property);
    
    console.log("\nDescriptor property");
    console.log(descriptor);
    
    // 将被装饰的函数设置为只读
    descriptor.writable = false;
    // 这个属性描述符会覆盖 getFullName 方法的描述符
    return descriptor;
}

class Human {
	constructor(firstName, lastName) {
		this.firstName = firstName;
		this.lastName = lastName;
	}
	
	// 装饰器语法
	@readonlyDecorator
	getFullName() {
		return `${this.firstName} ${this.lastName}`;
	}
}

humanObj = new Human("Virat", "Kohli");

console.log("\ngetFullName property value");
console.log(humanObj.getFullName);

// 修改 getFullName 的值
humanObj.getFullName = "Hello";

// 由于 getFullName 是只读的,这里打印的值还是 “Hello”
console.log("\nAfter changing getFullName value");
console.log(humanObj.getFullName);

通过 @readonlyDecorator 装饰器将 getFullName 方法设置为只读。

这里 JavaScript 引擎先调用 readonlyDecorator ,再调用 getFullName 函数。调用 readonlyDecorator 时,它利用属性描述符的 value 属性修改 getFullName 函数,因此当 JavaScript 引擎调用 getFullName 函数时,它将调用修改后的 getFullName 函数。看一下 JavaScript 引擎执行装饰器的步骤。

1
2
3
4
5
6
7
8
9
// 获取 `getFullName` 函数的属性描述符
funcDescriptor = Object.getOwnPropertyDescriptor(humanObj, 'getFullName');

// 将 funcDescriptor 作为参数传递并调用 readonlyDecorator
// readonlyDecorator 修改 `getFullName` 函数并返回修改后的 `getFullName` 定义的描述符
descriptor = readonlyDecorator(Human.prototype, 'getFullName', funcDescriptor);

// JavaScript 通过 readonlyDecorator 返回新的 `getFullName` 属性描述符
Object.defineProperty(Human.prototype, 'getFullName', descriptor);

一个带参数的装饰器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
function log(message) {
  function actualLogDecorator(target, property, descriptor) {
    console.log("\ngetFullName descriptor's value property");
    console.log(descriptor.value);

    const actualFunction = descriptor.value;

    const decoratorFunc = function() {
      console.log(message);
      // 调用 HumanObj 对象上的 getFullName 函数
      return actualFunction.call(this);
    }

    // 用 decoratorFunc 覆盖 getFullName 函数
    descriptor.value = decoratorFunc;

	console.log("\nNew descriptor's value property due to decorator");
    console.log(descriptor.value);

    // 这个属性描述符将覆盖 getFullName 属性描述符的 value 属性
    return descriptor
  }

  return actualLogDecorator;
}

class Human {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  @log("Logging by decorator")
  getFullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

humanObj = new Human("Virat", "Kohli");

console.log("\ngetFullName's descriptor's value property after modification due to decorator");

// Human.prototype 上定义了类方法
descriptorObject = Object.getOwnPropertyDescriptor(Human.prototype, 'getFullName');
console.log(descriptorObject.value);

console.log("\nOutput for getFullName method");
console.log(humanObj.getFullName());

输出的 descriptorObject.value 就是在 log 装饰器函数里定义的 decoratorFunc 函数。humanObj.getFullName() 的结果是先打印参数 message 再执行 getFullName 函数。

JavaScript 的执行步骤:

1
2
3
4
5
6
7
8
var funcDescriptor = Object.getOwnPropertyDescriptor(humanObj, 'getFullName');

// JavaScript 调用装饰器函数,log 函数返回分配给执行器的实际 logDecorator。
// 因此 actualDecorator 是一个在 getFullName 上的具有日志功能的函数
var actualDecorator = log("This is done using decorators"); // 这里返回一个函数

descriptor = actualDecorator(Human.prototype, 'getFullName', funcDescriptor);
Object.defineProperty(Human.prototype, 'getFullName', descriptor);

JavaScript 先调用 log 函数,log 函数返回一个修改 getFullName 方法的 actualLogDecorator 函数,在 getFullName 方法上添加了日志记录功能。

JavaScript 将 actualLogDecorator 视为装饰器函数,因此 actualLogDecorator 返回具有修改后的 getFullName 方法定义的属性描述符。

后面的步骤跟 JavaScript 执行带有装饰器的 getFullName 方法的步骤与无参数的装饰器示例中讨论的步骤一致。

类装饰器

类装饰器时在类上面声明的,而方法装饰器是在类方法的上面声明的。与方法装饰器需要返回属性描述符不同,类装饰器需要返回构造函数或新类。类装饰器值接受一个参数,就是声明装饰器的类。

一个类装饰器示例:

1
2
3
4
5
6
7
8
9
10
11
12
@decFunc
class Foo {
}

// 等同于

function Foo(FooClass) {
}

NewFoo = decFunc(Foo) || Foo;

Foo = NewFoo;

上面的代码说明了类装饰器执行时的步骤,有一个空的class Foo,它的装饰器函数 decFunc 接受类(或 ES2015 中的构造函数)作为参数,对传入的参数进行修改后返回修改后的类(或 ES2015 中的构造函数)。NewFoo 变量覆盖 Foo,因此现在 Foo 包含修改后的功能。

另一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function newConstructor(HumanClass) {
	const newConstructorFunc = function(firstName, lastName, age) {
		this.firstName = firstName;
		this.lastName = lastName;
		this.age = age;
	}

	return  newConstructorFunc;
} 

@newConstructor
class Human {
	constructor(firstName, lastName) {
		this.firstName = firstName;
		this.lastName = lastName;
	}
}

const person1 = new Human("Virat", "Kohli", 31);
console.log( person1 );

// 打印修改后的构造函数
console.log( Human.prototype.constructor );
console.log(person1.__proto__.constructor);

上面代码中,newConstructor 装饰器返回一个参数是 firstName, lastName, age 的构造函数,返回的构造函数会覆盖现有的 Human 构造函数。因此在创建 Human 实例对象时可以传递3个参数。

除了返回构造函数还可以返回一个新的类,如下:

1
2
3
4
5
6
7
8
9
function newConstructor(HumanClass) {
	return class NewClass {
		constructor(firstName, lastName, age) {
			this.firstName = firstName;
			this.lastName = lastName;
			this.age = age;
		}
	}
}

扩展阅读

Proxy MDN

Reflect MDN

💡其他疑问可以在「web 前端骚操作」知识星球 一起探索
评分:
comments powered by Disqus