Строим прочный прозрачный frontend

в 8:59, , рубрики: html, javascript, optimization, patterns, web, Веб-разработка, Проектирование и рефакторинг, метки: , , , , , ,

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

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

В качестве примера создадим сайт-базу контактов.

Подготовка

Для начала создадим файл index.html и добавим в него следующий код:

<!DOCTYPE html>
<html>
    <head>
        <title>Контакты</title>
        <meta charset="utf-8">
        <script type="text/javascript" src="http://code.jquery.com/jquery-1.8.3.min.js"></script>
        <script type="text/javascript" src="contacts.js"></script>
        <script type="text/javascript">
            var contacts = new Contacts

            jQuery(function(){
               contacts.getAll()
            })
        </script>
    </head>
    <body>
        <ol id="list">
            <li></li>
        </ol>
    </body>
</html>

Создадим второй файл в той же папке, назовём его contacts.js и добавим в него следующее:

Contacts = function()
{
}
Contacts.prototype.getAll = function()
{
    jQuery('#list').html(
        '<li>Петр Конюшин petr@konyushin.com</li>'+
        '<li>Кристина Ложкина krispink@mail.ru</li>'+
        '<li>Арсений Овалов mrbig@hdfgh.com</li>')
}

Если теперь открыть файл index.html в браузере, то можно увидеть пронумерованный список каких-то контактов — пока ничего интересного.

image

Рассмотрим наш код. Сразу создаётся экземпляр объекта Contacts:

var contacts = new Contacts

Когда сайт полностью загружен в браузер, вызывается метод getAll этого объекта:

jQuery(function(){
    contacts.getAll()
})

Сам объект пока имеет пустой конструктор

Contacts = function(){}

И один единственный метод, объявленный самым экономным для памяти способом:

Contacts.prototype.getAll = function()

Метод находит тэг с id равным «list» и заполняет его HTML кодом с контактами:

Contacts.prototype.getAll = function()
{
    jQuery('#list').html(
        '<li>Петр Конюшин petr@konyushin.com</li>'+
        '<li>Кристина Ложкина krispink@mail.ru</li>'+
        '<li>Арсений Овалов mrbig@hdfgh.com</li>')
}

Очевидно, что уже сейчас можно не пользоваться HTML напрямую и загружать список контактов используя объект Contacts и его метод getAll, но мы ещё далеки от намеченного уровня абстракции.

Следующим шагом мы избавимся от HTML кода в объекте Contacts.

Новый уровень

Создадим папку «ui», и создадим в новой папке файл «list.js». Добавим в новый файл следующий код:

UI_List = function(containerId)
{
    this._containerId = containerId
}
UI_List.prototype.getList = function()
{
    jQuery('#'+this._containerId).html(
        '<li>Петр Конюшин petr@konyushin.com</li>'+
        '<li>Кристина Ложкина krispink@mail.ru</li>'+
        '<li>Арсений Овалов mrbig@hdfgh.com</li>'
    )
}

Отредактируем файл contacts.js следующим образом:

Contacts = function()
{
    this._uiElements = []
}
Contacts.prototype.addUIElement = function(uiElement)
{
    this._uiElements.push(uiElement)
}
Contacts.prototype.getAll = function()
{
    for(var uiElementIndex = 0; uiElementIndex < this._uiElements.length; uiElementIndex ++)
    {
        this._uiElements[uiElementIndex].getList()
    }
}

И, наконец, обновим index.html:

<!DOCTYPE html>
<html>
    <head>
        <title>Контакты</title>
        <meta charset="utf-8">
        <script type="text/javascript" src="http://code.jquery.com/jquery-1.8.3.min.js"></script>
        <script type="text/javascript" src="contacts.js"></script>
        <script type="text/javascript" src="ui/list.js"></script>
        <script type="text/javascript">
            var contacts = new Contacts

            var uiList1 = new UI_List('list1')
            var uiList2 = new UI_List('list2')
            contacts.addUIElement(uiList1)
            contacts.addUIElement(uiList2)

            jQuery(function(){
               contacts.getAll()
            })
        </script>
    </head>
    <body>
        <ol id="list1">
            <li></li>
        </ol>
        <ul id="list2">
            <li></li>
        </ul>
    </body>
</html>

Если теперь откроем файл index.html, то увидим два списка вместо одного.

image

Пройдёмся быстро по коду. Добавили новый объект UI_List, который в качестве аргумента конструктора принимает id тэга, в который будут записываться контакты:

UI_List = function(containerId)
{
    this._containerId = containerId
}

Логику метода getAll мы перенесли в новый объект в метод getList, изменив только селектор jQuery:

UI_List.prototype.getList = function()
{
    jQuery('#'+this._containerId).html(
        '<li>Петр Конюшин petr@konyushin.com</li>'+
        '<li>Кристина Ложкина krispink@mail.ru</li>'+
        '<li>Арсений Овалов mrbig@hdfgh.com</li>'
    )
}

Мы изменили конструктор объекта Contacts, теперь в нём инициализируется свойство _uiElements как пустой массив. Это свойство исключительно для использования методами объекта Contacts поэтому выделено подчёркиванием в названии.

Contacts = function()
{
    this._uiElements = []
}

В этом свойстве будет хранится, в частности, экземпляр нашего нового объекта UI_List. Чтобы добавить его туда, реализовали метод:

Contacts.prototype.addUIElement = function(uiElement)
{
    this._uiElements.push(uiElement)
}

В этом методе можно будет добавить проверку на интерфейс, на текущий момент можно проверять, реализована ли у добавляемого UI элемента функция getList. Наконец, метод getAll объекта Contacts был изменён, теперь он проходит по всем добавленным в Contacts UI элементам и пытается вызвать у них метод getList.

В index.html мы добавили инстанциирование (создание экземпляров) объекта UI_List, а так же добавили эти экземпляры как UI elements в объект contacts.

var uiList1 = new UI_List('list1')
var uiList2 = new UI_List('list2')
contacts.addUIElement(uiList1)
contacts.addUIElement(uiList2)

Обратите внимание, что код в document.ready остался неизменным:

jQuery(function(){
		contacts.getAll()
})

Подитожим, чего мы добились на текущий момент.

  1. У нас есть уровень Contacts, на котором реализовано добавление скольких угодно объектов, и единственный метод getAll, который вызывает getList у каждого добавленного объекта. На этом уровне нас не интересует HTML.
  2. Глубже у нас есть ещё один уровень — это те объекты, которые добавляются в Contacts. Их может быть множество, их можно добавлять или удалять, но уровень выше не сломается. Каждый такой объект, так называемый UI элемент, собирает/енкапсулирует всю логику связанную с определённой частью пользовательского интерфейса. В данном случае с помощью UI_List мы собрали всю логику, касающуюся тэга списка с определённым id.
  3. Мы можем легко множить списки контактов, програмируя на уровне Contacts, то есть в файле index.html

Расширение функционала

Добавим теперь заметки к каждому контакту. Создадим новый файл ui/note.js и напишем в нём следующее:

UI_Note = function()
{
}
UI_Note.prototype.getNote = function(contactId)
{
    var notes = ['Отчёт', 'День рождения 5 января', 'Долг мне 500']
    jQuery('#note').html(
        notes[contactId]
    )
}

Изменим немного метод getList объекта UI_List, назначим метод на клик по контакту:

UI_List.prototype.getList = function()
{
    jQuery('#'+this._containerId).html(
        '<li><a href="#" onclick="contacts.getNote(0);return false;">Петр Конюшин petr@konyushin.com</a></li>'+
        '<li><a href="#" onclick="contacts.getNote(1);return false;">Кристина Ложкина krispink@mail.ru</a></li>'+
        '<li><a href="#" onclick="contacts.getNote(2);return false;">Арсений Овалов mrbig@hdfgh.com</a></li>'
    )
}

Так же добавим в этот файл костыль, так как без него не будет работать. Мы уберём его позже

UI_List.prototype.getNote = function()
{
}

Теперь добавим в Contacts метод getNote, который аргументом будет принимать id контакта:

Contacts.prototype.getNote = function(contactId)
{
    for(var uiElementIndex = 0; uiElementIndex < this._uiElements.length; uiElementIndex ++)
    {
        this._uiElements[uiElementIndex].getNote(contactId)
    }
}

И, наконец, добавим объявление и контейнер для заметок в index.html:

<!DOCTYPE html>
<html>
    <head>
        <title>Контакты</title>
        <meta charset="utf-8">
        <script type="text/javascript" src="http://code.jquery.com/jquery-1.8.3.min.js"></script>
        <script type="text/javascript" src="contacts.js"></script>
        <script type="text/javascript" src="ui/list.js"></script>
        <script type="text/javascript" src="ui/note.js"></script>
        <script type="text/javascript">
            var contacts = new Contacts

            var uiList = new UI_List('list')
            contacts.addUIElement(uiList)

            var uiNote = new UI_Note
            contacts.addUIElement(uiNote)

            jQuery(function(){
               contacts.getAll()
            })
        </script>
    </head>
    <body>
        <ol id="list">
            <li></li>
        </ol>
        <div id="note"></div>
    </body>
</html>

Если открыть index.html, то по клику на имя из списка контактов будет показываться заметка.

image

Детали по коду. Новый объект UI_Note сделан по аналогии с UI_List. В нём объявлен метод getNote, который у нас вызывается объектом Contacts. У объекта Contacts в свою очередь появился новый метод getNote, который проходит по своим UI элементам и вызывает у каждого одноимённый метод. В index.html ничего по интерфейсу не изменилось:

var contacts = new Contacts

var uiList = new UI_List('list')
contacts.addUIElement(uiList)

var uiNote = new UI_Note
contacts.addUIElement(uiNote)

jQuery(function(){
    contacts.getAll()
})

Список контактов теперь включает в себя вызов метода getNote объекта Contacts по клику на пункт:

<li><a href="#" onclick="contacts.getNote(0);return false;">Петр Конюшин petr@konyushin.com</a></li>
<li><a href="#" onclick="contacts.getNote(1);return false;">Кристина Ложкина krispink@mail.ru</a></li>
<li><a href="#" onclick="contacts.getNote(2);return false;">Арсений Овалов mrbig@hdfgh.com</a></li>

Вернёмся к нашему костылю. Суть проблемы: для каждого нового UI элемента нам необходимо реализовывать методы вызываемые из объекта Contacts. Сейчас таких два метода — getList и getNote, а дальше их будет гораздо больше. Поэтому нам необходим универсальный метод для каждого UI элемента. Назовём его, например, update, он будет принимать имя команды (getList или getNote) первым параметром, а аргументы команды — вторым.

Переписываем UI_List:

UI_List = function(containerId)
{
    this._containerId = containerId
}
UI_List.prototype.update = function(command, args)
{
    switch(command)
    {
        case 'getList':
            jQuery('#'+this._containerId).html(
                '<li><a href="#" onclick="contacts.getNote(0);return false;">Петр Конюшин petr@konyushin.com</a></li>'+
                '<li><a href="#" onclick="contacts.getNote(1);return false;">Кристина Ложкина krispink@mail.ru</a></li>'+
                '<li><a href="#" onclick="contacts.getNote(2);return false;">Арсений Овалов mrbig@hdfgh.com</a></li>')
            break
    }

}

Файл ui/note.js:

UI_Note = function()
{
}
UI_Note.prototype.update = function(command, args)
{
    var notes = ['Отчёт', 'День рождения 5 января', 'Долг мне 500']
    switch(command)
    {
        case 'getNote':
            var contactId = args
            jQuery('#note').html(
                notes[contactId]
            )
            break
    }
}

Файл contacts.js:

Contacts = function()
{
    this._uiElements = []
}
Contacts.prototype.addUIElement = function(uiElement)
{
    this._uiElements.push(uiElement)
}
Contacts.prototype._uiUpdate = function(command, args)
{
    for(var uiElementIndex = 0; uiElementIndex < this._uiElements.length; uiElementIndex ++)
    {
        this._uiElements[uiElementIndex].update(command, args)
    }
}
Contacts.prototype.getAll = function()
{
    this._uiUpdate('getList')
}
Contacts.prototype.getNote = function(contactId)
{
    this._uiUpdate('getNote', contactId)
}

Мы можем уверенно удалить и закоментировать строчку contacts.addUIElement(uiNote) и не беспокоиться, что сайт рухнет, даже если будет вызван метод contacts.getNote. Если его вызовут, не будет UI элементов, которые такую команду обрабатывают и ничего не произойдёт. Ещё одна прелесть такого подхода это логирование.

Логирование

Создадим файл ui/logger.js, добавим в него такой код:

UI_Logger = function()
{
}
UI_Logger.prototype.update = function(command, args)
{
    console.log('UI_Logger: '+command +'('+((typeof args == 'undefined') ? '' : args)+')')
}

Обновим файла index.html:

<head>
    <title>Контакты</title>
    <meta charset="utf-8">
    <script type="text/javascript" src="http://code.jquery.com/jquery-1.8.3.min.js"></script>
    <script type="text/javascript" src="contacts.js"></script>
    <script type="text/javascript" src="ui/list.js"></script>
    <script type="text/javascript" src="ui/note.js"></script>
    <script type="text/javascript" src="ui/logger.js"></script>
    <script type="text/javascript">
        var contacts = new Contacts

        contacts.addUIElement(new UI_Logger)
        contacts.addUIElement(new UI_List('list'))
        contacts.addUIElement(new UI_Note)

        jQuery(function(){
            contacts.getAll()
        })
    </script>
</head>

Теперь, открыв index.html файл, в консоли мы можем видеть лог каждой команды, передаваемой UI элементам из объекта Contacts. Вся логика по логированию собрана в файле UI_Logger, файл contacts.js остался без изменений. В случае чего, логирование можно с лёгкостью перенести в HTML, мы точно знаем, где у нас происходит логирование, и никакие части системы у нас от этого файла не зависят. Чтобы отключить логирование достаточно закомментировать строку contacts.addUIElement(new UI_Logger)

image

Итак, у нас есть три модуля сайта — Logger, List и Note, они независят друг от друга, реализуют общий простейший интерфейс (метод update), и включаются/отключаются одной строчкой contacts.addUIElement(new UI_Xxx). Ещё один плюс — у нас есть место из которого мы можем достучаться до любого модуля — это contacts. Нам не нужно хранить ссылки на UI элементы, они все могут общаться друг с другом посредством команд рассылаемых объектом Contacts. Продемонстрируем это.

Испытание на прочность

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

Наши UI элементы/модули независимы и поэтому держать логику таймера в UI_List некорректно, так как, при отключении UI_Note, это будет бессмысленный код. Поэтому таймер в пять секунд будет в UI_Note, и по его истечении мы дадим сигнал чтобы подчеркнутый контакт в списке перестал подчёркиваться. Сигнал будет дан через contacts.

Итак, код UI_List:

UI_List = function(containerId)
{
    this._containerId = containerId
}
UI_List.prototype.update = function(command, args)
{
    switch(command)
    {
        case 'getList':
            jQuery('#'+this._containerId).html(
                '<li><a href="#" onclick="contacts.getNote(0);return false;">Петр Конюшин petr@konyushin.com</a></li>'+
                '<li><a href="#" onclick="contacts.getNote(1);return false;">Кристина Ложкина krispink@mail.ru</a></li>'+
                '<li><a href="#" onclick="contacts.getNote(2);return false;">Арсений Овалов mrbig@hdfgh.com</a></li>')
            break
        case 'getNote':
            var contactId = args
            jQuery('#'+this._containerId).children().css('font-weight','normal')
            jQuery('#'+this._containerId).children().eq(contactId).css('font-weight','bold')
            break
        case 'clearSelection':
            jQuery('#'+this._containerId).children().css('font-weight','normal')
            break

    }
}

Код UI_Note:

UI_Note = function()
{
    this._timeOut = null
    this._visibleForHowManySeconds = 5
}
UI_Note.prototype.update = function(command, args)
{
    var notes = ['Отчёт', 'День рождения 5 января', 'Долг мне 500']
    var self = this
    switch(command)
    {
        case 'getNote':
            clearTimeout(this._timeOut)
            var contactId = args
            jQuery('#note').html(
                notes[contactId]
            )
            this._timeOut = setTimeout(
                function(){
                    contacts.clearSelection()
                },
                this._visibleForHowManySeconds * 1000
            )
            break
        case 'clearSelection':
            jQuery('#note').html('')
    }
}

Код, добавленный в contacts.js:

Contacts.prototype.clearSelection = function()
{
    this._uiUpdate('clearSelection')
}

Если теперь открыть index.html, то можно увидеть, что всё работает по плану.

Заключение

Построенная система состоит из независимых друг от друга модулей. Соответственно, её легко отлаживать. Расширение функционала не требует добавления нового кода в логику уже готовых UI элементов, например, реализация просмотра фотографии контакта не затронет ни UI_List, ни UI_Note. И, наконец, управление UI элементами централизированно в одном объекте с простым удобным интерфейсом.

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

В коде используется шаблон Наблюдатель (Observer), Contacts – observable, а UI элемент — observer. Подобное реализовано в jQuery методом trigger, и описать предложенный подход в среде jQuery — тема отдельной статьи. Преимущество описанных в статье объектов в меньшем количестве формальностей, и в возможности легко создавать новые observers, которые не оперируют html кодом (например, валидатор форм).

Источники и полезные ссылки:

Автор: VladimirFeskov

Источник

Поделиться

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