理解javascript装饰器

1,357 阅读9分钟

不久前,我开发了一个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>

浏览器中显示如下:

总结

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

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

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

原文