Hello fellow problem solvers! I'm hoping to learn something from you. I ran into a challenge that could probably teach me more about Angular, and I would appreciate any input anyone might have.
My company shares a common footer among many web sites, and we lazy-load that into each site using JSONP. The footer content is managed by a system that generates static HTML, for high performance, low costs, and high uptime. When we lazy-load that footer into pages, we cannot control the name of the callback function call included in the response.
Our footer might look something like this footer.json
file:
footerCallback(["<footer>FROM THE FOOTER</footer>"]);
We lazy-load that into HTML pages with simple pure-Javascript code like this:
<div id="footer"></div>
<script>
function footerCallback(json_data){
document.getElementById('footer').outerHTML = json_data[0];
}
window.onload = function() {
var script = document.createElement('script');
script.src = 'assets/static-footer.json'
script.async = true;
document.getElementsByTagName('head')[0].appendChild(script);
}
</script>
I posted a working demo on GitHub, here. Unfortunately I couldn't post it on Stackblitz to make it easier to run, since it uses JSONP requests. This version includes the shared footer in exactly the way that I described above, right here.
We need to include that same central footer in Angluar apps, so I'm trying to create a footer
component that will insert that lazy-loaded JSONP footer into an Angular app.
I created a footer
component and a footer
service, and I'm using HttpClientJsonpModule
to do a JSONP request. That part works. The service sends the JSONP request and I can see the response in my inspector in Chrome.
The part that does not work is the callback. I can't remove the original global callback function from the main index.html
because I don't know how to replace it with a function that the JSONP response can trigger. I was able to move the callback function into the component, but only by continuing to define it globally:
function footerCallback(json_data){
document.getElementById('footer').outerHTML = json_data[0];
}
const _global = (window) as any
_global.footerCallback = footerCallback
Angular expects to be able to tell the JSONP server the name of the callback function to include in the JSONP response. But there is no server, it's just a static file.
Angular is flexible enough that I can tell it the URL parameter to use to specify that callback function. If I specify "callback" as the callback function name with this.http.jsonp(this.footerURL,'callback')
then it will issue a request like this:
https://...footer.min.json?callback=ng_jsonp_callback_0
That's a nice feature and all, but it's the ng_jsonp_callback_0
that I need to customize, not the URL parameter. Because the server cannot adjust its response to include a reference to the function ng_jsonp_callback_0
. Because there is no server, it's just a static file.
My workaround was to define the callback function in the global scope. That way, I can support whatever callback function names the API or static file requires.
This at least enables me to encapsulate everything related to the lazy-loading footer into a footer component with its own service. But I feel like I'm not really doing this in the Angular Way, if I'm polluting the global scope.
Is there a way for me to manually specify the name of that callback function myself, rather than letting Angular pick the name ng_jsonp_callback_0
or whatever?
If not, then is there some other elegant way to handle this? Other than using a global callback function?
Well..the first thing is that Angular has hardcoded the callback, if you check the source code you will see something like this:
/**
* Get the name of the next callback method, by incrementing the global `nextRequestId`.
*/
nextCallback() {
return `ng_jsonp_callback_${nextRequestId++}`;
}
So when the ng_jsonp_callback_
is not called, Angular throws an error:
this.resolvedPromise.then(() => {
// Cleanup the page.
cleanup();
// Check whether the response callback has run.
if (!finished) {
// It hasn't, something went wrong with the request. Return an error via
// the Observable error path. All JSONP errors have status 0.
observer.error(new HttpErrorResponse({
url,
status: 0,
statusText: 'JSONP Error',
error: new Error(JSONP_ERR_NO_CALLBACK),
}));
return;
}
// Success. body either contains the response body or null if none was
// returned.
observer.next(new HttpResponse({
body,
status: 200,
statusText: 'OK',
url,
}));
// Complete the stream, the response is over.
observer.complete();
});
Angular is just loading the url with the callback that they define and uses the window object or an empty object, as you can see in the source code:
/**
* Factory function that determines where to store JSONP callbacks.
*
* Ordinarily JSONP callbacks are stored on the `window` object, but this may not exist
* in test environments. In that case, callbacks are stored on an anonymous object instead.
*
*
*/
export function jsonpCallbackContext() {
if (typeof window === 'object') {
return window;
}
return {};
}
Now that we know this, we will do the following:
ng_jsonp_callback_
so we can skip the error and subscribe to the response. We need to insert the script for this.Now in the component do the following:
export class FooterComponent implements AfterViewInit {
// Steps 2 and 3
constructor(private footerService: FooterService,
private elementRef: ElementRef,
private renderer: Renderer2) { }
ngAfterViewInit() {
this.addScript();
this.footerService.getFooter().subscribe((data: string[]) => { this.footerCallback(data) });
}
// step 1
private addScript() {
// Wrap the ng_jsonp_callback_0 with your footerCallback
const scriptSrc = `window.footerCallback = function(json_data) {window.ng_jsonp_callback_0(json_data);}`;
const scriptElement: HTMLScriptElement = this.renderer.createElement('script');
scriptElement.innerHTML = scriptSrc;
this.renderer.appendChild(this.elementRef.nativeElement, scriptElement);
}
// Insert a new Element with the json_data
private footerCallback(json_data: string[]) {
const footerElement: HTMLElement = this.renderer.createElement('div');
footerElement.innerHTML = json_data[0];
this.renderer.appendChild(this.elementRef.nativeElement, footerElement);
}
}
That is it, hope it helps.
EDIT Don't forget to do cleanup.