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

Nuxt.jsでQiita投稿一覧サイトを作る

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


// 2018/12/2 追記

今も見てくださっている方が多くいらっしゃることもあり(ありがとうございます!)自分の気づく範囲で処理の見直しを行いました。具体的な変更点は以下のとおりです。

  • データ取得に async/awaitfetch を使用するように修正
  • 検索部分をコンポーネント化し、検索結果と分離するよう修正
  • Vuex store を使用するよう修正
  • 表示する日付のフォーマットを変更&フィルタで行うよう修正
  • 本文をトリミングする処理をフィルタで行うよう修正
  • スタイルを若干修正
    • フォントを指定(Google Font を使用)

はじめに

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

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

モジュールのバージョン

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

"axios": "^0.17.1",
"dayjs": "^1.7.7",
"element-ui": "^2.4.11",
"nuxt": "^1.4.4"

※Nuxt.js は 1.0 系です。

使用する 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、時刻のフォーマットに dayjs を使うためそれぞれインストールします。

$ yarn add axios element-ui dayjs

設定

nuxt.config.js でサイトの設定を行います。

nuxt.config.js
module.exports = {
  mode: 'spa',
  head: {
    title: 'hello-nuxt',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: 'Nuxt.js project' }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
      {
        rel: 'stylesheet',
        href: 'https://fonts.googleapis.com/css?family=M+PLUS+1p'
      }
    ]
  },
  plugins: [
    { src: "@/plugins/element-ui", ssr: false },
    { src: "@/plugins/filters.js", ssr: false }
  ],
  css: ["element-ui/lib/theme-chalk/index.css"],
  /*
   ** Build configuration
   */
  build: {
    vendor: ["axios", "element-ui", "dayjs"],
    /*
     ** 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 用のプラグイン、filters 用のプラグインは後ほど作成)

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

これで設定は完了です。

プラグインの作成

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 });

filters

続けて、plugins 下に filters.js を作成します。こちらは後ほど実装するのでとりあえず形だけ。

plugins/filters.js
import Vue from "vue";
import dayjs from "dayjs";

Vuex ストアの作成

コンポーネントの親子間のデータのやり取りに Vuex store を使います。store では、Qiita の API を呼ぶ機能、そのレスポンスデータとデータローディング中かどうかを判定するフラグを持ちます。

index.js の作成

store 下に index.js を作成します。
今回は actions、mutations、state をそれぞれ独立したファイルに定義するので、index.js でそれらを読み込みます。

store/index.js
import Vue from "vue";
import Vuex from "vuex";
import mutations from "./mutations";
import actions from "./actions";
import state from "./state";

Vue.use(Vuex);

const store = () =>
  new Vuex.Store({
    state,
    mutations,
    actions
  });

export default store;

state.js の作成

store 下に state.js を作成します。
state では、コンポーネントの親子間で受け渡したいものを定義します。

store/state.js
export default {
  // Qiita API のレスポンスデータ
  lists: [],
  // データローディング中かどうかを判定するフラグ
  isLoading: false
};

mutations.js の作成

store 下に mutations.js を作成します。
mutations は、state の持つ変数に値をセットする Setter です。

store/mutations.js
export default {
  setItems(state, lists) {
    state.lists = lists;
  },
  hideLoading(state) {
    state.isLoading = false;
  },
  showLoading(state) {
    state.isLoading = true;
  }
};

actions.js の作成

store 下に actions.js を作成します。
action では、Qiita の API を呼び、そのレスポンスを mutations の commit を使って state にセットします。ローディング画面の表示/非表示判定用フラグの ON/OFF もここで行っています。

store/actions.js
import axios from "axios";

const BASE_URL = "https://qiita.com/api/v2/";

export default {
  async getItems({ commit }, payload) {
    // 'isLoading' を 'true' に設定
    commit("showLoading");
    // リクエスト送信
    const response = await axios
      .get(`${BASE_URL}items`, {
        headers: { "Content-Type": "application/json" },
        params: {
          page: 1,
          per_page: 20,
          query: payload.keyword
        },
        timeout: 15000
      })
      .catch(error => {
        console.error(error);
        // 'isLoading' を 'false' に設定
        commit("hideLoading");
        // エラー画面に遷移
        this.$router.push("/error");
      });
    // 'lists' にレスポンスを設定
    commit("setItems", response.data);
    // 'isLoading' を 'false' に設定
    commit("hideLoading");
  }
};

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

ヘッダーとコンテンツ(検索、検索結果一覧)、フッターのレイアウトを作ります。
#本当はページネーションも作りたかったのですが、レスポンスヘッダーの「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>

エラー

layouts 下に error.vue を作成します。何かエラーが生じた際に表示するページです。

layouts/error.vue
<template>
  <div class="container">
    <h1 v-if="error.statusCode === 404">Page not found ...(☍﹏⁰) </h1>
    <h1 v-else>An error occured </h1>
    <nuxt-link to="/">Back Top</nuxt-link>
  </div>
</template>

<script>
export default {
  props: ['error']
}
</script>

<style scoped>
.container {
  padding-top: 10%;
  text-align: center;
}

h1 {
  font-size: 22px;
}
</style>

コンポーネント

ヘッダー、フッター

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:link,
  .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>

検索

components 下に SearchForm.vue を作成します。
SearchForm.vue は検索画面に入力されたキーワードを元に store の action に用意した 'getItems' を呼ぶ機能、入力値のバリデーション機能を持ちます。

components/SearchForm.vue
<template>
  <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>
</template>

<script lang="babel">
export default {
  data () {
    return {
      // 検索フォーム
      searchForm: {
        keyword: ''
      },
      // バリデーションルール
      rules: {
        keyword: [
          // 未入力はエラー
          { required: true, message: 'Please input the keyword', trigger: 'blur' },
          // 空白のみの入力はエラー
          { whitespace: true, message: 'Please input the keyword', trigger: 'blur' }
        ]
      }
    }
  },
  methods: {
    // search ボタン押下で呼ばれるメソッド。バリデーション含む
    search (form) {
      this.$refs[form].validate((valid) => {
        if (!valid) {
          return false
        }
        this.sendRequest()
      })
    },
    // store の action に用意した 'getItems' を呼ぶ
    sendRequest () {
      this.$store.dispatch('getItems', {
        keyword: this.searchForm.keyword
      })
    }
  }
}
</script>

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

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

検索結果一覧

components 下に SearchResult.vue を作成します。
SearchResult.vue は store の state からデータを取得し、element-ui の Card で描画を行うコンポーネントです。他にページトップへ遷移する機能を持ちます。

components/SearchResult.vue
<template>
<div>
  <!-- 検索結果が0件だった場合 -->
  <template v-if="lists.length === 0 && !isLoading">
    <i class="el-icon-warning">&nbsp;No results found for your keyword.</i>
  </template>
  <!-- 検索結果一覧 -->
  <template 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">
            <!-- タイトル -->
              <a :href="element.url" target="_blank">{{ element.title }}</a>
            </div>
            <div class="bottom 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>{{ 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>
  </template>
  </div>
</template>

<script lang="babel">
import {mapState} from 'vuex'

export default {
  data () {
    return {
      scrollY: 0
    }
  },
  mounted () {
    window.addEventListener('scroll', this.handleScroll)
  },
  // store の state からデータを取得
  computed: mapState(['lists', 'isLoading']),
  methods: {
    // 現在の上部からのスクロール量取得
    handleScroll: function () {
      this.scrollY = window.scrollY
    },
    // トップへスクロール
    scrollTop: function () {
      document.body.scrollTop = 0
      document.documentElement.scrollTop = 0
    }
  }
}
</script>

<style>
.content-style {
  line-height: 30px;
  font-size: 14px;
}

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

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

.col-style {
  padding: 10px;
}

.page-component-up {
  background-color: #59bb0c;
  position: fixed;
  right: 80px;
  bottom: 80px;
  width: 40px;
  height: 40px;
  border-radius: 20px;
  cursor: pointer;
  transition: 0.3s;
  box-shadow: 0 0 6px rgba(0, 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>

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

ページ

検索&検索結果一覧表示

pages/search.vue を作成します。ここでは上で作成した検索と検索結果一覧のコンポーネントを読み込みます。
search.vue ではページロード時に store の action に用意した 'getItems' を呼ぶ機能とローディング画面を描画する機能を持ちます。

pages/search.vue
<template>
  <div>
    <el-container>
      <el-main v-loading.fullscreen.lock="isLoading">
        <!-- 検索フォーム -->
        <search-form />
        <!-- 検索結果一覧 -->
        <search-result />
      </el-main>
    </el-container>
  </div>
</template>

<script lang="babel">
import {mapState} from 'vuex'
import SearchResult from '~/components/SearchResult.vue'
import SearchForm from '~/components/SearchForm.vue'

export default {
  layout: 'navbar',
  components: {
    // 検索フォームを表示するコンポーネント
    SearchForm,
    // 検索結果一覧を表示するコンポーネント
    SearchResult
  },
  // store の state からデータを取得
  computed: mapState(['isLoading']),
  // store の action に用意した 'getItems' を呼ぶ
  fetch ({ store }) {
    store.dispatch('getItems', {
      keyword: 'nuxt.js'
    })
  }
}
</script>

<style>
.el-main {
  background-color: #fff;
}
</style>

ローディング画面は element-uiLoading を使用しており、isLoading を見て表示しています。

filters の実装

最初の方で作成した filters.js、ようやく実装です。
レスポンスの日付をフォーマットするフィルターと本文をトリミングするフィルターを作成します。

plugins/filters.js
import Vue from "vue";
import dayjs from "dayjs";

// 表示日時のフォーマット
Vue.filter('formatDate', function (value) {
  if (value) {
    return dayjs().format('YYYY/MM/DD hh:mm')
  }
})

// 本文のトリミング
Vue.filter("description", function(value) {
  if (value) {
    return value.slice(0, 100) + "...";
  }
})

これを、components 下にある SearchResult.vue に適用します。

components/SearchResult.vue
<template>
<div>
  <!-- 検索結果が0件だった場合 -->
  <template v-if="lists.length === 0 && !isLoading">
    <i class="el-icon-warning">&nbsp;No results found for your keyword.</i>
  </template>
  <!-- 検索結果一覧 -->
  <template 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">
              <a :href="element.url" target="_blank">{{ element.title }}</a>
            </div>
            <div class="bottom content-style text">
              <!-- 日付をフォーマット -->
-             <div>{{ element.created_at }}</div>
+             <div>{{ element.created_at | formatDate }}</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>{{ element.body }}</div>
+         <div>{{ element.body | description }}</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>
  </template>
  </div>
</template>

ビルド

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

yarn run generate

以上で完成です!

デモ

多少はレベルアップした感出てると思います。
※レスポンシブではないので PC でのアクセス推奨

最後に

コンポーネント間のデータの受け渡しやリスト、イベントハンドリングなど、基本的な操作は体験できたと思います。
vue.js は画面で行われる処理が分かりやすいと感じました。

調べていた頃、Element-ui を使っている人が多く採用したのですが、レスポンシブデザインではないのでその点は注意が必要です。

ページネーションもない中途半端なサイトですが、何かの参考になれば嬉しいです:)

参考

ソースコード

https://github.com/aytdm/hello-nuxt/tree/v1.1