装饰器Stage3用法解析

95 阅读7分钟

装饰器 Stage3 核心用法

装饰器提案相关内容请见:关于装饰器提案

核心用法

type Decorator = (value: Input, context: {
  kind: string;
  name: string | symbol;
  access: { get?(): unknown; set?(value: unknown): void };
  static?: boolean;
  private?: boolean;
  addInitializer(initializer: () => void): void;
}) => Output | void;

装饰器函数接收两个参数:

  • value:被装饰的目标(类、方法、属性等)本身
  • context:上下文对象,包含以下属性:
    • kind:装饰器类型"class"|"method"|"getter"|"setter"|"field"|"accessor"
    • name:被装饰元素的名称(字符串或Symbol)
    • access:包含get/set方法,用于访问被装饰元素
    • static:是否为静态成员
    • private:是否为私有成员
    • addInitializer(initializer):用于添加初始化逻辑的函数

装饰器类型

类装饰器

  • 作用:修改或替换类构造函数

  • 参数说明:

       type ClassDecorator = (value: Function, context: {
         kind: "class";
         name: string | undefined;
         addInitializer(initializer: () => void): void;
       }) => Function | void;
    
  • 用法示例:添加日志功能

        function logged(value, { kind, name }) {
          if (kind === "class") {
            return class extends value {
              constructor(...args) {
                super(...args);
                console.log(`constructing an instance of ${name} with arguments ${args.join(", ")}`);
              }
            }
          }
    
          // ...
        }
    
        @logged
        class C {}
    
        new C(1);
        // constructing an instance of C with arguments 1
    
  • 语法脱糖(desugars):

       class C {}
    
       C = logged(C, {
         kind: "class",
         name: "C",
       }) ?? C;
    
       new C(1);
    

类方法装饰器

  • 作用:包装或替换类方法

  • 参数说明:

        type ClassMethodDecorator = (value: Function, context: {
          kind: "method";
          name: string | symbol;
          access: { get(): unknown };
          static: boolean;
          private: boolean;
          addInitializer(initializer: () => void): void;
        }) => Function | void;
    
  • 用法示例:

        function logged(value, { kind, name }) {
          if (kind === "method") {
            return function (...args) {
              console.log(`starting ${name} with arguments ${args.join(", ")}`);
              const ret = value.call(this, ...args);
              console.log(`ending ${name}`);
              return ret;
            };
          }
        }
    
        class C {
          @logged
          m(arg) {}
        }
    
        new C().m(1);
        // starting m with arguments 1
        // ending m
    
    • this的指代:在装饰器返回的函数内部,this就是当前实例(即调用该方法的对象实例)。

    • value.call(this, ...args)的作用:

      • 恢复原始方法调用:
        • value是被装饰的原始方法m
        • .call()显示设置方法执行时的this上下文
      • 参数传递:
        • ...args将接收到的参数原样传递给原始方法
        • 确保参数传递不丢失
      • 返回值处理:
        • const ret = ... 捕获原始方法的返回值
        • return ret 确保装饰后方法返回原始结果
      • 这样就使得在保留原方法功能的基础上,添加额外的日志行为(在方法开始和结束时打印日志),而不会改变原方法的逻辑。
    • 当我们调用new C().m(1)时,实际上执行的是装饰器返回的新函数。过程如下:

      • 先打印开始日志:starting m with arguments 1。
      • 然后调用原始方法m,将this(即当前实例)作为上下文,并传入参数1。这样在原始方法内部,this也是指向实例,并且参数arg为1。
      • 原始方法执行,打印:方法内部,参数:1。
      • 接着打印结束日志:ending m。所以,this确保了原始方法能够访问实例的属性和其他方法,而value.call则是调用原始方法的关键。
    • 如果使用 value(...args) 代替 value.call(this, ...args),会导致什么问题?

          function brokenLogged(value, { kind, name }) {
            if (kind === "method") {
              return function (...args) {
                console.log(`starting ${name}`);
                // 错误!丢失 this 绑定
                const ret = value(...args); 
                console.log(`ending ${name}`);
                return ret;
              };
            }
          }
      
          class Example {
            value = 10;
            
            @brokenLogged
            getValue() {
              return this.value; // 这里 this 会变成 undefined!
            }
          }
      
          new Example().getValue(); // 抛出错误:Cannot read properties of undefined
      
  • 语法脱糖(desugars):

        class C {
          m(arg) {}
        }
    
        C.prototype.m = logged(C.prototype.m, {
          kind: "method",
          name: "m",
          static: false,
          private: false,
        }) ?? C.prototype.m;
    

类属性装饰器

  • 作用:拦截属性初始化(Stage 3重大变化)。这种类型的装饰器不会接收到 value(即value固定为undefined),但是可以通过初始化函数来拿到初始数据,并且还可以返回一个新的数据。

    • 和访问器装饰器、方法装饰器区别,属性装饰器的第一个参数为undefined。属性装饰器可以返回一个初始化函数,返回的初始化函数的入参为原始属性值,返回值为替代原始的属性值。
  • 参数说明:

        type ClassFieldDecorator = (value: undefined, context: {
          kind: "field";
          name: string | symbol;
          access: { get(): unknown, set(value: unknown): void };
          static: boolean;
          private: boolean;
        }) => (initialValue: unknown) => unknown | void;
    
  • 用法示例:

        function logged(value, { kind, name }) {
          if (kind === "field") {
            return function (initialValue) {
              console.log(`initializing ${name} with value ${initialValue}`);
              return initialValue;
            };
          }
    
          // ...
        }
    
        class C {
          @logged x = 1;
        }
    
        new C();
        // initializing x with value 1
    
  • 语法脱糖(desugars):

        let initializeX = logged(undefined, {
          kind: "field",
          name: "x",
          static: false,
          private: false,
        }) ?? (initialValue) => initialValue;
    
        class C {
          x = initializeX.call(this, 1);
        }
    

类访问器装饰器

  • 作用:专门用于装饰类中的 getter 和 setter 方法(是一种特殊的方法装饰器)。

  • 参数说明:

        type ClassGetterDecorator = (value: Function, context: {
          kind: "getter";
          name: string | symbol;
          access: { get(): unknown };
          static: boolean;
          private: boolean;
          addInitializer(initializer: () => void): void;
        }) => Function | void;
    
        type ClassSetterDecorator = (value: Function, context: {
          kind: "setter";
          name: string | symbol;
          access: { set(value: unknown): void };
          static: boolean;
          private: boolean;
          addInitializer(initializer: () => void): void;
        }) => Function | void;
    
  • 用法示例:

        function logged(value, { kind, name }) {
          if (kind === "getter" || kind === "setter") {
            return function (...args) {
              console.log(`starting ${name} with arguments ${args.join(", ")}`);
              const ret = value.call(this, ...args);
              console.log(`ending ${name}`);
              return ret;
            };
          }
        }
    
        class C {
          @logged
          set x(arg) {}
        }
    
        new C().x = 1
        // starting x with arguments 1
        // ending x
    
  • 语法脱糖(desugars):

        class C {
          set x(arg) {}
        }
    
        let { set } = Object.getOwnPropertyDescriptor(C.prototype, "x");
        set = logged(set, {
          kind: "setter",
          name: "x",
          static: false,
          private: false,
        }) ?? set;
    
        Object.defineProperty(C.prototype, "x", { set });
    
  • 和普通的方法装饰器的区别:

    • 上下文标识不同

      • 普通方法:kind === "method"
      • 访问器方法:kind === "getter"/"setter"
    • 参数差异

        // 普通方法可能有多个参数
        @logged
        transfer(amount, toAccount) { ... }
      
        // getter 无参数
        @logged
        get balance() { ... }
      
        // setter 单参数
        @logged
        set balance(value) { ... }
      

新的类元素

自动访问器

  • 作用:通过accessor修饰,自动生成私有属性和关联的 getter/setter 方法

  • 用法示例:

        class C {
          accessor x = 1;
        }
    
        // 等同于
        class C {
          #x = 1;
    
          get x() {
            return this.#x;
          }
    
          set x(val) {
            this.#x = val;
          }
        }
    
        // 也可以定义静态
        class C {
          static accessor x = 1;
          accessor #y = 2;
        }
    

自动访问器装饰器

  • 作用:与属性装饰器不同,自动访问器装饰器接收一个value,value是一个包含类原型 (或在静态自动访问器的情况下类本身) 上定义的 get 和 set 访问器的对象。装饰器可以包装这些值并返回一个新的 get / set,从而允许装饰器拦截对该属性的访问。

    • 此外,自动访问器可以返回一个 init 函数,该函数可用于更改属性的初始值,类似于属性装饰器。
    • 如果返回一个对象,但省略了其中任何值,则省略值的默认行为是使用原始行为。如果返回的值类型不是包含这些属性的对象,则会抛出一个错误。
  • 参数说明:

        type ClassAutoAccessorDecorator = (
          value: {
            get: () => unknown;
            set(value: unknown) => void;
          },
          context: {
            kind: "accessor";
            name: string | symbol;
            access: { get(): unknown, set(value: unknown): void };
            static: boolean;
            private: boolean;
            addInitializer(initializer: () => void): void;
          }
        ) => {
          get?: () => unknown;
          set?: (value: unknown) => void;
          init?: (initialValue: unknown) => unknown;
        } | void;
    
  • 用法示例:

        function logged(value, { kind, name }) {
          if (kind === "accessor") {
            let { get, set } = value;
    
            return {
              get() {
                console.log(`getting ${name}`);
    
                return get.call(this);
              },
    
              set(val) {
                console.log(`setting ${name} to ${val}`);
    
                return set.call(this, val);
              },
    
              init(initialValue) {
                console.log(`initializing ${name} with value ${initialValue}`);
                return initialValue;
              }
            };
          }
    
          // ...
        }
    
        class C {
          @logged accessor x = 1;
        }
    
        let c = new C();
        // initializing x with value 1
        c.x;
        // getting x
        c.x = 123;
        // setting x to 123
    
  • 语法脱糖(desugars):

        class C {
          #x = initializeX.call(this, 1);
    
          get x() {
            return this.#x;
          }
    
          set x(val) {
            this.#x = val;
          }
        }
    
        let { get: oldGet, set: oldSet } = Object.getOwnPropertyDescriptor(C.prototype, "x");
    
        let {
          get: newGet = oldGet,
          set: newSet = oldSet,
          init: initializeX = (initialValue) => initialValue
        } = logged(
          { get: oldGet, set: oldSet },
          {
            kind: "accessor",
            name: "x",
            static: false,
            private: false,
          }
        ) ?? {};
    
        Object.defineProperty(C.prototype, "x", { get: newGet, set: newSet });
    

关于addInitializer

通过 addInitializer 可以添加额外的初始化逻辑,这些逻辑的执行时机取决于所修饰对象的类型:

  • 修饰类:在类被完全定义并且所有静态字段都被初始化之后运行。

  • 修饰类元素(方法/属性) :在实例化期间(执行构造函数)运行,但是在实例字段被初始化之前。

  • 修饰静态元素:类定义的过程中,在普通字段定义完成后,并且在静态字段定义之前执行。

修饰类定义
  • 用法示例:举例 @customElement

    • 实现了将 JavaScript 类转换为可在 HTML 中使用的自定义标签的功能。
    • 将自定义元素名称(如 'my-element')与 类(如 MyElement)关联。
    • 允许在 HTML 中使用 标签。
       function customElement(name) {
         return (value, { addInitializer }) => {
           addInitializer(function() {
             customElements.define(name, this); // Web API 用于在浏览器中注册自定义元素
           });
         }
       }
      
       @customElement('my-element')
       class MyElement extends HTMLElement {
       }
      
    • 在类完全定义后(类装饰器的初始化时机),回调函数被调用,此时 this = 类构造函数 MyElement,执行 customElements.define('my-element', MyElement)。
  • 语法脱糖(desugars):

       class MyElement {
       }
    
       let initializersForMyElement = [];
    
       MyElement = customElement('my-element')(MyElement, {
         kind: "class",
         name: "MyElement",
         addInitializer(fn) {
           initializersForMyElement.push(fn);
         },
       }) ?? MyElement;
    
       for (let initializer of initializersForMyElement) {
         initializer.call(MyElement);
       }
    
修饰类元素
  • 用法示例:举例 @bound

    • 作用是自动将类的方法绑定到类的实例上。这样,即使将方法作为函数提取出来单独调用,它内部的 this 仍然指向类的实例,而不是丢失上下文(即不会变成 undefined 或全局对象等)。

        function bound(value, { name, addInitializer }) {
          // 1. 注册初始化逻辑
          addInitializer(function () {
          // 3. 在实例创建时执行
          // this指当前创建的实例,name指被装饰的方法名('m')
          // bind(this) 创建新函数,永久绑定this到当前实例,原始函数的功能不变,但this上下文固定
          // 将绑定后的新函数赋值回实例属性
            this[name] = this[name].bind(this);
          });
        }
      
        class C {
          message = "hello!";
      
          @bound
          m() { // 2. 装饰器应用于方法
            console.log(this.message);
          }
        }
      
        let { m } = new C();
      
        m(); // hello!
      
    • 问题背景:为什么要有这个@bound装饰器?为了解决this丢失问题

      • 当方法从实例中提取后调用,this 指向 undefined(严格模式下)
      • 需要手动绑定:instance.m.bind(instance)
         class C {
           message = "hello!";
           
           m() {
             console.log(this.message);
           }
         }
        
         const instance = new C();
         const extractedMethod = instance.m;
        
         extractedMethod(); 
         // 错误:Cannot read property 'message' of undefined
        
    • 执行时序:

      • 类定义阶段:装饰器 @bound 被应用,装饰器函数接收到方法 m 和上下文信息,addInitializer 注册一个回调函数。

      • 实例创建阶段(new C()):

            // 伪代码表示实例化过程
            const instance = new C();
        
            // 执行所有通过 addInitializer 注册的回调
            for (const initializer of initializers) {
              initializer.call(instance); // this = 当前实例
            }
        
      • 回调函数执行:

            function () {
              this[name] = this[name].bind(this);
              // 等价于:
              // instance.m = instance.m.bind(instance);
            }
        
  • 语法脱糖(desugars):

        class C {
          constructor() {
            for (let initializer of initializersForM) {
              initializer.call(this);
            }
    
            this.message = "hello!";
          }
    
          m() {}
        }
    
        let initializersForM = []
    
        C.prototype.m = bound(
          C.prototype.m,
          {
            kind: "method",
            name: "m",
            static: false,
            private: false,
            addInitializer(fn) {
              initializersForM.push(fn);
            },
          }
        ) ?? C.prototype.m;
    

关于Access

  • 作用:上文中的例子都属于装饰器的替换初始化能力,还没看到装饰器的访问能力。下面的例子是依赖注入装饰器,通过元数据能力使用access向实例注入数据。可以通过装饰器向实例注入数据。

  • 用法示例:

        const INJECTIONS = new WeakMap(); // 存储每个类需要的依赖信息
    
        // 依赖注入创建器
        function createInjections() {
          const injections = [];
    
          // injectable 类装饰器,标记类为可注入的
          function injectable(Class) {
            // 将当前收集的注入点保存到INJECTIONS
            INJECTIONS.set(Class, injections);
          }
    
          // inject 属性装饰器工厂,标记需要注入的属性
          function inject(injectionKey) {
            // 返回装饰器函数
            return function applyInjection(v, context) {
              // 收集注入点信息,其中context.access.set是Stage3装饰器中提供的属性设置器
              injections.push({ injectionKey, set: context.access.set });
            };
          }
    
          return { injectable, inject };
        }
    
    
        // 依赖容器
        class Container {
          registry = new Map();
         
          // 注册依赖
          register(injectionKey, value) {
            this.registry.set(injectionKey, value);
          }
          
          // 查找依赖
          lookup(injectionKey) {
            return this.registry.get(injectionKey);
          }
    
          // 创建类实例并注入依赖
          create(Class) {
            let instance = new Class(); // 1. 创建实例
            
            // 2. 获取类的注入点配置
            const classInjections = INJECTIONS.get(Class) || [];
            
            // 3. 执行依赖注入
            for (const { injectionKey, set } of classInjections) {
              set.call(instance, this.lookup(injectionKey)); // 查找依赖、注入依赖
            }
    
            return instance;
          }
        }
    
        class Store {}
    
        const { injectable, inject } = createInjections();
    
        @injectable
        class C {
          @inject('store') store;
        }
    
        let container = new Container();
        let store = new Store();
    
        container.register('store', store);
    
        let c = container.create(C);
    
        c.store === store; // true
    
    • 以上代码实现了一个简单的依赖注入容器,利用装饰器来标记需要注入的类和属性,然后在创建实例时完成依赖注入。它使用了 WeakMap 来存储类的元数据(需要注入的属性信息),避免内存泄漏。整个流程如下:
      • 使用 @injectable 标记类(在装饰器执行时,将该类需要注入的属性信息存入 INJECTIONS)。

      • 使用 @inject(key) 标记需要注入的属性(在装饰器执行时,将属性信息收集到闭包数组 injections 中,然后被 injectable 装饰器存入 INJECTIONS)。

      • 容器 Container 负责注册依赖项,并在创建实例时查找依赖并设置到实例上。这种模式在需要解耦和测试的场景中非常有用,例如将服务注入到组件中。

相关链接