美文网首页
理解javascript装饰器

理解javascript装饰器

作者: 漓漾li | 来源:发表于2020-01-15 18:06 被阅读0次
    image

    不久前,我开发了一个react应用,使用mobx做状态管理。这是一个时而兴奋时而困惑,但总体而言很享受的经历,很快我将会把它写出来。在使用mobx开发时,我发现了一个非常有趣的独特之处,那就是它使用装饰器来注释类的属性。我之前在写javascript时还没用过它,但自从我使用了mobx提供的这个功能以及做了一些开发后,我发现这是一个有巨大潜力的功能。

    装饰器现在还不是javascript的核心特性,他们正通过ECMATC39的标准化流程进行工作。不过并不代表我们不能去熟悉它。
    在不久的将来,它将得到浏览器和node的原生支持,与此同时,babel也得到支持。

    什么是装饰器

    Decoratordecorator function/methored的缩写。它是一个函数,它会通过返回一个新函数来修改传入的函数或方法的行为。

    你可以在函数式编程的任何语言中实现装饰器,比如javascript,你可以把函数绑定到一个变量上,也可以把函数当成函数的参数传递。这些语言中的几种有特殊的语法糖,用来定义和使用装饰器,其中一个就是python

    def cashify(fn):
        def wrap():
            print("$$$$")
            fn()
            print("$$$$")
        return wrap
    
    @cashify
    def sayHello():
        print("hello!")
    
    sayHello()
    
    # $$$$
    # hello!
    # $$$$
    

    让我们看看发生了什么,cashify函数是一个装饰器,他接受一个函数作为参数,它的返回值也是函数。我们使用pythonpie syntax把装饰器应用到sayHello函数上,本质上和我们在sayHello的定义下执行此操作是一样的:

    def sayHello():
        print("hello!")
    
    sayHello = cashify(sayHello)
    

    无论我们装饰的函数打印什么,最后的结果都会在他们前后打印$符号。

    为什么我要使用python的例子来介绍ECMAScript的装饰器,很高兴你问这个问题!

    • python是一个很好地方式去解释基础知识,因为它的装饰器的概念比它在JS中的工作方式更简单直接
    • jsTS都是用pythonpie syntax把装饰器应用到类的函数和属性上,所以它们外观和语法格式都很相似

    好了,那么js装饰器有什么不同呢?

    JS 装饰器和属性描述符

    python把传入的需要装饰的任何函数当做参数,但因为对象在js中的特殊工作方式,js装饰器可以获取到更多信息。

    对象在js中有属性,并且这些属性有以下值:

    const oatmeal = {
      viscosity: 20,
      flavor: 'Brown Sugar Cinnamon',
    };
    

    但除了它的值,每个属性还有一些其他隐藏的信息,用于定义它工作方式的不同方面,叫做属性描述符:

    console.log(Object.getOwnPropertyDescriptor(oatmeal, 'viscosity'));
    
    /*
    {
      configurable: true,
      enumerable: true,
      value: 20,
      writable: true
    }
    */
    

    JS在追踪与这个属性有关的很多东西:

    • configurable 决定该属性的类型能否被修改,以及它能否从对象中删除
    • enumerable 控制当你在枚举对象属性时,该属性是否显示(比如当你调用Object.keys(oatmeal)或者使用for循环时)
    • writable 控制你是否可以通过赋值操作符=修改该属性的值
    • value 是你访问这个属性时,所看到的静态值。通常,这是你经常看到和关心的属性描述符的唯一部分。它可以是任何JS值,包括一个函数,这会使这个属性成为其所属对象的方法。

    属性描述符也有两个其他的属性,为访问器描述符(通常称为gettersetter):

    • get 是一个返回属性值而不是用静态value属性的的函数
    • set 是一个特殊的函数,当你给这个属性赋值时,该函数会将你在等号右边放置的任何内容作为参数

    没有多余的装饰

    jses5就已经有了操作属性描述符的API,通过Object.getOwnPropertyDescriptorObject.defineProperty的形式。比如我喜欢我的燕麦片的浓度,我可以使用这个API像下边这样把它变成只读的:

    Object.defineProperty(oatmeal, 'viscosity', {
      writable: false,
      value: 20,
    });
    
    // 当我试图设置oatmeal.viscosity为不同的值时,它将会默默地报错
    oatmeal.viscosity = 30;
    console.log(oatmeal.viscosity);
    // => 20
    

    我甚至可以写一个通用的decorate函数,可以修改任何对象的任何属性的修饰符

    function decorate(obj, property, callback) {
      var descriptor = Object.getOwnPropertyDescriptor(obj, property);
      Object.defineProperty(obj, property, callback(descriptor));
    }
    
    decorate(oatmeal, 'viscosity', function(desc) {
      desc.configurable = false;
      desc.writable = false;
      desc.value = 20;
      return desc;
    });
    

    Adding the Shiplap and Crown Molding(巴拉巴拉...)

    第一个主要的装饰器的提案只与ES的类有关,而非普通对象。让我们设计一些类来代表我们的粥:

    class Porridge {
      constructor(viscosity = 10) {
        this.viscosity = viscosity;
      }
    
      stir() {
        if (this.viscosity > 15) {
          console.log('This is pretty thick stuff.');
        } else {
          console.log('Spoon goes round and round.');
        }
      }
    }
    
    class Oatmeal extends Porridge {
      viscosity = 20;
    
      constructor(flavor) {
        super();
        this.flavor = flavor;
      }
    }
    

    我们使用一个类来代表我们的燕麦粥,他继承自一个更通用的的 Porridge 类。Oatmeal设置了默认的浓度来覆盖Porridge的默认值,并且添加了新的口味属性。我们也使用了另一个es提案 class fields去覆盖浓度属性。
    我们可以重新创建我们原始的燕麦粥了:

    const oatmeal = new Oatmeal('Brown Sugar Cinnamon');
    
    /*
    Oatmeal {
      flavor: 'Brown Sugar Cinnamon',
      viscosity: 20
    }
    */
    

    很好,我们得到了我们的es6燕麦粥,我们要准备写装饰器了!

    如何去写一个装饰器

    js装饰器函数被传入三个参数:

    • target 是我们对象所继承的类
    • key 是我们应用装饰器的属性的名称,为字符串。
    • descriptor 是属性描述符对象

    我们在装饰器内做什么依赖于我们装饰器的目的。为了装饰对象的方法和属性,我们需要返回一个新的属性描述器。我们可以通过以下方式写一个装饰器来使一个属性为只读:

    function readOnly(target, key, descriptor) {
      return {
        ...descriptor,
        writable: false,
      };
    }
    

    我们可以像这样修改我们的oatmeal类:

    class Oatmeal extends Porridge {
      @readOnly viscosity = 20;
      // 你也可以吧@readonly放在属性上一行
    
      constructor(flavor) {
        super();
        this.flavor = flavor;
      }
    }
    

    现在我们燕麦粥像胶水一样的浓度不会被干预了,谢天谢地。
    如果我们想做一些真正有用的东西呢?我在最近的项目时遇到了一种情况,其中装饰器节省了我很多开发和维护的开销。

    处理API错误

    在我开头提到的Mobx/React app中,我有一些不同的类作为数据中心。他们各自都代表与用户交互的不同类别的集合,并且与不同的API端点对话以获取服务端的数据。为了处理API错误,我使每个数据中心在与网络通信时都准守一个协议:

    1. 设置ui中心的networkStatus属性为loading
    2. 发送api请求
    3. 处理结果
      • 如果成功,使用结果更新本地状态
      • 如果报错了,设置ui中心的apiError属性为接收到的错误
    4. 设置ui中心的networkStatus属性为idle

    我发现在我注意到之前,已经重复了很多次这种模式:

    class WidgetStore {
      async getWidget(id) {
        this.setNetworkStatus('loading');
    
        try {
          const { widget } = await api.getWidget(id);
          // Do something with the response to update local state:
          this.addWidget(widget);
        } catch (err) {
          this.setApiError(err);
        } finally {
          this.setNetworkStatus('idle');
        }
      }
    }
    

    这是很多错误处理的样板。因为我已经在所有更新可观察属性的方法上使用了MobX@action装饰器了(为了简单起见,此处未显示),所以也可以再添加一个装饰器用来节省我错误处理的代码。我想出了这个:

    function apiRequest(target, key, descriptor) {
      const apiAction = async function(...args) {
        // More about this line shortly:
        const original = descriptor.value || descriptor.initializer.call(this);
        
        this.setNetworkStatus('loading');
    
        try {
          const result = await original(...args);
          return result;
        } catch (e) {
          this.setApiError(e);
        } finally {
          this.setNetworkStatus('idle');
        }
      };
    
      return {
        ...descriptor,
        value: apiAction,
        initializer: undefined,
      };
    }
    

    然后我就可以像这样替换那些写在每个API操作方法上的模板:

    class WidgetStore {
      @apiRequest
      async getWidget(id) {
        const { widget } = await api.getWidget(id);
        this.addWidget(widget);
        return widget;
      }
    }
    

    我的错误处理代码依然在那,但是我只需要写一次,并且确保每个使用它的class都有setNetworkStatussetApiError方法即可。

    babel解决方案

    我选择descriptor.value和调用descriptor.initializer其中之一的那一行发生了什么?这是与babel相关的事。我的预感是,这种方式在js原生支持装饰器的时候不会起作用,但当考虑到babel处理作为类属性的箭头函数的方式时,就会很有必要。

    当你定义一个类属性,并且给它赋值一个箭头函数时,babel会巧妙地把函数绑定到类正确的实例上并且提供你正确的this值。通过设置descriptor.initializer为一个函数,它会返回你写的那个函数,并且在其作用域内为正确的this值。

    一个例子会让事情变简单:

    class Example {
      @myDecorator
      someMethod() {
        // 在这个例子中,我们的方法可以由descriptor.value引用到
      }
    
      @myDecorator
      boundMethod = () => {
        // 在这里,descriptor.initializer是一个函数,他会返回我们的boundMethod函数,并且this执行已经被调整为Example的实例
      };
    }
    

    装饰类

    除了属性和方法,你还可以装饰整个类。想要装饰类,你只需要传入装饰器函数的第一个参数target。比如,我想写一个自动把类注册为自定义html标签的装饰器,我在这里使用了一个闭包,来保证装饰器能够接收我们想要为标签提供参数的任何名称:

    function customElement(name) {
      return function(target) {
        // customElements是一个全局API,用来创建自定义标签
        customElements.define(name, target);
      };
    }
    

    我们将这样使用它:

    @customElement('intro-message');
    class IntroMessage extends HTMLElement {
      constructor() {
        super();
    
        const shadow = this.attachShadow({ mode: 'open' });
        this.wrapper = this.createElement('div', 'intro-message');
        this.header = this.createElement('h1', 'intro-message__title');
        this.content = this.createElement('div', 'intro-message__text');
        this.header.textContent = this.getAttribute('header');
        this.content.innerHTML = this.innerHTML;
    
        shadow.appendChild(this.wrapper);
        this.wrapper.appendChild(this.header);
        this.wrapper.appendChild(this.content);
      }
    
      createElement(tag, className) {
        const elem = document.createElement(tag);
        elem.classList.add(className);
        return elem;
      }
    }
    

    把它加入到我们的html中,可以这样使用它:

    <intro-message header="Welcome to Decorators">
      <p>Something something content...</p>
    </intro-message>
    

    浏览器中显示如下:

    image

    总结

    如今在你的项目中使用装饰器需要一些转译配置。我所见的最直接的教程就在MobX的文档中,它有TS和两个主要版本的babel信息。

    请记住装饰器当前还是发展中的提议,如果你在生产代码中使用它,你可能需要做一些更新或者持续使用babel装饰器插件,直到它成为ECMA官方的正式规范。甚至babel也没有很好地支持,最新版的装饰器提案包含很大的改动,并没有很好地向后兼容上一个版本。

    装饰器像很多最新的js特性一样,是你工具箱中很有用的工具,他很大程度的简化了不同和不相关的类的行为共享。然而过早的采用总需要一些成本。所以使用装饰器,也需要了解它对你代码库的影响。

    原文

    相关文章

      网友评论

          本文标题:理解javascript装饰器

          本文链接:https://www.haomeiwen.com/subject/wcztzctx.html