Steps for upgrading AngularJS to hybrid Angular 9

psamim

Samim Pezeshki

Posted on June 18, 2020

Steps for upgrading AngularJS to hybrid Angular 9

Update: What happened a year after, read this

AngularJS end of life is near. Its LTS version will end on June 2021. So many projects using AngularJS have started considering the options. One option is to migrate to ReactJS, but some choose to ``upgrade and continue using Angular.

Recently I had to upgrade a 5-year-old codebase from AngularJS 1.4 to Angular 9. It took some effort as I had forgotten the tools which were popular in the olden times! The project used Gulp, Sass, and server-side Jade (Pug).

These are the steps to upgrade.

Install Angular

The first step was to install Angular CLI and bootstrap a new project right next to the old AngularJS app files inside the same repository.

Remove Gulp

Gulp was used to compile Sass files and concatenate JS files. Angular 9 uses Webpack to do this and it is configured by default. I removed gulp and its related files.

Sass files had a single entry. I added the entry file to the Angular's src/styles.scss so that all existing styles are compiled now.

`scss
@import "app/main";
...
`

JS concatenation is not needed anymore because AngularJS is bootstrapped inside Angular and all dependent files are linked and bundled using the import syntax. I will discuss how to bootstrap AngularJS inside Angular in the next step.

If any files outside Angular needs concatenation or Sass compilation, Angular's Webpack can be customized and extended using custom Webpack configurations.

Bootstrap AngularJS

Following documentations here I used ngUpgrade to bootstrap the AngularJS module.

In the app.module.ts file:
`ts
import { UpgradeModule } from "@angular/upgrade/static";
import { angularJsModule } from "../ajs/app";

...

export class AppModule {
constructor(private upgrade: UpgradeModule) {}

ngDoBootstrap() {
this.upgrade.bootstrap(document.body, [angularJsModule.name], {
strictDi: true
});
}
}
`

You can see that I have imported angularJsModule. For it to work I had to refactor the AngularJS app a little bit. I created a new file to define the AngularJS module as angularJsModule and export it to use it in the code above for bootstrapping. Also in the process of refactoring, I decided to put all AngularJS code into a separate directory called ajs inside src.

`ts
import * as angular from "angular";
import ngStorage from "ngstorage";
import ngSanitize from "angular-sanitize";
import ngMaterial from "angular-material";

export const angularJsModule = angular
.module("MyApp", [
ngStorage.name,
ngSanitize,
ngMaterial,
])
.service("toaster", ["$mdToast", ToasterService])
...
`

It is better to define all services or directives you have in this file. If you have other JS files where you have defined services or directives you have to import them too so that they end up in the final bundle.

`ts
import "./components/date-tools.svc.js";
import "./components/toaster.svc.ts";
import "./payment/currency-tools.svc.js";
...
`

It is better to import all needed dependencies in the same file where they are going to be used and avoid globally scoped variables like moment or lodash. If you have correctly imported dependencies they all will be in the final bundle. But sometimes refactoring the code and adding import lodash from 'lodash' to many files takes time so I added them to global scope temporarily to mitigate the issue for now.

`ts
import * as lodash from "lodash";
import * as moment from "moment";

(window as any).moment = moment;
(window as any)._ = lodash;
`

There were remaining scripts that did not work with the above method and which I needed to have in the global scope or need something like HTML script tag so I added them to angular.json file's scripts.

`json
...
"architect": {
"build": {
...
"scripts": [
"node_modules/dropzone/dist/min/dropzone.min.js",
"node_modules/d3/d3.min.js"
]
},
...
`

Previously the project did not use npm to manage the dependencies. It used gulp to concatenate and bundle JS files and they were not pulled from node_modules. The library files were just copied into the project. In the process of removing gulp I also removed all library files from the project and let Webpack and npm manage those.

At this point, the app should have worked.

But I had one specific problem here. Many templates were compiled on the server-side by Jade (Pug) template engine using express's view engine feature. The main problem was the index.html file. Angular and in particular Webpack needs to create index.html to inject scripts and styles. I had to manually edit and convert this file from Jade to pure client-side HTML. Some variables were being injected from the server-side using Jade templates. I had to find other ways to access those variables. I ended up using Handlebars template engine instead of Jade because Handlebars is valid HTML and can be given to Angular. So I created index.html having Handlebar variables. Then Angular injects scripts and styles into it on build time and this file is finally served by express injecting server-side variables through Handlebars template variables. Ideally, I would like to not use Handlebars and passing variables this way seems not clean to me.

Here I configured Angular to use my Handlebars template file:

`json
...
"architect": {
"build": {
...
"options": {
"index": "client/main/src/ajs/index.handlebars",
}
},
...
`

Migrate Routes

The old AngularJS app used ui-router for routing. The good news was that ui-router can share routes between AgnularJS and Angular using angular-hybrid.

In the app.module.ts:
`ts
import { UIRouterUpgradeModule } from "@uirouter/angular-hybrid";
import { routerStates } from "./router-states";

...

@NgModule({
imports: [
UIRouterUpgradeModule.forRoot(routerStates),
],
...
})
export class AppModule {}
`

And in the router-states.ts file one route is using AngularJS and another route is using Angular:
`ts
import { VisaComponent } from "./trip-details/visa/visa.component";

...

export const routerStates = {
states: [
{
name: "documents",
url: "/documents",
templateUrl: "/views/documents",
controller: "DocumentsController",
redirectTo: "documents.manage"
},
{
name: "trip-details.visa",
url: "/visa",
component: VisaComponent,
}
...
`

Change Deployment Scripts

Finally, I had to change deployment scripts used by npm and CI/CD pipelines to use ng build command.

Development Server

There was still one specific problem to this project setup. In this project express server was set up in the same repository for both serving the front-end (and its server-side Jade templates) and also serving the back-end API endpoints. Angular has its own server used in development mode with hot-reload and specific configurations. For development, I wanted to have both the old express server (for APIs and legacy Jade templates) and the new Angular development server running at the same time.

So I used http-proxy-middleware. express can serve the APIs as before and also proxy requests to Angular development server running on port 4200 only in development mode.

in express-app.ts:
`ts
import { createProxyMiddleware } from "http-proxy-middleware";

...

if (process.env.NODE_ENV === "development") {
app.use(
["/client", "/sockjs-node", "/assets"],
createProxyMiddleware({
target: "http://localhost:4200",
ws: true,
pathRewrite: {
"^/assets": "/client/assets"
}
})
);
} else {
app.use("/client", express.static(path.join(dirname, "..", "public")));
app.use(
"/assets",
express.static(path.join(
dirname, "..", "public", "assets"))
);
}

`

The ideal setup would be to separate concerns and put the back-end code into its own separate repository but that is a separate issue.

So, in the end, the development setup is something like this:

Alt Text

In the production setup, there is no Angular dev server.

Sharing Services and Components

To use a service or component from Angular in AngularJS we need to downgrade those services and components. In the same file that I defined the angularJsModule:
`ts
import {
downgradeComponent,
downgradeInjectable
} from "@angular/upgrade/static";

...

.module("GlobalWorldApp", [
...
])
.directive(
"nextPaymentPlans",
downgradeComponent({ component: PlansComponent })
)
.factory("nextPayment", downgradeInjectable(PaymentService) as any);

So
nextPaymentis accessible as an AngularJS service and` can be used in AngularJS templates. So now we can start moving components and services from AngularJS to Angular gradually.

To access the AngularJS's rootScope from Angular we can inject it as a service, but better to avoid it if possible.
`ts
import { IRootScopeService } from "angular";
...
constructor(
@Inject("$rootScope") private _rootScope: IRootScopeService) {}
...
`

Good to mention that to properly use the rootScope we need to watch it.

Linter

Setting up the linter is a separate issue and process from upgrading AngularJS. But I just want to mention here that I decided to remove tslint and replace it with eslint. Mainly because tslint is deprecated and also because of the smoother integration of eslint with prettier and other tools. I also love and use prettier everywhere I can.

Upgrade

After all these steps we can just start upgrading the app! I mean now we can let AngularJS and Angular components co-exist. So we can rewrite and move components from AngularJS to Angular gradually. The full upgrade takes months or even may never come. I think we will develop new parts of the app in Angular and only rewrite the AngularJS parts which are buggy or need refactoring.

I hope this helps others and future me trying to upgrade AngularJS apps.

💖 💪 🙅 🚩
psamim
Samim Pezeshki

Posted on June 18, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related