Creating An Angular 1.x $location-Inspired RetroLocation Service In Angular 4.4.0-RC.0
Yesterday, I took a look at how to change the "hash" or "fragment" value of the browser URL using the Angular Location service. It's a bit frustrating that this feature seems to be undocumented. But, more than that, it's a bit frustrating that the Location service in Angular 4.x seems so underdeveloped when compared to the $location service in Angular 1.x. In Angular 1.x, the $location service could access and mutate all components of the URL independently. In Angular 4.x, the Location service reads as if it were created with whatever was minimally necessary to drive the Router (just my theory). As such, I thought it would be a fun exploration to create a $location-inspired service in Angular 4.x that could access and mutate the URL pathname, search, and hash values independently. Since this has a touch of nostalgia to it, I'm calling it RetroLocation.
Run this demo in my JavaScript Demos project on GitHub.
When I first started writing the code for my RetroLocation, I ran up against a few roadblocks. Specifically, I couldn't figure out how to access the host, port, protocol, or pathname of the non-application portion of the browser URL. Obviously, I could have reached into the window.location object; but, I wanted to try and maintain proper encapsulation. As such, I wanted to rely solely on the LocationStrategy provided by the dependency-injection system.
And, because the LocationStrategy only exposes the portion of the URL that pertains to the application (ie, the hash if using the HashLocationStrategy or the post-APP_BASE_HREF value if using the PathLocationStrategy), I had to limit my RetroLocation methods to the those concerned with the application. In the end, I could only figure out how to implement the following $location-inspired methods:
- url( [url] ) - Gets or sets the URL.
- path( [path] ) - Gets or set the pathname.
- search( [search [, paramValue]] ) - Gets or sets the search (or portion of the search).
- hash( [hash] ) - Gets or sets the hash / fragment.
Furthermore, RetroLocation exposes a .subscribe() method like the core Location service; but, unlike the core Location service which emits a PopStateEvent only when the location is changed manually, the RetroLocation will emit a PopStateEvent even when the location is changed programmatically (by the RetroLocation). I could swear that the $locationChangeStart event, in Angular 1.x, fired regardless of the root-cause; and, I wanted to create some parity around that event broadcast.
Let's look at how the RetroLocation can be consumed. I tried to create a simple demo that would showcase the ability to get and set independent portions of the browser URL:
// Import the core angular services.
import { Component } from "@angular/core";
import { PopStateEvent } from "@angular/common";
// Import the application components and services.
import { RetroLocation } from "./retro-location";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface SearchInput {
[ key: string ]: string | number | null | boolean;
}
@Component({
selector: "my-app",
styleUrls: [ "./app.component.css" ],
template:
`
<h2>
Change Path Components Independently
</h2>
<h3>
Pathname
</h3>
<ul>
<li>
<a (click)="setPath( '/home.htm' )">/home.htm</a>
</li>
<li>
<a (click)="setPath( 'about-us.htm' )">about-us.htm</a>
</li>
</ul>
<h3>
Search
</h3>
<ul>
<li>
<a (click)="setSearch({ id: 1 })">id: 1</a>
</li>
<li>
<a (click)="setSearch({ source: 'demo', utm: 'k7z', isValid: true, deleteMe: 'yes' })">
source: 'demo', utm: 'k7z', isValid: true, deleteMe: 'yes'
</a>
</li>
</ul>
<h3>
Hash
</h3>
<ul>
<li>
<a (click)="setHash( '#company' )">#company</a>
</li>
<li>
<a (click)="setHash( 'team' )">team</a>
</li>
</ul>
`
})
export class AppComponent {
public retroLocation: RetroLocation;
// I initialize the app component.
constructor( retroLocation: RetroLocation ) {
this.retroLocation = retroLocation;
// Subscribe to the PopStateEvents triggered by the RetroLocation. Unlike other
// location implementations, which only trigger events when the URL is changed
// outside of the service, the RetroLocation will trigger a PopStateEvent when
// the location is changed programmatically. This way, other components can
// listen for this event even if the location was changed by another part of
// the application.
// --
// NOTE: Only emits "popstate" events, not "hashchange".
this.retroLocation.subscribe(
( event: PopStateEvent ) : void => {
this.logPopStateEvent( event );
}
);
this.retroLocation.url( "/initial.htm#first-time" );
}
// ---
// PUBLIC METHODS.
// ---
// I set the location hash independently of the rest of the location.
public setHash( newHash: string ) : void {
console.warn( "Setting hash:", newHash );
this.retroLocation.hash( newHash );
}
// I set the location pathname independently of the rest of the location.
public setPath( newPath: string ) : void {
console.warn( "Setting path:", newPath );
this.retroLocation.path( newPath );
}
// I set the location query-string independently of the rest of the location.
public setSearch( newSearch: SearchInput ) : void {
console.warn( "Setting search:", newSearch );
this.retroLocation.search( newSearch );
// If the given search collection contains a "deleteMe" key-value pair, then
// let's perform a subsequent navigation to set that value to null. This is to
// demonstrate that you can set individual values; and, that setting a value to
// null will remove it from the location.
if ( newSearch.deleteMe ) {
console.warn( "Setting the 'deleteMe' query-string param to NULL." );
this.retroLocation.search( "deleteMe", null );
}
}
// ---
// PRIVATE METHODS.
// ---
// I log the RetroLocation "popstate" event. This is triggered whenever the browser
// location is changed programmatically by the application (using RetroLocation) or
// manually by the user (or an HREF link).
private logPopStateEvent( event: PopStateEvent ) : void {
console.group( "Pop State Event" );
console.log( "Event:", event.url );
// Read the location components independently from the RetroLocation.
console.log( "Url:", this.retroLocation.url() );
console.log( "Path:", this.retroLocation.path() );
console.log( "Search:", this.retroLocation.search() );
console.log( "Hash:", this.retroLocation.hash() );
console.groupEnd();
}
}
As you can see, I have links in the app component that allow us to change the path, search, and hash values independently. And, when RetroLocation emits a navigation event, I then read and log the path, search, and hash values independently. Notice that the search value isn't just a String - it's a set of key-value pairs. And, if we run this app and click on a few of the links, we get the following output:
As you can see, as we click on the links, the selected portion of the URL is changed independently of the rest of the location. Also notice that we can read the portions of the URL independently. And, when the search is read, it is returned as a set of key-value pairs, not as a compiled query-string.
There's a decent amount of code in my RetroLocation service, so I won't review it. Just take note that it doesn't access the window.location global object directly. Instead, it relies on the LocationStrategy which it receives from the dependency-injection (DI) container. As such, it maintains loose-coupling with the underlying platform location implementation.
One of the most interesting things about this experiment - for me - was the method signatures. Since many of the methods are both getters and setters, I had to overload the method signatures using TypeScript. This was the first time that I'd done this, and it took me a bunch of trial-and-error to get working. The rest of the code is just parsing, normalizing, and encoding string values.
// Import the core angular services.
import { Injectable } from "@angular/core";
import { LocationStrategy } from "@angular/common";
import { PopStateEvent as RetroPopStateEvent } from "@angular/common";
import { Subject } from "rxjs/Subject";
import { Subscription } from "rxjs/Subscription";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface Search {
[ key: string ]: string;
}
export interface SearchInput {
[ key: string ]: SearchInputValue;
}
type SearchInputValue = string | number | boolean | null;
interface UrlSegments {
pathname: string;
search: string;
hash: string;
}
namespace SubscribeHandlers {
export interface OnNext {
( value: RetroPopStateEvent ) : void;
}
export interface OnThrow {
( error: any ) : void;
}
export interface OnComplete {
() : void;
}
}
@Injectable()
export class RetroLocation {
private locationStrategy: LocationStrategy;
private popStateEvents: Subject<RetroPopStateEvent>;
// I initialize the retro-location service. This provides an API that is
// reminiscent of the AngularJS 1.x $location service.
constructor( locationStrategy: LocationStrategy ) {
this.locationStrategy = locationStrategy;
this.popStateEvents = new Subject();
// When the underlying location implementation emits a PopStateEvent, we
// want to communicate that out to any clients that may be subscribed to the
// RetroLocation events.
this.locationStrategy.onPopState(
( event: PopStateEvent | HashChangeEvent ) : void => {
// Since RetroLocation will emit this event when the location is
// changed programmatically, we want to limit it to a single event-type,
// popstate, in order to make things a bit more predictable.
if ( event.type === "popstate" ) {
this.popStateEvents.next({
url: this.url(),
pop: true,
type: "popstate"
});
}
}
);
}
// ---
// PUBLIC METHODS.
// ---
// I set or get the location hash.
public hash( newHash: string ) : RetroLocation;
public hash() : string;
public hash( newHash?: string ) : RetroLocation | string {
var urlSegments = this.getUrlSegments();
// Return the existing hash.
if ( newHash === undefined ) {
return( this.normalizeHash( this.decodeHash( urlSegments.hash ) ) );
}
// Set the new hash.
urlSegments.hash = this.encodeHash( this.normalizeHash( newHash ) );
this.navigate( urlSegments );
return( this );
}
// I set or get the location path.
public path( newPath: string ) : RetroLocation;
public path() : string;
public path( newPath?: string ) : RetroLocation | string {
var urlSegments = this.getUrlSegments();
// Return the existing path.
if ( newPath === undefined ) {
return( this.normalizePath( this.decodePath( urlSegments.pathname ) ) );
}
// Set the new path.
urlSegments.pathname = this.encodePath( this.normalizePath( newPath ) );
this.navigate( urlSegments );
return( this );
}
// I set or get the location search (query-string). When setting the search value,
// if the value of any key-value pair is null, the key will be omitted from the
// resultant search. If the value of the key-value pair is true, the key will be
// included without any value.
public search( key: SearchInput ) : RetroLocation;
public search( key: string, value: SearchInputValue ) : RetroLocation;
public search() : Search;
public search( key?: SearchInput | string, value?: SearchInputValue ) : RetroLocation | string | Search {
var urlSegments = this.getUrlSegments();
// Return the existing search collection.
if ( key === undefined ) {
return( this.parseQueryString( urlSegments.search ) );
}
// Set the entire search collection.
if ( typeof( key ) === "object" ) {
urlSegments.search = this.compileSearchInput( key );
this.navigate( urlSegments );
// Set just one of the search key-value pairs.
} else {
// Since we're merging the existing Search value with the incoming
// SearchInput field value, we need to down-cast the Search to a SearchInput
// structure so that we can insert non-string values into it.
var searchInput: SearchInput = this.parseQueryString( urlSegments.search );
searchInput[ key ] = value;
urlSegments.search = this.compileSearchInput( searchInput );
this.navigate( urlSegments );
}
return( this );
}
// I subscribe to the PopStateEvents for the RetroLocation.
// --
// CAUTION: These events will be emitted when the location is changed either manually
// by the user or programmatically by the RetroLocation service.
public subscribe(
onNext: SubscribeHandlers.OnNext,
onThrow?: SubscribeHandlers.OnThrow | null,
onComplete?: SubscribeHandlers.OnComplete | null
) : Subscription {
var subscription = this.popStateEvents.subscribe({
next: onNext,
error: onThrow,
complete: onComplete
});
return( subscription );
}
// I set or get the entire location URL.
public url( newUrl: string ) : RetroLocation;
public url() : string;
public url( newUrl?: string ) : RetroLocation | string {
// Return the existing url.
if ( newUrl === undefined ) {
return( this.locationStrategy.path( true ) ); // true = Include Hash.
}
// Set the new url.
this.navigate( this.parseUrl( newUrl ) );
return( this );
}
// ---
// PRIVATE METHODS.
// ---
// I compile the search input into a query-string. This will treat NULL and TRUE
// values specially.
private compileSearchInput( queryParams: SearchInput ) : string {
var pairs: string[] = [];
for ( var key of Object.keys( queryParams ) ) {
var value = queryParams[ key ];
// Skip any null values (omit the key from the results).
if ( value === null ) {
continue;
// Include just the key portion of the key-value pair.
} else if ( value === true ) {
pairs.push( this.encodeURIQuery( key ) );
// Include the full key-value pair.
} else {
pairs.push(
this.encodeURIQuery( key ) +
"=" +
this.encodeURIQuery( value.toString() )
);
}
}
return( pairs.join( "&" ) );
}
// I URL-decode the hash value.
private decodeHash( hash: string ) : string {
// CAUTION: I am not sure if there are any rules around what should or should
// not be encoded in the hash.
return( decodeURIComponent( hash ) );
}
// I URL-decode the path value.
private decodePath( path: string ) : string {
var parts = path.split( "/" );
var partsLength = parts.length;
for ( var i = 0 ; i < partsLength ; i++ ) {
parts[ i ] = this.decodeURISegment( parts[ i ] );
}
return( parts.join( "/" ) );
}
// I URL-decode a sub-path segment of a path value.
private decodeURISegment( encodedValue: string ) : string {
return( decodeURIComponent( encodedValue ) );
}
// I URL-decode the sub-search segment of a search value.
private decodeURIQuery( encodedValue: string ) : string {
return( decodeURIComponent( encodedValue ) );
}
// I URL-encode the hash value.
private encodeHash( hash: string ) : string {
// CAUTION: I am not sure if there are any rules around what should or should
// not be encoded in the hash.
return( encodeURIComponent( hash ) );
}
// I URL-encode the path value.
private encodePath( path: string ) : string {
var parts = path.split( "/" );
var partsLength = parts.length;
for ( var i = 0 ; i < partsLength ; i++ ) {
parts[ i ] = this.encodeURISegment( parts[ i ] );
}
return( parts.join( "/" ) );
}
// I URL-encode a sub-segment (key or value) of the search value.
private encodeURIQuery( value: string ) : string {
// On it's own, the encodeURIComponent() method is too aggressive. As such, we
// have to dial it back a bit after the value has been processed.
// --
// Read more: https://github.com/angular/angular.js/blob/3651e42e49ded7d410fd1cbd46f717056000afd4/src/Angular.js#L1472
var encodedValue = encodeURIComponent( value )
.replace( /%40/gi, "@" )
.replace( /%3A/gi, ":" )
.replace( /%24/g, "$" )
.replace( /%2C/gi, "," )
.replace( /%3B/gi, ";" )
;
return( encodedValue );
}
// I URL-encode a sub-segment of the path value.
private encodeURISegment( value: string ) : string {
// On it's own, the encodeURIComponent() method is too aggressive. As such, we
// have to dial it back a bit after the value has been processed.
// --
// Read more: https://github.com/angular/angular.js/blob/3651e42e49ded7d410fd1cbd46f717056000afd4/src/Angular.js#L1453
var encodedValue = this.encodeURIQuery( value )
.replace( /%26/gi, "&" )
.replace( /%3D/gi, "=" )
.replace( /%2B/gi, "+" )
;
return( encodedValue );
}
// I break the current URL value into normalized pathname, search, and hash segments.
private getUrlSegments() : UrlSegments {
return( this.parseUrl( this.url() ) );
}
// I navigate to the location defined by the given URL segments (pathname, search,
// and hash).
private navigate( urlSegments: UrlSegments ) : void {
var url = urlSegments.pathname;
if ( urlSegments.search ) {
url += ( "?" + urlSegments.search );
}
if ( urlSegments.hash ) {
url += ( "#" + urlSegments.hash );
}
// Only push the state if the URL has actually changed. We only need this
// because the RetroLocation emits an event and we want this event to be a bit
// more closely tied to an actual change in the location.
if ( url !== this.url() ) {
this.locationStrategy.pushState(
// pushState - all the other strategies appear to pass NULL here.
null,
// title - all the other strategies appear to pass empty-string here.
"",
// path - we are encoding the entire location into the path.
url,
// queryParams - we are baking these into the path (above).
""
);
this.popStateEvents.next({
url: url,
pop: true,
type: "popstate"
});
}
}
// I normalize the hash so that it is NOT starting with a hash.
private normalizeHash( value: string ) : string {
return( value.replace( /^#/, "" ) );
}
// I normalize the path so that it IS starting with a slash but NOT ending with one.
private normalizePath( value: string ) : string {
value = value
.replace( /^[\\\/]+/, "" )
.replace( /[\\\/]+$/, "" )
;
return( "/" + value );
}
// I normalize the query-string so that it is NOT starting with a question-mark.
private normalizeQueryString( value: string ) : string {
return( value.replace( /^\?/, "" ) );
}
// I parse the given query-string into a set of key-value pairs.
private parseQueryString( queryString: string ) : Search {
var queryParams = {};
// If there is no query-string value, skip the parsing.
if ( queryString ) {
for ( var pair of queryString.split( "&" ) ) {
var tokens = pair.split( "=" );
var key = this.decodeURIQuery( tokens.shift() );
var value = this.decodeURIQuery( tokens.join( "=" ) );
queryParams[ key ] = value;
}
}
return( queryParams );
}
// I parse the given URL into a set of normalized segments.
private parseUrl( url: string ) : UrlSegments {
var segments = url.match( /^([^?#]*)(\?[^#]*)?(#.*)?/ );
return({
pathname: this.normalizePath( segments[ 1 ] || "" ),
search: this.normalizeQueryString( segments[ 2 ] || "" ),
hash: this.normalizeHash( segments[ 3 ] || "" )
});
}
}
Right now, this service doesn't attempt to do any sort of caching - just on-demand parsing of the underlying location path. As such, I'm sure there's much room for improvement. But, the general idea is that this RetroLocation service brings back some of the awesome functionality that was exposed by the $location service in Angular 1.x.
If anyone has any insight into why this doesn't already exist, I'd be curious to know. I can totally understand that the long-term goal would be to use the Router for anything complicated. But, since the Router is contained within a separate Module, it seems like basic URL manipulation functionality would still be desired in the core modules (core and common). Regardless, this was fun to build.
Want to use code from this post? Check out the license.
Reader Comments