日期:2022年4月30日标签:JavaScript

JavaScript(ECMAScript) Decorator #

Decorator,中文译作装饰器、修饰器,Decorator 只是 ECMAScript 中的一个标准,JavaScript 并未实现这个标准语法,所以我们无法直接使用 Decorator 的语法。

但是由于 Decorator 十分 nice,所以 Babel 提供了插件,可以让我们在 javascript 中使用 Decorator 语法。

Decorator 是什么? #

Decorator 就是一个用来改变类的成员(属性、方法)和类本身的普通的 JavaScript 函数(建议使用纯函数)。当你在类成员和类的头部使用 @decoratorFunction 语法时,decoratorFunction 会被传递一些参数调用,可以用来修改类和类成员。

理解 Decorator 之前需要看一下 property descriptor 的语法。

Get Start #

因为 JavaScript 还不支持 Decorator 语法,所以我们需要做一些准备工作。

安装 Babel 或者 TypeScript,将包含 Decorator 语法的代码转换成 JavaScript 引擎能够理解的代码。

为了简单起见,使用 Babel。

1、安装 @baebl/core@babel/cli

$ npm install --save-dev @babel/core @babel/cli

$ npx babel --version
7.10.4 (@babel/core 7.10.4)

2、安装 @babel/preset-env@babel/plugin-proposal-decorators

$ npm install --save-dev @babel/preset-env
$ npm install --save-dev @babel/plugin-proposal-decorators

@babel/preset-env 包含了一些预设的标准的 babel 插件和配置,@babel/plugin-proposal-decorators 用于 decorator 语法转换。

3、添加 babel.config.json 文件

{
    "presets": [
        "@babel/preset-env"
    ],
    "plugins": [
        [
            "@babel/plugin-proposal-decorators",
            {
                "decoratorsBeforeExport": true
            }
        ]
    ]
}

4、编译文件

至此,你已经完成了编译 decorator 语法的基本环境配置了,你可以创建一个包含 decorator 语法的文件 decoratorTest.js,然后使用 npx babel decoratorTest.js -o decoratorTest.out.js 命令编译文件。

类方法 Decorator #

创建 user.js 文件:

class User {
    constructor(firstname, lastName) {
        this.firstname = firstname;
        this.lastName = lastName;
    }

    getFullName() {
        return this.firstname + " " + this.lastName;
    }
}

let user = new User("John", "Doe");

console.log(Object.getOwnPropertyDescriptor(
    User.prototype, "getFullName"
));

User.prototype.getFullName = function() {
    return "HACKED!";
}

console.log(user.getFullName());

编译,输出结果:

$ npx babel user.js -o user.out.js && node user.out.js
{
  value: [Function: getFullName],
  writable: true,
  enumerable: false,
  configurable: true
}
HACKED!

可以发现 getFullName 的 descriptor 的 writable 属性时 true,所以我们可以随意更改这个方法的值,因此最后输出了 HACKED!

为了避免方法被修改,需要更改 getFullName 的 descriptor

注意:getFullName 位于 User.prototype 上,类方法与类属性一样,只不过它的值是函数。

使用 Object.defineProperty 修改 descriptor。

class User {
    constructor(firstname, lastName) {
        this.firstname = firstname;
        this.lastName = lastName;
    }

    getFullName() {
        return this.firstname + " " + this.lastName;
    }
}

// 修改 writable 为 false
Object.defineProperty(User.prototype, "getFullName", {
    writable: false
});

let user = new User("John", "Doe");

console.log(Object.getOwnPropertyDescriptor(
    User.prototype, "getFullName"
));

编译输出结果:

$ npx babel user.js -o user.out.js && node user.out.js
{
  value: [Function: getFullName],
  writable: false,
  enumerable: false,
  configurable: true
}

如果此时,修改 getFullName 函数的值,将不会有任何作用,并且严格模式下,会直接报错。

class User {
    constructor(firstname, lastName) {
        this.firstname = firstname;
        this.lastName = lastName;
    }

    getFullName() {
        return this.firstname + " " + this.lastName;
    }
}

// 修改 writable 属性
Object.defineProperty(User.prototype, "getFullName", {
    writable: false
});

let user = new User("John", "Doe");

console.log(Object.getOwnPropertyDescriptor(
    User.prototype, "getFullName"
));

// 修改 getFullName 函数值,报错!
User.prototype.getFullName = function() {
    return "HACKED!";
}

console.log(user.getFullName());

编译输出结果:

$ npx babel user.js -o user.out.js && node user.out.js
{
  value: [Function: getFullName],
  writable: false,
  enumerable: false,
  configurable: true
}
D:\study\decorator\user.out.js:34
User.prototype.getFullName = function () {
                           ^

TypeError: Cannot assign to read only property 'getFullName' of object '#<User>'
    at Object.<anonymous> (D:\study\decorator\user.out.js:34:28)
    at Module._compile (internal/modules/cjs/loader.js:1256:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1277:10)
    at Module.load (internal/modules/cjs/loader.js:1105:32)
    at Function.Module._load (internal/modules/cjs/loader.js:967:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
    at internal/main/run_main_module.js:17:47

如果有很多方法与 getFullName 一样,都需要做同样的处理来避免被 Hacker 修改,那么工作量会越来越大,这也正是 decorator 的作用。下面我们用 decorator 来实现同样的作用。

// 定义 decorator 函数
function readonly(target) {
    target.descriptor.writable = false;
    return target;
}

class User {
    constructor(firstname, lastName) {
        this.firstname = firstname;
        this.lastName = lastName;
    }

    // 在需要添加装饰器的方法前,添加 decorator
    @readonly
    getFullName() {
        return this.firstname + " " + this.lastName;
    }
}

let user = new User("John", "Doe");

console.log(Object.getOwnPropertyDescriptor(
    User.prototype, "getFullName"
));

首先定义了一个 decorator 函数,并在需要添加装饰的顶部添加 @decoratorFunction

编译输出上面的代码。

$ npx babel user.js -o user.out.js && node user.out.js
{
  value: [Function: getFullName],
  writable: false,
  enumerable: false,
  configurable: true
}

可以看到 getFullNamewritablefalse,定义了 decorator 函数后,我们可以使用 @decoratorFunction 语法重复使用。

decorator 函数的参数 target 对象,包含了要修改的 element(类方法、类属性和类本身)的描述信息。target 对象的结构如下:

{
  kind: 'method' | 'accessor' | 'field' | 'class',
  key: '<property-name>',
  descriptor: <property-descriptor>,
  placement: 'prototype' | 'static' | 'own',
  initializer: <function>,
  ...
}

kind 属性标识 element(要修改的目标)的类型,它是类的方法、成员或者是类本身。key 是 element 的名称。你可以在 decorators proposal 获得更多的信息。另外还有一个比较重要的属性是 descriptor,它包含了 element 的属性描述。

我们更改之前的代码,打印 target 对象。

function readonly(target) {
    console.log(target);
    target.descriptor.writable = false;
    return target;
}

...

编译输出结果:

Object [Descriptor] {
  kind: 'method',
  key: 'getFullName',
  placement: 'prototype',
  descriptor: {
    value: [Function: getFullName],
    writable: true,
    configurable: true,
    enumerable: false
  }
}

可以看到 kindkeyplacement 的详细信息,placement 表示 getFullName 这个方法在类的 prototype 上。

你还可以给 decorator 函数传递参数 @decoratorFunc(...args)。因为这是一个 decorator 函数调用,所以定义的 decorator 函数必须返回一个函数用来装饰 element,你可以认为此时定义的 decorator 函数是一个高阶函数。

我们使用这种形式,来定义一个 change 装饰器,来达到 @readonly 同样的效果。

function change(key, value) {
    return function(target) {
        target.descriptor[key] = value;
        return target;
    };
}

class User {
    constructor(firstname, lastName) {
        this.firstname = firstname;
        this.lastName = lastName;
    }

    @change("writable", false)
    getFullName() {
        return this.firstname + " " + this.lastName;
    }
}

let user = new User("John", "Doe");

console.log(Object.getOwnPropertyDescriptor(
    User.prototype, "getFullName"
));

当类方法是 static 时,这个方法是在 class 本身,而不是它的 prototype 上,看下面的例子。

function change(key, value) {
    return function(target) {
        console.log("target", target);
        target.descriptor[key] = value;
        return target;
    };
}

class User {
    @change("writable", false)
    static getVersion() {
        return "1.0.0";
    }
}

// 注意 Object.getOwnPropertyDescriptor 的第一个参数是 User
console.log(Object.getOwnPropertyDescriptor(User, "getVersion"));

User.getVersion = function() {
    return "HACKED!";
};

console.log(User.getVersion());

编译输出结果:

$ npx babel user.js -o user.out.js && node user.out.js
target Object [Descriptor] {
  kind: 'method',
  key: 'getVersion',
  placement: 'static',
  descriptor: {
    value: [Function: getVersion],
    writable: true,
    configurable: true,
    enumerable: false
  }
}
{
  value: [Function: getVersion],
  writable: false,
  enumerable: false,
  configurable: true
}
D:\study\decorator\user.out.js:74
User.getVersion = function () {
                ^

TypeError: Cannot assign to read only property 'getVersion' of function 'function User() {
    _classCallCheck(this, User);

    _initialize(this);
  }'
    at Object.<anonymous> (D:\study\decorator\user.out.js:74:17)
    at Module._compile (internal/modules/cjs/loader.js:1256:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1277:10)
    at Module.load (internal/modules/cjs/loader.js:1105:32)
    at Function.Module._load (internal/modules/cjs/loader.js:967:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
    at internal/main/run_main_module.js:17:47

可以看到 target.placementstatic 表示 element 是 static 的,因为我们修改 writalbefalse,所以再重新赋值 getVersion 时报错。

类属性 Decorator #

定义一个 User 类。

class User {
    firstName = "default-first-name";
    lastName = "default-last-name";

    getFullName = function() {
        return this.firstName + " " + this.lastName;
    }

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

var dummy = new User();
console.log("dummy =>", dummy);
console.log("dummy.getFullName() =>", dummy.getFullName());

var user = new User("John", "Doe");
console.log("user =>", user);
console.log("user.getFullName() =>", user.getFullName());

首次编译输出结果:

$ npx babel user.js -o user.out.js && node user.out.js
dummy => User {
  firstName: 'default-first-name',
  lastName: 'default-last-name',
  getFullName: [Function (anonymous)]
}
dummy.getFullName() => default-first-name default-last-name
user => User {
  firstName: 'John',
  lastName: 'Doe',
  getFullName: [Function (anonymous)]
}
user.getFullName() => John Doe

此时,如果你输出 User.prototype,你会发现它没有 firstNamelastName,甚至没有 getFullName,因为你定义 getFullName ,是通过 = 语法定义的,此时的 getFullName 是一个类属性,只不过它的值是函数,类的属性是定义在类的 instance 上的。这意味着,如果我们想装饰类的属性,我们需要在 instance 被创建时装饰。

接下来,创建一个 @upperCase 装饰器,用来更新 instance 属性的默认值。

function upperCase(target) {
    console.log("target", target);

    const value = target.initializer();

    target.initializer = function() {
        return value.toUpperCase();
    };

    return target;
}
class User {
    // style 1
    @upperCase
    firstName = "default-first-name";

    // stype 2
    @upperCase lastName = "default-last-name";

    getFullName = function() {
        return this.firstName + " " + this.lastName;
    }

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

var dummy = new User();
console.log("dummy.getFullName() =>", dummy.getFullName());

编译查看结果:

$ npx babel user.js -o user.out.js && node user.out.js
target Object [Descriptor] {
  kind: 'field',
  key: 'firstName',
  placement: 'own',
  descriptor: { configurable: true, writable: true, enumerable: true },
  initializer: [Function: value]
}
target Object [Descriptor] {
  kind: 'field',
  key: 'lastName',
  placement: 'own',
  descriptor: { configurable: true, writable: true, enumerable: true },
  initializer: [Function: value]
}
dummy.getFullName() => DEFAULT-FIRST-NAME DEFAULT-LAST-NAME

可以看到 target.kind: "field"target.placement: "own",表示这是一个类属性。

target.initializer 是一个函数,函数的返回值用来初始化类属性的值,所以我们可以在装饰器函数中修改 target.initializer 函数以达到修改默认值的目的。

target.initializer 同样适用于 static properties

function upperCase(target) {
    console.log("target", target);

    const value = target.initializer();

    target.initializer = function() {
        return value.toUpperCase();
    };

    return target;
}
class User {
    // style 1
    @upperCase
    static firstName = "default-first-name";

    // stype 2
    @upperCase static lastName = "default-last-name";

    static getFullName = function() {
        return this.firstName + " " + this.lastName;
    }
}

console.log("getFullName() =>", User.getFullName());

编译输出:

$ npx babel user.js -o user.out.js && node user.out.js
target Object [Descriptor] {
  kind: 'field',
  key: 'firstName',
  placement: 'static',
  descriptor: { configurable: true, writable: true, enumerable: true },
  initializer: [Function: value]
}
target Object [Descriptor] {
  kind: 'field',
  key: 'lastName',
  placement: 'static',
  descriptor: { configurable: true, writable: true, enumerable: true },
  initializer: [Function: value]
}
getFullName() => DEFAULT-FIRST-NAME DEFAULT-LAST-NAME

类 Decorator #

Decorators 同样可以装饰类本身。例如,你想动态的给类增加一个方法。

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

User.getVersion = function() {
    return "1.0.0";
}

User.prototype.getFullName = function() {
    return this.firstName + " " + this.lastName;
}

console.log("version =>", User.getVersion());

let user = new User("John", "Doe");
console.log("full-name =>", user.getFullName());

上面的代码中,动态的给类添加了一个 static 方法 getVersion 和一个非 static 方法 getFullName。我们可以使用 decorator 实现同样的效果,首先打印查看一下 target 的值。

function peek(target) {
    console.log("target", target);
    return target;
}

@peek
class User {
    constructor(firstName, lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    static getVersion() {
        return "1.0.0";
    }
}

编译输出:

$ npx babel user.js -o user.out.js && node user.out.js
target Object [Descriptor] {
  kind: 'class',
  elements: [
    Object [Descriptor] {
      kind: 'method',
      key: 'getVersion',
      placement: 'static',
      descriptor: [Object]
    }
  ]
}

可以看到 target 有一点不同,它的 kind 属性为 class,并且包含了 elements 属性,elements 指示了这个类中的 targets(elements, 装饰器可以装饰的目标),此时他有一个 getVersion 的静态方法。

我们想要做的是在 elements 数组中添加一个新的 target,下面添加一个 非 static 方法的 element。

function add({name, callback}) {
    return function(target) {
        target.elements.push({
            kind: "method",
            key: name,
            placement: "prototype",
            descriptor: {
                value: callback,
                writable: false,
                configurable: false,
                enumerable: false
            }
        });

        return target;
    }
}

@add({
    name: "getFullName",
    callback: function() {
        return this.firstName + " : " + this.lastName;
    }
})
class User {
    constructor(firstName, lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    static getVersion() {
        return "1.0.0";
    }
}

let user = new User("John", "Doe");
console.log("full-name =>", user.getFullName());

编译输出结果:

$ npx babel user.js -o user.out.js && node user.out.js
full-name => John : Doe

可以看到,通过装饰器,我们成功的在类里面添加了一个方法。

The Legacy Decorator #

上面介绍的是目前新的 Decorator 语法,关于旧的语法可以看下面这篇文章。

Legacy: A minimal guide to JavaScript (ECMAScript) Decorators and Property Descriptor of the Object

参考资料 #

(完)

目录