Простой js шаблонизатор

1012
views

Недавно пришлось столкнуться с уже готовым очень простым проектом, в котором было необходимо добавить вывод дерева категорий и товаров в них. Данные приходили в виде json через запросы к API. В арсенале на фронте был лишь jQuery, а привыкнув к шикарному шаблонизатору Angular’а, я не хотел вновь возвращаться к конкатенации строк, и подключать какой-то фреймворк естественно не имело смысла. Мне необходим был минимальный шаблонизатор, который мог бы повторять куски верстки подобно директивам angular, с условиями и переменными.

Вооружившись статьями авторов John Resig и Krasimir Tsonev , я приступил к работе. В итоге я получил функцию, которая могла компилировать шаблон типа:

<script type="text/html" id="tpl">
    <%for(var i in products) {%>
    <div class="triple_service_item">
        <div class="row">
            <div class="col-xs-5 triple_service_name">
                <%products[i].name%>
            </div>
            <div class="col-xs-4 triple_service_time">
                <%products[i].spent_time%> min
            </div>
            <div class="col-xs-3 triple_service_price">
                <%products[i].price%>
            </div>
        </div>
    </div>
    <%}%>
</script>

Начнем с банального:

var TemplateEngine = function(tpl, data) {
    // код шаблонизатора
}
var template = '<p>Hello, my name is <%name%>. I\'m <%age%> years old.</p>';
console.log(TemplateEngine(template, {
    name: "John",
    age: 23
}));

И как вы догадались, хотелось бы, что бы функция возвращала:

<p>Hello, my name is John. I'm 23 years old.</p>

Для обнаружения js переменных в тексте верстки воспользуемся регулярным выражением:

var re = /<%([^%>]+)?%>/g

С помощью данной регулярки мы сможем находить все что находится между тегами <% и %>. Параметр /g обозначает, что нас интересует не одно совпадение, а все.

Есть множество способов как использовать в js регулярное выражение, мы воспользуемся методом .exec():

var re = /<%([^%>]+)?%>/g;
var match = re.exec(tpl);

Если мы сделаем console.log переменной match, то получим следующее:

[
    "<%name%>",
    " name ", 
    index: 21,
    input: 
    "<p>Hello, my name is <%name%>. I\'m <%age%> years old.</p>"
]

Как вы видите, наш масcив содержит лишь один элемент, а обработать нам нужно все, поэтому обернем всю логику в цикл while.

var re = /<%([^%>]+)?%>/g, match;
while(match = re.exec(tpl)) {
    console.log(match);
}

Запустив данный код мы найдем обе переменные <%name%> и <%age%>.

Теперь самое интересное, нам необходимо заменить найденные переменные их значениями. Самое простое что приходит на ум это сделать простой .replace(). Но это бы работало с простыми json объектами с одним уровнем вложенности. На практике же мы имеем дело с объектами, которые имеют многоуровневую вложенность:

{
    name: "John",
    profile: { age: 23 }
}

И tpl.replace(match[0], data[match[1]]) будет уже не достаточным решением. Потому что когда мы напишем <%profile.age%> , код заменится на data[«profile.age»] и будет undefined. Так как способ замены нам не подходит, было бы очень круто, если бы между тегами можно было выполнять реальный js код.

var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';

Как избавиться от this я расскажу ниже.

Как же это реализовать? В статье Джона Резига он использует new Function для создания функции из строки.

var fn = new Function("arg", "console.log(arg + 1);");
fn(2); // outputs 3

Для понимания, данный код можно рассмотреть как:

var fn = function(arg) {
    console.log(arg + 1);
}
fn(2); // outputs 3

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

Это именно то, что нам необходимо, нам необходимо шаблон преобразовывать в вид:

return 
"<p>Hello, my name is " + 
this.name + 
". I\'m " + 
this.profile.age + 
" years old.</p>";

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

return
'My skills:' + 
for(var index in this.skills) { +
'<a href="">' + 
this.skills[index] +
'</a>' +
}

мы конечно будем хватать ошибки. Именно для решения этой задачи Джон разбивает строку на элементы массива а в конце объединяет в строку.

var r = [];
r.push('My skills:'); 
for(var index in this.skills) {
r.push('<a href="">');
r.push(this.skills[index]);
r.push('</a>');
}
return r.join('');

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

var TemplateEngine = function(tpl, data) {
    var re = /<%([^%>]+)?%>/g,
        code = 'var r=[];\n',
        cursor = 0, match;
    var add = function(line) {
        code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
    }
    while(match = re.exec(tpl)) {
        add(tpl.slice(cursor, match.index));
        add(match[1]);
        cursor = match.index + match[0].length;
    }
    add(tpl.substr(cursor, tpl.length - cursor));
    code += 'return r.join("");'; // <-- return the result
    console.log(code);
    return tpl;
}
var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';
console.log(TemplateEngine(template, {
    name: "John",
    profile: { age: 23 }
}));

Но теперь осталась задача распознавать, когда код между тегами <% %> является переменной и её нужно добавлять в массив, а когда это js функция типа цикла и её сразу выполнять.

Что бы переменная не добавлялась в массив в виде строки, мы добавим проверку на js код:

var add = function(line, js) {
    js? code += 'r.push(' + line + ');\n' :
        code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}
var match;
while(match = re.exec(tpl)) {
    add(tpl.slice(cursor, match.index));
    add(match[1], true); // <-- say that this is actually valid js
    cursor = match.index + match[0].length;
}

Результатом данного кода будет:

var r=[];
r.push("<p>Hello, my name is ");
r.push(this.name);
r.push(". I'm ");
r.push(this.profile.age);
return r.join("");

Теперь все что нам необходимо, это передать полученную строку как параметр в new Function и выполнить её.

return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);

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

var template = 
'My skills:' + 
'<%for(var index in this.skills) {%>' + 
'<a href="#"><%this.skills[index]%></a>' +
'<%}%>';
console.log(TemplateEngine(template, {
    skills: ["js", "html", "css"]
}));

Для того, что бы в итоге получать рабочий код типа:

var r=[];
r.push("My skills:");
r.push(for(var index in this.skills) {);
r.push("<a href=\"\">");
r.push(this.skills[index]);
r.push("</a>");
r.push(});
r.push("");
return r.join("");

мы добавим второе регулярное выражение. Воспользовавшись регуляркой из статьи Кразимира, я столкнулся с проблемой, когда она срабатывала на переменные, которые начинались на for.  В моем случае это была переменная for_man. Поэтому я написал вот такую регулярку:

reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(?:(?=\()|(?= )|$)/g

Внедрив эту регулярку наш код будет выглядеть примерно так:

var re = /<%([^%>]+)?%>/g, 
    reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(?:(?=\()|(?= )|$)/g, 
    code = 'var r=[];\n', 
    cursor = 0, match;
    var add = function(line, js) {
        js? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
            (code += line != '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '');
        return add;
    };

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

var r=[];
r.push("My skills:");
for(var index in this.skills) {
r.push("<a href=\"#\">");
r.push(this.skills[index]);
r.push("</a>");
}
r.push("");
return r.join("");

И конечно же все это будет успешно скомпилировано.

Теперь для того, что бы шаблон не передавать строкой, а удобно его верстать в теле html страницы, мы поместим его между тегами:

<script type="text/html"></script>

В данном случае mime тип text/html для браузера будет неизвестным и он пропустит выполнение его. Но содержимое этих тегов мы легко можем получить с помощью .innerHTML.

Добавим теперь в начало нашего шаблонизатора проверку, если строка начинается с #, то это id нашего шаблона.

var html = tpl.charAt(0) === '#' ? document.getElementById(tpl.substring(1)).innerHTML : tpl;

Если строка начинается не с #, значит мы передали сразу шаблон как строку.

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

return new Function(name, code.replace(/[\r\t\n]/g, '')).apply(this,value);

Теперь наш шаблонизатор может принять шаблон типа:

<script type="text/html" id="tpl">
    <%for(var i in products) {%>
    <div class="triple_service_item">
        <div class="row">
            <div class="col-xs-5 triple_service_name">
                <%products[i].name%>
            </div>
            <div class="col-xs-4 triple_service_time">
                <%products[i].spent_time%> min
            </div>
            <div class="col-xs-3 triple_service_price">
                <%products[i].price%>
            </div>
        </div>
    </div>
    <%}%>
</script>

при этом js будет выглядеть так:

var scope = [
    {
        name: 'item 1',
        price: '10$',
        time: '30'
    },
    {
        name: 'item 1',
        price: '10$',
        time: '30'
    },
    {
        name: 'item 1',
        price: '10$',
        time: '30'
    }
];
var template = tpl('#tpl_id',scope);

Теперь содержимое переменной template будет скомпилированной версткой, которую мы можем вставить в любой участок кода.

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

var tpl = function (str, data) {
    var name = [], value = [];
    var html = str.charAt(0) === '#' ? document.getElementById(str.substring(1)).innerHTML : str;
    if (typeof(data) === "object") {
        for (var k in data) {
            name.push(k);
            value.push(data[k]);
        }
    }
    var re = /<%([^%>]+)?%>/g, reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(?:(?=\()|(?= )|$)/g, code = 'var r=[];\n', cursor = 0, match;
    var add = function(line, js) {
        js? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
            (code += line != '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '');
        return add;
    };
    while(match = re.exec(html)) {
        add(html.slice(cursor, match.index))(match[1], true);
        cursor = match.index + match[0].length;
    }
    add(html.substr(cursor, html.length - cursor));
    code += 'return r.join("");';
    return new Function(name, code.replace(/[\r\t\n]/g, '')).apply(this,value);
};