Nuxt.js と PWA で作るフィードリーダー

この記事は [Hamee Advent Calendar](https://qiita.com/advent-calendar/2018/hamee) 23日目の記事です。

## はじめに

Nuxt.js と PWA を試す目的で、前から作りたかったオリジナルのフィードリーダーを作ってみました。
ハンズオン形式で記事を書きたかったのですが、思ったよりも分量が多くなりそうだったので、部分的な技術紹介に留めたいと思います。

「管理を簡単に」をコンセプトにしているので、サーバーサイドは一切実装せず静的ファイルだけをホスティングサービスで配布しています。

全体のソースコードは github のリポジトリを参照してください。
https://github.com/t-yng/pwa-feed-reader

スマートフォンでWebページにアクセス（AndroidはChrome, iPhoneはsafari）して、ブラウザのメニューから「ホーム画面に追加」を押せば、モバイルアプリとして利用することが出来ます。
https://pwa-feed-reader.netlify.com/

※ `X-Frame-Options`が設定してあるページは表示出来なかったり、記事の更新日時が正常に取得出来なかったり、と結構な部分が未完成です。

<img src="https://qiita-image-store.s3.amazonaws.com/0/54838/c7d33551-8680-cd46-7bb3-b9e77c1daacd.gif" width="40%">

## 利用技術の紹介

### Nuxt.js
ユニバーサルなvue.jsでアプリケーションを開発をするためのフレームワークです。
静的サイト・SPA・SSR対応のアプリケーションを簡単に実装することができます。

[はじめに - Nuxt.js](https://ja.nuxtjs.org/guide/)

### PWA
一般的なWebページをモバイルのネイティブアプリと同様のUXが提供可能になる仕組みです。
例として、ホーム画面への追加やプッシュ通知などを機能として提供できます。

また、ブラウザ経由でアプリをインストール出来るため、App Store などにアプリを登録しなくても、モバイルアプリを配布可能になる特徴もあります。

[いまさら聞けないPWAとAMP - Qiita](https://qiita.com/edwardkenfox/items/4c0b9550ffa48c1f0445)

### Netlify
無料の静的コンテンツのホスティングサービスです。

[高機能ホスティングサービスNetlifyについて調べて使ってみた - Qiita](https://qiita.com/TakahiRoyte/items/b7c4d1581df1a17a93fb)

### feedlyAPI
feedlyが提供している、フィード情報の取得API
フィード検索で利用しています。

[Search | feedly Cloud API](https://developer.feedly.com/v3/search/)

### YQL
Yahoo!が提供している、外部APIで取得するXMLやJSONをJSONPとして扱えるようにできる便利なAPIです。
CORS対応していない外部APIをブラウザからリクエストすると発生する、クロスドメイン制約のエラー回避のための、プロキシとして利用しています。

[Google Feed API から YQL API へ移行したお話 - Qiita](https://qiita.com/nyatto/items/94c3f7cac14e8e8ef50f)

### IndexedDB
Webストレージよりも大容量のデータが保存可能なブラウザで利用できるAPI
localStorageなどと比較して、SQL ベースの RDBMS に似たトランザクショナルデータベースシステムでデータのCRUD操作をより柔軟に行うことができます。

## 実装説明

## フィードの追加機能
ユーザーが入力したキーワードを元にフィードを検索します。

<img src="https://qiita-image-store.s3.amazonaws.com/0/54838/80eda07a-2f2b-3700-275b-1c99cde4e444.gif" width="30%">

feedlyAPIはCORS対応していないため、YQLを噛ませてクロスドメイン制約を回避しています。

検索結果の一覧の描画更新は vue.js のリストレンダリングを利用しているので、でコンポーネントの`feeds`変数の配列を更新するだけで済むのは非常に便利です。

```javascript:pages/add.vue　
<template>
  <div>
    <Header/>
    <Content>
      <div class="search-box-wrapper">
        <div class="search-box">
          <input type="text" class="text-input" placeholder="キーワードまたはFeed URL を入力..." v-model="searchQuery">
        </div>
      </div>
      <div class="feed-list">
        <!-- feedsが検索結果の一覧の描画が自動で更新される -->
        <FeedListItem v-for="(feed, index) in feeds" :feed="feed" :key="index"/>
      </div>
    </Content>
  </div>
</template>

<script>
import _ from 'lodash'
import jsonp from 'jsonp'
import Header from '~/components/feed/add/Header.vue'
import Content from '~/components/common/Content.vue'
import FeedListItem from '~/components/feed/add/FeedListItem.vue'
import feedDb from '~/assets/js/feed-db'

export default {
  components: {
    Header,
    Content,
    FeedListItem
  },
  watch: {
    searchQuery: function(newQuery) {
      this.debouncedSearchFeeds()
    }
  },
  created: function() {
    this.debouncedSearchFeeds = _.debounce(this.searchFeeds, 500)
  },
  methods: {
    searchFeeds: async function() {
      if(this.searchQuery === '') {
        this.feeds = []
        return
      }

      const db = await feedDb.connect()
      const storedFeeds = await db.getAll()
      const storedFeedIds = storedFeeds.map((feed) => feed.id)

      // feedlyAPIのリクエストURLを生成
      const searchQuery = encodeURIComponent(this.searchQuery)
      const feedlyUrl = `https://cloud.feedly.com/v3/search/feeds/?query=${searchQuery}`

      // YQLのリクエストURLを生成
      const yqlQuery = encodeURIComponent(`select * from json where url="${feedlyUrl}"`)
      const yqlUrl = `https://query.yahooapis.com/v1/public/yql?q=${yqlQuery}&format=json`

      // JSONPでYQL経由でフィードを検索
      jsonp(yqlUrl, null, (err, data) => {
        if(data.query.results === null) {
          this.feeds = []
          return
        }

        let feeds = data.query.results.json.results
        // フィードが1件の時にYQLが配列からオブジェクトにキャストするため配列に戻す
        if(!Array.isArray(feeds)) {
          feeds = [feeds]
        }

        // フィードの一覧を更新して検索結果を表示
        this.feeds = feeds.filter((feed) => feed.visualUrl)
          .map((feed) => {
            const feedUrl = feed.feedId.replace('feed/', '')
            return {
              id: feed.feedId,
              added: storedFeedIds.includes(feed.feedId),
              title: feed.title,
              website: feed.website,
              url: feedUrl,
              iconUrl: feed.visualUrl,
            }
          })
        })
      }
  },
  data: function() {
    return {
      searchQuery: '',
      feeds: []
    }
  }
}
</script>
```

### PWA対応
Nuxt.jsでは [nuxt-community/pwa-module](https://github.com/nuxt-community/pwa-module) を利用して非常に手軽にPWA実装をすることが出来ます。

最初にPWAモジュールをインストールします。

```bash
$ npm install -D @nuxtjs/pwa-module
```

`nuxt.config.js` に設定を追記します。
マニフェストファイルも設定を元にモジュールが自動生成してくれます。

```javascript:nuxt.config.js
  modules: [
    '@nuxtjs/pwa',   // 追加
    '@nuxtjs/axios',
  ],

  workbox: {
    dev: true, // 開発環境でも利用可能にするための設定
  },

  // PWAのマニフェスト
  manifest: {
    name: 'Feedリーダー',
    lang: 'ja',
    'start_url': "/",
    'display': "standalone",
  },
```

最後にビルドすれば、マニフェストファイルと`/static/sw.js`が生成され、PWA化された状態になります。

```
$ npx nuxt build
```

#### サービスワーカーの登録
自分で実装したサービスワーカーを登録したい場合は、`/static`配下にJSファイルを配置して、`workbox.importScripts`に追記すればOKです。

```javascript:nuxt.config.js
  workbox: {
    dev: true,
    importScripts: [
      'custom-sw.js'  // JSファイルの配置場所は /static/custom-sw.js
    ]
  },
```

### フィードの取得・登録・削除
indexedDBを利用しています。
個人で使うような簡単なWebアプリケーションを開発する時に、いちいちサーバー立ててDBを用意するのは非常に手間ですが、localStorageでCRUD実装は地味に面倒でした。
それと比べると、かなり柔軟にデータのCRUD実装ができ、インデックスも貼れるので非常に便利です。
オフラインキャッシュをするPWAとも相性が良いので、PWAの普及と共にますます利用率が増えていきそうな気がします。

```javascript:assets/js/feed-db.js
/**
 * フィード情報を管理するデータベースクラス
 *
 * 使い方:
 * import feedDb from 'feed-db'
 * 
 * const db = await feedDb.connect()
 * db.add(feed)
 */
class FeedDB {
  static get DB_NAME() {
    return 'feed-db'
  }

  static get FEEDS_STORE_NAME() {
    return 'feeds'
  }

  /**
   * データベースへ接続する
   * 非同期で接続インスタンスが取得されるのでPromiseで対応している
   */
  async connect() {
    return new Promise((resolve, reject) => {
      if(this.db) {
        resolve(this)
        return
      }

      const req = indexedDB.open(FeedDB.DB_NAME)

      req.onupgradeneeded = (event) => {
        const db = event.target.result
        db.createObjectStore(FeedDB.FEEDS_STORE_NAME, { keyPath: 'id' })
      }

      req.onsuccess = (event) => {
        this.db = event.target.result
        resolve(this)
      }
    })
  }
  
  getTransaction(stores, readWrite = 'readwrite') {
    return this.db.transaction(stores, readWrite)
  }

  getFeedsStore() {
    return this.getTransaction([FeedDB.FEEDS_STORE_NAME]).objectStore(FeedDB.FEEDS_STORE_NAME)
  }

  add(feed) {
    const store = this.getFeedsStore().add(feed)
  }

  remove(feedId) {
    this.getFeedsStore().delete(feedId)
  }

  async getAll() {
    return new Promise((resolve, reject) => {
      let feeds = [];
      const store = this.getFeedsStore()
      store.openCursor().onsuccess = function(event) {
        var cursor = event.target.result;
        if (cursor) {
          feeds.push(cursor.value);
          cursor.continue();
        }

        resolve(feeds)
      };
    })
  }
}

export default new FeedDB()
```

## さいごに
Nuxt.js と PWA でフィードリーダーの実装をしました。
記事で詳細は書いてないですが、サーバーレスで静的ファイルをホスティングするだけで、モバイルアプリが配信できるのは非常に面白いなと感じました！
PWAに関しては、オフラインキャッシュなどほとんどが未実装のため、高速化の対応など色々と面白そうなことが残っているので、色々と試してみようと思います。