https://habrahabr.ru/post/350232/Привет всем!
Моя первая публикация прошла с неприятным осадком. Я обещал исправить это недоразумение и на ваш суд представляю свою первую статью-урок по VueJs. Надеюсь, она окажется полезной. Мыслей много, опыта тоже немало. Всю жизнь учусь по чужим статьям, урокам. Пришло время тоже делиться знаниями.
А будем мы творить модальные окна. Да опять они. Но не такие простые, как описаны в первой моей (не моей) публикации.
Много уже их создано для
Vue. Пользовался всякими. И видимо, когда достигаешь какого-то определенного уровня владения инструментом (в данном случае
Vue), сразу хочется сделать велосипед, но конечно со своими прибамбасами, типа, чтобы круче всех и т.д. И я не стал исключением из правил.
Из всех доступных модальных компонентов, использовал в основном этот —
Vuedals.
Но решил я его проапгрейдить. В принципе от основы остался только
EventBus и взаимодействие событий связанных с открытием-закрытием окон. Основной компонент переписан и стал оберткой-контейнером и добавлен новый компонент — само модальное окно.
Но обо всем по порядку. И статья получится очень немаленькая, кто осилит, тот красавчик :)
В основном модальные окна во всех примерах вызываются в таком стиле:
<template>
<button @click="visible = true">Show modal</button>
<modal :show="visible">
<div>какой то контент</div>
</modal>
</template>
<script>
export default {
data() {
return {
visible: false
}
}
</script>
Вроде все красиво. Но!
Какие вижу недостатки такого подхода.
Во-первых, темплейт модального окна находится внутри родительского компонента, где мы его вызываем. И контекст окна не изолирован от родителя. Мне так не всегда удобно и нужно.
Во-вторых, если одно и то-же окно используется в нескольких местах, приходится дублировать код. Что не есть гуд!
В-третьих, и что наверно является самым главным недостатком — мы можем использовать модальное окно только внутри страниц или других компонентов
Vue, а вот в местах типа
Vuex,
Router, да и вообще в любых скриптах не можем. Мне например, надо вызвать модальное окно входа/регистрации из роутера или из стора при каком-то событии. Примеров можно привести мильён.
Поэтому подход используемый в
Vuedals, когда мы открываем/закрываем окна путем вызова функции с параметрами и передавая «сырой» компонент, вида —
{
name: 'simple-modal',
props: ['test'],
template: "<div>{{test}}</div>"
}
или полноценный, который мы импортнули извне, мне оказался больше по душе.
Больше контроля, возможностей переиспользования и вызвать такое окно можно практически отовсюду.
Выглядит в общем это так, у нас есть компонент
ModalWrapper, который мы импортируем в приложение и вставляем например в корневой App-компонент. Где нибудь внизу.
Потом в любом месте вызываем метод this.$modals.open({ component: SimpleModal, title: 'Simple modal'}), куда передаем настройки нашего окна и компонент, который будем показывать и видим наше модальное окошко, которое рендерится в
ModalWrapper.
Есть куча событий, возникающие при всех манипуляциях с окнами, события эти управляются при помощи
EventBus-а, и их можно прослушивать и как-то реагировать.
Это вводные данные, чтобы информация легче осваивалась. В принципе, статья больше для новичков в Vue. Но надеюсь, будет пара моментов интересных и для искушенных.
Буду кидать куски кода и попутно комментировать, что к чему. Ссылка на примеры и исходники в конце есть.
Ну и пожалуй начнем с главного файла —
index.jsimport Bus from './utils/bus';
import ModalWrapper from './modal-wrapper';
import Modal from './modal'
const VuModal = {}
VuModal.install = (Vue) => {
Vue.prototype.$modals = new Vue({
name: '$modals',
created() {
Bus.$on('opened', data => {
this.$emit('modals:opened', data);
});
Bus.$on('closed', data => {
this.$emit('modals:closed', data);
});
Bus.$on('destroyed', data => {
this.$emit('modals:destroyed', data);
});
this.$on('new', options => {
this.open(options);
});
this.$on('close', data => {
this.close(data);
});
this.$on('dismiss', index => {
this.dismiss(index || null);
});
},
methods: {
open(options = null) {
Bus.$emit('new', options);
},
close(data = null) {
Bus.$emit('close', data);
},
dismiss(index = null) {
Bus.$emit('dismiss', index);
}
}
});
Vue.mixin({
created() {
this.$on('modals:new', options => {
Bus.$emit('new', options);
});
this.$on('modals:close', data => {
Bus.$emit('close', data);
});
this.$on('modals:dismiss', index => {
Bus.$emit('dismiss', index);
});
}
});
}
if (typeof window !== 'undefined' && window.Vue) {
window.Vue.use(VuModal);
}
export default VuModal;
export {
ModalWrapper,
Modal,
Bus
}
В нем мы импортируем компоненты, используемые для наших модальных-премодальных окон:
- ModalWrapper.js — общая обертка для вывода наших окон
- Modal.js — собственно сам компонент модального окна. Его нет в оригинальном Vuedals. Использовать напрямую его не обязательно. Он в любом случае работает под капотом. Дальше по ходу пьесы увидите этот финт ушами и станет понятно для чего я его добавил.
- Bus.js — EventBus для коммуникации между компонентом-оберткой (ModalWrapper), модальными окнами (Modal) и нашим приложением VueJs.
Сразу приведу код Bus.js и опишу что там происходит. Как говорил ранее,
EventBus оставил так, как есть в оригинале.
Bus.jslet instance = null;
class EventBus {
constructor() {
if (!instance) {
this.events = {};
instance = this;
}
return instance;
}
$emit(event, message) {
if (!this.events[event])
return;
const callbacks = this.events[event];
for (let i = 0, l = callbacks.length; i < l; i++) {
const callback = callbacks[i];
callback.call(this, message);
}
}
$on(event, callback) {
if (!this.events[event])
this.events[event] = [];
this.events[event].push(callback);
}
}
export default new EventBus();
Здесь мы создаем singleton-экземпляр
EventBus-а, который может подписываться на события (
$on) и вызывать (
$emit) события. Думаю здесь объяснять особо нечего.
EventBus собирает коллбеки и когда надо их вызывает. Дальше по ходу дела будет видно и понятно, как он связывает все наши компоненты.
А теперь по index.js
Здесь мы экспортируем по умолчанию дефолтную функцию
install — для подключения наших окон к приложению (используя
Vue.use()) и компоненты
ModalWrapper,
Modal и
Bus. Ну и при подключении
VueUniversalModal через script-тег в браузере, активируем наш компонент, если подключен глобальный
VueJs на странице.
И по порядку:
$modalsVue.prototype.$modals = new Vue({
name: '$modals',
created() {
Bus.$on('opened', data => {
this.$emit('modals:opened', data);
});
Bus.$on('closed', data => {
this.$emit('modals:closed', data);
});
Bus.$on('destroyed', data => {
this.$emit('modals:destroyed', data);
});
this.$on('new', options => {
this.open(options);
});
this.$on('close', data => {
this.close(data);
});
this.$on('dismiss', index => {
this.dismiss(index || null);
});
},
methods: {
open(options = null) {
Bus.$emit('new', options);
},
close(data = null) {
Bus.$emit('close', data);
},
dismiss(index = null) {
Bus.$emit('dismiss', index);
}
}
});
Здесь мы цепляем к глобальному
VueJs (через
prototype) экземпляр
Vue под именем
$modals.
В его методе
created (который запустится сразу, после запуска приложения) мы подписываем наш
EventBus к событиям
opened (открытие окна),
closed (закрытие окна) и
destroyed (окон нет, убираем
ModalWrapper). При возникновении этих событий
EventBus будет эмитить события
modals:opened,
modals:closed и
modals:destroyed в компонент
$modals. Эти события мы можем слушать везде, где доступен самолично
VueJs.
Вообще, я сначала хотел повыкидывать половину этих коммуникаций, так как некоторые совсем не обязательны, но подумав оставил. Может пригодиться для сбора какой-то статистики по модальным окнам, например. Да и начинающие, возможно что-то для себя поймут в этой, казалось бы, каше из
$on,
$emit — вызовов :)
Дальше this.$on…
Здесь мы включаем прослушивание событий
new,
close,
dismiss самим компонентом
$modals. При возникновении этих событий, вызываются соответствующие методы компонента
$modals. Которые в свою очередь открывают (
open), закрывают (
close) и отменяют (
dismiss) окно.
Как вы видите, у нас есть два способа закрыть окно —
dismiss (отменить или по буржуйски — cancel — из той же оперы) и
close (закрыть). Разница в том, что при закрытии модального окна через
close, мы можем передавать данные в функцию обратного вызова
onClose (рассмотрим далее), которую мы цепляем к опциям нашего нового модального окна.
И собственно методы
open,
close и
dismiss компонента
$modals. В них мы и запускаем через
EventBus, события
new,
close и
dismiss в нашем
ModalWrapper. Там уже и будет происходить вся магия.
И последнее в
install-функции файла index.js.
Vue.mixin({
created() {
this.$on('modals:new', options => {
Bus.$emit('new', options);
});
this.$on('modals:close', data => {
Bus.$emit('close', data);
});
this.$on('modals:dismiss', index => {
Bus.$emit('dismiss', index);
});
}
});
Здесь мы расширяем через
Vue-миксин всем компонентам
Vue метод
created, в котором при запуске включаем прослушку компонентами событий
modals:new,
modals:close и
modals:dismiss и при их вызове, через
EventBus опять же запускаем соответствующие события в
ModalWrapper.
Все эти адовы вызовы здесь нужны для управления нашими модальными окнами. И дают нам 4 варианта запуска событий
open,
close и
dismiss.
Первый способ вызова нашего модального окна в приложении:
this.$modals.open(options)
Второй способ:
this.$modals.$emit('new', options)
Третий:
this.$emit('modals:new', options)
И четвертый (для этого способа нам нужно импортировать Bus.js, но это дает нам возможность вызвать окно не из компонента
Vue, а из любого скрипта):
Bus.$emit('new', options)
Ну и
close,
dismiss по аналогии.
Тут как говорится — «на вкус и цвет» :)
Следующий больной — Modal.js или мы не ищем легких путей
Дальше пойдет код повеселее. С недавнего времени в своих компонентах использую
render-функции (как стандартные так и в
jsx-формате). Использовать начал повсеместно тогда, когда понял, что они дают больше возможностей для рендеринга. С
render-функциями вся мощь
Javascript-а плюс крутая внутренняя
VueJs кухня с
vNode дают ощутимые бонусы. В момент их появления я на них как-то косо глянул, подумал, нафиг надо и продолжил рисовать компоненты в
template-шаблонах. Но теперь я знаю, где собака зарыта :)
Modal — это полноценный компонент, который рендерит само модальное окно. У него куча входящих параметров:
modal.js - props props: {
title: { // заголовок окна
type: String,
default: ''
},
className: { // добавляемый css-класс к компоненту окна
type: String,
default: ''
},
isScroll: { // скроллинг контента, если он - контент не умещается в размеры окна
type: Boolean,
default: false
},
escapable: { // dismiss(сброс) окна по нажатию Esc-клавиши
type: Boolean,
default: false
},
dismissable: { // dismiss(сброс) окна по клику на его маску и показ кнопки закрытия в правом верхнем углу (крестика)
type: Boolean,
default: true
},
fullscreen: { // полноэкранный режим
type: Boolean,
default: false
},
isTop: { // прижать окно к верху страницы
type: Boolean,
default: false
},
isBottom: { // прижать окно к низу страницы
type: Boolean,
default: false
},
isLeft: { // прижать окно к левой стороне страницы
type: Boolean,
default: false
},
isRight: { // прижать окно к правой стороне страницы
type: Boolean,
default: false
},
center: { // окно посередине страницы
type: Boolean,
default: false
},
size: { // размер окна (высота)
type: String,
default: 'md'
},
bodyPadding: { // padding-отступы у body - элемента, в котором рендерится контент
type: Boolean,
default: true
}
},
В коде прокомментировал все параметры, чтобы было нагляднее. А код в спойлер кидаю, чтобы не получилась портянка. Итак текста немало.
Дальше:
import CloseIcon from './close-icon'
export default {
name: 'vu-modal',
componentName: 'vu-modal',
...
}
Вначале импортируем иконку-крестик в виде функционального компонента, который рендерит ее в
SVG-формате.
close-icon.jsexport default {
name: 'close-icon',
functional: true,
render(h) {
return h('svg', {
attrs: {
width: '12px',
height: '12px',
viewBox: '0 0 12 12',
xmlSpace: 'preserve'
}
}, [
h('line', {
attrs: {
x1: 1,
y1: 11,
x2: 11,
y2: 1
},
style: {
strokeLinecap: 'round',
strokeLinejoin: 'round',
}
}),
h('line', {
attrs: {
x1: 1,
y1: 1,
x2: 11,
y2: 11
},
style: {
strokeLinecap: 'round',
strokeLinejoin: 'round',
}
})
])
}
}
Зачем я ее так сообразил, даже не знаю.
Дальше параметр
name — ну это стандартный ход.
А вот
componentName здесь неспроста. Он нам нужен будет дальше, в
ModalWrapper-е при рендеринге.
И вычисляемый параметр
propsData:
export default {
...
computed: {
propsData() {
return (this.$parent.$vnode.data.props
&& this.$parent.$vnode.data.props.vModal) ?
this.$parent.$vnode.data.props.vModal : this.$props
}
}
}
Здесь уже сложнее.
А дело вот в чем. В оригинальном
Vuedals все окна тоже вызываются с помощью тех 4-х способов, описанных выше. В каждом из них мы должны передавать компонент, который хотим показать в окне и параметры окна (все они сейчас есть во входящих параметрах
Modal и плюс добавлены несколько новых). И если мы хотим запустить одно и то же окно в разных частях приложения, мы каждый раз передаем параметры окна (размеры, другие настройки). Что опять является дублированием. Да и запутаться недолго. А мы, программисты, существа крайне ленивые в основе своей. Поэтому и был создан этот компонент
Modal.
Теперь мы можем создать компонент модального окна, например, вот так:
simple-modal.js<template lang="html">
<vu-modal title="Test modal" :isScroll="true" size="p50" :center="true" :escapable="true">
<div slot="header" style="display: flex; justify-content: left; align-items: center;" v-if="header">
<div style="padding-left: 10px">Simple modal</div>
</div>
<div>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Quia consequuntur minus sint quidem ut tenetur dicta sunt voluptates numquam. Eum totam ex maxime aut recusandae quae laborum fugit ab autem.</p>
</div>
<div slot="footer">
<button class="uk-button uk-button-smaller uk-button-primary" @click="close">Cancel</button>
</div>
</vu-modal>
</template>
<script>
export default {
name: 'simple-modal',
props: {
lorem: {
type: Boolean,
default: true
}
}
};
</script>
То есть, стандартный компонент. Обернутый нашим
Modal (
vu-modal). Этому
vu-modal мы передаем нужные нам параметры. Они и будут значениями по умолчанию для этого окна.
И теперь мы вызываем это окно так:
import SimpleModal from './modals/simple'
...
this.$modals.open({
component: SimpleModal
})
Все нужные нам значения дефолтных настроек окна берутся автоматом из того самого компонента
SimpleModal, снятых с обертки
vu-modal. Мы один раз создали компонент окна с нужными нам настройками и потом используем его в любом месте не парясь о настройках. Более того, если нам надо переназначить те значения по умолчанию, мы указываем нужные нам значения при вызове этого окна:
import SimpleModal from './modals/simple'
...
this.$modals.open({
component: SimpleModal,
center: false
})
Теперь параметр
center заменит дефолтный параметр указанный в шаблоне окна —
SimpleModal.
То есть приоритет такой при мерджинге (слиянии) параметров:
- props (дефолтные значения в modal.js)
- props (в шаблоне компонента, обернутого vu-modal)
- options (при вызове окна)
Чем ниже, тем главнее.
Так вот вычисляемое свойство
propsData в компоненте
vu-modal возвращает нам правильные входящие параметры (
props), учитывая момент, является ли данный экземпляр
vu-modal оберткой в каком-то компоненте (
SimpleModal) или же нет.
Для этого при рендеринге окна в
ModalWrapper, если компонент этого окна обернут в
vu-modal мы будем передавать смердженные
props-ы под именем
vModal, в другом случае будем передавать обычные
props-ы.
Но так как в случае, когда компонент обернут в
vu-modal, при рендере
props-ы будут попадать в этот компонент-родитель (
SimpleModal), мы и проверяем, есть ли у компонента-родителя входящий параметр с именем
vModal. Если есть, то берем эти значения, иначе стандартные
props-ы.
А проверяем мы не у
this.$parent.$options.propsData, а именно у
this.$parent.$vnode.data.props, потому что если у компонента-родителя не прописан в
props-ах параметр
vModal, то при рендеринге этот
vModal мы cможем увидеть только у
this.$parent.$vnode.data.props. Сюда попадают все без исключения параметры, которые мы передали. А потом уже фильтруются и лишние отбрасываются.
Приведу еще раз этот кусок кода, он маленький, чтобы с мысли не сбивать)
export default {
...
computed: {
propsData() {
return (this.$parent.$vnode.data.props
&& this.$parent.$vnode.data.props.vModal) ?
this.$parent.$vnode.data.props.vModal : this.$props
}
}
}
Сейчас возможно не совсем все понятно. Информации много, и не совсем все стандартное, как во многих уроках. Пишу такого рода статью в первый раз, не совсем пока ясно, как лучше преподать. Во внутренностях
Vue копаются наверняка многие, но мало кто пишет об этом. Сам долгое время искал инфу по таким моментам. Что-то находил, остальное ковырял сам. И хочется рассказывать о таких вещах.
Но более понятно станет, когда будем разбирать
ModalWrapper. Там мы будем формировать и отправлять смердженные
props-ы нашим окнам.
Ну и осталась
render-функция нашего компонента
Modal (
vu-modal):
render(h)"render(h) {
const { dismissable, title, isScroll, fullscreen, isTop, isBottom, isLeft, isRight, center, size, className, bodyPadding } = this.propsData
const closeBtn = dismissable
? h('div', {
class: 'vu-modal__close-btn',
on: {
click: () => {this.$modals.dismiss()}
}
}, [h(CloseIcon)])
: null
const headerContent = this.$slots.header
? this.$slots.header
: title
? h('span', {class: ['vu-modal__cmp-header-title']}, title)
: null
const header = headerContent
? h('div', {
class: ['vu-modal__cmp-header']
}, [ headerContent ])
: null
const body = h('div', {
class: ['vu-modal__cmp-body'],
style: {
overflowY: isScroll ? 'auto' : null,
padding: bodyPadding ? '1em' : 0
}
}, [ this.$slots.default ])
const footer = this.$slots.footer
? h('div', {
class: ['vu-modal__cmp-footer']
}, [ this.$slots.footer ])
: null
let style = {}
let translateX = '-50%'
let translateY = '0'
if(center) {
translateX = '-50%'
translateY = '-50%'
}
if(isRight || isLeft) {
translateX = '0%'
}
if((isTop || isBottom) && !isScroll && !center) {
translateY = '0%'
}
style.transform = `translate(${translateX}, ${translateY})`
return h('div', {
style,
class: ['vu-modal__cmp', {
'vu-modal__cmp--is-fullscreen': fullscreen,
'vu-modal__cmp--is-center': center,
'vu-modal__cmp--is-top': isTop && !isScroll && !center,
'vu-modal__cmp--is-bottom': isBottom && !isScroll && !center,
'vu-modal__cmp--is-left': isLeft,
'vu-modal__cmp--is-right': isRight
},
isScroll && fullscreen && 'vu-modal__cmp--is-scroll-fullscreen',
isScroll && !fullscreen && 'vu-modal__cmp--is-scroll',
!fullscreen && `vu-modal__cmp--${size}`,
className
],
on: {click: (event) => {event.stopPropagation()}}
}, [
closeBtn,
header,
body,
footer
])
}
Здесь вроде ничего необычного.
Сначала вытаскиваем все наши параметры из ранее описанного вычисляемого значения
propsData.
Выводим кнопку-крестик, которое вызывает событие
dismiss (отмену окна), если свойство
dismissable равно
true.
Формируем
header — если нашему
vu-modal передан слот с именем
header (
this.$slots.header) рисуем этот слот, если передано свойство
title — выводим его, иначе
header вообще не показываем.
Формируем блок
body с содержимым дефолтного слота (
this.$slots.default).
И следом
footer — если передан слот
footer (
this.$slots.footer).
Дальше мы определяем правильные значения для
css-свойства
transform: translate(x, y) нашего окна. А именно параметры
X и
Y в зависимости от переданных свойств нашему окну. И потом мы передаем при рендере этот
transform главному
div-у окна для правильного позиционирования.
Ну и рендерим все это дело, попутно вычисляя нужные класс.
И плюс вешаем на главный
div.vu-modal__cmp onClick-обработчик, с
event.stopPropagation(), чтобы клик по окну не всплывал выше, дабы не активировать клик по
div-у (маске), которым обернуто каждое окно и которое реагирует на клик и вызывает
dismiss. Иначе сработает это событие
dismiss на маске и наше окно закроется.
Уффф!
Завершающий компонент — ModalWrapper
Начало modal-wrapper.js
import './style.scss'
import Bus from './utils/bus'
import ModalCmp from './modal'
export default {
name: 'vu-modal-wrapper',
data () {
return {
modals: []
}
},
mounted() {
if (typeof document !== 'undefined') {
document.body.addEventListener('keyup', this.handleEscapeKey)
}
},
destroyed() {
if (typeof document !== 'undefined') {
document.body.removeEventListener('keyup', this.handleEscapeKey)
}
},
Подключаем наши стили:
style.scssbody.modals-open {
overflow: hidden;
}
.vu-modal {
&__wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 5000;
overflow-x: hidden;
overflow-y: auto;
transition: opacity .4s ease;
}
&__mask {
background-color: rgba(0, 0, 0, .5);
position: absolute;
width: 100%;
height: 100%;
overflow-y: scroll;
&--disabled {
background-color: rgba(0, 0, 0, 0);
}
}
&__cmp {
display: flex;
flex-direction: column;
border-radius: 0px;
background: #FFF;
box-shadow: 3px 5px 20px #333;
margin: 30px auto;
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 650px;
&--is-center {
margin: auto;
top: 50%;
}
&--is-scroll {
max-height: 90%;
}
&--is-scroll-fullscreen {
max-height: 100%;
}
&--is-fullscreen {
width: 100%;
min-height: 100%;
margin: 0 0;
}
&--is-bottom {
bottom: 0;
}
&--is-top {
top: 0;
}
&--is-right {
right: 0;
margin-right: 30px;
}
&--is-left {
left: 0;
margin-left: 30px;
}
&--xl {
width: 1024px;
}
&--lg {
width: 850px;
}
&--md {
width: 650px;
}
&--sm {
width: 550px;
}
&--xs {
width: 350px;
}
&--p50 {
width: 50%;
}
&--p70 {
width: 70%;
}
&--p90 {
width: 90%;
}
&-body {
padding: 1em;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
}
&::-webkit-scrollbar-thumb {
background-color: darkgrey;
outline: 1px solid slategrey;
}
}
&-header {
user-select: none;
border-bottom: 1px solid #EEE;
padding: 1em;
text-align: left;
&-title {
font-size: 16px;
font-weight: 800;
}
}
&-footer {
border-top: solid 1px #EEE;
user-select: none;
padding: 1em;
text-align: right;
}
}
&__close-btn {
user-select: none;
position: absolute;
right: 12px;
top: 5px;
line {
stroke: grey;
stroke-width: 2;
}
&:hover {
cursor: pointer;
line {
stroke: black;
}
}
}
}
В массиве
modals мы будем хранить наши окна, которые активны в данный момент.
Ну и при монтировании и удалении нашего
ModalWrapper-компонента вешаем обработчик
keyup на
window (если window есть), который запускает метод
handleEscapeKey:
handleEscapeKeyhandleEscapeKey(e) {
if (e.keyCode === 27 && this.modals.length) {
if (!this.modals.length)
return;
if (this.current.options.escapable)
this.dismiss();
}
}
Который в свою очередь, если нажата
Esc-клавиша и есть окно(а) и у текущего (запущенного последним) окна свойство
escapable равно
true, запускает метод
dismiss, который закрывает это самое текущее окно.
Ну и пожалуй самое интересное началось. Попробую описывать происходящее прямо в коде, может так лучше будет.
При создании нашего
ModalWrapper включаем прослушку событий от
EventBus-а. Тех самых, которые запускаются в методах
$modals, описанных раннее:
created()created() {
Bus.$on('new', options => { // главное событие, открытие окна
const defaults = { // значения параметров по умолчанию, эти же параметры у компонента Modal
title: '',
dismissable: true,
center: false,
fullscreen: false,
isTop: false,
isBottom: false,
isLeft: false,
isRight: false,
isScroll: false,
className: '',
size: 'md',
escapable: false,
bodyPadding: true
};
// а вот здесь немного магии!
// формируем правильные props-ы для нового окна. О чем говорил раннее.
// rendered нужен нам для определения того, является ли переданный компонент в options, обернутым в Modal (vu-modal) или нет
let instance = {} // объект, который мы будем добавлять в массив modals
let rendered
if(options.component.template) {
// если мы передаем "сырой" компонент с template, то оберток Modal мы там не ожидаем, хотя возможен такой вариант. Но не будем его рассматривать. Это дело можно поправить. Сейчас это не актуально.
rendered = false
} else {
// иначе вызываем функцию render переданного компонента, дабы получить его componentOptions
rendered = options.component.render.call(this, this.$createElement)
}
// из которых мы и вытаскиваем таким длинным путем упомянутое раннее корневое свойство componentName компонента Modal. Если оно есть и равно 'vu-modal', то наш компонент обернут Modal (vu-modal)
if(rendered && rendered.componentOptions && rendered.componentOptions.Ctor.extendOptions.componentName === 'vu-modal') {
// в таком случае берем его props-ы, те самые которые мы указали в template-компонента у vu-modal
const propsData = rendered.componentOptions.propsData
instance = {
isVmodal: true, // это значение тоже передаем в массив, чтобы использовать позднее при рендеринге
options: Object.assign(defaults, propsData, options) // опции мерджим по приоритету описанному раннее
}
} else {
instance = {
isVmodal: false, // иначе у нас компонент не обернут vu-modal
options: Object.assign(defaults, options) // опции мерджим только с дефолтными
}
}
rendered = null
this.modals.push(instance); // добавляем в modals
Bus.$emit('opened', { // посылаем событие об открытом окне через EventBus c данными нового окна
index: this.$last, // его индекс в массиве, последний
instance // и настройки
});
this.body && this.body.classList.add('modals-open'); // добавляем к элементу body страницы нужный класс
});
Далее события:
close и dismissBus.$on('close', data => { // помните, раннее я писал о возможности передачи данных при закрытии окна через close
let index = null;
if (data && data.$index)
index = data.$index; // можем передать индекс определенного окна
if (index === null)
index = this.$last; // если индекса нет, то берем последний
this.close(data, index); // вызываем метод close с данными и индексом
});
Bus.$on('dismiss', index => { // при закрытии окна через dismiss, можем указать индекс определенного окна
if (index === null)
index = this.$last; // если нет, берем последний
this.dismiss(index); // вызываем метод dismiss с индексом
});
Теперь методы:
splicemethods: {
splice(index = null) { // для внутреннего использования, при закрытии окна
if (index === -1)
return;
if (!this.modals.length)
return;
if (index === null) // если индекс не передан, то удаляем последний
this.modals.pop();
else
this.modals.splice(index, 1);
if (!this.modals.length) { // если окна закончились
this.body && this.body.classList.remove('modals-open'); // у body убираем класс 'modals-open'
Bus.$emit('destroyed'); // и посылаем сигнал, через EventBus, о том, что активных окон нет
}
}
}
closedoClose(index) { // здесь мы удаляем из массива modals окно, при помощи метода splice, описанного выше
if (!this.modals.length)
return;
if (!this.modals[index])
return;
this.splice(index);
},
// собственно, главный обработчик закрытия окна, через close. Можем передать нужные данные и указать на конкретное окно по индексу
close(data = null, index = null) {
if (this.modals.length === 0)
return;
let localIndex = index;
// если переданный index является функцией, запускаем ее для определения нужного индекса, при этом передаем в эту функцию данные и массив с окнами. То есть можем закрыть определенное окно, при каких-то условиях
if (index && typeof index === 'function') {
localIndex = index(data, this.modals);
}
if (typeof localIndex !== 'number')
localIndex = this.$last; // иначе берем последнее окно
// далее, смотрим, если в настройках окна есть callback-функция onClose, запускаем ее с переданными данными, если они есть
// мы можем вернуть из onClose какое-то значение, если оно равно false, то отменяем закрытие окна
if (localIndex !== false && this.modals[localIndex]) {
if(this.modals[localIndex].options.onClose(data) === false) {
return
}
}
Bus.$emit('closed', { // эмитим событие 'closed' и передаем туда все данные
index: localIndex, // индекс окна
instance: this.modals[index], // его настройки
data // и данные, если есть
});
// и собственно, если выше все прошло удачно, удаляем окно из массива modals, тем самым закрывая его
this.doClose(localIndex);
},
В методе
dismiss, все аналогично методу
close:
dismissdismiss(index = null) {
let localIndex = index;
if (index && typeof index === 'function')
localIndex = index(this.$last);
if (typeof localIndex !== 'number')
localIndex = this.$last;
if (this.modals[localIndex].options.onDismiss() === false)
return;
Bus.$emit('dismissed', {
index: localIndex,
instance: this.modals[localIndex]
});
this.doClose(localIndex);
},
Вычисляемые свойства:
computedcomputed: {
current() { // активное окно
return this.modals[this.$last];
},
$last() { // индекс активного (последнего) окна
return this.modals.length - 1;
},
body() { // елемент body, если есть, для добавления/удаления класса 'modals-open'
if (typeof document !== 'undefined') {
return document.querySelector('body');
}
}
}
Ну и последняя функция, теперь мною любимая:
render(h)render(h) {
// если окон нет, то выводим пустоту
if(!this.modals.length) {
return null
};
// пробегаем по всем окнам
let modals = this.modals.map((modal, index) => { // рендерим массив окон
let modalComponent // здесь будет собранный компонент окна
if(modal.isVmodal) { // если переданный в опциях компонент уже обернут Modal (vu-modal)
// рендерим его и передаем в него props-ы, включающие в себя vModal c параметрами для компонента vu-modal и переданными props-ами для самого компонента, если таковые имеются
modalComponent = h(modal.options.component, {
props: Object.assign({}, {vModal: modal.options}, modal.options.props)
})
} else {
// иначе рендерим компонент Modal с параметрами окна вычисленными выше и в него рендерим компонент, переданный в опциях с props-ами, опять же, если таковые были переданы
modalComponent = h(ModalCmp, {
props: modal.options
}, [
h(modal.options.component, {
props: modal.options.props
})
])
}
// возвращаем отрендеренную маску с окном внутри, если окно не последнее - глушим маску через css
// если dismissable окна равно true, подключаем обработчик, для закрытия
return h('div', {
class: ['vu-modal__mask', {'vu-modal__mask--disabled': index != this.$last }],
on: {click: () => {modal.options.dismissable && this.dismiss()}},
key: index
}, [
modalComponent // наше итоговое модальное окно
])
})
// и конечный рендер враппера с окнами внутри
return h('div', {
class: 'vu-modal__wrapper',
}, [ modals ])
}
// Конец! :)
Вот такая история. Длинная история. В следующий раз буду стараться короче, если так нужно.
Ну и в итоге пример кода для открытия окна, чтобы информация усвоилась лучше.
this.$modals.open({
title: 'Modal with callbacks',
component: Example,
props: {
lorem: true,
test: true
},
onDismiss() {
console.log('Dismiss ok!')
}
onClose(data) {
if(data.ended) return false
console.log('Ok!')
}
})
А запускаем мы
close, например по кнопке в нашем окне, передаем туда данные:
this.$modals.close({
ended: true
})
В этом случае перед закрытием запускается наш коллбек
onClose.
По аналогии работает
onDismiss. Этот коллбек запускается при клике на кнопке-крестике, маске окна или прямо в нашем окне, например при клике в футере на кнопке 'Cancel':
this.$modals.dismiss()
И еще. По поводу
render-функций. Они выглядят конечно не так презентабельно, как код в
template. Но в них можно делать то, что в
template невозможно, или возможно, но с костылями и на порядок большим количеством кода, чем получается в
render-функции. И если рисуете компоненты в
render-функциях, очень осторожно изменяйте в них
props- и
data-свойства, от которых зависит рендер, иначе рискуете уйти в бесконечный цикл обновлений (
update) компонента.
Наверно пока все. Итак кучу накатал. Но хотелось описать всю движуху. Следующая пара статей будет короче. Но, тоже с нюансами, о которых хочется рассказать.
И спасибо всем, кто дожил до этой строки! :)
P.S.
Здесь примеры окон. Там же есть ссылка на Github с исходниками. Документацию дописываю, на русском языке тоже будет.