Ionicアプリを作る際

Reactではなく

Angularを採用したわけ

株式会社TRIVE GROUP

くまぽん(なかの)

中野 伸吾

もくじ

  • 自己紹介
  • アーキ選定の話
     
  • Angular Universalでハマったこと
  • SSRモードでLocalStorageを使う
  • RxJSとPromiseの両立

自己紹介

くまぽん(なかの)   中野伸吾

株式会社TRIVE GROUP リードエンジニア

Angular歴:半年ちょっと

 

Twitter:  s_nakano0514

GitHub:  nakano-shingo

Wantedly

アーキ選定の話

アーキ判断基準

  • 採用難易度
    今後のエンジニアの市場的にシェアあるかどうか
     
  • 開発コスト
    React/Ionic or Angular/Ionic(SSR) < React/Next
     
  • SEO観点
    React/Next or Angular/Ionic(SSR) > React/Ionic
     
  • パフォーマンス
    React/Next > Angular/Ionic or React/React (?)
     
  • SHOPPLが実現したい世界観
    ※ デザインの雰囲気+遷移系アニメーションなど、UIコンポーネントの得意/不得意を含めて方針決め
    結論、Ionicは使いたい

余談

検証の一環で、React/Next.jsのアプリケーションに Ionic@coreの導入を試した
→ ルーティング設定周りで沼にハマりそうになったので、早めに検証を打ち止めた

最初の有力候補は

React/Ionicでした

SEOリスクを軽減したい

  1. SPA × Dynamic Rendering

  2. SSR

    Nuxt/React or Ionic/Angular (+ Angular Universal)

選択肢は、以下の2択

Dynamic Rendering

検証結果、うまくいかず断念

Ionic系のCustom Elementsの変換が上手くいかない

Rendertronを使って検証

結論

Ionicは利用したい+SPAは
(SEOリスクを回避するのが)厳しい

Ionic Angular (+Angular Universal)

の採用を決めた

Angular Universalでハマったこと

windowオブジェクトは使えない

  • 例:「window.location.hostname」などは使えない
const routes: Routes = [
  {
    path: '',
    loadChildren: () => {
      if (typeof window !== 'undefined' && isPlatform('capacitor')) {
        // アプリ(下タブあり)
        return import('./tabs/tabs.module').then((m) => m.TabsPageModule);
      } else {
        // Web(下タブなし)
        return import('./about/about.module').then((m) => m.AboutPageModule);
      }
    },
  },

Capacitorビルドした際にAuth0が上手く動かなかった

  • iFrame周りの挙動が怪しくて動かなかった
  • おそらくAngular Universal周りが悪さをしていた?

結果的にFirebase Authを採用した

SSRモードの罠 1

  • Ionicのライフサイクルメソッド(ionViewWillEnterなど)は、SSRモードでは上手く動かない
    ※ ngOnInitは問題なく動く
import { Component, Inject, OnInit } from '@angular/core';
import { Meta } from '@angular/platform-browser';

@Component({
  selector: 'app-detail',
  templateUrl: './detail.page.html',
  styleUrls: ['./detail.page.scss'],
})
export class DetailPage implements OnInit {

  constructor(
    public meta: Meta,
    @Inject(Localstorage) private localStorage
  ) {}

  ngOnInit() {
    // これはOK
    this.meta.updateTag({
      property: 'og:url',
      content: `${environment.frontUrl}${this.router.url}`,
    });
  }

  ionViewWillEnter() {
    // こちらはNG
    this.meta.updateTag({
      property: 'og:url',
      content: `${environment.frontUrl}${this.router.url}`,
    });
  }
}

SSRモードの罠 2

本番ビルドするとIonicがMaterial Designモード固定になる
(iOSのUserAgentであっても、無条件でmdモードになる)

公式Issueでも取り上げられている

既知の伸び代

SSRモードでLocalStorageを使う

import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {
  static isBrowser = new BehaviorSubject<boolean>(null);

  constructor(@Inject(PLATFORM_ID) private platformId: any) {
    AppComponent.isBrowser.next(isPlatformBrowser(platformId));
  }
}

src/app/app.component.ts

ブラウザ or サーバ描画の判定

LocalStorageをSSRで使うための抽象化クラスを用意

import { Injectable } from '@angular/core';
import { AppComponent } from '../app/app.component';

class LocalStorage implements Storage {
  [name: string]: any;
  readonly length: number;
  clear(): void {}
  getItem(key: string): string | null {
    return undefined;
  }
  key(index: number): string | null {
    return undefined;
  }
  removeItem(key: string): void {}
  setItem(key: string, value: string): void {}
}

@Injectable({
  providedIn: 'root',
})
export class Localstorage implements Storage {
  private storage: Storage;

  constructor() {
    this.storage = new LocalStorage();

    AppComponent.isBrowser.subscribe((isBrowser) => {
      if (isBrowser) {
        this.storage = localStorage;
      }
    });
  }

  [name: string]: any;

  length: number;

  clear(): void {
    this.storage.clear();
  }

  getItem(key: string): string | null {
    return this.storage.getItem(key);
  }

  key(index: number): string | null {
    return this.storage.key(index);
  }

  removeItem(key: string): void {
    return this.storage.removeItem(key);
  }

  setItem(key: string, value: string): void {
    return this.storage.setItem(key, value);
  }
}

src/local-storage.ts

抽象化した

LocalStorageクラスの利用

import { Injectable, Inject } from '@angular/core';
import { AngularFireAuth } from '@angular/fire/auth';
import { FirebaseUser } from '../../model/user';
import { Localstorage } from '../local-storage';
import * as firebase from 'firebase/app';
import 'firebase/auth';

@Injectable({
  providedIn: 'root',
})
export class AuthenticationService {

  constructor(
    public ngFireAuth: AngularFireAuth,
    @Inject(Localstorage) private localStorage
  ) {}

  getCurrentUser() {
    this.ngFireAuth.authState.subscribe((firebaseUser: FirebaseUser) => {
      this.localStorage.setItem('user', JSON.stringify(firebaseUser));
    });
  }
}

src/app/service/authentication.service.ts  ※ 一部抜粋

参考

RxJSとPromiseの両立

Promise処理と共存の
ベストプラクティスがわからない

  async getItem(itemId: number): Promise<Observable<Item>> {
    let params;
    await this.authService.initFirebaseAuth();

    if (firebase.auth().currentUser) {
      const token = await firebase.auth().currentUser.getIdToken(true);
      params = new HttpParams().set('token', token);
    }

    return this.http
      .get<Item>(`${this.baseUrl}/items/${itemId}`, { params })
      .pipe(
        map((item) => {
          return item;
        })
      );
  }

最後になりますが

仲間を探しています!

【こんな方にぴったり】
◆ 創業メンバーとガッツリ働いてみたい方
◆ 事業視点と技術向上を両立したい方
◆ これから伸びていく市場で働いてみたい方
◆ 会社や事業の成長を肌で感じたい方

この世にまだないプロダクトを生み出したい方

 

【具体的な仕事内容】
◆ 自社サービス/新規事業のグロースに向けた開発業務(フロントエンド・バックエンド)

 

【開発環境】
・Ruby (Ruby on Rails)
・Ionic/Angular
・MySQL
・Docker
・Firebase(Auth/Analytics等)
・GCP (App Engine/Cloud SQL/Cloud Function等)
・GitHub Actions (自動テスト/自動デプロイ等)

気になる方は、私のTwitter宛に

DMお待ちしてます!!

ご静聴
ありがとうございました

Ionicアプリを作る際、ReactではなくAngularを採用したわけ

By shingo-nakano

Ionicアプリを作る際、ReactではなくAngularを採用したわけ

  • 323