こんにちは。フロントエンドエンジニアの松岡です。

Angular でアイコン表示コンポーネントを作る方法を紹介します。

  • 本記事のソースコードは Angular v15 を前提としています。
  • コンポーネントメタデータの selector のプレフィクスを省略したり styles を直接記述するなど、ガイドライン準拠より説明の簡単さを優先していますのでご留意ください。

TL;DR

src/app/icon.component.ts(アイコン表示コンポーネント)

import { Component, Input } from '@angular/core';

@Component({
  standalone: true,
  selector: 'icon',
  template: `
    <svg width="0" height="0" fill="currentColor">
      <use
        attr.href="assets/icon.svg#{{ icon }}"
        attr.xllink:href="assets/icon.svg#{{ icon }}"
      ></use>
    </svg>`,
  styles: [`
    svg {
      width: 100%;
      height: 100%;
    }
  `],
})
export class IconComponent {
  @Input() icon!: string;
}

asset/icon.svg(アイコンの定義)

<svg
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
>
  <symbol id="bell">
    <svg><path ... /></svg>
  </symbol>

  <symbol id="bookmark">
    <svg><path ... /></svg>
  </symbol>
</svg>

src/app/page1.component.ts(アイコン表示コンポーネントの利用者)

@Component({
  standalone: true,
  selector: 'page1',
  imports: [
    IconComponent,
  ],
  template: `
    <icon [icon]="'bell'"></icon>
    <icon [icon]="'bookmark'"></icon>
  `
})
export default class Page1Component { ... }

アイコン表示コンポーネントとは

記事タイトルにある「アイコン表示コンポーネント」とは アイコンをレンダリングするだけのコンポーネント を指しています。テキストの強調や補足のためにアイコンを使うことってありますよね。インラインで SVG を記述する方法もあれば、個別にコンポーネントを宣言していろいろなページで再利用する方法もあります。

インライン SVG はテンプレートの行数が増えるのと「これって何のアイコンだっけ?」と迷うことが多く、私は個別にコンポーネント宣言するほうを好んで使っています。

増殖するアイコン表示コンポーネント

まず、個別のコンポーネント宣言のソースコードを紹介します。

@Component({
  standalone: true,
  selector: 'bell-icon',
  template: `<svg>...</svg>`,
  ...
})
export class BellIconComponent { ... }

このやり方だと、違うパターンのアイコンが欲しくなるたびにコンポーネントが増えていきます。

src/app/icons/
├── bell-icon.component.ts
├── bookmark-icon.component.ts
├── calculator-icon.component.ts
├── calendar-icon.component.ts
├── cloud-icon.component.ts
├── cube-icon.component.ts
├── document-icon.component.ts

アイコンが増えるのに比例してインポートも増えていきます。

@Component({
  standalone: true,
  selector: 'page1',
  imports: [
    BellIconComponent,
    BookmarkIconComponent,
    CalculatorIconComponent,
  ],
  ...
})
export default class Page1Component { ... }

この数が膨大になると パフォーマンスにそこそこ影響 しそうです。また aria-hidden 属性の追加など「アイコン表示コンポーネント」共通の振る舞いを持たせたい時、増殖した すべてのコンポーネントを変更しないといけない のも厄介でした。

<use><symbol> でアイコンが埋め込みできる

HTML の <use><symbol> を使えば、アイコン表示コンポーネントはひとつに集約できます。Google Chrome では <use> は 2012 年、<symbol> は 2008 年に実装されていますが、つい最近まで知りませんでした。

MDN <use>
https://developer.mozilla.org/ja/docs/Web/SVG/Element/use

MDN <symbol>
https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol

以降はこれらのタグを使ってひとつに集約した「アイコン表示コンポーネント」について説明します。

ひとつに集約したアイコン表示コンポーネント

<use> タグは外部にある SVG のノードを参照します。これを使って外部からノードを指定できるようにします。

@Component({
  selector: 'icon',
  template: `
    <svg width="0" height="0" fill="currentColor">
      <use
        attr.href="assets/icon.svg#{{ icon }}"
        attr.xllink:href="assets/icon.svg#{{ icon }}"
      ></use>
    </svg>`,
  ...
})
export class IconComponent {
  @Input() icon!: string;
}
  • widthheight はそれぞれ初期値に 0 を与えて、CSS ファイルの読み込み前にアイコンが画面いっぱいに広がって表示されるのを防ぎます。
  • attr.hrefattr.xllink に同じパスを指定しているのは、attr.xllink しかサポートしていないブラウザの互換性を考慮したものです。

アイコン定義

<symbol> でアイコンを列挙し id にアイコンを識別するためのテキストを与えます。

<svg
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
>
  <symbol id="bell">
    <svg><path ... /></svg>
  </symbol>

  <symbol id="bookmark">
    <svg><path ... /></svg>
  </symbol>
</svg>

アイコン表示コンポーネントを利用する

利用側で、どのアイコンを使うか指定します。このテキストは <symbol> に与えた id と同じものです。

@Component({
  selector: 'page1',
  imports: [
    IconComponent,
  ],
  template: `
    <icon [icon]="'bell'"></icon>
    <icon [icon]="'bookmark'"></icon>
  `,
  ...
})
export default class Page1Component { ... }

集約すると何が嬉しいのか

プロジェクトのファイル構造がすっきりシンプルになります。

/src
├── app
│   ├── app.component.ts
│   ├── icon.component.ts
│   └── page1.component.ts
└── assets
│   └── icon.svg

アイコンを増やす時は icon.svg に新しい <symbol> を定義します。VS Code では SVG Preview などエクステンションを使えば <symbol> ごとのプレビュー画像が確認できます。

サイズ指定など共通の振る舞いを持たせたい時、1 ファイルを変更するだけで済みます。

ブラウザでの挙動

npm run ng serve

アイコンは Angular のアセットとして配置しているため、モジュールを読み込むたびにアイコン解決の HTTP リクエストが走ります。モジュールを遅延ロードをしている場合はルーティングの loadChildrenloadComponent が評価されるタイミングと同等です。

export const appRoutes: Route[] = [
  {
    path: 'page1',
    loadComponent: () => import('./page1.component'),
  },
  {
    path: 'page2',
    loadComponent: () => import('./page2.component'),
  },
];
  • /page1 を開いたタイミングで GET icon.svg がリクエストされる
  • /page2 を開いたタイミングで GET icon.svg がリクエストされる
  • /page1 を再び開いたタイミングで GET icon.svg がリクエストされる

https://heroicons.com/ からアイコンを 10 パターンほどコピーして icon.svg に貼り付けたところ、レスポンスのサイズは 237 バイトでした。SVG はテキストのためレスポンスは軽量です。

ソースコードの多い複雑なアイコンや膨大な数のアイコンを使うようなケースでは、icon.svg のファイル分割など検討したほうが良いかもしれません。

ビルド

npm run ng build

アイコン表示コンポーネントをひとつに集約したケース(今回紹介した方法)と、個々にコンポーネント宣言したケース(旧来の方法)を比較してみました。どちらも同じ 10 パターンのアイコンを使い /page1/page2 それぞれで 5 パターンずつアイコンを読み込むようにしました。

export const appRoutes: Route[] = [
  {
    path: 'page1',
    loadComponent: () => import('./page1.component'),
  },
  {
    path: 'page2',
    loadComponent: () => import('./page2.component'),
  },
];

ビルドファイルが吐き出される dist 配下のファイルサイズは以下のとおりです。★マークを付けたファイルに大きな差が出ました。

ファイル サイズ(ひとつに集約) サイズ(個々にコンポーネント宣言)
合計 288 kB 292 kB
3rdpartylicenses.txt 13 kB 13 kB
{chunk}.{hash}.js ★ 770 bytes 4.1 kB
{chunk}.{hash}.js ★ 780 bytes 4.3 kB
assets ★ 96 bytes 0
common.{hash}.js ★ 610 bytes 0
favicon.ico 15 kB 15 kB
index.html 579 bytes 582 bytes
main.{hash}.js 200 kB 199 kB
polyfills.{hash}.js 34 kB 34 kB
runtime.{hash}.js 2.7 kB 2.7 kB
styles.{hash}.css 0 0

ひとつに集約したケースは ランタイムの読み込みに負荷 がかかり、個々にコンポーネント宣言したケースは 初回のモジュール読み込みに負荷 がかかる、ということが読み取れます。どちらもメリット・デメリットがありそうですが、ファイル構造のシンプルさで言えば「ひとつに集約」のほうが扱いやすいのではと思っています。

おわりに

ブラウザとビルドの検証に使ったソースコードは GitHub で公開しています。

https://github.com/ringtail003/angular-icon

よければ是非、ひとつに集約したアイコン表示コンポーネントを実装してみてください。増殖したコンポーネントに困っているどなたかの参考になれば幸いです 😊