Well, this article is here to help you implement SPA with AngularJS and Webpack. But I am not going to show you every line of code since that will be too much to say. I will just share some of the main concerns and solutions. You can see the complete boilerplate here directly.
Why SPA?
Comparing to traditional websites, a SPA won’t reload the whole page when user tries to navigate to another “page”, instead, the JavaScript will load data asynchronously and change the content.
That sounds simple, but what is the point?
Benefits
SPA has folloing benefits:
- SPA saves a lot of network load comparing to traditional websites and feels more like an application than a web page.
- SPA communicates with server with AJAX most of the time, this leads to a better separation between front end and back end.
- SPA is easy to mock during test since you can fake all the AJAX.
- SPA is easy to cache resources and make the most use of local storage.
Downsides
Though SPA provides a lot of good thinks, there are some downsides you should know:
- SEO is hard to handle cause it’s a dynamic web page. You should take a look at this article if your business cares about SEO.
- Not as easy as you do with traditional websites if you want to implement front-end analytics. Google Analytics has pointed it out.
What takes you to SPA?
To implement a SPA maybe is not as easy as you thought, there is some key points you should know before you get your hands wet:
- You need to lazy load your “page” resources when navigating if you want to build a scalable SPA.
- The server needs to route all the “virtual path” to your SPA page entry. A SPA usually has one single entry like “yourdomain.com/index.html”. Whatever page URL the server receives ( like “yourdomain.com/dashborad” ), it returns the entry and leave the real route work to font end.
Front-End requirement and solutions.
Well, let us begin to dive into front end development. We will go through all the things you need to concern when start a new front end project.
- Browser Compatibility: Prefer Modern browers but it depends the browser market share of your customers. Let the data help you to decide, this article will target to browsers: Chrome, Firefox, Safari, IE9+.
- ES6: Babel.
- Style Preprocessor: SASS.
- Code Style: Use ESLint as the linting utility. Prefer Airbnb’s JavaScript Style Guide
- Framework: AngularJS ( the lastest AngularJS doesn’t support IE8 anymore ).
- Libraris & Utilities: jQuery / lodash.
- Polyfills: Babel-polyfill and others if in need.
- CommonJS / Module Bundler / Resource Loader: NPM & Webpack.
- Image Compression and Assets Encoding ( e.g. encoding image or webfont into base64 string ): Webpack loaders.
- Development enviroment: Webpack-dev-server.
- Unit Test: Karma and Mocha.
- E2E Test ( UI Test ): Protractor and Mocha and Chai-as-promised.
- Online Debugging: Anyproxy.
Hard parts of AngularJS
To implement SPA with AngularJS is not that easy ( Yes, not easy as with React! ).
The first hard thing is to implement a lazy load route. We can choose UI-Route to implement front end routing, but it does not support dynamically routing and it’s a little tricky to lazy load controller either. But fortunately, we can use resolve
to wait certain controller to be loaded, and templateProvider
will be executed after resolve
is resolved:
const templates = {};
function lazyLoadPage(pageName) {
return new Promise((resolve) => {
/* lazy load controller and template */
...
templates[pageName] = pageInfo.template;
resolve();
});
}
$stateProvider
.state('home', {
url: '/home',
templateProvider() {
return templates.home || 'fail to load template';
},
controller: 'homeCtrl',
resolve: { /* this name does not matter */lazyLoad() { return lazyLoadPage('home'); } },
})
And we can even build a route-generater
to write those code automatically.
Sounds perfect? Then the most fractured thing comes to us: AngularJS does not allow you to register component ( I mean controller, service, filter and directive ) after the module has finishd bootstrapping, which means, even you successfuly load your page script ( with controller definition inside ) and executed, the injector can not find your controller.
That is sucks, but however, I have angular-delay-register to help you overcome this problem.
Basically, you need to use angular-delay-register to set up your module:
import couchPotato from 'angular-delay-register';
const module = angular.module('app', [
'scs.couch-potato',
]);
couchPotato.configureApp(module);
module.run(/* @ngInject */$couchPotato => {
module.lazy = $couchPotato; // note that you need to use the name 'lazy'
});
and use new methods to register components, like below:
module.registerController('dashboardCtrl', /* @ngInject */($scope) => {
$scope.name = 'Neekey';
});
it’s not perfect, but believe me, this is the best way I can find to work around this problem. You don’t want to wrap every component into a new module like ocLazyLoad does, which basically means your write your components like below:
/* home.js */
angular.module( 'homeModule', []).controller( 'homeCtrl', () => {
});
/* dashboard.js */
angular.module( 'dashboardModule', []).controller( 'dashboardCtrl', () => {
});
: (
The last things about AngularJS is the dependence injection when you want to compress your code. You may feel happy to write your code like below:
module.controller('homeCtrl', ( $scope, $http, $q ) => {
});
AngularJS’s dependence injection is realy cool, but compression tools like UglifyJS does not know much about it, which will break the literal strings $scope
, $http
and $q
into some kind of random varibles. And believe me, you do not want to change your code manually like below:
module.controller('homeCtrl', [ '$scope', '$http', '$q', ( $scope, $http, $q ) => {
}]);
Another disaster, but fortunately, we have ng-annotate, the only things we need to get used to is add some annotation before your DI:
module.controller('homeCtrl', /* @ngInject */( $scope, $http, $q ) => {
});
Webpack
Webpack is so powerful. With its various loader, we can get lots of features:
- CommonJS
- ES6
- Sass
- Image / Font / HTML load
It can also integrate ESLint as a preloader to help lint our code in development or buid for production. In addition, benefits from live reload, the page can just reload itself when certain source files change.
(Webpack also provide a feature called Hot Module Replacement, which is very cool, but AngularJS 1.x does not work well with it, so just forget about it)
We can also benefit from code spliting to split our page entries so that we can load each page code asynchronously.
Begin with directory structure.
Below is the basic directory we need for our SPA
- app
- build
- config
- test
- package.json
- README.md
Simple enough. Let us talk about each one of them.
App: where source code lies
Below is what inside app
- app
- pages
- home
- dashboard
- index.js
- style.scss
- view.html
- module.js
- route.js
- app.js
app.js
is our main entry, and every page code goes into pages
. Since every body needs a module instance, we create a pages/module.js
to export this instance, it also includes our module dependences and set up angular-delay-register
:
import angular from 'angular';
import 'angular-resource';
import 'angular-ui-router';
import couchPotato from 'angular-delay-register';
const module = angular.module('app', [
'ui.router',
'scs.couch-potato',
]);
couchPotato.configureApp(module);
module.run(/* @ngInject */$couchPotato => {
module.lazy = $couchPotato; // note that you need to use the name 'lazy'
});
export default module;
Let’s take a looks at our page controller:
import html from './view.html';
import module from '../module';
import './style.scss';
module.registerController('dashboardCtrl', /* @ngInject */($scope) => {
$scope.name = 'Neekey';
});
export { html as template };
Notice we use registerController()
instead of controller()
, this is one of the methods that angular-delay-register
brings to us, and we also export our view as HTML string, which will be used in routing.
Finally, route.js
includes routing works as I told you in Hard parts of AngularJS, which is like:
import module from './module';
module.config(/* @ngInject */($stateProvider, $locationProvider, $urlRouterProvider) => {
const templates = {};
function pageHandle(pageName, pageInfo, resolve) {
templates[pageName] = pageInfo.template;
resolve();
}
function lazyLoadPage(pageName) {
return new Promise((resolve) => {
switch (pageName) {
case 'dashboard':
require.ensure(['./dashboard/index'], (require) => {
pageHandle(pageName, require('./dashboard/index'), resolve);
});
break;
case 'home':
require.ensure(['./home/index'], (require) => {
pageHandle(pageName, require('./home/index'), resolve);
});
break;
default:
break;
}
});
}
$stateProvider
.state('dashboard', {
url: '/dashboard',
templateProvider() {
return templates.dashboard || 'fail to load template';
},
controller: 'dashboardCtrl',
resolve: { lazyLoad() { return lazyLoadPage('dashboard'); } },
})
.state('home', {
url: '/',
templateProvider() {
return templates.home || 'fail to load template';
},
controller: 'homeCtrl',
resolve: { lazyLoad() { return lazyLoadPage('home'); } },
});
/**
* looks like futureState is not compatible with urlRouterProvider.otherwise
*/
$urlRouterProvider.otherwise('/');
// 启用 html5 history
$locationProvider.html5Mode(true);
});
The most tricky part about route.js
is that you need to make sure you use require.ensure
with a literal module name like ./dashboard/index
instead of varibles. Webpack will only split code automatically this way.
And also take a look at the function pageHandle
, its second parameter pageInfo
is the page module asynchronously loaded, you can see how the pageInfo.template
is used, that why we export view HTML string in pages/[page-name]/index.hs
.
Config
The two most important files in /config
are:
- config
- webpack.config.js
- route-builder.js
webpack.config.js
is where all the configuration goes, you should have a look at it in our boilerplate, and route-builder.js
generates /app/pages/route.js
automatically based on what is inside directory /app/pages
.
Considering we the route configuration for each page may vary, you can also customize each page route through /app/pages/[page-name]/route.json
, it makes routing work easy and also very flexible.
Test: Unit and E2E test goes here
The directory explains itself:
- test
- e2e
- unit
- karma.conf.js
- protractor.conf.js
I will talk more about test in this article.
Build: Production code goes here
- build
- index.html
- app.js
- vendor.js
- 1.1.js
- ...
Basically files in /build
are all generated by Webpack except index.html
, which is made by us for development, in production this file should be generated by backend so that I can contain some dynamic data inside.
Development
Local development is based on /build/index.html
and webpack-dev-server
.
/build/index.html
:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<script>
document.write( '<base href="' + location.pathname + '">' );
</script>
<script src="http://localhost:8080/webpack-dev-server.js"></script>
<script src="vendor.js"></script>
<script src="app.js"></script>
</head>
<body ng-app="app">
<ul class="menu">
<li><a ui-sref="home">home</a></li>
<li><a ui-sref="dashboard">dashboard</a></li>
<li><a href="./not-found">Not Found(will redirect to home)</a></li>
</ul>
<div class="main" ui-view></div>
</body>
</html>
Notice we add webpack-dev-server.js
to enable live reload ( there’s a entry add to /config/webpack.conf.js
too ), and use document.write( '<base href="' + location.pathname + '">' );
to create <base>
setting to correct path whatever path this application is serverd on.
To make it easy to start development, add command into package.json
:
{
"scripts": {
"dev": "NODE_DEV=development node ./node_modules/webpack-dev-server/bin/webpack-dev-server --host 0.0.0.0 --devtool eval --config config/webpack.config.js --progress --colors --hot --inline --content-base build"
}
}
So when you want to start development, type npm run dev
, open http://localhost:8080
, you are ready to go.
Don’t forget Unit Test
Angular use Karma to do Unit Test. You need to install karma and its browser launcher first ( and I prefer mocha ):
$ npm install karma karma-chrome-launcher mocha karma-mocha --save-dev
Then you need to set up karma configuration /config/karma.conf.js
:
// Karma configuration
// Generated on Thu May 26 2016 20:41:12 GMT+1000 (AEST)
module.exports = function (config) {
config.set({
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '',
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['mocha'],
// list of files / patterns to load in the browser
files: [
/* test tool using with mocha */
'../node_modules/chai/chai.js',
/* vendors the application requires */
'http://localhost:8080/vendor.js',
/* test module needed by angular */
'../node_modules/angular-mocks/angular-mocks.js',
/* the entry for test */
'http://localhost:8080/test-launcher.js',
/* test cases */
'./unit/**/*-spec.js',
/* watch all the source files, but not load them */
{ pattern: '../app/**/*.*', included: false, served: false, watched: true },
],
...
});
};
Let us focus on configuration field files
. Since all the karma test is goint to run in browser so we need to include all the dependences and code, As we ara using webpack to compile our code with various loders, we can not just include the source files directly, so insteat of using local file path, we use local dev server URL like “http://localhost:8080/vendor.js”.
Also, as we are using Webpack’s code spliting, all the page controllers will not be loaded unless we trigger the routing, but that is not what we want to do since we are not doing UI test.
So the simplest solution to work around this is to create another applicaion entry for unit test named test-launcher.js
, which basically just import all the page controllers directly:
import module from './pages/module';
import './pages/dashboard';
import './pages/home';
import './pages/profile';
Now it’s time to see test spec file /test/unit/test-spec.js
:
/* global beforeEach describe it chai inject */
const Assert = chai.assert;
describe('testController', () => {
beforeEach(module('app'));
let $controller;
beforeEach(inject(_$controller_ => {
// The injector unwraps the underscores (_) from around the parameter names when matching
$controller = _$controller_;
}));
describe('$scope.grade', () => {
it('sets the strength to "strong" if the password length is >8 chars', () => {
const $scope = {};
$controller('dashboardCtrl', { $scope });
Assert.equal($scope.name, 'Neekey');
});
});
});
Also E2E Test
E2E test in much easier, since we do not need to care about how code is organized. As UI Test, you only cares about the if the page runs as you expect.
Let’s install protractor first:
$ npm install protractor --save-dev
And set up it’s configuration /test/protractor.conf.js
:
exports.config = {
framework: 'mocha',
seleniumAddress: 'http://localhost:4444/wd/hub',
specs: ['./e2e/route-spec.js'],
};
Since protractor relies on Selenium and Webdriver, you need to install them also:
$ ./node_module/protractor/bin/webdriver-manager update // will install selenium
$ ./node_module/protractor/bin/webdriver-manager start // start selenium, the default address will be: http://localhost:4444/wd/hub
And we can write our spec now, /test/e2e/route-spec.js
:
/* global describe it beforeEach browser element by */
const chai = require('chai');
chai.use(require('chai-as-promised'));
const assert = chai.assert;
describe('Protractor Demo App', () => {
beforeEach(() => {
browser.get('http://localhost:8080');
});
it('default to home', () => {
// Find by model.
const wrapper = element.all(by.css('.home'));
return assert.eventually.equal(wrapper.count(), 1);
});
it('navigate to dashboard', () => {
// Find by model.
const dashboardEl = element.all(by.partialLinkText('dashboard'));
dashboardEl.click();
const wrapper = element.all(by.css('.dashboard'));
return assert.eventually.equal(wrapper.count(), 1);
});
});
We test our dev server page directly, so make sure you start your dev server before your run you e2e test.