【フロントエンド学習記録】Ember.js でトーストメッセージを出す

今回はEmber.jsでトーストメッセージを出す部分を見ていきます。

前回の記事はこちらです。

maimux2x.hatenablog.com

トーストメッセージの実装では当初、Rails のように画面遷移をベースに考えてしまってパラメータをトリガーにするなどちょっと迷走したりしました...。

Rails の場合、画面遷移時(リダイレクト時)に flash メッセージを出すことが簡単に対応できると思います。

しかし、Ember.js などの SPA を実現するフレームワークでは画面遷移がありません。

つまり、Railsflash がサーバーからクライアントへの通知を実現しているのに対して、クライアントだけで完結しているということであり、トーストメッセージ自体が一つの「状態」であると捉える必要がありました。

Ember.js での実装

トーストメッセージを出したい操作は以下になります。

  • Admin 画面へのログイン・ログアウト時
  • ブログ記事の新規作成成功時
  • ブログ記事の更新成功時
  • ブログ記事の削除成功時

ブログ記事の投稿・更新に関連するエラーはトーストではなく、各フォーム下部に表示させる形で実装したため、今回は想定していません。

トースト自体は Bootstrap のトーストプラグインを利用する形で進めます。

getbootstrap.jp

実装を進めるにあたって、トーストを出すタイミングやトーストで何を出すかなどは実装を一まとめにして使いたい場所で呼び出せた方が便利です。

まず、「呼び出し」を共通化するために service に呼び出し時に使用する関数を定義します。 service は Ember.js においてアプリケーション全体や複数のコンポーネント・コントローラーから共通して利用されるオブジェクトを定義することができます。

// web/app/services/toast.js

import Service from '@ember/service';
import { getOwner } from '@ember/owner';

export default class ToastService extends Service {
  show(body, bgColor) {
    const controller = getOwner(this).lookup('controller:application');

    controller.showToast({ body, bgColor });
  }
}

この部分はトーストの呼び出しAPIを提供すると考えると分かりやすそうです。 つまり、「何を出すか」だけを service は知っている状態になっています。

ここでポイントとなるのが上記コードの

const controller = getOwner(this).lookup('controller:application');

です。 getOwner(this)thisToastService インスタンスです。ToastService は「何を出すか」だけを知っていて「どう出すか」は知りません。詳細はこのあと触れるのですが「どう出すか」の部分は web/app/controllers/application.js に詳細を定義します。つまり ToastServiceApplicationController に依存していて、getOwner(this).lookup('controller:application') の部分で明示的に「どう出すか」を管理しているコントローラを見つけ出すようにしています。

web/app/controllers/application.js を見ていく前に、service を呼び出している部分を見ていきたいと思います。

// web/app/controllers/admin/posts/new.js

import Controller from '@ember/controller';
import { action } from '@ember/object';
import { service } from '@ember/service';

export default class AdminPostsNewController extends Controller {
  @service router;
  @service toast;
  @service session;

  @action
  async createPost(event) {
    event.preventDefault();

    const response = await fetch('http://localhost:3000/posts', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.session.token}`,
      },

      body: JSON.stringify({
        post: {
          title: this.model.title,
          body: this.model.body,
          tag_names: this.model.tags.split(','),
        },
      }),
    });

    if (!response.ok) {
      const json = await response.json();
      this.model.errors = json.errors;
    } else {
      this.router.transitionTo('admin.posts');

      this.toast.show('Post created successfully', 'success');
    }
  }
}

トースト呼び出しを実行しているのは一番最後の this.toast.show('Post created successfully', 'success'); の部分ですね。POST リクエストのレスポンスが ok だった場合にトーストが表示されるようになっています。

続いて、トーストを「どう出すか」の役割を担っている web/app/controllers/application.js の内容を見ていきたいと思います。

// web/app/controllers/application.js

import Controller from '@ember/controller';
import { action } from '@ember/object';
import { modifier } from 'ember-modifier';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

import { Toast } from 'bootstrap';
import { scheduleTask } from 'ember-lifeline';

export default class ApplicationController extends Controller {
  @service toast;

  @tracked toastData = [];

  toasts = new Map();

  setToast = modifier((el) => {
    const toast = new Toast(el);

    this.toasts.set(el, toast);

    const handler = () => {
      this.toasts.delete(el);
      this.toastData = this.toastData.filter(({ id }) => id !== el.id);
    };

    el.addEventListener('hidden.bs.toast', handler);

    return () => {
      el.removeEventListener('hidden.bs.toast', handler);
    };
  });

  @action
  showToast(data) {
    const id = crypto.randomUUID();

    this.toastData = [...this.toastData, { id, ...data }];

    scheduleTask(this, 'render', () => {
      const entry = [...this.toasts].find(([el]) => el.id === id);

      if (!entry) return;

      const [, toast] = entry;

      toast.show();
    });
  }
}

まず、setToast = modifier() の部分を見ていきます。modifier とは、要素(DOM)に対して、ライフサイクルに応じた特定の動作や振る舞いを紐づけるための仕組みのことです。

この setToast = modifier() は、画面上に表示される各トースト要素に対して、Bootstrapのトースト動作を紐付けし、ライフサイクル管理(初期化・監視・後始末)を行う役割を持っています。

具体的には、

  • ターゲットとする要素(DOM)が出現した時に、new Toast(el) でトーストインスタンスを生成し、内部の toasts Map に保存する -トーストが閉じられたタイミング(hidden.bs.toast イベント)で、そのトーストインスタンスと対応する表示データ(toastData)を削除し、画面と内部状態の両方を最新化する
  • ターゲットとする要素(DOM)が削除されるときは、イベントリスナーを解除してクリーンアップする

という流れを管理しています。

続いて showToast アクションを見ていきます。この部分はトーストを画面上に表示するために、this.toastData の配列に対して表示させるトーストデータを登録して、タイミングを管理してDOM描画が完了した後にトーストを表示させる役割を持っています。このアクションは 一番初めに実装を確認した service で呼び出す形で使用されています。

詳細を順番に見ていくと、各トーストを一意に識別できるようにするために以下で一位なIDを生成しています。

const id = crypto.randomUUID();

toastData 配列に新しいトースト情報(メッセージ・トーストの背景色など)を追加している部分です。 テンプレート側でこのデータをもとにトースト用のHTMLが描画されます。

this.toastData = [...this.toastData, { id, ...data }];

ここでは Ember.js の scheduleTask(this, 'render', callback) を使ってDOM描画が完了した上でtoast.show() を実行するようにしています。 こうしないとDOM描画が完了前に toast.show() が実行されてしまう可能性があり、適切にトーストが表示されないことがあります。

scheduleTask(this, 'render', () => {
  const entry = [...this.toasts].find(([el]) => el.id === id);
  if (!entry) return;
  const [, toast] = entry;
  toast.show();
});

最後にトーストを表示するためのテンプレートの実装を見ていきます。

<div class="toast-container position-fixed top-0 end-0 p-3">
  {{#each this.toastData as |toast|}}
    <div
      id={{toast.id}}
      class="toast align-items-center text-bg-{{toast.bgColor}} border-0"
      data-bs-delay="2000"
      role="alert"
      aria-live="assertive"
      aria-atomic="true"
      {{this.setToast}}
    >
      <div class="d-flex">
        <div class="toast-body">
          {{toast.body}}
        </div>
        <button
          type="button"
          class="btn-close btn-close-white me-2 m-auto"
          data-bs-dismiss="toast"
          aria-label="Close"
        ></button>
      </div>
    </div>
  {{/each}}
</div>

ここはBootstrapのトーストコンポーネントをベースに application.js で定義した toastData のデータを表示させる部分です。this.setToast を呼び出しているdivapplication.jssetToast = modifier((el) => {} がターゲットにしている el に該当します。 toastData は配列で複数のトーストが出ることを想定して(現状そのパターンはないけど・・・) #each で実行されています。

まとめ

ここまで実装を見てきたのですが、トーストが表示されるまでの流れを改めてまとめておきたいと思います。

  1. web/app/controllers/admin/posts/new.js から this.toast.show(...) が呼び出される ↓
  2. ApplicationController の showToast アクションが発火 ↓
  3. 配列 toastData にトーストデータが追加され、DOM に divレンダリングされる ↓
  4. setToast モディファイアが発動し、 new Toast(el) でBootstrapのトーストインスタンスを生成 ↓
  5. scheduleTaskで DOM レンダリング完了後、toast.show() を実行 ↓
  6. トーストが実際に表示される

「トースト」を表示するという要件一つに対して考えるべき点がたくさんあって、なかなかに難しかったです。 ですが、設計やDOM描画のタイミングについてなど勉強になる部分がたくさんありました。

引き続きフロントエンドの学習も頑張りたいと思います!