Очень маленький фреймверк или как написать собственный Angularjs в 200 строк

в 11:11, , рубрики: AngularJS, framework, javascript, templates, Веб-разработка, Песочница, метки: , , , ,

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

Цель:

Начать использовать компонентный подход в HTML верстке(новую семантику), а именно вэбкомпоненты.

Под компонентом я подразумеваю — “независимый модуль программного кода, предназначенный для повторного использования и развертывания”. К примеру в другом проекте.



Тут хочу сделать небольшое отступление и внести ясность – речь пойдет о компонентах, а не о виджетах(функционально обособленных единицах приложения, объединяющих в себе представление, логику и/или данные, конкретного приложения — javascript+html+css).
Разница, по моему мнению, состоит в том что компонент не подвязывается к конкретной модели данных или логике конкретного приложения – и вы можете его без труда перенести в другое приложение. А виджет – это единица конкретного приложения тесно связанная логикой или данными, и перенести его куда либо без внутреннего изменения самого виджета у вас не получится.
В качестве примеров компонента могу привести селект, аккордеон или табки, а виджетов – панель состояния или окно отображения сообщений в чате.

Зачем:

Реюзабельный код и удобство верстки с помощью возможности расширения HTML синтаксиса.

Как сказано в доке к одному известному фреймверку:

“Directives is a unique and powerful feature available only in Angular. Directives let you invent new HTML syntax, specific to your application.”

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

И описанное далее это ни в коем случае не MV* фреймверк, и никогда не задумывался как онный.

Как:

В простейшем случае подобие вэбкомпонента возможно рассмотреть состоящим из двух основных частей: шаблона и скрипта.
Декораторор(css) – пока не будем рассматривать по причине дополнительной логики в нем возможной. Да и сравнивать лучше с уже чем-то существующим, поэтому совсем чуточку потрогаем angularjs.
Вызвать JavaScript функцию я думаю ни у кого не вызовет трудностей, так что мы основной упор сделали на реализации шаблонов для HTML.

В недавно опубликованной статье приводятся два подхода для шаблонизации на клиентской стороне.

Подход 1 — используется в течении уже длительного времени – создание «закадрового» DOM, с его последующим сокрытием.

<div id="mytemplate" hidden>
  <img src="logo.png">
  <div class="comment"></div>
</div>

Достоинства/Недостатки:
+ Использование браузерного DOM – браузер знает что это и умеет с этим работать. При необходимости мы можем легко клонировать его.
+ Ничего лишнего не отображается – hidden предотвращает блок шаблона от показа.
— Инертно – к примеру, даже несмотря на то что наше содержание скрыто, сетевой запрос будет отправлен и выполнен для загрузки изображения.
— дизайн и тематизация – основная страница должна загрузить CSS разметку и для вебкомпонентов. Это делает подход хрупкими т.к. нет никаких гарантий, что мы не столкнемся с конфликтов имен.

Подход 2 — перегрузка <script> и манипулирование с ее содержание в виде строки.
Скорее всего В 2008 году Джон Резиг стал первым, кто предложил использовать этот подход. Но в данное время существуют множество других реализаций, например handlebars.js или mustache.js.

<script id="mytemplate" type="text/x-handlebars-template">
  <img src="logo.png">
  <div class="comment"></div>
</script>

Достоинства/Недостатки:
+ Ничего не рендерится – браузер не отображает этот блок, потому что <script> имеет “display: none” по умолчанию.
+ Инертность – браузер не парсит и не анализирует содержание скрипта, т.к. Это JS, и тип установлен как «text/javascript».
— Безопасность – подспудно поощряется использование innerHtml. А это может привести к XSS уязвимости.

Кроме того лично мне видятся еще следующие недостатки этих подходов:

  • Подход 1 – Шаблон хотя и скрыт от отображения, все равно находится в основной ветке DOM, и это может оказать влияние на быстродействие выборок (к примеру getElementByID или querySelector) в зависимости от конкретной реализации движка браузера. И самое неприятное – мы можем случайно изменить сам шаблон компонента.
  • Подход 2 – быстродействие- парсинг и шаблонизация проводится JavaScript
  • К тому же оба эти подхода не умеют работать если в шаблонируемый узел уже вставлена часть DOM с динамически подвязанными через addEventListener листенерами

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

Наш подход — как и первый вариант мы работаем непосредственно с DOM деревом а не строкой. Берем узел DOM который содержит не откомпилированные компоненты – клонируем его и полученным клоном заменяем данный узел в основной ветке DOM. Это позволяет нам далее работать с оригиналом пока отображается клон.

Т.к. мы уже имеем отпарсеную DOM ветвь вне дерева отображения, содержащую наши компоненты, мы можем свободно манипулировать ими и их контентом, как с узлами(например, с помощью querySelectorAll(“component.name”)).

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

Как использовать:

  • описать компонент как javascript объект

    component = {
        name: ...,
        template: ...,
        script: function (element, attrs) {….},
        onattach: function (element) {….},
        instantiate: true/false
    }

    Чуть подробнее:

    • name – имя вэбкомпонента, с помощью которого его можно использовать в html разметке
    • template – строка представляющая собой html шаблон с отмеченным местом вхождения контента().
    • script – логика работы компонента(параметры element – корневой узел компонента, attrs – его атрибуты). Выполняется один раз при компиляции компонента до добавления его в DOM
    • onattach – скрипт который выполняется сразу после аттача откомпилированого компонента в DOM
    • instantiate – создается или нет новый экземпляр компонента для каждого отдельного тега. Если создается то экземпляр компонента становится доступен черейз свойство корневого элемента (element.component).
  • Зарегистрировать компонент в
    TagBuilder.register(component);
  • Использовать компонент в html разметке, например
    <accordion>
                    <accordion-page header=”Header 1”>
                    … 
                    </accordion-page>
                    <accordion-page class="open" header=”Header 2”>
                    … 
                    </accordion-page>
                    ...
    </accordion>

    или через w-component=”accordion” в случае если вы не хотите удалять узел из DOM(К примеру если вам необходимо расширить функциональность <td>).

  • Просто откомпилировать часть DOM содержащую компоненты
    TagBuilder.apply('component css selector or components wrapper');

    Возможно так же компилировать html шаблоны с компонентами как строки, с помощью TagBuilder.compile(string, options);, а потом с помощью TagBuilder.apply('component css selector or components wrapper'); приаттачить скриптовую часть компонентов. В последнем случае для этого на элеменш маркирующие атрибуты(w-component=”name”) .

Простейший пример(в сравнении):

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

html:

<expandable header="Click">
    <ul>
        <li>lkj;lksdf</li>
        <li>lkj;lksdf</li>
        <li>lkj;lksdf</li>
        <li>lkj;lksdf</li>
        <li>lkj;lksdf</li>
    </ul>
</expandable>

css:

.content {
    background-color: #abc;
    height: 0;
    overflow: hidden;
    
    -webkit-transition: all 1s ease;
    -moz-transition: all 1s ease;
    -o-transition: all 1s ease;
    -ms-transition: all 1s ease;
    transition: all 1s ease;
}
.inner{
    padding: 5px;
}

javascript(tag.js):

var component = {
    name:'expandable',
    template: '<div><div class="header"></div><div class="content"><div class="inner"><content></content></div></div></div>',
            script: function(element, attrs) {
                var $header = $(element).find('.header');

                $header.html(attrs.header);
                $header.on('click', function(e){
                    var $element = $(element);
                        height = $element.find('.inner').outerHeight(true);
                        $container = $element.find('.content');
                    
                    $container.toggleClass('show');
                    if($container.hasClass('show')){
                        $container.height(height);
                    } else {
                        $container.height(0);
                    }
                });
            }
        };

TagBuilder.register(component);
TagBuilder.apply(document.body);

javascript(angular.js):

var myApp = angular.module('myApp', []);

myApp.directive('expandable', function() {
        return {
            restrict: 'E',
            template: '<div><div class="header"></div><div class="content"><div ng-transclude class="inner"></div></div></div>',
            replace: true,
            transclude: true,
            link: function(scope, element, attrs) {
                var $header = $(element).find('.header');

                $header.html(attrs.header);
                $header.on('click', function(e){
                    var $element = $(element);
                        height = $element.find('.inner').outerHeight(true);
                        $container = $element.find('.content');
                    
                    $container.toggleClass('show');
                    if($container.hasClass('show')){
                        $container.height(height);
                    } else {
                        $container.height(0);
                    }
                });
            }
        };
    });

эти примеры в действии:

Не трудно увидеть что отличий не сильно много.



Но они(различия) всетаки есть:

1) Представим себе ситуацию когда где-то ранее в коде к внутренней части дома компонента до компиляции приаттачили клик.

В ангуляре с этим произойдет фейл — клик отвалится. В нашем случае ничего не меняется – проблема состоит что компиляция в ангуляре идет клоном у нас же контент переносится. Я понимаю что в ангуляре есть для этого ng-click и подобное, но все равно …

2) “Наше” — покрывает только определенную функциональность “invent new HTML syntax, specific to your application”, собственно для этого только и задумывался. И позволяет использовать новый HTML синтакс именно с теми шаблонизаторами и фреймверками которые вам необходимо, не ограничивает вас. И честно говоря уровень входа в “это” — гораздо ниже, так как — гораздо проще. Это не MV* и не application фреймверк. И нет никаких скоупов, только экземпляр компонента — в том случае если вам это нужно.

3) У нас получилось совсем немного кода – если не считать комментариев это 217 строк чистого яваскрипта(чуть больше килобайта минимизированного кода). У нас возможно наследованное компонентов, в angularjs немного сложнее с этим потому что вторым параметром “directive” должна идти функция.

4) Не сильно понравилось что ангуляр дополнительно вставляет в DOM дополнительные служебные конструкции типа

<span class="ng-scope"></span>

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

Roadmap:

  • Подумать над добавлением компонентам коллбеков на аттач и деатач для DOM через мутаторы или события родителя в зависимости от версии поддерживаемого DOM браузера.
  • Добавлять атрибут (is=”name”) откомпилированным компонентам для возможности поиска экземпляров через селекторы.

Все:

Данное “хозяйство” уже используется в проектах. И вдуг кому будет интересно посмотреть или поюзать добро пожаловать – github.com/YuriyLuchaninov/tagjs.

На гитхабе есть примеры реализации(просто демонстрация возможностей):

А в общем мне импонирует angularjs и я надеюсь что вскоре они добавят поддержку множественных ng-view и транзишены между директивами без роутинга… И я брошу все и уйду на angularjs, эххх – лирика.

Хотя, идеальных фреймверков не бывает.

Сылки:

http://www.html5rocks.com/en/tutorials/webcomponents/template/
http://habrahabr.ru/post/152001/
http://html5-demos.appspot.com/static/webcomponents/index.html

Автор: Luchaninov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js