/ JavaScript

Model-View-Controller (MVC) на JavaScript

Комментарий: этот пост был опубликован в 2006 году (страшно подумать, 11 лет назад!). Я недавно перевел сайт на Ghost и удалил его. Как оказалось, пост довольно популярен - почти в этот же день меня спросили, куда же он делся? Так что я добавляю его на новый блог и заодно перевожу на русский, т.к. на нём и попросили.

View this post and comments in: English.

JavaScript мне нравится его гибкостью и возможностью комбинировать разные стили и подходы при написании программ. Но в этой его сильной стороне кроется опасность - без внимательного отношения к структуре поддержка проекта на JavaScript может стать весьма затратной.

В этой статье я покажу, как правильно применять шаблон проектирования Модель-Представление-Контроллер (Model-View-Controller, или, сокращенно, MVC) на примере небольшого компонента, напоминающего ListBox (HTML тэг "select"), с возможностью добавления и удаления элементов.

Надеюсь, эта статья будет интересна сама по себе, но будет лучше, если вы попробуете запустить пример и поиграться с ним.

Для начала давайте посмотрим, что такое шаблон проектирования Model-View-Controller. Его имя включает в себя три главных его части: Model - хранит данные приложения; View - представляет модель клиенту; Controller - модифицирует модель, реагируюя на события от клиента. На Википедии его части определены следующим образом:

  • Модель (Model) предоставляет данные и реагирует на команды контроллера, изменяя своё состояние.
  • Представление (View) отвечает за отображение данных модели пользователю, реагируя на изменения модели.
  • Контроллер (Controller) интерпретирует действия пользователя, оповещая модель о необходимости изменений.

Давайте просто сделаем так, как написано: сконструируем компонент так, что бы его три класса соответствовали описанию.

Данные компонента - это список строк, одна из которых может быть выбрана и удалена. Сконструируем модель из массива и индекса выбранного элемента:

/**
 * The Model. Model stores items and notifies
 * observers about changes.
 */
function ListModel(items) {
    this._items = items;
    this._selectedIndex = -1;

    this.itemAdded = new Event(this);
    this.itemRemoved = new Event(this);
    this.selectedIndexChanged = new Event(this);
}

ListModel.prototype = {
    getItems : function () {
        return [].concat(this._items);
    },

    addItem : function (item) {
        this._items.push(item);
        this.itemAdded.notify({ item : item });
    },

    removeItemAt : function (index) {
        var item;

        item = this._items[index];
        this._items.splice(index, 1);
        this.itemRemoved.notify({ item : item });
        if (index === this._selectedIndex) {
            this.setSelectedIndex(-1);
        }
    },

    getSelectedIndex : function () {
        return this._selectedIndex;
    },

    setSelectedIndex : function (index) {
        var previousIndex;

        previousIndex = this._selectedIndex;
        this._selectedIndex = index;
        this.selectedIndexChanged.notify({ previous : previousIndex });
    }
};

Event - это простой класс, реализующий шаблон Наблюдатель (Observer pattern):

function Event(sender) {
    this._sender = sender;
    this._listeners = [];
}

Event.prototype = {
    attach : function (listener) {
        this._listeners.push(listener);
    },
    notify : function (args) {
        var index;

        for (index = 0; index < this._listeners.length; index += 1) {
            this._listeners[index](this._sender, args);
        }
    }
};

Перед тем как создать класс Представления надо определиться с тем, из каких элементов он будет состоять. Конечно, есть огромное количество реализации интерфейса компонента, но для целей статьи подойдет самый простой: сделать список на основе тэга SELECT и добавить две кнопки для добавления (кнопка "плюс") и удаления элементов (кнопка "минус"). Для компонента нужна реализацию списка с выбранным элементом и у контрола SELECT это как раз есть. Класс Представление связан с классом Контроллера, т.к. последний "... интерпретирует действия пользователя, ...".

Собственно, реализация классов View и Controller:

/**
 * The View. View presents the model and provides
 * the UI events. The controller is attached to these
 * events to handle the user interaction.
 */
function ListView(model, elements) {
    this._model = model;
    this._elements = elements;

    this.listModified = new Event(this);
    this.addButtonClicked = new Event(this);
    this.delButtonClicked = new Event(this);

    var _this = this;

    // attach model listeners
    this._model.itemAdded.attach(function () {
        _this.rebuildList();
    });
    this._model.itemRemoved.attach(function () {
        _this.rebuildList();
    });

    // attach listeners to HTML controls
    this._elements.list.change(function (e) {
        _this.listModified.notify({ index : e.target.selectedIndex });
    });
    this._elements.addButton.click(function () {
        _this.addButtonClicked.notify();
    });
    this._elements.delButton.click(function () {
        _this.delButtonClicked.notify();
    });
}

ListView.prototype = {
    show : function () {
        this.rebuildList();
    },

    rebuildList : function () {
        var list, items, key;

        list = this._elements.list;
        list.html('');

        items = this._model.getItems();
        for (key in items) {
            if (items.hasOwnProperty(key)) {
                list.append($('<option>' + items[key] + '</option>'));
            }
        }
        this._model.setSelectedIndex(-1);
    }
};

/**
 * The Controller. Controller responds to user actions and
 * invokes changes on the model.
 */
function ListController(model, view) {
    this._model = model;
    this._view = view;

    var _this = this;

    this._view.listModified.attach(function (sender, args) {
        _this.updateSelected(args.index);
    });

    this._view.addButtonClicked.attach(function () {
        _this.addItem();
    });

    this._view.delButtonClicked.attach(function () {
        _this.delItem();
    });
}

ListController.prototype = {
    addItem : function () {
        var item = window.prompt('Add item:', '');
        if (item) {
            this._model.addItem(item);
        }
    },

    delItem : function () {
        var index;

        index = this._model.getSelectedIndex();
        if (index !== -1) {
            this._model.removeItemAt(this._model.getSelectedIndex());
        }
    },

    updateSelected : function (index) {
        this._model.setSelectedIndex(index);
    }
};

Ну и как они вместе создаются на странице:

$(function () {
    var model = new ListModel(['PHP', 'JavaScript']),
        view = new ListView(model, {
            'list' : $('#list'), 
            'addButton' : $('#plusBtn'), 
            'delButton' : $('#minusBtn')
        }),
        controller = new ListController(model, view);
    
    view.show();
});​
<select id="list" size="10"></select>
<button id="plusBtn">  +  </button>
<button id="minusBtn">  -  </button>

Можете протестировать его работу и попробовать добавить различную функциональность (например, сделать поддержку нескольких выбранных элементов).

Если знаете UML, то диаграмма классов компонента может вас заинтересовать:

Alex Netkachov

Alex Netkachov

Alex works at Central London on the next generation of energy trading solutions to traders, brokers and exchanges worldwide.

Read More

Why not to stay updated if the subject is interesting? Join Telegram channel Alex@Net or follow alex_at_net on Twitter. Or just, use the comments form below.