了解JavaScript装饰器

原文地址:https://www.simplethread.com/understanding-js-decorators/
原文作者:Mike Green
声明:本翻译仅做学习交流使用,转载请注明来源

不久之前,我创建了一个使用 MobX 做状态管理的 React 应用。我发现 MobX 开发的一项独特功能特别有趣,那就是它使用装饰器来注释类的属性。 之前我并没有真正在 JavaScript 中使用过它们,但是在使用 MobX 提供的功能并编写了一些自己的功能之后,我认为它们是具有巨大潜力的功能。

装饰器还不是 JavaScript 的核心功能; 他们正在通过 ECMA TC39 的标准化流程进行工作。 不过,这并不意味着我们无法熟悉它们。 在不久的将来,Node 和浏览器将为它们提供原生支持,现在我们可以通过 Babel 使用装饰器。

什么是装饰器?

装饰器是「装饰器函数」(或方法)的简写。 该函数通过返回新函数来修改传递给它的函数或方法的行为。

你可以使用任何支持函数为头等公民的语言来实现装饰器,例如 JavaScript,你可以在其中将函数绑定到变量或将其作为参数传递给另一个函数。 其中的几种语言具有用于定义和使用装饰器的特殊语法糖。 其中之一是 Python:

def cashify(fn):
    def wrap():
        print("$$$$")
        fn()
        print("$$$$")
    return wrap

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

sayHello()

# $$$$
# hello!
# $$$$

让我们看看那里发生了什么。 我们的 cashify 函数是一个装饰器:它接收一个函数作为参数,并且其返回值也是一个函数。 我们使用 Python 的「pie语法」将装饰器应用于我们的 sayHello 函数,这与在 sayHello 定义下执行此操作本质上是相同的:

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

sayHello = cashify(sayHello)

最终结果是,我们在装饰功能之前或之后的任何时候打印美元符号。

为什么要使用 Python 中的示例介绍 ECMAScript 装饰器? 我很高兴你问!

  • Python 是解释基础知识的好方法,因为其装饰器的概念比其在 JS 中的工作方式更简单。
  • JS 和 TypeScript 都使用 Python 的「pie 语法」,并且把装饰器应用于类的方法和属性,因此在外观和语法上都相似。

好的,JS 装饰器有什么不同?

JS装饰器和属性描述符

尽管将 Python 装饰器传递给他们装饰的任何函数作为参数,但 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值,包括一个函数,它将使属性成为其所属对象的方法。

属性描述符还可以具有其他两个属性,这些属性使 JS 将其视为「访问器描述符」(通常称为 getter 和 setter ):

  • get 是一个返回属性值而不使用静态值属性的函数。
  • set 是一个特殊函数,当您为属性分配值时,该函数会传递您在等号右侧放置的任何内容作为参数。

没有装饰器之前的做法

自 ES5 以来,JS 实际上已经有了一个用于处理属性描述符的 API,其形式为 Object.getOwnPropertyDescriptor 和 Object.defineProperty 函数。 例如,如果我喜欢燕麦片的厚度,可以使用该 API 将其设为只读,如下所示:

Object.defineProperty(oatmeal, 'viscosity', {
  writable: false,
  value: 20,
});

// When I try to set oatmeal.viscosity to a different value, it'll just silently fail.
oatmeal.viscosity = 30;
console.log(oatmeal.viscosity);
// => 20

我甚至可以编写一个通用的装饰函数,让我篡改任何对象的任何属性的描述符:

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;
});

对 class 使用装饰器

Decorators 提案与 Python 装饰器的第一个主要区别在于,它只涉及 ECMAScript class,而不涉及常规对象。 首先让我们创建一个类代表我们的一碗粥:

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;
  }
}

我们使用继承自更通用的粥类的 class 来代表我们的一碗燕麦片。 燕麦将默认粘度设置为高于粥的默认粘度,并添加了新的风味属性。 我们还使用了另一个 ECMAScript 提案,即类字段,以覆盖粘度值。

我们可以像这样重新创建一碗燕麦粥:

const oatmeal = new Oatmeal('Brown Sugar Cinnamon');

/*
Oatmeal {
  flavor: 'Brown Sugar Cinnamon',
  viscosity: 20
}
*/

太好了,我们已经准备好了 ES6 燕麦粥,并且可以编写装饰器了!

如何写一个装饰器

JS 装饰器函数传递了三个参数:

  1. target 是我们的对象是其实例的类。
  2. key 是我们要应用装饰器的属性名称(以字符串形式)。
  3. descriptor 是该属性的描述符对象。

我们在装饰器函数内部执行的操作取决于装饰器的目的。 为了修饰对象的方法或属性,我们需要返回一个新的属性描述符。 我们可以通过以下方式编写将属性设为只读的装饰器:

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

我们可以通过如下方式修改燕麦类来使用它:

class Oatmeal extends Porridge {
  @readOnly viscosity = 20;
  // (you can also put @readOnly on the line above the property)

  constructor(flavor) {
    super();
    this.flavor = flavor;
  }
}

现在,我们的燕麦片像胶水一样的稠度可以不受篡改。 谢天谢地。

如果我们想做一些实际有用的事情怎么办? 我在处理一个最近的项目时遇到了一种情况,其中装饰器为我节省了很多打字和维护开销:

处理API错误

在开始时提到的 MobX / React 应用程序中,我有几个不同的类充当数据 store。 它们各自表示用户与之交互的不同事物的集合,并且各自与不同的 API 端点对话以获取来自服务器的数据。 为了处理 API 错误,我通过网络进行通信时使每个 store 都遵循一个协议:

  1. 将用户界面商店的 networkStatus 属性设置为 「loading」。
  2. 向 API 发送请求
  3. 处理结果
    • 如果成功,则使用响应数据更新本地状态
    • 如果出现问题,请将 UI store 的 apiError 属性设置为我们收到的错误
  4. 将 UI store 的 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;
  }
}

我的错误处理代码仍然存在,但是现在我只需要编写一次,并确保使用它的每个类都具有 setNetworkStatus 和 setApiError 方法。

Babel 替换方案

那我在 descriptor.value 和调用 descriptor.initializer 之间进行选择的那个呢? 这是 Babel 要处理的。 我的直觉是,当 JS 原生支持装饰器时,它不会以这种方式工作,但是为了解决 Babel 如何处理定义为类属性的箭头函数,因此现在有必要。

当你定义一个类属性并为它的值分配一个箭头函数时,Babel 会做一些技巧来将该函数绑定到该类的正确实例上,并为您提供正确的该值。 它通过将 descriptor.initializer 设置为一个函数来实现此目的,该函数返回您编写的函数,并在其范围内使用正确的此值。

一个例子可以使事情变得不那么混乱:

class Example {
  @myDecorator
  someMethod() {
    // 在这种情况下,我们的方法将由descriptor.value引用
  }

  @myDecorator
  boundMethod = () => {
    // 在这里,descriptor.initializer将是一个函数,调用该函数时,它将返回我们的「 boundMethod」函数,该函数的范围已适当调整,以便“ this」引用Example的当前实例。
  };
}

装饰类

除了属性和方法,您还可以装饰整个类。 为此,您实际上只需要将第一个参数传递给装饰器函数 target。 例如,我可以编写一个装饰器,将该装饰器自动包装的类注册为自定义 HTML 元素。 我在这里使用闭包,以使装饰器能够接收我们想要为元素提供参数的任何名称:

function customElement(name) {
  return function(target) {
    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>

在浏览器中的效果如下:

screen

兼容

今天在您的项目中使用装饰器需要一些编译器配置。我见过的最直接的指引在 MobX 文档中。它包含有关 TypeScript 和 Babel 的两个主要版本的信息。

请记住,装饰器是目前不断发展的提案,因此,如果您现在在生产代码中使用装饰器,则一旦它们成为 ECMAScript 规范的正式特性,您可能需要进行一些更新或继续使用 Babel 的装饰器插件。尽管 Babel 尚未对其提供很好的支持,但是最新版本的装饰器提案已经包含了与以前版本不兼容的重大更改。

像许多最新的 JS 功能一样,装饰器是有用的工具。它们可以大大简化跨不相关的类之间的行为共享。但是,早期采用总是有一定的成本。使用装饰器,但使用装饰器时应清楚其对代码库的影响。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值