Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Native Class Roadmap #338

Closed
wants to merge 2 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 269 additions & 0 deletions text/0000-native-class-roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
- Start Date: 2018-06-14
- RFC PR: (leave this empty)
- Ember Issue: (leave this empty)

# Native Class Roadmap

## Summary

Now that we’ve (almost) achieved feature parity for using native classes to
extend from EmberObject, the next step for Ember is to move toward using and
recommending native classes by default, with the long term goal of deprecating
EmberObject and removing it from the framework. This RFC seeks to outline where
we want to go (e.g. what Ember looks like without EmberObject), constraints we
face, and potential paths forward.

## Design Constraints

In order to figure out the path forward to native classes, we need to first
develop an idea of what the future of Ember looks like once we have fully
adopted them and removed EmberObject entirely. Below are several design
considerations for the new object model:

- **It should leverage the platform.** We should use and encourage standard
Javascript classes and idioms wherever possible. This means leveraging classes,
class fields, and decorators for most features that the object model provides.
- **Ember should remain as neutral as possible about the implementation details.**
Part of the reason we are going to have migration pains as a community moving
forward is that Ember is tied to the behavior of EmberObject in a myriad of
small ways. In an ideal world, these details would be opaque to Ember, allowing
users to implement and change-out object models at their own convenience. This
would have made the transition much easier, and will provide flexibility in the
community for pursuing new language features and libraries (e.g. mixins)
- **It should be possible to adopt incrementally.** This will be one of the largest
changes the community has ever gone through, on the same level if not larger than
dropping Views and adopting Components. EmberObject is pervasive - it's been
used for every construct imaginable in Ember apps and addons due to its
versatility and simplicity. Ideally users will be able to make incremental
changes toward pure native classes by first rewriting their existing classes
using native class syntax (as is already possible) and then dropping EmberObject
altogether.
- **We should consider the long tail of adoption, particularly for addons.** There
will be a long period where the community will need to support older versions of
Ember at the same time as newer versions. Ideally it should be possible to
support both the new and old model at once without separate code paths, or the
overlap period should be long enough that addons can drop EmberObject without
breaking Ember versions that are still part of their support matrix.
- **If possible, conversion should be automate-able.** This is not a hard
constraint, since it limits us to having very similar behavior to the existing
object model. However, if it is possible to create scripts that speed up
adoption it will help the community tremendously, so if we are at an impasse on
particular behaviors or implementations, we should consider whether one would be
easier to automate towards.

## Detailed design

As mentioned above, one of the main reasons this conversion is going to be a
difficult and long process is the fact that both user code and Ember code are
very intertwined with the existing object model. Specifically:

- They rely on the behavior of static methods such as `create`, `extend`, and
`destroy`.
- They rely on the behavior of EmberObject's constructor
- They rely on a non-trivial amount of functionality provided via inheritance
- They rely on features of EmberObject's inheritance model that do not exist in
native Javascript (Mixins, concatenated/merged properties, nuances of observers
and listeners, etc.)

The fact that these are so intertwined makes it very difficult (if not
impossible) for us to rewrite the core classes that exist in Ember without
breaking user land code.

One strategy we can implement to prevent this from happening again in the future
is to make the details of classes opaque to Ember. Rather than providing base
classes that can be extended by users to add functionality and implement their
applications, we can rewrite Ember to treat user classes as delegates. In other
words, Ember will make no assumptions about the behavior of a class, other than
that it fulfills a certain interface.

For instance, a Component can be any class which receives an `args` hash and
optionally implements any of the component lifecycle hooks:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non important, curious question - Would this in effect reduce lookup time for properties/non existent properties, especially for things are are high up the prototype chain?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory yes, though I wouldn't count on it being that huge of a win. In aggregate though, who knows 😄


```js
export default class FooComponent {
didInsertElement() {
if (this.args.isShowing) {
// do something
}
}
}
```

This method allows users to provide base-less classes, classes which do not
extend anything, to Ember as well. Ember could continue to provide empty base
classes to extend from, but it wouldn't have to.

Following a strategy of delegates and interfaces would disentangle Ember from
the details of the object model as much as possible. Ember would not care about
the implementation of a class, it would simply assume that it operates by the
same rules as standard Javascript classes do.

This method would also allow us to extract CoreObject from Ember and provide it
as a separate package for users who want to continue using the legacy object
model. So long as their objects match the new interfaces provided by Ember, it
shouldn't matter what class system they are using.

> Note: Just because we could continue to provide CoreObject as a separate
package doesn't mean we _should_. The point is that this strategy makes us
flexible enough to support it, if we choose to.

### Implementation and Adoption

The largest concerns for adopting the delegate pattern are:

1. Implementing the delegate pattern before removing EmberObject, so app
developers can switch seamlessly.
2. Giving a large enough buffer to addon authors so that they can switch to the
delegate pattern without dropping support for Ember versions that are still in
common use.

We can solve #1 during the Ember v3 lifecycle by changing Ember's code
internally to treat the existing base classes and native classes as delegates.
However, we cannot count on being able to remove EmberObject for v4, since
it's likely that there will still be users of Ember versions that do not support
delegates. So, the overall roadmap would look like:

- Ember v3: Implement delegates in Ember, allowing users to adopt the new world.
- Ember v4: Deprecate EmberObject, and prepare the framework for removal.
- Ember v5: Remove EmberObject.

## How we teach this

Once implemented, we can begin providing examples of creating base-less classes
for all of Ember's standard constructs. We should update the guides to recommend
this method, make announcments, and move toward officially deprecating
EmberObject.

We should also write migration guides that help users through the process of
converting to native classes. In particular, non-Ember constructs that use
EmberObject will likely have to change a fair amount. Demonstrating various
conversions in blog posts and guides will help to show people the path forward,
and spread knowledge of best practices when using native classes.

## Drawbacks

This approach limits what we can do significantly in framework code (no
inheritance, can't provide defaults, must provide all behavior external to user
code). There are many established software patterns other than inheritance for
providing most behavior, so in practice this shouldn't be an issue.

## Alternatives

- **Gradually rewrite current base classes.** We could attempt to gradually rewrite
Ember constructs such as Services, Routes, and Components in place. This would
give us more flexibility for a lot of things, like constructor behavior and
default method implementations, etc. It would probably take about as long to do
this as the delegate pattern, and could get very messy along the way, especially
for removing certain constructs (like Mixins).
- **Provide new base classes at the same time as the old ones.** We could provide
new base classes, exposed at different import paths, that users could gradually
switch to over time. This would give us more flexibility as well, but it would
also be about as much work and the same adoption timeline as the delegate
pattern, since Ember would have to support both the old Ember object model and
the new base classes at the same time until the switch. It would also likely be
fairly confusing to users, since they would have to make sure they were
importing the correct classes.

## Unresolved questions

If we go this direction, it means we cannot specify any base class behavior. One
issue that arises is the fact that dependency injections will not be available
to the class _during_ object construction. They can be assigned _after_ only.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this result affect initializers/instance-initializers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initializers and instance-initializers are (as I understand them) essentially pure functions that run at the beginning off app creation/instantiation. They would still receive the app definition/instance and container as such, and wouldn't be directly impacted by this change.

However, and injections which are setup by initializers would be affected. All injections are currently assigned by this mechanism, and assigning after would make them unavailable.

It's worth noting that only potential solutions mentioned in the RFC which would work for injections done this way would be the init hook and passing the injections to the constructor as named parameters. Personally, I am not a fan of the ability to unilaterally inject items onto any type thing (e.g. store automatically injected to routes/controllers) and think it would make sense to recommend users manually declare the dependency.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps in v4 we have init() to support this and in Ember v5 we drop it and make everything explicit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that would be an option if we decided to go with the other DI options and drop init altogether. It would give us a good timeline to clean things up on as well, and falls neatly inline with the proposed timeline for removing EmberObject.


There are a few options for how we could resolve this:

1. **Provide injected properties as named params in the constructor.** This would
allow us to mirror the injection definitions, which is fairly intuitive.
However, accessors and methods which depend on services will not work unless
the user assigns the service, which may be counterintuitive:

```js
class Foo {
@service time;

getCurrentTime() {
return this.time.now();
}

constructor({ time }) {
this.currentTime = time.now(); // this works
this.currentTime = this.getCurrentTime(); // this does not
}
}
```

2. **Provide injected properties as positional params to the constructor, using a
separate decorator.** This would completely separate named injections from
constructor injections entirely. This would likely be confusing to newcomers,
but would be similar to how other DI systems work:

```js
@inject('time', 'date')
class Foo {
@service time;

constructor(time, date) {
this.currentTime = time.now();
this.currentDate = date.today();
}
}
```


3. **Utilize private global state to provide the container to injections during

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this, but then it should be done for getOwner() so that even a manual call to getOwner(this) inside the constructor will work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this would make the most sense if we pursued this strategy. Will update the RFC to reflect that

construction.** If done correctly, this would allow users to use injected
properties during all phases of construction. This would be the most intuitive
for users since it mirrors the existing behavior, but would be the most
difficult to maintain:

```js
const constructionContainers = [];

function getOwner(obj) {
return OWNER in obj
? obj[OWNER]
: constructionContainers[constructionContainers.length - 1];
}

function service(target, key, desc) {
desc.get = function() {
return getOwner(this).lookup(`service:${key}`);
}
}

class Container {
lookup(className) {
let Class = this.registry.lookup(className);

// We push the current container onto a stack, and pop it off when done
// constructing, because other lookups may occur during the construction
// of this object
constructionContainers.push(this);
let instance;

try {
instance = new Class();
instance[OWNER] = this;
} finally {
// remove the current container, even in the case of an error
constructionContainers.pop();
}

return instance;
}
}

class Foo {
@service time;

constructor() {
this.currentTime = this.time.now();
}
}
```

4. **We could not support injections during object construction.** Instead, we could
formalize the `init` hook as a separate hook from the constructor, which
semantically means “initialized by the container”. This may be a reasonable hook
for the framework to have and could be very useful, even if it seems a bit
confusing (e.g. what’s the difference between `init` and `constructor`?)