Ох уж эти модальные окна или почему я полюбил render-функции в VueJs

Привет всем!

Моя первая публикация прошла с неприятным осадком. Я обещал исправить это недоразумение и на ваш суд представляю свою первую статью-урок по 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.js

import 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 } 

В нем мы импортируем компоненты, используемые для наших модальных-премодальных окон:

  1. ModalWrapper.js — общая обертка для вывода наших окон
  2. Modal.js — собственно сам компонент модального окна. Его нет в оригинальном Vuedals. Использовать напрямую его не обязательно. Он в любом случае работает под капотом. Дальше по ходу пьесы увидите этот финт ушами и станет понятно для чего я его добавил.
  3. Bus.js — EventBus для коммуникации между компонентом-оберткой (ModalWrapper), модальными окнами (Modal) и нашим приложением VueJs.

Сразу приведу код Bus.js и опишу что там происходит. Как говорил ранее, EventBus оставил так, как есть в оригинале.

Bus.js

let 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 на странице.

И по порядку:

$modals

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);       }     }   }); 

Здесь мы цепляем к глобальному 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.js

export 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.scss

body.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:

handleEscapeKey

handleEscapeKey(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 и dismiss

Bus.$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 с индексом     }); 

Теперь методы:

splice

methods: { 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, о том, что активных окон нет       }     } } 

close

doClose(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:

dismiss

dismiss(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);     }, 

Вычисляемые свойства:

computed

computed: {     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 с исходниками. Документацию дописываю, на русском языке тоже будет.

FavoriteLoadingДобавить в избранное

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *