Decorators do not work as you might expect 🤔 Quick Recap on Decorators The @Clamp Decorator Exploring decorators under the hood The Problem Creating instance-targeted decorators Bonus Conclusion Special Thanks

2025-06-08

装饰器并不像你预期的那样工作🤔

装饰器快速回顾

@Clamp 装饰器

探索装饰器背后的原理

问题

创建以实例为目标的装饰器

奖金

结论

特别感谢

在开发一个名为ngx-template-streams的库(简而言之,它允许你在 Angular 模板中将事件作为流来处理)的过程中,我发现装饰器不是基于实例的,而是基于类的。我之前并没有意识到这种行为,并且一直以为装饰器是针对每个类实例应用的。在这篇博文中,我们将深入研究装饰器,探讨它们为何会这样,以及如何创建基于实例的装饰器。

封面照片由 Garett Mizunaka 在 Unsplash 上拍摄

装饰器快速回顾

装饰器非常棒。它允许我们为类声明和成员(包括属性、访问器、参数和方法)添加注解和元编程语法。换句话说,我们可以使用装饰器在不修改任何其他对象的情况下为对象附加额外的功能。因此,它们非常适合以声明式的方式组合功能片段。这意味着装饰器设计模式的设计方式是多个装饰器可以堆叠在一起,每个装饰器都可以添加新功能。

此外,许多人认为装饰器是子类化的灵活替代方案。子类化在编译时添加行为,因此会影响所有实例,而装饰器则在运行时将行为添加到单个对象中。

装饰器如此受欢迎,坦白说是有原因的。它们使我们的代码更易于阅读、测试和维护。因此,一些领先的开源项目都采用了装饰器设计模式,包括AngularInversifyNest

好的,那么装饰器是什么?

Idan Dardikman精彩地总结了这个问题:

装饰器只是用函数包装一段代码的简洁语法

TypeScript 对装饰器的支持目前处于实验阶段。不过,ECMAScript 装饰器提案已经进入第二阶段(草案),因此它们最终可能会融入原生 JS 中。

如前所述,装饰器有多种类型。例如,我们可以将装饰器附加到类上:

@Component()
class HeroComponent {}
}

@Component()是类装饰器的一个绝佳示例,也是Angular的核心构建块之一。它将额外的元数据附加到类中。

您很可能还会遇到一些属性方法参数装饰器:

@Component()
class HeroComponent {
  @Input() name: string;

  constructor(@Inject(TOKEN) someDependency: number) {}

  @deprecated
  greet() {
    console.log('Hello there!');      
  }
}

所以,装饰器非常通用、富有表现力且功能强大。不过,这篇博文并非要详细解释装饰器。在本文中,我们将实现一个属性装饰器来探索其行为,但不会讨论其他类型装饰器的实现。如果您想了解更多关于装饰器的知识,我强烈推荐官方文档、这篇简单的介绍,或者这个关于装饰器相关主题的精彩系列文章。

@Clamp 装饰器

现在该举个例子来理解我在开头提到的行为了。我之前说过,装饰器不是以实例为目标的,并且每个类和每个用途只调用一次

为了证明这一点,我们将实现我们自己的属性装饰Clamp

要在 TypeScript 中使用装饰器,我们必须启用一个名为 的编译器选项experimentalDecorators。最佳位置是tsconfig.json

{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true
  }
}

现在我们可以创建一个Clamp装饰器,将其应用于 类型的属性number。它的作用是将属性值限制在指定的上下限内。

例如,如果下限为10,上限为50,那么我们的装饰应该将值限制在这些界限内:

clamp(5) // => 10
clamp(100) // => 50

我们稍后会实现此功能,但首先,让我们将注意力转移到属性装饰器上。

属性装饰器具有以下签名:

type PropertyDecoratorType = (target: any, propertyKey: string | symbol) => void;

它是一个带有两个参数的普通函数target。是拥有被修饰属性对象, 是修饰属性的名称。现在,你可能会认为 是一个类的实例,但事实并非如此。只是该类的原型,稍后我们会对此进行详细介绍。propertyKeytargettarget

上面的签名描述了一个属性装饰器,它定义明确。这意味着参数是固定的,并且没有扩展签名的空间。但是,我们的装饰器应该是可配置的,并且接受lower绑定upper。因此,我们必须使用工厂函数。换句话说,我们将装饰器方法封装在另一个定义所有可配置选项的方法(工厂)中:

function Clamp(lowerBound: number, upperBound: number) {
  return (target: any, propertyKey: string | symbol) => {
    // logic goes here
    console.log(`@Clamp called on '${String(propertyKey)}' from '${target.constructor.name}'`);
  }
}

太棒了,我们把一个普通的装饰器改造成了一个装饰器工厂,释放出更强大的力量。耶!

在实现逻辑之前,我们先来试试吧!我们将创建一个类TestBench,并用我们自制的装饰器来装饰一些属性@Clamp

class TestBench {
  @Clamp(10, 20)
  a: number;

  @Clamp(0, 100)
  b: number;
}

这就是我们简单的测试平台。注意,我们并没有创建TestBench类的实例。所以在运行这段代码之前,我们先来做个小测试:

问题:您预计会发生什么?

  • :没有。由于我们没有创建该类的实例,因此装饰器不会被调用;因此,没有任何记录。
  • B :每个类调用一次装饰器工厂;因此,只有一个值打印到控制台。
  • C:工厂被调用两次,每个属性一次;因此,将有两个值打印到控制台。
  • D:它爆炸了。

好的,鼓声响起……🥁🥁🥁

运行此代码将得到以下输出:

@Clamp called on 'a' from 'TestBench'
@Clamp called on 'b' from 'TestBench'

太棒了!等等,什么?看来我们的装饰器函数被调用了两次,每个装饰属性调用一次。这意味着上面测验的答案是C。如有疑问,这里有一个在线演示:

现在的问题是,为什么在我们没有创建类的实例的情况下调用装饰器方法。

探索装饰器背后的原理

要找到这个问题的答案,我们必须深入研究一下,看看使用装饰器后 TypeScript 编译器实际上会生成什么。你可以运行代码,也可以tsc将代码复制粘贴到TypeScript Playground中。无论我们做什么,都应该得到以下转译后的代码:

"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
function Clamp(lowerBound, upperBound) {
    return (target, propertyKey) => {
        // logic goes here
        console.log(`@Clamp called on '${String(propertyKey)}' from '${target.constructor.name}'`);
    };
}
class TestBench {}
__decorate([
    Clamp(10, 20)
], TestBench.prototype, "a", void 0);
__decorate([
    Clamp(0, 100)
], TestBench.prototype, "b", void 0);

乍一看,这不太容易理解,尤其是__decorate顶部定义的这个有点神奇的函数。但这个方法非常重要,尤其是它的使用方式。

那么__decorate它从何而来,又有何作用?这个方法源自编译器的深层底层,在使用任何类型的装饰器时都会生成。TypeScript 有一个生成此代码的辅助函数,名为decorateHelper。一定要查看源代码。这是一个很好的学习资源。

好的,但它到底做了什么?简单来说,它循环遍历每个传入的装饰器并尝试对其进行求值。详细的解释超出了本文的范围。幸运的是,有一篇很棒的博客文章对此进行了深入的解释。

因此,让我们将注意力集中到生成的代码的底部:

__decorate([
    Clamp(10, 20)
], TestBench.prototype, "a", void 0);
__decorate([
    Clamp(0, 100)
], TestBench.prototype, "b", void 0);

这就是__decorate函数被调用的地方。此外,我们可以看到它被调用了两次,每个装饰属性调用一次,并且两次都传入了相同的值target,即TestBench.prototype。第二个参数是propertyKey,最后一个参数是属性描述符。这里,void 0用于传递undefined

void运算符接受一个表达式,对其进行求值并返回undefined。有关更多信息,请参阅MDN 文档。StackOverflow 上还有一个很棒的答案,解释了与仅仅使用 相比,这个小运算符的用途undefined。TL ;DR:JavaScript 并非完美,也不undefined是保留关键字,这意味着它只是一个变量名,我们可以为其分配新值。

上面的代码是由 TypeScript 编译器生成的,通常我们会在浏览器中加载这些代码,文件加载完成后,这些代码就会被执行。换句话说,装饰器会在我们使用装饰器的类加载时立即应用。因此,装饰器(这里指的是属性装饰器)只能访问类的原型和属性名称,而不能访问实例。这是设计使然,既然我们知道了编译器会生成什么,那么这一切都说得通了。

到目前为止,关键点应该是我们现在知道为什么装饰器不是以实例为目标,而是在我们的 JavaScript 在浏览器中加载时执行。

必须意识到这一点,否则我们可能会遇到意外的行为。为了理解这一点,我们必须在装饰器中添加逻辑。

问题

装饰器是在类加载时应用的,而不是在创建实例时,这本身并没有错,而且设计就是这样的。那么,可能出现什么问题呢?

为了找到答案,我们首先要实现实际的 Clamp 功能。因此,我们创建一个名为 的工厂函数makeClamp,它返回一个clamp带有upperandlower绑定的函数。在这里再次使用工厂函数可以使该功能更具可复用性。

function makeClamp(lowerBound: number, upperBound: number) {
  return function clamp(value: number) {
    return Math.max(lowerBound, Math.min(value, upperBound));
  }
}

我们可以看到这个工厂返回了一个clamp方法。下面是一个使用方法的示例:

const clamp = makeClamp(0, 10);

console.log(clamp(-10)); // => 0
console.log(clamp(0));   // => 0
console.log(clamp(5));   // => 5
console.log(clamp(10));  // => 10
console.log(clamp(20));  // => 10

上面的例子应该能让我们正确理解装饰器的作用。带有 注释的类属性@Clamp应该将属性值限制在一个包含lower边界内upper

简单地将其添加到装饰器函数是不够的,因为我们希望装饰器在实例上运行,并且它应该在每次设置属性时限制其值。

假设我们不知道target仅仅是一个类的原型,所以我们使用 来修改目标上已经存在的属性Object.defineProperty。除了其他功能之外,这还允许我们定义gettersetter,这正是我们所需要的。以下是我们需要做的:

  1. clamp使用工厂创建所需的方法makeClamp
  2. 维护一些用于存储限制属性值的内部状态。
  3. Object.defineProperty使用并提供和修改目标属性gettersetter以便我们可以拦截对值的任何修改并通过我们的clamp方法运行它。

将其放入代码中可能如下所示:

function Clamp(lowerBound: number, upperBound: number) {
  return (target: any, propertyKey: string | symbol) => {
    console.log(`@Clamp called on '${String(propertyKey)}' from '${target.constructor.name}'`);

    // 1. Create clamp method
    const clamp = makeClamp(lowerBound, upperBound);

    // 2. Create internal state variable that holds the clamped value
    let value;

    // 3. Modify target property and provide 'getter' and 'setter'. The 'getter'
    // simply returns the internal state, and the 'setter' will run any new value
    // through 'clamp' and update the internal state.
    Object.defineProperty(target, propertyKey, {
      get() {
        return value;
      },
      set(newValue: any) {
        value = clamp(newValue);
      }
    })
  }
}

我们来更新一下测试平台,为了简单起见,删除一个属性,并创建两个测试类的实例。此外,我们将该属性设置为以下值:

class TestBench {
  @Clamp(10, 20)
  a: number;
}

const tb1 = new TestBench();
console.log(`Setting 'a' on TB1`)
tb1.a = 30;
console.log(`Value of 'a' on TB1:`, tb1.a);

const tb2 = new TestBench();
console.log(`Value of 'a' on TB2:`, tb2.a);

运行此代码将打印以下输出:

@Clamp called on 'a' from 'TestBench'
Setting 'a' on TB1
Value of 'a' on TB1: 20
Value of 'a' on TB2: 20

现在,这个输出看起来有点不对劲,不是吗?我们创建第一个实例tb1,并立即将属性设置a30。这会导致setter被调用,并将值限制在指定的上下限之间。结果应该是20,事实也确实如此。到目前为止,一切顺利。然后,我们创建另一个实例tb2,并简单地读取属性,导致getter被调用。20即使我们还没有在第二个实例上设置值,它仍然会返回。为什么?

这就是我所说的意外行为,至少在我们没有意识到 不是类实例而是原型的情况下target是这样。因此,对目标的任何修改都会影响每个实例,因为我们是在全局修改类的原型。此外,value原本是每个装饰器的内部状态 ,现在却在所有实例之间共享,因为它们都共享相同的装饰器作用域。 事实就是如此,但就我们的用例而言,这并不酷。

看看这个现场演示!我强烈建议你稍微琢磨一下代码。

创建以实例为目标的装饰器

那么,如果我们希望装饰器基于实例,我们该怎么做呢?我们当然不想在全局实例之间共享状态。

解决方案包括在应用装饰器后修改目标属性,并在实例上定义一个具有相同名称的属性。换句话说,我们在目标原型上定义一个带有 的属性,该属性将在目标实例首次使用时setter安装一个同名的属性,即。propertyKey

好的,让我们看一下代码。我添加了大量注释,以便更容易理解发生了什么:

function Clamp(lowerBound: number, upperBound: number) {
  return (target: any, propertyKey: string | symbol) => {
    console.log(`@Clamp called on '${String(propertyKey)}' from '${target.constructor.name}'`);

     // Create clamp method
    const clamp = makeClamp(lowerBound, upperBound);

    // Create map to store values associated to a class instance
    const values = new WeakMap();   

    // Define property on the target with only a `setter` because we don't
    // want to read from the prototype but instead from the instance.
    // Once the value of the property is set for the first time we define
    // a property with a `getter` and `setter` on the instance.
    Object.defineProperty(target, propertyKey, {
      set(newValue: any) {
        console.log('set on target');

        // This `setter` gets called once per new instance, and only the 
        // first time we set the value of the target property.

        // Here we have access to the instance `this`, so we define 
        // a property with the same name on the class instance.
        Object.defineProperty(this, propertyKey, {
          get() {
            console.log('get on instance');
            // This `getter` gets called every time we read the instance property.
            // We simply look up the instance in our map and return its value.
            return values.get(this);
          },
          set(newValue: any) {
            console.log('set on instance');
            // This `setter` is called every time we set the value of the 
            // property on the class instance.
            values.set(this, clamp(newValue));
          }
        });

        // Finally we set the value of property on the class instance.
        // This will trigger the `setter` on the instance that we defined above.
        return this[propertyKey] = newValue;
      }
    })
  }
}

本质上,我们在使用Object.definePropertyinside Object.defineProperty,但使用了不同的对象。第一个使用 ,target即类原型;第二个使用this,即类实例。

WeakMap另外,请注意,我们在装饰器顶部使用了来存储每个实例的属性值。 AWeakMap是一种特殊的 ,Map但不同之处在于,即使该对象在 中用作 的WeakMap, 也不会阻止对象被垃圾回收。如果您想了解更多信息,请查看这篇精彩的博客文章,它很好地解释了这些差异。WeakMap

好了,让我们测试一下这个修改后的装饰器版本,看看它是否真的以实例为目标,以及它是否不再在同一个类的所有实例之间共享状态。为此,我稍微更新了测试平台,并添加了一些注释:

// When this class gets loaded, the decorator is applied and executed.
// This will define the `setter` for the target property on the prototype
// of this class.
class TestBench {
  @Clamp(10, 20)
  a: number;
}

const tb1 = new TestBench();

// This should return `undefined` because we didn't define a `getter`
// on the target prototype for this property. We only install a `getter`
// once we set the value for the first time.
console.log(`Reading 'a' on TB1`, tb1.a);

// This calls the `setter` for `target.a` and defines a property with 
// a `getter` and `setter` on the class instance for the same property.
tb1.a = 30;

// From this moment on, every time we read the value for this property
// we would call the most inner `getter`.
console.log(`Reading 'a' on TB1`, tb1.a);

// The same applies for updating the value. This will call the `setter`
// that we defined for the property of the class instance.
console.log(`Updating 'a' on TB1`);
tb1.a = 15;

// Creating a new instance doesn't do anything
const tb2 = new TestBench();

// Remember, we have globally defined a getter for `target.a` and because we
// are operating on a new instance, the target setter will be called which
// will set up the property on the new instance with their own `getter`
// and `setter` methods.
console.log(`Setting 'a' on TB2`);
tb2.a = 5;

console.log(`Reading 'a' on TB2:`, tb2.a);

// Remains unmodified because every instance has it's own property defined
// with their own `getter` and `setter`
console.log(`Reading 'a' on TB1:`, tb1.a);

太棒了!看起来效果不错。我们刚刚实现了自己的装饰器,它工作在实例级别,而不是基于原型。我的意思是,它仍然需要修改原型,但现在每个装饰器都只作用于一个实例,并且彼此隔离。

查看最终解决方案并仔细研究代码:

奖金

上面展示了一个完整的解决方案,但在我撰写这篇博文时,Netanel Basal向我指出了一个更简洁、更清晰的解决方案。它不需要重复调​​用Object.defineProperty,因为他发现返回值不会被忽略(这与文档中提到的不同),实际上它会被用作调用 的输入Object.defineProperty

考虑到这一点,我们可以将上面的解决方案简化为以下内容,其具有完全相同的行为:

function Clamp(lowerBound: number, upperBound: number): any {
  return (target: any, propertyKey: string | symbol) => {
    const clamp = makeClamp(lowerBound, upperBound);

    // We need a unique key here because otherwise we would be
    // calling ourselves, and that results in an infinite loop.
    const key = Symbol();

    // We can return a property descriptor that is used to define 
    // a property on the target given the `propertyKey`.
    return {
      get() {
        // Read the value from the target instance using the
        // unique symbol from above
        return this[key]; 
      },
      set(newValue: any) { 
        // Clamp the value and write it onto the target instance
        // using the unique symbol from above
        this[key] = clamp(newValue);
      }
    }
  }
}

现在,这很干净,不是吗?🔥

以下是现场演示:

结论

装饰器是基于类和属性的,这意味着它们在类加载时针对每个被装饰的属性应用并执行一次。这意味着target不是类实例,而是类的原型。对 所做的任何更改target都是全局的,如果我们尝试使用装饰器作用域来维护某些内部状态,则该状态将在同一类的所有实例之间共享,并且它们都使用相同的装饰器作用域。这可能会导致意外的行为。

然而,在本文中,我们看到了一种解决方案,它涉及Object.defineProperty具有不同目标的双精度,以使装饰器基于实例。

希望到现在为止,您已经更好地理解了装饰器的工作原理以及它们的行为方式。

如果您喜欢这篇文章,请随意点赞,如果您有任何问题或意见请告诉我!

特别感谢

我要感谢Netanel BasalManfred Steyer审阅本文并提供宝贵反馈。🙏

鏂囩珷鏉ユ簮锛�https://dev.to/angular/decorators-do-not-work-as-you-might-expect-3gmj
PREV
使用指令在 Angular 中实现全屏切换功能。
NEXT
Angular 应用的编译时与运行时配置