Недавно решил разобраться с 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 шрифт + классы на основе названий файлов. Чем это можно делать?
Поделиться с друзьями
-->

Комментарии (12)


  1. profesor08
    07.07.2017 18:14
    +1

    Часто хочется анимировать появление какого-то блока неизвестных размеров, т.е. изменить его высоту с height: 0 до height: auto.

    Анимируй не height, а max-height. Главное чтоб значение max-height было больше, чем элемент должен достигать. Тем самым установив элементу height: auto; max-height: 0; анимируешь уже только max-height.
    <div id="menu">
        <a>hover me</a>
        <ul id="list">
            <!-- Create a bunch, or not a bunch, of li's to see the timing. -->
            <li>item</li>
            <li>item</li>
            <li>item</li>
            <li>item</li>
            <li>item</li>
        </ul>
    </div>
    

    #menu #list {
        max-height: 0;
        transition: max-height 0.15s ease-out;
        overflow: hidden;
        background: #d5d5d5;
    }
    
    #menu:hover #list {
        max-height: 500px;
        transition: max-height 0.25s ease-in;
    }
    
    


    Живой пример: jsfiddle


    1. Reon
      07.07.2017 20:51
      +1

      Вредный совет, высота неизвестна, а вы на глаз предлагаете подбирать.


      1. profesor08
        07.07.2017 22:00

        Во первых не на глаз, а выставить максимально возможную для элемента, которую он не должен достигать. Во вторых это единственное решение, которое есть. В третьих, результат работы полностью удовлетворительный.


        1. Reon
          08.07.2017 10:12
          +1

          1. У вас 1000 компонентов плавно изменяющих высоту, в разных местах приложения. Откуда вы узнаете максимально возможную высоту?
          2. Блок с высотой 500px будет анимироваться 200мс, а с высотой 100px уже 40мс.
          Единственно правильное решение — это мерить элемент javascript'ом.


          1. profesor08
            08.07.2017 17:31

            Речь шла исключительно о решении на CSS. Оно есть, оно не идеальное, есть подводные камни, но тем не менее, факт его существования и возможность его применения отрицать нельзя. И не надо приплетать хитровымудренные примеры, когда оно не может быть применено, для каждого решения есть своя область применения.


    1. noodles
      08.07.2017 21:16

      По хорошему, если вдруг очень нужно анимировать что-то кроме transform или opacity, например высоту как в данном случае, что влечёт перерасчёт макета страницы, то лучше использовать методику First Last Invert Play с помощью js. Немного мудрённо, но для для перформанса хорошо.


    1. justboris
      09.07.2017 15:40
      -1

      Это решение нечестное. Нужно заранее в CSS выставить max-height. Это получится только для фиксированных выпадающих меню, а у автора в проекте это используется для динамического списка точек. Придется ставить заведомо большее значение, что-то вроде 10000px.


      А если вы выставите такое значение в своем демо, то заметите, что анимация выпадения сильно ускорилась. Это происходит потому, что браузер рассчитывает кадры, исходя из высоты от 0 до 10000, хотя реальная высота блока будет намного меньше.


      В общем, чтобы получить нормальную анимацию, когда она всегда отрабатывает за фиксированные N секунд, нужно добавить немного Javascript.


      А метод с max-height сгодится лишь для фиксированных блоков из 5 элементов, или прототипов, которые все равно придется переделывать нормально.


  1. virtual_hack2root
    07.07.2017 19:24
    +1

    По анимации, могу посоветовать вот эту часть (5) еще есть остальные 4 части


  1. ollejah
    07.07.2017 19:48

    Спасибо, познавательно. Особенно в части мутаций.


    Я использую webpack-svgstore-plugin.
    Также можно подключить директиву


    <svg width="12" height="12" fill="#ccc9c6" v-svg="'mono-info'" />,
    # где `v-svg` — путь к `id` в спрайте


  1. OlegOleg1980
    08.07.2017 09:05

    Есть ли разница, где отписываться: в beforeDestroy или destroyed?
    У меня тоже были проблемы с памятью, но после добавления отписок в destroyed от части утечек памяти избавился.


  1. pmcode
    09.07.2017 07:03
    +1

    Можно глупый вопрос? Почему event bus создается как пустой объект Vue. Во-первых, вроде как в приложении уже есть инстанс Vue. Почему нельзя использовать его? Во-вторых, с точки зрения дизайна это вообще ни разу не очевидно и выглядит как какой-то левый хак. И в целом, насколько легковесна такая операция? Потому что вот пример плагина, который берет и создает собственный even bus, хотя мог бы использовать тот, который уже есть в приложении.


    1. JSmitty
      11.07.2017 15:09

      Это рекомендация из официальной документации для распространения. Ранее (v1) был кроме emit() был еще dispatch(), который бросал эвент вверх по дереву компонентов.