Недавно решил разобраться с vue.js. Лучший способ изучить технологию — что-нибудь на ней написать. С этой целью был переписан мой старый планировщик маршрутов, и получился вот такой проект. Код получился достаточно большим для того, чтобы столкнуться с задачей масштабирования.
В этой статье приведу ряд приемов, которые, на мой взгляд, помогут в разработке любого крупного проекта. Этот материал для вас, если вы уже написали свой 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 шрифт + классы на основе названий файлов. Чем это можно делать?
Автор: Алексей Кузнецов