この記事は 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対応のアプリケーションを簡単に実装することができます。
PWA
一般的なWebページをモバイルのネイティブアプリと同様のUXが提供可能になる仕組みです。
例として、ホーム画面への追加やプッシュ通知などを機能として提供できます。
また、ブラウザ経由でアプリをインストール出来るため、App Store などにアプリを登録しなくても、モバイルアプリを配布可能になる特徴もあります。
Netlify
無料の静的コンテンツのホスティングサービスです。
高機能ホスティングサービスNetlifyについて調べて使ってみた - Qiita
feedlyAPI
feedlyが提供している、フィード情報の取得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
変数の配列を更新するだけで済むのは非常に便利です。
<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
に設定を追記します。
マニフェストファイルも設定を元にモジュールが自動生成してくれます。
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です。
workbox: {
dev: true,
importScripts: [
'custom-sw.js' // JSファイルの配置場所は /static/custom-sw.js
]
},
フィードの取得・登録・削除
indexedDBを利用しています。
個人で使うような簡単なWebアプリケーションを開発する時に、いちいちサーバー立ててDBを用意するのは非常に手間ですが、localStorageでCRUD実装は地味に面倒でした。
それと比べると、かなり柔軟にデータのCRUD実装ができ、インデックスも貼れるので非常に便利です。
オフラインキャッシュをするPWAとも相性が良いので、PWAの普及と共にますます利用率が増えていきそうな気がします。
/**
* フィード情報を管理するデータベースクラス
*
* 使い方:
* 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に関しては、オフラインキャッシュなどほとんどが未実装のため、高速化の対応など色々と面白そうなことが残っているので、色々と試してみようと思います。