SOLID принципы в JavaScript. Принцип единственной ответственности
Принципы хорошего кода

SOLID принципы в JavaScript. Принцип единственной ответственности

SOLID — это аббревиатура для пяти принципов объектно-ориентированного дизайна, описанных Робертом Мартином в книге Agile Software Development: Principles, Patterns and Practices. Эти принципы:

  • Принцип единственной ответственности (Single responsibility)
  • Принцип открытости-закрытости (Open-closed)
  • Принцип подстановки Барбары Лисков (Liskov substitution)
  • Принцип разделения интерфейса (Interface segregation)
  • Принцип инверсии зависимостей (Dependency inversion)

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

Принцип единственной ответственности

Принцип единственной ответственности звучит так:

«Существует лишь одна причина, приводящая к изменению класса».

Само название принципа может немного вводить в заблуждение — из него можно сделать вывод, что объект должен делать что-то одно. Однако это не так. Объект должен иметь связанный (cohesive) набор поведения, относящийся к одной ответственности. Изменение этой ответственности должно приводить к изменению объекта.

Когда объект имеет несколько ответственностей, его изменение, связанное с одной из ответственностей, может побочно изменить логику, связанную с другой ответственностью. Разделение ответственностей как раз и делает код более устойчивым к изменениям.

Но как определить, является ли данный набор поведения единственной ответственностью? Как разделить связанные ответственности?

Стереотипные роли объектов

Один из подходов, помогающих в разделении спаренных ответственностей, основан на стереотипах ролей объекта. Стереотипные роли объектов — это набор общих ролей, которые обычно встречаются в объектно-ориентированной архитектуре.

Концепция стереотипных ролей обсуждалась в книге Ребекки Вирфс-Брок и Алана МакКина «Object Design: Roles, Responsibilies, and Collaborations». В книге представлены следующие стереотипы:

Information holder — объект, содержащий какую-либо информацию и предоставляющий информацию другим объектам.

Structurer — объект, который поддерживает отношения между объектами и хранит информацию об их отношениях.

Service provider — объект, выполняющий специфичную функцию и предоставляющий ее другим объектам.

Controller — объект, контролирующий выполнение задачи, принимающий решения.

Coordinator — объект, который не принимает решений, но делегирует работу другим объектам.

Interfacer — объект, преобразующий информацию или запросы между другими объектами.

Такое разделение помогает увидеть, что объект выполняет несколько ролей, а значит имеет более одной ответственности.

Пример разделения ответственностей в JavaScript

Для понимания применения принципа разделения ответственностей в JavaScript, рассмотрим пример, иллюстрирующий отображение списка продуктов с возможностью добавить товар в корзину:


// Модель Продукт
function Product(id, description) {
    this.getId = function() {
        return id;
    };
    this.getDescription = function() {
        return description;
    };	
}

// Модель Корзина
function Cart() {
    var items = [];

    this.addItem = function(item) {
        items.push(item);
    };
 
    this.getItems = function(item) {
        return items;
    };
}

(function() {
    var products = [
        new Product(1, 'MacBook Air'),
        new Product(2, 'iPhone 5s'),
        new Product(3, 'iPad mini')
    ],
    cart = new Cart();

    /**
     * Функция добавления товара в корзину
     */
    function addToCart() {
        var productId = $(this).attr('id');
        var product = $.grep(products, function(x) {
            return x.getId() == productId;
        })[0];
        cart.addItem(product);

        var newItem = $('<li></li>')
           .html(product.getDescription())
           .attr('id-cart', product.getId())
           .appendTo('#cart');
   }

   products.forEach(function(product) {
       $('<li></li>')
           .html(product.getDescription())
           .attr('id', product.getId())
           .dblclick(addToCart)
           .appendTo('#products');
   });
})();

Казалось бы, несложный и логичный пример: есть «класс» корзины, продукта. В анонимной функции мы инициализировали некий массив продуктов и объект корзины, объявили функцию добавления продукта в корзину addToCart, вывели список продуктов.

Однако данный пример как раз наглядно иллюстрирует нарушение принципа единственной ответственности:

Во-первых, по двойному щелчку на названии продукта мы добавляем его в массив items модели корзины.

Во-вторых, по двойному щелчку мы также добавляем продукт в предсталение корзины, добавляем новый элемент в список #cart.

В-третьих, у нас есть код, выводящий список продуктов.

Давайте попробуем разделить эти три ответственности:


function Event(name) {
    this._handlers = [];
    this.name = name;
}

// Функция добавления обработчика события
Event.prototype.addHandler = function(handler) {
    this._handlers.push(handler);
};

// Функция удаления обработчика события
Event.prototype.removeHandler = function(handler) {
    for (var i = 0; i < handlers.length; i++) {
        if (this._handlers[i] == handler) {
            this._handlers.splice(i, 1);
            break;
        }
    }
};

// Срабатывание события (выполнение всех связанных обработчиков)
Event.prototype.fire = function(eventArgs) {
    this._handlers.forEach(function(h) {
        h(eventArgs);
    });
};

var eventAggregator = (function() {
    var events = [];

    function getEvent(eventName) {
        return $.grep(events, function(event) {
            return event.name === eventName;
        })[0];
    }

    return {
        publish: function(eventName, eventArgs) {
            var event = getEvent(eventName);

            if (!event) {
                event = new Event(eventName);
                events.push(event);
            }
            event.fire(eventArgs);
        },

        subscribe: function(eventName, handler) {
            var event = getEvent(eventName);

            if (!event) {
                event = new Event(eventName);
                events.push(event);
            }

            event.addHandler(handler);
        }
    };
})();

/**
 * Модуль корзины
 */
function Cart() {
    var items = [];

    this.addItem = function(item) {
        items.push(item);

        /**
         * после добавления элемента в корзину публикуем событие   
         * itemAdded
         */
        eventAggregator.publish("itemAdded", item);
    };
}

/**
 * Представление корзины
 */
var cartView = (function() {
    /**
     * представление корзины подписано на событие itemAdded
     * и при наступлении этого события - отображает новый элемент
     */
    eventAggregator.subscribe('itemAdded', function(eventArgs) {
        var newItem = $('<li></li>')
            .html(eventArgs.getDescription())
            .attr('id-cart', eventArgs.getId())
            .appendTo('#cart');
    });
})();

/**
 * Контроллер корзины. Контроллер подписан на событие productSelected  
 * и добавляет выбранный продукт в корзину
 */
var cartController = (function(cart) {
    eventAggregator.subscribe('productSelected', function(eventArgs) {
        cart.addItem(eventArgs.product);
    });
})(new Cart());

/**
 * Модель Продукта
 */
function Product(id, description) {
    this.getId = function() {
        return id;
    };
    this.getDescription = function() {
        return description;
    };
}

var products = [
    new Product(1, 'MacBook Air'),
    new Product(2, 'iPhone 5s'),
    new Product(3, 'iPad mini')
];

/**
 * Представление продукта
 */
var productView = (function() {
    function onProductSelected() {
        var productId = $(this).attr('id');
        var product = $.grep(products, function(x) {
            return x.getId() == productId;
        })[0];
        eventAggregator.publish('productSelected', {
            product: product
        });
    }

    products.forEach(function(product) {
        var newItem = $('<li></li>')
            .html(product.getDescription())
            .attr('id', product.getId())
            .dblclick(onProductSelected)
            .appendTo('#products');
    });
})();

В переделанном варианте мы вообще избавились от анонимной функции и вынесли ее функционал в соответствующие объекты. Представление корзины cartView отвечает за отображение элементов, добавленных в корзину, контроллер cartController — за добавление элемента в корзину, productView — отображает список продуктов.

Мы также описали некий аггрегатор событий, который обеспечивается связи между объектами, делая саму систему слабо связанной. И хотя объектов стало больше — каждый объект теперь играет определенную роль и выполняет определенную функцию. Например, изменение внешнего отображения элементов корзины не будет затрагивать логику добавления продуктов в корзину.

Заключение

В статье мы показали, что SOLID-принципы объектно-ориентированного проектирования могут применяться не только в классических объектно-ориентированных языках, но и в JavaScript.

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

Рассылка
Подпишитесь на рассылку и получайте дайджест новостей и статей.
Никакого спама!
Подписаться