Pull to refresh

Пишем реализацию MVC для Backbone

Reading time 10 min
Views 11K
image

Одним пасмурным утром я подумал, что было бы неплохо хорошенько прорефакторить один из моих старых проектов. Это некоммерческое легковесное приложение для кастомизации HUD в одном 3Д шутере. Писал я его 2 года назад, был горяч и неопытен. В результате куча отменного спагетти-кода, который, несмотря на все свои недостатки, делал своё дело. Став мудрее и опытнее, я решил полностью переписать приложение, дать ему новую архитектуру, упростить поддержку и обновление. Как это сделать? Ответ казался простым — использовать MVC, разделить на уровни и связать всё в единое целое. Так я столкнулся с проблемой выбора простого и эффективного фреймворка, который станет прочным фундаментом. После быстрого исследования я выбрал backbone.js. Очень понравился своей простотой и гибкостью. Можно просто открыть исходники и понять, как всё устроено и работает. Единственный нюанс, который не радовал — MV-паттерн. Размазывать логику по многочисленным views очень не хотелось, так родилась идея написать свой велосипед, который предоставит недостающие части головоломки. Плюс, создание чего-то нового — это всегда увлекательно и интересно. Недолго думая, я приступил к реализации контроллеров для backbone.

Постановка задачи и реализация базовых методов


Итак, мне необходима возможность создавать контроллеры, которые свяжут в одно целое все части приложения. Каждый контроллер должен иметь доступ ко всем моделям и коллекциям (как к базовым конструкторам, так и к уже созданным экземплярам). Так же требуется возможность создавать компоненты (views) и иметь возможность слушать их события, чтобы реагировать надлежащим образом.

Скелет контроллера будет выглядеть вот так:
Controller = {

    views: {}, // views hash map
    models: {}, // models hash map
    collections: {}, // collections hash map

    // Set of methods to get existing view, get view constructor and create new view using constuctor
    getView: function() {}, 
    getViewConstructor: function() {},
    createView: function() {},

    // Set of methods to get existing model, get model constructor and create new model using constuctor
    getModel: function() {},
    getModelConstructor: function() {},
    createModel: function() {},

    // Set of methods to get existing collection,
    // get collection constructor and create new collectionusing constuctor
    getCollection: function() {},
    getCollectionConstructor: function() {},
    createCollection: function() {},

    // This method will subscribe controller instance to view events
    addListeners: function() {}
}


Пока что всё очень просто. Но для сложного приложения нам необходимо иметь несколько контроллеров, желательно, чтобы набор коллекций и моделей был общим для всего приложения. Так нам на помощь приходит Application — базовый конструктор, который объединит контроллеры в единое приложение.

Скелет приложения будет выглядеть так:
Application = {
    //Method that will initialize all controllers upon applicaiton launch
    initializeControllers: function() {},

    // Set of methods to get existing model, get model constructor and create new model using constuctor
    getModel: function() {},
    getModelConstructor: function() {},
    createModel: function() {},

    // Set of methods to get existing collection, get collectionconstructor and create new collectionusing constuctor
    getCollection: function() {},
    getCollectionConstructor: function() {},
    createCollection: function() {},
}

Так же было бы полезно сразу же создавать экземпляры всех коллекций в момент старта приложения. А ещё было бы неплохо вызывать callback-функцию у каждого контроллера после того, как приложение стартует. Этот callback нужно вызвать в тот момент, когда все предварительные данные готовы. Таким образом, каждый контроллер будет «знать», что приложение готово к работе. Не долго думая, добавляем методы:
// Create collection instances upon application start
Application.buildCollections()

// Initialize all application controllers
Application.initializeControllers()


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

EventBus = {
    // Function to add event listeners
    addListeners: function() {},

    // Function to fire event listneres
    fireEvent: function() {}
}


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

Реализация Application


Начнём с главного конструктора — Application. Базовый класс реализуем тем же способом, как это делает backbone.
var Application = function(options) {
    _.extend(this, options || {});

    // Create a new instance of EventBus and pass the reference to out application
    this.eventbus = new EventBus({application: this});

    // Run application initialization if needed
    this.initialize.apply(this, arguments);

    // Create documentReady callback to lauch the application
    $($.proxy(this.onReady, this));
};


Далee, используя _.extend, расширяем прототип:
_.extend(Application.prototype, {

        // Hash maps to store models, collections and controllers
	models: {},
	collections: {},
	controllers: {},

	/**
	 * Abstract fuction that will be called during application instance creation
	 */
	initialize: function(options) {
		return this;
	},

	/**
	 * Called on documentReady, defined in constructor
	 */
	onReady: function() {
		// initialize controllers
		this.initializeControllers(this.controllers || {});
		// call to controller.onLauch callback
		this.launchControllers();
		// call application.lauch callback
		this.launch.call(this);
	},

	/**
	 * Function that will convert string identifier into the instance reference	 
	 */ 
	parseClasses: function(classes) {
		var hashMap = {};

		_.each(classes, function(cls) {
			var classReference = resolveNamespace(cls),
				id = cls.split('.').pop();

			hashMap[id] = classReference;
		}, this);

		return hashMap;
	},

	/**
	 * Abstract fuction that will be called during application lauch
	 */
	launch: function() {},

	/**
	 * Getter to retreive link to the particular controller instance
	 */
	getController: function(id) {
		return this.controllers[id];
	},

	/**
	 * Function that will loop throught the list of collection constructors and create instances
	 */
	buildCollections: function() {
		_.each(this.collections, function(collection, alias) {
			this.getCollection(alias);
		}, this);
	}
});


Для инициализации наших контроллеров, нам понадобится два метода. Application.initializeControllers создаст экземпляры и вычитает наборы коллекций и моделей, чтобы сохранить ссылки непосредственно в самом приложении. А Application.launchControllers пройдётся по уже созданным конроллерам и выполнит Controller.onLaunch callback.
_.extend(Application.prototype, {
    ...    
	/**
	 * Fuction that will loop through all application conrollers and create their instances
	 * Additionaly, read the list of models and collections from each controller
         * and save the reference within application
	 */
	initializeControllers: function(controllers) {
		this.controllers = {};

		_.each(controllers, function(ctrl) {
			var classReference = resolveNamespace(ctrl),
				id = ctrl.split('.').pop();

                        // create new Controller instance and pass reference to the application
			var controller = new classReference({
				id: id,
				application: this
			});

			controller.views = this.parseClasses(controller.views || []);

			_.extend(this.models, this.parseClasses(controller.models || []));
			_.extend(this.collections, this.parseClasses(controller.collections || {}));

			this.buildCollections();
			this.controllers[id] = controller;
		}, this);
	},

	/**
	 * Launch all controllers using onLauch callback
	 */
	launchControllers: function() {
		_.each(this.controllers, function(ctrl, id) {
			ctrl.onLaunch(this);
		}, this);
	}
    ...
});


Чтобы обеспечить коммуникацию между контроллерами и дать возможность подписаться на события от конкретных копмонентов, добавим метод Application.addListeners, который делегирует работу в наш EventBus:
_.extend(Application.prototype, {
    ...
	/**
	 * Abstract fuction that will be called during application lauch
	 */
	addListeners: function(listeners, controller) {
		this.eventbus.addListeners(listeners, controller)
	}    
    ...
});


Для работы с моделями и коллекциями нам понадобятся функции для получения ссылки на экземпляр, ссылки на конструктор и метод создания новой сущности. Рассмотрим конкретную реализацию на примере моделей, для коллекций функции будут работать аналогично.
_.extend(Application.prototype, {
    ...    
	/**
	 * Getter to retreive link to the particular model instance
	 * If model instance isn't created, create it
	 */
	getModel: function(name) {
		this._modelsCache = this._modelsCache || {};

		var model = this._modelsCache[name],
			modelClass = this.getModelConstructor(name);

		if(!model && modelClass) {
			model = this.createModel(name);
			this._modelsCache[name] = model;
		}

		return model || null;
	},

	/**
	 * Getter to retreive link to the particular model consturctor
	 */
	getModelConstructor: function(name) {
		return this.models[name];
	},

	/**
	 * Function to create new model instance
	 */
	createModel: function(name, options) {
		var modelClass = this.getModelConstructor(name),
			options = _.extend(options || {});

		var model = new modelClass(options);

		return model;
	},

	/**
	 * Getter to retreive link to the particular collection instance
	 * If collection instance isn't created, create it
	 */
	getCollection: function(name) {
		...
	},

	/**
	 * Getter to retreive link to the particular collection consturctor
	 */	
	getCollectionConstructor: function(name) {
		...
	},
	/**
	 * Function to create new collection instance
	 */	
	createCollection: function(name, options) {
		...
	},    
    ...
});


Теперь, наш базовый конструктор для приложения готов. Следует упомянуть метод Application.parseClasses. Дело в том, что я решил передавать списки контролеров, моделей, коллекций и вью в виде массива строк. Получая на входе
[
    'myApplication.controller.UserManager',
    'myApplication.controller.FormBuilder'
]

функция Application.parseClasses превратит этот массив в маппинг
{
    'UserManager': myApplication.controller.UserManager,
    'FormBuilder': myApplication.controller.FormBuilder
}


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

Реализация Controller


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

var Controller = function(options) {
	_.extend(this, options || {});
	this.initialize.apply(this, arguments);
};

А дальше можно расширять прототип
_.extend(Controller.prototype, {
	views: {},
	models: {},
	collections: {},

	initialize: function(options) {
	},

	/**
	 * Add new listener to the application event bus
	 */
	addListeners: function(listeners) {
		this.getApplication().addListeners(listeners, this);
	},

	/**
	 * Abstract fuction that will be called during application lauch
	 */	
	onLaunch: function(application) {
	},

	/**
	 * Getter that will return the reference to the application instance
	 */	
	getApplication: function() {
		return this.application;
	}
});


Добавляем методы для работы с Views:
_.extend(Controller.prototype, {
    ...

	/**
	 * Getter that will return the reference to the view constructor
	 */		
	getViewConstructor: function(name) {
		return this.views[name];
	},

	/**
	 * Function to create a new view instance
	 * All views are cached within _viewsCache hash map
	 */
	createView: function(name, options) {
		var view = this.getViewConstructor(name),
			options = _.extend(options || {}, {
				alias: name
			});

		return new view(options);
	}
    ...
});


Работу с моделями и коллекциями мы делегируем на наш Application
_.extend(Controller.prototype, {
    ...
	/**
	 * Delegate method to get model instance reference
	 */		
	getModel: function(name) {
		return this.application.getModel(name);
	},

	/**
	 * Delegate method to get model constructor reference
	 */		
	getModelConstructor: function(name) {
		return this.application.getModelConstructor(name);
	},

	/**
	 * Delegate method to create model instance
	 */		
	createModel: function(name, options) {
		return this.application.createModel(name)
	},

	/**
	 * Delegate method to get collection instance reference
	 */		
	getCollection: function(name) {
		return this.application.getCollection(name);
	},

	/**
	 * Delegate method to get collection constructor reference
	 */		
	getCollectionConstructor: function(name) {
		return this.application.getCollectionConstructor(name);
	},

	/**
	 * Delegate method to create collection instance
	 */		
	createCollection: function(name, options) {
		return this.application.createCollection(name);
	}    
    ...
});


И под конец, позволим нашим контроллерам общаться, используя Application.EventBus
_.extend(Controller.prototype, {
    ...
	/**
	 * Delegate method to fire event
	 */		
	fireEvent: function(selector, event, args) {
		this.application.eventbus.fireEvent(selector, event, args);
	}    
    ...
});

Базовый конструктор для контроллера готов! Осталось совсем немного :)

Реализация EventBus


Для начала опишем конструктор. Чтобы дать возможность контроллеру слушать события от view, нам необходимо немного расширить базовый прототип Backbone.View. Дело в том, что нам нужен некий селектор, по которому будут отслеживаться события. Для того введём свойство alias, которое автоматически будет присваиваться в момент создания компонента. И добавим метод fireEvent, который вызовет «родной» View.trigger() и нотифицирует EventBus о новом событии.

var EventBus = function(options) {
	var me = this;

	_.extend(this, options || {});

        // Extend Backbone.View.prototype
	_.extend(Backbone.View.prototype, {
		alias: null,

		/*
		 * Getter that wll return alias
		 */
		getAlias: function() {
			return this.options.alias;
		},

		/*
		 * Instead of calling View.trigger lets use custom function
		 * It will notify the EventBus about new event
		 */
		fireEvent: function(event, args) {
			this.trigger.apply(this, arguments);
			me.fireEvent(this.getAlias(), event, args);
		}
	});
};


Теперь можно смело расширять прототип. Используем EventBus.addListeners для подписки на новые события, а EventBus.fireEvent надёт нужный обработчик и выполнит его.
_.extend(EventBus.prototype, {
	// Hash Map that will contains references to the all reginstered event listeners
	pool: {},
	/**
	 * Function to register new event listener
	 */ 
	addListeners: function(selectors, controller) {

		this.pool[controller.id] = this.pool[controller.id] || {};
		var pool = this.pool[controller.id];

		if(_.isArray(selectors)) {
			_.each(selectors, function(selector) {
				this.control(selector, controller);
			}, this)
		}
		else if(_.isObject(selectors)) {
			_.each(selectors, function(listeners, selector) {
				_.each(listeners, function(listener, event) {
					pool[selector] = pool[selector] || {};
					pool[selector][event] = pool[selector][event] || [];

					pool[selector][event].push(listener);

				}, this);
			}, this)

		}
	},

	/**
	 * Function to execute event listener
	 */ 	
	fireEvent: function(selector, event, args) {
		var application = this.getApplication();

		_.each(this.pool, function(eventsPoolByAlias, controllerId) {
			var events = eventsPoolByAlias[selector];

			if(events) {
				var listeners = events[event]
					controller = application.getController(controllerId);

				_.each(listeners, function(fn) {
					fn.apply(controller, args);
				});
			}


		}, this);
	},

	/**
	 * Getter to receive the application reference
	 */ 	
	getApplication: function() {
		return this.options['application'];
	}
});


Ура! Теперь все основные части реализованы! Заключительный штрих
Application.extend = Backbone.Model.extend;
Controller.extend = Backbone.Model.extend;

Теперь мы можем создавать наследоваться от наших базовых конструкторов, используя функцию extend.

Документация и примеры



Исходные файлы и документация на оффициальной странице Backbone.Application

Так же я создал простой пример — это классический ToDo с использованием MVC. Исходники и коментарии к реализации можно посмотреть здесь — github.com/namad/Backbone.Application/blob/master/examples/ToDo/js/todos.js

И в качестве бонуса более сложный пример, ради которого я написал весь этот велосипед — visualHUD, редактор HUD для моей любимой игры Quake Live. На данный момент новая версия всё ещё в разработке, необходимо доделать кучу мелочей, но в целом весь функционал работает и его можно пощупать своими руками. Кому интересно, исходинки старой версии на гуглокоде

P.S. Это моя первая статья подобного характера и я понятия не имею, что получилось :) Так что любой адекватный отзыв на вес золота. Спасибо!
Tags:
Hubs:
+5
Comments 33
Comments Comments 33

Articles