Wrapping 3rd-Party Scripts In Angular Services In Case They Get Blocked In Angular 9.1.4
InVision, like all SaaS (Software as a Service) apps, include a few 3rd-party libraries to help with things like error-logging, in-app support (such as live-chat), and feature-tracking so that we can understand how people use the application. Increasingly, however, I'm seeing browsers and browser extensions block externally-loaded scripts. This can lead to unexpected errors; and, often-times, cryptic code that tries to handle potentially unavailable libraries. The more this happens, the more strongly I feel that 3rd-party scripts should be wrapped in Angular services such that we can encapsulate the 3rd-party library, demoting it to a mere "implementation detail". As such, I wanted to explore what this might look like in Angular 9.1.4.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
What I want to get away from is Angular application code that looks like this:
window.tracker = ( window.tracker || [] );
window.tracker.push({
event: "UserAction",
data: {}
});
... where tracker
is some global library that may or may not exist at the time this application logic is called. Furthermore, I'd love to get away from this use of Array
data-types as a means to consume libraries before they exist. And, of course, not all 3rd-party APIs are designed to have this Array-based abstraction; so, this isn't even a universally-available approach.
Ideally, this tracker
library should be wrapped in an Angular service that exposes an API that looks, feels, and behaves like the 3rd-party library; but, that may or may not use the 3rd-party library under the hood, encapsulating it as an "implementation detail":
export interface Tracker {
identify( user: UserIdentity ) : void;
track( eventType: string, eventData?: EventData ) : void;
}
To see what I mean, I've created an Angular service called ThirdPartyTracker
which is going to proxy a library that may or may not be loaded at window.tracker
. The ThirdPartyTracker
service exposes an API that captures the intent of the 3rd-party library; and, in my case, falls-back to a "mock library" implementation under the hood if the 3rd-party library doesn't exist. I'm falling-back to a mock API just to keep all consuming code as simple as possible.
The following code contains a few things:
The augmentation of the
global
name-space to include thetracker
library so that the TypeScript compiler doesn't complain. In a true production application, such a type-definition may be included automatically from yournode_modules
; or, it may be available through the Definitely-Typed project.The
ThirdPartyTracker
Angular service which proxies / wraps thetracker
library, hiding it from the rest of the Angular application as a mere implementation detail.A "no operation" (No-Op) implementation of the
tracker
library,NoopTracker
, which implements the API in a way that is safe to consume but is, essentially, useless from a business-intelligence standpoint.
The most interesting part of the ThirdPartyTracker
is that it wraps access to the window.tracker
library in a Promise
:
// Import the core angular services.
import { Injectable } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// So that TypeScript doesn't complain, we're going to augment the GLOBAL / WINDOW
// name-space definition to include the Tracker API. This also provides us with a place
// to actually DOCUMENT the API so that our developers aren't guessing about what's
// available on the library.
declare global {
var tracker: Tracker;
}
// The following interfaces both help with the Tracker definition as well as with the
// type annotations that we're going to use in our proxy API.
export interface Tracker {
identify( user: UserIdentity ) : void;
track( eventType: string, eventData?: EventData ) : void;
}
export interface UserIdentity {
id: number;
name: string;
fields: {
[ key: string ]: any;
}
}
export interface EventData {
[ key: string ]: any;
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Injectable({
providedIn: "root"
})
export class ThirdPartyTracker {
private trackerPromise: Promise<Tracker> | null;
// I initialize the third-party-tracker service, which proxies a 3rd-party script
// that MAY HAVE BEEN LOADED in the page or MAY HAVE BEEN BLOCKED by an ad-blocker.
constructor() {
this.trackerPromise = null;
}
// ---
// PUBLIC METHODS.
// ---
// I identify the application user.
public identify( user: UserIdentity ) : void {
this.getTracker().then(
( tracker ) => {
console.info( "Identifying user,", user.name, "." );
tracker.identify( user );
}
);
}
// I track the given event for the previously-identified user.
public track( eventType: string, eventData?: EventData ) : void {
this.getTracker().then(
( tracker ) => {
console.info( "Tracking user action,", eventType, "." );
tracker.track( eventType, eventData );
}
);
}
// ---
// PRIVATE METHODS.
// ---
// I return a Promise that resolves with a Tracker API (which may be the 3rd-party
// library or a mock API representation).
private getTracker() : Promise<Tracker> {
if ( this.trackerPromise ) {
return( this.trackerPromise );
}
if ( window.tracker ) {
return( this.trackerPromise = Promise.resolve( window.tracker ) );
}
// A "complete" status indicates that the "load" event has been fired on the
// window; and, that all sub-resources such as Scripts, Images, and Frames have
// been loaded.
if ( window.document.readyState === "complete" ) {
// If this event has fired AND the 3rd-party script isn't available (see IF-
// condition BEFORE this one), it means that the 3rd-party script either
// failed on the network or was BLOCKED by an ad-blocker. As such, we have to
// fall-back to using a mock API.
return( this.trackerPromise = Promise.resolve( new NoopTracker() ) );
}
// ASSERT: If we made it this far, the document has not completed loading (but it
// may be in an "interactive" state which is when I believe that the Angular app
// gets bootstrapped). As such, we need bind to the LOAD event to wait for our
// third-party scripts to load (or fail to load, or be blocked).
this.trackerPromise = new Promise<Tracker>(
( resolve ) => {
window.addEventListener(
"load",
function handleWindowLoad() {
// At this point, the 3rd-party library is either available or
// it's not - there's no further loading to do. If it's not
// present on the global scope, we're going to fall-back to using
// a mock API.
resolve( window.tracker || new NoopTracker() );
}
);
}
);
return( this.trackerPromise );
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// I provide a mock API for the 3rd-party script. This just allows the consuming code to
// act as though the library is available even if it failed to load (example, it was
// blocked by an ad-blocker).
class NoopTracker implements Tracker {
constructor() {
console.warn( "Tracker API not available, falling back to mock API." );
}
public identify( user: UserIdentity ) : void {
// NOOP implement, nothing to do....
}
public track( eventType: string, eventData?: EventData ) : void {
// NOOP implement, nothing to do....
}
}
As you can see, internally to the ThirdPartyTracker
service, access to the underlying library is hidden in the getTracker()
method. This method returns a Promise
that may not resolve until after the window.load
event is triggered. And, when it does resolve, it may resolve with the actual window.tracker
library; or, if that's unavailable (because it was blocked by an ad-blocker, for example), it will resolve with the No-Op implementation of the 3rd-party script.
This way, most of the ThirdPartyTracker
implementation doesn't need to know if the window.tracker
library exists. And, the rest of our Angular application can remain blissfully unaware as well.
To see what I mean, let's now look at the App component, which is going to attempt to track some user activity. Of course, it will be using our ThirdPartyTracker
abstraction, not the root window.tracker
library:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { ThirdPartyTracker } from "./third-party-tracker";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
template:
`
<p>
Welcome to my sweet Angular app!
</p>
<p>
<a (click)="doThis()">Do This</a> ,
<a (click)="doThat()">Do That</a>
</p>
`
})
export class AppComponent {
private tracker: ThirdPartyTracker;
// I initialize the app component.
constructor( tracker: ThirdPartyTracker ) {
this.tracker = tracker;
this.tracker.identify({
id: 4,
name: "Ben Nadel",
fields: {
role: "Admin",
company: "Oh Chickens Entertainment!"
}
});
}
// ---
// PUBLIC METHODS.
// ---
public doThat() : void {
// The user would be doing something .....
this.tracker.track( "App.Main", { Action: "DoThat" } );
}
public doThis() : void {
// The user would be doing something .....
this.tracker.track( "App.Main", { Action: "DoThis" } );
}
}
This AppComponent
doesn't do anything other than inject the ThirdPartyTracker
service and call some if its methods.
To pull this all together, I then include a silly little window.tracker
implementation in my index.html
page:
<!--
This is the 3rd-party script that we're loading in order to get [window.tracker].
It may or may NOT be loaded by the time our Angular app is bootstrapped.
-->
<script async src="./assets/tracker.js"></script>
... which contains no real logic:
window.tracker = {
identify: function() {
console.warn( "GLOBAL TRACKER[ Identify ]:", arguments );
},
track: function() {
console.warn( "GLOBAL TRACKER[ Track ]:", arguments );
}
};
Now, if we run this Angular application and click around in the App components, we can see that our ThirdPartyTracker
service seamlessly proxies our window.tracker
library:
As you can see in the Console logging, our ThirdPartyTracker
service logs the API calls and then the underlying window.tracker
library does as well. Of course, the "happy path" is never the thing we have to "worry" about. Really, the whole point of this is to keep our application logic simple even in the "sad path" scenario.
To mimic a "sad path" scenario, let's add tracker.js
to the Chrome Dev Tools resource blocking and try to re-run the demo:
As you can see, in this demo, our Chrome Dev Tools are blocking the browser from loading the tracker.js
Script file. And yet, our Angular application continues to function normally. This is because the rest of the Angular application is consuming our always available Service class, ThirdPartyTracker
, which completed decoupled the application from the very notion of the underlying library.
The Services that you write for your Angular application are dependable and well documented (in so much as you can open up and read the non-compiled files). 3rd-party scripts, on the other hand, are often cryptic, poorly documented, and - increasingly - getting blocked by the browser. As such, I'm becoming more included to try and wrap all 3rd-party libraries in Angular services so that I can keep the consuming code simple, consistent, and error-free.
Epilogue on the New Angular Ivy Compiler
We've all been eagerly awaiting the new Angular Ivy compiler in Angular 9. And, now that we have it, I'm noticing significantly reduced payload sizes. A demo like this, in Angular 8 (and below) probably would have been as much as 600kb. But, this demo transfers a mere 50kb:
Outstanding work, Angular team! And, I can only imagine that this number will continue to go down now that the big compiler refactoring ground-work has been laid. What an exciting time to be an Angular developer!!
Want to use code from this post? Check out the license.
Reader Comments