Создаем подобие шаблонизатора на TypeScript

в 18:37, , рубрики: dom, innerHTML, javascript, TypeScript, метки: , ,

Совсем недавно Microsoft представила новый язык программирования TypeScript. Наверняка многим понравилось наличие типизации и плагины для Microsoft Visual Studio и других редакторов. Чтобы оценить насколько полезен язык в разработке, я решил поиграться с ним, написав небольшой код, который поможет в разработке приложений.

Каждый с этим сталкивался

Любой, кто разрабатывал приложения с использованием технологий HTML+js, знает, что очень часто приходится решать задачу шаблонизации данных на представлении. Существуют множество решений: как с использованием фреймворков (раз, два, три, четыре и так далее), так и простые методики, вроде таких:

var StringTemplate =
        "<div class="{Flag}" >" +
            "<p class="name" >{Name}</p>" +
            "<p class="message" >{Text}</p>" +
            "<p class="date" >{Date}</p>" +
        "</div>",
    html = "",
    Container = document.getElementById('container2');

    for (var i = 0; i < TestDataLength; i++) {
        
        html += StringTemplate
                .replace(/{Flag}/g, TestData[i].Out ? "message out" : "message")
                .replace(/{Name}/g, TestData[i].Name || "")
                .replace(/{Text}/g, TestData[i].Text || "")
                .replace(/{Date}/g, TestData[i].Date.toLocaleTimeString());
        
    }
    Container.innerHTML = html;

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

Замыкания — любимые друзья js-разработчика!

Самый быстрый способ шаблонизации в случае необходимости частой модификации представления является ручная сборка DOM. Методов для этого нужно аж целых три: document.createElement(), document.createTextNode() и (но не обязательно) document.createAttr(). Работа с этими методами в различных браузерах может быть даже медленнее, чем с innerHTML, но на выходе мы получаем указатель на элемент и код страницы не захламляется идентификаторами.

Немного сахара

Прежде всего, создадим модуль Templater и три функции, которые позволят увеличить производительность и сделают код более приятным для чтения:

module Templater {

    export function Element(TagName: string, Attributes: Object) : HTMLElement {
        var item:HTMLElement = document.createElement(TagName);

        for (var p in Attributes)
            if (typeof(Attributes[p]) == "string")
                item.setAttribute(p, Attributes[p]);
            else if (Attributes[p] instanceof Attr)
                item.setAttributeNode(Attributes[p]);

        return item;
    }

    export function Text(Text: string): Text {
        return document.createTextNode(Text);
    }

    export function Nest(Element: HTMLElement, Items: any[]): HTMLElement {
        var l = Items.length;
        for (var i = 0; i < l; i++)
            if (Items[i])
                Element.appendChild(Items[i]);
        return Element;
    }

   // ...

Первая функция создает тег с указанным именем и задает необходимые атрибуты, Nest — создает иерархическую структуру элементов и Text — просто синоним для document.createTextNode();

Структуры данных

Поскольку в TypeScript есть такое благо цивилизации, как ненавязчивая типизация, то ей стоит воспользоваться. Итак, у нас есть элемент на форме и связанные с ним данные. Сделаем очевидный поступок:

    export class Block {
        public Element: HTMLElement;
        public Binding;

        constructor (public _Element: HTMLElement, public _Binding: Object) {
            this.Element = _Element;
            this.Binding = _Binding;
        }
    }

Ну и вспомогательную структуру, которая будет совершать действия над элементом:

       export class Template {

        public Create: { (Element: { (TagName: string, Attributes: Object): HTMLElement; }, 
                          Nest: { (Element: HTMLElement, Items: any[]) : HTMLElement; },
                          Text: { (Text: string): Text; } ): Block; };
        public Update: { (Element: Block, Data); };

        constructor (public _Create: {
                                (Element: { (TagName: string, Attributes: Object): HTMLElement; },
                                    Nest: { (Element: HTMLElement, Items: any[]): HTMLElement; },
                                    Text: { (Text: string): Text; }) : Block; },
                     public _Update: { (Element: Block, Data); }) {
            this.Create = _Create;
            this.Update = _Update;
        }

        public Make(Data) {
            var item = this.Create(Element, Nest, Text);
            this.Update(item, Data);
            return item;
        }
    }

Внимательный читатель заметит, что в качестве аргументов мы указываем типизированные анонимные функции. Это очень удобно: и разработчик компонента и разработчик, использующий компонент, будут знать, чего библиотека от него хочет.

Пример использования

Шаблон создается таким образом:

import T = Templater;

var myItem = new T.Template(
    (Element, Nest, Text) => {
        var Name, Date, Text, Flag, Container;

        Nest(Container = Element('div', 
                { 'class': Flag = document.createAttribute("class") } ), [
            Name = Element('p', { 'class': 'name' }),
            Text = Element('p', { 'class': 'text' }),
            Date = Element('p', { 'class': 'date' })
        ]);

        return new T.Block(Container, {
            Name: Name,
            Date: Date,
            Text: Text,
            Flag: Flag,
            Container: Container
        });
    },
    (Element: T.Block, Data) => {
        var b = Element.Binding;
        b.Name.innerText = Data.Name || "";
        b.Date.innerText = Data.Date.toLocaleTimeString();
        b.Text.innerText = Data.Text || "";
        b.Flag.nodeValue = "message " + (Data.Out == true ? "out" : "");
    });

Появляется еще одно преимущество TypeScript — укороченная запись анонимной функции ( аргументы ) => { код }. Она делает код намного более понятным к чтению.
Аргументы (Element, Nest, Text) я передаю в первой функции из соображений производительности: все таки прямая ссылка на функцию работает быстрее, чем указатель на статическую функцию модуля. Таковы особенности Javascript и Typescript исправить ситуацию в таком случае просто не способен.
Что же делает этот код?
С помощью первой функции (Create) мы создаем пустой элемент документа, а возвращаем пару (элемент, binding), где последний представляет некое подобие ViewBag, содержащей ссылки на нужные шаблону элементы управления. QuerySelector для такой задачи уже не понадобится.
Вторая же функция (Update) связывает данные из Data на элементы из Binding

Набор тестовых данных

Код, отвечающий за тестовые данные можно вынести в отдельный модуль, как в старом-добром .NET

module Example {
    export class ExampleData {
        public Name: string;
        public Date: Date;
        public Text: string;
        public Out: bool;

        constructor (public _Name: string,
                    public _Date: Date,
                    public _Text: string,
                    public _Out: bool) {
            this.Name = _Name;
            this.Date = _Date;
            this.Text = _Text;
            this.Out = _Out;
        }

        public static GetExampleData(count: number): any[] {
            var result = [],
                ExampleText = "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit...",
                NameA = "John Doe", NameB = "Jane Doe",
                StartDate = new Date(),
                Interval = 300;

            for (var i = 0; i < count; i++) {
                result.push(new ExampleData(
                    i % 2 == 1 ? NameA : NameB,
                    StartDate,
                    ExampleText,
                    i % 2 == 1));
                StartDate = new Date(StartDate.getTime() + Interval);
            }

            return result;
        }
    }
}

Работая с TypeScript не перестаешь радоваться тому, что ключевое слово this в этом языке обозначает то же, что и C-подобных языках программирования — ссылку на текущий экземпляр объекта класса, а не «что-то-там в зависимости от контекста вызова функции».

Запускаем

Для того, чтобы проверить работоспособность кода, осталось главную функцию:

window.onload = () => {

    var TestDataLength = 1500,
        TestData = Example.ExampleData.GetExampleData(TestDataLength),
        Container = document.getElementById('container1');

    var TestStart = new Date();
    for (var i = 0; i < TestDataLength; i++)
        Container.appendChild(myItem.Make(TestData[i]).Element);
    var TestEnd = new Date();

    console.log("Test completed", {
        time: TestEnd.getTime() - TestStart.getTime(),
        count: TestDataLength
    });

    var StringTemplate =
        "<div class="{Flag}" >" +
            "<p class="name" >{Name}</p>" +
            "<p class="message" >{Text}</p>" +
            "<p class="date" >{Date}</p>" +
        "</div>";
    var html = "";
    Container = document.getElementById('container2');

    TestStart = new Date();
    for (var i = 0; i < TestDataLength; i++) {
        
        html += StringTemplate
                .replace(/{Flag}/g, TestData[i].Out ? "message out" : "message")
                .replace(/{Name}/g, TestData[i].Name || "")
                .replace(/{Text}/g, TestData[i].Text || "")
                .replace(/{Date}/g, TestData[i].Date.toLocaleTimeString());
        
    }
    Container.innerHTML = html;
    TestEnd = new Date();
    console.log("Test with string templater completed", {
        time: TestEnd.getTime() - TestStart.getTime(),
        count: TestDataLength
    });
};

И выполнить простую команду:
tsc templater.ts
Немного подождав, компилятор выдает скомпилированный в js файл, который практически не отличается по размеру и читаемости от исходного. Спасибо, Microsoft!

Ссылка на исходный код

Автор: Dima_Sharihin


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


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