简要介绍JavaScript中的“装饰器”的提案的一些基础示例以及ECMAScript相关的内容
为什么用ECMAScript装饰器代替标题中的JavaScript装饰器? 因为ECMAScript是用于编写脚本语言(如JavaScript)的标准,所以它不强制JavaScript支持所有规范,但JavaScript引擎(由不同浏览器使用)可能支持或不支持由ECMAScript引入的功能,或者支持一些不同的行为。
将ECMAScript视为您所说的某种语言,例如英语。 那么JavaScript就像英式英语一样。 方言本身就是一种语言,但是它是基于它所源自的语言的原则而应运而生。 因此,ECMAScript是烹饪/书写JavaScript的“烹饪书”,由主厨/开发人员决定遵循或不遵守所有配料/规则。
通常而言,JavaScript采用者遵循用语言编写的所有规范(不然开发人员将会被逼疯),并在新版本的JavaScript引擎出现后,并且直到确保一切正常,才会发布它。 ECMA International的TC39或技术委员会39负责维护ECMAScript语言规范。 一般来说,该团队的成员是由ECMA International、浏览器供应商和对网络感兴趣的公司而组成。
由于ECMAScript是开放标准,任何人都可以提出新的想法或功能,并对其进行推动实行。 因此,一个新功能的提案会经历4个主要阶段,并且TC39会参与这个过程,直到该功能准备好施行。
阶段 | 名称 | 任务 |
---|---|---|
0 | strawman | 提出新功能(建议) 到TC39委员会。 一般由TC39成员或TC39撰稿人提供。 |
1 | proposal | 定义提案,依赖,挑战,示例,polyfills等使用用例。某个拥护者(TC39成员)将负责此提案。 |
2 | draft | 这是最终版本的草稿版本。 因此需要提供该功能的描述和语法。另外 例如Babel这样的语法编译器需要进行支持。 |
3 | candidate | 提案已经准备就绪,可以针对采用者和TC39委员会提出的关键问题做出一些修订。 |
4 | finished | 提案已经准备被纳入规范中 |
直到现在(2018年6月),装饰器处于第二阶段,我们做了一个Babel插件babel-plugin-transform-decorators-legacy
来转化装饰器功能。在第二阶段,功能的语法可能会改变,因此不建议在现在的生产项目中使用这个功能。无论如何,我觉得装饰器在快速达成目标上都是优雅的和有效的。
从现在开始,我们试验实验性质的JavaScript, 因此你的node.js的版本可能不支持这些功能。所以,我们会需要Babel或者TypeScript等语法编译器。使用插件来创建一个非常基本的项目,我在里面加了些东西来支持这片文章。
为了理解装饰器,我们需要首先理解什么是JavaScript对象属性的property descriptor。 property descriptor是一个对象属性的一组规则,例如属性是可写的还是可枚举的。 当我们创建一个简单的对象并添加一些属性时,每个属性都有默认的property descriptor。
var myObj = { myPropOne: 1, myPropTwo: 2};复制代码
myObj
是如下控制台所示的一个简单JavaScript对象。
现在,如果我们向下面的myPropOne属性写入新值,操作将会成功,我们将得到更改后的值。
myObj.myPropOne = 10;console.log( myObj.myPropOne ); //==> 10复制代码
要获取属性的property descriptor,我们需要使用Object.getOwnPropertyDescriptor(obj,propName)
方法。 这里的Own
表示仅当属性属于对象obj
而不属于原型链时才返回propName
属性的property descriptor。
let descriptor = Object.getOwnPropertyDescriptor( myObj, 'myPropOne');console.log( descriptor );复制代码
Object.getOwnPropertyDescriptor
方法返回一个具有描述属性权限和当前状态的键的对象。 value
是属性的当前值,writable
是用户是否可以为属性赋予新值,enumerable
是该属性是否会在如for in
循环或for of
循环或Object.keys
等枚举中显示。configurable
的是用户是否具有更改property descriptor
的权限,并对writable
和enumerable
进行更改。 property descriptor
也有get
和set
中间件函数来返回值或更新值的键,但这些是可选的。
要在对象上创建新属性或使用自定义descriptor
更新现有属性,我们使用Object.defineProperty
。 让我们修改一个现有属性myPropOne
,其中的writable
属性设置为false
,这会禁止写入myObj.myPropOne
。
'use strict';var myObj = { myPropOne: 1, myPropTwo: 2};// modify property descriptorObject.defineProperty( myObj, 'myPropOne', { writable: false} );// print property descriptorlet descriptor = Object.getOwnPropertyDescriptor( myObj, 'myPropOne');console.log( descriptor );// set new valuemyObj.myPropOne = 2;复制代码
从上面的错误可以看出,我们的属性myPropOne是不可写的,因此如果用户试图为其分配新值,它将抛出错误。
如果Object.defineProperty
正在更新现有property descriptor
,则原始的descriptor
将被新的修改覆盖。 更改之后,Object.defineProperty
返回原始对象myObj
。
下面再看一下如果enumerable
被设置成false后会发生什么?
var myObj = { myPropOne: 1, myPropTwo: 2};// modify property descriptorObject.defineProperty( myObj, 'myPropOne', { enumerable: false} );// print property descriptorlet descriptor = Object.getOwnPropertyDescriptor( myObj, 'myPropOne');console.log( descriptor );// print keysconsole.log( Object.keys( myObj ));复制代码
正如你看到的那样,在Object.keys
的枚举中,我们看不见myPropOne
这个属性了。
当你用Object.defineProperty
定义一个对象的新属性的时候,传递一个空的{}descriptor
,默认的descriptor
会看起来向下面的那样。
现在,让我们定义一个带有自定义descriptor
的新属性,其中configurable
设为false
,writable
保持为false
,enumerable
为true
,并将valu
设为3。
var myObj = { myPropOne: 1, myPropTwo: 2};// modify property descriptorObject.defineProperty( myObj, 'myPropThree', { value: 3, writable: false, configurable: false, enumerable: true} );// print property descriptorlet descriptor = Object.getOwnPropertyDescriptor( myObj, 'myPropThree');console.log( descriptor );// change property descriptorObject.defineProperty( myObj, 'myPropThree', { writable: true} );复制代码
通过将configurable
设置为false,我们失去了更改属性myPropThree
的descriptor
的能力。 如果不希望用户操纵对象的默认行为,这非常有用。
get(getter)和set(setter)属性也可以在property descriptor
中设置。 但是当你定义一个getter时,它会带来一些损失。 descriptor
上不能有初始值或值键,因为getter会返回该属性的值。 您也不能在descriptor
上使用writable
属性,因为您的写入是通过setter完成的,您可以在那里阻止写入。 可以看看相关和的MDN文档,或阅读,这里不多作赘诉。
您可以使用带有两个参数的Object.defineProperties
一次创建和/或更新多个属性。 第一个参数是属性被添加/修改的目标对象,第二个参数是属性名作为key
,值为property descriptor
的对象。 该函数返回第一个目标对象。
你有没有尝试过Object.create
函数来创建对象? 这是创建没有或自定义原型的对象的最简单方法。 它也是使用自定义property descriptor
从头开始创建对象的更简单的方法之一。 以下是Object.create
函数的语法。
var obj = Object.create( prototype, { property: descriptor, ... } )复制代码
这里的prototype
是一个对象,它将成为obj
的原型。 如果原型为null
,那么obj
将不会有任何原型。 当用var obj = {}
定义一个空或非空对象时,默认情况下,obj .__ proto__
指向Object.prototype
,因此obj
具有Object
类的原型。
这与使用Object.create
,用Object.prototype
作为第一个参数(正在创建的对象的原型)类似。
'use strict';var o = Object.create( Object.prototype, { a: { value: 1, writable: false }, b: { value: 2, writable: true }} );console.log( o.__proto__ );console.log( 'o.hasOwnProperty( "a" ) => ', o.hasOwnProperty( "a" ) );复制代码
但是当我们将原型设置为null时,我们会得到以下错误。
'use strict';var o = Object.create( null, { a: { value: 1, writable: false }, b: { value: 2, writable: true }} );console.log( o.__proto__ );console.log( 'o.hasOwnProperty( "a" ) => ', o.hasOwnProperty( "a" ) );复制代码
###Class Method Decorator 现在我们了解了如何定义和配置对象的新属性或现有属性,让我们将注意力转移到装饰器上,以及为什么我们讨论了property descriptor
。
Decorator
是一个JavaScript函数(推荐的纯函数),用于修改类属性/方法或类本身。 当您在类属性,方法或类本身的顶部添加@decoratorFunction
语法时,decoratorFunction
由一些参数来调用,我们可以使用它们修改类或类的属性。 让我们创建一个简单的readonly
装饰器功能。 但在此之前,让我们使用getFullName
方法创建简单的User
类,该方法通过组合firstName
和lastName
来返回用户的全名。
class User { constructor( firstname, lastName ) { this.firstname = firstname; this.lastName = lastName; } getFullName() { return this.firstname + ' ' + this.lastName; }}// create instancelet user = new User( 'John', 'Doe' );console.log( user.getFullName() );复制代码
上面的代码打印John Doe
到控制台。 但是存在巨大的问题,任何人都可以修改getFullName
方法。
User.prototype.getFullName = function() { return 'HACKED!';}复制代码
于是,现在我们得到了以下结果。
HACKED!复制代码
为了避免公共访问覆盖我们的任何方法,我们需要修改位于User.prototype
对象上的getFullName
方法的property descriptor
。
Object.defineProperty( User.prototype, 'getFullName', { writable: false} );复制代码
现在,如果任何用户尝试覆盖getFullName
方法,将会得到以下错误。
但是,如果我们在User类中有很多方法,那么手动执行这些操作就不会那么好。 这就是装饰者的由来。我们可以通过在下面的getFullName
方法的顶部放置@readonly
语法来实现同样的事情。
function readonly( target, property, descriptor ) { descriptor.writable = false; return descriptor;}class User { constructor( firstname, lastName ) { this.firstname = firstname; this.lastName = lastName; } @readonly getFullName() { return this.firstname + ' ' + this.lastName; }}User.prototype.getFullName = function() { return 'HACKED!';}复制代码
看看readonly
方法。 它接受三个参数。 property
是属于目标对象的属性/方法的名称(与User.prototype
相同),descriptor
是该属性的property descriptor
。 从装饰器功能中,我们必须不惜代价返回descriptor
。 这里的descriptor
将替换该属性的现有property descriptor
。
还有另一个版本的装饰器语法,就像@decoratorWrapperFunction(... customArgs)
一样。 但是在这个语法中,decoratorWrapperFunction
应该返回一个与之前示例中使用的相同的decoratorFunction
。
function log( logMessage ) { // return decorator function return function ( target, property, descriptor ) { // save original value, which is method (function) let originalMethod = descriptor.value; // replace method implementation descriptor.value = function( ...args ) { console.log( '[LOG]', logMessage ); // here, call original method // `this` points to the instance return originalMethod.call( this, ...args ); }; return descriptor; }}class User { constructor( firstname, lastName ) { this.firstname = firstname; this.lastName = lastName; } @log('calling getFullName method on User class') getFullName() { return this.firstname + ' ' + this.lastName; }}var user = new User( 'John', 'Doe' );console.log( user.getFullName() );复制代码
装饰者不区分静态和非静态方法。 下面的代码能执行得很好,唯一会改变的是你如何访问该方法。 这同样适用于我们将在下面看到的Instance Field Decorators
。
@log('calling getVersion static method of User class')static getVersion() { return 'v1.0.0';}console.log( User.getVersion() );复制代码
Class Instance Field Decorator
到目前为止,我们已经看到使用@decorator
或@decorator(.. args)
语法更改方法的property descriptor
,但是公共/私有属性(类实例字段)呢? 与typescript
或java
不同,JavaScript类没有如我们所知道的类实例字段类属性。 这是因为在类中和构造函数外定义的任何东西都应该属于类原型。 但是有一个新的方案使用公共和私人访问修饰符来启用类实例字段,现在已经进入阶段3,并且我们有对应的babel转换器插件。 让我们定义一个简单的User
类,但是这次我们不需要为构造函数中的firstName
和lastName
设置默认值。
class User { firstName = 'default_first_name'; lastName = 'default_last_name'; constructor( firstName, lastName ) { if( firstName ) this.firstName = firstName; if( lastName ) this.lastName = lastName; } getFullName() { return this.firstName + ' ' + this.lastName; }}var defaultUser = new User();console.log( '[defaultUser] ==> ', defaultUser );console.log( '[defaultUser.getFullName] ==> ', defaultUser.getFullName() );var user = new User( 'John', 'Doe' );console.log( '[user] ==> ', user );console.log( '[user.getFullName] ==> ', user.getFullName() );复制代码
现在,如果检查User
类的原型,将无法看到firstName
和lastName
属性。
类实例字段是面向对象编程(OOP)的非常有用和重要的部分。 我们有这样的提案是很好的,但“革命还尚未成功”啊各位。
与位于类原型的类方法不同,类实例字段位于对象/实例上。 由于类实例字段既不是类的一部分也不是它的原型,因此操作它的descriptor
并不简单。 Babel给我们的是类实例字段的property descriptor
上的初始化函数,而不是值键。 为什么初始化函数而不是值,这个主题是争论的,因为装饰器处于第2阶段,没有发布最终草案来概述这个,但你可以按照Stack Overflow上的这个答案来理解整个背景故事。
话虽如此,让我们修改我们的早期的示例并创建简单的@upperCase
修饰器,它将改变类实例字段的默认值的大小写。
function upperCase( target, name, descriptor ) { let initValue = descriptor.initializer(); descriptor.initializer = function(){ return initValue.toUpperCase(); } return descriptor;}class User { @upperCase firstName = 'default_first_name'; lastName = 'default_last_name'; constructor( firstName, lastName ) { if( firstName ) this.firstName = firstName; if( lastName ) this.lastName = lastName; } getFullName() { return this.firstName + ' ' + this.lastName; }}console.log( new User() );复制代码
我们也可以使用装饰器函数和参数来使其更具可定制性。
function toCase( CASE = 'lower' ) { return function ( target, name, descriptor ) { let initValue = descriptor.initializer(); descriptor.initializer = function(){ return ( CASE == 'lower' ) ? initValue.toLowerCase() : initValue.toUpperCase(); } return descriptor; }}class User { @toCase( 'upper' ) firstName = 'default_first_name'; lastName = 'default_last_name'; constructor( firstName, lastName ) { if( firstName ) this.firstName = firstName; if( lastName ) this.lastName = lastName; } getFullName() { return this.firstName + ' ' + this.lastName; }}console.log( new User() );复制代码
descriptor.initializer
函数由Babel内部使用来创建对象属性的property descriptor
的值。 该函数返回分配给类实例字段的初始值。 在装饰器内部,我们需要返回另一个返回最终值的初始化函数。
类实例字段提案具有高度的实验性,并且直到它进入第4阶段之前很有可能它的语法可能会发生变化。 因此,将类实例字段与装饰器一起使用并不是一个好习惯。
Class Decorator
现在我们熟悉装饰者可以做什么。 它们可以改变类方法和类实例字段的属性和行为,使我们可以灵活地使用更简单的语法动态实现这些内容。
类装饰器与我们之前看到的装饰器略有不同。 之前,我们使用property descriptor
来修改属性或方法的行为,但在类装饰器的情况下,我们需要返回一个构造函数。
让我们来了解一下构造函数是什么。 在下面,JavaScript类只不过是一个函数,用于添加原型方法并为字段定义一些初始值。
function User( firstName, lastName ) { this.firstName = firstName; this.lastName = lastName;}User.prototype.getFullName = function() { return this.firstName + ' ' + this.lastName;}let user = new User( 'John', 'Doe' );console.log( user );console.log( user.__proto__ );console.log( user.getFullName() );复制代码
这里有一篇很棒的文章,用JavaScript来理解这一点。
所以当我们调用new User
时,User
函数是通过我们传递的参数来调用的,结果我们得到了一个对象。 因此,User
是一个构造函数。 顺便说一句,JavaScript中的每个函数都是构造函数,因为如果你检查function.prototype
,你将获得构造函数属性。 只要我们在函数中使用new
的关键字,我们应该期待得到一个对象的返回结果。
如果从构造函数返回有效的JavaScript对象,则将使用该值而不是使this
分配创建的新对象。 这将打破原型链,因为重新调整的对象将不具有构造函数的任何原型方法。 考虑到这一点,让我们关注类装饰器可以做什么。 类装饰器必须位于类的顶部,就像之前我们在方法名称或字段名称上看到装饰器一样。 这个装饰器也是一个函数,但它应该返回一个构造函数或一个类。
假设我有一个简单的User
类,如下所示。
class User { constructor( firstName, lastName ) { this.firstName = firstName; this.lastName = lastName; }}复制代码
我们的User
类目前没有任何方法。 如前所述,类装饰器必须返回一个构造函数。
function withLoginStatus( UserRef ) { return function( firstName, lastName ) { this.firstName = firstName; this.lastName = lastName; this.loggedIn = false; }}@withLoginStatusclass User { constructor( firstName, lastName ) { this.firstName = firstName; this.lastName = lastName; }}let user = new User( 'John', 'Doe' );console.log( user );复制代码
类装饰器函数将接收目标类UserRef
,它是上面示例中的User
(应用了装饰器的)中的User
,并且必须返回一个构造函数。 这为装饰者打开了无限可能的大门。 因此类装饰器比方法/属性装饰器更受欢迎。
上面的例子比较基础,当我们的User
类可能有大量的属性和原型方法时,我们不想创建一个新的构造函数。 比较好的是,我们可以引用了装饰器函数中的类,即UserRef
。 我们可以从构造函数返回新类,并且该类将可以扩展User
类(更准确地说UserRef
类)。 因此,类也是一个构造函数,这是合法的。
function withLoginStatus( UserRef ) { return class extends UserRef { constructor( ...args ) { super( ...args ); this.isLoggedIn = false; } setLoggedIn() { this.isLoggedIn = true; } }}@withLoginStatusclass User { constructor( firstName, lastName ) { this.firstName = firstName; this.lastName = lastName; }}let user = new User( 'John', 'Doe' );console.log( 'Before ===> ', user );// set logged inuser.setLoggedIn();console.log( 'After ===> ', user );复制代码
你可以通过将一个装饰器放到另一个上面,链式地使用多个装饰器。执行顺序与他们出现的位置顺序一致。
装饰者是更快达成目标的巧妙方式。 不久的将来它们便会被添加到ECMAScript规范中。
翻译自, 祝好。
《IVWEB 技术周刊》 震撼上线了,关注公众号:IVWEB社区,每周定时推送优质文章。
- 周刊文章集合:
- 团队开源项目: