王鹏飞

Blog

Tutorial

About

JavaScript

2022年4月30日

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

参考资料

(完)

留言(0


发表评论

邮箱地址不会被公开。*表示必填项