Start Date Release Date Release Versions PR link Tracking Link Stage Teams
7/28/2017
Recommended
  • Framework

Summary

This RFC aims to solidify the usage of ES2015 Classes as a public API of Ember so that users can begin building on them, and projects like ember-decorators can continue to push forward with experimental Javascript features. This includes:

  • Making the class constructor function a public API
  • Modifying some of the internals of Ember.Object to support existing features and make the usage of ES Classes cross-compatible with Ember.Object

It does not propose additions in the form of helpers or decorators, which should continue to be iterated on in the community as the spec itself is finalized. It also does not propose deprecating or removing existing functionality in Ember.Object.

Motivation

The Ember Object model has served its purpose well over the years, but now that ES Classes are becoming prevalent throughout the wider Javascript community it is beginning to show its age. With class properties at stage 3 and decorators at stage 2 in the TC39 process, classes are finally at a point where we can start integrating them into Ember.

The ember-decorators project has been experimenting with using ES Classes and filling out the Ember feature-set, allowing us to write Ember classes like so:

export default class MyComponent extends Ember.Component {
  didInsertElement() {
    // do stuff
  }

  @computed
  get foo() {
    // do stuff
  }

  @action
  bar() {
    // do stuff
  }
}

Using classes makes Ember easier to teach and understand by normalizing it with standard Javascript coding practices, and allows us to share code and solutions with other frameworks and libraries. It also brings with it all the benefits of ES Class syntax:

  • More aligned with the greater Javascript community
  • Ability to share code more easily with other libraries and frameworks
  • Easier to statically analyze
  • Cleaner and easier to read (subjective)

The Ember Object model already works extremely well with ES classes, as demonstrated above, but there several failure scenarios. Furthermore, because they are not officially supported as a public API, there is no guarantee that they will continue to work well. Thus, this RFC seeks to solidify the behavior of ES Classes so that the community can continue to experiment with new Javascript features and build on a stable API.

Detailed Design

Many of the standard features of Ember classes work out of the box today, either with vanilla ES Classes or through ember-decorators, including:

  • Inheritance
  • Lifecycle hooks
  • Computeds
  • Injections
  • Actions

However, the following features either do not exist or do not work as a user familiar with Ember.Object would expect:

  • Extending from ES Classes using extend
  • Class properties
  • Mixins
  • Observers and events
  • Merged and concatenated properties

These features will require changes to Ember.Object

Extend

Currently, once a class is defined using ES Classes it is not possible for users to extend it using the previous CoreObject style of writing and extending classes. This can limit the rate of adoption because ES Classes would become a trapdoor - once you begin using them, you must continue to use them. It would be a particularly thorny issue for addon developers, who may design components which their users expect to be able to extend and modify.

This RFC proposes that extend be fixed on ES Classes to make them fully cross-compatible with the existing syntax. There are two general approaches to making this work:

  1. Modify CoreObject to use prototypes/ES Classes internally. This would bring CoreObject more inline with ES Classes, but would be a significant internal change.

  2. Modify CoreObject to have different behavior if it is extending an ES Class using extend.

Both approaches should be explored and benchmarked to determine if there are an significant advantages to one over the other.

Class Properties

When using Ember.Object.extend, properties that are passed in on the object are assigned to the prototype of the class:

const Foo = Ember.Object.extend({ bar: 'baz' });
const foo = Foo.create();

console.log(Foo.prototype.bar) // 'baz'
foo.hasOwnProperty('bar') // false

This differs from the behavior of ES Class properties, which initialize their value on the instance of the class.

class Foo {
  bar = 'baz'
}

const foo = new Foo();

console.log(Foo.prototype.bar) // undefined
foo.hasOwnProperty('bar') // true

The above is essentially currently compiled down by Babel to the following:

class Foo {
  constructor() {
    this.bar = 'baz';
  }
}

Property assignments like this are always done at the end of the constructor, and given the requirement that super must always be called before properties are assigned it is unlikely that this will change as the spec progresses.

While one might intuitively expect class properties to function the same in ES Classes as they do with Ember Objects, this difference in behavior means that class properties will always be assigned after properties passed into create are initialized on the object, and thus will always win:

const Foo = Ember.Object.extend({ testProp: 'default value' });

class Bar extends Ember.Object {
  testProp = 'default value'
}

const foo = Foo.create({ testProp: 'new value' });
const bar = Bar.create({ testProp: 'new value' });

console.log(foo.get('testProp')); // 'new value'
console.log(bar.get('testProp')); // 'default value'

This behavior makes sense when you consider that it is equivalent to assigning values in init rather than on the object when it is defined. Rather than modify Ember.Object to treat class properties as default values, this RFC proposes that we accept the difference in behavior and utilize the constructor to allow users to set default values, as in the following example:

class Foo extends Ember.Object {
  constructor(props) {
    props.testProp = props.testProp || 'default value';

    super(props);
  }
}

This enforces a public API rather than allowing create to override values as it pleases, and is more inline with the behavior of components in Glimmer today - args that are passed into the class are distinguished from properties that are defined on the class.

Mixins

Mixins are a contentious part of both the Ember Object model and the wider Javascript community - some swear by the pattern, and others believe it fundamentally flawed. While Ember mixins are at the core of Ember Object, the fact is that no standard solution for them has arisen in the wider Javascript community as of yet.

Additionally, while concepts like computed properties, actions, and service injection are either unique to Ember or highly dependent on implementation, mixins can be implemented in a generic way which could be used across all of Javascript, independent of one's framework or library of choice. With that in mind, this RFC considers mixins out of scope and suggests that in the future Ember users can choose to use a mixin library if it suits their needs.

It should also be noted that existing classes which have used mixins can still be extended using ES Class syntax:

const Mix = Ember.Mixin.create({ bar: 'baz' });
const Foo = Ember.Object.extend(Mix, { /* ... */ });

class Bar extends Foo { /* ... */ }

const bar = Bar.create();

console.log(bar.get('bar')); // 'baz'

Observers and Events

Observers and events both fail to work properly when using ES Class syntax. The root of the issue here is how Ember.Object works at a fundamental level, and will require some refactoring to fix.

Currently, each time Ember.Object.extend is used, it stores the list of mixins and objects passed in on a list which also contains the superclass's properties and mixins, and so on. A class is then returned which has access to a closure variable, wasApplied:

makeCtor = function() {
  wasApplied = false;

  return class {
    constructor() {
      if (!wasApplied) {
        this.proto();
      }
    }
  }
}

The proto function walks the chain of stored mixins, collapsing them into a single object prototype the first time the class is created. It is during this walk that observers and events listeners are applied and finalized, as well as merged and concatenated properties applied (this will be touched on more in the next section).

Unfortunately, due to the nature of how observers and event listeners work, they cannot be applied at class definition time without a class decorator. For example:

const Foo = Ember.Object.extend({
  fooObserver: Ember.observer('foo', function() { /* ... */m })
});

class Bar extends Foo {
  fooObserver() { /* ... */ }
}

When proto walks the mixin chain for Foo, it will add an observer that triggers the fooObserver function whenever foo changes. Bar, however, overloads the fooObserver function with a function that is not observed, and thus should not trigger (this is analagous to how Ember Object's work today). Currently there is no time at which Bar can inspect undecorated properties to determine if the superclass has already defined them and if they are observed and thus should have the observer removed.

To fix this, the wasApplied state should be moved to the ember meta object on the class itself, so that both Ember Objects and ES Classes can track if they have had it applied. Additional logic will also need to be added to allow the current "squashing" behavior of proto to work with Prototypes instead of a list of mixins as well.

Merged and Concatenated Properties

Ember Objects currently have the ability to define special properties which are merged or concatenated with their superclass when extended. This is most commonly seen with actions and classNames among others.

As mentioned in the last section, merged and concatenated properties are also combined during the proto "squash" phase, and so it is also broken in ES Classes currently. This RFC proposes that their behavior also be fixed as part of the refactors to Ember.Object.

How We Teach This

The sole purpose of this RFC is to make the behavior of ES Classes within Ember a public API so that projects like ember-decorators can continue to build and experiment with confidence that the underlying behavior will not change. The Ember Object model will remain exactly the same as today, and will continue to be the recommended path for Ember users. Thus, we will not need to add new documentation for the time being.

Drawbacks

  • Making constructor a public API means we are solidifying the lifecycle of objects, locking us into a particular sequence of events (init occurs within the super() portion of the constructor).
  • Lack of mixin support may make it difficult for mixin heavy codebases to utilize ES Classes.
  • ES Class features/usage such as getters and setters may confuse users in general (getter functions will appear to work, but without a computed decorator will not update, etc.)

Alternatives

  • Class property initialization can be changed such that properties are initialized after the constructor runs entirely, allowing them to be overwritten by values passed to create

Topics for Future RFCs

While working on this RFC, some issues were brought into focus regarding existing features in CoreObject that are seen as problematic or unintuitive. In order to avoid bikeshedding these have been slated for discussion in future RFCs, but the discussion points have been included below.

Merged and Concatenated Properties

Merged and concatenated properties are pain points for new Ember developers, specifically because they give no lexical hint that they are special in any way. Developers must know that these particular properties will be merged with the superclass, and there is no way to opt out of this behavior.

With decorators, this same behavior can be accomplished in a much clearer and more straightforward way:

class FooComponent extends Ember.Component {
  @concatenated classNameBindings = ['foo']

  @computed
  get foo() { /* ... */ }

  @merged actions = {
    bar() { /* ... */ }
  }
}

They could also be accomplished more ergonomically with specialized decorators:

class FooComponent extends Ember.Component {
  @className
  @computed
  get foo() { /* ... */ }

  @action
  bar() { /* ... */ }
}

This approach has two distinct advantages over the existing behavior:

  1. It is less magical. The decorators indicate to new users that the properties are special in some way, and ultimately they are just plain decorators, which are compatible with ES Classes as a whole and can be reused anywhere.
  2. It provides a way to opt out of the behavior. Currently, there is no easy way to prevent properties which were marked to be merged from being merged, meaning subclasses are stuck with the values that their superclass provided.

Observers and Listeners

Observers and event listeners are a powerful pattern that saw a lot of usage in Ember 1. However, it is now widely accepted that they are problematic when overused, and using computed properties and lifecycle hooks are better patterns in most cases.

As such, rather than having events and observers turned on by default it may make more sense to have them be opt-in APIs. This could be accomplished by making new class decorators like so:

@evented
class Foo extends Ember.Object {
  @on('init')
  onInit() {
    // do something
  }
}

Or it could be accomplished with new base classes that include the functionality:

class Foo extends EventedObject {
  @on('init')
  onInit() {
    // do something
  }
}

Unresolved questions

None currently