There are several quirks of the existing Ember object model that are based on the fact that we enlist Ember users quite a bit to help us in managing the data flow graph of an Ember object. This proposal attempts to preserve the data flow analysis, but cutting the user out of the loop of it entirely.
We have an opportunity with ES5 getters/setters, ES6 classes, and ES.later decorators/private state to drastically simplify the object model, eliminating the need for Ember.Object entirely, while still preserving observability.
The key aspect of this proposal is creating a strong separation between private properties and the public interface, limiting observability to the public interface.
There has been some desire to use ES6ification as an opportunity a la Glimmer Component, and this motivating example does precisely that.
@ember class Person {
@reader #firstName;
@reader #lastName;
constructor(firstName, lastName) {
this.#firstName = firstName;
this.#lastName = lastName;
}
}
@ember class NuclearFamily {
@reader #father;
@reader #mother;
@reader #children;
constructor(father, mother, children) {
this.#father = father;
this.#mother = mother;
this.#children = children;
}
get familyName() {
return `Mr. and Mrs. ${this.#father.firstName} ${this.#father.lastName}`
}
}
class FamilyComponent {
@tracked #sortOrder = 'firstName';
get #children() {
let family = this.args.family;
return family.children.sortBy(this.#sortOrder);
}
@action // bind `this`
sortBy(sortKey) {
this.#sortOrder = sortKey;
}
}
The @ember
class annotation means "some public properties in this class are used in a template or accessed by a public getter in another @ember
object. In essence, it makes all public properties and getters @tracked
.
Before I explain how it works, things that are worth calling out:
- There is no Ember.Object base class; what this means practically
is that there are no special methods on
@ember
objects, and all property access is done normally. - "Computed properties" don't exist anymore. Tracked properties replace computed properties, even with private state.
- The component class did not need to be a special superclass.
- Templates are basically unchanged, but they can access the private
state of their component using
this.#state
.
This proposal introduces a few concepts, but it eliminates a number of existing concepts (outside of code using Ember.Object
and objects interacting with Ember.Object
s of course):
Ember.Object
.set
and.get
.notifyPropertyChange
- observers
- computed properties (replaced by the
@uses
annotation on getters) Ember.Component
andEmber.GlimmerComponent
(we might provide either a mixin or base class for convenience).set
on components- name pollution from the Ember object superclass
The replacements:
Ember.Object
, with all its extra methods, is replaced with@ember
.set
and.get
are eliminated, replaced by regular setters and a distinction between public and private state..notifyPropertyChange
is eliminated, tracked by setters- observers are eliminated entirely as a programming model concept
- computed properties are replaced by
@tracked
. Ember.Component
andEmber.GlimmerComponent
are replaced with regular ES6 classes and a handful of annotations..set
on components is eliminated, replaced with explicit private properties in the component accessed by the template.
New concepts:
- the
@ember
annotation - separation of public and private state
- the
@reader
and@accessor
annotations on private fields - the
@action
annotation on components
The primary shift here that drives the rest of the design is that there is a strong separation between public accesses and private state.
This proposal assumes JavaScript private state using the this.#foo
notation.
When using the @ember
annotation, the primary difference between public and private properties is that public properties are managed by Ember, allowing us to update the DOM efficiently on change.
This makes sense: an object's public interface is "reactive", while its private interface should not be observed by outside objects.
Ember CLI would lint against:
- direct use of the public interface from inside a class:
this.foo
andthis.foo = bar
- access to public properties of the component in the template; the
template is a part of the object, not a separate object communicating through a public
interface.
get #foo
can be used for private getters.
The semantics of private state in JavaScript make linting against inappropriate access to private state unnecessary.
Under the hood, Ember uses getters and setters to manage the public and private state, as well as observation.
The reader annotation is a decorator that defines a public property whose getter delegates to the same-named private property.
- It is read-only, attempts to set the property will throw an exception.
- It is tracked, more later
The accessor annotation is a decorator that defines a @reader
as well as a setter that delegates to the same-named private property.
Consider this getter:
get familyName() {
return `Mr. and Mrs. ${this.#father.firstName} ${this.#father.lastName}`
}
The @ember
annotation finds all getters and adds @tracked
.
When the instrumented getter is accessed, Ember starts collecting dependencies.
First, the getter gets #father
a private field.
Next, it accesses .firstName
, which is a public reader. The getter implementation tells Ember that it was called, adding the dependency to the dependency list.
When the getter finishes, it has a list of dependencies, and it registers interest in being notified about changes (the various strategies we've been considering for making this cheaper are very applicable here).
Because we're not using Ember.Object
, it would be possible to use efficient primitives from the templating engine directly, which is a topic for another RFC.
At the beginning of the lifecycle of an object, all properties are private, and you might have a few methods that allow other objects to interact with them.
class Person {
#first, #last;
constructor(first, last) {
#first = first;
#last = last;
}
// not private it's a public API
isKatz() {
return this.#last === 'Katz'
}
}
This is not a very useful object. Let's imagine your model hook returned one of these, and you're trying to do something with it in a template:
If you try to do this, JavaScript disallows you from accessing those names on another object (because private state is truly private and lexical).
The solution is simple:
class Person {
@reader #first;
@reader #last;
constructor(first, last) {
#first = first;
#last = last;
}
// not underscored, so it's a public API
isKatz() {
return #last === 'Katz'
}
}
And now you can use it in your template just fine:
The fact that neither #first
nor #last
is marked @tracked
means that this class doesn't have any side effects that could trigger re-renders.
If you wanted to encapsulate the code that creates the full name into a getter:
class Person {
@reader #first;
@reader #last;
constructor(first, last) {
this.#first = first;
this.#last = last;
}
// the annotation makes it clear that modifying `#first` or `#last` can trigger
// side effects.
@tracked get fullName() {
return `${this.#first} ${this.#last}`;
}
// not underscored, so it's a public API
isKatz() {
return #last === 'Katz'
}
}
This model builds on the improved local reasoning of @tracked
.
By making a property @readonly
, you can be sure that it will only be mutated by the class itself, and not external code. And because it's a public property, it still can be safely accessed from the outside.
One of the properties of this system is that the annotated JavaScript class behaves exactly the same as an unannotated JavaScript class. The only difference is that the private/public split creates an implicit notification channel from manipulations of private properties to the DOM, but that's not a part of the programming model of working with your JavaScript objects. If the DOM updates, the closest component will receive a didUpdate
hook, but only once per run loop, and only if the component actually survived (a parent component didn't remove it).
- Reading a property is always benign and has no non-local side effects
- Writing a private property that is not a
@tracked
is benign and has no non-local side effects. - Writing a private property that is
@tracked
can have non-local side effects, but only through an object that has an instance in its private state, and only if that property is@reader
.
class Person {
@tracked @reader #first;
@tracked @reader #last;
constructor(first, last) {
this.#first = first;
this.#last = last;
}
update(fullName) {
let [ first, last ] = fullName.split(' ');
this.#first = first;
this.#last = last;
}
}
class Family {
#father;
#mother;
constructor(father, mother) {
this.#father = father;
this.#mother = mother;
}
updateFather(name) {
this.#father.update(name);
}
updateMother(name) {
this.#mother.update(name);
}
}
Now let's try to use these objects in a component.
class FamilyComponent {
#family;
@tracked #fatherName;
@tracked #motherName;
constructor(attrs) {
// the parent component passed in a father and mother as attrs.father and attrs.mother
this.#family = new Family(father, mother);
// Copy out the names so we can update them. All of this is kosher
// since we're reading from public properties and writing to
// private properties on ourself.
this.#fatherName = `${this.#father.first} ${this.#father.last};
this.#motherName = `${this.#mother.first} ${this.#mother.last};
}
@action
#updateFather(name) {
this.#family.updateFather(#fatherName);
}
@action
#updateMother(name) {
this.#family.updateMother(#motherName);
}
}
And a template:
In this example, it's quite safe to use {{mut this.#fatherName}}
, because it's just internal state on the component.
When the user hits enter on the first input field, the #updateFather
action will get fired. That action calls this.#family.updateFather(name);
.
Next, the updateFather
method calls this.#father.update(name);
.
Finally, the update()
method on Person
modifies its this.#first
and yhis.#last
. Since those properties are @tracked
, we know that changing them might have an effect. In this case, indeed it does!
The public first
and last
properties on @father
can see the change, and will update with the new values.
As you can see, this is a very clear unidirectional data flow loop, and it is simply a side effect of separating out your public interface from your private state.
If you disconnect the DOM from your program, your objects will behave exactly the same as a regular JavaScript object.
Each part of this small program (the two models, the component and the template) are very easily about to reason locally about its responsibilities, but the public/private enforcement means that as you scale up, things keeps working. Since the data flow is implicit, there's no good way to screw it up.
The nice thing about this system is that the public/private split simply creates an implicit data flow channel through the public properties. However, that channel is not observable in your Ember classes; it is simply used by Ember to propagate information back to the template. Rather than having to manually create a unidirectional data flow loop, Ember does it for you!
You also aren't required to add @tracked
to every intermediate getter in your program--just the ones accessed directly from the template.
While internally, Ember is doing a whole bunch of dependency tracking, that is no longer a primary focus of the programming model.
- The absence of observers means that users will not participate in the low-level operation of the tracking system
- Users will be allowed to mutate private state with abandon, as if they were writing regular JavaScript code.
- As a result, the semantics of the tracking system are pretty much invisible to the user; they write regular JavaScript code, and we do enough tracking so that we know which parts of the DOM we need to update.
Essentially, this proposal closes the leak between the regular JavaScript programming model and the work we have historically made people do to help us update the DOM efficiently.
Private properties can always be mutated safely without having to worry about an unexpected non-local effect, and communication between objects is always done through the public interface.
reader
may need a better name -- it may only make sense to Rubyists.While this looks like a big change, I think it can be rolled out very unobtrusively. Unlike glimmer, there is no moment where we would need to leap into a while new model.