В последнее время все чаще требуется максимально быстрая разработка прототипов веб-приложений. Интерфейсы усложняются, требуется отображение огромного количества данных. И вот я озадачился созданием легкого и максимально простого html-шаблонизатора. Кому интересно, что получилось на ~50 строках JS-кода — под кат.

Задача минимум


Сделать односторонний (JS > HTML) шаблонизатор, связывающий JS данные с html отображением максимально просто. Решение должно быть быстрым, с минимальным порогом вхождения — тяп ляп и готово. Максимальная допиливаемость под конкретные требования — примитивность залог успеха.

Принцип работы


Со стороны html


Будем писать обычный html код, где для связи с js будут использоваться обычные data-атрибуты:
data-template — содержимое, которое будет отображаться внутри тега
data-namespace — данные, которые привязываются к JS

Со стороны JS


Данные должны записываться и обновляться прозрачно. Т.е. если у нас есть объект object со свойством data, то данный должны обновляться в html сразу после обычного:

object.data = 5;

Без всяких вспомогательных методов типа:

object.setData = function(val){
    this.data = val;
    document.getElementById("tpl").html = val;
}
object.setData(5);

— это норм подход, но что будете делать, если с сервера приходит большая пачка json данных, которые надо распихать по разным объектам и отобразить изменения в интерфейсе? Писать и вызывать свой сеттер для каждого свойства/набора свойств — мне такая реализация надоела.

Решение простое. Для каждого свойства объекта мы можем задать дефолтные сеттеры/геттеры, срабатывающие как раз при изменении изменении значения обычным присваиванием. Называется это Object.defineProperty(...) — пересказывать статью не буду, перейду сразу к идее.

Нам надо пробежаться по всем свойствам объекта, которые имеют связь с html. Способов это сделать два:

1) Пробежаться по dom, вытащив оттуда все возможные (не повторяющиеся) значения атрибута data-namespace в отдельный массив
2) Для каждого объекта в js вручную будем задавать массив, который говорит, какие данные (только какие а не с чем) будут использоваться в связях.

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

html
<div data-template="dataA" data-namespace="test">тут должно быть значение dataA из объекта со значением свойства this_namespace=test</div>
<div data-template="dataB" data-namespace="test">тут должно быть значение dataB из объекта со значением свойства this_namespace=test</div>
<div data-template="dataA" data-namespace="test2">тут должно быть значение dataA из объекта со значением свойства this_namespace="test2"</div>

js

var obj1 = {
	this_namespace: 'test', // имя которое будет использоваться для идентификации объекта 
	this_data: ['dataA', 'dataB'] // имена которое будет использоваться для идентификации свойств объекта
};
var obj2 = {
	this_namespace: 'test2', // имя которое будет использоваться для идентификации объекта 
	this_data: ['dataA'] // имена которое будет использоваться для идентификации свойств объекта
}

Как видим, изначально в объекте нет никаких данных, мы их получим позже — для начала объект надо подготовить к такому.

Magic


Вся магия заключается в том, чтобы внутри объекта пробежаться по массиву this_data и насоздавать в этом же объекте по 2 свойства на каждый элемент массива:

1) __dataA, __dataB… — тут будут храниться значения
2) dataA, dataB… — и тут будут храниться значения =)

Смысл такой манипуляции в том, что если мы зададим например дефолтный сеттер просто для свойства dataA, который будет записывать переданное значение сюда же — получится рекурсивненько. Чтобы этого избежать, создается отдельное свойство с префиксом "__" (можно любой, например fake_ или magic_ — просто я выбрал такой чтобы не засорять namespace объекта).

Получается, что когда мы напишем obj.dataA = 3, это значение запишется в свойство obj.__dataA, а дефолтный геттер при запросе obj.dataA будет отдавать значение obj.__dataA. То есть по факту, в массиве obj вообще нету свойств dataA и dataB в чистом виде — сеттеры и геттеры подменяют их на свойство с префиксом.

Как я и говорил, нужно обеспечить максимально низкий порог вхождения — чтобы начинающим было проще. А кому надо могут переписать как хотят. В связи с этим, весь метод придания объекту нужного вида будет реализован через прототип объекта.

Тут код неудобно читать, так что сразу смотрим сюда (jsfiddle) — там весь код с комментариями и запуск.

Что еще


  1. Не кроссбраузерно, (ie11+) из за необходимости использования let вместо var — чтобы особо не заморачиваться с замыканиями. Но опять же — можно переписать.
  2. Вложенные объекты из коробки работать не будут — можно дописать. Я привел лишь максимально сокращенный код, который можно подстроить под себя.
  3. Следуя концепции того, что в html не должно быть логики, я не стал добавлять возможность использования в шаблонах каких-то даже примитивных операций (например dataA + dataB). Тупо вывод данных. Логика должна быть на js. Хотя когда я только начал это делать, такая возможность была и там пришлось по-быстрому использовать eval.
  4. В коде нет никаких проверок вообще, это вы делаете сами по необходимости, просто в моем конкретном случае, для которого писался этот метод, все проверки идут через другую прослойку.
  5. Как утешительный приз: в начале статьи я говорил о том что «воооот, нам приходит куча json дааааных, что же мне с ними дееелать...» — тут все просто: парсим JSON, распихиваем его в нужные объекты через Object.assign — это будет работать.
  6. В своих проектах я именую все переменные и свойства с префиксом — типом данных. Что-то вроде arr_list = [1,2,3]; int_count = 2; и т.д. Мне это позволяет «без регистрации и смс» настроить дефолтные геттеры отдавать значение, соответствующее типу данных, и если вдруг у нас int_count = 1.4, нам вернется результат 1 и на лету проверять все значения. Это дисциплинирует и реально помогает.
  7. Для каждой ситуации можно предусмотреть разные set и get, в зависимости от того что хочется получить в итоге — оч удобно.

Спрашивайте, критикуйте, будьте здоровы!
Поделиться с друзьями
-->

Комментарии (55)


  1. k12th
    10.01.2017 14:26
    +1

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


    1. axeax
      10.01.2017 15:02
      -3

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


      1. k12th
        10.01.2017 15:19
        +3

        Я не об этом. Про порог вхождения тоже можно поспорить — все-таки наличие многократно вычитанной документации и Q&A снимает множество вопросов.


      1. Rastishka
        10.01.2017 15:40
        +2

        У vue.js порог вхождения ниже чем в вашем примере имхо. Даже если не читать документацию все интуитивно понятно.


        1. axeax
          10.01.2017 15:44
          +1

          я о применении а не разборе исходников, в каком месте это https://vuejs.org/v2/examples/index.html проще чем

          let obj1 = {
          	this_namespace: 'test',
          	this_data: ['dataA', 'dataB']
          };
          obj1._sTpl();
          


          <div data-template="dataA" data-namespace="test"></div>
          


          даже документация не нужна


          1. SerafimArts
            10.01.2017 15:59
            +9

            Что такое data-template? Ссылка на внешний html шаблон? А неймспейс — это… Зачем он? Да и вызов приватного метода _sTpl вообще непонятно что делает. И откуда он у пустого объекта взялся?


            Тут документация нужна для каждой строчки, т.к. понятно всё только вам, как автору.


            1. axeax
              10.01.2017 16:07
              -1

              3 строчки, 3 настройки которые надо менять
              Что происходит дальше описывает статья, и в реализации метода каждая строчка документирована. Еще раз повторюсь — тут задача была в показательности и простоте, без приблуд и претендования на звание фреймворка нуждающегося в документации впринципе, так что «Ссылка на внешний html шаблон?» неуместно, весь метод изначально не для этого предназначен


            1. TheShock
              10.01.2017 16:16
              +3

              Да и вызов приватного метода _sTpl вообще непонятно что делает

              И вообще откуда этот метод взялся? Там что — расширен прототип Object?


              1. axeax
                10.01.2017 16:31
                -1

                ссылка в статье https://jsfiddle.net/axeax/ky9zbc18/


                1. SerafimArts
                  10.01.2017 16:47
                  +5

                  Вы только что предложили прочитать документацию (разновидность её) по примеру, который сказали, что настолько прост, что не требует документации. Верно?


                  1. axeax
                    10.01.2017 16:55

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

                    все верно, ок, не «не нуждается в документации», а «минимальный набор работающий из коробки со стремящимся к 0 времени затрат времени на чтение документации»


                    1. SerafimArts
                      10.01.2017 17:29
                      +5

                      Хорошо, допустим, а вот такой пример сколько времени на чтение документации требует?


                      <div id="app">{{ message }}</div>

                      +


                      new Vue({ 
                        el: '#app',
                        data: { message: 'izi pizi' } 
                      });

                      Или ещё лучше, с помощью другого решения (не Vue):


                      class Some {
                        message = 'izi pizi';
                      }
                      
                      ko.applyBindings(new Some, app);

                      Неужели больше? Мне кажется что нет, всё очевиднее и, самое главное, прозрачнее на порядки.


                      Или вы со мной не согласны? Разве возникают хоть какие-то вопросы по этим примерам, аналогичные приведённым мною?


                      1. axeax
                        10.01.2017 18:06
                        -3

                        мне приходит новый message каждые n секунд, и глядя на это я не понимаю как мне его обновлять каждый раз, я не открывал документацию, но поверю что это можно сделать так же просто как message = response.message

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


                        1. SerafimArts
                          10.01.2017 18:23
                          +1

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


                          P.S. Во втором случае — нет, при "message = response.message" данные не обновятся, это же просто переменная на чистом JS без какой-либо магии. Для связывания её надо объявить как обсервер (т.е. тупо завернуть внутрь функции ko.observable('izi pizi')), но это не особо важно.


                          1. axeax
                            10.01.2017 18:35

                            Важно, т.к. мой метод как раз реализует всего одну функцию — связывает представление с данными в автоматическом режиме (если допилить несколькими строчками), максимально быстро (в моем приложении специфическое требование к производительности, на счету каждые 50-100мкс)


                            1. SerafimArts
                              10.01.2017 18:55

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


                              class Some {
                                message = ko.observable('izi pizi');
                              }
                              
                              ko.applyBindings(new Some, app);


              1. raveclassic
                10.01.2017 17:46
                +9

                Зашел по ссылке, увидел Object.prototype._sTpl = , закрыл.


                1. axeax
                  10.01.2017 18:11
                  -4

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


                  1. raveclassic
                    10.01.2017 18:42

                    Подсказка: циклы for in


                    1. axeax
                      10.01.2017 18:55
                      -1

                      Hasownproperty?


                      1. raveclassic
                        10.01.2017 19:12
                        +3

                        Прекрасно, вы только что обрекли на это весь мир.
                        А если серьезно, расширять системные объекты, еще и enumerable свойствами — в лучшем случае дурной тон.


                  1. justboris
                    10.01.2017 19:12
                    +4

                    Расширять стандартные прототипы — плохо.
                    За всем кодом не уследить, что-то где-то сломается.


                    Так что по сути это Javascript-версия паттерна #define TRUE FALSE


                    1. axeax
                      10.01.2017 19:17
                      -3

                      Я написал почему так сделано


                      1. Aingis
                        10.01.2017 19:28
                        +2

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


    1. xGromMx
      10.01.2017 18:48
      +1

      Кстати вот презентация Evan You http://www.thedotpost.com/2016/12/evan-you-reactivity-in-frontend-javascript-frameworks


  1. impwx
    10.01.2017 14:36
    +5

    Вы как-то хитро вывернули наизнанку механизм шаблонизации. Обычно шаблон должен знать, с какими данными он работает, но не наоборот. Было бы логичнее сделать так: в функцию передается объект с данными и настройки, а он возвращает объект-прокси, который выглядит точно так же, но умеет обновлять UI:

    var obj = {
        dataA: 1,
        dataB: 2
    };
    var proxy = template(obj, 'test', ['dataA', 'dataB']);
    proxy.dataA = 2; // обновляем UI
    

    P.S. По поводу именования переменных с префиксом — попробуйте Typescript, он позволит больше не заниматься сизифовым трудом.


    1. axeax
      10.01.2017 14:46

      шаблон должен знать, с какими данными он работает

      — это можно реализовать пробежавшись по DOM, я писал об этом, но не применил, т.к. это уже другая история.

      Про объект-прокси скажу: как логичнее — решается для конкретной задачи.
      До typescript я еще не дорос


      1. impwx
        10.01.2017 15:07

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

        Шаблон не может не знать о модели данных. В вашем случае атрибутом data-template="dataA" вы описываете контракт: для шаблона необходим объект, содержащий поле dataA. Это нормально и по-другому никак не сделаешь. А вот ссылка из модели на шаблон явно лишняя.


        1. axeax
          10.01.2017 15:14

          А вот ссылка из модели на шаблон явно лишняя.

          Так одни и те-же данные можно доставать из разных экземпляров


  1. lega
    10.01.2017 15:01

    Хорощий старт, ещё несколько месяцев и вы напишите свой vue.js/marteshka.js

    не стал добавлять возможность использования в шаблонах каких-то даже примитивных операций (например dataA + dataB)
    «a + b» — это не логика, а вид представления данных, так же как и «firstname + ' ' + lastname», который можно разложить в `{{firstname}} {{lastname}}`

    такая возможность была и там пришлось по-быстрому использовать eval.
    Не так все просто, вам бы тогда пришлось бы делать паресер выражения и отслеживать все зависимости т.е. +10кб кода, а если прсто eval — то это будет dirtychecking который нужно будет дергать на каждый чих.

    распихиваем его в нужные объекты через Object.assign — это будет работать.
    Это не все, нужно рекурсивно конвертировать объеты ну и с биндингами что-то делать.


    1. axeax
      10.01.2017 15:08

      В случае с {{firstname}} {{lastname}} мы можем обернуть это в отдельные теги или дополнительно парсить такие конструкции.
      eval в рамках нужной области видимости работал прекрасно, а рекурсивно конвертировать объекты не так сложно, да и если набор данных статичен то в этом нет необходимости.


  1. misato
    10.01.2017 18:13
    +1

    Посмотрите ractive.js — порог вхождения минимальный, всё очень просто, и не требует переделки всего легаси под него.


  1. rumkin
    10.01.2017 20:27
    +4

    Расширение прототипа? В 2017? Это ведь не серьезно!?


    1. bitver
      11.01.2017 13:30

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


  1. vlasenkofedor
    11.01.2017 12:46

    Расширение прототипа? В 2017? Это ведь не серьезно!?

    не стоит все стричь под одну гребенку
    если это ваш проект и вы знаете, что вы делаете, то вполне возможно (в решении статьи не поддерживаю)
    почему не облегчить себе жизнь
    к примеру:
    HTMLCollection.prototype.forEach = NodeList.prototype.forEach = Array.prototype.forEach;
    


    1. justboris
      11.01.2017 12:55
      +1

      Лучше все же использовать Array.from:


      Array.from(document.querySelectorAll('.button')).forEach(...)

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


      1. vlasenkofedor
        11.01.2017 13:16
        -1

        Мы же о коммерческой разработке говорим, а не личных домашних веб-страничках

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


    1. rumkin
      11.01.2017 15:06

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


  1. vlasenkofedor
    11.01.2017 13:07

    Array.from — не кроссбраузерно. Привел решение без полифилов.


    1. justboris
      11.01.2017 13:17
      +1

      Какое решение? С расширением прототипа — это плохое решение


      1. vlasenkofedor
        11.01.2017 13:35

        С расширением прототипа — это плохое решение

        Разработчики языка представили плохую возможность расширение стандартных прототипов?
        Вот уж эти разработчики :-)
        Все плохо без меры. А когда мера есть, то и пользоваться нужно. Так как для этого вам специально и открыли доступ.


        1. justboris
          11.01.2017 13:42
          +1

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


          Сейчас это работает ради обратной совместимости, в документации явно написано, что такой подход не рекомендуется.


          1. vlasenkofedor
            11.01.2017 14:01

            в документации явно написано, что такой подход не рекомендуется.

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

            Что вам было и приведено в качестве примера
            HTMLCollection.prototype.forEach = NodeList.prototype.forEach = Array.prototype.forEach;
            


            1. raveclassic
              11.01.2017 14:05
              +2

              А теперь вы внимательно почитайте спеку. HTMLCollection и NodeList — array-like объекты, которые не подразумевают некоторых методов Array.



              1. vlasenkofedor
                11.01.2017 14:15

                не подразумевают некоторых методов Array

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


                1. justboris
                  11.01.2017 14:19
                  +1

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


                  if(!Array.prototype.forEach) {
                    Array.prototype.forEach = function () {...}
                  }

                  А ваша идея с присвоением метода к NodeList — это отсебятина.
                  И я бы бежал поскорее от работы с проектом, где такое встречу.


                  1. vlasenkofedor
                    11.01.2017 15:30

                    А ваша идея с присвоением метода к NodeList — это отсебятина.
                    И я бы бежал поскорее от работы с проектом, где такое встречу.

                    Да не приглашал я вас на свой горшок.
                    Куда вы собрались бежать бегите. Флаг в руки не забудьте с жесткими утверждениями про прототипы, eval, with…


                  1. Aingis
                    11.01.2017 19:26
                    +1

                    Вообще-то NodeList.forEach() определён в стандарте (через iterable<Node>).

                    На почитать: «NodeList object is finally an Iterable».


                    1. justboris
                      11.01.2017 19:37
                      +1

                      Как интересно, спасибо за информацию.


                      Только одного forEach мало, обычно еще нужны filter и map хотя бы.
                      Так что от Array.from(nodes) или [...nodes] никуда не деться


        1. raveclassic
          11.01.2017 13:44
          +1

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

          UPD: justboris одновременно указали на мдн :)


        1. k12th
          11.01.2017 14:17
          +1

          JS был разработан за 10 дней одним человеком, в нем есть и неудачные моменты, и это нормально.


          На данный момент консенсус такой, что расширять прототипы кастомными методами считается плохой практикой. Судьба Prototype.js о чем-то да говорит.


          Другое дело полифиллы, потому что они future proof.


    1. raveclassic
      11.01.2017 13:23

      Array.fromстандарт, а полифиллы нужны там, где они нужны.


  1. Elfet
    11.01.2017 13:22
    +2

  1. Andrey7287
    13.01.2017 13:39
    +2

    Не так интересна статья, как комментарии под ней =)