Стейт веб-приложения — это и есть DOM



    Привет, Хабр!

    В этой статье речь пойдет об управлении клиентским стейтом одностраничного WEB-приложения, разработанного целиком на ванильных веб-компонентах, без использования фреймворков. Ранее, до появления Custom Elements и классов JavaScript, управление стейтом действительно представляло проблему. С одной стороны мы имели дерево DOM, в котором можно было хранить лишь текстовую информацию (включая результаты пользовательского ввода), с другой строны нам нужны сложные структуры данных (Array, Map), которые хранились в переменных JavaScript разной степени «глобальности». Чтобы подружить эти 2 мира, был необходим 2-х сторонний байндинг, различные реализации которого и предлагали веб-фреймворки. При этом, естественно, основным «источником правды», то есть стейтом, считались объекты JS, тогда как дерево DOM рассматривалось исключительно как отображение (view), которое должно пересоздаваться при каждом изменении стейта. Алгоритмы байндинга описывались декларативно, что позволяло их кэшировать, а следовательно минимизировать трансформации реального DOM.

    Современные веб-стандарты все упростили. Веб-компонент — это экземпляр класса JavaScript, который может иметь публичные свойства (или приватные — с геттерами / сеттерами), и эти свойства доступны через DOM Properties, что позволяет рассматривать дерево DOM как распределенный стейт, не имеющий ограничений на типы данных. Например, если в каком-то месте нашего документа размещен веб-компонент login-session, отвечающий за вывод формы авторизации, а также за хранение и отображение регистрационной информации, то текущее имя пользователя можно получить примерно так:

    document.querySelector('login-session').login_name

    Другой пример — компонент item-list хранит в себе массив сообщений, и выводит их в нумерованный список HTML, но мы можем работать напрямую с его данными, например так:

    document.querySelector('item-list').items[0]
    document.querySelector('item-list').items.filter(v => v.includes('some text'))

    При создании веб-компонента можно использовать фабричные методы (поскольку параметры конструктора не предусмотрены):

    document.createElement('item-view').build('my message')

    Поскольку веб-компоненты могут наследоваться, мы получаем классический ООП с инкапсуляцией данных внутри веб-компонента, с доступом через querySelector(), и гибким управлением вложенным стейтом (родительский веб-компонент сам решает — сохранять дочерние элементы в дереве, или пересоздавать их заново). Дополнительный бонус — формы HTML можно не пересоздавать, а значит результаты пользовательского ввода сохраняются непосредственно в контролах. С точки зрения управления состоянием, необходимость использования фреймворков становится неочевидной, а что касается моделей реактивности — да, стандарты ничего не говорят по этому поводу, и мы можем либо использовать обычные колбэки (push-реактивность), либо любой другой метод.

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

    Приложение состоит из следующих компонентов:

    • login-session — компонент верхнего уровня, работает в 2-х режимах: в первом он выводит форму авторизации, во втором — информацию о пользователе (с гиперссылкой для смены логина), а также 2 дочерних компонента — список сообщений и строку поиска. Предоставляет геттер login_name().
    • item-list — компонент выводит форму для отправки сообщения, и список уже отправленных. Предоставляет геттеры для полного и отфильтрованного списка.
    • item-view — компонент, отображающий одно сообщение. При создании сохраняет в своих свойствах текущее имя пользователя, дату создания и текст сообщения. Предоставляет геттер для текста.
    • item-search — форма поиска, обращается к компоненту item-list и выводит результат фильтрации в модальное окно.
    • input-text — расширение стандартного HTML-элемента input, используется для предварительной обработки пользовательского ввода.

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

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

    customElements.define('input-text',
        class extends HTMLInputElement {
            constructor() {
                super()
                this.setAttribute('type', 'text')
                this.addEventListener('input', ev => {
                    this.value = this.value.replace(/</g, '&lt;')
                })
            }
        },
        {extends:'input'}
    )

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

    Спасибо за внимание.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 43

      +1
      Не сказал бы, что код получился более читабельным(субъективно).
      За счет чистого html в примере, без сахара или шаблонизатора выглядит весьма скомканно

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

      Поиск по DOM тоже достаточно прожорлив, разве не лучше при создании сохранять в стейт элемент?

      В результате не сказал бы, что на данный момент без велосипедов и фреймворков можно использовать веб-компоненты
        0
        Поиск по DOM тоже достаточно прожорлив
        Вряд-ли это когда-либо станет узким местом, если я в списке ищу либо фильтрую, я же не по DOM хожу, а по Array или Map. Дерево DOM дает понятную иерархию состояний, с автоматической инвалидацией дочерних, если родитель того захочет. Вон, во Flatter по сути к тому же пришли — Provider, он же InheritedWidget как раз в дереве виджетов живет.
          0

          Я имел ввиду не фильтр, а скорее подобные строки this.querySelector('ol')

            +1
            Я понял, но это единичные штуки. Можно было бы ID присвоить, но в пределах компонента проще не заморачиваться, ID это штука глобальная, а Shadow DOM не факт что вообще хорошее решение.
        0
        -
          +2

          Тогда уже проще писать на Svelte, чем вручную выписывать все отслеживания состояний и вручную вставлять html в компоненты.

            0

            А наследование компонентов там есть? Для толстых приложений полезная штука.

              +1

              А что делать при наследовании с шаблоном компонента?


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

                +1

                Ну, как вам сказать… У нас на работе развесистое дерево абстрактных классов, на концах которого — компоненты пользовательского интерфейса.

                  0

                  Можете посмотреть, как это сделано в $mol, где вы можете отнаследоваться от любого компонента и переопределить любую часть "шаблона".

                  +2

                  Вместо наследования компонентов лучше использовать агрегацию.

                    0
                    Тогда придется отделять бизнес-логику от представления, то есть привет MVC. Религиозный вопрос, действительно случаев, где нужны толстые компоненты с логикой и представлением в одном флаконе, не так часты, но мне несколько раз попадались.
                      0
                      Вместо наследования компонентов лучше использовать агрегацию.

                      Чем агрегация отличается от композиции? join в контейнер по контексту?


                      Тогда придется отделять бизнес-логику от представления, то есть привет MVC

                      Разделять можно/нужно на уровне нод. Тогда бизнес-логика этого не заметит.

                        0
                        Разделять можно/нужно на уровне нод. Тогда бизнес-логика этого не заметит.
                        Это как?
                          +1

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


                          Это как?

                          Как то так :)

                  +1

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

                    +2

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

                      0
                      В точку. Но разве Реакт нельзя использовать похожим образом?
                        +2

                        Там обычно данные передаются через пропсы или контекст, querySelector в рандомные места страницы там не приветствуется

                          0
                          Спасибо, согласен.
                      +2

                      Указанные вами фрэймворки — это в первую очередь декларативность, а веб-компоненты никак не реализуют её.


                      P.S. У вас XSS-уязвимость в item-view. Фрэймворки решают их прозрачным образом.

                        0

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

                        +2
                        Интересный способ для библиотек компонентов, возможно кому-то и зайдет.

                        Да, я конечно понимаю, что пример тестовый, но с innerHtml всё-таки стоит быть по-осторожнее.
                        А то могут и что-то вроде
                        <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="alert('pwned');" />
                        

                        вбить в поле с именем.
                          0
                          Спасибо! Исправил путем переопределения поведения стандартного элемента input.
                            +2

                            Эскейпить лучше на выходе, а не на входе данных. Но руками это делать — дело неблагодарное.

                              0
                              Я согласен, фреймворк все равно нужен, просто не встречал готового на основе ванильных wc, кроме LitElement, который категорически не зашел.
                            +2
                            <<img src onerror=alert()//

                            Вставка через ctrl+v и эскейпится только первый <


                            Но придирки к мелочам

                              0
                              Спасибо, исправил на вариант justboris в соседнем комментарии!
                          +1

                          Компоненты какие-то неизменяемые вышли:


                          const it = document.createElement('item-view').build(initial_value)
                          container.appendChild(it)
                          it.build(another_value); // и ничего не поменялось

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


                          function ItemView(mess) {
                              const who = document.querySelector('login-session').login_name
                              const when = new Date()
                              const div = document.createElement('div');
                          
                              div.innerHtml = `
                                          <li>
                                              ${mess}<br/>
                                              <small><i>${who}, ${when.toLocaleString()}</i></small>
                                          </li>
                              `;
                          
                              return div;
                          }

                          Нормальный компонент должен уметь не только создаваться, но и обновляться. Тут-то проблема управления состоянием снова и вылезет.

                            0

                            Отображение не поменялось, потому что мы его формируем в connectedCallback(), который срабатывает только один раз. Получается иммутабельный компонент, который нужно каждый раз пересоздавать целиком. Если мы захотим обновлять существующий — нужно перенести манипуляции с DOM в build() и все. Похожая идея реализована во Flutter, где все виджеты делятся на иммутабельные и мутабельные (со стейтом), и первых больше.

                              0

                              Вот как раз во Flutter stateless-виждеты очень даже изменяемые.

                                0
                                Нужна демонстрация, например сохраняем объектную ссылку во внешней переменной, а потом пересоздаем предка.
                            +1
                            Мы видим, что код получается предельно читаемым, инкапсуляция данных и алгоритмов внутри компонента устраняет возможные побочные эффекты
                            Я этого не вижу) Ну да, веб разработка шагнула вперед — сделали возможность создавать свои элементы. Но тут же сделали шаг назад — спагетти-код, который раньше писали в контроллерах страниц, теперь пишется в компонентах.
                            Поскольку веб-компоненты могут наследоваться ...
                            А оно надо?) Вместо получаемой смеси логики и верстки, в сложных случаях лучше выносить представление из компонента, разделять логику на части и при необходимости использовать наследования для некоторых из этих частей, а не для всего компонента. Фреймворки как раз уже несколько лет двигаются в этом направлении (начиная от mixins и заканчивая нынешними custom react hooks, vue composition api). Правда, подобная композиция уже лет 20 успешно используется, но не в вебе.
                              0
                              OK, я не спорю, бывают сложные случаи, когда нужно прям на клиенте иметь MVC, но все же большинство WEB-приложений (мы же только о клиентской части говорим) это целиком уровень view, и сложную бизнес-логику тут я представляю с трудом. Навскидку приходит только какой-нибудь плеер или браузерная игра, в подавляющем же большинстве случаев SPA — это всего-лишь морда для сервера, и тут толстые компоненты, инкапсулирующие и логику и представление, да еще с иерархией наследования — очень кстати.

                              Пример из жизни — компонент для редактирования записи бизнес-справочника. Есть абстрактный контрагент, его подвиды — покупатель / поставщик / банк / сотрудник, поставщики в свою очередь делятся на резов / нерезов, резы делятся на ИП / ООО / ПAO — каждая категория с разным набором атрибутов. На выходе в серверную часть передается уже провалидированный JSON. Вся бизнес-логика в этом приложении — показ правильного набора атрибутов, и валидация ввода в каждом из них (или валидация комбинации атрибутов — очень частая задача). Лепить еще и сюда MVC конечно можно, но по моему мнению это излишнее удорожание проекта. Для данного случая иерархия наследования WC это идеальное решение. IMHO.
                                +1
                                Конечно, во многих случаях можно не усложнять.
                                Но лучше бы из коробки сразу иметь возможность качественной композиции. Иначе ее никто и не будет использовать. Если проект будет развиваться, через какое-то время окажется, что имеющегося функционала в компонентах окажется недостаточно. К валидации ввода могут добавиться маски, ввод тегов с получением списка с сервера и прочая логика. Не хочется в какой-то момент «радовать» заказчика, что для мелкой фичи придется потратить 50 или более часов на написание своего компонента вместо используемого 3rd-party, из-за того что автор компонента использовал наследование, миксины или еще что-то недостаточно гибкое.
                                  0
                                  Ну, не знаю, вопрос религиозный. Излишняя гибкость имеет другую проблему — невозможно быстро внедрить новую сквозную функциональность. Мы не знаем, какой запрос на изменение завтра прилетит — или кастомизировать функционал для клиента, у которого оборот больше миллиона и он китаец, или другой вариант — для всех типов клиентов внедрить проверку благонадежности прямо в момент создания записи. Композиция хороша, когда есть точное ТЗ, и грамотный архитектор, а если этого нет — то все-равно придется половину проекта переписывать от релиза к релизу, и тогда наследование будет не худшим вариантом.
                                  PS
                                  Я работал в проектах без четкого ТЗ и без грамотного архитектора, и удержать объем кода в приемлемых рамках — было одной из главных задач. Наследование радикально минимизирует код, платить приходится недостаточной гибкостью, это правда.
                                    +1
                                    Тут скорее дело привычки. В институтах, книгах для борьбы с дублированием кода в основном учат только наследованию. Поэтому большинству разработчиков привычней использовать наследование. Оно вполне хорошо, пока не появляется необходимость в большом числе наследников и частых изменениях.

                                    Композиция хороша, когда есть точное ТЗ.
                                    Уточню, что есть много вариантов композиции. Все зависит от того, что и как используется. И мы с вами явно представляем разное)
                                    Как раз, когда нет точного ТЗ, от композиции профита должно быть больше. В случае наследования сделаешь базовый класс и наследников, потом изменятся требования, и придется менять как базовый класс, так и наследников. В случае же композиции понадобиться заменить/дописать отдельные компоненты в объекте, который их хранит. Но сам объект менять не нужно.
                              0
                              -
                                +1

                                @ strannik_k ТЗ может и нет, но
                                setPrototypeOf есть:)))

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

                                Самое читаемое