Русский
Русский
English
Статистика
Реклама

Javacript

Из песочницы Делаем модальные окна для сайта. Заботимся об удобстве и доступности

18.09.2020 12:15:01 | Автор: admin

Я занимаюсь вёрсткой и программированием сайтов. Почти в каждом макете, который я верстал, были модальные окна. Обычно это формы заказа звонка в лендингах, уведомления о завершении каких-то процессов, или сообщения об ошибках.


Вёрстка таких окон сначала кажется простой задачей. Модальные окна можно сделать даже без помощи JS только лишь с помощью CSS, но на практике они оказываются неудобными, и из-за маленьких недочетов модальные окна раздражают посетителей сайта.


В итоге было задумано сделать собственное простое решение.



Вообще говоря, есть несколько готовых скриптов, JavaScript библиотек, реализующих функционал модальных окон, например:


  • Arctic Modal,
  • jquery-modal,
  • iziModal,
  • Micromodal.js,
  • tingle.js,
  • Bootstrap Modal (из библиотеки Bootstrap) и др.

(в статье не рассматриваем решения на базе Frontend-фреймворков)


Несколькими из них я пользовался сам, но почти у всех находил какие-то недостатки. Некоторые из них требуют подключения библиотеки jQuery, которая есть не на всех проектах. Для разработки своего решения, нужно сначала определиться с требованиями.


Что мы ждём от модальных окон? Отвечая на этот вопрос, я основывался на докладе Знакомьтесь, модальное окно Анны Селезнёвой, а так-же на относительно старой статье NikoX arcticModal jQuery-плагин для модальных окон.


Итак, чтобы нам хотелось видеть?


  • Окна должны открываться как можно быстрее, без тормозов браузера, с возможностью анимировать открытие и закрытие.
  • Под окном должен быть оверлей. Клик/тап по оверлею должен закрывать окно.
  • Страница под окном не должна прокручиваться.
  • Окон может быть несколько. Открытие одного определенного окна должно осуществляться кликом на любой элемент страницы с data-атрибутом, который мы выберем.
  • Окно может быть длинным прокручиваемым.
  • Желательно поработать над доступностью, а также с переносом фокуса внутрь окна и обратно.
  • Должно работать на IE11+

Дисклеймер: Прежде чем мы рассмотрим подробности, сразу дам ссылку на готовый код получившейся библиотеки (HystModal) на GitHub, а также ссылку на демо+документацию.


Начнём с разметки.


1. Разметка HTML и CSS


1.1. Каркас модальных окон


Как открыть окно быстро? Самое простое решение: разместить всю разметку модального окна сразу в HTML странице. Затем скрывать/показывать это окно при помощи переключения классов CSS.


Набросаем такую разметку HTML (я назвал этот скрипт hystmodal):


<div class="hystmodal" id="myModal">    <div class="hystmodal__window">        <button data-hystclose class="hystmodal__close">Close</button>          Текст модального окошка.        <img src="img/photo.jpg" alt="Изображение в окне" />    </div></div>

Итак, разместим перед закрывающим тегом </body> наш блок с окном (.hystmodal). Он будет фоном. Удобно указать уникальный атрибут id (например #myModal) каждому окну (ведь их у нас может быть несколько).


Сделаем так, чтобы .hystmodal растягивался на всё окно браузера и закрывал собой содержимое страницы. Чтобы этого добиться, установим фиксированное позиционирование в CSS и приравняем свойства top, bottom, left и right к нулю.


.hystmodal {    position: fixed;    top: 0;    bottom: 0;    right: 0;    left: 0;    overflow: hidden;    overflow-y: auto;    -webkit-overflow-scrolling: touch;    display: flex;    flex-flow: column nowrap;    justify-content: center; /* см. ниже */    align-items: center;    z-index: 99;    /* Чтобы окно не прилипало к границе    браузера установим отступы */    padding:30px 0;}

В этом коде сделаны ещё две вещи:


  1. Так как мы хотим центрировать окно внутри страницы, превращаем .hystmodal в flex-контейнер с выравниваем его потомков по центру по вертикали и горизонтали.
  2. Окно может быть больше высоты экрана браузера, поэтому мы устанавливаем overflow-y: auto, чтобы при переполнении возникала полоса прокрутки. Также, для сенсорных экранов (в основном для Safari) нам стоит установить свойство -webkit-overflow-scrolling: touch, чтобы сенсорная прокрутка работала именно на этом блоке а не на странице.

Теперь установим стили для самого окна.


.hystmodal__window {    background: #fff;    /* Установим по умолчанию ширину 600px    но она будет не больше ширины браузера */    width: 600px;    max-width: 100%;    /* Заготовка для будущих анимаций */    transition: transform 0.15s ease 0s, opacity 0.15s ease 0s;    transform: scale(1);}

Кажется возникли сложности.


Проблема 1. Если высота окна больше высоты окна браузера, то контент окна будет обрезан сверху.



Это возникает из-за свойства justify-content: center. Оно позволяет нам удобно выровнять потомков по основной оси (по вертикали), но если потомок становится больше родителя то часть его становится недоступной даже при прокручиваемом контейнере. Подробнее можно посмотреть на stackoverflow. Решение установить justify-content: flex-start, а потомку установить margin:auto. Это выровняет его по центру.


Проблема 2. В ie-11 если высота окна больше высоты окна браузера, то фон окна обрезается.


Решение: мы можем установить flex-shrink:0 потомку тогда обрезки не происходит.


Проблема 3. В браузерах кроме Chrome нет отступа от нижней границы окна (т.е. padding-bottom не сработал).


Сложно сказать баг это браузеров или наоборот соответствует спецификации, но решения два:


  • установить псевдоэлемент ::after после потомка и дать ему высоту вместо padding
  • обернуть элемент в дополнительный блок и дать отступы уже ему.

Воспользуемся вторым методом. Добавим обертку .hystmodal__wrap. Так мы заодно обойдём и проблему 1, а вместо padding у родителя установим margin-top и margin-top у самого .hystmodal__window.


Наш итоговый html:


<div class="hystmodal" id="myModal" aria-hidden="true" >    <div class="hystmodal__wrap">        <div class="hystmodal__window" role="dialog" aria-modal="true" >            <button data-hystclose class="hystmodal__close">Close</button>              <h1>Заголовок модального окна</h1>            <p>Текст модального окна ...</p>            <img src="img/photo.jpg" alt="Изображение" width="400" />            <p>Ещё текст модального окна ...</p>        </div>    </div></div>

В код также добавлены некоторые aria и role атрибуты для обеспечения доступности.


Обновленный код CSS для обертки и окна.


.hystmodal__wrap {    flex-shrink: 0;    flex-grow: 0;    width: 100%;    min-height: 100%;    margin: auto;    display: flex;    flex-flow: column nowrap;    align-items: center;    justify-content: center;}.hystmodal__window {    margin: 50px 0;    flex-shrink: 0;    flex-grow: 0;    background: #fff;    width: 600px;    max-width: 100%;    overflow: visible;    transition: transform 0.2s ease 0s, opacity 0.2s ease 0s;    transform: scale(0.9);    opacity: 0;}

1.2 Скрываем окно


Сейчас наше окно всегда видно. Когда говорят о скрытии элементов, первое что приходит на ум это переключать свойство display со значения none до нашего значения flex.


Но этот подход нас не устроит, ведь свойство display не анимируется. Все переходы дочерних элементов, указанные в свойстве transition, работать не будут.


Нам поможет другое свойство visibility:hidden. Оно скроет окно визуально, хотя и зарезервирует под него место. А так как все будущие окна на странице имеют фиксированное
позиционирование они будут полностью скрыты и не повлияют на остальную страницу. Кроме того, на элементы с visibility:hidden нельзя установить фокус с клавиатуры, а от скрин-ридеров мы уже скрыли окна с помощью атрибута aria-hidden="true".


Добавим также классы для открытого окна:


.hystmodal--active{    visibility: visible;}.hystmodal--active .hystmodal__window{    transform: scale(1);    opacity: 1;}

1.3 Оформление подложки


В лучшем случае, нам нужен отдельный html-элемент в качестве оверлея. Можно использовать и имеющийся элемент модального окна .hystmodal в качестве оверлея, но тогда анимация на этом элементе (например переход opacity) будет затрагивать и внутренние элементы. В итоге, не получится анимировать разные свойства для окна и оверлея отдельно.


Просто разместим элемент .hysymodal__shadow прямо перед закрывающие </body>. В будущем, сделаем так, чтобы этот элемент создавался автоматически из js при инициализации библиотеки.


Его свойства:


.hystmodal__shadow{    position: fixed;    border:none;    display: block;    width: 100%;    top: 0;    bottom: 0;    right: 0;    left: 0;    overflow: hidden;    pointer-events: none;    z-index: 98;    opacity: 0;    transition: opacity 0.15s ease;    background-color: black;}/* активная подложка */.hystmodal__shadow--show{    pointer-events: auto;    opacity: 0.6;}

1.4 Отключение прокрутки страницы


Когда модальное окна открывается, мы хотим, чтобы страница под ним не прокручивалась.
Самый простой способ этого добиться повесить overflow:hidden для body или html, когда окно открывается. Однако с этим есть проблема:


Проблема 4. В браузере Safari на iOS страница будет прокручиваться, даже если на тег html или body повешен overflow:hidden.
Решается двумя способами, либо блокированием событий прокрутки (touchmove, touchend или touchsart) из js вида:


targetElement.ontouchend = (e) => {    e.preventDefault();};

Однако при этом блокируется и прокрутка самого модального окна, а также все прокручиваемые блоки внутри окна, если они будут. Требуется дополнительные проверки селекторов из js, что приводит к усложнению кода, поэтому рассмотрим другой вариант.


ps: можно конечно применить библиотеку scroll-lock, в которую заложено это решение, но в статье было решено воспользоваться другим вариантом.


Другое решение основано частично на CSS. Пусть когда окно открывается, на элемент <html> будет добавляться класс .hystmodal__opened:


.hystmodal__opened {    position: fixed;    right: 0;    left: 0;    overflow: hidden;}

Благодаря position:fixed, окно не будет прокручиваться даже в safari, однако здесь тоже не всё гладко:


Проблема 5. При открытии/закрытии окна страница прокручивается в начало.
Действительно, это происходит из-за изменения свойства position, текущая прокрутка окна сбрасывается.


Для решения, нам нужно написать следующий JS (упрощенно):


При открытии:


// Находим тег html и сохраняем егоlet html = document.documentElement;//сохраним текущую прокрутку:let scrollPosition = window.pageYOffset;//установим свойство top у html равное прокруткеhtml.style.top = -scrollPosition + "px";html.classList.add("hystmodal__opened");

При закрытии:


html.classList.remove("hystmodal__opened");//прокручиваем окно туда где оно былоwindow.scrollTo(0, scrollPosition);html.style.top = "";

Отлично, приступим к JavaScript коду.


2. Код JavaScript


2.2 Каркас библиотеки


Нам нужна совместимость со старыми браузерами включая IE11 поэтому нам нужно выбрать из 2 вариантов кодинга:


  • Разрабатывать на старом стандарте ES5, и использовать только те фичи, которые поддерживают все браузеры.
  • Применить современный синтаксис ES6, но подключить транспайлер Babel, который автоматически преобразует код для всех браузеров и встроит необходимые полифилы.
    Было принято решение использовать второй вариант, с прицелом на будущее.
    Приступим.

Основа нашей библиотеки единственный класс HystModal. Ниже я приведу скелет кода с комментариями, а потом добавим остальной функционал.


class HystModal{    /**     * При создании экземпляра класса, мы передаём в него     * js-объект с настройками. Он становится доступен     * в конструкторе класса в виде переменной props     */    constructor(props){        /**         * Для удобства некоторые свойства можно не передавать         * Мы должны заполнить их начальными значениями         * Это можно сделать применив метод Object.assign         */        let defaultConfig = {            linkAttributeName: 'data-hystmodal',            // ... здесь остальные свойства        }        this.config = Object.assign(defaultConfig, props);        // сразу вызываем метод инициализации        this.init();    }    /**      * В свойство _shadow будет заложен div с визуальной     * подложкой. Оно сделано статическим, т.к. при создании     * нескольких экземпляров класса, эта подложка нужна только     * одна     */    static _shadow = false;    init(){        /**         * Создаём триггеры состояния, полезные переменные и.т.д.         */        this.isOpened = false; // открыто ли окно        this.openedWindow = false; //ссылка на открытый .hystmodal        this._modalBlock = false; //ссылка на открытый .hystmodal__window        this.starter = false, //ссылка на элемент "открыватель" текущего окна        // (он нужен для возвращения фокуса на него)        this._nextWindows = false; //ссылка на .hystmodal который нужно открыть        this._scrollPosition = 0; //текущая прокрутка (см. выше)        /**         * ... остальное         */        // Создаём только одну подложку и вставляем её в конец body        if(!HystModal._shadow){            HystModal._shadow = document.createElement('div');            HystModal._shadow.classList.add('hystmodal__shadow');            document.body.appendChild(HystModal._shadow);        }        //Запускаем метод для обработки событий см. ниже.        this.eventsFeeler();    }    eventsFeeler(){        /**          * Нужно обработать открытие окон по клику на элементы с data-атрибутом         * который мы установили в конфигурации - this.config.linkAttributeName         *          * Здесь мы используем делегирование события клика, чтобы обойтись одним         * лишь обработчиком события на элементе html         *          */        document.addEventListener("click", function (e) {            /**             * Определяем попал ли клик на элемент,             * который открывает окно             */             const clickedlink = e.target.closest("[" + this.config.linkAttributeName + "]");            /** Если действительно клик был на              * элементе открытия окна, находим              * подходящее окно, заполняем свойства             *  _nextWindows и _starter и вызываем             *  метод open (см. ниже)             */            if (clickedlink) {                 e.preventDefault();                this.starter = clickedlink;                let targetSelector = this.starter.getAttribute(this.config.linkAttributeName);                this._nextWindows = document.querySelector(targetSelector);                this.open();                return;            }            /** Если событие вызвано на элементе             *  с data-атрибутом data-hystclose,             *  значит вызовем метод закрытия окна             */            if (e.target.closest('[data-hystclose]')) {                this.close();                return;            }        }.bind(this));        /** По стандарту, в обработчике события в this         * помещается селектор на котором события обрабатываются.         * Поэтому нам нужно вручную установить this на наш          * экземпляр класса, который мы пишем с помощью .bind().         */         //обработаем клавишу escape и tab        window.addEventListener("keydown", function (e) {               //закрытие окна по escape            if (e.which == 27 && this.isOpened) {                e.preventDefault();                this.close();                return;            }            /** Вызовем метод для управления фокусом по Tab             * и всю ответственность переложим на него             * (создадим его позже)             */             if (e.which == 9 && this.isOpened) {                this.focusCatcher(e);                return;            }        }.bind(this));    }    open(selector){        this.openedWindow = this._nextWindows;        this._modalBlock = this.openedWindow.querySelector('.hystmodal__window');        /** Вызываем метод управления скроллом         * он будет блокировать/разблокировать         * страницу в зависимости от свойства this.isOpened         */        this._bodyScrollControl();        HystModal._shadow.classList.add("hystmodal__shadow--show");        this.openedWindow.classList.add("hystmodal--active");        this.openedWindow.setAttribute('aria-hidden', 'false');        this.focusContol(); //вызываем метод перевода фокуса (см. ниже)        this.isOpened = true;    }    close(){        /**         * Метод закрытия текущего окна. Код упрощён         * подробнее в статье далее.         */        if (!this.isOpened) {            return;        }        this.openedWindow.classList.remove("hystmodal--active");        HystModal._shadow.classList.remove("hystmodal__shadow--show");        this.openedWindow.setAttribute('aria-hidden', 'true');        //возвращаем фокус на элемент которым открылось окно        this.focusContol();        //возвращаем скролл        this._bodyScrollControl();        this.isOpened = false;    }    _bodyScrollControl(){        let html = document.documentElement;        if (this.isOpened === true) {            //разблокировка страницы            html.classList.remove("hystmodal__opened");            html.style.marginRight = "";            window.scrollTo(0, this._scrollPosition);            html.style.top = "";            return;        }        //блокировка страницы        this._scrollPosition = window.pageYOffset;        html.style.top = -this._scrollPosition + "px";        html.classList.add("hystmodal__opened");    }}

Итак, мы описали класс HystModal. Чтобы всё работало, нужно всего лишь подключить наш скрипт и создать экземпляр класса:


const myModal = new HystModal({    linkAttributeName: 'data-hystmodal', });

Тогда по клику по ссылке/кнопке с атрибутом data-hystmodal, например такой: <a href="#" data-hystmodal="#myModal">Открыть окно</a> будет
открываться окно. Однако у нас появляются новые нюансы:


Проблема 6: если в браузере есть фиксированный скроллбар (который влияет на ширину страницы), то при открытии/закрытии окна происходит сдвиг контента, когда полоса прокрутки то появляется то пропадает.



Действительно скроллбар пропадает и контент страницы перераспределяется. Чтобы решить эту проблему, можно добавить отступ справа к тегу html, равный ширине скроллбара когда он пропадает.


Однако ширина скроллбара может быть разной в разных браузерах и операционных системах. Кроме того, скроллбара может не быть на коротких страницах или он может быть плавающим (например, в Chrome на Android). Поэтому ширину нужно вычислять динамически из скрипта.


Дополним метод _bodyScrollControl()


//при открытии окнаlet marginSize = window.innerWidth - html.clientWidth;//ширина скроллбара равна разнице ширины окна и ширины документа (селектора html)if (marginSize) {    html.style.marginRight = marginSize + "px";} //при закрытии окнаhtml.style.marginRight = "";

Почему код метода close() упрощён? Дело в том, что просто убирая классы CSS у элементов, мы не можем анимировать закрытие окна.


Проблема 7. При закрытии окна, свойство visibility:hidden применяется сразу и не даёт возможности анимировать закрытие окна.


Причина этого известна: свойство visibility:hidden не анимируется. Конечно, можно обойтись без анимации, но, если она нужна, сделаем следующее.


  • Создадим дополнительный CSS-класс .hystmodalmoved почти такой-же как и .hystmodal--active

.hystmodal--moved{    visibility: visible;}

  • Затем при закрытии сначала добавим этот класс к окну и повесим обработчик события transitionend на модальном окне. Затем удалим класс `.hystmodalactive, таким образом вызывая css-переход. Как только переход завершится, сработает обработчик события transitionend, в котором сделаем всё остальное и удалим сам обработчик события.

Ниже: новая версия методов закрытия окна:


close(){    if (!this.isOpened) {        return;    }    this.openedWindow.classList.add("hystmodal--moved");    this.openedWindow.addEventListener("transitionend", this._closeAfterTransition);    this.openedWindow.classList.remove("hystmodal--active");}_closeAfterTransition(){    this.openedWindow.classList.remove("hystmodal--moved");    this.openedWindow.removeEventListener("transitionend", this._closeAfterTransition);    HystModal._shadow.classList.remove("hystmodal__shadow--show");    this.openedWindow.setAttribute('aria-hidden', 'true');    this.focusContol();    this._bodyScrollControl();    this.isOpened = false;}

Вы заметили, что мы создали ещё один метод _closeAfterTransition() и перенесли основную логику закрытия туда. Это нужно, чтобы удалить обработчик события transitionend после закрытия окна, ведь в метод removeEventListener необходимо передать именно ту функцию, которую мы привязывали.


Кроме того, если анимация не будет нужна, можно просто вызвать this._closeAfterTransition() не вешая его на событие.


Как мы помним, внутри addEventListener, this будет указывать на селектор где происходит событие, а не на наш экземпляр класса, поэтому в конструкторе нужно добавить ещё одну строчку для жёсткой привязки метода к this.


//внутри конструктораthis._closeAfterTransition = this._closeAfterTransition.bind(this)

2.2 Закрытие окна по клику на оверлей


Нам нужно обработать ещё одно событие закрытие окна по клику на элемент подложки .hystmodal__wrap. Мы можем повесить обработчик клика на документ для делегирования события как при открытии и проверить что событие произошло на .hystmodal__wrap примерно так:


document.addEventListener("click", function (e) {    const wrap = e.target.classList.contains('hystmodal__wrap');    if(!wrap) return;    e.preventDefault();    this.close();}.bind(this));

Это будет работать, но есть один малозаметный недостаток.


Проблема 8. Если кнопку мыши нажать внутри окна, а отпустить за его пределами (над подложкой), окно закрывается.


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


Окно закрывается потому что по спецификации, если нажатие и отпускание мыши были на разных элементах, то событие click сработает на самом ближайшем общем для них элементе, а у нас это как раз .hystmodal__wrap.


Мы могли бы решить это изменением html, добавляя ещё один div сразу после .hystmodal__window и размещая его визуально под окном. Но нам бы не хотелось добавлять лишний пустой div ещё сильнее усложняя разметку.


Мы можем разбить наш addEventListener на два отдельных обработчика: для событий mousedown и mouseup и будем проверять чтобы оба события происходили именно на .hystmodal__wrap. Добавим новые обработчики событий в наш метод eventsFeeler()


document.addEventListener('mousedown', function (e) {    /**    * Проверяем было ли нажатие над .hystmodal__wrap,    * и отмечаем это в свойстве this._overlayChecker    */    if (!e.target.classList.contains('hystmodal__wrap')) return;    this._overlayChecker = true;}.bind(this));document.addEventListener('mouseup', function (e) {    /**    * Проверяем было ли отпускание мыши над .hystmodal__wrap,    * и если нажатие тоже было на нём, то закрываем окно    * и обнуляем this._overlayChecker в любом случае    */    if (this._overlayChecker && e.target.classList.contains('hystmodal__wrap')) {        e.preventDefault();        !this._overlayChecker;        this.close();        return;    }    this._overlayChecker = false;}.bind(this));

2.3 Управление фокусом


У нас заготовлено два метода для управления фокусом: focusContol() для переноса фокуса внутрь окна и обратно при его закрытии, а также focusCatcher(event) для блокирования ухода фокуса из окна.


Решения для фокуса были реализованы аналогично js-библиотеке Micromodal (Indrashish Ghosh). А именно:


1.В служебный массив сохраним все css селекторы на которых может быть установлен фокус (свойство помещаем в init()):


//внутри метода init или конструктораthis._focusElements = [    'a[href]',    'area[href]',    'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',    'select:not([disabled]):not([aria-hidden])',    'textarea:not([disabled]):not([aria-hidden])',    'button:not([disabled]):not([aria-hidden])',    'iframe',    'object',    'embed',    '[contenteditable]',    '[tabindex]:not([tabindex^="-"])'];

2.В методе focusContol() находим первый такой селектор в окне и устанавливаем на него фокус, если окно открывается. Если же окно закрывается то переводим фокус на this.starter:


focusContol(){    /** Метод переносит фокус с элемента открывающего окно     * в само окно, и обратно, когда окно закрывается     * см. далее в тексте.     */    const nodes = this.openedWindow.querySelectorAll(this._focusElements);    if (this.isOpened && this.starter) {        this.starter.focus();    } else {        if (nodes.length) nodes[0].focus();    }}

3.В методе focusCatcher() находим в окне и превращаем в массив коллекцию всех элементов на которых может быть фокус. И проверяем, если фокус должен был выйти на пределы окна, то вместо этого устанавливаем фокус снова на первый или последний элемент (ведь фокус можно переключать как по Tab так и по Shift+Tab в обратную сторону).


Результирующий код метода focusCatcher:


focusCatcher(e){    /** Метод не позволяет фокусу перейти вне окна при нажатии TAB     * элементы в окне фокусируются по кругу.     */    // Находим все элементы на которые можно сфокусироваться    const nodes = this.openedWindow.querySelectorAll(this._focusElements);    //преобразуем в массив    const nodesArray = Array.prototype.slice.call(nodes);    //если фокуса нет в окне, то вставляем фокус на первый элемент    if (!this.openedWindow.contains(document.activeElement)) {        nodesArray[0].focus();        e.preventDefault();    } else {        const focusedItemIndex = nodesArray.indexOf(document.activeElement)        if (e.shiftKey && focusedItemIndex === 0) {            //перенос фокуса на последний элемент            focusableNodes[nodesArray.length - 1].focus();        }        if (!e.shiftKey && focusedItemIndex === nodesArray.length - 1) {            //перерос фокуса на первый элемент            nodesArray[0].focus();            e.preventDefault();        }    }}

По сути мы реализовали все необходимое для успешного создания модальных окон, осталось ещё несколько дел:


Проблема 9. В IE11 не работают методы Element.closest() и Object.assign().


Для поддержки Element.closest, воспользуемся полифилами для closest и matches от MDN.


Можно их вставить просто так, но так как у нас проект всё равно собирается webpack, то удобно воспользоваться пакетом element-closest-polyfill который просто вставляет этот код.


Для поддержки Object.assign, можно воспользоваться уже babel-плагином @babel/plugin-transform-object-assign


3. Заключение и ссылки


Повторяя начало статьи, всё изложенное выше, я оформил в маленькую библиотеку hystModal под MIT-лицензией. Вышло 3 кБ кода при загрузке с gzip. Ещё написал для неё подробную документацию на русском и английском языке.


Что вошло ещё в библиотеку hystModal, чего не было в статье:


  • Настройки (вкл/выкл управление фокусом, варианты закрытия, ожидание анимации закрытия)
  • Коллбеки (функции вызывающиеся перед открытием окна и после его закрытия (в них передаётся объект модального окна))
  • Добавлен запрет на какие-либо действия пока анимация закрытия окна не завершится, а также ожидание анимации закрытия текущего окна перед открытием нового (если окно открывается из другого окна).
  • Оформление кнопки-крестика закрытия в CSS
  • Минификация CSS и JS плагинами Webpack.

Если вам будет интересна эта библиотека, буду рад звёздочке в GitHub, или напишите в Issues о найденных багах. (Особенно большие проблемы, наверное, в грамматике английской версии документации, так как мои знания языка пока на начальном уровне. Связаться со мной также можно в Instagram

Подробнее..

Перевод Рецепты по приготовлению оффлайн-приложений

03.09.2020 14:17:53 | Автор: admin


Доброго времени суток, друзья!

Представляю вашему вниманию перевод замечательной статьи Джейка Арчибальда Offline Cookbook, посвященной различным вариантам использования сервис-воркера (ServiceWorker API, далее по тексту просто воркер) и интерфейса кэширования (Cache API).

Предполагается, что вы знакомы с основами названных технологий, потому что кода будет много, а слов мало.

Если не знакомы, то начните с MDN, а затем возвращайтесь. Вот еще неплохая статья про сервис-воркеры специально для визуалов.

Без дальнейших предисловий.

В какой момент сохранять ресурсы?


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

Первый вопрос: когда следует кэшировать ресурсы?

При установке как зависимость



Одним из событий, возникающих при работе воркера, является событие install. Это событие можно использовать для подготовки к обработке других событий. При установке нового воркера старый продолжает обслуживать страницу, так что обработка события install не должна нарушить его работу.

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

Речь идет о тех файлах, без которых приложение не сможет работать подобно файлам, включаемым в начальную загрузку нативных приложений.

self.addEventListener('install', event => {    event.waitUntil(        caches.open('mysite-static-v3')            .then(cache => cache.addAll([                '/css/whatever-v3.css',                '/css/imgs/sprites-v6.png',                '/css/fonts/whatever-v8.woff',                '/js/all-min-v4.js'                // и т.д.            ]))    )})

event.waitUntil принимает промис для определения продолжительности и результата установки. Если промис будет отклонен, воркер не будет установлен. caches.open и cache.addAll возвращают промисы. Если один из ресурсов не будет получен,
вызов cache.addAll будет отклонен.

При установке не как зависимость



Это похоже на предыдущий пример, но в данном случае мы не ожидаем завершения установки, поэтому это не приведет к ее отмене.

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

self.addEventListener('install', event => {    event.waitUntil(        caches.open('mygame-core-v1')            .then(cache => {                cache.addAll(                    // уровни 11-20                )                return cache.addAll(                    // ключевые ресурсы и уровни 1-10                )            })    )})

Мы не передаем промис cache.addAll в event.waitUntil для уровней 11-20, так что если он будет отклонен, то игра все равно будет работать оффлайн. Разумеется, вам следует позаботиться о решении возможных проблем с кэшированием первых уровней и, например, повторить попытку кэширования в случае провала.

Воркер может быть остановлен после обработки событий до того, как уровни 11-20 будут кэшированы. Это означает, что данные уровни не будут сохранены. В будущем в воркер планируется добавить интерфейс фоновой загрузки для решения названной проблемы, а также для загрузки больших файлов, таких как фильмы.

Прим. пер.: данный интерфейс был реализован в конце 2018 года и получил название Background Fetch, но пока работает только в Хроме и Опере (68% по данным CanIUse).

При активации



Подходит для удаления старого кэша и миграции.

После установки нового воркера и остановки старого, новый воркер активируется и мы получаем событие activate. Это отличная возможность для замены ресурсов и удаления старого кэша.

self.addEventListener('activate', event => {    event.waitUntil(        caches.keys()            .then(cacheNames => Promise.all(                cacheNames.filter(cacheName => {                    // если true, значит, мы хотим удалить данный кэш,                    // но помните, что он используется во всем источнике                }).map(cacheName => caches.delete(cacheName))            ))    )})

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

При возникновении пользовательского события



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

Предоставьте пользователю кнопку Прочитать позже или Сохранить. При нажатии кнопки получите ресурс и запишите его в кэш.

document.querySelector('.cache-article').addEventListener('click', event => {    event.preventDefault()    const id = event.target.dataset.id    caches.open(`mysite-article ${id}`)        .then(cache => fetch(`/get-article-urls?id=${id}`)            .then(response => {                // get-article-urls возвращает массив в формате JSON                // с URL для данной статьи                return response.json()            }).then(urls => cache.addAll(urls)))})

Интерфейс кэширования доступен на странице, как и сам воркер, поэтому нам не нужно вызывать последний для сохранения ресурсов.

Во время получения ответа



Подходит для часто обновляемых ресурсов, таких как почтовый ящик пользователя или содержимое статьи. Также подходит для незначительного контента, такого как аватары, но в этом случае следует проявлять осторожность.

Если запрашиваемого ресурса нет в кэше, получаем его из сети, отправляем клиенту и записываем в кэш.

Если вы запрашиваете несколько URL, таких как пути к аватарам, убедитесь, что это не приведет к переполнению хранилища источника (origin протокол, хост и порт) если пользователю потребуется освободить место на диске, вы не должны оказаться первыми. Позаботьтесь об удалении ненужных ресурсов.

self.addEventListener('fetch', event => {    event.respondWith(        caches.open('mysite-dynamic')            .then(cache => cache.match(event.request)                .then(response => response || fetch(event.request)                    .then(response => {                        cache.put(event.request, response.clone())                        return response                    })))    )})

Для эффективного использования памяти мы читаем тело ответа только один раз. В приведенном примере метод clone используется для создания копии ответа. Это делается для того, чтобы одновременно отправить ответ клиенту и записать его в кэш.

Во время проверки на новизну



Подходит для обновления ресурсов, не требующих последних версий. Это может относиться и к аватарам.

Если ресурс имеется в кэше, используем его, но получаем обновление при следующем запросе.

self.addEventListener('fetch', event => {    event.respondWith(        caches.open('mysite-dynamic')            .then(cache => cache.match(event.request)                .then(response => {                    const fetchPromise = fetch(event.request)                        .then(networkResponse => {                            cache.put(event.request, networkResponse.clone())                            return networkResponse                        })                        return response || fetchPromise                    }))    )})

При получении пуш-уведомления



Интерфейс уведомлений (Push API) это абстракция над воркером. Она позволяет воркеру запускаться в ответ на сообщение от операционной системы. Причем, это происходит независимо от пользователя (при закрытой вкладке браузера). Страница, как правило, отправляет пользователю запрос на предоставление разрешения для совершения определенных действий.

Подходит для контента, зависящего от уведомлений, такого как сообщения в чате, новости в ленте, письма в почте. Также используется для синхронизации контента, такого как задачи в списке или отметки в календаре.

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

Без подключения к сети Twitter не предоставляет контент, связанный с уведомлением. Тем не менее, клик по уведомлению приводит к его удалению. Не делайте так!

Следующий код обновляет кэш перед отправкой уведомления:

self.addEventListener('push', event => {    if (event.data.text() === 'new-email') {        event.waitUntil(            caches.open('mysite-dynamic')                .then(cache => fetch('/inbox.json')                    .then(response => {                        cache.put('/inbox.json', response.clone())                        return response.json()                    })).then(emails => {                        registration.showNotification('New email', {                            body: `From ${emails[0].from.name}`,                            tag: 'new-email'                        })                    })        )    }})self.addEventListener('notificationclick', event => {    if (event.notification.tag === 'new-email') {        // предположим, что все ресурсы, необходимые для рендеринга /inbox/ были кэшированы,        // например, при установке воркера        new WindowClient('/inbox/')    }})

При фоновой синхронизации



Фоновая синхронизация (Background Sync) еще одна абстракция над воркером. Она позволяет запрашивать разовую или периодическую фоновую синхронизацию данных. Это также не зависит от пользователя. Однако, ему также оправляется запрос на разрешение.

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

self.addEventListener('sync', event => {    if (event.id === 'update-leaderboard') {        event.waitUntil(            caches.open('mygame-dynamic')                .then(cache => cache.add('/leaderboard.json'))        )    }})

Сохранение кэша


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

Размеры хранилищ не являются фиксированными и зависят от устройства, а также от условий хранения ресурсов. Это можно проверить так:

navigator.storageQuota.queryInfo('temporary').then(info => {    console.log(info.quota)    // результат: <квота в байтах>    console.log(info.usage)    // результат <размер хранящихся данных в байтах>})

Когда размер того или иного хранилища достигает лимита, это хранилище очищается по определенным правилам, которые в настоящее время нельзя изменить.

Чтобы решить эту проблему был предложен интерфейс отправки запроса на разрешение (requestPersistent):

navigator.storage.requestPersistent().then(granted => {    if (granted) {        // ура, данные сохранятся за счет увеличения размера хранилища    }})

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

Для того, чтобы это работало операционная система должна рассматривать хранилища браузера как отдельные элементы.

Ответы на запросы


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

Только кэш



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

self.addEventListener('fetch', event => {    // если не будет найдено совпадение,    // ответ будет похож на ошибку соединения    event.respondWith(caches.match(event.request))})

Только сеть



Подходит для ресурсов, которые не могут быть записаны в кэш, например, данные аналитики или не GET-запросы.

self.addEventListener('fetch', event => {    event.respondWith(fetch(event.request))    // или просто не вызывайте event.respondWith    // это приведет к стандартному поведению браузера})

Сначала кэш, затем, при неудаче, сеть



Подходит для обработки большинства запросов в оффлайн-приложениях.

self.addEventListener('fetch', event => {    event.respondWith(        caches.match(event.request)            .then(response => response || fetch(event.request))    )})

Сохраненные ресурсы возвращаются из кэша, несохраненные из сети.

Кто успел, тот и съел



Подходит для небольших ресурсов в погоне за повышением производительности устройств с маленьким объемом памяти.

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

// Promise.race нам не подойдет, поскольку он отклоняется// при отклонении любого из переданных ему промисов.// Напишем собственную функциюconst promiseAny = promises => new Promise((resolve, reject) => {    // все promises должны быть промисами    promises = promises.map(p => Promise.resolve(p))    // выполняем текущий промис, как только он разрешается    promises.forEach(p => p.then(resolve))    // если все промисы были отклонены, останавливаем выполнение функции    promises.reduce((a, b) => a.catch(() => b))        .catch(() => reject(Error('Все промисы отклонены')))})self.addEventListener('fetch', event => {    event.respondWith(        promiseAny([            caches.match(event.request),            fetch(event.request)        ])    )})

Прим. пер.: сейчас для этой цели можно использовать Promise.allSettled, но его поддержка браузерами составляет 80%: -20% пользователей это, пожалуй, слишком.

Сначала сеть, затем, при неудаче, кэш



Подходит для ресурсов, которые часто обновляются и не влияют на текущую версию сайта, например, статьи, аватары, новостные ленты в социальных сетях, рейтинг игроков и т.д.

Это означает, что вы предоставляете онлайн-пользователям новый контент, а оффлайн-пользователям старый. Если запрос ресурса из сети завершился успешно, вероятно, следует обновить кэш.

У данного подхода есть один недостаток. Если у пользователя проблемы с соединением или оно медленное, он вынужден ждать завершения или провала запроса вместо того, чтобы моментально получить контент из кэша. Это ожидание может быть очень долгим, что приведет к ужасному пользовательскому опыту.

self.addEventListener('fetch', event => {    event.respondWith(        fetch(event.request).catch(() => caches.match(event.request))    )})

Сначала кэш, затем сеть



Подходит для часто обновляемых ресурсов.

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

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

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

Код на странице:

const networkDataReceived = falsestartSpinner()// получаем свежие данныеconst networkUpdate = fetch('/data.json')    .then(response => response.json())        .then(data => {            networkDataReceived = true            updatePage(data)        })// получаем сохраненные данныеcaches.match('/data.json')    .then(response => {        if (!response) throw Error('Данные отсутствуют')        return response.json()    }).then(data => {        // не перезаписывайте новые данные из сети        if (!networkDataReceived) {            updatePage(data)        }    }).catch(() => {        // мы не получили данные из кэша, сеть - наша последняя надежда        return networkUpdate    }).catch(showErrorMessage).then(stopSpinner)

Код воркера:

Мы обращаемся к сети и обновляем кэш.

self.addEventListener('fetch', event => {    event.respondWith(        caches.open('mysite-dynamic')            .then(cache => fetch(event.request)                .then(response => {                    cache.put(event.request, response.clone())                    return response                }))    )})

Подстраховка



Если попытки получить ресурс из кэша и сети провалились, должен быть запасной вариант.

Подходит для местозаполнителей (замена изображений пустышками), неудачных POST-запросов, страниц Недоступно при отсутствии подключения к сети.

self.addEventListener('fetch', event => {    event.respondWith(        // пробуем получить ресурс из кэша        // если не получилось, обращаемся к сети        caches.match(event.request)            .then(response => response || fetch(event.request))            .catch(() => {                // если оба запроса провалились, используем страховку                return caches.match('/offline.html')                // у вас может быть несколько запасных вариантов                // в зависимости от URL или заголовков запроса            })    )})

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

Создание разметки на стороне воркера



Подходит для страниц, которые рендерятся на стороне сервера и не могут быть сохранены в кэше.

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

import './templating-engine.js'self.addEventListener('fetch', event => {    const requestURL = new URL(event.request.url)    event.respondWith(        Promise.all([            caches.match('/article-template.html')                .then(response => response.text()),            caches.match(`${requestURL.path}.json`)                .then(response => response.json())        ]).then(responses => {            const template = responses[0]            const data = responses[1]            return new Response(renderTemplate(template, data), {                headers: {                    'Content-Type': 'text/html'                }            })        })    )})

Все вместе

Вам не обязательно ограничиваться одним шаблоном. Скорее всего, вам придется их комбинировать в зависимости от запроса. Например, в trained-to-thrill используется следующее:

  • Кэширование при установке воркера для постоянных элементов пользовательского интерфейса
  • Кэширование при получении ответа от сервера для изображений и данных Flickr
  • Получение данных из кэша, а при неудаче из сети для большинства запросов
  • Получение ресурсов из кэша, а затем из сети для результатов поиска Flick

Просто смотрите на запрос и решайте, что с ним делать:

self.addEventListener('fetch', event => {    // разбираем URL    const requestURL = new URL(event.request.url)    // обрабатываем запросы к определенному хосту особым образом    if (requestURL.hostname === 'api.example.com') {        event.respondWith(/* определенная комбинация шаблонов */)        return    }    // маршрутизация для относительных путей    if (requestURL.origin === location.origin) {        // обработка различных путей        if (/^\/article\//.test(requestURL.pathname)) {            event.respondWith(/* другая комбинация шаблонов */)            return        }        if (/\.webp$/.test(requestURL.pathname)) {            event.respondWith(/* другая комбинация шаблонов */)            return        }        if (request.method == 'POST') {            event.respondWith(/*  другая комбинация шаблонов */)            return        }        if (/cheese/.test(requestURL.pathname)) {            event.respondWith(                // прим. пер.: не смог перевести - Вопиющая ошибка сыра?                new Response('Flagrant cheese error', {                // такого статуса не существует                status: 512                })            )            return        }    }    // общий паттерн    event.respondWith(        caches.match(event.request)            .then(response => response || fetch(event.request))    )})

Надеюсь, статья была вам полезной. Благодарю за внимание.
Подробнее..

Новый график на Moiva.io с данными от StateOfJS

19.01.2021 14:16:27 | Автор: admin

Автор популярных ежегодных отчетов #StateOfJS и #StateOfCSS Sacha Greif (он же автор VulcanJS и Sidebar) обратился ко мне с идей включить данные отчета на Moiva.io.

Я ответил "Конечно!"

Что такое Moiva.io

Это проект цель которого максимально упростить сравнение жаваскрипт библиотек и показать тренды по ряду метрик.

О чем новый график

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

Откуда данные

У меня уже и ранее была мысль добавить данные отчетов в проект и я намеревался вручную собрать все данные и захардкодить.

На мое счастье Sacha Greif подбросил идею использовать их апи. Выяснилось что у них есть открытое и очень толковое GraphQL api. О большем я и мечтать не мог. Дальнейшее - дело техники.

Заключение

Наиболее нагляден новый график на примере сравнения жаваскрипт фреймворков https://moiva.io/?compare=@angular/core+react+svelte+vue.
Любое мнение, фидбек и идеи очень приветствуются.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru