Vue初学者がPokéAPIを用いて簡単なアプリケーションを作成してみた

# はじめに

この記事は、Vue初学者がVueチュートリアルを終えて、何かアウトプットしたいなと考え、簡単なアプリケーションを作成したよと言うものです。

# 背景

#### Vue.jsを学習するきっかけ
→ 学生時代にReact.jsを学習していたこともあり、Webフロントエンドに対して元々興味があったこと
→ 業務でもフロントタスクに参加したいと思っていたこと

# 成果物

#### 概要

・limit(表示したいポケモンの数)とoffset(何番目のポケモンから表示するか)を入力し、検索ボタンをクリックすると、それに応じたポケモンをリスト表示
・各ポケモンをクリックすると、詳細ページに遷移

![list_view.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624643/3e1d6986-acde-20b1-3217-069e86bd0511.png)

![list_view2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624643/ab299d98-cfe2-6ee1-9d0d-088c47150f00.png)

![about_view.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624643/ca02af28-9f67-6b47-0449-bb672de1a0d3.png)

# 使用した技術

1.Vue.js

2.PokéAPI 
(https://pokeapi.co/)

3.Vite 
(https://ja.vitejs.dev/)

4.axios

5.Vuetify 
(https://vuetifyjs.com/en/)

# 概要

## 1. Viteでビルドプロジェクト！

開発環境
> MacBook Air M2(2022)
> macOS:13.0(Ventura)

nodeとnpmのバージョン
```
node -v
v20.10.0
npm -v
10.2.0
```

nodeとnpmが確認できたら、viteでvueプロジェクトを作成します。
作業ディレクトリ内で下記コマンドを実行します。

```
npm init vite
```
その後、フレームワーク等の選択を行います。
今回はvue + javascriptを選択してプロジェクトを作成しました。
```
Select a framework: › - Use arrow-keys. Return to submit.
    Vanilla
❯   Vue
    React
    Preact
    Lit
    Svelte
    Solid
    Qwik
    Others

```
```
Select a variant: › - Use arrow-keys. Return to submit.
    TypeScript
❯   JavaScript
    Customize with create-vue ↗
    Nuxt ↗

```

## 2. ディレクトリ構成

主に、データ、API通信、ルーティング、状態管理、ページのように分割し構成してみました。

```
src
├─ data
|  └─ index.js
├─ network
│  └─ index.js
├─ router
│  └─ index.js
├─ store
│  └─ index.js
└─ pages
│  └─ about
│  │  └─ About.vue
│  └─ list
│     └─ components
│     │  └─ PokemonList.vue
│     └─ Pokemon.vue
└─ App.vue
└─ main.js
```

## 3. ページとコンポーネント

### リスト表示画面

```Pokemon.vue
<template>
  <div class="main-container">
    <div class="title">
      <h1>Pokemon Vue App</h1>
    </div>
    <PokemonListVue :image-url="imageUrl" />
  </div>
</template>

<script>
import PokemonListVue from "./components/PokemonList.vue";

export default {
  data: () => {
    return {
      imageUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/"
    }
  },
  components: {
    PokemonListVue
  }
}

</script>

<style scoped>
.main-container {
  width: 100vh;
}
</style>
```

**Pokemon.vue**では、Titleの表示と、PokemonList.vueの配置を行っています。
その際に、PokemonListのpropsとして、ポケモンの画像をリスト表示する際に使用する、baseとなるポケモンのimageUrlを渡しています。

```PokemonList.vue
<template>
  <v-app>
    <div>
      <v-container class="input-container">
        <v-row>
          <v-col>
            <v-text-field type="number" label="limit" v-model="limitState" />
          </v-col>
          <v-col>
            <v-text-field type="number" label="offset" v-model="offsetState" />
          </v-col>
        </v-row>
      </v-container>
    </div>
    <div class="submit">
      <v-btn @click="submitButton" height="50" width="100">検索</v-btn>
    </div>
    <v-container>
      <v-row>
        <v-col cols="3" v-for="(pokemon, index) in pokemons" :key="'poke' + index">
          <v-card color="#f5f5f5" @click="navigationToAbout(pokemon)">
            <div>
              <v-card-title class="text-h5">
                {{ 'No.' + pokemon.id }}
              </v-card-title>
              <v-card-subtitle>
                {{ pokemon.name }}
              </v-card-subtitle>
            </div>
            <v-avatar class="pokemon-image" size="125" rounded="0">
              <v-img :src="imageUrl + pokemon.id + '.png'" />
            </v-avatar>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </v-app>
</template>

<script>
import { fetchPokemonData } from '../../../network';

export default {
  props: {
    'imageUrl': String,
  },
  data: () => {
    return {
      pokemons: [],
    }
  },
  methods: {
    submitButton() {
      fetchPokemonData(this.$store.state.limit, this.$store.state.offset)
        .then((data) => {
          console.log(data.data.results);
          // this.pokemons = data.data.results
          this.pokemons = [];
          data.data.results.forEach(pokemon => {
            pokemon.id = pokemon.url.split('/')
              .filter(function (part) { return !!part }).pop();
            this.pokemons.push(pokemon);
          });
          console.log(this.pokemons)
        })
        .catch((error) => {
          console.log(error);
        })
    },
    navigationToAbout(data) {
      console.log("about " + data.name)
      this.$router.push({ path: `/about/${data.name}` })
    }
  },
  computed: {
    limitState: {
      get() {
        return this.$store.state.limit
      },
      set(value) {
        this.$store.dispatch("setLimitAction", value)
      }
    },
    offsetState: {
      get() {
        return this.$store.state.offset
      },
      set(value) {
        this.$store.dispatch("setOffsetAction", value)
      }
    },
  },
  mounted: function () {
    fetchPokemonData(this.$store.state.limit, this.$store.state.offset)
      .then((data) => {
        console.log(data.data.results);
        this.pokemons = [];
        data.data.results.forEach(pokemon => {
          pokemon.id = pokemon.url.split('/')
            .filter(function (part) { return !!part }).pop();
          this.pokemons.push(pokemon);
        });
        console.log(this.pokemons)
      })
      .catch((error) => {
        console.log(error);
      })
  },
}
</script>

<style>
.submit {
  margin: 16px;
}

.pokemon-image {
  margin-top: 16px;
  margin-bottom: 16px;
}
</style>
```

**PokemonList.vue**では、propsとして受け取ったbaseのimageUrlを使用して、imageUrl末尾にポケモンのIDを指定することで、画像の表示を行います。
その際、mount時に**fetchPokemonData**をし、表示したいポケモンのリストを取得し、取得したデータからidを取り出し、propsとして受け取ったbaseのimageUrlの末尾に指定することで表示しています。
`<v-img :src="imageUrl + pokemon.id + '.png'" />`

また、検索ボタンのクリックイベントとして、**fetchPokemonData**を登録し、クリックされたタイミングでのlimitとoffsetを渡すことで、入力された数値に応じて表示を切り替えています。

### 詳細画面

```About.vue
<template>
  <v-app>
    <v-container class="center-container">
      <v-row>
        <v-col>
          <div>
            <div class="title">
              <h1>{{ name }}</h1>
            </div>
            <div v-if="mainImageUrl != undefined && mainImageUrl != ''">
              <v-card class="mx-auto" max-width="240" height="240" :color="typeColor" :image="mainImageUrl" />
              <div>
                <div class="other-title">
                  <h3>Other Images</h3>
                </div>
                <v-container>
                  <v-row>
                    <v-col cols="3" v-for="(url, index) in imageUrlList" :key=index>
                      <v-card :color="typeColor">
                        <v-avatar class="pokemon-image" size="125" rounded="0">
                          <v-img :src="url" />
                        </v-avatar>
                      </v-card>
                    </v-col>
                  </v-row>
                </v-container>
              </div>
            </div>
            <div v-else>
              <v-progress-circular :indeterminate="true" :size="100" color="primary" />
            </div>
            <div class="submit">
              <v-btn @click="submitButton" height="50" width="100">戻る</v-btn>
            </div>
          </div>
        </v-col>
      </v-row>
    </v-container>
  </v-app>
</template>

<script>
import { fetchPokemonDetails } from '../../network';
import { getPokemonTypeColor } from '../../data/index';

export default {
  data: () => {
    return {
      name: "",
      mainImageUrl: "",
      imageUrlList: [],
      typeColor: ""
    }
  },
  methods: {
    submitButton() {
      this.$router.push({ path: '/' })
    }
  },
  mounted: function () {
    console.log(this.$route.params)
    this.name = this.$route.params.name
    fetchPokemonDetails(this.name)
      .then((data) => {
        console.log(data.data);
        this.mainImageUrl = data.data.sprites.other['official-artwork'].front_default;
        this.imageUrlList.push(data.data.sprites.front_default);
        this.imageUrlList.push(data.data.sprites.other.dream_world.front_default);
        this.imageUrlList.push(data.data.sprites.other.home.front_default);
        this.imageUrlList.push(data.data.sprites.other.showdown.front_default);
        this.typeColor = getPokemonTypeColor(data.data.types[0].type.name)
      })
      .catch((error) => {
        console.log(error);
      })
  }
}
</script>

<style>
.center-container {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  width: 100vh;
}

.submit {
  margin: 24px;
}

.other-title {
  margin: 24px;
}
</style>
```

詳細画面では、routerのparamsとして受け取ったポケモンの"name"を、fetchPokemonDetailsの引数として渡し、PokéAPIからデータを取得します。
そして、取得した詳細データから、メインで出す画像と、other image として出すその他の画像のURLをそれぞれ、imageUrlとimageUrlListに格納し、表示します。

(今回は簡単な実装を目的としているため、配列にpushしていく形で実装していますが、本来はモデルクラスを作成し、レスポンスデータから必要なものだけ取得し、使いやすい形で保持することをお勧めします。)

## 4. VueRouterでルーティング

```router/index.js
import { createRouter, createWebHashHistory } from "vue-router";
import Home from '../pages/list/Pokemon.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about/:name?',
    name: 'About',
    component: () => import('../pages/about/About.vue'),
    props: true
  }
]
const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router
```

今回作成したアプリケーションは、リスト表示と各ポケモンの詳細画面の２画面から構成されています。
リスト表示画面から各ポケモンの詳細画面に遷移する際、routerのparamsとしてポケモンの"name"を渡すために、遷移先のページに対してpropsをtrueとしています。
実装意図としては、詳細画面で各ポケモンの詳細情報をPokéAPIから取得するためです。

PokéAPIから特定のポケモンに関する詳細情報を取得するには、下記URLの末尾にポケモンのidかnameをクエリパラメータとして渡す必要があります。
`https://pokeapi.co/api/v2/{id or name}`

そのため、今回はnameを詳細画面に渡し、詳細画面でPokéAPIからデータを取得する実装としています。

## 5. PokéAPIとの通信処理

```network/index.js
import axios from "axios";

const pokeApi = axios.create({
  baseURL: 'https://pokeapi.co/api/v2/'
})

// 表示したい数のポケモンのリストを取得
export const fetchPokemonData = async (limit, offset) => {
  return await pokeApi.get('pokemon', {
    params: {
      limit: limit,
      offset: offset
    }
  })
}

// ポケモンの詳細情報を取得
export const fetchPokemonDetails = async (name) => {
  return await pokeApi.get(`pokemon/${name}`)
}
```

PokéAPIとの通信処理はここにまとめて記述してあります。
今回通信にはaxsiosを使用しました。
(意図としては、参加しているプロダクトで使用しているためです。)

**fetchPokemonData**では、limitとoffsetをクエリパラメータとして渡すことで、それに応じたポケモンのリストを取得できます。

また、**fetchPokemonDetails**では、ポケモンのnameを末尾に指定することで、指定したポケモンの詳細情報を取得しています。

PokéAPIの使用方法に関しては下記ページをご参照ください。
https://pokeapi.co/

## 6. Vuexで状態管理

```store/index.js
import { createStore } from 'vuex';

const store = createStore({
  state: {
    limit: 12,
    offset: 0
  },
  mutations: {
    setLimit(state, newLimit) {
      state.limit = newLimit
    },
    setOffset(state, newOffset) {
      state.offset = newOffset
    }
  },
  actions: {
    setLimitAction: function ({ commit }, limit) {
      commit('setLimit', limit)
    },
    setOffsetAction: function ({ commit }, offset) {
      commit('setOffset', offset)
    }
  },
  getters: {
    limit: state => state.limit,
    offset: state => state.offset
  },
});

export default store;
```

詳細画面からリスト表示画面に遷移(戻るボタンクリック)した際、リスト表示画面の状態を保持するために、limitとoffsetをそれぞれVuexでglobal stateとして管理しています。
また、検索フォームで使用するために、双方向バインディングができるように実装しています。

- 参考
https://qiita.com/key_it6/items/094cc773c2fdd1fc40a3


## 7. スタイリング

スタイリングに関しては、Vuetifyを使用しました。
意図としては、今回の学習のスコープとしてスタイリングは入っていないため、ざっくりと見た目を整えるために、フレームワークを使用した次第です。

使用してみた所感としては、とても使いやすかったです。
今回はあまり複雑なUIではないので、使い込めていませんが、宣言的UIに慣れている方であれば、比較的簡単に使用できるのではないかと思います。

# まとめ

今回はVueでとてもシンプルなアプリケーションの作成を行いました。
Vueでアプリケーションを構成する上で必要となる基本的なところを実装を通して学ぶ事ができたかなと思います！