Help us understand the problem. What is going on with this article?

Nuxt×News API with Vuetifyで国内・海外のニュースを集めたニュースサイトを作る😉

nuxt-news-app

こんにちは、K (@k_urtica)です。

みなさん、News APIをご存じでしょうか?膨大な数の様々なニュースソースから国内・世界の最新のニュースを取得することが出来るWeb APIです。リクエストの制限はありますが無料で使うことができるものです✨

今回はそのNews APIを使用して国内・海外発の様々なニュースを集めたニュースサイトを作りたいと思います。
なお、ソースコードはGitHubで公開しています。また実際のニュースサイトはこちらのリンクから確認することができます💡

この記事で学べる(かもしれない)こと🤔

  • NuxtとVuetifyの統合、Vuetifyを使用したUIの構築
  • 外部APIをコールしてフェッチしたデータをNuxtで利用する方法
  • Zeit NowでNuxtアプリを公開する方法

サービス概要

これから作るニュースサイトのおおまかな概要です。機能自体はそこまで多くなくシンプルなニュースサイトになっています🤭
もちろんモバイル対応、ついでにPWA化します。

トップページ

top.png

トップページはNews APIで取得できるヘッドラインニュースおよびヘッドラインニュースのテクノロジー~ヘルスといったカテゴリーごとのトップニュースを5件ずつカード形式で表示します。カードはリンクとしそれぞれのニュースページへ飛べるようにします。各ニュースカードごとにシェア用のボタンを付けてシェアできるようにしたいと思います👍

また、共通コンポーネントとして画面左側のドロワーメニューおよびヘッダー・フッターがあります(これらは全ページ共通で表示します)

ヘッドラインカテゴリーページ

category.png

カテゴリー別にヘッドラインニュース一覧を表示します。トップページでは各カテゴリごとに5件までの表示に制限していましたがこのページでは最大30件のニュースを取得して表示します。またニュースカードのレイアウトも少し変えて画像を大きく表示するようにします。

ワールドニュースページ

world.png

ワールドニュースページは世界の様々なニュースソース(新聞・通信社、メディア)から取得したニュースを表示します。ヘッドラインカテゴリーページと同様にこちらもニュースソースごとに最大30件表示するようにします。
※ちなみにNews APIは海外のサービスなのでワールドニュースはニュースソースの数が多くなっています。

ナビゲーションドロワー

drawer.png

ページ共通のドロワーには上記のカテゴリーページやワールドニュースページへのリンクを設置します。
また、ダークモードに切り替えられるスイッチをここに付けたいと思います☀

技術スタック

今回構築に使用した主な技術は以下です。

  • Nuxt.js 2.11.0 (SPA)
  • Vuetify.js 2.2.8
  • AOS
  • ZEIT Now

構成図

全体の構成図は↓です。ホスティングにNowを使います。GitHubの更新をトリガーにしてNowでビルドされ、NuxtのSPA資産を静的ホスティングし、同じくserverMiddlewareをサーバーレス関数としてデプロイします。

サーバーレス関数ではNews APIをコールして各種ニュースを取得するようにします。
クライアントサイドから直接ではなく、サーバーレス関数を経由しサーバーレス関数自体のAPIをNowのCDNでキャッシュすることでNews APIのリクエスト制限を回避します。また、News APIのAPI Keyをクライアントサイドから隠蔽します。

構成図.jpg

News APIアカウント登録

最初にNews APIを使うためにAPI Keyを取得します。News APIのサイトで登録を行う必要がありますが、大した入力はなく、名前・メールアドレス・パスワードだけで簡単に登録できます。登録したらAPI Keyが発行されます。

プロジェクト作成

では実際にNuxtプロジェクトを作ります。以下のコマンドで一発で作成できます😉

$ yarn create nuxt-app <project-name>

選択肢は以下を選択します。下記以外はデフォルトのままでOKです。

  • UIフレームワーク:Vuetify
  • サーバーサイドフレームワーク:None
  • Nuxtモジュール:Axios
  • Linter:ESLint、Prettier(どちらも任意)
  • レンダリングモード:Single Page App(SPA)

dotenv追加

環境変数を使うためにdotenv(@nuxtjs/dotenv)を追加します。
create nuxt-app時に選択していればプロジェクト作成時に導入できるのですが、どうもテンプレートが古い(※)らしくdependenciesとして追加されたりnuxt.configでmodulesに定義されたりしてしまうので個別に追加します。
※Nuxt v2.9以前の方法で導入されてしまう

yarn add --dev @nuxtjs/dotenv
nuxt.config.js
  buildModules: [
    "@nuxtjs/eslint-module",
    "@nuxtjs/vuetify",
    "@nuxtjs/dotenv" // ← buildModulesに追加
  ],

プラグイン導入・設定

使うプラグイン周りの設定を最初にします。

Vuetify

UIフレームワークのVuetifyです。プロジェクト作成時点で下記の設定がありますが、ライトテーマかつデフォルトカラーテーマとするためコメントアウト部分を削除します。
また、SASS変数オーバーライド用のファイルのパスも変えておきます。

nuxt.config.js
  vuetify: {
    customVariables: ["~/assets/css/vuetify/variables.scss"]
    // customVariables: ['~/assets/variables.scss'],
    // theme: {
    //   dark: true,
    //   themes: {
    //     dark: {
    //       primary: colors.blue.darken2,
    //       accent: colors.grey.darken3,
    //       secondary: colors.amber.darken3,
    //       info: colors.teal.lighten1,
    //       warning: colors.amber.base,
    //       error: colors.deepOrange.accent4,
    //       success: colors.green.accent3
    //     }
    //   }
    // }
  },

axios

axiosはbaseURLを環境変数化しておきます。
ついでにNews APIのAPI Keyも同様に環境変数として注入できるようにしておきます。ここでのAPI Keyはローカル開発時のみ使うもので、本番環境では別途Nowのsecretsを使ってserverless Functionsへ環境変数を渡します(本番環境ではクライアントサイドとAPI Keyを共有しないようにします)

nuxt.config.js
require("dotenv").config();
const { BASE_URL, API_KEY } = process.env;

export default {
  // ...省略
  axios: {
    baseURL: process.env.BASE_URL || "http://localhost:3000"
  },
  env: {
    BASE_URL,
    API_KEY
  }
}
.env
BASE_URL=http://localhost:3000
API_KEY=<API KEY>

また、エラー発生時の共通処理をinterceptorsに実装します。interceptorsを定義することにより、個々のAPI呼び出し箇所でのエラーハンドリングの必要をなくします。
Nuxtのerrorコンテキストを使いerrorページへ遷移させることができます。

/plugins/axios.js
export default function({ $axios, error }) {
  $axios.onError((e) => {
    error({
      statusCode: e.response.status,
      message: e.response.data.message
    });
  });
}

aos

aosはスクロールアニメーションライブラリです💫 軽量かつ簡単に導入できるのでいれます!

yarn add aos@next

プラグインに追加します。initでグローバルな設定を定義できます。

/plugins/aos.js
import AOS from "aos";
import "aos/dist/aos.css";

export default ({ app }) => {
  // eslint-disable-next-line new-cap
  app.AOS = new AOS.init({
    duration: 400,
    easing: "ease-out"
  });
};

ssrには対応していないため、modeでclientを指定してあげます。

nuxt.config.js
  plugins: [
    "@/plugins/axios",
    { src: "~/plugins/aos", mode: "client" }, // ←←
    "~/plugins/dayjs.js"
  ],

dayjs

日付の解析・検証・フォーマット等ができる軽量ライブラリです。なくてもOKですがニュース日付を扱うのに便利なので入れておきます。
色々なところで簡単に使えるようにinjectします。(統合された注入)

/plugins/dayjs.js
import dayjs from "dayjs";

import "dayjs/locale/ja";
dayjs.locale("ja");

export default ({ app }, inject) => {
  inject("dayjs", (string) => dayjs(string));
};
nuxt.config.js
  plugins: [
    "@/plugins/axios",
    { src: "~/plugins/aos", mode: "client" },
    "~/plugins/dayjs.js"  // ←←
  ],

実装

ようやくですが、、実装に入りたいと思います😆

トップページ

image.png

トップページは共通コンポーネントのヘッダー/ドロワー/フッター(見えていないですが)、pagesファイルのindex.vueおよびSNSシェア用ボタンコンポーネントからなります。

ヘッダー

ページ上部のヘッダーバーです。このコンポーネントはトップページだけでなくページ共通で使います。

機能
  • リンク付きサイトタイトル
  • ドロワー開閉用のハンバーガーメニュー
実装

長いので開いてね 😉
TheHeader.vue
<template>
  <v-app-bar class="header-bar" flat dense app clipped-left>
    <v-app-bar-nav-icon @click.stop="drawer" dark aria-label="drawer" />
    <v-toolbar-title>
      <nuxt-link :to="{ name: 'index' }">
        <h1>NUXT×NEWS APP</h1>
      </nuxt-link>
    </v-toolbar-title>
  </v-app-bar>
</template>

<script>
export default {
  methods: {
    drawer() {
      this.$store.commit("setDrawer", !this.$store.state.drawer);
    }
  }
};
</script>

<style lang="scss" scoped>
.header-bar {
  background: linear-gradient(to bottom, #323232 0%, #3f3f3f 40%, #1c1c1c 150%),
    linear-gradient(
      to top,
      rgba(255, 255, 255, 0.4) 0%,
      rgba(0, 0, 0, 0.25) 200%
    );
  background-blend-mode: multiply;
  opacity: 0.87;
}
a {
  text-decoration: none;
}
h1 {
  font-size: 28px;
  color: #ff7c00;
  background: -webkit-linear-gradient(
    top,
    #ffb76b 0%,
    #ffa73d 50%,
    #ff7c00 51%,
    #ff7f04 100%
  );
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  @media screen and (max-width: 600px) {
    font-size: 23px;
  }
}
</style>

ヘッダーはVuetifyのv-app-barを使用します。
clipped-leftpropを付与することで、後述のナビゲーションドロワーコンポーネントをv-app-barの下に配置することができるようになります。

ハンバーガーメニューはv-app-bar-nav-iconだけで実現できます。
このボタンでドロワーメニューの開閉制御を行えるようにします。ただし開閉制御用のフラグおよび更新処理はstoreに定義するため、ここではdrawerメソッドでmutationsをコールするのみです。

ドロワー

画面左の開閉可能なドロワーメニューです。各ニュースカテゴリー・ワールドニュースへのリンクを設けます。
(いちおう下部に私のSNSアカウントへのリンクおよびシェア用ダイアログ表示ボタンがあります)

機能
  • ヘッドラインカテゴリー、ワールドニュースへのリンクメニュー
  • ダークモード切り替えスイッチ
実装

かなり長いので開いてね 😉
TheDrawer.vue
<template>
  <v-navigation-drawer
    v-model="drawer"
    app
    clipped
    floating
    mobile-break-point="1020"
  >
    <v-list shaped dense class="py-5">
      <v-list-item
        v-for="item in drawerItems"
        :key="item.title"
        :to="item.link"
        exact
        nuxt
        color="primary"
      >
        <v-list-item-icon>
          <v-icon v-text="item.icon" />
        </v-list-item-icon>
        <v-list-item-title>{{ item.title }}</v-list-item-title>
      </v-list-item>

      <v-list-item
        v-for="item in headlineCategory"
        :key="item.title"
        :to="item.link"
        exact
        nuxt
        color="primary"
        class="pl-8"
      >
        <v-list-item-icon>
          <v-icon v-text="item.icon" />
        </v-list-item-icon>
        <v-list-item-title>{{ item.title }}</v-list-item-title>
      </v-list-item>

      <v-divider class="my-1" />

      <v-list-group no-action>
        <template v-slot:activator>
          <v-list-item-icon>
            <v-icon>mdi-earth</v-icon>
          </v-list-item-icon>
          <v-list-item-title>ワールド</v-list-item-title>
        </template>

        <v-list-item
          v-for="source in $store.state.newsSouceList"
          :key="source.id"
          :to="{ name: 'world-source', params: { source: source.id } }"
          nuxt
          exact
        >
          <v-list-item-title>{{ source.name }}</v-list-item-title>
        </v-list-item>
      </v-list-group>
    </v-list>

    <template v-slot:append>
      <v-row justify="center">
        <v-tooltip top>
          <template v-slot:activator="{ on }">
            <v-switch
              v-model="darkMode"
              v-on="on"
              color="warning"
              class="mx-auto"
              hide-details
              inset
              dense
              validate-on-blur
            />
          </template>
          <span class="caption">テーマ切り替え</span>
        </v-tooltip>
      </v-row>

      <div class="pa-3 text-center">
        <v-divider />
        <v-btn
          v-for="item in bottomItems"
          :key="item.title"
          :href="item.link"
          :title="item.title"
          target="_blank"
          rel="noopener"
          class="mr-3"
          icon
        >
          <v-icon>{{ item.icon }}</v-icon>
        </v-btn>

        <share-dialog />
      </div>
    </template>
  </v-navigation-drawer>
</template>

<script>
const ShareDialog = () => import("~/components/share/ShareDialog.vue");

export default {
  components: {
    ShareDialog
  },
  data: () => ({
    drawerItems: [
      {
        title: "トップニュース",
        icon: "mdi-newspaper-variant-multiple-outline",
        link: { name: "index" }
      },
      {
        title: "ヘッドライン",
        icon: "mdi-fire",
        link: {
          name: "headline-category",
          params: { category: "general" }
        }
      }
    ],
    headlineCategory: [
      {
        title: "テクノロジー",
        icon: "mdi-laptop-mac",
        link: {
          name: "headline-category",
          params: { category: "technology" }
        }
      },
      {
        title: "ビジネス",
        icon: "mdi-domain",
        link: {
          name: "headline-category",
          params: { category: "business" }
        }
      },
      {
        title: "エンターテイメント",
        icon: "mdi-filmstrip",
        link: {
          name: "headline-category",
          params: { category: "entertainment" }
        }
      },
      {
        title: "スポーツ",
        icon: "mdi-soccer",
        link: {
          name: "headline-category",
          params: { category: "sports" }
        }
      },
      {
        title: "サイエンス",
        icon: "mdi-atom",
        link: {
          name: "headline-category",
          params: { category: "science" }
        }
      },
      {
        title: "ヘルス",
        icon: "mdi-heart-circle-outline",
        link: {
          name: "headline-category",
          params: { category: "health" }
        }
      }
    ],
    bottomItems: [
      {
        title: "Twitter",
        link: "https://twitter.com/intent/follow?screen_name=k_urtica",
        icon: "mdi-twitter"
      },
      {
        title: "GitHub Repo",
        link: "https://github.com/kiysi/nuxt-news-app",
        icon: "mdi-github-circle"
      }
    ],
    darkMode: false
  }),
  computed: {
    drawer: {
      get() {
        return this.$store.state.drawer;
      },
      set(val) {
        this.$store.commit("setDrawer", val);
      }
    }
  },
  watch: {
    darkMode() {
      this.$vuetify.theme.dark = !this.$vuetify.theme.dark;
    }
  }
};
</script>

ドロワーはVuetifyのv-navigation-drawerを使用します。
clippedpropを付けることで前述のv-app-bar(ヘッダー)の下にくるように配置することができます。
Vuetifyのドロワーはmobile-break-pointの値に応じて、それ以下の画面幅の時に自動的に閉じるようになっています。デフォルトは1264pxですが、プロパティを付与することでオーバーライドできます。

ドロワー開閉制御はv-modelで行います。開閉フラグ・更新処理自体はstoreに定義しているため、算出プロパティでgetter, setterを定義しstoreのドロワー開閉フラグの参照・更新を行うようにします。
※ ちなみにコンポーネントを跨がない場合はこんなことをする必要はありません。

リンクメニューはv-listを使います。v-listは関連コンポーネントが多いのですが、基本的にはv-list内でv-list-itemをループしてコンテンツを表示します。ループするアイテムはdataに定義したメニュー用のタイトルやアイコン、リンク情報を定義した辞書のリストです。

v-list-itemには(外部・内部)リンク用のプロパティが用意されており、ここでは内部リンクを使うのでtoプロパティにリンク先情報をバインドします。
なおNuxtアプリの場合はnuxtプロパティを明示的に付けないとnuxt-linkに変換されないので注意してください。

ワールドニュースに表示するニュースリストはstoreのstateからひっぱってきます。stateにセットしてあるデータはあらかじめNews APIで取得して用意しておいたニュースソースリストのjsonファイルです。各ニュースソースのnameをリストの表示文字列に、idをリンクパラメータに利用します。

assets/json/news-sources.json
[
  {
    "id": "abc-news",
    "name": "ABC News",
    "description": "Your trusted source for breaking news, analysis, exclusive interviews, headlines, and videos at ABCNews.com.",
    "url": "https://abcnews.go.com"
  },
  ... 省略

ダークモード切替スイッチはv-switchを使います(まぁイベントを発火できればただのボタンでも何でも良いんですが。。)
肝心のテーマ切り替えですが、Vuetifyのテーマは$vuetify.theme.darkで真偽値定義されているためこれを更新するだけで簡単にライト ↔ ダークテーマを切り替えることが可能です👉
実装ではv-modelでv-switchのオンオフをdataとして保持し、ウォッチャで監視して切り替えます。

ダークモード切替スイッチ(および私のSNSアカウントボタン)はドロワーの下部に寄せていますが、これは<template v-slot:append></template>を使うことで実装できます。

フッター

image.png

フッターはこんな感じでページ最下部にあります。

実装

長いので開いてね 😉
<template>
  <v-footer color="grey darken-4" padless>
    <v-container>
      <v-row>
        <v-col class="py-0" align="center">
          <p class="white--text caption mb-0">NUXT×NEWS APP</p>
          <p class="white--text caption mb-0">
            powered by
            <a href="https://newsapi.org/" target="_blank" rel="noopener"
              >NewsAPI.org</a
            >
          </p>
        </v-col>
      </v-row>
    </v-container>
  </v-footer>
</template>

<style lang="scss" scoped>
a {
  text-decoration: none;
  color: #bbdefb;
}
</style>

中身はサイト名とNews APIへのリンクだけです。
フッターに関してもヘッダーと同様にVuetifyでコンポーネントが用意されています。ここは特筆することはないですが、例えばfixedプロパティを付与することで常時画面下部にフッターを固定することができたりします。Vuetifyを使うとかなり簡単に理想のレイアウトを実現することができます😊

index.vue

image.png

index.vueです。これはコンポーネントではなくpagesファイルです。ニュースカードを表示します。
もう少しコンポーネント化しても良かったのですが、、若干めんどうだったのでindex.vueで実装します。

機能
  • ヘッドライン・カテゴリ別ヘッドラインニュースの表示(最新5件ずつ)
実装

かなり長いので開いてね 😉
/pages/index.vue
<template>
  <v-row justify="center">
    <template v-if="topNewsList">
      <v-col
        v-for="newsItems in topNewsList"
        :key="newsItems.category"
        cols="12"
        align="center"
      >
        <h2 class="mb-1 text-left news-category">
          {{ getNewsCategory(newsItems.category) }}
        </h2>
        <v-divider class="mb-3" />
        <v-card
          v-for="news in newsItems.newsList"
          :key="news.title"
          outlined
          hover
          class="mb-3"
        >
          <v-container class="pt-3 pb-0">
            <a :href="news.url" target="_brank" rel="noopener">
              <v-row>
                <v-col class="text-left py-1 pb-0" cols="8">
                  <h3 class="news-title mb-2">{{ news.title }}</h3>
                  <p class="news-author mb-0 text-right">
                    <span class="mr-2">{{ news.author }}</span>
                    <time>{{ getFormtedDate(news.publishedAt) }}</time>
                  </p>

                  <template
                    v-if="
                      $vuetify.breakpoint.smAndUp && news.description !== null
                    "
                  >
                    <v-card-text class="news-text pl-1 py-2">
                      {{ getNewsText(news.description) }}
                    </v-card-text>
                  </template>
                </v-col>
                <v-col class="px-2" cols="4">
                  <v-img
                    :src="getImageUrl(news.urlToImage)"
                    :height="$vuetify.breakpoint.smAndUp ? 150 : 90"
                    style="border-radius: 6px;"
                  />
                </v-col>
              </v-row>
            </a>

            <share-buttons
              :news-title="news.title"
              :news-url="news.url"
              align="center"
            />
          </v-container>
        </v-card>

        <v-btn
          :to="{
            name: 'headline-category',
            params: { category: newsItems.category }
          }"
          nuxt
          small
          color="blue darken-2"
          rounded
          outlined
          min-width="50%"
          class="my-3"
        >
          {{ getNewsCategory(newsItems.category) }}
          ニュースをさらに見る
        </v-btn>
      </v-col>
      <snack-bar message="ニュースリンクをコピーしました" />
    </template>
  </v-row>
</template>

<script>
import ShareButtons from "~/components/share/ShareButtons.vue";
const SnackBar = () => import("~/components/parts/SnackBar.vue");

export default {
  head() {
    return {
      titleTemplate: null
    };
  },
  components: {
    ShareButtons,
    SnackBar
  },
  async asyncData({ $axios }) {
    const res = await $axios.$get("/api/news");
    return {
      topNewsList: res
    };
  },
  methods: {
    getNewsCategory(category) {
      switch (category) {
        case "general":
          return "ヘッドライン";
        case "technology":
          return "テクノロジー";
        case "business":
          return "ビジネス";
        case "entertainment":
          return "エンターテイメント";
        case "sports":
          return "スポーツ";
        case "science":
          return "サイエンス";
        case "health":
          return "ヘルス";
        default:
          return category;
      }
    },
    getNewsText(text) {
      const limitedText = text.substr(0, 120);
      if (limitedText.slice(-3) === "...") {
        return limitedText;
      }
      return limitedText + "...";
    },
    getFormtedDate(date) {
      return this.$dayjs(date).format("M/DD HH:mm");
    },
    getImageUrl(imageUrl) {
      if (imageUrl !== null && imageUrl.match(/^https?:\/\//)) {
        return imageUrl;
      } else {
        return require("@/assets/img/no-image.png");
      }
    }
  }
};
</script>

<style lang="scss" scoped>
.news-category {
  color: #616161;
  @media screen and (max-width: 600px) {
    font-size: 20px;
  }
}
.news-title {
  font-size: 17px;
  color: #616161;
  @media screen and (max-width: 600px) {
    font-size: 13px;
  }
}
.news-author {
  font-size: 11.5px;
  color: #757575;
}
.news-text {
  font-size: 12px;
  color: #757575;
}
a {
  text-decoration: none;
}
</style>

まず、index.vueでは画面に表示するニュースを取得するために、asyncData内でaxiosを使ってserverMiddlewareのAPIをコールします。取得したニュースデータはローカルdataに保持します。

APIで取得したニュースデータは以下の形式になっています。

[
  {
    "category": "general",
    "newsList": [
      {
        "source": {
          // 省略
        },
        "author": "...",
        "title": "...",
        "description": "...",
        "url": "https://~",
        "urlToImage": "https://~",
        "publishedAt": "...",
        "content": "..."
      },
   // + generalカテゴリのニュース4件(省略)
    ]
  },
  {
    "category": "technology",
    "newsList": [
      // 省略
    ]
  },
  // ... 省略
]

リストの要素としてカテゴリ別のヘッドラインニュースの辞書があり、辞書の要素としてカテゴリー名とニュースリスト(5件)を保持します。
そのため、index.vueではこの取得したリストをループすることで画面に出力します。

  1. 2重ループの外側のループでリストの要素を取得
  2. 内側のループでカテゴリのニュースリストでカード形式でニュースを表示

ニュース画像URLはニュースによってはなかったりするので有無を判定する必要があります(今のところなかった場合はnull or "null"がセットされるのを確認しています)
その場合はassets/img/配下の代替画像(no image)を表示するようにします。

getImageUrl(imageUrl) {
      if (imageUrl !== null && imageUrl.match(/^https?:\/\//)) {
        return imageUrl;
      } else {
        return require("@/assets/img/no-image.png");
      }
    }

ニュースカード下部のSNSシェア用ボタンはコンポーネント化します。シェア用のニュースタイトルおよびURLが必要なのでpropsで渡しておくようにします(詳細は後述)

 <share-buttons
   :news-title="news.title"
   :news-url="news.url"
   align="center"
  />

また、SNSシェア用ボタンの一番右のボタンで該当ニュースのURLをコピーできる機能を設けるのですが、コピーした時にスナックバーメッセージを表示させます。スナックバーはコンポーネント化するのでこれもpropsで表示するメッセージをわたすようにします。

<snack-bar message="ニュースリンクをコピーしました" />

image.png

ヘッドラインカテゴリーページ

image.png

ヘッドラインカテゴリーページは、ドロワーのヘッドライン~ヘルスカテゴリーを表示するページを指します。メイン部分(_category.vue)以外は共通コンポーネントを使い、ニュースカード部分はコンポーネント化するため、実装はそこまで多くないです😃
また、取得する各ニュースのカテゴリーはNews APIのパラメータの違いだけしかありません。

_category.vue

ヘッドラインカテゴリーをroutes paramに持つpagesファイルです。

機能
  • ヘッドラインニュースカードを表示する
実装

長いので開いてね 😉
/pages/headline/_category.vue
<template>
  <div>
    <h2 class="news-category text-center mb-1">{{ getNewsCategory() }}</h2>
    <v-divider class="mb-2" />

    <v-row justify="center">
      <v-col
        v-for="news in newsList"
        :key="news.title"
        class="mb-2"
        cols="12"
        sm="6"
      >
        <news-card
          :news="news"
          data-aos="zoom-in-up"
          data-aos-anchor-placement="top-bottom"
        />
      </v-col>
      <snack-bar message="ニュースリンクをコピーしました" />
    </v-row>
  </div>
</template>

<script>
const NewsCard = () => import("~/components/main/NewsCard.vue");
const SnackBar = () => import("~/components/parts/SnackBar.vue");

export default {
  head() {
    return {
      title: "ヘッドライン - " + this.getNewsCategory()
    };
  },
  components: {
    NewsCard,
    SnackBar
  },
  async asyncData({ $axios, params }) {
    const res = await $axios.$get("/api/news/headline", {
      params: { category: params.category }
    });
    return {
      newsList: res.articles
    };
   },
  methods: {
    getNewsCategory() {
      switch (this.$route.params.category) {
        case "general":
          return "ヘッドライン";
        case "technology":
          return "テクノロジー";
        case "business":
          return "ビジネス";
        case "entertainment":
          return "エンターテイメント";
        case "sports":
          return "スポーツ";
        case "science":
          return "サイエンス";
        case "health":
          return "ヘルス";
        default:
          return this.$route.params.category;
      }
    }
  }
};
</script>

<style lang="scss" scoped>
.news-category {
  color: #616161;
  @media screen and (max-width: 600px) {
    font-size: 20px;
  }
}
</style>

トップページと同様にページ表示時にasyncDataでカテゴリごとのニュースリストを取得します。ヘッドラインのカテゴリはrouteのパラメータをそのまま利用します。
実際にニュースを表示するニュースカードはコンポーネントとするので、APIで取得したニュースリストをループして取り出した一つのニュースをpropsとしてニュースカードコンポーネントに渡します。
<news-card />にはaosのプロパティを付与します。これだけでアニメーションが利用できます 💫

aosの他の設定はこちら

ニュースカード

image.png

propsで受け取ったニュースデータを表示するカードコンポーネントです。カード自体をリンクとし、ニュースサイトへ飛べるようにします。

機能
  • ニュースの表示
実装

長いので開いてね 😉
/components/main/NewsCard.vue
<template>
  <div>
    <v-card hover outlined>
      <a :href="news.url" target="_blank" rel="noopener">
        <v-hover v-slot:default="{ hover }">
          <v-img
            :src="getImageUrl(news.urlToImage)"
            :class="{ 'on-hover': hover }"
            :aspect-ratio="16 / 9"
          >
            <v-container fill-height fluid class="pa-0">
              <v-row class="mt-auto" no-gutters>
                <v-col cols="12" class="news-title-section pa-2">
                  <h2 class="white--text" align="left">{{ news.title }}</h2>
                </v-col>
              </v-row>
            </v-container>
          </v-img>
        </v-hover>
      </a>
      <v-card-text class="caption text-right pt-1 pb-0">
        <span class="mr-2">{{ news.author }}</span>
        <time>{{ getFormtedDate(news.publishedAt) }}</time>
      </v-card-text>

      <share-buttons
        :news-title="news.title"
        :news-url="news.url"
        align="center"
      />
    </v-card>
  </div>
</template>

<script>
import { mapState } from "vuex";
import ShareButtons from "~/components/share/ShareButtons.vue";

export default {
  components: {
    ShareButtons
  },
  props: {
    news: {
      type: Object,
      default: null,
      required: true
    }
  },
  computed: {
    ...mapState(["headlineNewsList"])
  },
  methods: {
    getFormtedDate(date) {
      return this.$dayjs(date).format("M/DD HH:mm");
    },
    getImageUrl(imageUrl) {
      if (imageUrl !== null && imageUrl.match(/^https?:\/\//)) {
        return imageUrl;
      } else {
        return require("@/assets/img/no-image.png");
      }
    }
  }
};
</script>

<style lang="scss" scoped>
a {
  text-decoration: none;
}
.news-title-section {
  background-color: rgba(20, 20, 20, 0.65);
  h2 {
    font-size: 18px;
    @media screen and (max-width: 600px) {
      font-size: 14px;
    }
  }
}
.on-hover {
  transition: 0.3s;
  opacity: 0.7;
}
</style>

カードはVuetifyのv-cardおよび関連コンポーネントを使うことで簡単に実装できます。ニュース日付はAPIで取得してきた形式だと長ったるいものになっているので、プラグインでセットアップしたdayjsを使いフォーマットしたものを表示します(index.vueでも同様の実装)

また、index.vueと同様に画像は画像URLの有無を判定する必要があるので、URLが無かった場合はno-image画像を表示するようにします。

SNSシェアボタンはコンポーネントを利用します(後述)

SNSシェアボタン

image.png

twitter、facebook、はてブへのシェア、pocketへの追加、およびニュースURLコピー用のボタンを保持するコンポーネントです。

機能
  • SNSシェア
  • ニュースURLのコピー
実装

長いので開いてね 😉
/components/share/ShareButtons.vue
<template>
  <div>
    <v-btn
      v-for="item in itemList"
      :key="item.type"
      @click.stop="shareNews(item.type)"
      :title="item.title"
      icon
    >
      <v-icon :color="item.color" small class="share-btn">
        {{ item.icon }}
      </v-icon>
    </v-btn>

    <v-btn @click.stop="copyNewsUrl()" icon title="ニュースリンクをコピー">
      <v-icon small>mdi-link</v-icon>
    </v-btn>
  </div>
</template>

<script>
export default {
  props: {
    newsTitle: {
      type: String,
      default: "",
      required: true
    },
    newsUrl: {
      type: String,
      default: "",
      required: true
    }
  },
  data: () => ({
    itemList: [
      {
        type: "twitter",
        icon: "mdi-twitter",
        color: "#1da1f2",
        title: "Twitterでシェア"
      },
      {
        type: "facebook",
        icon: "mdi-facebook",
        color: "#3B5998",
        title: "Facebookでシェア"
      },
      { type: "hatebu", icon: "B!", color: "#008fde", title: "はてブに追加" },
      {
        type: "pocket",
        icon: "mdi-pocket",
        color: "#ee4056",
        title: "Pocketに追加"
      }
    ]
  }),
  methods: {
    shareNews(type) {
      const twitterUrl = "https://twitter.com/intent/tweet?url={0}&text={1}";
      const faceBookUrl = "https://facebook.com/sharer/sharer.php?u={0}";
      const hatebuUrl = "https://b.hatena.ne.jp/entry/{0}";
      const pocketUrl = "https://getpocket.com/edit?url={0}&title={1}";

      let shareUrl = "";
      switch (type) {
        case "twitter":
          shareUrl = this.formatByArr(twitterUrl, this.newsUrl, this.newsTitle);
          break;
        case "facebook":
          shareUrl = this.formatByArr(faceBookUrl, this.newsUrl);
          break;
        case "hatebu":
          shareUrl = this.formatByArr(hatebuUrl, this.newsUrl, this.newsTitle);
          break;
        case "pocket":
          shareUrl = this.formatByArr(pocketUrl, this.newsUrl, this.newsTitle);
          break;
        default:
          return;
      }
      window.open(shareUrl, "_blank", "noopener");
    },
    formatByArr(msg) {
      let args = [];
      for (let i = 1; i < arguments.length; i++) {
        args[i - 1] = arguments[i];
      }
      // URLエンコード
      args = args.map((x) => encodeURI(x));
      return msg.replace(/\{(\d+)\}/g, function(m, k) {
        return args[k];
      });
    },
    copyNewsUrl() {
      navigator.clipboard.writeText(this.newsUrl);
      this.$store.commit("setSnackBar", true);
    }
  }
};
</script>

<style lang="scss" scoped>
.share-btn {
  opacity: 0.5;
  &:hover {
    transition: all 0.2s ease;
    opacity: 1;
  }
}
</style>

ソースコードとしては長いですが、大したことはしません。押されたボタンを判定してそれぞれのシェアリンクを生成してシェアページを開くだけです。シェアリンク生成用のニュースタイトル・URLはpropsで受け取ります。
ニュースURLコピー用ボタンの場合は、押下時のメソッドでURLをクリップボードへのコピーにくわえて、スナックバー表示のためのmutationsをコールします。これにより、storeのフラグがスナックバーコンポーネント内で検知され、スナックバーメッセージが表示されます。

ちなみにVuetifyのデフォルトで使えるアイコンはMaterial Design Iconsです。

ワールドニュースページ

pages/world/_source.vueファイルです。
ワールドニュースーページは、基本的にはヘッドラインカテゴリーページとパス・パラメータおよびコールするAPIの違いしかありません。
ので、割愛します 🤪

layouts

上記のコンポーネントをlayoutファイルへ追加します。
ヘッダー・ドロワー・フッターといったものは基本的には<v-content></v-content>の外に配置します。

Vuetify参考

長いので開いてね 😉
<template>
  <v-app>
    <the-header />

    <the-drawer />

    <v-content>
      <v-container id="main" class="mt-4 mb-10">
        <nuxt />
      </v-container>
    </v-content>

    <the-scroll-top-button />
    <the-footer />
  </v-app>
</template>

<script>
import TheScrollTopButton from "@/components/common/TheScrollTopButton.vue";
import TheHeader from "~/components/common/TheHeader.vue";
import TheDrawer from "~/components/common/TheDrawer.vue";
import TheFooter from "~/components/common/TheFooter.vue";

export default {
  components: {
    TheHeader,
    TheDrawer,
    TheScrollTopButton,
    TheFooter
  }
};
</script>

<style lang="scss" scoped>
#main {
  max-width: 1380px;
  margin: auto;
}
</style>

serverMiddleware

NuxtのserverMiddleware実装です。Nowにはserverless FunctionsとしてデプロイしNews APIをコールします。
大まかにはExpressのドキュメントを参照してもらえればわかるかと思います。

APIとしてはトップニュース、カテゴリー別のヘッドラインニュース、ワールドニュースを取得可能なものを用意します。

重要なのは共通ヘッダー部分で、ここでレスポンスキャッシュの設定をします。
"Cache-Control": "public, max-age=300, s-maxage=7200"

ブラウザキャッシュにあたるmax-ageはいちおう300秒にセットしていますが、ここは正直どうでも良いです(0でもOK)
大事なのはNow CDNのエッジキャッシュ時間にあたるs-maxageでここは7200秒(2h)をセットします。CDNでできるだけキャッシュすることでオリジンサーバからのNews APIのリクエスト回数を抑えます。
キャッシュ時間は目安で、いちおうニュースサイトなのであまり長い時間はよくないということで上記の秒数にします。

ただし、数が多いワールドニュース取得のAPIも7200秒にしてしまうとリクエスト制限に引っかかってしまうため、ワールドニュースは別途キャッシュ時間をオーバーライドして、キャッシュ秒数を増やします。
res.set({ "Cache-Control": "public, max-age=300, s-maxage=18000" });

※ 肝心のNews APIのリクエスト制限ですが、無料枠だと1日あたり500回までかつ12時間あたり250回までというかなりシビアなものとなっています 🙁
それをふまえたキャッシュ時間を設定します。

長いので開いてね 😉
/api/news.js
import Express from "express";
import axios from "axios";

const app = Express();

const newsApi = axios.create({
  headers: {
    common: {
      Authorization: "Bearer " + process.env.API_KEY
    }
  }
});

// 共通ヘッダー
app.use((req, res, next) => {
  res.set({
    "Cache-Control": "public, max-age=300, s-maxage=7200",
    "Content-Type": "application/json; charset=utf-8"
  });
  next();
});

// トップニュース
app.get("/api/news", async (req, res, next) => {
  const endpoint = "https://newsapi.org/v2/top-headlines";
  const categorys = [
    "general",
    "technology",
    "business",
    "entertainment",
    "sports",
    "science",
    "health"
  ];
  const response = [];
  try {
    const resNews = await Promise.all(
      categorys.map((category) => {
        const params = { params: { country: "jp", pageSize: "5", category } };
        return newsApi.get(endpoint, params);
      })
    );
    categorys.map((category, index) => {
      response.push({ category, newsList: resNews[index].data.articles });
    });
    res.json(response);
  } catch (error) {
    error.statusCode = error.response.status;
    next(error);
  }
});

// ヘッドラインニュース
app.get("/api/news/headline", async (req, res, next) => {
  const endpoint = "https://newsapi.org/v2/top-headlines";
  const params = { params: { country: "jp", pageSize: "30" } };
  try {
    if (req.query.category) {
      Object.assign(params.params, { category: req.query.category });
    }
    const response = await newsApi.get(endpoint, params);
    res.json(response.data);
  } catch (error) {
    error.statusCode = error.response.status;
    next(error);
  }
});

// ワールドニュース
app.get("/api/news/world", async (req, res, next) => {
  res.set({ "Cache-Control": "public, max-age=300, s-maxage=18000" });

  const endpoint = "https://newsapi.org/v2/top-headlines";
  const params = { params: { pageSize: "30" } };
  try {
    if (req.query.sources) {
      Object.assign(params.params, { sources: req.query.sources });
    }
    const response = await newsApi.get(endpoint, params);
    res.json(response.data);
  } catch (error) {
    error.statusCode = error.response.status;
    next(error);
  }
});

app.use((err, req, res, next) => {
  console.error(err);
  const msg = err.statusCode === 429 ? "Request limited" : err.message;
  res.status(err.statusCode).send({ message: msg });
});

export default app;

PWA対応

Nuxt使ってるのでとりあえずPWA化しましょう。↓最低限(キャッシュ戦略を何も考えていない)の設定ですがPWAになります。
iconはとりあえず512×512サイズを用意しおけば、それ以下のサイズの画像をNuxt/pwaモジュールがよしなに作ってくれます。

yarn add @nuxtjs/pwa
nuxt.config.js
export default {
  // ...省略
  modules: ["@nuxtjs/pwa"],
  pwa: {
    manifest: {
      lang: "ja",
      name: "NUXT×NEWS APP",
      short_name: "NUXT×NEWS APP",
      description,
      background_color: "#f0f0f0",
      display: "standalone"
    },
    icon: {
      iconSrc: "static/img/icon.png"
    },
    workbox: {
      runtimeCaching: [
        {
          urlPattern: "^https://fonts.(?:googleapis|gstatic).com/(.*)",
          handler: "cacheFirst"
        }
      ]
    }
  },

↓ 😄

image.png

デプロイ

よーやく、ひととおり実装ができたのでNowへデプロイします!
まず、Nowのアカウント登録をします。GitHubアカウントで登録できます。 → Zeit Now登録画面
つぎにNow CLIをグローバルにインストールします↓

yarn global add now

now.json

Nowのデプロイ設定ファイルにあたるnow.jsonをプロジェクトルートに作成します。
環境変数とルート・ヘッダーの設定をします。

環境変数の設定は2か所にありbuild配下のenvはNuxtビルド時に注入される環境変数になります。こちらの環境変数はクライアントサイドと共有されます。
ルートのenvはNowのserverless Functionsへ注入される環境変数です。こちらの環境変数はクライアントサイドと共有されません(隠蔽されます)
それぞれ、axiosのベースURLとNews APIのAPI KEYをセットします。

rewritesにはSPA特有の404対策のフォールバックルートを設定します。ただし、/api/news~ルートはserverless Functionsなので別途そちらに向くようにします。

headersにはレスポンス共通ヘッダーを付与します。

{
  "version": 2,
  "build": {
    "env": {
      "BASE_URL": "@base_url"
    }
  },
  "env": {
    "API_KEY": "@api_key"
  },
  "rewrites": [
    {
      "source": "/api/news(.*)",
      "destination": "/api/news.js"
    },
    {
      "source": "/(.*)",
      "destination": "index.html"
    }
  ],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-XSS-Protection",
          "value": "1; mode=block"
        }
      ]
    }
  ],
  "github": {
    "silent": true
  }
}

より詳しくは公式ドキュメントを参照。

envセット

環境変数はnow secrets add <secret-name> <secret-value>コマンドでセットします。今回必要なのは以下2つです。

now secrets add base_url https://nuxt-news-app.<now user name>.now.sh/
now secrets add api_key <News APIのAPI KEY>

Nowへデプロイ

さて、よーやくですが実装・設定まわり含めて完了しました 👌

...色々やってきて、、じゃあいつデプロイするの!?

...

『今でしょ!!!』っていうことで↓↓

now

これでデプロイされます!
初回はプレビュー環境(staging)と本番環境両方にデプロイされます。

デプロイされたサイトはデフォルトであればhttps://nuxt-news-app.<now user name>.now.shから確認できるはずです。

以降、本番環境にデプロイするにはnow --prodコマンドかGitHubのデフォルトブランチ更新のどちらかでデプロイされます 😆

おわりに

長い記事になってしまいましたが、、最後までありがとうございました。記事分割すればよかったかもですね。
これからもチュートリアルもどきの記事を書きたいと思いますのでよろしくおねがいします 😉

あと、Vuetifyを使ったことがない人はぜひ使ってみてほしいです!

おまけ(運営中のサービス)

最後に、、こんなサービスを開発・運営しています。Qiitaの人気の記事・ユーザー・技術書籍を見つけることができるWebサービスです↓↓
QT Visualizer
よかったらぜひ見てください 😊

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした