18
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

HameeAdvent Calendar 2018

Day 23

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

Last updated at Posted at 2018-12-23

この記事は Hamee Advent Calendar 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が設定してあるページは表示出来なかったり、記事の更新日時が正常に取得出来なかったり、と結構な部分が未完成です。

利用技術の紹介

Nuxt.js

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

はじめに - Nuxt.js

PWA

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

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

いまさら聞けないPWAとAMP - Qiita

Netlify

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

高機能ホスティングサービスNetlifyについて調べて使ってみた - Qiita

feedlyAPI

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

Search | feedly Cloud API

YQL

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

Google Feed API から YQL API へ移行したお話 - Qiita

IndexedDB

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

実装説明

フィードの追加機能

ユーザーが入力したキーワードを元にフィードを検索します。

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

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

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 を利用して非常に手軽にPWA実装をすることが出来ます。

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

$ npm install -D @nuxtjs/pwa-module

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

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です。

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

フィードの取得・登録・削除

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

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に関しては、オフラインキャッシュなどほとんどが未実装のため、高速化の対応など色々と面白そうなことが残っているので、色々と試してみようと思います。

18
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?