Web開発チームの山本です。
普段はReactで開発をしているのですが、Vueには触れたことがなかったので、簡単なアプリを作成してみました。今回は、ニュース記事を表示できるアプリについて紹介します。
アプリ概要
今回は、News APIを使ってカテゴリーごとに記事を表示させていきます。ただVueに集中したかったので、フロントのみを実装しています。
またVuexも使ってみたかったので、ブックマーク機能を追加しました。ブックマークした記事は、ブックマークのページでまとめて表示することもできます。
開発環境
私の開発環境は次のようになっています。
- 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/ を開くと、サンプルが表示さます。サンプル表示させただけですが、表示できると嬉しいですね!
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が表示されます。
同じように下記のコンポーネントを作成していきます。
- 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ライブラリを使用すれば、そんなに時間がかからず作成できるはずです。
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>
これで最初の画面に、ニュースを表示できるようになりました。
カテゴリー選択
ニュースデータの取得と表示はできたので、カテゴリーを選択して表示できるようにします。まずは、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が表示されるようになりました。各カテゴリーをクリックしても、カテゴリー毎のニュースも表示されるようになっていると思います。
ニュースのブックマーク
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のアクションが実行されます。
これで、ブックマークの機能が完成しました!
まとめ
初めてVueとVuexを使ってアプリを作りましたが、初心者でもReactに比べて時間をかけずに作成することができました。まだまだこのアプリの改善したいところもありますが、短時間で動くアプリを作るのを目標に作ってみました。これから、Vueを使っていく上で、様々な疑問が出てくると思うので、その時はまた別の記事でまとめたいです。
我々ワウテック技術開発部はこのような環境で開発をしています。
興味を持った方がいらっしゃればいつでもご連絡下さい。