В этой статье приведу ряд приемов, которые, на мой взгляд, помогут в разработке любого крупного проекта. Этот материал для вас, если вы уже написали свой todo лист на vue.js+vuex, но еще не зарылись в крупное велосипедостроение.
1. Централизованная шина событий (Event Bus)
Любой проект на vue.js состоит из вложенных компонентов. Основной принцип — props down, events up. Подкомпонент получает от родителя данные, которые он не может менять, и список событий родителя, которые он может запустить.
Принцип годный, но создает сильную связность. Если целевой компонент глубоко вложен, приходится протаскивать данные и события через все обертки.
Разберемся с событиями. Зачастую полезно иметь глобальный event emitter, с которым может общаться любой компонент независимо от иерархии. Его очень легко сделать, дополнительные библиотеки не нужны:
Object.defineProperty(Vue.prototype,"$bus",{
get: function() {
return this.$root.bus;
}
});
new Vue({
el: '#app',
data: {
bus: new Vue({}) // Here we bind our event bus to our $root Vue model.
}
});
После этого в любом компоненте появляется доступ к this.$bus, можно подписываться на события через this.$bus.$on() и вызывать их через this.$bus.$emit(). Вот пример.
Очень важно понимать, что this.$bus — глобальный объект на все приложение. Если забывать отписываться, компоненты остаются в памяти этого объекта. Поэтому на каждый this.$bus.$on в mounted должен быть соответствующий this.$bus.$off в beforeDestroy. Например, так:
mounted: function() {
this._someEvent = (..) => {
..
}
this._otherEvent = (..) => {
..
}
this.$bus.$on("someEvent",this._someEvent);
this.$bus.$on("otherEvent",this._otherEvent);
},
beforeDestroy: function() {
this._someEvent && this.$bus.$off("someEvent",this._someEvent);
this._otherEvent && this.$bus.$off("otherEvent",this._otherEvent);
}
2. Централизованная шина промисов (Promises Bus)
Иногда в компоненте нужно инициализировать некую асинхронную штуку (например, инстанц google maps), к которой хочется обращаться из других компонентов. Для этого можно организовать объект, который будет хранить промисы. Например, такой. Как и в случае в event bus, не забываем удаляться при деинициализации компонента. И вообще, указанным выше способом к vue можно прицепить любой внешний объект с любой логикой.
3. Плоские структуры (flatten store)
В сложном проекте данные зачастую сильно вложены. Работать с такими данными неудобно как в vuex, так и в redux. Рекомендуется уменьшать вложенность, например, воспользовавшись утилитой normalizr. Утилита — это хорошо, но еще лучше понимать, что она делает. Я не сразу пришел к пониманию плоской структуры, для таких же типа себя рассмотрю подробный пример.
Имеем проекты, в каждом — массив слоев, в каждом слое — массив страниц: projects > layers > pages. Как организовать хранилище?
Первое, что приходит в голову — обычная вложенная структура:
projects: [{
id: 1,
layers: [{
id: 1,
pages: [{
id: 1,
name: "page1"
},{
id: 2,
name: "page2"
}]
}]
}];
Такую структуру легко читать, легко бегать циклом foreach по проектам, рендерить подкомпоненты со списками слоев и так далее. Но предположим, что нужно поменять название страницы с id:1. Внутри некоторого маленького компонента, который отрисовывает страницу, вызывается $store.dispatch(«changePageName»,{id:1,name:«new name»}). Как найти место, где в этой глубоко вложенной структуре лежит нужный page с id:1? Пробегать по всему хранилищу? Не лучшее решение.
Можно указывать полный путь, типа
$store.dispatch("changePageName",{projectId:1,layerId:1,id:1,name:"new name"})
Но это значит, что в каждый маленький компонент рендеринга страницы нужно протаскивать всю иерархию, и projectId, и layerId. Неудобно.
Вторая попытка, из sql:
projects: [{id:1}],
layers: [{id:1,projectId:1}],
pages: [{
id: 1,
name: "page1",
layerId: 1,
projectId: 1
},{
id: 2,
name: "page2",
layerId: 1,
projectId: 1
}]
Теперь данные легко менять. Но тяжело бегать. Чтобы вывести все страницы в одном слое, нужно пробежать по вообще всем страницам. Это может быть спрятано в getter-е, или в рендеринге шаблона, но пробежка все равно будет.
Третья попытка, подход normalizr:
projects: [{
id: 1,
layersIds: [1]
}],
layers: {
1: {
pagesIds: [1,2]
}
},
pages: {
1: {name:"page1"},
2: {name:"page2"}
}
Теперь все страницы слоя могут быть получены через тривиальный геттер
layerPages: (state,getters) => (layerId) => {
const layer = state.layers[layerId];
if (!layer || !layer.pagesIds || layer.pagesIds.length==0) return [];
return layer.pagesIds.map(pageId => state.pages[pageId]);
}
Заметим, что геттер не бегает по списку всех страниц. Данные легко менять. Порядок страниц в слое задан в объекте layer, и это тоже правильно, поскольку процедура пересортировки как правило находится в компоненте, который выводит список объектов, в нашем случае это компонент, который рендерит layer.
4. Мутации не нужны
Согласно правилам vuex, изменения данных хранилища должны происходить только в функциях-мутациях, мутации должны быть синхронными. В vuex находится основная логика приложения. Поэтому блок валидации данных тоже будет логичным включить в хранилище.
Но валидация далеко не всегда синхронна. Следовательно, по крайней мере часть валидационной логики будет находится не в мутациях, а в действиях (actions).
Предлагаю не разбивать логику, и хранить в actions вообще всю валидацию. Мутации становятся примитивными, состоят из элементарных присваиваний. Но тогда к ним нельзя обращаться напрямую из приложения. Т.е. мутации — некая утилитарная штука внутри хранилища, которая полезна разве что для vuex-дебаггера. Общение приложения с хранилищем происходит через исключительно действия. В моем приложении любое действие, даже синхронное, всегда возвращает промис. Мне кажется, что заведомо считать все действия асинхронными (и работать с ними как с промисами) проще, чем помнить что есть что.
5. Ограничение реактивности
Иногда бывает, что данные в хранилище не меняются. Например, это могут быть результаты поиска объектов на карте, запрошенные из внешнего api. Каждый результат — это сложный объект с множеством полей и методов. Нужно выводить список результатов. Нужна реактивность списка. Но данные внутри самих объектов постоянны, и незачем отслеживать изменение каждого свойства. Чтобы ограничить реактивность, можно использовать Object.freeze.
Но я предпочитаю более тупой метод: пусть state хранит только список id-шников, а сами результаты лежат рядом в массиве. Типа:
const results = {};
const state = {resultIds:[]};
const getters = {
results: function(state) {
return _.map(state.resultsIds,id => results[id]);
}
}
const mutations = {
updateResults: function(state,data) {
const new = {};
const newIds = [];
data.forEach(r => {
new[r.id] = r;
newIds.push(r.id);
});
results = new;
state.resultsIds = newIds;
}
}
Вопросы
Кое-что у меня получилось не настолько красиво, как хотелось. Вот мои вопросы к сообществу:
— Как победить css анимации сложнее изменения opacity? Часто хочется анимировать появление какого-то блока неизвестных размеров, т.е. изменить его высоту с height: 0 до height: auto.
Это легко решается с javascript — просто оборачиваем в контейнер с overflow: hidden, смотрим высоту обернутого элемента и анимируем высоту контейнера. Это можно решить через css?
— Ищу нормальный способ работы с иконками в webpack, пока безуспешно (поэтому продолжаю пользоваться fontello). Нравятся иконки whhg. Вытащил svg, разбил на файлы. Хочу выбрать несколько файлов и автоматически собирать в inline шрифт + классы на основе названий файлов. Чем это можно делать?
Комментарии (12)
virtual_hack2root
07.07.2017 19:24+1По анимации, могу посоветовать вот эту часть (5) еще есть остальные 4 части
ollejah
07.07.2017 19:48Спасибо, познавательно. Особенно в части мутаций.
Я использую webpack-svgstore-plugin.
Также можно подключить директиву
<svg width="12" height="12" fill="#ccc9c6" v-svg="'mono-info'" />, # где `v-svg` — путь к `id` в спрайте
OlegOleg1980
08.07.2017 09:05Есть ли разница, где отписываться: в beforeDestroy или destroyed?
У меня тоже были проблемы с памятью, но после добавления отписок в destroyed от части утечек памяти избавился.
pmcode
09.07.2017 07:03+1Можно глупый вопрос? Почему event bus создается как пустой объект Vue. Во-первых, вроде как в приложении уже есть инстанс Vue. Почему нельзя использовать его? Во-вторых, с точки зрения дизайна это вообще ни разу не очевидно и выглядит как какой-то левый хак. И в целом, насколько легковесна такая операция? Потому что вот пример плагина, который берет и создает собственный even bus, хотя мог бы использовать тот, который уже есть в приложении.
JSmitty
11.07.2017 15:09Это рекомендация из официальной документации для распространения. Ранее (v1) был кроме emit() был еще dispatch(), который бросал эвент вверх по дереву компонентов.
profesor08
Анимируй не height, а max-height. Главное чтоб значение max-height было больше, чем элемент должен достигать. Тем самым установив элементу height: auto; max-height: 0; анимируешь уже только max-height.
Живой пример: jsfiddle
Reon
Вредный совет, высота неизвестна, а вы на глаз предлагаете подбирать.
profesor08
Во первых не на глаз, а выставить максимально возможную для элемента, которую он не должен достигать. Во вторых это единственное решение, которое есть. В третьих, результат работы полностью удовлетворительный.
Reon
1. У вас 1000 компонентов плавно изменяющих высоту, в разных местах приложения. Откуда вы узнаете максимально возможную высоту?
2. Блок с высотой 500px будет анимироваться 200мс, а с высотой 100px уже 40мс.
Единственно правильное решение — это мерить элемент javascript'ом.
profesor08
Речь шла исключительно о решении на CSS. Оно есть, оно не идеальное, есть подводные камни, но тем не менее, факт его существования и возможность его применения отрицать нельзя. И не надо приплетать хитровымудренные примеры, когда оно не может быть применено, для каждого решения есть своя область применения.
noodles
По хорошему, если вдруг очень нужно анимировать что-то кроме transform или opacity, например высоту как в данном случае, что влечёт перерасчёт макета страницы, то лучше использовать методику First Last Invert Play с помощью js. Немного мудрённо, но для для перформанса хорошо.
justboris
Это решение нечестное. Нужно заранее в CSS выставить max-height. Это получится только для фиксированных выпадающих меню, а у автора в проекте это используется для динамического списка точек. Придется ставить заведомо большее значение, что-то вроде 10000px.
А если вы выставите такое значение в своем демо, то заметите, что анимация выпадения сильно ускорилась. Это происходит потому, что браузер рассчитывает кадры, исходя из высоты от 0 до 10000, хотя реальная высота блока будет намного меньше.
В общем, чтобы получить нормальную анимацию, когда она всегда отрабатывает за фиксированные N секунд, нужно добавить немного Javascript.
А метод с max-height сгодится лишь для фиксированных блоков из 5 элементов, или прототипов, которые все равно придется переделывать нормально.