Intro
This article is accumulation of my experience in building frontend app architectures. Concepts I am writing about are fine for small and big apps, mobile and web.
I assume that everyone knows BackboneJS basics or at least understands it as pseudocode.
Problem
When application starts growing it takes more and more time to add new features, detect bugs and make refactoring.
Why does it happen?
Cross dependencies
It's common practice when you need something that does not exist in current file but exists somewhere else in your project and you just import staff you need and use it.
For example developer wants to display stats in header view:
//header-view.js
define(function(require) {
// Including stats view and model
var StatsView = require('views/stats'),
StatsModel = require('models/stats');
var HeaderView = Backbone.View.extend({
render: function() {
var statsModel = new StatsModel(),
statsView = new StatsView({model: statsModel});
// Fetch stats data from server and render view on success
statsModel.fetch({
success: function() {
this.$('.user').html(statsView.render().$el);
}
});
}
});
});
This has its pros and cons.
Pros are: it looks pretty easy and if we need to show Stats view in some place we can just include it and use.
Cons are: Header view should know about internal Stats view implementation. It should know that it requires Stats model and should fetch data before render.
And what happens if Stats view is changed? If it will use, for example Collection instead of Model?
This case we should search through the whole project to understand what files use our Stats view and modify each entry.
This moves us to second problem.
Big scope
Developers should always keep in mind the whole project. Why? Because they should understand that files will be affected if they will change something.
This is fine for small projects, but a problem for another. Sometimes we work with thousands of files in one project. Dependencies can be very complex: this view extends that view which uses another mixin we want to change etc
Developers start feeling fatigue very fast. Each modification requires "compiling whole project in the head" to understand what can be broken and what can be affected by this change.
As a result - they want to start working on something new and small again.
DRY (don't repeat yourself)
I think everyone knows this principle. But do we really follow it if we still should write same code (create model, fetching, create view) again and again?
The main goal of DRY is to allow to create something that can be reusable but not cause Cross dependencies and Big scope issues.
Component-based architecture
This is not something new. I have just collected practices which helped me to solve problems together and use them in my projects. And it works (at least for me).
Overview
The main goal is to create reusable components which provide Public API and hide implementation inside.
For example:
// header-view.js
define(function(require) {
// Including stats module and creating new instance
var StatsModule = require('modules/stats'),
statusModuleIns = new StatsModule();
var HeaderView = Backbone.View.extend({
render: function() {
// call public API method to show stats details
statusModuleIns.showDetails({
container: this.$('.user')
});
}
});
})
In this case we follow DRY as much as possible - no code duplication. As a bonus we received reusable Stats component which hides implementation and gives Public API - showDetails method.
What is inside?
Anything you want.
Example 1:
// modules/stats/main.js
define(function(require) {
// Include local module model and view
var Model = require('./model'),
View = require('./view'),
Controller = function() {}
// Public API for module
Controller.prototype.showDetails = function(container) {
// internal logic
this.container = container;
this.model = this.model || new Model();
if (this.model.isNew()) {
this.model.fetch({success: this._renderDetails});
} else {
this._renderDetails();
}
}
Controller.prototype._renderDetails = function() {
this.detailsView = new View({
model: this.model
container: this.container
});
}
return Controller;
});
Or even Example 2:
// modules/stats/main.js
define(function(require) {
var $ = require('jquery'),
Controller = function() {}
Controller.prototype.showDetails = function(container) {
$.get('/stats', function(data) {
$(container).html('<stats>' + data.stats + '</stats>');
});
}
return Controller;
});
Internal implementation is hidden so developers don't care what happens inside. Do you want to show stats details? Okay - use showDetails method of Stats component.
Benefits
No big scope
Developers are working with a small component scope which reduces complexity of changes even in a big application.
No need to keep in mind the whole project, because developer takes care about internal implementation and Public API. So they feel free to make any changes if Public API stays the same.
So even in a big project refactoring or new techniques can be implemented without any problems in local component scope.
No cross dependencies
We are working with high level abstraction - components, not directly with views / models etc. This reduces application complexity and allows reuse implemented functionality.
So we can use only controllers Public API to make operations with a component. No direct calls to views / models.
Testing
It's very easy and important to test components.
Unittests should cover components Public API - in this case we can make any internal changes in the component. If public api requests / responses are the same it means module works as expected. No need to test models / views but we still can be sure in tests results.
Practice
Application structure
Base
At first we have to use one place for modules in our application.
application/
app.js
modules/
stats/
user/
Each module can have different structure based on which framework is used in application. For example Backbone.js
applications/
modules/
stats/
main.js
models/
model.js
collection.js
views/
templates/
extended.hbs
short.hbs
extended.js
short.js
Module contains all needed files - views, templates, models, collections etc. Main.js - is an entry point of module which provides Public API.
For example, if we are using Require.js as AMD loader we can define packages in Require.js config so we can make operations with modules. In this case main.js
file will be called automatically.
// config.js
requirejs.config({
...
packages: [
'modules/stats',
'modules/user'
]
});
And use it
// modules/stats/main.js called
var StatsModule = require('modules/stats');
This gives us needed level of abstraction.
Shared functionality
When application starts growing we see that some functionality is duplicated.
For example, we create modal windows and it's nice idea to use some base-modal-view.js
to avoid duplication.
This problem can be solved by placing base shared elements in shared folder
application/
app.js
modules/
shared/
views/
base-modal.js
var BaseModalView = require('shared/views/base-modal');
return BaseModalView.extend({
});
But we should keep in mind that shared/
folder just for shared base functionality, not for modules specific views, models etc
Components communication
Next step to reduce components cross dependencies is to disallow using components inside other components. In this case we need some controller which allows to do cross-components communication.
Let's create a new screens/
folder.
application/
app.js
modules/
shared/
screens/
home/
main.js
The role of screen is to include all modules for current screen and make cross-components communication.
// screens/home/main.js
define(function(require) {
// Include all modules that are used in current screen
var UserModule = require('modules/user'),
HeaderModule = require('modules/header'),
Controller = function() {};
// When screen starts execute initialize
Controller.prototype.initialize = function() {
// Create new User component instance
this.user = new UserModule();
// Create new Header component instance
this.header = new HeaderModule();
// If user will click on "Add" button in header view
// we should inform screen and execute needed User Public API
this.header.on('user:new', this._addNewUser);
// Use promises to show user details when they will be loaded
this.user.getDetails().then(this._showUser);
}
Controller.prototype._addNewUser = function() {
// Open new user modal window
this.user.showAddModal();
}
Controller.prototype._showUser = function(details) {
// Render user details in header
this.header.show({user: details});
}
return Controller;
});
So result project structure is
application/
app.js
shared/ <- shared base functionality
views/
models/
utils/
...
modules/ <- reusable components
user/
stats/
news/
...
screens/ <- application "pages"
home/
news/
contacts/
...
That's it for now. Feel free to ask questions as github issues for this project https://github.com/artyomtrityak/component-based-architecture/issues