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

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

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

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

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

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

Но сначала надо выбрать метод сообщения о событиях. В JavaScript нет специального элемента языка или рекомендованного метода для реализации шаблона Publish-Subscribe. Все реализации просто используют передачу функций. Наиболее известная реализация, это EventEmitter. Так, например, реализованы события в node.js. Главные методы этой реализации - "on" для добавления обработчика события и "emit" для вызова обработчиков некоторого события. Для реализации событий в некотором классе достаточно наследоваться от EventEmitter или добавить методы, пробрасывающие вызовы к внутреннему экземпляру EventEmitter.

class EventEmitter {
  constructor() {
    this._events = {};
  }
  on(evt, listener) {
    (this._events[evt] || (this._events[evt] = [])).push(listener);
    return this;
  }
  emit(evt, arg) {
    (this._events[evt] || []).slice().forEach(lsn => lsn(arg));
  }
}

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

/**
 * The Model. Model stores items and notifies
 * observers about changes.
 */
class ListModel extends EventEmitter {
  constructor(items) {
    super();
    this._items = items || [];
    this._selectedIndex = -1;
  }

  getItems() {
    return this._items.slice();
  }

  addItem(item) {
    this._items.push(item);
    this.emit('itemAdded', item);
  }

  removeItemAt(index) {
    const item = this._items.splice(index, 1)[0];
    this.emit('itemRemoved', item);
    if (index === this._selectedIndex) {
      this.selectedIndex = -1;
    }
  }

  get selectedIndex () {
    return this._selectedIndex;
  }

  set selectedIndex(index) {
    const previousIndex = this._selectedIndex;
    this._selectedIndex = index;
    this.emit('selectedIndexChanged', previousIndex);
  }
}

Перед тем как создать класс Представления надо определиться с тем, из каких элементов он будет состоять. Конечно, есть огромное количество реализации интерфейса компонента, но для целей статьи подойдет самый простой: сделать список на основе тэга 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.
 */
class ListView extends EventEmitter {
  constructor(model, elements) {
    super();
    this._model = model;
    this._elements = elements;

    // attach model listeners
    model.on('itemAdded', () => this.rebuildList())
      .on('itemRemoved', () => this.rebuildList());

    // attach listeners to HTML controls
    elements.list.addEventListener('change',
      e => this.emit('listModified', e.target.selectedIndex));
    elements.addButton.addEventListener('click',
      () => this.emit('addButtonClicked'));
    elements.delButton.addEventListener('click',
      () => this.emit('delButtonClicked'));
  }

  show() {
    this.rebuildList();
  }

  rebuildList() {
    const list = this._elements.list;
    list.options.length = 0;
    this._model.getItems().forEach(
      item => list.options.add(new Option(item)));
    this._model.selectedIndex = -1;
  }
}

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

    view.on('listModified', idx => this.updateSelected(idx));
    view.on('addButtonClicked', () => this.addItem());
    view.on('delButtonClicked', () => this.delItem());
  }

  addItem() {
    const item = window.prompt('Add item:', '');
    if (item) {
      this._model.addItem(item);
    }
  }

  delItem() {
    const index = this._model.selectedIndex;
    if (index !== -1) {
      this._model.removeItemAt(index);
    }
  }

  updateSelected(index) {
    this._model.selectedIndex = index;
  }
}

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

Your favourite JavaScript technologies:<br>
<select id="list" size="10" style="width: 17em"></select><br>
<button id="plusBtn">  +  </button>
<button id="minusBtn">  -  </button>
window.addEventListener('load', () => {
  const model = new ListModel(['node.js', 'react']),
    view = new ListView(model, {
      'list' : document.getElementById('list'),
      'addButton' : document.getElementById('plusBtn'), 
      'delButton' : document.getElementById('minusBtn')
    }),
    controller = new ListController(model, view);

  view.show();
});
Your favourite JavaScript technologies: