Wowgineer Notes

WowTechエンジニアの徒然開発日記

Wowgineer Notes

〜新たな"Wow"を生み出す〜

初めてVue + VuexでNewsAPIを使ったアプリ作ってみた

Web開発チームの山本です。

普段はReactで開発をしているのですが、Vueには触れたことがなかったので、簡単なアプリを作成してみました。今回は、ニュース記事を表示できるアプリについて紹介します。

 

 

アプリ概要

今回は、News APIを使ってカテゴリーごとに記事を表示させていきます。ただVueに集中したかったので、フロントのみを実装しています。

 

またVuexも使ってみたかったので、ブックマーク機能を追加しました。ブックマークした記事は、ブックマークのページでまとめて表示することもできます。

 

f:id:yamamoto5555:20210129113252p:plain

 

開発環境

私の開発環境は次のようになっています。

  • MacBookPro
  • Node.js v12.16.2
  • npm 6.14.4

準備

Vue CLIをインストール

Vue CLIは、Vue.jsを迅速に開発するためのフルシステムです。ReactでいうCreate React Appに近いかなと思います。Vue CLIは、基本的に必要なことをやってくれるので、とても助かります。ありがたや。

$npm install -g @vue/cli
$vue --version
@vue/cli 4.5.8

 

プロジェクトの作成

news-appというプロジェクトを作成します。

$vue create news-app
$cd news-app
//サーバーを起動 $npm run serve

 ブラウザでhttp://localhost:8081/ を開くと、サンプルが表示さます。サンプル表示させただけですが、表示できると嬉しいですね!

 

f:id:yamamoto5555:20210108111443p:plain 

Vue用のUIライブラリ

今回UIライブラリにVue Materialを使用します。雰囲気Google Newsぽくなるかなと思ったからです(なって欲しい)。

$npm install vue-material --save

 

公式ドキュメントで必要なコンポーネントだけをimportすることが推奨されていたので、main.jsで使いたいコンポーネントだけをimportしていきます。

import Vue from 'vue'
import App from './App.vue'
import { MdButton, MdCard, MdLayout, MdApp, MdList, MdToolbar, MdDrawer, MdIcon, MdContent } from 'vue-material/dist/components'
import 'vue-material/dist/vue-material.min.css'
import 'vue-material/dist/theme/default.css'

Vue.config.productionTip = false

Vue.use(Vuex)
Vue.use(MdButton)
Vue.use(MdCard)
Vue.use(MdLayout)
Vue.use(MdApp)
Vue.use(MdList)
Vue.use(MdToolbar)
Vue.use(MdDrawer)
Vue.use(MdIcon)
Vue.use(MdContent)

News APIのアカウント登録

表示させるニュースは、NewsAPIを使っていきます。アカウントを作成すると、API keyを取得できます。NewsAPIは、カテゴリーごとや言語ごとにニュースを取得できるので便利でした。

Vueでコンポーネント作成

Vue Materialを使用して、Headerを作成しましょう。Vue MaterialのComponentsの中のAppを使いました。

src/components/App.vue

<template>
  <div id="app">
    <div class="page-container">
      <md-app md-mode="reveal">
        <md-app-toolbar class="md-primary">
          <md-button class="md-icon-button">
            <md-icon>menu</md-icon>
          </md-button>
          <span class="md-title">News App</span>
        </md-app-toolbar>
      </md-app>
    </div>
  </div>
</template>
<script> export default { name: 'App', } </script>
<style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; } </style>

上記のコードをかくと、http://localhost:8081/に下のような画像のようにHeaderが表示されます。

f:id:yamamoto5555:20210120110856p:plain

同じように下記のコンポーネントを作成していきます。

  • NewsItem.vue:ニュースを表示するカードのコンポーネント
  • Content.vue:複数のカードを表示されるコンポーネント

src/components/NewsItem.vue

<template>
    <md-card>
      <md-card-area>
          <md-card-media>
          </md-card-media>
          <md-card-header>
            <div class="md-title">Article Title</div>
          </md-card-header>

          <md-card-content>
            Content.........
          </md-card-content>
        </md-card-area>
        <md-card-actions md-alignment="space-between">
          <md-button class="md-icon-button" >
            <md-icon>bookmark</md-icon>
          </md-button>
          <md-button class="md-primary">Read More</md-button>
        </md-card-actions>
    </md-card>
</template>

 Vue MaterialにCardというコンポーネントがあるので、それを使用してカードを作成します。

 

src/components/Content.vue

<template>
  <div class="md-layout md-gutter md-alignment-center">
    <div v-for="n in 8" class="md-layout-item" :key="n">
<NewsItem :article="n"/>
</div> </div> </template> <script> import NewsItem from "./NewsItem.vue" export default { components: { NewsItem }, } </script>



 NewsItem.vueをContent.vueで呼び出し複数のカードを表示していきます。

作成した Content.vueをApp.vueで読み込んでみましょう。

src/components/App.vue

<template>
  <div id="app">
    <div class="page-container">
      <md-app md-mode="reveal">
        <md-app-toolbar class="md-primary">
          <md-button class="md-icon-button">
            <md-icon>menu</md-icon>
          </md-button>
          <span class="md-title">News App</span>
        </md-app-toolbar>
        <md-app-content>
          <Content />
        </md-app-content>
      </md-app>
    </div>
  </div>
</template>
<script>

export default {
import Content from './components/Content.vue'
  name: 'App',
components: {
    Content
  },
}
</script>

 

そうすると、カードを複数表示するコンポーネントが完成します。UIライブラリを使用すれば、そんなに時間がかからず作成できるはずです。

f:id:yamamoto5555:20210120114053p:plain



 

Vuexの作成

 次に、登録したNewsAPIを使用して、ニュースのデータを取得し表示します。さらに、次の3つのことを実現させたいです。

  • カテゴリーごとに記事を表示させる
  • 記事をブックマークできる
  • ブックマークされた記事は一覧で表示できるようにする

上記のことを実現するために考えなくてはいけないのは、記事とブックマークされた記事のデータ管理です。ただこれをVueで行うと、データの受け渡しをpropsで行うことになり、複雑化してしまいます。。。今回は、Vuexを使用してデータ一元化を行ないます。

 

Vuexとは

ReactでいうReduxのように、Vue.js アプリケーションのための状態管理のライブラリです。詳しいことは、公式ドキュメントに書かれています。

 

Vuexの導入

Vuexをインストールしましょう。

$npm install vuex --save

インストールしたら、main.jsを編集しVuexの設定をします。

import Vue from 'vue'
import Vuex from 'vuex'
import App from './App.vue'
import store from './store'


Vue.config.productionTip = false

Vue.use(Vuex)


new Vue({
  render: h => h(App),
  store,
}).$mount('#app')

「import store from './store'」と記載していますが、storeのディレクトリは作成していなかったので、作っていきます。

src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import news from './modules/news'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

export default new Vuex.Store({
  modules: {
    news,
  },
  strict: debug,
})

 src/store/modules/news.js

// initial state
const state = () => ({
})

// getters
const getters = {}

// actions
const actions = {
}

// mutations
const mutations = {
}

export default {
  namespaced: true,
  state,
  getters,
  actions,
  mutations
}

 

stateの作成

stateは、アプリケーション全体のデータを管理するところです。今回のアプリでは、3つのデータを保存していきます。

  • カテゴリーごとに表示するニュースのデータ
  • 選択しているカテゴリー状態
  • ブックマークしているデータ

newsDataはカテゴリーごとにデータを取り出しやすくしたかったので、keyをカテゴリー名にしています。

 src/store/modules/news.js

const state = () => ({
    newsData: {
        "technology":[],
        "entertainment":[],
        "business":[],
        "science":[],
    },
    activeCategory: "business",
    bookmarkData:[]
})

 

NewsAPIでデータ取得

保存するニュースのデータをNewsAPIから取得します。指定したカテゴリーのデータを取得したかったので、引数でカテゴリー名を渡しています。APIからデータ取得するときに、axiosを使用しました。詳しい使い方は、ドキュメントをご覧ください。

import axios from 'axios' 

const key = NewsAPIの登録後に渡されるAPI key export const changeCategory = async (category) => { const res = await axios.get('https://newsapi.org/v2/top-headlines?country=jp&category='+category+'&apiKey='+ key) const articles = res.data.articles; return articles }
actionの作成

actionでAPIの実行をしましょう。初回のみデータ取得したいので、既にカテゴリーのデータがある場合には、更新せずにreturn falseしています。また、本当はサーバー側でidの割り振ると思うのですが、今回はフロントだけで処理を行なっているため、article_id_listというのを使って、idを割り振っています。ニュースデータは固定で20件ずつ取るようにしているので、article_id_listのstart_indexを20ごとに決めています。

import { changeCategory } from '../../../api/news.js'

const article_id_list = {business: 0, entertainment: 20, technology: 40, science: 60} const actions = { async getArticles({commit, state}){ const category = state.activeCategory if(state.newsData[category].length ){ return false }else{ const new_articles = await changeCategory(category) const article_data = new_articles.map((article, index)=>{ const start_index = article_id_list[category] article.id = index + start_index article.bookmark = false return article }) commit('setArticles', {data: article_data, category: category}) return new_articles } } } 

カテゴリーの変更をするactionを追加しましょう。

import { changeCategory } from '../../../api/news.js'

const article_id_list = {business: 0, entertainment: 20, technology: 40, science: 60} const actions = { async getArticles({commit, state}){ const category = state.activeCategory if(state.newsData[category].length ){ return false }else{ const new_articles = await changeCategory(category) const article_data = new_articles.map((article, index)=>{ const start_index = article_id_list[category] article.id = index + start_index article.bookmark = false return article }) console.log("article_data", article_data) commit('setArticles', {data: article_data, category: category}) return new_articles } },
  //カテゴリーの変更をするaction追加 updateCategory({commit, state}, category){ if(state.activeCategory !== category){ commit('setActiveCategory', category) } } }

 

mutationの作成

 mutationでデータの変更をしていきます。

const mutations = {
//取得した記事のデータを保存 setArticles(state, data){ const news_data = data.data const news_category = data.category state.newsData[news_category] = news_data },
  //選択中のカテゴリーを保存 setActiveCategory(state, category){ state.activeCategory = category } }

 記事のデータは、全てを渡すのではなく、選択しているカテゴリーのデータだけを渡すようにgettersの中に書いていきます。

const getters = {
    getArticlesByCategory: (state) => {
        const category = state.activeCategory
        return state.newsData[category]
    }
}

機能の実装

ニュースの表示

App.vueで記事のデータを取得します。createdの中で、getArticlesのアクションを実行しましょう。mapGettersを使って、Component内でstoreに保存した記事のデータを取り出します。そして、Content Componentにarticlesを渡していきます。 

src/components/App.vue

<template>
  <div id="app">
    <div class="page-container">
      <md-app md-mode="reveal">
        <md-app-toolbar class="md-primary">
          <md-button class="md-icon-button">
            <md-icon>menu</md-icon>
          </md-button>
          <span class="md-title">News App</span>
        </md-app-toolbar>
        <md-app-content >
          <Content :articles="articles"/>
        </md-app-content>
      </md-app>
    </div>
  </div>
</template>

<script>
import Content from './components/Content.vue'
import { mapState, mapGetters } from 'vuex'

export default {
  name: 'App',
  components: {
    Content
  },
    computed: {
      ...mapState({
        activeCategory: state => state.news.activeCategory,
      }),
      ...mapGetters('news', {
        articles: 'getArticlesByCategory'
      })
       
    },
    created () {
       this.$store.dispatch('news/getArticles')
  },
}
</script>

src/components/Content.vue

<template>
  <div class="md-layout md-gutter md-alignment-center">
    <div v-for="article in articles" class="md-layout-item" :key="article.title">
      <NewsItem :article="article"/>
    </div>
  </div>
</template>

<script>
import  NewsItem  from "./NewsItem.vue"

export default {
  components: {
    NewsItem
  },
  props: {
      articles: Array
  }
}

</script>

  

記事によっては、画像データがないのもあったので、画像がない場合は、no_image.jpgを表示するようにしています。

src/components/NewsItem.vue

<template>
    <md-card :id="article.id">
      <md-card-area>
          <md-card-media>
            <img :src="article.urlToImage" :alt="article.title" @error="noImage">
          </md-card-media>

          <md-card-header>
            <div class="md-title">{{article.title}}</div>
          </md-card-header>

          <md-card-content>
            {{article.description}}
          </md-card-content>
        </md-card-area>
        <md-card-actions md-alignment="space-between">
          <md-button  v-bind:href="article.url" class="md-primary">Read More</md-button>
        </md-card-actions>
    </md-card>
</template>

<script>
  import AssetsImage from "@/assets/no_image.jpg";  
  export default {
    name: 'NewsItem',
    props: {
      article: Object
    },
    data(){
      return {
        assetImage: AssetsImage
      }
    },
    methods: {
      noImage(element){
        element.target.src = this.assetImage
      }
    }
  }
</script>

 これで最初の画面に、ニュースを表示できるようになりました。

f:id:yamamoto5555:20210129113631p:plain


カテゴリー選択

ニュースデータの取得と表示はできたので、カテゴリーを選択して表示できるようにします。まずは、Drawerの左上のアイコンをクリックした時に表示/非表示できるようにします。これもVue MaterialのDrawerのコンポーネントを使っていきます。SideMenu.vueの中に、Drawer内に表示するものを書いていきます。

 

src/components/SideMenu.vue

<template>
    <div>
        <md-toolbar class="md-transparent" md-elevation="0">Category</md-toolbar>
        <md-list>
        <md-list-item class="item-active">
            <md-icon>business</md-icon>
            <span id="business" class="md-list-item-text" @click="setResource('business')">Business</span>
        </md-list-item>

        <md-list-item>
            <md-icon>music_note</md-icon>
            <span id="entertainment" class="md-list-item-text" @click="setResource('entertainment')">Entertainment</span>
        </md-list-item>

        <md-list-item>
            <md-icon>emoji_objects</md-icon>
            <span id="technology" class="md-list-item-text" @click="setResource('technology')">Technology</span>
        </md-list-item>

        <md-list-item>
            <md-icon>science</md-icon>
            <span id="science" class="md-list-item-text" @click="setResource('science')">Science</span>
        </md-list-item>
        <md-list-item>
            <md-icon>bookmarks</md-icon>
            <span id="bookmark" class="md-list-item-text" @click="setResource('bookmark')">Bookmark</span>
        </md-list-item>
        </md-list>
    </div>
</template>

<script>
    export default {
        name: 'SideMenu',
        props:{
            setResource: Function
        },

}
</script>

<style>
.md-list-item{
    cursor: pointer;
}

.md-list .item-text-active{
    color:#448aff;
}
</style>

SideMenu.vueをApp.vueから呼び出します。このDrawerの表示/表示のデータは、storeに入れずに、menuVisibleとしてlocalで管理します。そして、setResourceというメソッドで、カテゴリーを変更していきます。

 

 src/components/App.vue

<template>
  <div id="app">
    <div class="page-container">
      <md-app md-mode="reveal">
        <md-app-toolbar class="md-primary">
          <md-button class="md-icon-button" @click="menuVisible = !menuVisible">
            <md-icon>menu</md-icon>
          </md-button>
          <span class="md-title">News App</span>
        </md-app-toolbar>
        <md-app-drawer :md-active.sync="menuVisible"  >
           <SideMenu  :setResource = "setResource" />
        </md-app-drawer>
        <md-app-content>
     <Content :articles="articles"/>
    </md-app-content> </md-app> </div> </div> </template> <script> import Content from './components/Content.vue' import SideMenu from './components/SideMenu.vue' import { mapState, mapGetters } from 'vuex' export default { name: 'App', components: { SideMenu, Content }, data() { return { menuVisible: false, } }, ・




methods: {
setResource(category){
  this.$store.dispatch('news/updateCategory', category)
  this.menuVisible = false
}
}
} </script> <style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; } .md-drawer { width: 230px; max-width: calc(100vw - 125px); } .md-content{ height: calc(100vh - 64px) } </style>

 

左上のアイコンをクリックすると、下のようにDrawerが表示されるようになりました。各カテゴリーをクリックしても、カテゴリー毎のニュースも表示されるようになっていると思います。

f:id:yamamoto5555:20210129195241p:plain

 

ニュースのブックマーク

actionとmutationの追加からしていきます。

  src/store/modules/news.js

const actions = {
・




  //ブックマークデータの更新のactionを追加 updateBookmark({commit}, value){ commit('updateBookmark', value) } }

  src/store/modules/news.js

const mutations = {





  //ブックマークした記事の保存または削除 updateBookmark(state, id){ let category = state.activeCategory
//既にブックマークされているか const bookmark_index = state.bookmarkData.findIndex((item) => item.id == id)
      if(category !== null && bookmark_index !== -1){ category = state.bookmarkData[bookmark_index].category }
const article_index = state.newsData[category].findIndex((item) => item.id == id) const bookmark = state.newsData[category][article_index].bookmark
if(article_index != null){
//newsDataのbookmarkをtrue・falseを変更 state.newsData[category][article_index].bookmark = !bookmark //ブックマークされていなかったら、bookmarkDataにデータを追加 if(bookmark_index === -1){ let copy_data = Object.assign(state.newsData[category][article_index]) copy_data.category = category state.bookmarkData.push(copy_data) }else{
         //既にある場合は、bookmarkDataから削除 state.bookmarkData.splice(bookmark_index, 1) } } } }

 mutationsのupdateBookmarkで、bookmarkDataとnewsDataの更新をしていきます。

 

activeCategoryがbookmarkの時は、bookmarkDataを渡すようにgettersを変更します。

  src/store/modules/news.js

const getters = {
    getArticlesByCategory: (state) => {
       const category = state.activeCategory
    let data
    if(category === "bookmark"){
     data = state.bookmarkData
    }else{
     data = state.newsData[category]
    }
     return data; } }

 

次に、クリックイベントを追加するために、コンポーネントの変更を行います。

src/components/NewsItem.vue

<template>
    <md-card :id="article.id">
      <md-card-area>
          <md-card-media>
            <img :src="article.urlToImage" :alt="article.title" @error="noImage">
          </md-card-media>

          <md-card-header>
            <div class="md-title">{{article.title}}</div>
          </md-card-header>

          <md-card-content>
            {{article.description}}
          </md-card-content>
        </md-card-area>
        <md-card-actions md-alignment="space-between">
          <md-button class="md-icon-button" @click="onChangeBookmark(article.id)">
            <md-icon v-if="article.bookmark">bookmark</md-icon>
            <md-icon v-else>bookmark_border</md-icon>
          </md-button>
          <md-button  v-bind:href="article.url" class="md-primary">Read More</md-button>
        </md-card-actions>
    </md-card>
</template>

<script>
  import AssetsImage from "@/assets/no_image.jpg";  
  export default {
    name: 'NewsItem',
    props: {
      article: Object
    },
    data(){
      return {
        assetImage: AssetsImage
      }
    },
    methods: {
      onChangeBookmark(id){
        this.$store.dispatch('news/updateBookmark', id)
      },
      noImage(element){
        element.target.src = this.assetImage
      }
    }
  }
</script>

ブックマークのアイコンをクリックすると、onChangeBookmarkの関数から、先ほど追加したupdateBookmarkのアクションが実行されます。

 

これで、ブックマークの機能が完成しました!

f:id:yamamoto5555:20210201115440g:plain

 

まとめ

初めてVueとVuexを使ってアプリを作りましたが、初心者でもReactに比べて時間をかけずに作成することができました。まだまだこのアプリの改善したいところもありますが、短時間で動くアプリを作るのを目標に作ってみました。これから、Vueを使っていく上で、様々な疑問が出てくると思うので、その時はまた別の記事でまとめたいです。

 

 

我々ワウテック技術開発部はこのような環境で開発をしています。
興味を持った方がいらっしゃればいつでもご連絡下さい。

f:id:wowtech-dev:20190313163146j:plain