Javascript.RU

И опять про MVC

Я нашел в сети очень мало реализаций MVC для JavaScript, поэтому хотел бы раскрыть преимущества данного подхода именно в реализации JavaScript.

Этот пост является противоположной точкой зрения на статью Прочь от MVC

Немного истории: MVC (Model View Controller) описан в 1979 году, еще для языка SmallTalk.
С тех пор ничего нового в интерфейсах так и не изобрели. Шаблон улучшался, дополнялся и расширялся. Появлялись последователи в зависимости от возможностей платформы и языка на котором пишется приложение, такие как HMVC (Hierarhical Model View Controller), MVP (Model View Presenter), MVVM (Model View ViewModel), но суть осталась прежняя - отделение источника данных от изменящих и командующих функций и их слабое связывание.
Заранее скажу что с самого появления шаблона в 1979 году первый отзыв был примерно такой: "Зачем это нужно? И так всё работает хорошо.", второй отзыв: "Ба! Да тут же данные дуплицируются в Виде и в Модели. Всё будет тормозить! Зачем это нужно?" и, наконец, третий :"При каждом изменении модели вид обновляется. Всё будет тормозить. Зачем это нужно?!". Поэтому не удивляйтесь что при прочтенни данной статьи у вас возникнут похожие вопросы. Несмотря на всё, MVC и его последователи очень стал очень удачным и проверенным временем решением не для всех, но для большого круга задач. Надеюсь вы тоже в этом убедитесь.

Задача: создать простейшую интерактивную страничку - список значений, и две кнопки - для добавления и удаления значений. Традиционный подход довольно простой - создаём html файл. Для списка используется select, для манипуляций - button. В атрибуте onClick у кнопок прописываем вызов JavaScript функции. Для удаления значения из select обращаемся к DOM объекту select, спрашиваем свойство selectedIndex и удаляем соответствующий объект .

<html><head><script>
// <![CDATA[
function addItem (value) {
    if (!value) {
        return;
    }
    var myselect = document.getElementById('myselect');
    var newoption = myselect.appendChild(document.createElement('option'));
    newoption.value = value;
    newoption.innerHTML=value;  
}

function removeCurrentItem () {
    var myselect = document.getElementById('myselect');
    if (myselect.selectedIndex === -1) {
        return;
    }
    var selectedOption = myselect.options[myselect.selectedIndex];
    selectedOption.parentNode.removeChild(selectedOption);
}

// ]]>
</script>
</head>
<body>
    <select id="myselect" size="4"></select>
    <button onClick="addItem(prompt('enter value'))">+</button>
    <button onClick="removeCurrentItem()" />-</button>
</body>
</html>

Всё просто и отлично работает. Зачем же нужно что-то еще? Для данного приложения проблем не ожидается, однако, по статистике, собственно, написание кода занимает около 25% времени, а остальные 75% программист занимается развитием, поддержкой проекта, добавление новых функций. И по мере усложнения приложения и увеличения количества поддерживать код становится всё сложнее и сложнее, количество багов увеличивается.

"Я сделаю через Jquery/Dojo/MooTools/Моя_любимая_аякс_библиотека" скажете вы. Например так:

<html><head>
<script src="jquery.js"></script>
<script>
// <![CDATA[
$(document).ready(function(){
    $('#plus').bind('click', function(){
        var value = prompt('add value');
        if (!value) {
            return;
        }
        $('<option>').html(value).appendTo($('#myselect'));
    });
    $('#minus').bind('click', function(){
        var myselect = $('#myselect');
        if (myselect.attr('selectedIndex') === -1) { 
            return;
        }
        myselect.children().remove(':selected');
    });
});
// ]]>
</script>
</head>
<body>
    <select id="myselect" size="4"></select>
    <button id="plus">+</button>
    <button id="minus"/>-</button>
</body>
</html>

Стало еще лучше, код стал более компактный и он выглядит очень круто. Однако это не решило основную проблему: данные (список) хранится внутри пользовательского интерфейса, внутри html элемента select. Второй недостаток - у нас мешанина из html и javascript кода. Несмотря на простоту, он плохо развивается. К примеру к нам подходит начальник и говорит простейшую просьбу: "хочу чтобы кнопка 'минус' появлялась только когда что-то выделено в списке.". Сразу у вас, как у программиста, возникает вопрос: где делать это изменение - в html или в JavaScript. "Хм, пожалуй выключу кнопку в html" - скажете вы. А ваш коллега, пока вы в отпуске, сделает похожую операцию внутри JavaScript.
Получается что, казалось бы, простое изменение начинает очень сильно запутывать код, этого хотелось бы избежать.

В классической реализации MVC основные принципы такие:

  1. Слабое связывание
  2. Модель ничего не знает ни о ком. Модель рассылает оповещения, которые может слушать, например, Вид, если хочет.
  3. Вид знает о модели но не может её менять, вид может манипулировать контроллером
  4. Контроллер знает о Модели и может её менять, а также знает о Виде (или Видах) и может его (их) менять.

В данном случае отличия следующие:

  1. Модель ничего не знает ни о ком. Может рассылать оповещения
  2. Вид знает только о модели
  3. Контроллер знает и о Виде и о Модели

Как видим, в отличие от классического шаблона, данный шаблон более жёсткий, т.к. вид ничего не знает о контроллере, мне не нравится идея перекрестной линковки объектов (Вида и Контроллера), и пока можно я попытаюсь без такой линковки обойтись. Назовем его, к примеру, Ортодоксальный MVC (Orthodox MVC)

Модель в нашем случае хранит просто массив объектов (строк) которые мы ввели. Также хранится порядковый номер "текущего" объекта. Также здесь есть стандартные методы добавления и удаления объектов: addItem, removeCurrentItem, без них не обойтись, т.к. модели необходимо рассылать оповещения о своём изменении.
Для рассылки оповещения используется объект типа сабджект (modelChangedSubject), можно сделать несколько таких объектов и рассылать подписчикам не просто извещения "модель изменилась" а "свойство X у модели изменилось". Это поможет Виду обновляться не целиком а только тем участкам которые реально изменились в модели.
Я не нашёл в jquery стандартную функцию создания сабджектов, поэтому взял готовую makeObservableSubject из отличной статьи по mvc одного Канадского программиста.

Как видим, у нашего html тело (body) "пустое", все элементы рисуются "вручную" т.е. с помощью javascript. Посмотрите Gmail - там то же самое. Все элементы: кнопочки, списки рисуются яваскриптом.
Вид знает о модели, но в идеале Вид должен иметь доступ к модели в режиме read-only, т.е. нельзя разрешить виду вызывать removeCurrentItem даже если это очень хочется.
К сожалению я пока не придумал как можно в JavaScript одним функциям разрешить изменения другого объекта, а другим функциям оставить только доступ в режиме чтения. Если у вас есть идеи как этого добиться, поделитесь пожалуйста.

В данном контроллере мы просто вешаем эвенты на объекты вида, связывая модель и вид.

Полный текст страницы:

<!doctype html>
<html>
  <body>
    <script src="jquery.js"></script>
    <script>
// <![CDATA[
var OMVC = {};
OMVC.makeObservableSubject = function () {
    var observers = [];
    var addObserver = function (o) {
        if (typeof o !== 'function') {
            throw new Error('observer must be a function');
        }
        for (var i = 0, ilen = observers.length; i < ilen; i += 1) {
            var observer = observers[i];
            if (observer === o) {
                throw new Error('observer already in the list');
            }
        }
        observers.push(o);
    };
    var removeObserver = function (o) {
        for (var i = 0, ilen = observers.length; i < ilen; i += 1) {
            var observer = observers[i];
            if (observer === o) {
                observers.splice(i, 1);
                return;
            }
        }
        throw new Error('could not find observer in list of observers');
    };
    var notifyObservers = function (data) {
        // Make a copy of observer list in case the list
        // is mutated during the notifications.
        var observersSnapshot = observers.slice(0);
        for (var i = 0, ilen = observersSnapshot.length; i < ilen; i += 1) {
            observersSnapshot[i](data);
        }
    };
    return {
        addObserver: addObserver,
        removeObserver: removeObserver,
        notifyObservers: notifyObservers,
        notify: notifyObservers
    };
};


OMVC.Model = function () {
    var that = this;
    var items = [];
    this.modelChangedSubject = OMVC.makeObservableSubject();
    this.addItem = function (value) {
        if (!value) {
            return;
        }
        items.push(value);
        that.modelChangedSubject.notifyObservers();
    };
    this.removeCurrentItem = function () {
        if (that.selectedIndex === -1) {
             return;
        }
        items.splice(that.selectedIndex, 1);
        that.modelChangedSubject.notifyObservers();
    };
    this.getItems = function () {
        return items;
    };
    this.selectedIndex = -1;
    this.getSelectedIndex = function () {
        return that.selectedIndex;
    }
    this.setSelectedIndex = function (value) {
        that.selectedIndex = value;
    }
};

OMVC.View = function (model, rootObject) {
    var that = this;
    that.select = $('<select/>').appendTo(rootObject);
    that.select.attr('size', '4');
    that.buttonAdd = $('<button>+</button>').appendTo(rootObject).height(20);
    that.buttonRemove = $('<button>-</button>').appendTo(rootObject).height(20);
    model.modelChangedSubject.addObserver(function () {
        var items = model.getItems();
        var innerHTML = '';
        for (var i = 0; i<items.length; i += 1) {
             innerHTML += "<option>"+items[i]+"</option>";
        }
        that.select.html(innerHTML);
    });
};

OMVC.Controller = function (model, view) {
    view.buttonAdd.bind('click', function () {
        model.addItem(prompt('addvalue'));
    });
    view.buttonRemove.bind('click', function () {
        model.removeCurrentItem();
    });
    view.select.bind('click', function () {
       model.setSelectedIndex(view.select[0].selectedIndex);
    });
};        

$(document).ready(function () {
    var model = new OMVC.Model();
    var view = new OMVC.View(model, $('<div/>').appendTo($("body")));
    var controller = new OMVC.Controller(model, view);
});
// ]]>
    </script>
  </body>
</html>

Напоследок, вопрос: какие же преимущества нам даёт реализация в виде MVC перед первой, стандартной или улучшенной jQuery-евской? Мы написали в три раза больше кода и ради чего?
Ответ: Даже на таком простом примере, потихоньку начинают становиться заметными преимущества шаблона MVC:

  1. Нет нужды присваивать элементам интерфейса id, чтобы получать их потом по document.getElementById и им подобным функциям, ведь ссылки на все элементы уже хранятся в Виде, их искать по html документу не нужно.
  2. Все объекты рисуются внутри элемента rootObject, Вид можно легко использовать много раз для любого rootObject.
  3. В html body ничего не хранится, т.е. конечно что-то там есть, но нас это совершенно не интересует - раньше изменения нам нужно было искать и делать в двух местах: в html и в функциях JavaScript. Теперь всё делается только в JavaScript и более того, дизайн хранится только в объекте Вид.
  4. Изменения очень легки. К примеру, упомянутая выше просьба "начальника" выполняется следующим образом:
    • создаём сабджект по изменению selectedIndex
      this.selectedIndexChangedSubject = OMVC.makeObservableSubject();
    • при изменении оповещаем that.selectedIndexChangedSubject.notifyObservers();
    • и в Виде подписываемся на оповещенеие selectedIndexChangedSubject

Полная, финальная версия с добавлением фичи с подсветкой:

<!doctype html>
<html>
  <body>
    <script src="jquery.js"></script>
    <script>
// <![CDATA[
var OMVC = {};
OMVC.makeObservableSubject = function () {
    var observers = [];   
    var addObserver = function (o) {
        if (typeof o !== 'function') {
            throw new Error('observer must be a function');
        }
        for (var i = 0, ilen = observers.length; i < ilen; i += 1) {
            var observer = observers[i];
            if (observer === o) {
                throw new Error('observer already in the list');
            }
        }
        observers.push(o);
    };    
    var removeObserver = function (o) {
        for (var i = 0, ilen = observers.length; i < ilen; i += 1) {
            var observer = observers[i];
            if (observer === o) {
                observers.splice(i, 1);
                return;
            }
        }
        throw new Error('could not find observer in list of observers');
    };    
    var notifyObservers = function (data) {
        // Make a copy of observer list in case the list
        // is mutated during the notifications.
        var observersSnapshot = observers.slice(0);
        for (var i = 0, ilen = observersSnapshot.length; i < ilen; i += 1) {
            observersSnapshot[i](data);
        }
    };    
    return {
        addObserver: addObserver,
        removeObserver: removeObserver,
        notifyObservers: notifyObservers,
        notify: notifyObservers
    };
};


OMVC.Model = function () {
    var that = this;
    var items = [];
    this.modelChangedSubject = OMVC.makeObservableSubject();
    this.addItem = function (value) {
        if (!value) {
            return;
        }
        items.push(value);
        that.modelChangedSubject.notifyObservers();
    };
    this.removeCurrentItem = function () {
        if (that.selectedIndex === -1) {
             return;
        }
        items.splice(that.selectedIndex, 1);
        if (items.length === 0) {
             that.setSelectedIndex(-1);
        }
        that.modelChangedSubject.notifyObservers();
    };
    this.getItems = function () {
        return items;
    };
    this.selectedIndex = -1;
    this.getSelectedIndex = function () {
        return that.selectedIndex;
    }
    this.selectedIndexChangedSubject = OMVC.makeObservableSubject();
    this.setSelectedIndex = function (value) {
        that.selectedIndex = value;
        that.selectedIndexChangedSubject.notifyObservers();
    }
};

OMVC.View = function (model, rootObject) {
    var that = this;
    that.select = $('<select/>').appendTo(rootObject);
    that.select.attr('size', '4');
    that.buttonAdd = $('<button>+</button>').appendTo(rootObject).height(20);
    that.buttonRemove = $('<button>-</button>').appendTo(rootObject).height(20).fadeOut();
    model.modelChangedSubject.addObserver(function () {
        var items = model.getItems();
        var innerHTML = '';
        for (var i = 0; i<items.length; i += 1) {
             innerHTML += "<option>"+items[i]+"</option>";
        }
        that.select.html(innerHTML);
    });
    model.selectedIndexChangedSubject.addObserver(function () {
        if(model.getSelectedIndex() === -1) {
            that.buttonRemove.fadeOut();
        } else {
            that.buttonRemove.fadeIn();
        }
    });
};

OMVC.Controller = function (model, view) {
    view.buttonAdd.bind('click', function () {
        model.addItem(prompt('addvalue'));
    });
    view.buttonRemove.bind('click', function () {
        model.removeCurrentItem();
    });
    view.select.bind('click', function () {
       model.setSelectedIndex(view.select[0].selectedIndex);
    });
};        

$(document).ready(function () {
    var model = new OMVC.Model();
    var view = new OMVC.View(model, $('<div/>').appendTo($("body")));
    var controller = new OMVC.Controller(model, view);
});
// ]]>
    </script>
  </body>
</html>

Раньше у нас была интерактивная html страничка - а теперь стало приложение на языке javascript с графической оболочкой на HTML. Почувствуйте разницу.

P.S.: Напоминаю, что использование MVC больше подходит к бизнес приложениям, интерфейсам типа админки или что-то подобное Gmail/Google docs. Если у вас промо-сайт с тысячей баннеров и летающими снежинками по экрану, использование MVC бессмысленно и даже вредно.

+6

Автор: hogart, дата: 16 мая, 2011 - 12:01
#permalink

Да какая разница, мвц, цвм…
Главное — уменьшить связанность. MVC в частности и n-звенная архитектура вообще — это только один из способов добиться меньшей связанности.

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


Автор: super_be, дата: 8 августа, 2011 - 08:00
#permalink

Полезная хрень. Я бы еще воспользовался бы шаблонное библиотекой из комплекта ajax от мелкомягких.


Отправить комментарий

Приветствуются комментарии:
  • Полезные.
  • Дополняющие прочитанное.
  • Вопросы по прочитанному. Именно по прочитанному, чтобы ответ на него помог другим разобраться в предмете статьи. Другие вопросы могут быть удалены.
    Для остальных вопросов и обсуждений есть форум.
P.S. Лучшее "спасибо" - не комментарий, как все здорово, а рекомендация или ссылка на статью.
Содержание этого поля является приватным и не предназначено к показу.
  • Адреса страниц и электронной почты автоматически преобразуются в ссылки.
  • Разрешены HTML-таги: <strike> <a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd> <u> <i> <b> <pre> <img> <abbr> <blockquote> <h1> <h2> <h3> <h4> <h5> <p> <div> <span> <sub> <sup>
  • Строки и параграфы переносятся автоматически.
  • Текстовые смайлы будут заменены на графические.

Подробнее о форматировании

CAPTCHA
Антиспам
1 + 9 =
Введите результат. Например, для 1+3, введите 4.
 
Поиск по сайту
Другие записи этого автора
Больше записей нет. Прокомментируйте эту запись - может быть, тогда он что-нибудь еще хорошее напишет ;)
Содержание

Учебник javascript

Основные элементы языка

Сундучок с инструментами

Интерфейсы

Все об AJAX

Оптимизация

Разное

Дерево всех статей

Популярные таги
Последние комментарии
Последние темы на форуме
Forum