logo

深入了解ECMAScript装饰器

223
2023年11月07日
ECMAScript装饰器的历史、实现、装饰器模式实际应用案例,以及该提案的后续发展。

2015年,ECMAScript 6被引入——这是JavaScript语言的一个重大版本发布。此次发布引入了许多新特性,比如const/let、箭头函数、类等。这些特性大多旨在消除JavaScript的怪癖。因此,所有这些特性都被标记为“Harmony”。一些消息来源称整个ECMAScript 6被称为“ECMAScript Harmony”。除了这些特性,“Harmony”标签还突出了其他预计很快成为规范一部分的特性。装饰器就是其中一种期待的特性。

自从第一次提到装饰器以来已经过去了近十年。装饰器的规范已经被多次从头开始重写,但它们尚未成为规范的一部分。随着JavaScript早已不再局限于基于浏览器的应用程序,规范的作者必须考虑JavaScript可以执行的广泛平台范围。这正是为什么这个提案进入第3阶段花费了这么长时间的原因。

全新的东西?

首先,让我们澄清一下编程世界中装饰器是什么。

装饰器是一种结构型设计模式,它允许你通过将对象放入包含行为的特殊包装对象中来附加新的行为。”
© Refactoring.Guru

关键点在于装饰器是一种设计模式。这意味着,通常情况下,它可以在任何编程语言中实现。如果你对JavaScript有基本的了解,很可能你已经在不知不觉中使用了这种模式。

听起来有趣吗?那么,试着猜猜世界上最流行的装饰器是什么…… 让我们来见识一下世界上最著名的装饰器,高阶函数——debounce

Debounce

在深入讨论debounce函数的细节之前,让我们先回顾一下高阶函数是什么。高阶函数是指接受一个或多个函数作为参数或将函数作为结果返回的函数。debounce函数是高阶函数的一个显著例子,同时也是JS开发人员最常用的装饰器。

高阶函数 debounce 延迟调用另一个函数,直到自上次调用以来已经过了一定时间,而不改变其行为。最常见的用例是在用户输入数值到搜索栏时防止向服务器发送多个请求,比如加载自动完成建议。它会等到用户完成或暂停输入,然后才向服务器发送请求。

在学习JavaScript语言的大多数资源中,在关于超时的部分,你会找到涉及编写这个函数的练习。最简单的实现看起来是这样的:

const debounce = (fn, delay) => {
  let lastTimeout = null

  return (...args) => {
    clearInterval(lastTimeout)

    lastTimeout = setTimeout(() => fn.call(null, ...args), delay)
  }
}

使用这个函数可能看起来像这样:

class SearchForm {
  constructor() {
    this.handleUserInput = debounce(this.handleUserInput, 300)
  }

  handleUserInput(evt) {
    console.log(evt.target.value)
  }
}

当使用装饰器的特殊语法时,实现相同行为的代码将如下所示:

class SearchForm {
  @debounce(300)
  handleUserInput(evt) {
    console.log(evt.target.value)
  }
}

所有样板代码都消失了,只留下了必要的部分。看起来很简洁清晰,不是吗?

高阶组件(HOC)

下一个例子将来自React世界。尽管在使用这个库构建的应用程序中,使用高阶组件(HOC)的情况越来越少,但HOC仍然是装饰器使用的一个很好且广为人知的例子。

让我们看一个withModal HOC的例子:

const withModal = (Component) => {
  return (props) => {
    const [isOpen, setIsOpen] = useState(false)

    const handleModalVisibilityToggle = () => setIsOpen(!isOpen)

    return (
      <Component
        {...props}
        isOpen={isOpen}
        onModalVisibilityToggle={handleModalVisibilityToggle}
      />
    )
  }
}

现在,让我们看看它如何被使用:

const AuthPopup = ({ onModalVisibilityToggle }) => {
  // 组件
}

const WrappedAuthPopup = withModal(AuthPopup)

export { WrappedAuthPopup as AuthPopup }

使用特殊的装饰器语法,使用HOC将会是这样的:

@withModal()
const AuthPopup = ({ onModalVisibilityToggle }) => {
  // 组件
}

export { AuthPopup }

重要提示: *函数装饰器不是当前提案的一部分。然而,它们在未来装饰器规范的发展中是可以考虑的事项。*再一次,所有的样板代码都消失了,只留下了真正重要的部分。也许一些读者在上面的例子中没有看到任何特别之处。在上面的例子中,只使用了一个装饰器。让我们看一个这样的例子:

const AuthPopup = ({
  onSubmit,
  onFocusTrapInit,
  onModalVisibilityToggle,
}) => {
  // 组件
}

const WrappedAuthPopup = withForm(
  withFocusTrap(
    withModal(AuthPopup)
  ), {
  mode: 'submit',
})

export { WrappedAuthPopup as AuthPopup }

看到那些难以阅读的嵌套了吗?你花了多少时间来理解代码中发生了什么?现在,让我们看看相同的例子,但使用装饰器语法:

@withForm({ mode: 'submit' })
@withFocusTrap()  
@withModal()
const AuthPopup = ({
  onSubmit,
  onFocusTrapInit,
  onModalVisibilityToggle,
}) => {
  // 组件
}

export { AuthPopup }

你不同意从上到下的代码比之前的嵌套函数调用更易读吗?

debounce高阶函数和withModal高阶组件只是装饰器模式在日常生活中应用的几个例子。这种模式可以在我们经常使用的许多框架和库中找到,尽管我们许多人可能经常没有注意到它。试着分析一下你正在工作的项目,并寻找装饰器模式被应用的地方。你可能会发现不止一个这样的例子。

JavaScript实现

在深入讨论装饰器提案及其实现之前,我想让我们看一下这张图片:

Old browser

通过这张图片,我想提醒你JavaScript语言最初创建的主要目的。我不是那种喜欢抱怨说“哦,JavaScript只适合突出显示表单字段”的人。通常情况下,我称这样的人为“恐龙”。

JavaScript主要关注我们为其编写代码的最终用户。这是一个关键的观点,因为每当在JavaScript语言中引入新的东西时,比如类的实现与其他编程语言中不同,同样的抱怨者就会出现并开始哀叹事情并没有以用户友好的方式完成。相反,在JavaScript中,一切都是以最终用户为考量而设计的,这是其他任何编程语言都不能夸耀的。

今天,JavaScript不仅仅是一个浏览器语言。它可以在各种环境中运行,包括服务器端。负责向语言引入新特性的TC39委员会面临着满足所有平台、框架和库需求的艰巨任务。然而,主要关注点仍然是浏览器中的最终用户。

装饰器的历史

为了更深入地了解这个提案的历史,让我们回顾一下关键事件的列表。

2014-04 – 阶段0: 装饰器是由Yehuda Katz提出的,最初打算成为ECMAScript 7的一部分。

type Decorator = (
  target: DecoratedClass,
  propertyKey: string,
  descriptor: PropertyDescriptor
) => PropertyDescriptor | void

function debounce(delay: number): PropertyDescriptor {
  return (target, propertyKey, descriptor) => {
    let lastTimeout: number
    const method = descriptor.value

    descriptor.value = (...args: unknown[]) => {
      clearInterval(lastTimeout)

      lastTimeout = setTimeout(() => method.call(null, ...args), delay)
    }

    return descriptor
  }
}

在这个阶段,你已经可以看到为什么装饰器API后来经历了如此重大的变化的一个原因。装饰器的第一个参数是整个类,即使你只是装饰其中的一个成员。此外,假定开发人员可以改变这个类。JavaScript引擎总是努力尽可能地进行优化,在这种情况下,开发人员对整个类的改变破坏了引擎提供的大量优化。后面我们会看到,这确实是装饰器API被多次从头开始重写的一个主要原因。

2015-03 – 阶段1 : 在没有重大变化的情况下,该提案进入了阶段2。然而,发生了一件事件,这件事件对该提案的进一步发展产生了重大影响:TypeScript 1.5发布了,它支持了装饰器。尽管装饰器被标记为实验性的(--experimentalDecorators),像Angular和MobX这样的项目积极开始使用它们。此外,这些项目的整体工作流程假定了专门使用装饰器。由于这些项目的流行,许多开发人员错误地认为装饰器已经成为官方JS标准的一部分。

这给TC39委员会带来了额外的挑战,因为他们不得不考虑开发人员社区的期望和要求,以及语言引擎中的优化问题。

2016-07 – 阶段2: 在装饰器提案达到阶段2后,其API开始经历重大变化。此外,在某个时刻,该提案被称为“JavaScript的ESnext类特性”。在其开发过程中,有许多关于如何构建装饰器的想法。为了全面了解所有变化的历史,我建议查看该提案存储库中的提交。以下是装饰器API曾经的样子:

type Decorator = (args: {
  kind: 'method' | 'property' | 'field',
  key: string | symbol,
  isStatic: boolean,
  descriptor: PropertyDescriptor
}) => {
  kind: 'method' | 'property' | 'field',
  key: string | symbol,
  isStatic: boolean,
  descriptor: PropertyDescriptor,
  extras: unknown[]
}

到了阶段2的末尾,装饰器API的样子如下:

type Decorator = (
  value: DecoratedValue,
  context: {
    kind: 'class' | 'method' | 'getter' | 'setter' | 'field' | 'accessor',
    name: string | symbol,
    access?: {
      get?: () => unknown,
      set?: (value: unknown) => void
    },
    private?: boolean,
    static?: boolean,
    addInitializer?: (initializer: () => void) => void
  }
) => UpdatedDecoratedValue | void

function debounce(delay: number): UpdatedDecoratedValue {
  return (value, context) => {
    let lastTimeout = null

    return (...args) => {
      clearInterval(lastTimeout)

      lastTimeout = setTimeout(() => value.call(null, ...args), delay)
    }
  }
}

整个第二阶段历时六年,期间装饰器API经历了重大变化。然而,正如我们从上面的代码中可以看到的那样,变异被排除在外。这使得该提案对JavaScript引擎以及各种平台、框架和库更加可接受。但是,装饰器的发展历史并没有结束。

2020-09 – 宣布 MobX 6. 再见,装饰器: 一些完全依赖装饰器的库开始摆脱旧的实现,因为它们意识到他们使用装饰器的方式将不再被标准化。

在 MobX 中不再使用装饰器不再是常态。这对一些人来说是个好消息,但其他人会讨厌它。这是理所当然的,因为我认为装饰器的声明性语法仍然是最好的。当 MobX 开始时,它是一个仅支持 TypeScript 的项目,所以装饰器是可用的。虽然还是实验性的,但显然它们很快就会被标准化。至少这是我的期望(之前我主要做 Java 和 C#)。然而,那个时刻还没有到来,与此同时两个装饰器提案已经被取消。尽管它们仍然可以被转译。
© Michel Weststrate,MobX 作者

2022-03 – 第三阶段 : 经过多年的变化和完善,装饰器终于达到了第三阶段。在第二阶段进行了广泛的调整和完善后,第三阶段开始时并没有发生重大变化。一个特别的亮点是创建了一个名为 Decorator Metadata 的新提案。

2022-08 – SpiderMonkey Newsletter : Firefox 使用的浏览器引擎 SpiderMonkey 成为第一个开始实现装饰器的引擎。这样的实现表明该提案已经基本准备好成为规范的一个完整部分。

2022-09 – Babel 7.19.0. 第三阶段装饰器: 在编译器中添加对提案的支持是一个非常重要的更新。大多数提案在其标准化计划中都有类似的条目,而装饰器提案也不例外。

2022-11 – 宣布 TypeScript 4.9: ECMAScript 装饰器被列入了 TS 4.9 迭代计划。然而,一段时间后,TS 团队决定将装饰器移至 5.0 版本。以下是 作者的评论:

虽然装饰器已经达到了第三阶段,但我们发现了一些需要与冠军讨论的规范行为。在解决这些问题并审查更改之间,我们预计装饰器将在下一个版本中实现。

总的来说,这个决定是有道理的,因为他们不想冒险过早地将一个特性纳入 TS,特别是如果它没有成为标准的一部分。这种情况总是有可能发生。然而,在这种情况下,它可能并不像第一次实现那样重要。

在 TS 4.9 中,只有装饰器规范的一小部分被包含在内 —— 类自动访问器。装饰器规范的这一补充是为了纠正在实现的最初阶段普遍存在的变异。其背后的原因是通常希望使属性具有响应性,这意味着在属性更改时应该发生一些效果,比如 UI 重新渲染,例如:

class Dashboard extends HTMLElement {
  @reactive
  tab = DashboardTab.USERS
}

在旧的实现中,使用 reactive 装饰器,您必须通过添加额外的 setget 访问器来变异 target 类以实现所需的行为。通过使用自动访问器,这种行为现在更加明确,从而使引擎能够更好地对其进行优化。

class Dashboard extends HTMLElement {
  @reactive
  accessor tab = DashboardTab.USERS
}

另一个有趣的事情是装饰器应该如何工作。由于 TS 团队无法移除在 --experimentalDecorators 标志下工作的旧实现,他们决定采取以下方法:如果配置中存在 --experimentalDecorators 标志,则将使用旧实现。如果不存在该标志,则将使用新实现。

2023-03 – 宣布 TypeScript 5.0: 正如承诺的那样,TS 团队在 TS 5.0 中发布了装饰器规范的完整版本。

2023-03 – Deno 1.32: 尽管在版本 1.32 中,Deno 支持了 TS 5.0,但他们决定推迟与装饰器相关的功能。

请注意,ES 装饰器目前尚不受支持,但我们将努力在未来版本中启用它们。

2023-05 – Angular v16 来了: Angular 16 也为 ECMAScript 装饰器添加了支持。然而,一些围绕装饰器构建的其他框架(并且受到 Angular 启发的?)表示他们暂时不会对 ECMAScript 装饰器进行更改。对于他们中的许多人来说,两个重要方面 是元数据和参数装饰器。

我认为在元数据支持和参数装饰器实现之前,我们不会支持 JS 装饰器。
© Kamil Mysliwiec,NextJS 的创始人

2023-08 – 宣布 TypeScript 5.2: 在 TS 5.2 中,另一个标准被添加,以补充装饰器规范 —— 装饰器元数据。这个提案的主要想法是简化装饰器对其所使用的类元数据的访问。关于语法和用法有这么多争论的另一个原因是作者们不得不为此目的创建一个全新的提案。

仅仅是语法糖吗?

在所有的解释和示例之后,你可能会有一个问题:“那么,JavaScript 中的装饰器只是具有特殊语法的高阶函数,就是这样吗?”

事情并不是那么简单。除了之前提到的 JavaScript 主要关注最终用户之外,还值得补充的是,JS 引擎总是试图使用新的语法作为至少 尝试 使您的 JavaScript 更快的参考点。

import { groupBy } from 'npm:lodash@4.17.21'

const getGroupedOffersByCity = (offers) => {
  return groupBy(offers, (it) => it.city.name)
}

// 还是?

const getGroupedOffersByCity = (offers) => {
  return Object.groupBy(offers, (it) => it.city.name)
}

看起来似乎没有区别,但对于引擎来说是有区别的。只有在第二种情况下,当使用原生函数时,引擎才能 尝试 进行优化。

描述 JavaScript 引擎中优化的工作将需要另一篇文章。不要犹豫去探索浏览器源代码或搜索文章,以更好地理解这个主题。

还要记住,有许多 JavaScript 引擎,它们都以不同的方式执行优化。然而,如果您通过使用原生语法来帮助引擎,您的 应用 代码通常在大多数情况下都会运行得更快。

可能的扩展

规范中的新语法也为未来的其他功能打开了大门。类似地,考虑构造函数和类。当私有字段被引入规范时,它们被作为类的一个特性引入。对于那些坚决否认类的有用性并声称构造函数是等效的人来说,私有字段成为了另一个放弃构造函数而转向类的理由。这样的功能很可能会继续发展。

虽然我们目前可以在许多情况下使用高阶函数实现与装饰器相同的效果,但它们仍然无法涵盖装饰器规范未来可能添加的所有潜在功能。

装饰器规范存储库中的 “可能的扩展” 文件提供了装饰器规范可能在未来如何发展的见解。一些点在最初阶段被列出,但在当前标准中并不存在,比如参数装饰器。然而,也提到了全新的概念,比如 const/let 装饰器或块装饰器。这些潜在的扩展展示了 JavaScript 中装饰器功能的持续发展和扩展。

事实上,有许多提案和扩展正在考虑进一步增强装饰器规范。其中一些提案,比如 装饰器元数据,甚至在核心装饰器规范尚未标准化的情况下已经在考虑中。这强调了装饰器在规范中有着光明的未来,并且我们可以希望在不久的将来看到它们成为标准的一部分。

结论

在十多年的时间里对装饰器提案的深入考虑可能看起来确实是一个漫长的过程。的确,早期领先的框架和库对装饰器的早期采用在揭示初始实现的缺点方面发挥了作用。然而,这种早期采用也作为宝贵的学习经验,突显了与 Web 平台协调和开发与平台和开发者社区保持一致的解决方案的重要性,同时保留了装饰器的本质。对提案的完善所花费的时间最终有助于使其成为 JavaScript 语言更加健壮和经过深思熟虑的补充。

的确,装饰器将会给我们今天编写应用程序的方式带来重大变化。也许不会立即,因为当前的规范主要集中在类上,但随着所有的 补充和持续的工作,许多应用程序中的 JavaScript 代码很快将会有所不同。我们现在比以往任何时候都更接近终于看到那些 真正的 装饰器成为规范的一部分的时刻。这是一个令人兴奋的发展,承诺增强 JavaScript 应用程序的表现力和功能。

本文链接:https://www.iokks.com/art/a4332ee4713f
本博客所有文章除特别声明外,均采用CC BY 4.0 CN协议 许可协议。转载请注明出处!