Компактный (3.5 Кб) и быстрый шаблонизатор doT.js для браузеров и nodeJS до сих пор (v.1.0.1) имеет итерацию только по массивам. Это не всегда удобно — подгонять управляющий объект под наличие в нём массивов. Лучше подогнать шаблонизатор под наличие в нём итератора по объектам c проверкой условия. Проверять условия в циклах по объектам приходится часто — это и hasOwnProperty(), и проверка на DOM-объект, и взятие части хеша по фильтрации индексов.
Как пишут в шаблоне итерацию по массиву? Примерно так:
{{~it.elemsArray:property:i}} ... {{~}}
Внутри цикла можно использовать {{=property}} (значение текущего свойства), {{=i}} (индекс массива) и выражения, основанные на них. Ничего не мешает дописать в шаблонизаторе проверку не только на массив, но и на объект, чтобы корректно выбрать дальнейшее построение цикла. Для этого понадобится забраться внутрь достаточно простого шаблонизатора и переписать цикл, уже работающий по массивам, на цикл, который будет работать по объектам. Оставив, конечно, исходную возможность в общем коде.
Добавим в шаблонизатор ещё один тип итератора. (Число команд в арсенале увеличится на одну — {{@ ...}}, поэтому шаблонизатор станет работать немного медленнее — на время поиска ещё одного регекспа по шаблону.)
{{@ it.elemsObject : property : i : condition}} ... {{@}} <!--это будет итератор по объектам-->
{{~ it.elemsObjectOrArray : property : i : condition}} ... {{~}}
Усложнится дальнейшая генерация кода, но она, как известно, выполняется быстро. (Медленнее, скорее всего, будет даже не парсинг шаблона, а интерпретация синтезированного кода.)
"condition" здесь — просто исполняемая часть выражения. Особенность этого шаблонизатора, как и многих других — в том, что он синтезирует JS-код (что называется — компилирует шаблон), превращая его в исполняемую функцию, и тут же её выполняет. Воспользуемся этим, чтобы просто присоединить часть выражения к значению property, которое вычисляется в цикле. Как известно, цикл по объектам в JS часто выглядит так:
if(it.elemsObject)
for(var i in it.elemsObject){
var property = it.elemsObject[i];
if(property condition){
... тело цикла ...
}};
Именно это и будет делать итератор по объекту в нашем дополнении. Синтакически непонятное «property condition» — это наша уловка, полноценно использующая и без того существующий недостаток этого шаблонизатора — неявное исполнение eval() в new Function(). Чтобы не нарушать синтаксическую целостность кода, надо будет соблюдать требование, чтобы condition было выражением, не меняющим смысл при сцеплении с property.
Вот 3 наиболее популярных примера, когда это нужно и как это будет выглядеть.
1. Цикл по собственным свойствам
{{@ it.elemsObject: propName: ii :.hasOwnProperty(ii)}}… {{@}}
2. Цикл по DOM-элементам
{{@ it.elemsObject : elName : ii :.attributes}} ... {{@}}
Даст цикл:
var arr1 = it.elemsObject;
if(arr1)
for(var ii in arr1){
var elName = arr1[ii];
if(elName.attributes){
out += '...'
}}
3. Цикл по части основного объекта
{{@ it.elemsObject : elName : ii :, /yd+/.test(ii) }} ... {{@}}
Выбирает для итераций только те объекты, индексы которых выглядят как «y<число>».
Последний пример сильно расширяет гибкость использования шаблонов — в корень общего объекта можно поместить одну или несколько коллекций элементов, отличающихся форматом индекса или свойствами своих значений. (Например, значения могут быть объектом со свойством — признаком коллекции. Здесь же продемонстрировано, как легко отказаться в шаблоне от элемента propName по умолчанию — просто поставив запятую, превратив выражение в перечисление выражений.
В существующем шаблонизаторе такие циклы, на самом деле, можно было бы реализовать за счёт использования конструкции «evaluate»: {{ <произвольные операторы> }}. Тогда пришлось бы полностью описать свой цикл в шаблоне. Но вместо этого создаём небольшое дополнение конструкции цикла по массивам, чтобы иметь краткую запись условного выражения, не теряющую понятности. Немного грамотно было бы (для больших систем) — обернуть выражение в функцию (чтобы не иметь составного синтаксиса), что, кстати, замедлило бы его и потребовало бы явно писать имя property в условии.
Для справки, цикл, который делает существующий шаблонизатор по массивам, выглядит так:
var arr1 = it.elemsObject;
if(arr1){
var vname, ii =-1, l1 = arr1.length -1;
while(ii < l1){
vname = arr1[++ii];
out += '...'
}}
Поэтому он не мог работать c объектами — он использует счётчик. Который, наверное, немного быстрее, чем универсальный цикл for-in, но потеря времени идёт не на циклах. Теряется время на интерпретации синтезированного шаблона. Не будем в данном расширении его как-либо совершенствовать, чтобы полностью сохранить работоспособность и поведение существующих шаблонов.
Есть в нашем новом типе цикла, c condition в конце, небольшое, но естественное ограничение — в нём нельзя использовать 2 символа "}" подряд. Во всех случаях кода это легко обойти. Допустимо писать многострочные выражения с определениями функций, например, не забывая лишь о том, что "}}" — это конец заголовка итератора. Пример:
{{@ it :year:i:, aa = function(){
return 1234;
}, /^yd+$/.test(i + aa) }}
С такими возможностями шаблон легко превращается в описание функций с небольшими вставками HTML (а не наоборот). Необязательно это удобно, но ограничения на JS в итераторе мы сняли.
Сопутствующие доработки
Значения propName: ii можно не писать — регексп парсинга в шаблонизаторе поправлен так, чтобы при отсутствии значений подставлялись имена по умолчанию. Имя свойства по умолчанию — arrI1, arrI2 и далее, в зависимости от порядкового номера заголовка итератора. Индекс объекта по умолчанию — i1, i2 или далее. Такие странные имена выбраны по историческим причинам — такие же существуют в doT.js для индексов массивов, а неявные имена массивов выглядели как arr1, arr2 и далее. Мы просто сохраняем традицию: если имя хеша — arr1, то имя его свойства — arrI1 (равно arr1[i1]). Например,
{{@ it.elemsObject :::.attributes}}
равносильно
{{@ it.elemsObject : arrI1 : i1 :.attributes}}
(если это — первый заголовок итератора в шаблоне)
или
{{@ it.elemsObject : arrI2 : i2 :.attributes}}
(если это — второй заголовок итератора в шаблоне, и т.д.)
Если подумать над качеством чтения шаблона, то имя по умолчанию правильнее было бы выбрать как последнее в составном имени объекта. В данном примере — elemsObject. Было бы так: {{@ it.elemsObject}} равен {{@ it.elemsObject: elemsObjectI1: i1}}. Но ради исторического соответствия не будем вводить такое правило для итератора.
* Второе важное дополнение. Чтобы не удалялись элементы со значением 0||''||false||null — фильтр цикла становится true (фильтр не действует), если выражение в заголовке итератора отсутствует.
И третье — многострочный парттерн в регекспе для первого параметра (имя массива или объекта) заменён на односторочный (".+"). Это приводит к небольшому ускорению парсинга (2% по измерениям).
Сравнение скоростей
Сколько же мы заплатим по времени исполнения за то, что включим в шаблонизатор возможность цикла по объектам?
Ответ на вопрос даст тест, который лежит в проекте doT.js на Гитхабе. Его понадобится переписать, потому что он измеряет скорости скомпилированных шаблонов, а интересует в первую очередь разница в скоростях компиляции. Будем для простоты рассматривать тесты в Хроме (версия 30). На других браузерах их легко повторить и сделать похожие выводы и сравнение.
Он изначально сделан для сравнения сильно укороченного doU.js и полного doT.js, и показывает, насколько медленнее исполняемая часть полной функции doT.compile() по сравнению с сильно укороченной. Ещё там доказывается, что использование «it» вместо this почти не имеет разницы на длинных шаблонах, поэтому тесты изобилуют строчками измерений разных простых вариантов. Шаблон, скорость работы которого измеряется, выглядит так:
<h1>Just static text</h1>
<p>Here is a simple {{=it.f1}} </p>
<div>test {{=it.f2}}
<div>{{=it.f3}}</div>
<div>{{!it.f4}}</div>
</div>
Или его модификации, или повторения много раз (32-128-256). Нас интересуют эти тесты, чтобы показать, что лишняя проверка на заголовок итератора не замедлила работу простых шаблонов.
Для наших целей интересуют ещё тесты циклов. Чтобы их скорость была примерно равна скорости простых шаблонов, написан такой шаблон для теста (он без переносов строк):
<h1>Text from hash</h1><div>
{{@it::i}} <div>{{=i}}: {{=it[i]}} : </div> {{@}}
{{@it:val:i}} <div>{{=i}}: {{=val}} : </div> {{@}}
{{@it:a}} <div>{{=a}}: {{=a}} : </div> {{@}}
</div>
Для тестирования в старом шаблонизаторе doT.js версии 1.0.1 символы "@" заменялись на "~". То же — для тестирования по массивам — в новом. И данные использовались для одного случая — хеши, для другого — массивы с тем же количеством элементов. По результатам внизу диаграммы увидим, кто из них быстрее.
Будет интересовать как несжатая (doT11.js), так и сжатая (doT11m.js) новая версия. Для прохождения тестов переменную doT в каждой версии пришлось сделать уникальной. На картинках изображены диаграммы скоростей прохождения тестов (число вычислений с секунду): больше — быстрее, а значит, лучше. Первая картинка — это результат (внимание) скомпилированных шаблонов. Поэтому они такие быстрые — 500-700 К в секунду, а разница между минифицированной версией и несжатой не имеет того смысла, который мы хотим видеть. Зато, этот тест показывает, насколько медленнее полный цикл (строчки «5. doT.js», 6, 7) по сравнению с результатами компиляции (больше показатель — лучше).
Результаты показывают, что время компиляции шаблона занимает огромную долю общего времени — от 99% для коротких шаблонов до 90% для длинных (больше 10КБ), а для перезагруженного браузера — 92% и 75% при тех же условиях. Это есть плата за неявные eval(), которые приходится выполнять при дешаблонизации.
По числам видно, что минифицированный код работает быстрее на 1-1.5%. А исполнение компилированных шаблонов, по идее, не должна вообще зависеть от минификации. Зависимость — или проявление случайных погрешностей, или ошибки компиляции функций, или погрешности измерительного инструмента.
Отдельного внимания заслуживает сравнение 2 типов циклов. Как предполагалось, цикл по массиву работает быстрее на почти тех же шаблонах, но немного различных данных. Разница — в 20% для коротких шаблонов, 25% для шаблонов средней длины (2-5 КБ с числом циклов до 300 в шаблоне). При 100 и более циклах в Хроме появляется интересный эффект, видимо, связанный с числом переменных в скомпилированной функции. Производительность цикла по объектов остаётся на обычном уровне, а циклы по массивам вдруг начинают резко проседать с ростом числа циклов (и переменных) в шаблоне. Например, при 190 циклах тест шаблона становится медленнее на 10%, а при 750 циклах — вообще чуть ли не тормозится — 6% от скорости шаблона по объектам.
Следующий рисунок иллюстрирует начало коллапса циклов по массивам на длинных шаблонах с числом циклов 386 (больше показатель — лучше).
Интересен вопрос — есть ли порог коллапса для цикла по объектам? Да. Увеличив объём тестового шаблона ещё в 2 раза (до 512 копий короткого шаблона), получаем зависание браузера на итерациях по массиву и раз в 10 меньшую скорость работы циклов по объектам. (Возможно, в таком режиме что-то не оптимизировано и переполняется какой-нибудь стек. Или так влияет скрипт тестирования, использованный в данном проекте, который, кроме шаблонизации, делает ещё тысячи и сотни тысяч повторений тестов, и у него тоже могло что-нибудь «потечь». Пока же делаем вывод, что предел числа циклов в шаблоне и в функциях JS вообще — есть. Лучше не делать больше 1000 циклов по объектам в одном шаблоне (для Хрома).)
Ещё замечен эффект общего замедления работы шаблонизатора со временем работы открытого браузера и проведения измерений в нём. При этом скорость работы по массивам начинает выглядеть не такой значительно повышенной по отношению к скорости по объектам.
Скорости интерпретации шаблонов
Исходный интерес в вопросе о скоростях был в скорости интерпретации итератора по объектам. То, что скомпилированный шаблон по массиву работает быстрее — уже выяснили. Но разработчикам часто интересны не компилирвоанные шаблоны, а их интерпретация, пусть она и в 10-100 раз медленнее этапа исполнения. Ведь если надо отрисовать страницу за 30 мс, обычно не задумываются о компиляции для последующих отрисовок за 3 мс. Сделаем тесты для интерпретации наших шаблонов, используя те же заготовки функций и данных. Именно тут сыграет свою роль заготовка минифицированного шаблонизатора — в первой серии измерений она не имела значения.
В имеющихся тестах тоже есть отдельная страничка для отдельной компиляции (без исполнения). Преобразуем тесты на ней в полные, добавив исполнение отрисовки шаблонов. Это, практически, не изменит картину, но даст реалистичные тесты, более похожие на практические задачи.
Запускаем файл doT/benchmarks/compileBench.html.
Эта группа тестов показала всё, что нас интересовало:
1) минифицированный код показывает стабильное ускорение для одиночных коротких шаблонов — на 5%;
2) для тех же, но несжатых функций, короткие шаблоны демонстрируют замедление по сравшению с старым кодом в тестах с циклами измерений, на 4%;
3) для длинных шаблонов новый код всегда стабильно быстрее на 3%;
Интуитивно казалось, что сравнение покажет замедление на 10%, особенно там, где надо парсить массивы — примерно на столько увеличивается работа по проходам регекспов по шаблону. Изменения показывают соответствие этим предположениям, но совсем не такое значительное. Во-первых, оно скомпенсировано улучшением шаблона массива без потери свойств (".*"), во-вторых — шаблон стал медленее на коротких шаблонах на 4% (на всех, не только массивы), но разница исчезает на длинных — в этом и заключается ожидаемое замедление.
Отдельной группой стоят измерения по другим шаблонам, с циклами (3 последних измерения). Третье — это шаблон по массивам в старом коде. Его пришлось чуть подправить, чтобы не было неявных переменных ("~it:val:i", а не "@it::i"). Возможно, поэтому он немного медленее, на 10% в коротких шаблонах. Видны другие особенности:
4) Интерпретатор по объектам медленнее интерпретатора по массивам на 3-5%;
5) исполнение того и другого было быстрее относительно других измерений, а интерпретация — в 2-3 раза медленнее; чем длиннее шаблон, тем медленнее;
6) старый код по массивам не быстрее нового кода по объектам — быстрее на указанные 3-5% лишь новый код по массивам. (Этот эффект получен небольшим переписыванием кода компиляции — использованы "++" вместо "+=1".)
7) скорость работы шаблонов и быстрота работы по массивам заметно (в разы) зависит от перезагрузки браузера. Массивы быстрее циклов по объектов на 20% вначале и на 5% через некоторое время работы тестовой страницы. Возможно, это — не только эффект работы функции, но и влияние среды тестирования (тестировалось в Хроме 32bit, Win).
Ответы на вопрос, не стал ли код хуже, мы получили. В целом, он не требует дальнейших доработок, чтобы его работа стала похожа на работу старого кода. Заменять старый на новый в существующих проектах — можно. Даже, если вопрос включает и скорости компиляции или исполнения.
Ещё раз не мешает напомнить: интерпретация (см. тесты) — гораздо медленнее исполнения скомпилированных шаблонов. Если где-то в проекте важна скорость и есть более одного исполнения шаблона — используйте отдельную компиляцию (doT.compile()).
Хакнем шаблонизатор дальше?
Если описанное решение хорошо подходит для осторожного расширения функций как ничего не меняющее для старого кода, лишь добавляющее новый итератор, то для новых проектов можно использовать сразу вариант, в котором цикл над массивами while заменён на универсальный цикл for-in, а к циклу над массивом добавлено выражение-фильтр. Два типа итератора объединяются фактически в один, а скорость парсинга практически не страдает, как и скорость прохождения цикла. Могут быть побочные эффекты, поэтому замену на второй вариант неплохо бы протестировать в существующем проекте, чтобы определить, не будет ли нештатных ситуаций с ошибочными шаблонами. Меняются циклы, а значит — меняются побочные эффекты от ошибок.
Для цикла for-in над массивом для некоторых старых браузеров надо следить, чтобы итератор работал только над числовыми индексами и не захватил в итерацию свойство length. Требование отлично выполняется имеющимся механизмом фильтрации цикла, поэтому остаётся написать лишь корректную проверку, если объект оказался массивом. Не лишней будет такая проверка и для коллекций типа arguments или attributes. И точно не лишней — для объектов, унаследовавших свойства массива. Объекты на основе массива будут по умолчанию итерироваться только по числовым индексам.
Впрочем, это — несколько другая задача. В данной статье ограничимся сделанным осторожным расширением, которое проверено и работает на одном проекте.
Результаты
Исследовано дополнение шаблонизатора doT.js до возможности итерации по объектам, сделанное с целью получить расширение, не нарушающее остальные свойства имеющейся версии.
Получили:
1) итерацию в шаблоне по объектам ({{@...}}) с возможностью фильтрации части элементов по любому условию;
2) возможность не указывать неиспользуемые параметры и двоеточия в конце заголовка итератора;
3) для итератора по массивам добавлена аналогичная возможность не указывать параметры в заголовке итератора по массивам;
4) нет потери скорости в работе шаблонизатора по сравнению со старой версией (есть незначительная потеря на коротких шаблонах);
5) минифицированная версия — на 250 байт больше, а на коротких шаблонах работатет немного быстрее;
6) на длинных шаблонах новая версия — на 3% быстрее при полном цикле (компиляция + исполнение).
О минификации. Результаты необходимо руками исправлять — восстанавливать переменные «global» и «doT», чтобы всё корректно работало. Этого не сделано в минифицированной версии 1.0.1 на гитхабе автора, поэтому она работает не совсем так, как несжатая (doT не появляется в глобальном окружении).
Брать в ветке GitHub; jsFiddle для проверки.
Автор: spmbt