В DockYard, мы много времени уделяем Ember, от построения web приложений, создания и поддержки аддонов, до вклада в экосистему Ember. Мы надеемся поделиться некоторым опытом, который мы приобрели, через серию постов, которые будут посвящены лучшим практикам Ember, паттернам, антипаттернам и распространённым ошибкам. Это первый пост из данной серии, так что давайте начнём, вернувшись к основам Ember.Object
.
Ember.Object
это одна из первых вещей, которую мы узнаём, как разработчики Ember, и неудивительно. Практически каждый объект, с которым мы работаем в Ember, будь то маршрут (Route), компонент (Component), модель (Model), или сервис (Service), наследуется от Ember.Object. Но время от времени, я вижу как его неправильно используют:
export default Ember.Component.extend({
items: [],
actions: {
addItem(item) {
this.get('items').pushObject(item);
}
}
});
Для тех, кто сталкивался с этим раньше, проблема очевидна.
Ember.Object
Если вы посмотрите API и отмените выбор всех Inherited (Наследуемых), Protected (Защищённых), и Private (Приватных) опций, вы увидите, что Ember.Object
не имеет собственных методов и свойств. Исходный код не может быть короче. Это буквального расширение Ember CoreObject
, с примесью Observable
:
var EmberObject = CoreObject.extend(Observable);
CoreObject
обеспечивает чистый интерфейс для определения фабрик или классов. Это, по существу, абстракция вокруг того, как вы обычно создаёте функцию конструктор, определяя методы и свойства на прототипе, а затем создавая новые объекты с помощью вызова new SomeConstructor()
. За возможность вызывать методы суперкласса, используя this._super()
, или объединять набор свойств в класс через примеси, вы должны благодарить CoreObject
. Все методы, которые часто приходиться использовать с Ember objects, такие как init
, create
, extend
, или reopen
, определяются там же.
Observable
это примесь (Mixin), которая позволяет наблюдать за изменениями свойств объекта, а также в момент вызова get
и set
.
При разработке Ember приложений, вам никогда не приходиться использовать CoreObject
. Вместо этого вы наследуете Ember.Object
. В конце концов, в Ember самое важное реакция на изменения, так что вам нужны методы с Observable
для обнаружения изменения значений свойств.
Объявление нового класса
Вы можете определить новый тип наблюдаемого объекта путем расширения Ember.Object
:
const Post = Ember.Object.extend({
title: 'Untitled',
author: 'Anonymous',
header: computed('title', 'author', function() {
const title = this.get('title');
const author = this.get('author');
return `"${title}" by ${author}`;
})
});
Новые объекты типа Post
теперь могут быть созданы путём вызова Post.create()
. Для каждой записи будут наследоваться свойства и методы, объявленные в классе Post
:
const post = Post.create();
post.get('title'); // => 'Untitled'
post.get('author'); // => 'Anonymous'
post.get('header'); // => 'Untitled by Anonymous'
post instanceof Post; // => true
Вы можете изменить значения свойств и дать посту название и имя автора. Эти значения будут установлены на экземпляре, а не на классе, поэтому не повлияют на посты, которые будут созданы.
post.set('title', 'Heads? Or Tails?');
post.set('author', 'R & R Lutece');
post.get('header'); // => '"Heads? Or Tails?" by R & R Lutece'
const anotherPost = Post.create();
anotherPost.get('title'); // => 'Untitled'
anotherPost.get('author'); // => 'Anonymous'
anotherPost.get('header'); // => 'Untitled by Anonymous'
Поскольку обновление свойств таким образом не влияет на другие инстансы, легко подумать, что все операции, выполненные в примере безопасны. Но остановимся на этом этом немного больше.
Утечка состояния внутрь класса
Пост может иметь дополнительный список тегов, так что мы можем создать свойство с именем tags
и по умолчанию это пустой массив. Новые теги могут быть добавлены при помощи вызова метода addTag()
.
const Post = Ember.Object.extend({
tags: [],
addTag(tag) {
this.get('tags').pushObject(tag);
}
});
const post = Post.create();
post.get('tags'); // => []
post.addTag('constants');
post.addTag('variables');
post.get('tags'); // => ['constants', 'variables']
Похоже, что это работает! Но проверим, что происходит, после создания второго поста:
const anotherPost = Post.create();
anotherPost.get('tags'); // => ['constants', 'variables']
Даже если цель состояла в том, чтобы создать новый пост с пустыми тегами (предполагаемый по умолчанию), пост был создан с тегами из предыдущего поста. Поскольку новое значение для свойства tags
не было установлено, а просто мутировало основной массив. Так мы эффективно прокинули состояние в класс Post
, которое затем используется на всех экземплярах.
post.get('tags'); // => ['constants', 'variables']
anotherPost.get('tags'); // => ['constants', 'variables']
anotherPost.addTag('infinity'); // => ['constants', 'variables', 'infinity']
post.get('tags'); // => ['constants', 'variables', 'infinity']
Это не единственный сценарий, при котором вы можете спутать состояние экземпляра и состояние класса, но это, конечно, тот, который встречается чаще. В следующем примере, вы можете установить значение по умолчанию для createdDate
для текущей даты и времени, передав new Date()
. Но new Date()
вычисляется один раз, когда определяется класс. Поэтому независимо от того, когда вы создаете новые экземпляры этого класса, все они будут иметь одно и то же значение createdDate
:
const Post = Ember.Object.extend({
createdDate: new Date()
});
const postA = Post.create();
postA.get('createdDate'); // => Fri Sep 18 2015 13:47:02 GMT-0400 (EDT)
// Sometime in the future...
const postB = Post.create();
postB.get('createdDate'); // => Fri Sep 18 2015 13:47:02 GMT-0400 (EDT)
Как держать ситуацию под контролем?
Для того, чтобы избежать совместного использования меток между постами, свойство tags
необходимо будет установить, во время инициализации объекта:
const Post = Ember.Object.extend({
init() {
this._super(...arguments);
this.tags = [];
}
});
Так как init
вызывается всякий раз во время вызова Post.create()
, каждый пост экземпляра всегда получит свой собственный массив tags
. Кроме того, можно сделать tags
вычисляемым свойством (computed property):
const Post = Ember.Object.extend({
tags: computed({
return [];
})
});
Вывод
Теперь очевидно, почему вы не должны писать таких компонентов, как в примере из начала этого поста. Даже если компонент появляется только один раз на странице, когда вы выходите из маршрута, только экземпляр компонента уничтожается, а не фабрика. Так что когда вы вернётесь, новый экземпляр компонента будет иметь следы предыдущего посещения страницы.
Эта ошибка может встречаться при использовании примесей. Несмотря на то, что Ember.Mixin
это не Ember.Object
, объявленные в нём свойства и методы, примешиваюся к Ember.Object
. Результат будет тем же: вы можете в конечном итоге разделить состояние между всеми объектами, которые используют примесь.
RubaXa
Ember тут не причем, типичная же проблема непонимания прототипов. Хотя они могли бы решить проблему на уровне реализации метода
.extend
, но это скользкая дорожка.justboris
Эту проблему пытаются решить будущие class properties.
Там инициализаторы свойств срабатывают для каждого объекта заново, и состояние не утекает
RubaXa
Повторю ещё раз, нет никакой проблемы, есть непонимание некоторых программистов, как работает наследование на прототипах. Сlass properties, как и сами классы, это просто сахар, который вместо определения свойств в прототипе, будет инициализировать их в конструкторе за вас.