A radical new approach to developing AngularJS apps
15 May 2014Learn how to use my AngularJS seed with Google Closure Compiler.
Concept
This is not a ‘one size fits all’ solution. There is no CoffeeScript/etc. compiler or CSS preprocessor involved. That’s up to you.
What you get is a clean and scalable project structure for your AngularJS apps.
Google Closure Compiler
Most of you might use uglifyjs for minification. At least that’s what I did for a long time and there is nothing wrong with it. It is an awesome tool.
However Closure Compiler can do more than that.
- Minify code knowing about AngularJS
- Inject dependencies automatically
- Create namespaces
–angular-pass
--angular_pass
Generate $inject properties for AngularJS for functions annotated with @ngInject
Example controller.
/**
* Controller.
*
* @param {angular.$window} $window
* @param {angular.$http} $http
* @ngInject
*/
var Ctrl = function($window, $http) {
// ...
};
The above example after minification.
var a = function(b, c) {
// that's beautiful
};
a.$inject = ["$window", "$http"];
No more
angular.module('app').controller('Ctrl', ['one', 'two', 'three', 'four', 'five', 'six', function(one, two, three, four, five, six) {
// ^ that's quite a long line
}])
–externs
--externs closure/externs/angular.js
The file containing JavaScript externs. You may specify multiple
Example
angular.module('app', []);
The above example after minification without --externs
.
angular.d('app', []);
That does not work as angular
does not have a d()
method.
module()
, directive()
, service()
and so on have to stay as they are.
That’s why we need an
AngularJS externs file
with the AngularJS global namespace.
–generate_exports
--generate_exports
Generates export code for those marked with @export
Closure Compiler throws away all unnecessary code. So if you declare a function
but never call it the compiler doesn’t include it in app.min.js
. Problem here is
that we declare our controllers and controller properties in our xyz-controller.js
files
and use them in your template files xyz.html
. Closure Compiler doesn’t look into
our HTML template and doesn’t know which methods we’d like to
keep.
@export
and @expose
to the rescue. Simply use these annotations in your code
to prevent variable names from being mangled.
/**
* Controller.
*
* @export
*/
var Ctrl = function() {
/**
* `text` model cannot by used in template because it is not exposed.
*
* @type {String}
*/
this.text = 'Hello world!';
/**
* `info` can be used in template because it is exposed.
*
* @type {String}
* @expose
*/
this.info = '"info" can be used in the template';
};
<!-- empty span -->
<span>{{ ctrl.text }}</span>
<!-- span with '"info" can be used in the template' -->
<span>{{ ctrl.info }}</span>
So what’s the difference between @export
and @expose
?
- use
@export
for constructor functions - use
@expose
for properties inside constructor functions
Important!
The style guide from Google says that
if you are using @export for properties, you will need to add the flags:
--remove_unused_prototype_props_in_externs = false --export_local_property_definitions
Both of them didn’t work for me because the Closure Compiler threw Errors.
Namespaces
Closure Compiler modularization works very similar to Node.js modules.
You can create namespaces by using goog.provide() and goog.require().
It is a bit like module.exports
and require()
from Node.js.
xyz-controller.js
/**
* Create namespace.
*/
goog.provide('my.Ctrl');
/**
* Controller.
*
* Assign constructor function to namespace.
*/
my.Ctrl = function() {
// ..
};
xyz-module.js
/**
* Require controller.
*/
goog.require('my.Ctrl');
/**
* Module.
*/
angular.module('app', [])
.config(function() { /*...*/ })
.controller('Ctrl', my.Ctrl);
No more, what’s the difference between
angular.module('app', [])
and angular.module('app')
?!?
Be careful when providing and requiring submodules. Inside our main app.js
we can
require submodules and inject them into our main module. However you must use the
name
and not the whole module.
/**
* Require submodule
*/
goog.require('my.first.module');
/**
* Main app.
*
* Inject submodule by using its `name`.
*/
my.app = angular.module('app', [
'ui.router',
my.first.module.name
])
‘controller as’ syntax
Since Angular version 1.1.5 you can use an alternative syntax for your controllers. The old way was simply using the controller name. Either in your HTML
<div ng-controller="MyCtrl">
</div>
or inside the router.
$routeProvider.when('/first', {
templateUrl: 'first.html',
controller: 'MyCtrl'
});
By using the alternate so called ‘controller as’ syntax you have fine grained control over your app.
$stateProvider
.state('third', {
url: '/third',
templateUrl: 'states/third/third.html',
controller: 'ThirdCtrl as third'
})
.state('third.one', {
url: '/one',
templateUrl: 'states/third/one/one.html',
controller: 'ThirdOneCtrl as thirdOne'
})
.state(...);
You can now reference the controllers in your templates.
<p>{{ third.label }}</p>
<p>{{ thirdOne.label }}</p>
As opposed to the standard syntax.
<!-- where might 'label' come from? Parent or child controller? -->
<p>{{ label }}</p>
The ‘controller as’ syntax provides a much better overview in templates. It is easy to see where things come from.
Everything is a JavaScript class
Yes, with prototype
and new
.
Controller
/**
* First controller.
*/
var FirstCtrl = function() {
this.text = 'Hello world!';
};
/**
* Write value of `text` model to stdout.
*/
FirstCtrl.prototype.log = function() {
console.log(this.text);
};
angular.module('app', [])
.controller('FirstCtrl', FirstCtrl);
Service
Use module.service
instead of module.factory
or module.provider
.
/**
* Version service.
*/
var Version = function() {
this.version = '0.0.1';
};
/**
* Return the current version.
*/
Version.prototype.get = function() {
return this.version;
};
angular.module('app', [])
.service('version', Version);
Directive
/**
* @constructor
*/
var Directive = function(version) {
this.version = version;
this.link = this.link.bind(this);
this.scope;
this.elem;
this.attrs;
};
/**
* Version directive factory. Entry point and used in `module.directive`.
*/
Directive.factory = function(version) {
var dir = new Directive(version);
return {
link: dir.link
};
};
/**
* Linking function.
*/
Directive.prototype.link = function(scope, elem, attrs) {
this.scope = scope;
this.elem = elem;
this.attrs = attrs;
this.elem.text(this.version.get());
};
angular.module('app', [])
.directive('version', Directive.factory);
Filter
/**
* @constructor
*/
var Filter = function() {
this.checkmark = '\u2714';
this.cross = '\u2718';
this.convert = this.convert.bind(this);
};
/**
* Check filter factory. Entry point and used in `module.filter`.
*
* @return {function}
*/
Filter.factory = function() {
var filter = new Filter();
return filter.convert;
};
/**
* Actual filter function.
*/
Filter.prototype.convert = function(input) {
return input ? this.checkmark : this.cross;
};
angular.module('app', [])
.filter('check', Filter.factory);
Angular UI Router
nuff said
Everything is grouped in ‘states’ and ‘components’
Directives, Filters and Services belong into the components/
folder.
Each file should have a -directive
, -filter
or -service
suffix.
As you can see we have a version
Directive and a version
Service.
With the suffixes they are easy to differentiate.
All states belong into the states/
folder. Give each folder the appropriate
state name, i.e. home/
for home
state. Put child states inside their parent
directories, i.e. one/
inside third
for third.one
state.
As all components and states have their unit tests right next to them the test/unit/
folder only contains our karma.conf.js
and the Istanbul code coverage reports.
The same goes for the unit/e2e/
folder. It only contains the test scenarios for our
main app.js
file.
Gruntfile.js
package.json
node_modules/
...
closure/
library/
base.js
deps.js
externs/
angular.js
compiler.jar
app/
index.html
img/
css/
js/
app.js
app.min.js
lib/
angular.js
angular-ui-router.js
...
components/
directives/
version-directive.js
version-directive.spec.js
filters/
check-filter.js
check-filter.spec.js
services/
version-service.js
version-service.spec.js
states/
first/
first-controller.js // controller
first-controller.spec.js // controller unit tests (karma)
first-module.js // module initialization with Router
first.html // partial template
first.pageobject.js // Page Object for e2e tests (protractor)
first.scenario.js // e2e tests (protractor)
second/
second-controller.js
second-controller.spec.js
second-module.js
second.html
second.pageobject.js
second.scenario.js
third/ // parent state 'third'
third-controller.js
third-controller.spec.js
third-module.js
third.html
third.pageobject.js
third.scenario.js
one/ // child state 'third.one'
one-controller.js
one-controller.spec.js
one-module.js
one.html
one.pageobject.js
one.scenario.js
two/ // child state 'third.two'
two-controller.js
two-controller.spec.js
two-module.js
two.html
two.pageobject.js
two.scenario.js
test/
e2e/
protractor.conf.js
scenarios.js
unit/
karma.conf.js
coverage/
...
Important!
compiler.jar
(6,8 MB)
is not in this repo. You have to download it manually and place it inside
the closure/
directory.
‘states’ are independent modules
Yes, with square brackets []
. They live inside the xyz-module.js
files.
They have their own config()
function and they all define their own routes.
/**
* Create namespace.
*/
goog.provide('my.first.module');
/**
* Require controller.
*/
goog.require('my.first.Ctrl');
/**
* First module.
*/
my.first.module = angular.module('first', [
'ui.router'
]);
/**
* Configuration function.
*/
my.first.module.configuration = function($stateProvider) {
$stateProvider.state('first', {
url: '/first',
templateUrl: 'states/first/first.html',
controller: 'FirstCtrl as first'
});
};
/**
* Init first module.
*/
my.first.module
.config(my.first.module.configuration)
.controller('FirstCtrl', my.first.Ctrl);
Each state has its own controller. They live in the xyz-controller.js
files.
/**
* Create namespace.
*/
goog.provide('my.first.Ctrl');
/**
* First controller.
*/
my.first.Ctrl = function() {
// ...
};
Each state creates an own namespace. Inside the main app.js
we use goog.require()
to pull in all submodules and inject them into our main app.
Child states don’t bubble up to our main app.js
. Load
them in their parent state, i.e. third.one
and third.two
are required in
third
state which is itself required by our main app.js
.
This structure makes it much easier to understand data coming from resolve
.
Karma unit tests with Istanbul code coverage
Using code coverage with AngularJS is really easy. Though only few projects actually include Istanbul.
preprocessors: {
'app/states/**/!(*.pageobject|*.scenario|*.spec).js': 'coverage'
}
minimatch is magic. The pattern
includes all *.js
files in our states/
directory. It ignores all
*.pageobject.js
, *.scenario.js
and *.spec.js
files.
They would pass and show up green in the coverage report but they create noise.
So leave them out.
To increase readability inside your Karma tests use the $injector
service
instead injecting services inline.
Good
beforeEach(inject(function($rootScope, $controller) {
// ...
}));
Better
beforeEach(inject(function($injector) {
var $rootScope = $injector.get('$rootScope');
var $controller = $injector.get('$controller');
// ...
}));
That one line beforeEach(inject(function(...) {
can become pretty long
and hard to read.
Protractor tests using Page Objects
From Organizing Real Tests: Page Objects
When writing real tests scripts for your page, it’s best to use the Page Objects pattern to make your tests more readable
xyz.pageobject.js
var XYZ = function() {
this.navigate = function() {
browser.get('index.html#/xyz');
};
// ng-repeat
this.animals = element.all(by.repeater('animal in first.animals'));
};
module.exports = new XYZ();
xyz.scenario.js
describe('first', function() {
var xyz = require('./xyz.pageobject.js');
beforeEach(function() {
xyz.navigate();
});
it('should render all animals', function() {
expect(xyz.animals.count()).toBe(3);
});
});
Conclusion
I’ve built a seed project called nghellostyle. It includes all the proposals mentioned in this post. The name comes from Google’s internal seed project which is unfortunately not online.
Tweet comments powered by Disqus