LoginSignup
30
22

More than 5 years have passed since last update.

FirebaseのWebAppチュートリアルをAngular2で書き直してみた。(2.0)

Last updated at Posted at 2016-08-14

(追記)2.0に対応しました。(2016/09/15)

今回のGitHubリポジトリ→ovrmrw/friendlychat-ng2

先日Firebaseの公式サイトでWebアプリのチュートリアルを発見しまして、これをやってみたらDatabaseだけじゃなくAuthとStorageの使い方の勉強にもなりました。これはWebアプリ初心者なら一度はやっておくべきチュートリアルだなと思ったのですが、JavaScriptファイルがVanillaJSで書かれていて個人的に解釈するのがとても大変でした。

そこで「これはよろしくない」と思い、多くの人が容易に理解可能なAngular2スタイルに書き直さなければならないと思い立ちまして、今回のエントリーに至った次第です。

Firebase Hostingにもデプロイしてありますのでよろしければどうぞ。

大体オリジナルをコピーできているかなと思います。(完全ではない)

Firebaseを1ファイルでコントロールする

Firebaseを直接叩いているのは下記のコードです。ComponentはServiceを経由してここにアクセスします。

ちなみにオリジナルのJSコードはこちら→main.js

オリジナルを時にはほぼ忠実に、時には適当に書き換えて作りました。

src/firebase/firebase.controller.ts
import firebase from 'firebase';
import { Observable, Subject, BehaviorSubject, ReplaySubject } from 'rxjs/Rx';
import lodash from 'lodash';
import { MessageType } from '../types';

const config = {
  apiKey: 'AIzaSyDUGgHesSALxeaKYH_qyt-NxPndMgD6MVI',
  authDomain: 'friendlychat-d014b.firebaseapp.com',
  databaseURL: 'https://friendlychat-d014b.firebaseio.com',
  storageBucket: 'friendlychat-d014b.appspot.com',
};

const LOADING_IMAGE_URL = 'https://www.google.com/images/spin-32.gif';

export class FirebaseController {
  private auth: firebase.auth.Auth;
  private database: firebase.database.Database;
  private storage: firebase.storage.Storage;
  private messagesRef: firebase.database.Reference;
  private authSubject$: Subject<firebase.User | null>;
  private messages: MessageType[] = [];
  private messagesSubject$: Subject<MessageType[]>;
  private informStableSubject$: Subject<boolean>;

  constructor() {
    firebase.initializeApp(config);

    this.authSubject$ = new BehaviorSubject<firebase.User | null>(null);
    this.messagesSubject$ = new ReplaySubject<MessageType[]>();
    this.informStableSubject$ = new ReplaySubject<boolean>();

    this.auth = firebase.auth();
    this.database = firebase.database();
    this.storage = firebase.storage();

    // サインインかサインアウトをするとその都度この処理が走る。
    this.auth.onAuthStateChanged((user: firebase.User) => {
      if (user) { // User is signed in!
        this.authSubject$.next(user);
      } else { // User is signed out!
        this.authSubject$.next(null);
      }
    });

    this.messagesRef = this.database.ref('messages');
  }

  signIn() {
    // Sign in Firebase using popup auth and Google as the identity provider.
    const provider = new firebase.auth.GoogleAuthProvider();
    this.auth.signInWithPopup(provider);
  }

  signOut() {
    // Sign out of Firebase.
    this.auth.signOut();
  }

  saveMessage(message: string) {
    if (message) {
      const currentUser = this.auth.currentUser;
      if (currentUser) {
        // Add a new message entry to the Firebase Database.
        this.messagesRef.push({
          name: currentUser.displayName,
          text: message,
          photoUrl: currentUser.photoURL || '/images/profile_placeholder.png'
        }).then(() => {
          this.informStableSubject$.next(true);
        }).catch((error) => {
          console.error('Error writing new message to Firebase Database', error);
        });
      }
    }
  }

  saveImageMessage(file: File) {
    if (file) {
      // We add a message with a loading icon that will get updated with the shared image.
      const currentUser = this.auth.currentUser;
      if (currentUser) {
        this.messagesRef.push({
          name: currentUser.displayName,
          imageUrl: LOADING_IMAGE_URL,
          photoUrl: currentUser.photoURL || '/images/profile_placeholder.png'
        }).then((data) => {
          // Upload the image to Firebase Storage.
          const uploadTask = this.storage.ref(currentUser.uid + '/' + Date.now() + '/' + file.name)
            // .put(file, { 'contentType': file.type });
            .put(file);
          // Listen for upload completion.
          uploadTask.on('state_changed', null, (error) => {
            console.error('There was an error uploading a file to Firebase Storage:', error);
          }, () => {
            // Get the file's Storage URI and update the chat message placeholder.
            const filePath = uploadTask.snapshot.metadata.fullPath;
            data.update({ imageUrl: this.storage.ref(filePath).toString() });
          });
        });
      }
    }
  }

  setImageUrl(imageUrl: string): Observable<string> {
    if (!imageUrl.startsWith('gs://')) { // 変数imageUrlがFirebase Storageのものではない場合、そのまま返す。
      return Observable.of(imageUrl);
    }
    // If the image is a Firebase Storage URI we fetch the URL.
    const subject = new BehaviorSubject<string>(LOADING_IMAGE_URL); // とりあえずグルグルを表示させる。

    this.storage.refFromURL(imageUrl).getMetadata().then((metadata) => {
      subject.next(metadata.downloadURLs[0]); // imageファイルの本来のURLを取得したら差し替える。
      subject.complete(); // 適切にcompleteしないとメモリリークの原因になる。
    });
    return subject.asObservable();
  }

  loadMessages() {
    this.messages = [];
    // Make sure we remove all previous listeners.
    this.messagesRef.off();

    // Loads the last 12 messages and listen for new ones.
    this.messagesRef.limitToLast(12).on('child_added', (snapshot: firebase.database.DataSnapshot) => {
      const message = lodash.defaultsDeep(snapshot.val(), { key: snapshot.key }) as MessageType;
      this.messages.push(message);
      this.messagesSubject$.next(this.messages);
      this.informStableSubject$.next(true);
    });
    this.messagesRef.limitToLast(12).on('child_changed', (snapshot: firebase.database.DataSnapshot) => {
      const message = lodash.defaultsDeep(snapshot.val(), { key: snapshot.key }) as MessageType;
      this.messages = lodash.reject(this.messages, { key: snapshot.key });
      this.messages.push(message);
      this.messagesSubject$.next(this.messages);
      this.informStableSubject$.next(true);
    });
  }

  get currentUser$() { return this.authSubject$.asObservable(); }
  get messages$() { return this.messagesSubject$.asObservable(); }
  get informStable$() { return this.informStableSubject$.asObservable(); }
}

サインイン、サインアウトの状態をrxjsで制御する

当然のことながらサインイン、サインアウトをしたときにアプリはその状態を反映させなければなりません。
このサンプルでは、rxjsのSubjectを使って
FirebaseController → AppService → AppComponent
の順でcurrentUser$ストリームを流しています。

AppComponentではrxjsのSubscriptionがメモリリークを起こさないようにngOnDestroy()でdisposeしています。この辺りはrxjsを扱うときの基本とも言えます。

src/app/app.service.ts
// 抜粋
@Injectable()
export class AppService {
  constructor(
    private fc: FirebaseController
  ) { }

  get currentUser$() { return this.fc.currentUser$; }  
}
src/app/app.component.ts
// 抜粋
  ngOnInit() {
    this.disposable = this.service.currentUser$.subscribe(user => {
      if (user) { // Firebaseからサインインの状態を受け取った。
        this.isAuthed = true;
      } else { // Firebaseからサインアウトの状態を受け取った。
        this.isAuthed = false;
      }
      this.cd.markForCheck();
    });
  }

  ngOnDestroy() {
    this.disposeSubscriptions(); // Subscriptionをまとめて破棄する。適切なメモリ管理はrxjsの基本。
  }

またAppComponentはAngular2のtemplateを通じて子Componentに isAuthed の値を渡しています。

src/app/app.template.html
<!-- 抜粋 -->
  <chat-header [isAuthed]="isAuthed"></chat-header>
  <chat-main [isAuthed]="isAuthed"></chat-main>

こうやってコンポーネントツリー全体にサインイン、サインアウトの状態を通知しているわけですね。

SnackBarをrxjsで制御する

Firebaseチュートリアルで使われるCSSフレームワークはMaterial Design Liteというもので、その機能の一つとして SnackBarというものがあります。Toastrみたいな通知機能です。

今回のサンプルではこれもrxjsのSubjectで制御しています。

src/main/main.component.ts
// 抜粋
  snackbarText$ = new Subject<string>();

  onSubmitMessage() {
    if (this.isAuthed) { // サインインしている。
      this.service.send(this.text);
    } else { // サインインしていない。
      this.snackbarText$.next('You must sign-in first');
    }
  }

  onSelectImageFile(event) { // ファイルを選択した。
    if (event.target && event.target.files && event.target.files.length) {
      const file = event.target.files[0] as File;
      console.log(file);
      if (file.type.match('image.*')) { // imageファイルを選択した。
        this.service.saveImage(file);
      } else { // imageファイルではないものを選択した。
        this.snackbarText$.next('You can only share images');
        this.resetForm();
      }
    }
  }

上記コードの中で snackbarText$.next(...) と書いてある部分がそうなのですが、表示させたい文字列を渡すとtemplateを通じてChatSnackbarComponentのsnackbarText(Observable)のsubscribe()に流れます。
その結果next()する度にSnackBarが画面に表示される、というわけです。

src/main/main.template.html
<!-- 抜粋 -->
  <chat-snackbar [snackbarText]="snackbarText$"></chat-snackbar>
src/snackbar/snackbar.component.ts
// 抜粋
export class ChatSnackbarComponent extends ParentComponent implements OnInit, OnDestroy {
  @Input() snackbarText: Observable<string>; // ChatMainComponentから.next()で新しい値を送り込む。

  constructor(
    private cd: ChangeDetectorRef,
    private el: ElementRef
  ) {
    super();
  }

  ngOnInit() {
    this.disposable = this.snackbarText.subscribe(text => {
      if (text) {
        const data = {
          message: text,
          timeout: 2000
        };
        const element = (<HTMLElement>this.el.nativeElement).querySelector('#must-signin-snackbar') as any;
        element.MaterialSnackbar.showSnackbar(data); // SnackBarを表示する。
        this.cd.markForCheck();
      }
    });
  }

  ngOnDestroy() {
    this.disposeSubscriptions();
  }
}

画像のURLを時間差で差し替える(rxjsで)

実際の動きを見てもらえればわかりますが、画像ファイルを表示する場合はまず最初にグルグルを表示させて、その後Firebase Storageから本来のURLを取得次第差し替えをしています。これもrxjsのSubjectです。

下記はFirebaseControllerのコード。

src/firebase/firebase.controller.ts
// 抜粋
  setImageUrl(imageUrl: string): Observable<string> {
    if (!imageUrl.startsWith('gs://')) { // 変数imageUrlがFirebase Storageのものではない場合、そのまま返す。
      return Observable.of(imageUrl);
    }
    // If the image is a Firebase Storage URI we fetch the URL.
    const subject = new BehaviorSubject<string>(LOADING_IMAGE_URL); // とりあえずグルグルを表示させる。

    this.storage.refFromURL(imageUrl).getMetadata().then((metadata) => {
      subject.next(metadata.downloadURLs[0]); // imageファイルの本来のURLを取得したら差し替える。
      subject.complete(); // 適切にcompleteしないとメモリリークの原因になる。
    });
    return subject.asObservable();
  }

これをSerivceを通じてChatMessageComponentが受け取ります。

src/message/message.service.ts
// 抜粋
export class ChatMessageService {
  constructor(
    private fc: FirebaseController
  ) { }

  resolveImageSrc(imageUrl: string) {
    return this.fc.setImageUrl(imageUrl);
  }
}
src/message/message.component.ts
// 抜粋
export class ChatMessageComponent implements OnChanges {
  @Input() imageUrl: string;
  imageSrc: Observable<string>;

  constructor(
    private service: ChatMessageService,
    private cd: ChangeDetectorRef
  ) { }

  ngOnChanges() {
    if (this.imageUrl) {
      this.imageSrc = this.service.resolveImageSrc(this.imageUrl); // 時間差でimageファイルのURLを差し替える。
    }
  }
}

そしてこのimageSrcをtemplateにAsyncPipe付きで渡しています。こうすることで時間差でimgタグのsrcアトリビュートを書き換えることができます。

src/message/message.template.html
<!-- 抜粋 -->
  <div *ngIf="imageUrl" class="message"><img (load)="onLoadImage()" [src]="imageSrc | async"></div>

File APIを使って画像ファイルを選択する

JavaScriptには元々File APIというものがあり、ローカルのファイルを選択することができます。(その後Firebase Storageにアップロードする)

この流れはまずtemplateから始まり、最終的にFirebaseControllerのsaveImageMessage()関数に到達します。

  1. buttonタグをクリックして(click)="mediaCapture.click()"が発火することによりファイル選択ウインドウを表示させる。(inputタグに#mediaCaptureを書いているのでbuttonタグ内でinputタグを参照できる)
  2. 画像ファイルを選択する。
  3. inputタグの(change)="onSelectImageFile($event)"が発火。
  4. ComponentのonSelectImageFile()関数を実行。
  5. ServiceのsaveImage()関数を実行。
  6. FirebaseControllerのsaveImageMessage()関数を実行。
src/main/main.template.html
<!-- 抜粋 -->
  <form id="image-form" action="#">
    <input id="mediaCapture" #mediaCapture (change)="onSelectImageFile($event)" type="file" accept="image/*,capture=camera">
    <button id="submitImage" (click)="mediaCapture.click()" title="Add an image" class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-color--amber-400 mdl-color-text--white">
      <i class="material-icons">image</i>
    </button>
  </form>
src/main/main.component.ts
// 抜粋
  onSelectImageFile(event) { // ファイルを選択した。
    if (event.target && event.target.files && event.target.files.length) {
      const file = event.target.files[0] as File;
      console.log(file);
      if (file.type.match('image.*')) { // imageファイルを選択した。
        this.service.saveImage(file);
      } else { // imageファイルではないものを選択した。
        this.snackbarText$.next('You can only share images');
        this.resetForm();
      }
    }
  }
src/main/main.service.ts
// 抜粋
export class ChatMainService {
  constructor(
    private fc: FirebaseController
  ) { }

  saveImage(file: File) {
    this.fc.saveImageMessage(file);
  }
}
src/firebase/firebase.controller.ts
// 抜粋
  saveImageMessage(file: File) {
    if (file) {
      // We add a message with a loading icon that will get updated with the shared image.
      // 省略
    }
  }

最後に

とりあえずパッと思い付いた解説はこんなところです。後はソースコードを読んでください。(手抜き)

今回VanillaJSで書かれたコードをAngular2に書き直してみてすごく勉強になりました。
僕のようにWebアプリを書いたことがない初心者には特にオススメなので、是非公式チュートリアルをやってみてから、自分流にリメイクしてみてください。

30
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
22