View on GitHub

Frontend Component-based architecture

javascript, frontend, architecture

Download this project as a .zip file Download this project as a tar.gz file

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

DEMO projects