Best way to inline SVGs in Angular Universal

560 views Asked by At

We have an Angular 16 Universal project and we want to find the best way to use SVG icons. Performance is crucial for our web app. We cannot use fonts such as Icomoon because the icons are multicolor, and it gets hard to customize and maintain.

First, we developed an Angular directive that inlines the icons in runtime. We tried the following modes:

  • Client-only: inlines the icons via a HttpClient.get() when the app runs in the browser. However, the icons do not start being downloaded until the whole main.js (which contains the directive) is loaded. This causes a perceptible flickering.

  • SSR + Client: with Angular hydration activated, the server performs the get calls to get the icons and the client wont repeat said calls. This solves the flickering, because the returned page already contains the SVGs. However, I am concerned about the creation of a bottleneck when introducing these requests in the server-side.

Moreover, the icons are currently served by an assets server, and we want to be able to send our components as a library to other teams so that they may reuse them. This could create problems if those teams make requests to our assets server from different hostnames (CORS). Therefore, some suggestions were made:

  • Inlining the SVGs on build time, specially for those that are critical and must be always shown. This would fix the potential issues of our directive, but it would increase the size of the scripts. Moreover, I haven't found a simple way to configure it via Webpack. Pasting them directly in our templates seems like an undesirable solution.

  • Using the assets folder so that the icons are included in the dist folder when passing our library to other teams.

Which would be the best way to include icons as SVG taking into account all these ideas?

3

There are 3 answers

2
VonC On BEST ANSWER

Expanding on Eliseo's answer, you might consider a method to preload SVG icons during the application initialization, and then utilize an Angular component to display these icons inline, allowing for CSS customization.
That would provide an alternative approach to achieve inlining SVGs without needing to delve into Webpack configurations.

The structure of your Angular project would be:

src/
|-- app/
    |-- components/
        |-- header/
            |-- header.component.html  // Template file for the header component
            |-- header.component.ts  // TypeScript file for the header component
            |-- header.component.css  // CSS file for the header component
    |-- core/
        |-- services/
            |-- svg.service.ts  // Service for fetching and storing SVG icons
    |-- shared/
        |-- components/
            |-- svg-icon/
                |-- svg-icon.component.ts  // Component for rendering SVG icons inline
    |-- modules/
        # other modules
    |-- app.module.ts  // Main application module where SVG_ICON_INITIALIZER is provided
|-- assets/
    |-- symbol-defs.svg  // SVG file containing symbol definitions for icons
|-- environments/
    |-- environment.ts
    # environment configuration files
|-- main.ts  // Main entry file for the application
|-- index.html  // Main HTML file
|-- styles.css  // Global styles
|-- # other files

Create a service to handle fetching and storing SVG icons.
And use APP_INITIALIZER to preload SVG icons during application initialization: that should mitigate the perceptible flickering mentioned in the "Client-only" mode and possibly alleviate the server bottleneck concern in the "SSR + Client" mode.

src/app/core/services/svg.service.ts:

import { Injectable, APP_INITIALIZER, Provider } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class SvgService {
  private svgDefs: string;

  constructor(private http: HttpClient) {}

  loadSvgIcons(): Promise<void> {
    return this.http.get('assets/symbol-defs.svg', { responseType: 'text' })
      .toPromise()
      .then(svgDefs => {
        this.svgDefs = svgDefs;
      });
  }

  getIcon(iconId: string): string {
    const match = this.svgDefs.match(new RegExp(`<symbol id="${iconId}"[^>]*>((.|\\n)*)<\\/symbol>`));
    return match ? match[0] : '';
  }
}

export const SVG_ICON_INITIALIZER: Provider = {
  provide: APP_INITIALIZER,
  useFactory: (svgService: SvgService) => () => svgService.loadSvgIcons(),
  deps: [SvgService],
  multi: true,
};

The loadSvgIcons method fetches the SVG icons from the local asset file ('assets/symbol-defs.svg') and stores the SVG markup in the svgDefs property. The getIcon(iconId: string) method is used to retrieve specific SVG icon data from the svgDefs property.

Then create an Angular component to render SVG icons inline.

src/shared/components/svg-icon/svg-icon.component.ts:

import { Component, Input } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { SvgService } from './svg.service';

@Component({
  selector: 'svg-icon',
  template: `<div [innerHTML]="iconSvg"></div>`,
})
export class SvgIconComponent {
  @Input() icon: string;
  iconSvg: SafeHtml;

  constructor(private svgService: SvgService, private sanitizer: DomSanitizer) {}

  ngOnChanges() {
    const iconSvgString = this.svgService.getIcon(this.icon);
    this.iconSvg = this.sanitizer.bypassSecurityTrustHtml(iconSvgString);
  }
}

The SvgIconComponent is designed to render SVG icons inline. The icon input property is used to specify the icon ID. The iconSvg property is used to store the SVG icon string. The ngOnChanges lifecycle hook is used to update the iconSvg property when the icon input property changes.

In your Angular module file (e.g., app.module.ts), you would import the SVG_ICON_INITIALIZER from the svg.service.ts file and add it to the providers array to make sure the SVG icons are preloaded during application initialization.

src/app/app.module.ts:

// app.module.ts
import { SVG_ICON_INITIALIZER } from './svg.service';

@NgModule({
  declarations: [SvgIconComponent],
  imports: [/* */],
  providers: [SVG_ICON_INITIALIZER],
  bootstrap: [/* */]
})
export class AppModule { }

For illustration, in a typical Angular project, you might have a header directory within a components directory or directly under the app directory, containing a header.component.html file for the template of the header component.

In the header.component.html file, you would use the svg-icon component to render SVG icons inline:

src/app/components/header/header.component.html (template file for the header component):

<div class="header">
  <svg-icon icon="icon-1"></svg-icon>
  <!-- other header content -->
</div>

Now that SVG icons are rendered inline, you can apply CSS styles as needed.

svg-icon svg {
  fill: currentColor;
}

svg-icon:hover svg {
  fill: gold;
}

You get:

 +------------------------+       +-------------------------+       +-------------------+
 | Angular Initialization |       | SvgService              |       | SvgIconComponent  |
 |                        |       |                         |       |                   |
 | APP_INITIALIZER        |       | loadSvgIcons()          |       | ngOnChanges()     |
 | (SVG_ICON_INITIALIZER) | ----> | getIcon(iconId: string) | ----> | render SVG inline |
 |                        |       |                         |       | with [innerHTML]  |
 +------------------------+       +-------------------------+       +-------------------+
                                      |                                          
                                      | Fetch SVG icons                           
                                      v                                          
                              +--------------------------+                             
                              | External Asset File      |                             
                              | (assets/symbol-defs.svg) |                             
                              +--------------------------+
  • The APP_INITIALIZER (using SVG_ICON_INITIALIZER) triggers the loadSvgIcons() method in SvgService during Angular's initialization phase.
  • SvgService fetches the SVG icons from the external asset file (assets/symbol-defs.svg).
  • SvgIconComponent utilizes SvgService to obtain specific SVG icon data via the getIcon(iconId: string) method whenever there is a change in the icon input property (triggering ngOnChanges()).
  • SvgIconComponent renders the SVG icon inline within its template using Angular's [innerHTML] binding to inject the SVG markup into the DOM.

By encapsulating the SVG handling within a service and a component, that facilitates the reusability and sharing of components with other teams: you should be able to pass the library to other teams


Note: That does use a local asset file ('assets/symbol-defs.svg') which is bundled with the application during the build process.
This is straightforward and avoids any network requests at runtime to fetch the SVG icons, which mitigates potential CORS issues. However, it does not leverage an external asset server.

In scenarios where the SVG icons are expected to change frequently or when there is a need to share icons across multiple projects, using an external asset server could be beneficial.

You would need to update the SvgService to fetch the SVG icons from the asset server instead of the local 'assets/symbol-defs.svg' file.

src/app/core/services/svg.service.ts:

@Injectable({ providedIn: 'root' })
export class SvgService {
  // previous code
  loadSvgIcons(): Promise<void> {
    // Update the URL to point to the asset server
    return this.http.get('https://assets-server.com/symbol-defs.svg', { responseType: 'text' })
      .toPromise()
      .then(svgDefs => {
        this.svgDefs = svgDefs;
      });
  }
  // rest of the code
}

But you will also need to handle CORS issues that may arise when fetching the SVG icons from an external asset server.
Make sure the asset server is configured to allow cross-origin requests from the domains where your Angular applications are hosted. That can typically be done by setting appropriate CORS headers on the asset server.

https://dev-academy.com/angular-cors/cors-request-and-response-flow.png

// Example CORS headers on the asset server
Access-Control-Allow-Origin: https://your-angular-app.com
Access-Control-Allow-Methods: GET, OPTIONS

See more at "Angular CORS Guide: Fixing errors", from Saujan Ghimire

You might also need to implement versioning and cache-busting strategies to make sure the latest version of the SVG icons are fetched from the asset server whenever there are updates. That can be done by appending a version query parameter to the URL when fetching the SVG icons.

The version variable can be hardcoded to 'v1'. Whenever there is an update to the SVG icons, you would update this value to a new version string, e.g., 'v2', 'v3', and so forth. That change in the URL triggers the browser to fetch the updated SVG icons from the server, bypassing any cached version.

src/environments/environment.ts:

// src/environments/environment.ts
export const environment = {
  production: false,
  SVG_ICON_VERSION: 'v1',
  // other environment-specific configurations
};

By placing the SVG_ICON_VERSION variable in the environment file, you allow for environment-specific versioning of your SVG icons. For example, you might have a different version of the SVG icons in your development environment compared to your production environment.

Then you would update the loadSvgIcons() method in the SvgService to append the version query parameter to the URL when fetching the SVG icons.
src/app/core/services/svg.service.ts:

import { environment } from '../../../environments/environment';

@Injectable({ providedIn: 'root' })
export class SvgService {
  // previous code
  loadSvgIcons(): Promise<void> {
    const version = 'v1';  // Update this value whenever the SVG icons are updated
    return this.http.get(`https://assets-server.com/symbol-defs.svg?version=${version}`, { responseType: 'text' })
      .toPromise()
      .then(svgDefs => {
        this.svgDefs = svgDefs;
      });
  }
  // rest of the code
}

That would leverage an external asset server to serve SVG icons, facilitating easier updates and sharing of icons across projects. However, it does introduce a network request at runtime to fetch the SVG icons, which could potentially introduce a bottleneck.

+--------------------+          +-----------------+          +--------------------+
| Angular            |          | SvgService      |          | External Asset     |
| Application        |          |                 |          | Server             |
|                    |          | loadSvgIcons()  |          |                    |
| 1. Bootstraps      |          | 1. Fetches SVG  |          | 1. Serves SVG      |
| 2. Calls           |          |    icons from   |          |    icons           |
|    APP_INITIALIZER | -------> | external server | -------> | 2. Checks CORS     |
|    (loadSvgIcons)  |          |    with version |          |    headers         |
| 3. Renders         |          |    query param  |          | 3. Returns SVG     |
|    components      |          | 2. Stores SVG   |          |    icons           |
|    with SVG icons  | <------  |    icons        | <------- |                    |
+--------------------+          +-----------------+          +--------------------+
  • The Angular application bootstraps and calls the APP_INITIALIZER (which triggers loadSvgIcons method in SvgService).
  • SvgService sends a request to fetch SVG icons from the external asset server, appending a version query parameter to the URL.
  • The external asset server serves the SVG icons, after checking the CORS headers to ensure the request is coming from an allowed domain.
  • SvgService stores the fetched SVG icons for later use.
  • The Angular application renders components, which use the stored SVG icons from SvgService for displaying SVG icons inline.

I'm studying the APP_INITIALIZER option, but if I'm right, until main.js is completely loaded, the code won't be executed at all.
That would still cause some perceptible flickering, especially if the main.js file keeps growing in the future."

True, the APP_INITIALIZER token is used to execute functions when an application starts, but it does run after the main.js file is loaded.
If main.js becomes large and takes a significant amount of time to load, there would still be a perceptible delay before the SVG icons are fetched and displayed, potentially leading to flickering.

To mitigate this, you have serval approaches:

  • You can try and optimize the bundle size: reducing the size of the main.js bundle would result in faster loading times. That can be achieved through code-splitting, tree-shaking, and other bundle optimization techniques.

  • With server-side rendering, the SVG icons could be fetched and inlined into the HTML on the server before it is sent to the client. That would eliminate the flickering since the icons would already be present on the initial render.

  • Another approach would be to implement a service worker to cache the SVG icons after the first load can eliminate network latency in subsequent loads. The service worker could preload essential assets, including SVG icons, ensuring they are available as soon as the app loads.

  • If the SVG icons are hosted on a Content Delivery Network (CDN), they could be loaded more quickly than from a single server, especially if the CDN is optimized for delivering static assets.

  • HTTP/2 or HTTP/3 protocols can also help in loading assets in parallel, reducing the overall load time.

2
Danny '365CSI' Engelman On

Create a native <svg-icon> Web Component which creates SVG client side as IMG to prevent style or ID bleeding. The component holds all SVG data for critical icons, is loaded first, and loads non-critical icons lazy (if needed).

<script>
((t,e={path:(t,e="")=>`<path d='${t}' ${e}/>`},r={v1:"",v2:"",v3:"",is:"",img:1,box:9,rect:"<rect width='100%' height='100%' fill='{tile}' {border}/>"
,border:"",filter:"",tile:"none",fill:"none",width:1,scale:1,opacity:1,rotate:0,stroke:"#000",xy:0,w:0,h:0,top:"",api:[t,e]})=>{
customElements.define("svg-icon",class extends HTMLElement{static get observedAttributes(){return Object.keys(r)}attributeChangedCallback(){this.svg()}svg(i=this,s=i.A||Object.keys(i.A={...r}).map((t=>Object.defineProperty(i,t,{set:e=>i.setAttribute(t,e),get:()=>i.getAttribute(t)||getComputedStyle(i).getPropertyValue("--svg-icon-"+t).replace(/"/g,"").trim()||i.A[t]},e[t]=e=>(i.A[t]=e,"")))),l,a=(t[i.is]||"").split`;`.map((t=>([s,l]=t.trim().split`:`,e[s]?e[s].apply(i,l.split`,`):t))).join``,o=i.box/2,
c=`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${i.w||i.box} ${i.h||i.box}'>${i.rect}<g stroke-width='{width}' stroke='{stroke}' fill='{fill}' opacity='{opacity}' filter='{filter}' transform='translate({xy}) matrix({scale} 0 0 {scale} ${o-o*i.scale} ${o-o*i.scale}) rotate({rotate} ${o} ${o})'>${a}</g>${i.top}</svg>`.replace(/{\s?([^{}\s]*)\s?}/g,((t,e)=>i[e]))){return i.innerHTML=1==i.img?
`<img src="data:image/svg+xml,${c.replace(/#/g,"%23")}">`:c}})})(
{
   menu:"box:9;path:m1.5 2.8h6m0 2h-6m0 2h6,stroke-linecap='round'",
   settings:"box:96;<circle stroke-width='12' cx='48' cy='50' r='26'/><circle stroke-width='12' cx='48' cy='50' r='36' stroke-dasharray='14'/>",
   renew:"box:96;fill:black;path:M48 24v12l16-16-16-16v12c-18 0-32 14-32 32 0 6 2 12 5 17L27 60A23 23 0 0 1 24 48c0-13 11-24 24-24zm26 6L69 37c2 3 3 7 3 11 0 13-11 24-24 24v-12l-16 16 16 16v-12c18 0 32-14 32-32 0-6-2-12-5-16z"
});
</script>

<style>
  svg-icon {
    width: 80px;
    display: inline-block;
    background: grey;
  }
  svg-icon:hover { background: lightgrey; cursor:pointer }
</style>

<svg-icon is="menu"></svg-icon>
<svg-icon is="settings"></svg-icon>
<svg-icon is="renew" rotate=45 fill=gold stroke=white></svg-icon>

2
Eliseo On

I imagine you can load an unique file svg in the way

<svg>
    <defs>
        <symbol id="icon-1" viewBox="0 0 512 512">
            <path .../>
        </symbol>
        <symbol id="icon-2" viewBox="0 0 512 512">
            <path .../>
        </symbol>
        ...
    </def>
</svg>

For this, use APP_INITIALIZER

export function initializeAppFactory(httpClient: HttpClient):
   () => Observable<any> {
     return ()=>
         httpClient.get('./assets/svgs.svg',{responseType: 'text'})
    }
}

Then your svgs like

<svg>
     <use attr.xlink:href="./assets/symbol-defs.svg#icon-1"></use>
</svg>

You can also makes a component

@Component({
  selector: 'svg-icon',
  styles: [`
    :host {
        svg {
          all: inherit;
        }
      }
    `],
  template: `
      <svg width="100%" height="100%">
        <use attr.xlink:href="./assets/symbol-defs.svg#{{icon}}"></use>
      </svg>
    `
})
export class SvgIconComponent {
  @Input() icon: string;
}

And use

<svg-icon icon="icon-1"></svg-icon>