QiitaAPI
vue.js
nuxt.js
ElementUI
Vue.js #2Day 17

Nuxt.jsでQiita投稿一覧サービスを作る

初めまして、こんにちは。
Nuxt.jsの記事を書いていたところ、空きを見つけたので参加登録しました。
遅れての参加なので、もし問題があればご指摘ください。

はじめに

Nuxt.js(vue.js)の勉強がてらQiita APIを使って投稿一覧サービスを作りました。
プロジェクト作成から静的サイトを生成までをまとめたいと思います。

何かお気づきの点あればアドバイス頂けると嬉しいです:bow_tone1:

ss.png

デモ (PCでの操作を推奨)

モジュールのバージョン

使用するモジュールのバーションは以下の通りです。

"nuxt": "^1.0.0-rc11",
"axios": "^0.17.1",
"element-ui": "^2.0.8"

使用するAPI

Qiita API v2の投稿を使用します。
未認証でも(IPアドレスごとに)1時間に60回までリクエストを送れるので、未認証でコールします。

プロジェクトの作成

スターターテンプレートからプロジェクトを作成します。
プロジェクト名は「hello-nuxt」としました。

$ vue init nuxt-community/starter-template hello-nuxt
$ cd hello-nuxt
$ yarn install

今回はUIライブラリにelement-ui、Ajax通信ライブラリにaxiosを使うため
それぞれインストールします。

$ yarn add axios element-ui

設定

nuxt.config.jsでサイトの設定を行います。
#headやローディング部分は手を加えていないので省略しています。

nuxt.config.js
module.exports = {
  plugins: ['~plugins/element-ui', { src: '~plugins/element-ui', ssr: false }],
  css: ['element-ui/lib/theme-chalk/index.css'],
  /*
  ** Build configuration
  */
  build: {
    vendor: ['axios', 'element-ui'],
    /*
    ** Run ESLint on save
    */
    extend (config, ctx) {
      if (ctx.dev && ctx.isClient) {
        config.module.rules.push({
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: 'eslint-loader',
          exclude: /(node_modules)/
        })
      }
      config.module.rules = config.module.rules.map((rule) => {
        if (rule.loader === 'babel-loader') {
          rule.exclude = /node_modules/
        }
        return rule
      })
    }
  }
}

cssプロパティで設定したファイルはグローバルに適用されるため、ここにelement-uiを指定します。
また、プラグインとして定義するため、pluginsプロパティにも指定します。
(pluginsプロパティに指定したelement-ui用のプラグインは後ほど作成)

buildプロパティextendには、babelやeslintの適用除外のディレクトリを指定します。
さらにbuildプロパティvendorにaxiosとelement-uiを指定し、全体のサイズをコンパクトにします。

これで設定は完了です。

プラグインの作成

nuxt.config.jsで指定したelement-ui用のプラグインを作成します。
plugins下にelement-ui.jsを作成します。

plugins/element-ui.js
import Vue from 'vue'

const ElementUI = require('element-ui')
const locale = require('element-ui/lib/locale/lang/ja')
Vue.use(ElementUI, { locale })

レイアウト・コンポーネントの作成

レイアウト

ヘッダーとコンテンツ(検索、投稿一覧)、フッターのレイアウトを作ります。
#本当はページネーションも作りたかったのですが、レスポンスヘッダーの「Total-Count」等が取得できないため割愛しました。

【イメージ図】

※以下、図中の言葉を見出しに使います。

layouts下にnavbar.vueを作成します。

layouts/navbar.vue
<template>
  <div>
    <my-header />
    <nuxt />
    <my-footer />
  </div>
</template>

<script>
import MyHeader from '~/components/Header.vue'
import MyFooter from '~/components/Footer.vue'
export default {
  name: 'navbar',
  components: {
    MyHeader,
    MyFooter
  }
}
</script>

コンポーネント

ヘッダー、フッター

components下にHeader.vue、Footer.vueを作成します。
ヘッダー、フッターはシンプルに背景色はQiitaカラーで(〃'ω')

components/Header.vue
<template>
  <div class="header">
    <b><nuxt-link to="/">Hello Qiita with Nuxt.js \\\\ ٩(*'ω'*)و ////</nuxt-link></b>
  </div>
</template>

<style>
.header {
    font-size: 20px;
    left: 0;
    top: 0;
    width: 100%;
    background-color: #59bb0c;
    color: #fff;
    text-align: left;
    padding: 15px;
}

.header a {
    text-decoration: none;
}

.header a:visited {
    color: #fff;
}
</style>
components/Footer.vue
<template>
  <div class="footer">
  </div>
</template>

<style>
.footer {
    left: 0;
    bottom: 0;
    width: 100%;
    height: 61px;
    background-color: #59bb0c;
    color: white;
    text-align: center;
}
</style>

検索

pages/search.vueを作成します。
search.vueではページ表示時にQiitaのAPIをコールする機能と検索画面に入力されたキーワードを元に
QiitaのAPIをコールする機能、入力値のバリデーション機能を持ちます。

pages/search.vue
<template>
  <div>
    <el-container>
      <el-main>
        <!-- 検索フォーム -->
        <el-form :inline="true" :model="searchForm" ref="searchForm" :rules="rules" @submit.native.prevent>
          <el-form-item prop="keyword">
            <el-input placeholder="search by keyword" prefix-icon="el-icon-search" v-model="searchForm.keyword"  @keyup.enter.native="search('searchForm')" />
          </el-form-item>
          <el-form-item>
            <el-button @click="search('searchForm')">search</el-button>
          </el-form-item>
        </el-form>
        <!-- 投稿一覧 -->
        <my-list :lists="mylist" :hasData="hasData" />
      </el-main>
    </el-container>
  </div>
</template>

<script lang="babel">
import axios from 'axios'
import MyList from '~/components/List.vue'
const BASE_URL = 'https://qiita.com/api/v2/'
export default {
  layout: 'navbar',
  components: {
    // 投稿一覧を表示するコンポーネント
    MyList
  },
  data () {
    return {
      // 検索フォーム
      searchForm: {
        keyword: ''
      },
      // バリデーションルール
      rules: {
        keyword: [
          { required: true, message: 'Please input the keyword', trigger: 'blur' }
        ]
      },
      mylist: [],
      hasData: true
    }
  },
  created () {
    // 初回ページ描画時にキーワード「nuxt.js」でQiitaのAPIをコール
    this.searchForm.keyword = 'nuxt.js'
    this.sendRequest()
    this.searchForm.keyword = ''
  },
  methods: {
    // キーワード検索時に呼ばれるメソッド。バリデーション含む
    search (form) {
      this.$refs[form].validate((valid) => {
        if (!valid) {
          return false
        }
        this.sendRequest()
      })
    },
    // リクエスト送信
    sendRequest () {
      axios.get(BASE_URL + 'items', {
        headers: {'Content-Type': 'application/json'},
        params: {
          page: 1,
          per_page: 20,
          query: this.searchForm.keyword
        }
      })
        .then(response => {
          if (response.data.length === 0) {
            this.hasData = false
          }
          this.mylist = response.data
        })
        .catch(e => {
          console.error('error:', e)
        })
    }
  }
}
</script>

<style>
.el-form {
  margin-top: 1em;
  margin-left: 1em;
}
</style>

バリデーションでは空入力であるかどうかのみチェックを行っています。
ss5.png

投稿一覧

components下にList.vueを作成します。
List.vueは検索からQiita APIのレスポンスを受け取り、element-uiのカードで描画を行うコンポーネントです。
他にページトップへ遷移する機能を持ちます。

components/List.vue
<template>
<div>
  <!-- 検索結果が0件だった場合 -->
  <div v-if="lists.length === 0 && !hasData">
    <i class="el-icon-warning">&nbsp;No results found for your keyword.</i>
  </div>
  <!-- 投稿一覧 -->
  <div v-else>
    <el-col :span="6" v-for="(element, index) in lists" :key="index" class="col-style">
      <el-card :body-style="{ padding: '15px' }" class="box-card">
        <div slot="header" class="clearfix">
          <a :href="element.url" target="_blank">{{ element.title }}</a>
        </div>
        <div class="bottom clearfix content-style text">
          <div>{{ element.created_at }}</div>
          <span>
            <img :src="element.user.profile_image_url" width="15" height="15" />
            <!-- 自己紹介があればPopoverで表示 -->
            <template v-if="element.user.description">
              <el-popover slot="description" placement="top-start" width="300" trigger="hover" :content="element.user.description">
                <span slot="reference">&nbsp;{{ element.user.id }}</span>
              </el-popover>
            </template>
            <template v-else>
              <span>&nbsp;{{ element.user.id }}</span>
            </template>
          </span>
          &nbsp;
          <span>
            <i class="el-icon-star-off">{{ element.likes_count }}</i>
          </span>
          <div>{{ getDescription(element.body) }}</div>
          <el-tag size="mini" type="info" class="tab-style" v-for="(tag, index) in element.tags" :key="index">{{ tag.name }}</el-tag>
        </div>
      </el-card>
    </el-col>
    <!-- ページトップへスクロール用のボタン -->
    <div v-if="250 < scrollY" class="page-component-up">
      <transition name="fade">
        <i class="el-icon-caret-top" @click="scrollTop" />
      </transition>
    </div>
  </div>
</div>
</template>

<script lang="babel">
export default {
  // search.vueからデータを受け取る
  props: ['lists', 'hasData'],
  data () {
    return {
      scrollY: 0
    }
  },
  mounted () {
    window.addEventListener('scroll', this.handleScroll)
  },
  methods: {
    // ボディ部のトリミング
    getDescription: function (body) {
      return body.slice(0, 100) + '...'
    },
    // 現在の上部からのスクロール量取得
    handleScroll: function () {
      this.scrollY = window.scrollY
    },
    // トップへスクロール
    scrollTop: function () {
      document.body.scrollTop = 0
      document.documentElement.scrollTop = 0
    }
  }
}
</script>

<style>
.clearfix:before,
.clearfix:after {
  display: table;
  content: "";
}

.clearfix:after {
  clear: both
}

.content-style {
  line-height: 30px;
}

.tab-style {
  margin-right: 5px;
}

.box-card {
  height: 360px;
  font-size: 15px;
}

.col-style {
  padding: 10px;
}

.text {
  font-size: 14px;
}

.page-component-up {
  background-color: #59bb0c;
  position: fixed;
  right: 80px;
  bottom: 80px;
  width: 40px;
  height: 40px;
  border-radius: 20px;
  cursor: pointer;
  transition: .3s;
  box-shadow: 0 0 6px rgba(0,0,0,.12);
}

.page-component-up i {
  color: #fff;
  display: block;
  line-height: 40px;
  text-align: center;
  font-size: 18px;
}

a:link, a:visited {
    color: #59bb0c;
}

a:hover {
    color: #3b8070;
}
</style>

検索から受け取ったデータ'lists'と'hasData'を見ることにより
検索結果が0件だった場合は「No results found for your keyword.」と表示させます。
ss4.png

ビルド

全て作成したら、静的ファイルを生成します。

yarn run generate

以上で完成です!

最後に

コンポーネント間のデータの受け渡しやリスト、イベントハンドリングなど、基本的な操作は体験できたと思います。

vue.jsは画面で行われる処理が分かりやすいと感じました。

試しに検索で使ってもらえると嬉しいです:)

参考

ソースコード

https://github.com/aytdm/hello-nuxt