@ionic/vueでハイブリッドアプリを作ってみた

## 作ったアプリ
![memo_gif.gif](https://qiita-image-store.s3.amazonaws.com/0/158622/db59e5f7-a1d6-f11c-f4fa-bc25cc9e7fa3.gif)

こんなアプリを[@ionic/vue](https://github.com/ModusCreateOrg/ionic-vue)で作ってみました。
アプリの機能としては

- アプリっぽい動きを実装する
  - スマホっぽい無限スクロールで、スクロールの下限に達したらAPIでfetchして、スクロールを伸ばす
- メモの作成
- メモの閲覧

を実装しました。

## 書くきっかけ
[A Vue from Ionic](https://blog.ionicframework.com/a-vue-from-ionic/)という記事を見つけて、自分で試してみようと思ったのがきっかけです。
調べてみたら[こんな記事](https://moduscreate.com/blog/using-ionic-4-in-vue-applications-with-beep/)を発見。[beep](https://github.com/ModusCreateOrg/beep)というオープンソースのサンプルアプリがあったので、これを参考にして開発を進めることにしました。

## 利用したライブラリ
```json:package.json
{
  "name": "vue-ionic-sample",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "build-android": "npx cap sync android && npx cap copy android",
    "build-ios": "npx cap sync && npx cap copy ios"
  },
  "dependencies": {
    "@capacitor/cli": "^1.0.0-beta.11",
    "@capacitor/core": "^1.0.0-beta.11",
    "@capacitor/ios": "^1.0.0-beta.11",
    "@ionic/core": "^4.0.0-beta.17",
    "@modus/ionic-vue": "^1.1.1",
    "axios": "^0.18.0",
    "vue": "^2.5.17",
    "vue-class-component": "^6.0.0",
    "vue-property-decorator": "^7.0.0",
    "vue-router": "^3.0.1",
    "vue-router-layout": "^0.1.2",
    "vuex": "^3.0.1",
    "vuex-class": "^0.3.1",
    "vuex-type-helper": "^1.2.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "^3.2.0",
    "@vue/cli-plugin-typescript": "^3.2.0",
    "@vue/cli-service": "^3.2.0",
    "typescript": "3.0.3",
    "vue-auto-routing": "^0.3.0",
    "vue-cli-plugin-auto-routing": "^0.2.1",
    "vue-template-compiler": "^2.5.17"
  }
}
```

執筆時点（2018/12/8）では上記のライブラリを利用しています。
vue-cliの開始時点で
- Babel
- TypeScript
- Vue-Router
- Vuex
- Tslint
- vue-class-component

等々はあらかじめ入った状態で、開発をはじめたので独自で導入したプラグインは下記になります

```diff:開発途中で導入したプラグイン
   "dependencies": {
+    "@capacitor/cli": "^1.0.0-beta.11",
+    "@capacitor/core": "^1.0.0-beta.11",
+    "@capacitor/ios": "^1.0.0-beta.11",
+    "@ionic/core": "^4.0.0-beta.17",
+    "@modus/ionic-vue": "^1.1.1",
+    "axios": "^0.18.0",
     "vue": "^2.5.17",
     "vue-class-component": "^6.0.0",
     "vue-property-decorator": "^7.0.0",
     "vue-router": "^3.0.1",
-    "vuex": "^3.0.1"
+    "vue-router-layout": "^0.1.2",
+    "vuex": "^3.0.1",
+    "vuex-class": "^0.3.1",
+    "vuex-type-helper": "^1.2.0"
   },
   "devDependencies": {
     "@vue/cli-plugin-babel": "^3.2.0",
     "@vue/cli-plugin-typescript": "^3.2.0",
     "@vue/cli-service": "^3.2.0",
-    "typescript": "^3.0.0",
+    "typescript": "3.0.3",
+    "vue-auto-routing": "^0.3.0",
+    "vue-cli-plugin-auto-routing": "^0.2.1",
     "vue-template-compiler": "^2.5.17"
   }
 }
```
typescriptのversionが下がってるのは、ライブラリ導入過程で[問題](https://github.com/ktsn/vuex-type-helper/issues/7)が発生したので、そのワークアラウンドです。
開発途中にて導入したそれぞれのライブラリについて簡単に説明したいと思います。

### [@ionic/vue](https://github.com/ModusCreateOrg/ionic-vue)
- vueのtemplateにて、ionicの独自のタグを実装できるようになる
- Vue-Routerをラップした「IonicVueRouter」を利用する必要がある

### [Capacitor](https://github.com/ionic-team/capacitor)
- ハイブリッドアプリのビルドやネイティブのSDKの利用を簡単にできるようになる
- 今回作成したアプリでは、vueのビルド結果をxcodeのプロジェクトとしてビルドするために利用している

### [vue-cli-plugin-auto-routing](https://github.com/ktsn/vue-cli-plugin-auto-routing)
- vue-cliのroutingが[Nuxt.js](https://github.com/nuxt/nuxt.js)と同様になる
- ただ、今回はIonicVueRouterに関連した問題が発生したため、完全にNuxt.jsのroutingに同じになっていない

## 実装の解説
### main.ts/index.html
```ts:main.ts
import Vue from 'vue';
import { Ionic, IonicAPI } from '@modus/ionic-vue';

import router from './router';
import store from './stores/store';

Vue.config.productionTip = false;

Ionic.init();
Vue.use(IonicAPI);

new Vue({
  router,
  store,
}).$mount('#app');
```

```html:index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title>vue-ionic-sample</title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but vue-ionic-sample doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <ion-app id="app">
      <ion-vue-router/>
    </ion-app>
    <!-- built files will be auto injected -->
  </body>
</html>
```

[GettingStarted](https://github.com/ModusCreateOrg/ionic-vue#installing--getting-started)に載っているように、vueの起動処理を変更しました。
また、index.htmlにionincの独自タグを書くにあたり `vue.config.js` に 

```js:vue.config.js
runtimeCompiler: true
```

を追加しました。

### router.ts
#### IonicVueRouterへの対応

```ts:router.ts
import Vue from 'vue';
import { IonicVueRouter } from '@modus/ionic-vue';
import routes from 'vue-auto-routing';

Vue.use(IonicVueRouter);

export default new IonicVueRouter({
  // @see node_modules/@modus/ionic-vue/src/router.ts
  direction: 1,
  viewCount: 0,
  mode: 'history',
  routes: routes.map((route) => {
    return { ...route, path: '/' + route.path };
  }),
});
```

[GettingStarted](https://github.com/ModusCreateOrg/ionic-vue#installing--getting-started)に載っているように、routingを変更しました。
ただ、typescriptを利用していると、下記の `direction` と `directionOverride` の型定義が原因でコンパイルができないという問題が発生しました。

```ts:node_modules/@modus/ionic-vue/types/router.d.ts
export default class Router extends _VueRouter {
    direction: number;
    directionOverride: number | null;
    viewCount: number;
    prevRouteStack: Route[];
    history: any;
    static installed: boolean;
    static install: PluginFunction<never>;
    constructor(args?: RouterArgs);
    extendHistory(): void;
    canGoBack(): boolean;
    guessDirection(nextRoute: Route): number;
}
```

そこで、IonicVueRouterのコンストラクタを見たところ、下記のように引数に値がない場合の初期値が定義されていたので、その値をIonicVueRouterの初期化処理にて代入しています。

```ts:node_modules/@modus/ionic-vue/src/router.ts
export default class Router extends _VueRouter {
  direction: number;
  directionOverride: number | null;
  viewCount: number;
  prevRouteStack: Route[];
  history: any;
  static installed: boolean;
  static install: PluginFunction<never>;

  constructor(args: RouterArgs = {} as RouterArgs) {
    super(args);

    // The direction user navigates in
    this.direction = args.direction || 1;

    // Override normal direction
    this.directionOverride = null;

    // Number of views navigated
    this.viewCount = args.viewCount || 0;
```

#### vue-cli-plugin-auto-routingへの対応
vue-cli-plugin-auto-routingを導入した時点では、router.tsが下記のように更新されます。

```ts:router.ts
import Vue from 'vue';
import Router from 'vue-router';
import routes from 'vue-auto-routing';
import { createRouterLayout } from 'vue-router-layout';

Vue.use(Router);

const RouterLayout = createRouterLayout(layout => {
  return import('@/layouts/' + layout + '.vue');
});

export default new Router({
  routes: [
    {
      path: '/',
      component: RouterLayout,
      children: routes
    }
  ]
})
const RouterLayout = createRouterLayout(layout => {
  return import('@/layouts/' + layout + '.vue');
});

export default new Router({
  routes: [
    {
      path: '/',
      component: RouterLayout,
      children: routes
    }
  ]
})
```

`/` を対象とした親のroutingが一つあり、その親がlayoutをcomponentとして持ち、さらにchildrenとしてpages以下を自動生成したroutingが持つという感じです。
ただ、これだと下記の@ionic/vue側の実装と相性が悪いです。

```vue:node_modules/@modus/ionic-vue/src/components/ion-vue-router.vue
<template>
  <ion-router-outlet
    ref="ionRouterOutlet"
    @click="catchIonicGoBack">
    <router-view
      v-if="customTransition"
      :name="name"/>
    <transition
      v-else
      :css="bindCSS"
      mode="in-out"
      @before-enter="beforeEnter"
      @enter="enter"
      @after-enter="afterEnter"
      @enter-cancelled="enterCancelled"
      @before-leave="beforeLeave"
      @leave="leave"
      @after-leave="afterLeave"
      @leave-cancelled="leaveCancelled"
    >
      <router-view :name="name"/>
    </transition>
  </ion-router-outlet>
</template>
```

なぜなら、親のroutingが `/` を対象とした一つであるため、URLが変更された場合に

```html
<router-view
  v-if="customTransition"
  :name="name"/>
```
の変更が検知されず、transitionの描画が実行されないためです。

そのため対応としては、`/` を対象とした親のroutingを挟まずに直接にchildrenを `router.ts` に配置するようにしました。

```ts:router.ts
  routes: routes.map((route) => {
    return { ...route, path: '/' + route.path };
  }),
```
こうすることで、 `router-view` が変更され、transitionの描画が実行されます。

### pages/about.vue
```vue:pages/about.vue
<template>
  <ion-page class="ion-page">
    <ion-header class="header">
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-back-button default-href="/" />
        </ion-buttons>
        <ion-title>このアプリについて</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content class="content">
      <h2>
        利用してる技術
      </h2>
      <ion-list>
        <ion-item>vue-cli</ion-item>
        <ion-item>ionic</ion-item>
        <ion-item>capacitor</ion-item>
      </ion-list>
      <h2>スマホっぽいスクロール</h2>
      <ion-list>
        <ion-item v-for="(item, index) in itemsForList" :key="index">
          {{item}}
        </ion-item>
      </ion-list>

      <ion-infinite-scroll @ionInfinite="doInfinite">
        <ion-infinite-scroll-content></ion-infinite-scroll-content>
      </ion-infinite-scroll>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import axios from 'axios';

@Component
export default class About extends Vue {
  public items: string[] = [];

  get itemsForList() {
    return this.items;
  }

  async created() {
    const posts = await axios.get('https://jsonplaceholder.typicode.com/posts');
    this.items = posts.data.map((post: any) => {
      return post.title;
    });
  }

  async doInfinite(ionInfiniteEvent: any) {
    const posts = await axios.get('https://jsonplaceholder.typicode.com/posts');
    const body = posts.data.map((comment: any) => {
      return comment.body;
    });

    setTimeout(() => {
      this.items.push(...body);
      ionInfiniteEvent.target.complete();
    }, 500);
  }
}
</script>
```

`/about` のrouting先です。
<ion-infinite-scroll>でスクロールが下限に達したら `ionInfinite` というeventがdispatchされるので、それをキャッチして `doInfinite()` を実行します。
`doInfinite()`　では、 [jsonplaceholder](https://github.com/typicode/jsonplaceholder)というAPIを叩いて、それを `this.items` に追加しています。
また、Loadingっぽさを演出するため500ミリ秒待たせています。

### pages/create/index.vue
```vue:pages/create/index.vue
<template>
  <ion-page class="ion-page">
    <ion-header class="header">
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-back-button default-href="/" />
        </ion-buttons>
        <ion-title>メモを作る</ion-title>
        <ion-buttons slot="end">
          <ion-button :disabled="!isValidMemo" clear @click="createMemo">
            <span v-if="requestPending">
              <ion-spinner/>
            </span>
            <span v-else>Done</span>
          </ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>
    <ion-content class="content">
      <ion-textarea
        class="textarea"
        type="text"
        :value="memo"
        large
        @input="memo = $event.target.value"
        placeholder="メモを入力してね"
      ></ion-textarea>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Action, Getter } from 'vuex-class';

@Component
export default class About extends Vue {
  public memo: string = '';
  public requestPending: boolean = false;

  @Action('memo/add') addMemo: any;
  @Getter('memo/memos') memos!: string[];

  get isValidMemo() {
    return this.memo !== '';
  }

  createMemo() {
    this.requestPending = true;
    setTimeout(() => {
      this.addMemo(this.memo);
      this.requestPending = false;
      this.$router.push('/');
    }, 500);
  }
}
</script>
```

`/create` のrouting先です。
右上のDONEボタンをクリックしたら `createMemo()` が実行されます。
`createMemo()` では `this.memo` をVuexのstoreに保存しています。
また、Savingっぽさを演出するため500ミリ秒待たせています。

### pages/read/index.vue
```vue:pages/read/index.vue
<template>
  <ion-page class="ion-page">
    <ion-header class="header">
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-back-button default-href="/" />
        </ion-buttons>
        <ion-title>メモ一覧</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content class="content">
      <ion-list>
        <ion-item v-for="(memo, index) in memos" :key="index">
          {{memo}}
        </ion-item>
      </ion-list>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Getter } from 'vuex-class';

@Component
export default class About extends Vue {
  @Getter('memo/memos') memos!: string[];
}
</script>
```

`/read` のrouting先です。
VuexのStoreに保存した `memos` を表示します。

### アプリのビルド方法
下記のように `capacitor.config.json` の `webDir` に、ビルド結果が生成されるフォルダを指定します。

```json:capacitor.config.json
{
  "appId": "com.example.app",
  "appName": "vue-ionic-memo",
  "bundledWebRuntime": false,
  "webDir": "dist"
}
```


そしてプロダクションビルドを実行したあとに `package.json` に書いた下記のスクリプトを実行します。

```json:package.json
"build-ios": "npx cap sync && npx cap copy ios"
```

詳細についてはCapacitorの[公式ドキュメント](https://capacitor.ionicframework.com/docs/ios) を御覧ください。

## 作ってみた感想
### with Nuxt.js ???
開発当初は、自分の所属する[オープンロジ](https://www.openlogi.com/)で利用しているNuxt.jsで@ionic/vueの導入を試みました。
ただVue-RouterをIonicVueRouterに置き換える際に、Nuxt.jsでは実現が困難だったため、vue-cliでやることにしました。
結果としては、世間でよく言われる「Nuxt.jsは規約/vue-cliはカスタマイズ」というメリット・デメリットを理解することができたりで、非常に勉強になりました。
### @ionic/vue
ほぼvueだけでネイティブアプリっぽい動きを実現できるのは素晴らしいなと思います。
もし時間があったら、vue-nativeと比較して、双方のメリット・デメリット等を勉強してみたいと思います。