Ember can use new functionality of ES6 - modules
. In near future, Ember will be build using this convention and eventually we will import computed
instead of Ember.computed
. To make code more clear and ready for future, we can create local versions of these modules.
import Ember from 'ember';
import DS from 'ember-data';
// GOOD
const { Model, attr } = DS;
const { computed } = Ember;
const { alias } = computed;
export default Model.extend({
name: attr('string'),
degree: attr('string'),
title: alias('degree'),
fullName: computed('name', 'degree', function() {
return `${this.get('degree')} ${this.get('name')}`;
}),
});
// BAD
export default DS.Model.extend({
name: DS.attr('string'),
degree: DS.attr('string'),
title: Ember.computed.alias('degree'),
fullName: Ember.computed('name', 'degree', function() {
return `${this.get('degree')} ${this.get('name')}`;
}),
});
Using plain jQuery provides invoke actions out of the Ember Run Loop. To have control on all operations in Ember it's good practice to trigger actions in run loop.
/// GOOD
Ember.$('#something-rendered-by-jquery-plugin').on(
'click',
Ember.run.bind(this, this._handlerActionFromController)
);
// BAD
Ember.$('#something-rendered-by-jquery-plugin').on('click', () => {
this._handlerActionFromController();
});
Usage of observers is very easy BUT it leads to hard to reason about consequences. If observers are not necessary then better to avoid them.
// GOOD
export default Controller.extend({
actions: {
change() {
console.log(`change detected: ${this.get('text')}`);
},
},
});
// BAD
export default Model.extend({
change: Ember.observer('text', function() {
console.log(`change detected: ${this.get('text')}`);
},
});
When using computed properties do not introduce side effects. It will make reasoning about the origin of the change much harder.
import Ember from 'ember';
const {
Component,
computed: { filterBy, alias },
} = Ember;
export default Component.extend({
users: [
{ name: 'Foo', age: 15 },
{ name: 'Bar', age: 16 },
{ name: 'Baz', age: 15 }
],
// GOOD:
fifteen: filterBy('users', 'age', 15),
fifteenAmount: alias('fifteen.length'),
// BAD:
fifteenAmount: 0,
fifteen: computed('users', function() {
const fifteen = this.get('users').filterBy('items', 'age', 15);
this.set('fifteenAmount', fifteen.length); // SIDE EFFECT!
return fifteen;
})
});
When you use promises and its handlers, use named functions defined on parent object. Thus, you will be able to test them in isolation using unit tests without any additional mocking.
export default Component.extend({
actions: {
// BAD
updateUser(user) {
user.save().then(() => {
return user.reload();
}).then(() => {
this.notifyAboutSuccess();
}).catch(() => {
this.notifyAboutFailure();
});
},
// GOOD
updateUser(user) {
user.save()
.then(this._reloadUser.bind(this))
.then(this._notifyAboutSuccess.bind(this))
.catch(this._notifyAboutFailure.bind(this));
},
},
_reloadUser(user) {
return user.reload();
},
_notifyAboutSuccess() {
// ...
},
_notifyAboutFailure() {
// ...
},
});
And then you can make simple unit tests for handlers:
test('it reloads user in promise handler', function(assert) {
const component = this.subject();
// assuming that you have `user` defined with kind of sinon spy on its reload method
component._reloadUser(user);
assert.ok(userReloadSpy.calledOnce, 'user#reload should be called once');
});
To maintain good readable of code, you should write code grouped and ordered in this way:
- Services
- Default values
- Single line computed properties
- Multiline computed properties
- Observers
- Lifecycle Hooks
- Actions
- Custom / private methods
const { Component, computed, inject: { service } } = Ember;
const { alias } = computed;
export default Component.extend({
// 1. Services
i18n: service(),
// 2. Defaults
role: 'sloth',
// 3. Single line Computed Property
vehicle: alias('car'),
// 4. Multiline Computed Property
levelOfHappiness: computed('attitude', 'health', function() {
const result = this.get('attitude') * this.get('health') * Math.random();
return result;
}),
// 5. Observers
onVahicleChange: observer('vehicle', function() {
// observer logic
}),
// 6. Lifecycle Hooks
init() {
// custom init logic
},
didInsertElement() {
// custom didInsertElement logic
},
// 7. All actions
actions: {
sneakyAction() {
return this._secretMethod();
}
},
// 8. Custom / private methods
_secretMethod() {
// custom secret method logic
}
});
Build model groups for each type of element. You should create 3 main subgroups in this order:
- Attributes
- Relations
- Computed Properties
// GOOD
export default Model.extend({
// 1. Attributes
shape: attr('string'),
// 2. Relations
behaviors: hasMany('behaviour'),
// 3. Computed Properties
mood: computed('health', 'hunger', function() {
const result = this.get('health') * this.get('hunger');
return result;
})
});
// BAD
export default Model.extend({
mood: computed('health', 'hunger', function() {
const result = this.get('health') * this.get('hunger');
return result;
}),
hat: attr('string'),
behaviors: hasMany('behaviour'),
shape: attr('string')
});
Standard file structure in Ember App is divided by type of file function. Pods organize files by features, it's much better solution because in bigger project finding particular file is significant faster. But whether everything should be kept in pods?
-
what includes in pods:
- Routes
- Components
- Controllers
- Templates
-
what not includes in pods:
- Models
// GOOD
app
models/
plants.js
chemicals.js
pods/
application/
controller.js
route.js
template.hbs
login/
controller.js
route.js
templates.hbs
plants/
controller.js
route.js
template.hbs
components/
displayOfDanger
component.js
template.hbs
It makes code more readable if model has the same name as a subject. It’s more maintainable, and will conform to future routable components. We can do this in two ways:
- set alias to model (in case when there is a
Nail Controller
):
const { alias } = Ember.computed;
export default Ember.Controller.extend({
nail: alias('model'),
});
- set it in
setupController
method:
export default Ember.Route.extend({
setupController(controller, model) {
controller.set('nail', model);
},
});
If you are using query params in your controller, those should always be placed on top. It will make spotting them much easier.
import Ember from 'ember';
const { Controller} = Ember;
// BAD
export default Controller.extend({
statusOptions: Ember.String.w('Accepted Pending Rejected'),
status: [],
queryParams: ['status'],
});
// GOOD
export default Controller.extend({
queryParams: ['status'],
status: [],
statusOptions: Ember.String.w('Accepted Pending Rejected'),
});
Ember Data could handle lack of specified types in model description. Nonetheless this could lead to ambiguity. Therefore always supply proper attribute type to ensure the right data transform is used.
const { Model, attr } = DS;
// GOOD
export default Model.extend({
name: attr('string'),
points: attr('number'),
dob: attr('date'),
});
// BAD
export default Model.extend({
name: attr(),
points: attr(),
dob: attr(),
});
In case when you need a custom behavior it's good to write own Transform
You should't change passed data in components instead trigger actions that should change this data.
export default Component.extend({
actions: {
removeElement(element) {
this.attr.removeElement(element);
},
},
});
export default Controller.extend({
actions: {
removeElement(element) {
this.get('data').removeObject(element);
},
},
});
export default Component.extend({
actions: {
removeElement(element) {
this.get('dataArray').removeObject(element);
},
},
});
Sometimes you have some data that are not crucial for given page and can be
loaded after the page has been rendered. This approach has many advantages such
as improving perceived load time. One solution to this problem is using
service-backed components that fetch the data using i.e injected store. This
makes the component hard to test - therefore it's better to fetch the data in
the router's setupController
hook (which doesn't block the render) and pass
the promise to the component.
//BAD - service-backed component
import Ember from 'ember';
const {
Component,
inject: { service }
} = Ember;
export default Component.extend({
store: service(),
loadComments() {
this.get('store').findAll('comment').then((comments) => {
// do something with the comments
});
},
});
//GOOD
//route
import Ember from 'ember';
export default Route.extend({
setupController(controller, model) {
controller.set('commentsPromise', this.store.findAll('comment'));
}
})
// you can now handle commentsPromise in your component or controller
Always use closure actions (according to DDAU convention). Exception: only when you need bubbling.
export default Controller.extend({
actions: {
detonate() {
alert('Kabooom');
}
}
});
export default Component.extend({
actions: {
pushLever() {
this.attr.boom();
}
}
})
export default Component.extend({
actions: {
pushLever() {
this.sendAction('detonate');
}
}
})
Dynamic segments in routes should use snake case. Reason:
- Ember could resolve promises without extra serialization work
// GOOD
this.route('tree', { path: ':tree_id'});
// BAD
this.route('tree', { path: ':treeId'});
When content of each block is larger than one line, use component to wrap this code. Ember convention is build app through divided to smaller modules (Components). This is more flexible and readable. Use simple rule of thumb - if you need more than one line in #each
block, then use component.
In acceptance tests there is a tendency to repeat the same code many times (mainly selectors). It's also hard to reason about exact behavior based only on selector naes. To avoid this we can extract this to other abstract layer and then import them to tests.
// GOOD
export default Ember.Object({
expectGroceryHeader(msg) {
andThen(() => {
this.get('assert').equal(find('#grocery-header').text(), msg);
});
return this;
}
})
import PageObject from 'my-project/tests/page-objects/base';
...
test('check changed message', function(assert) {
PageObject.create({ assert })
.expectGroceryHeader('cabbage');
});