Thank you to InRhythm teammates Mike Fisher and Khaled Mohamed for their invaluable contribution to this article.
Managing a gradual, staged transition from Angular 1 to Angular 2 or React is not a pipe dream. Upgrading does not have to mean a full rewrite, nor a significant disruption to the release cycle. Although the risk can be high, you’re probably reading this article because you know the transition is worth it for the superior performance and smaller bundle sizes of the next generation frameworks.
Additional motivation to upgrade might be as a justification to ditch the old Gulp/Bower build process for Webpack 2 + ES6 Modules in all its tree-shaking glory… or even because it has become difficult to hire and retain top talent when engineers don’t want to work on legacy Angular 1 products (you did see the SO developer survey, right?).
Fortunately, there is a simple migration path that can be pursued in three discrete, meaningful and low-risk steps.
- Use Webpack (Browserify, Rollup, etc.) to build your application bundle.
- Convert stateful services and controllers to ES6 classes.
- Abstract the Angular module system out of your code following the ui-router pattern.
Each of above steps will move you tangibly closer to Angular 2 or React and provide real value and flexibility along the way. I hope to prove to you that the process is achievable incrementally, without significantly disrupting the release cycle.
First things first… Prerequisites
This post assumes that you are already familiar with modern build tools like Webpack, Rollup or Browserify and aware of the benefits of upgrading to Angular 2 or React. As such, I’m not going to dive into Webpack configurations nor into the specifics of next-generation frameworks. Instead, I’ll focus on how to sequence the transition in order to minimize disruption to your release schedule.
If you’re working with a legacy Angular 1 codebase, there’s a decent chance that the starting point was John Papa’s famous generator-hottowel. I’m going to start with a freshly generated hottowel project to demonstrate the migration.
Step 1: Integrate Webpack into your build process
Hottowel uses a combination of IIFEs, Gulp, Bower, wiredep, and gulp-useref to read and concatenate the source files into an application bundle. It’s a brilliant pattern that leverages the Bower dependency tree and Angular’s module system and dependency injection to correctly build your application. It works incredibly well, but it in 2017 is showing it’s age. The lack of a proper bundler means that recent, game changing developments like native JavaScript modules, improvements to IDE tooling, tree shaking, live-refresh/hot-module-reloading, and CSS modules are almost impossible to support. Furthermore, Angular 2 and React presuppose the use of a bundler. To move forward, you’ll have to ditch your old gulpfile.
You do not need to modify your existing source code to transition to Webpack.
Amazingly, you do not need to modify your existing source code to transition to Webpack. You simply need to create an application entry point and add an index.js file in each subdirectory of your application to require all of your source files.
For example, this is the directory structure that hottowel generates:
First, create a file src/client/app/index.js
with the following contents:
// src/client/app/index.js import './app.module.js'; import './admin'; import './blocks'; import './core'; import './dashboard'; import './layout'; import './widgets';
This file is the entry point to your build process. It represents the root of the tree that comprises your application. Inside ./admin
and ./blocks
and every other subdirectory there is an index.js
file which will import all of the source code in the same directory in addition to any subdirectories.
For example, src/client/app/admin/index.js
will look like this:
// src/client/app/admin/index.js import './admin.controller.js'; import './admin.html'; import './admin.module.js'; import './admin.route.js';
and src/client/app/blocks/index.js
which contains three subdirectories and no files should look like this:
// src/client/app/blocks/index.js import './exception'; import './logger'; import './router';
When complete, a chain of index.js
files and import
statements will link your entire application. By pointing Webpack at src/client/app/index.js
it can generate your entire application bundle. The directory structure should now look like this, with an index.js
in src/client/app
, src/client/app/admin
and in every other subdirectory under src/client/app
:
If it’s unclear how this pattern works, I recommend reading up on module loaders and these resources on ES6 import
and Node/CommonJS module resolution.
Step 2: Embrace ES6 modules, upgrade Angular services and controllers to ES6 classes
Now that your bundler is in place, you can begin to strip away some of the cruft necessitated by simply concatenating source files. As an example, let’s take a look at the AdminController at src/client/app/admin/admin.controller.js
.
// src/client/app/admin/admin.controller.js (function() { 'use strict'; angular .module('app.admin') .controller('AdminController', AdminController); AdminController.$inject = ['logger']; /* @ngInject */ function AdminController(logger) { var vm = this; vm.title = 'Admin'; activate(); function activate() { logger.info('Activated Admin View'); } } })();
Immediately, there are a few things we can do. First, we can remove the wrapping IIFE since Webpack will package each file in its own closure. We can also remove ‘use strict’ because ES6 modules are executed in strict mode by default. Let’s also import angular
because we want to be explicit about the dependencies of every file. Importing 3rd party libraries instead of treating them as globals allows Webpack to extract them into a vendor or commons bundle on which you can set appropriate caching headers, potentially saving returning users hundreds of kilobytes of downloaded content and multiple network calls.
After these changes admin.controller.js
should now look like this:
// src/client/app/admin/admin.controller.js import angular from 'angular'; angular .module('app.admin') .controller('AdminController', AdminController); AdminController.$inject = ['logger']; /* @ngInject */ function AdminController(logger) { var vm = this; vm.title = 'Admin'; activate(); function activate() { logger.info('Activated Admin View'); } }
Next, we want to convert the services and controller to ES6 classes. Both React and Angular 2 encourage implementing application components with ES6 classes. If you’re unfamiliar with ES6 classes, I highly recommend reading up on them and on prototypal inheritance in JavaScript in general. As an ES6 class, AdminController
looks like this:
// src/client/app/admin/admin.controller.js import angular from 'angular'; angular .module('app.admin') .controller('AdminController', AdminController); class AdminController { static $inject = ['logger']; constructor(logger) { this.logger = logger; } $onInit() { this.title = 'Admin'; this.logger.info('Activated Admin View'); } }
A few things to consider
There are a few subtle patterns to recognize here. First, we ditched the /* ngInject */
annotation in favor of a static class property.
Second, there is no private scope. With ES6 classes, all members are public (for now). The body of an ES6 class can only contain static and prototype property declarations. This means that the initialization logic in the body of the AdminController
function needs to be moved into the constructor or into the new $onInit
lifecycle method introduced in Angular 1.5.
* Technically, to take advantage of lifecycle methods we need to convert this controller into a component. Take a look at Todd Mottos upgrade guides to familiarize yourself with the new patterns and for tips on easing the transition to Angular 2.
Finally, vm = this;
, also known as “controller as” syntax has been eliminated. Instance and prototype properties and methods are accessible on this
so we don’t need that workaround anymore. This is a mixed blessing because vm
helped us avoid JavaScript’s notoriously confusing rules about resolving this
. When you refactor to a class you’ll need to decide on a pattern for how to keep this proper this
in scope. Concepts to investigate include arrow functions (lexical scoping of this
), binding prototype methods to the instance in the constructor, class property initializers, and the proposed bind operator ::
.
Take your time–you don’t have to do this all at once
Take your time as you refactor your services and controllers into classes and your directives into components–you don’t have to do this all at once. In fact, you can write new code with the new patterns and leave your old code alone if it’s working just fine. The point of Step 1 was to give you the flexibility to refactor at your own pace.
Step 3: Abstract away the Angular module system and decouple your source code from the framework
If you’ve made it this far, your formerly legacy codebase is starting to look a little more fresh. You now have a proper build process and you can tell prospective hires that yes, your company uses the latest ES6 features ????.
The final step is to decouple your source code as much as possible from the framework. This can also be done incrementally, file-by-file as time permits.
Utilizing the following patterns will breathe new life into your legacy application while setting the stage for an eventual migration to the framework of your choosing. By the end, you might find that you don’t need to switch frameworks at all!
- Use the ui-router pattern to decouple your codebase form the
ng-module
system. - Replace Angular helper functions with Lodash. Tree shaking dramatically reduces the cost of utilizing small portions of 3rd party libraries.
- Write pure functions and turn any Angular service that does not manage private state into a plain old JavaScript library. If a service isn’t stateful then it’s just business logic. Treat your business logic like an external dependency and
import
functionality as needed. - Use Angular 1.5 components for everything. Break up large templates into components using the presentational/container component pattern. This pattern was popularized by the React community but works just as well with Angular 1.5 components.
- Ditch “scope soup”,
$broadcast
, and 2-way binding in favor of 1-way component bindings and framework-independent state management libraries like Redux and RxJS that support unidirectional data flow. Redux and RxJS are used extensively in React, Vue and Angular 2 development and helper libraries for Angular 1 exist (ng-redux, rx.angular.js).
If you’re serious about making the transition to Angular 2, then thoroughly read the official upgrade guide and consider developing a hybrid application if you really can’t afford the risk of a hard cutover.
UI-Router pattern
Of all the recommendations above, the ui-router pattern for eliminating the Angular module system might be the least familiar, though I’ve found it essential for easing the migration away from Angular 1.
The ui-router team put together an amazing sample application that shows off the power of their library along with demonstrating beautifully how to build a modern, modular application. One thing it illustrates admirably is how to abstract the Angular wiring into a single file in the application.
Currently, every file in our example application is still tightly coupled to Angular because they contain this little preamble or something like it:
import angular from 'angular'; angular .module('app.admin') .controller('AdminController', AdminController);
This repetition just feels wrong when Don’t Repeat Yourself is a programmer’s mantra. To DRY up this anti-pattern, the ui-router team followed a three-pronged strategy:
- Maintain a single Angular module for the entire application. You may have heard that it’s helpful to create many modules for your application because it aids code reuse. However, now that JavaScript has real modules, we don’t need Angular’s module system anymore.
- Convert ngModules into mini-libraries of plain JavaScript classes, functions and objects to bundle related functionality.
- Expose a helper method to wire the single module and the mini-libraries into an Angular 1 application.
Applying this to our sample hottowel application, we’ll modify src/client/app/app.module.js
to create the single Angular module and expose the helper function that handles registering application components with the injector
.
Creating the single module and helper method
After completing step 2, app.module.js
should look like this:
// src/client/app/app.module.js import angular from 'angular'; angular.module('app', [ 'app.core', 'app.widgets', 'app.admin', 'app.dashboard', 'app.layout' ]);
After applying the ui-router refactor, app.module.js
now looks like this:
// src/client/app/app.module.js /* Adapted from http://github.com/ui-router/sample-app-ng1/blob/master/app/bootstrap/ngmodule.js */ import angular from 'angular'; /** * Creates the module 'app' and exports it for other modules to import and * register providers and controllers */ export const ngModule = angular.module('app', [ /* List 3rd party Angular dependencies */ ]); const BLANK_MODULE = { states: [], components: {}, directives: {}, services: {}, filters: {}, configBlocks: [], runBlocks: [], constants: {}, values: {} }; /** * Register each app module's states, directives, components, filters, services, * constants, values, and config/run blocks with the `ngModule. * * @param ngModule the `angular.module()` object * @param appModule the feature module consisting of components, states, services, etc */ export function loadModule(ngModule, appModule) { const module = Object.assign({}, BLANK_MODULE, appModule); ngModule.config(['$stateProvider', $stateProvider => module.states.forEach(state => $stateProvider.state(state))]); Object.keys(module.components).forEach(name => ngModule.component(name, module.components[name])); Object.keys(module.directives).forEach(name => ngModule.directive(name, module.directives[name])); Object.keys(module.services).forEach(name => ngModule.service(name, module.services[name])); Object.keys(module.filters).forEach(name => ngModule.filter(name, module.filters[name])); Object.keys(module.constants).forEach(name => ngModule.constant(name, module.constants[name])); Object.keys(module.values).forEach(name => ngModule.value(name, module.values[name])); module.configBlocks.forEach(configBlock => ngModule.config(configBlock)); module.runBlocks.forEach(runBlock => ngModule.run(runBlock)); return ngModule; }
The export ngModule
is our single application module. 'app.core'
, 'app.admin'
, etc. are gone. In their place we will attach every provider to ngModule
via the loadModule
function.
loadModule
is how we wire up the Angular application. It accepts two arguments, an Angular module and an object describing a mini-library composed of ui-router state definitions, services, factories, controllers, etc. When invoked, loadModule
will iterate through each collection and dictionary, registering the providers within on the module. Any class, object or function that we want to expose as an injectable in our Angular app we can wire up with via loadModule
. With this framework in place, we can remove the Angular preamble from the beginning of every file.
Refactoring the Admin module into a mini-library
Let’s see how this can be applied to src/client/app/admin
, converting it from an Angular module to a mini-library.
Before, index.js
and admin.controller.js
look like this:
// src/client/app/admin/index.js import './admin.controller.js'; import './admin.html'; import './admin.module.js'; import './admin.route.js'; // src/client/app/admin/admin.controller.js import angular from 'angular'; angular .module('app.admin') .controller('AdminController', AdminController); class AdminController { static $inject = ['logger']; constructor(logger) { this.logger = logger; } $onInit() { this.title = 'Admin'; this.logger.info('Activated Admin View'); } }
After the refactor, they look like this:
/ src/client/app/admin/index.js import { ngModule, loadModule } from '../app.module.js'; import AdminController from './admin.controller.js'; import adminRoute from './admin.route.js'; Modified to return a ui-router state definition object // import './admin.html'; The template will be imported in the route as a string // import './admin.module.js'; Deleted. This file is no longer needed const adminModule = { controllers: { AdminController }, states: { adminRoute } } loadModule(ngModule, adminModule); // src/client/app/admin/admin.controller.js export default class AdminController { static $inject = ['logger']; constructor(logger) { this.logger = logger; } $onInit() { this.title = 'Admin'; this.logger.info('Activated Admin View'); } }
When we invoke loadModule(ngModule, adminModule)
it will register the admin module’s components on the main 'app'
module. Applying this pattern to the rest of the project, we have now removed the Angular wiring from nearly every file in the application! Our source code is nearly clear of coupling to the framework and it’s in much better shape that when we started out.
What’s next?
It would be easy to mistake the refactored admin.module.js
for something that’s not Angular 1. With most of the framework ceremony stripped away, it’s clear that we could quickly factor AdminController
into a React component that expects a logger
prop and whose render
method returns the template from admin.html
.
If you decided opted to replace $scope
inheritance and $rootscope.$broadcast
with ngRedux then moving to React becomes a task that’s more tedious than complex. You can also look into ngUpgrade which supports loading hybrid ng1/ng2 applications, giving you even more granular control over how you fully leave Angular 1.
Extra credit: refactor your source code to TypeScript
TypeScript can be a game changer, eliminating whole classes of errors that generally aren’t diagnosable until runtime with plain JavaScript. If you’re unfamiliar with statically typed languages and the benefits thereof, I recommend starting with the TypeScript documentation to see how it can make your projects more resilient.
An incremental transition to TypeScript is simple with a Webpack build process. First, change the extension of any file that you want to write in TypeScript to .ts
and then add ts-loader to your Webpack config, configured to resolve and transpile files with a .ts
extension. Since TypeScript is a superset of JavaScript you can mix and match as you please.
Additional Reading
Below are links to articles and repositories which you might find helpful when planning your upgrade. Please post any helpful articles or migration advice in the comments.
- The wonderful ui-router example application
- ngReact to register React components as ng1 directives
- Managing state with Redux and Angular and an example application
- Comparison of major frameworks including Vue, React, Angular 1/2, and Ember
- Using TypeScript with React