20190211のvue.jsに関する記事は9件です。

Nuxt.js(SPAモード)でユーザー認証を効率的にする方法を解説

こんにちは、とくめいチャットサービス「ネコチャ」運営者のアカネヤ(@ToshioAkaneya)です。

今回は、Nuxt(SPAモード)で、ミドルウェアを使いユーザー認証をする方法を解説します。

user/authというエンドポイントが、クッキーやローカルストレージを元に認証済みユーザーを返すAPIだとします。

以下のように、Vuexにuserが登録されていなければ、authを呼び出すことで、APIの呼び出しを抑えてログイン処理を書くことが出来ます。

クッキーやローカルストレージにトークンが保存されてるブラウザで、Nuxtアプリに訪れた時に任意のページについてユーザー認証を済ませることが出来ます。

middleware/auth.js
export default async ({store}) => {
  if (!store.getters['user']) {
    await store.dispatch('auth')
  }
}
nuxt.config.js
  router: { middleware: ['auth'] },
store/index.js
export const state = () => ({
  user: null
})

export const getters = {
  user: (state) => state.user,
}
export const mutations = {
  setUser(state, { user }) {
    state.user = user
  }
}
export const actions = {
  async auth({ commit }) {
    const user = await this.$axios.$get(`/users/auth`)
    commit('setUser', { user })
  }
}

はてなブックマーク・Pocketはこちらから

はてなブックマークに追加
Pocketに追加

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Ruby on Rails, Vue.js で始めるモダン WEB アプリケーション入門

はじめに

この記事では Ruby on Rails と Vue.js を使って WEB アプリケーションを作成しながら、必要な技術について説明しています。

私自身は Rails を使う機会が多いのですが、Vue.js を勉強する目的で学んだことをまとめたものです。
そのため Rails は既に触ったことがあり、Vue.js を初めて使ってみたいという人に向けて入門となる情報です。

ここで紹介したコードはGitHubに公開しています。

この記事で作成するアプリケーションについて

この記事では以下の環境で動作することを確認しました。

  • Ruby on Rails: 5.2.2
    • Ruby: 2.5.3
    • Node.js: 8.14.0 (webpackerは 6.44+ が必要)
    • npm: 6.4.1
    • yarn: 1.12.3
  • Vue.js: 2.6.2

Vue.js とは

Vue.js とは Javascript のフレームワークです。
以下の特徴があります。

  • 親しみやすい
    • HTML, CSS, JavaScript を知っている人は親しみやすい
  • 融通が効く
    • ライブラリから、完全な機能を備えたフレームワークまでの間でスケールできる
    • 徐々に適用できるエコシステム
  • 高性能
    • 20KB min+gzip コンパクトなランタイム
    • 猛烈に速い Virtual DOM
    • 最小限の成果で最適化が可能

Ruby on Rails を初期化する(Vue.js 利用)

Rails を初期化する方法は rails new です。
今回は Vue.js を使いたいので --webpack=vue オプションを追加します。
※ Rails 5.1 から Webpacker がサポートされ Rails で Webpack が利用できるようになりました。(参考)

尚、既に作成した Rails で Vue を使いたい場合は ./bin/rails webpacker:install:vue を実行すればよい。

Rails を初めて学ぶ方は、Ruby on Rails 事始めRuby on Rails 事始め - その2 も参考にしてみてください。

rails new コマンドを実行すると次のファイルとディレクトリが作成されます。
(開発を進める上で意識する必要があるディレクトリには説明文を載せました)

.
├── Gemfile
├── Gemfile.lock
├── README.md
├── Rakefile
├── app/
│   ├── assets/
│   ├── channels/
│   ├── controllers/       ... MVC の Controller 用のコードを配置するディレクトリ
│   ├── helpers/
│   ├── javascript/        ... Sprocket を使ってコンパイルする Javascript を配置するディレクトリ
│   ├── jobs/
│   ├── mailers/
│   ├── models/            ... MVC の Model 用のコードを配置するディレクトリ
│   └── views/             ... MVC の View 用のコードを配置するディレクトリ
├── bin/
│   ├── bundle             ... bundle コマンド用 stub ファイル
│   ├── rails              ... rails コマンド用 stub ファイル
│   ├── rake
│   ├── setup
│   ├── spring
│   ├── update
│   ├── webpack            ... webpack コマンド用 stub ファイル
│   ├── webpack-dev-server ... webpack-dev-server コマンド用 stub ファイル
│   └── yarn               ... yarn コマンド用 stub ファイル
├── config/
│   ├── application.rb
│   ├── boot.rb
│   ├── cable.yml
│   ├── credentials.yml.enc
│   ├── database.yml
│   ├── environment.rb
│   ├── environments
│   ├── initializers
│   ├── locales/
│   ├── master.key
│   ├── puma.rb
│   ├── routes.rb
│   ├── spring.rb
│   ├── storage.yml
│   ├── webpack/           ... webpacker 用の設定ファイルを配置するディレクトリ
│   └── webpacker.yml      ... webpacker 用の設定ファイル
├── config.ru
├── db/
├── lib/
├── log/
├── node_modules/          ... バンドルする npm パッケージが保存されるディレクトリ
├── package.json
├── public/
├── storage/
├── test/
├── tmp/
├── vendor
├── yarn-error.log         ... yarn コマンド実行時のエラーログが記述されるファイル
└── yarn.lock              ... yarn 用の npm パッケージのバージョンを管理するファイル

Webpacker を使うよう指定して初期化したことで Rails が動作するために必要なファイルやディレクトリに加えて、npm を使うためのファイルやディレクトリが作成されているのが分かると思います。

npm パッケージを管理するためには yarn を使うことになります。
※ パッケージをインストールする方法や削除する方法は yarn の使い方(公式)を参照してください。

アプリケーションの全体像

Vue.js は view に特化したアプリケーションです。
そこで DB の O/R マッピングは Rails で行い、Rails 側に API を用意し、Vue.js からは AJAX を使って API 経由でデータを取得して、ブラウザに表示するようにします。

便宜上、Vue.js で実装する機能を Frontend と呼び、Rails で実装する機能を Backend と呼ぶことにします。

モデルを作成する

まずは Backend 側でモデルを作成していきます。

Employeeモデルを作成する
$ ./bin/rails g model employee name:string department:string gender:integer birth:date joined_date:date payment:bigint note:text

作成したモデルファイルは適宜 default 値や null: false を設定しておきます。

db/migrate/20190205185733_create_employees.rb
class CreateEmployees < ActiveRecord::Migration[5.2]
  def change
    create_table :employees do |t|
      t.string :name, null: false, default: ""
      t.string :department, null: false, default: ""
      t.integer :gender, null: false, default: 0
      t.date :birth, null: true
      t.date :joined_date, null: true
      t.bigint :payment, null: false, default: 0
      t.text :note, null: false, default: ""

      t.timestamps
      t.integer :lock_version
    end
  end
end

generate が終わったらマイグレーションを行います。

$ ./bin/rails db:create db:migrate

モデルにもバリデーションを追加しておくことにします。

app/models/employee.rb
class Employee < ApplicationRecord
  GENDERS = { other: 0, male: 1, female: 2 }

  enum gender: GENDERS

  validates :gender, inclusion: { in: GENDERS.keys.concat(GENDERS.keys.map(&:to_s)) }, exclusion: { in: [nil] }
  validates :name, exclusion: { in: [nil, ""] }
  validates :department, exclusion: { in: [nil] }
  validates :payment, numericality: true, exclusion: { in: [nil] }
  validates :note, exclusion: { in: [nil] }
end

ActiveAdmin を導入する

モデル作成は終わりましたが、都度モデルを作成・編集するときに DB の操作が必要になるのは手間なので、開発がしやすくなるよう ActiveAdmin を導入しておくことにします。

ActiveAdmin を導入すると WEB でモデルを CRUD 操作できるようになり scaffold で用意しなくて済みます。

ActiveAdmin のインストール方法は公式を参照してください。ここでは user 認証無で導入します。(もし認証が必要になったらその時に追加してください)

Gemfile
gem 'activeadmin'
ActiveAdminをインストールする
$ rails g active_admin:install --skip-users

終わったらマイグレーションを行います。

$ ./bin/rails db:create db:migrate

これで http://localhost:3000/admin にアクセスすると ActiveAdmin の Dashboard 画面が表示されます。

image.png

Employee モデルを ActiveAdmin を使って編集できるようにするためには次のコマンドを実行します。

EmployeeモデルをActiveAdminでCRUD出来るようにする
$ ./bin/rails generate active_admin:resource Employee

最後に ActiveAdmin 経由で操作を許可する attribute を設定します。
ID や lock_version 等の自動で設定される値以外は全て許可すればよいでしょう。

app/admin/employees.rb
ActiveAdmin.register Employee do
  permit_params :name, :department, :gender, :birth, :joined_date, :payment, :note
end

API を作成する

API では作成した Employee モデルの一覧と詳細を取得できるようにします。
まずは ActionController::API を継承した ApiController を定義してから Employee モデル用の API Controller を作成することにします。

ApiControllerを定義する(app/controllers/api_controller.rb)
class ApiController < ActionController::API
end
EmployeesController(app/controllers/api/v1/employees_controller.rb)
class Api::V1::EmployeesController < ApiController
  before_action :set_employee, only: [:show]

  # ActiveRecordのレコードが見つからなければ404 not foundを応答する
  rescue_from ActiveRecord::RecordNotFound do |exception|
    render json: { error: '404 not found' }, status: 404
  end

  def index
    employees = Employee.all
    render json: employees
  end

  def show
    render json: @employee
  end

  private

    def set_employee
      @employee = Employee.find(params[:id])
    end
end

これでひとまず API コントローラの設定は終わりです。
最後に API コントローラへのルーティングを追加します。

config/routes.rb
Rails.application.routes.draw do
    : <snip>
  # APIコントローラへのルーティング
  namespace :api, {format: 'json'} do
    namespace :v1 do
      resources :employees, only: [:index, :show]
    end
  end
end

rails server を立ち上げたら ActiveAdmin で Employee モデルを追加してから http://localhost:3000/api/v1/employees にアクセスして一覧が JSON 形式で取得できること、 http://localhost:3000/api/v1/employees/1 等 Employee モデルの ID を指定すると該当するモデルデータが JSON 形式で表示できることを確認してみてください。

Vue.js で Hello Vue! を表示する

backend 側で TOP ページに Vue.js を表示する

まずは ./bin/rails new 又は ./bin/rails webpacker:install:vue により Vue がインストールされるとデフォルトで追加される hello_vue.js を表示させることにします。

TOP 画面に hello_vue を表示することにします。
具体的には HomeController を追加し、HomeControlelr#index を root にします。

app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
  end
end
config/routes.rb
Rails.application.routes.draw do
  root to: 'home#index'
    : <snip>
end
app/views/home/index.html.erb
<%= javascript_pack_tag 'hello_vue' %>
<%= stylesheet_pack_tag 'hello_vue' %>

HomeController と routes の内容は Rails を学んだことがあれば理解できるものだと思います。

ここで、 app/views/home/index.html.erb に書かれた <%= javascript_pack_tag 'hello_vue' %> が Webpacker を使うために必要な設定となります。(※)
javascript_pack_tag により webpacker により生成された javascript が script タグにより読み込まれるようになります。

javascript_pack_tag は内部的に javascript_include_tag を呼び出しています。(参考)
※ webpacker は app/javascript/packs/ 配下に設置されたファイルをコンパイルします。(参考)

frontend で Hello Vue! を表示する

frontend となる Vue 側のコードは hello_vue.js, app.vue です。

app/javascript/packs/hello_vue.js
import Vue from 'vue'
import App from '../app.vue'

document.addEventListener('DOMContentLoaded', () => {
  const el = document.body.appendChild(document.createElement('hello'))
  const app = new Vue({
    el,
    render: h => h(App)
  })

  console.log(app)
})
app/javascript/app.vue
<template>
  <div id="app">
    <p>{{ message }}</p>
  </div>
</template>

<script>
export default {
  data: function () {
    return {
      message: "Hello Vue!"
    }
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
  text-align: center;
}
</style>

app.vue が Vue.js で記述されたスクリプトファイルです。
ここで書かれた内容が hello_vue.jsnew Vue({ el, render: h => h(App) }) によりインスタンス化されて実行されることになります。

app.vue は template, script, style の 3 つのセクションがあります。

template は仮想 DOM を構成する DOM を定義します。
つまりブラウザで表示させる内容です。

script は仮想 DOM に関連する JavaScript を記述します。
詳細は Vue.js の公式を見て頂くことになりますが、 data が仮想 DOM が保持するデータです。
return されるハッシュがそのデータを表し、これにより key である message が Vue.js で利用できるようになります。(関数として定義するのは Vue.js のルールです)
template に書かれた {{ message }} はこのデータを指しています。

style は仮想 DOM に適用するスタイルを css で定義します。

http://localhost:3000/ にアクセスしてみると、次のように Hello Vue! が表示されたことが分かります。

image.png

image.png
※ FireFox addon - Vue.js devtools

Vue.js でモデルを表示する

frontend と backend を連携させていきます。

Vue.js で API を利用するための方法として、公式ページでも紹介されている axios を使うことにします。(参考)

これを参考にして、先ほど作成した Employee モデルの一覧を取得する API http://localhost:3000/api/v1/employees から一覧を取得し、それを列挙してみることにします。

まずは axios をインストールします。

axiosをインストールする
$ yarn add axios

次に app.vue で axios を使ってモデル一覧を取得するように修正します。

app/javascript/app.vue
<template>
  <div id="app">
    <table>
      <tbody>
        <tr>
          <th>ID</th>
          <th>name</th>
          <th>birth</th>
          <th>department</th>
          <th>gender</th>
          <th>joined_date</th>
          <th>payment</th>
          <th>note</th>
        </tr>
        <tr v-for="e in employees" :key="e.id">
          <td>{{ e.id }}</td>
          <td>{{ e.name }}</td>
          <td>{{ e.birth }}</td>
          <td>{{ e.department }}</td>
          <td>{{ e.gender }}</td>
          <td>{{ e.joined_date }}</td>
          <td>{{ e.payment }}</td>
          <td>{{ e.note }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
import axios from 'axios';

export default {
  data: function () {
    return {
      employees: []
    }
  },
  mounted () {
    axios
      .get('/api/v1/employees.json')
      .then(response => (this.employees = response.data))
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
  text-align: center;
}
</style>

script ではまず axios を import しています。これで script 内で axios を使えるようになります。

次に data を見てみると先と同様の書き方で仮想 DOM のデータとして employees が定義されていることが分かります。
初期値として空配列を設定しておき、AJAX を使ってモデル一覧が取得出来たら上書きすることにします。

次に mounted を見てみます。
mounted は Vue.js におけるライフサイクル(参考)において、仮想 DOM が DOM に置き換わるタイミングを指します。
つまりまずは employees が空配列の状態で template を使って生成された DOM が表示されることになります。(table のヘッダ行のみが存在して、内容が空)
このタイミングで API にアクセスしてモデルの取得を試みます。(axios.get('/api/v1/employees.json') の部分)
そして正常に応答が返って来た場合に employees に受け取ったデータを格納します。(.then(response => (this.employees = response.data)) の部分)
※ this は Vue コンポーネントのインスタンスを指します。this.employees により data で定義したデータを読み書き出来ます
※ response.data は JSON 形式の配列データが返る(そのようにAPIを定義したため)ため、配列として管理するための this.employees にそのまま代入しています

最後に template を見てみます。
div, table, tbody, tr, th 等は通常の DOM ですが、v-for:key や、Hello Vue! と同様に {{ e.name }} のように {{ }} で括られた内容が書かれていることが分かると思います。

v-XXX, :YYY は Vue.js のディレクティブ(参考)です。
例えば v-for ディレクティブは配列を使って DOM 要素を繰り返し表示できるといった機能があります。
また、:YYY は省略記法で v-bind:YYY ディレクティブの場合に使える書き方です。(参考)
:keyv-for と組み合わせることにより、繰り返し作成される各 DOM 要素に一意の ID をつけています。

では TOP ページを開いて見ましょう。次の画面のように取得したデータが table 形式で表示できていると思います。

image.png

image.png

アプリケーションをカスタマイズする

一覧画面でモデルが持つ全ての attributes を表示すると見づらいので、一覧画面は概要表示に留めて詳細を確認したい場合は詳細ページに遷移して、その画面内で全ての attributes を表示するようカスタマイズしていくことにします。

一覧画面では概要のみを表示させるようにする

frontend 側で表示する attributes を制限するには、先ほど作成した template から不要な項目を削除するだけです。

app/javascript/app.vue
<template>
  <div id="app">
    <table>
      <tbody>
        <tr>
          <th>ID</th>
          <th>name</th>
          <th>department</th>
          <th>gender</th>
        </tr>
        <tr v-for="e in employees" :key="e.id">
          <td>{{ e.id }}</td>
          <td>{{ e.name }}</td>
          <td>{{ e.department }}</td>
          <td>{{ e.gender }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>
  : <snip>

次に backend 側で frontend に一覧を返す API を修正して、必要な項目のみ渡すようにします。

app/controllers/api/v1/employees_controller.rb
class Api::V1::EmployeesController < ApiController
  : <snip>
  def index
    employees = Employee.select(:id, :name, :department, :gender)
    render json: employees
  end
  : <snip>
end

以上で、一覧画面を概要表示にするための修正は終わりです。

image.png

image.png

ブラウザで表示してみると、上記のように表示されている内容も、モデル一覧のデータ(frontendが受け取る employees の値)も修正されたことが分かると思います。

詳細画面を作成して一覧画面から遷移する

詳細画面を作成していきます。

画面遷移には Vue.js 公式ルータの vue-router (参考)を使うことにしました。

まずは vue-router をインストールします。

vue-routerをインストールする
$ yarn add vue-router

次にこれまで app.vue に記述していた内容を EmployeeIndexPage.vue にコピーし、app.vue ではルーティング設定を書くことにします。

app/javascript/EmployeeDetailPage.vue
<template>
  <dl>
    <dt>ID</dt>
    <dd>{{ employee.id }}</dd>
    <dt>Name</dt>
    <dd>{{ employee.name }}</dd>
    <dt>Department</dt>
    <dd>{{ employee.department }}</dd>
    <dt>Gender</dt>
    <dd>{{ employee.gender }}</dd>
    <dt>Birth</dt>
    <dd>{{ employee.birth }}</dd>
    <dt>Joined Date</dt>
    <dd>{{ employee.joined_date }}</dd>
    <dt>Payment</dt>
    <dd>{{ employee.payment }}</dd>
    <dt>Note</dt>
    <dd>{{ employee.note }}</dd>
  </dl>
</template>

<script>
import axios from 'axios';

export default {
  data: function () {
    return {
      employee: {}
    }
  },
  mounted () {
    axios
      .get(`/api/v1/employees/${this.$route.params.id}.json`)
      .then(response => (this.employee = response.data))
  }
}
</script>

<style scoped>
</style>
app/javascript/app.vue
<template>
  <div>
    <router-view></router-view>
  </div>
</template>

<script>
import Vue from 'vue'
import VueRouter from 'vue-router'

import EmployeeIndexPage from 'EmployeeIndexPage.vue'

const router = new VueRouter({
  routes: [
    { path: '/',
      component: EmployeeIndexPage  }
  ]
})

// ref. https://jp.vuejs.org/v2/guide/plugins.html#%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%81%AE%E4%BD%BF%E7%94%A8
Vue.use(VueRouter)

export default {
  router
}
</script>

<style scoped>
</style>

vue-router を使うためには Vue component に VueRouter のインスタンスを引数とするコンポーネントを作成します。
また、コンポーネントが表示する内容は router-view に書かれるので template に <router-view></router-view> を記述しておきます。

ルーティング設定は VueRouter をインスタンス化する時の routes 設定に記述し、path, component をそれぞれ設定することになります。
(名前のとおりですが、path はルーティングにマッチする URL のパスを示し、component はそのパスにアクセスした時に利用する Vue コンポーネントを示します)

また、CommonJS 環境では Vue.use を使って VueRouter を指定する必要があるとのことなので設定しておくことを忘れないようにしましょう。(参考)

次に、詳細ページを追加してルーティング設定まで行うことにします。
EmployeeIndexPage と同じ要領です。

app/javascript/EmployeeDetailPage.vue
<template>
  <dl>
    <dt>ID</dt>
    <dd>{{ employee.id }}</dd>
    <dt>Name</dt>
    <dd>{{ employee.name }}</dd>
    <dt>Department</dt>
    <dd>{{ employee.department }}</dd>
    <dt>Gender</dt>
    <dd>{{ employee.gender }}</dd>
    <dt>Birth</dt>
    <dd>{{ employee.birth }}</dd>
    <dt>Joined Date</dt>
    <dd>{{ employee.joined_date }}</dd>
    <dt>Payment</dt>
    <dd>{{ employee.payment }}</dd>
    <dt>Note</dt>
    <dd>{{ employee.note }}</dd>
  </dl>
</template>

<script>
import axios from 'axios';

export default {
  data: function () {
    return {
      employee: {}
    }
  },
  mounted () {
    axios
      .get(`/api/v1/employees/${this.$route.params.id}.json`)
      .then(response => (this.employee = response.data))
  }
}
</script>

<style scoped>
</style>
app/javascript/app.vue
<template>
  <div>
    <router-view></router-view>
  </div>
</template>

<script>
import Vue from 'vue'
import VueRouter from 'vue-router'

import EmployeeIndexPage from 'EmployeeIndexPage.vue'
import EmployeeDetailPage from 'EmployeeDetailPage.vue'

const router = new VueRouter({
  routes: [
    { path: '/',
      component: EmployeeIndexPage  },
    { path: '/employees/:id(\\d+)',  // :id は数値のみに制限する
      component: EmployeeDetailPage  }
  ]
})

// ref. https://jp.vuejs.org/v2/guide/plugins.html#%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%81%AE%E4%BD%BF%E7%94%A8
Vue.use(VueRouter)

export default {
  router
}
</script>

<style scoped>
</style>

EmployeeDetailPage.vue に書かれた this.$route.params.id/employees/:id における :id として設定された値を参照しています。(例: /employees/1 の場合は this.$route.params.id が 1 になる)

http://localhost:3000/#/http://localhost:3000/#/employees/1 にそれぞれアクセスしてみてください。一覧ページがこれまで通り表示され、詳細ページが表示できるようになっていることが確認できると思います。(/#/ って何だ?と思った方は調べてみて下さい)

image.png

最後に一覧ページから詳細ページへ遷移するリンクを追加します。

app/javascript/app.vue
<script>
  : <snip>
const router = new VueRouter({
  routes: [
    { path: '/',
      component: EmployeeIndexPage  },
    { path: '/employees/:id(\\d+)',
      name: 'EmployeeDetailPage',  // ルートに名前を付けている ref. https://router.vuejs.org/ja/guide/essentials/named-routes.html#%E5%90%8D%E5%89%8D%E4%BB%98%E3%81%8D%E3%83%AB%E3%83%BC%E3%83%88
      component: EmployeeDetailPage  }
  ]
})
  : <snip>
</script>
app/javascript/EmployeeIndexPage.vue
<template>
  <table>
    <tbody>
      <tr>
        <th>ID</th>
        <th>name</th>
        <th>department</th>
        <th>gender</th>
      </tr>
      <tr v-for="e in employees" :key="e.id">
        <td><router-link :to="{ name: 'EmployeeDetailPage', params: { id: e.id } }">{{ e.id }}</router-link></td>
        <td>{{ e.name }}</td>
        <td>{{ e.department }}</td>
        <td>{{ e.gender }}</td>
      </tr>
    </tbody>
  </table>
</template>
  : <snip>

<router-link> によりルータを使って遷移できるリンクを作成できます。
to オプションで遷移先を指定するのですが、パラメータを渡したい時などは上記のように、ルートに名前を付けてパラメータを渡すようにします。

ブラウザで表示させてみると、一覧画面の ID にリンクが付いており、クリックすると詳細ページに遷移できることが確認できると思います。

モデルの新規作成が出来るようにする

frontend にモデルの新規作成画面を作成する

まずはモデルを新規作成する画面を作っていきます。

image.png

画面は上記のようにフォームと Commit ボタンがあるのみです。
Commit ボタンが押されたらモデルの登録を行い、作成したモデルの詳細画面に遷移するようにしてみます。

作り方としてはこれまでと同様にモデルの新規作成画面用の .vue ファイルを作成して、app.vue に routing 情報を追加することになります。

app/javascript/EmployeeNewPage.vue
<template>
  <form @submit.prevent="createEmployee">
    <div v-if="errors.length != 0">
      <ul v-for="e in errors" :key="e">
        <li><font color="red">{{ e }}</font></li>
      </ul>
    </div>
    <div>
      <label>Name</label>
      <input v-model="employee.name" type="text">
    </div>
    <div>
      <label>Department</label>
      <input v-model="employee.department" type="text">
    </div>
    <div>
      <label>Gender</label>
      <select v-model="employee.gender">
        <option>other</option>
        <option>male</option>
        <option>female</option>
      </select>
    </div>
    <div>
      <label>Birth</label>
      <input v-model="employee.birth" type="date">
    </div>
    <div>
      <label>Joined Date</label>
      <input v-model="employee.joined_date" type="date">
    </div>
    <div>
      <label>Payment</label>
      <input v-model="employee.payment" type="number" min="0">
    </div>
    <div>
      <label>Note</label>
      <input v-model="employee.note" type="text">
    </div>
    <button type="submit">Commit</button>
  </form>
</template>

<script>
import axios from 'axios';

export default {
  data: function () {
    return {
      employee: {
        name: '',
        department: '',
        gender: '',
        birth: '',
        joined_date: '',
        payment: '',
        note: ''
      },
      errors: ''
    }
  },
  methods: {
    createEmployee: function() {
      axios
        .post('/api/v1/employees', this.employee)
        .then(response => {
          let e = response.data;
          this.$router.push({ name: 'EmployeeDetailPage', params: { id: e.id } });
        })
        .catch(error => {
          console.error(error);
          if (error.response.data && error.response.data.errors) {
            this.errors = error.response.data.errors;
          }
        });
    }
  }
}
</script>

<style scoped>
</style>
app/javascript/app.vue
  : <snip>
<script>
import Vue from 'vue'
import VueRouter from 'vue-router'

import EmployeeIndexPage from 'EmployeeIndexPage.vue'
import EmployeeDetailPage from 'EmployeeDetailPage.vue'
import EmployeeNewPage from 'EmployeeNewPage.vue'

const router = new VueRouter({
  routes: [
    { path: '/',
      component: EmployeeIndexPage  },
    { path: '/employees/:id(\\d+)',  // :idは数値のみに制限する
      name: 'EmployeeDetailPage',
      component: EmployeeDetailPage },
    { path: '/employees/new',
      name: 'EmployeeNewPage',
      component: EmployeeNewPage    }
  ]
})
  : <snip>
</script>
  : <snip>

ルーティングの設定は詳細画面を作成した時と同様です。

EmployeeNewPage.vue では template は form で構成されています。

form の attributes に @submit.prevent とありますが、 @submit には form の submit イベントが発行されたときのイベントハンドラを処理するためのメソッドを設定します。
.prevent と続いていることで、通常の submit で処理されるページリロードを行わないようにします。これは event.preventDefault() と同じ効果です。(参考)

form 内の要素は大半が input や select 等の入力フォームで、一番上にエラーメッセージを表示するための次の要素が設定されています。

app/javascript/EmployeeNewPage.vue(エラー表示部)
  : <snip>
    <div v-if="errors.length != 0">
      <ul v-for="e in errors" :key="e">
        <li><font color="red">{{ e }}</font></li>
      </ul>
    </div>
  : <snip>

ここでは Vue コンポーネントの errors データに要素が格納されている場合にそれらをリストアップしています。

そして、input や select 等の入力フォームでは v-model を指定しています。
これにより Vue コンポーネントのデータとフォームデータとの双方向データバインディングが行われます。例えば name に値を入力すると this.employee.name にその値が格納され、this.employee.name に値を入力すると name 用の input フォームにその値が表示されるといった具合です。

最後に、submit ボタンが押されたときの処理として定義した createEmployee メソッドについて説明しますが、axios を使って AJAX 処理を行っている点はこれまでと同様です。ただ HTTP のリクエストメソッドが GET ではなく POST になっているだけです。
モデル作成用の API(まだ作成していない) にデータを送信し、失敗したら this.errors にエラー内容を格納するようにしています。(ここで this.errors に値が格納されたら、先に紹介した template のエラー表示部にエラーメッセージが表示されることになります)
そして、モデル作成が成功したら詳細画面に遷移します。

詳細画面に遷移する処理は this.$router.push({ name: 'EmployeeDetailPage', params: { id: e.id } }); で行っています。
template で遷移先を定義する際は <router-link :to="..."> でしたが、プログラム的に行う場合は router.push(location, onComplete?, onAbort?) を使います。
これにより router の history スタックに新しいエントリが追加されます。(参考)

以上で新規作成画面は終わりです。

backend に新規作成用 API を作成する

ルーティングに create アクションを追加し、コントローラにアクションを処理するメソッドを追加することになります。(コントローラでは作成時に意図しないエラーが発生した時用に rescue_from の処理を追加しています)

config/routes.rb
Rails.application.routes.draw do
    : <snip>
  namespace :api, {format: 'json'} do
    namespace :v1 do
      resources :employees, only: [:index, :show, :create]
    end
  end
end
app/controllers/api/v1/employees_controller.rb
class Api::V1::EmployeesController < ApiController
  before_action :set_employee, only: [:show]

  # 拾えなかったExceptionが発生したら500 Internal server errorを応答する
  rescue_from Exception, with: :render_status_500

  # ActiveRecordのレコードが見つからなければ404 not foundを応答する
  rescue_from ActiveRecord::RecordNotFound, with: :render_status_404

  def index
    employees = Employee.select(:id, :name, :department, :gender)
    render json: employees
  end

  def show
    render json: @employee
  end

  def create
    employee = Employee.new(employee_params)
    if employee.save
      render json: employee, status: :created
    else
      render json: { errors: employee.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private

    def set_employee
      @employee = Employee.find(params[:id])
    end

    def employee_params
      params.fetch(:employee, {}).permit(:name, :department, :gender, :birth, :joined_date, :payment, :note)
    end

    def render_status_404(exception)
      render json: { errors: [exception] }, status: 404
    end

    def render_status_500(exception)
      render json: { errors: [exception] }, status: 500
    end
end

以上でモデル作成用 API の作成は終わりです。

動作確認

frontend と backend が正常に動作するか見てみましょう。

http://localhost:3000/#/employees/new にアクセスすると次のようなフォームが表示されると思います。

image.png

入力値が不十分な状態で Commit ボタンを押すと上部にエラーメッセージが表示されること、必要な情報を入力してから Commit ボタンを押すとモデルが作成されてそのモデルの詳細画面に遷移することを確認してみて下さい。

image.png

image.png

モデルの編集が出来るようにする

モデルの編集画面を作成していくことにします。

やることはモデルの新規作成とほぼ同じです。必要となる画面がほぼ同じなのでまずは新規作成画面の form 部分をコンポーネントとして新規作成画面から分離して編集画面でも再利用できるようにしましょう。

新規作成画面の form 部分を再利用可能なコンポーネントとして分離する

app/javascript/EmployeeFormPane.vue
<template>
  <form @submit.prevent="$emit('submit')">
    <div v-if="errors.length != 0">
      <ul v-for="e in errors" :key="e">
        <li><font color="red">{{ e }}</font></li>
      </ul>
    </div>
    <div>
      <label>Name</label>
      <input v-model="employee.name" type="text">
    </div>
    <div>
      <label>Department</label>
      <input v-model="employee.department" type="text">
    </div>
    <div>
      <label>Gender</label>
      <select v-model="employee.gender">
        <option>other</option>
        <option>male</option>
        <option>female</option>
      </select>
    </div>
    <div>
      <label>Birth</label>
      <input v-model="employee.birth" type="date">
    </div>
    <div>
      <label>Joined Date</label>
      <input v-model="employee.joined_date" type="date">
    </div>
    <div>
      <label>Payment</label>
      <input v-model="employee.payment" type="number" min="0">
    </div>
    <div>
      <label>Note</label>
      <input v-model="employee.note" type="text">
    </div>
    <button type="submit">Commit</button>
  </form>
</template>

<script>
export default {
  props: {
    employee: {},
    errors: ''
  }
}
</script>

<style>
</style>
app/javascript/EmployeeNewPage.vue
<template>
  <employee-form-pane :errors="errors" :employee="employee" @submit="createEmployee"></employee-form-pane>
</template>

<script>
import axios from 'axios';

import EmployeeFormPane from 'EmployeeFormPane.vue';

export default {
  components: {
    EmployeeFormPane
  },
  data() {
    return {
      employee: {
        name: '',
        department: '',
        gender: '',
        birth: '',
        joined_date: '',
        payment: '',
        note: ''
      },
      errors: ''
    }
  },
  methods: {
    createEmployee: function() {
      axios
        .post('/api/v1/employees', this.employee)
        .then(response => {
          let e = response.data;
          this.$router.push({ name: 'EmployeeDetailPage', params: { id: e.id } });
        })
        .catch(error => {
          console.error(error);
          if (error.response.data && error.response.data.errors) {
            this.errors = error.response.data.errors;
          }
        });
    }
  }
}
</script>

<style scoped>
</style>

コンポーネントとして再利用するためにまず form 部分を vue ファイルとして分離します。
分離するにあたって変更した点は次のとおりです。

EmployeeFormPane 側の変更点

  • form の attributes の @submit.prevent="createEmployee"@submit.prevent="$emit('submit')" へと変更された
  • Vue コンポーネントの data がなくなり、代わりに props へと変更された

EmployeeNewPage 側の変更点

  • <template> に記述していた form がなくなり、代わりに <employee-form-pane></employee-form-pane> へと変更された
  • <script>import EmployeeFormPane from 'EmployeeFormPane.vue';components: { EmployeeFormPane }, が追加された

EmployeeFormPane 側の $emit('submit') では submit イベントを発行しています。これにより親コンポーネント側で submit イベントを処理することが出来ます。EmployeeNewPage 側の <employee-form-pane @submit="createEmployee"></employee-form-pane> は submit イベントを受け取って createEmployee メソッドを実行することを指しています。

また、form の初期値であり、かつ入力された値を格納する employee と、登録時のエラーを表示する errors は EmployeeFormPane 側で親から受け取れるように props で指定しています。props では親コンポーネントから受け取れる値を設定します。EmployeeNewPage 側の <employee-form-pane :errors="errors" :employee="employee"></employee-form-pane> は自身のデータ errors と employee を同名の props として子コンポーネントに渡しています。

編集画面を作成する

編集画面は詳細画面と新規作成画面の両方を合わせたような内容になります。
コンポーネントの初期化時に AJAX でコンポーネントのデータを API 経由で取得し、その値を初期値として form を表示するといった内容になります。

ルーティング設定も忘れずに行いましょう。

app/javascript/EmployeeEditPage.vue
<template>
  <employee-form-pane :errors="errors" :employee="employee" @submit="updateEmployee"></employee-form-pane>
</template>

<script>
import axios from 'axios';

import EmployeeFormPane from 'EmployeeFormPane.vue';

export default {
  components: {
    EmployeeFormPane
  },
  data() {
    return {
      employee: {},
      errors: ''
    }
  },
  mounted () {
    axios
      .get(`/api/v1/employees/${this.$route.params.id}.json`)
      .then(response => (this.employee = response.data))
  },
  methods: {
    updateEmployee: function() {
      axios
        .patch(`/api/v1/employees/${this.employee.id}`, this.employee)
        .then(response => {
          this.$router.push({ name: 'EmployeeDetailPage', params: { id: this.employee.id } });
        })
        .catch(error => {
          console.error(error);
          if (error.response.data && error.response.data.errors) {
            this.errors = error.response.data.errors;
          }
        });
    }
  }
}
</script>

<style scoped>
</style>
app/javascript/app.vue
  : <snip>
<script>
import Vue from 'vue'
import VueRouter from 'vue-router'

import EmployeeIndexPage from 'EmployeeIndexPage.vue'
import EmployeeDetailPage from 'EmployeeDetailPage.vue'
import EmployeeNewPage from 'EmployeeNewPage.vue'
import EmployeeEditPage from 'EmployeeEditPage.vue'

const router = new VueRouter({
  routes: [
    { path: '/',
      component: EmployeeIndexPage  },
    { path: '/employees/:id(\\d+)',  // :idは数値のみに制限する
      name: 'EmployeeDetailPage',
      component: EmployeeDetailPage },
    { path: '/employees/new',
      name: 'EmployeeNewPage',
      component: EmployeeNewPage    },
    { path: '/employees/:id(\\d+)/edit',
      name: 'EmployeeEditPage',
      component: EmployeeEditPage   }
  ]
})
  : <snip>
</script>

<style scoped>
</style>

backend 側にモデル更新用 API を作成する

ルーティングに create アクションを追加し、コントローラにアクションを処理するメソッドを追加する流れは新規作成用 API の時と同じです。

config/routes.rb
Rails.application.routes.draw do
  : <snip>
  namespace :api, {format: 'json'} do
    namespace :v1 do
      resources :employees, only: [:index, :show, :create, :update]
    end
  end
end
app/controllers/api/v1/employees_controller.rb
class Api::V1::EmployeesController < ApiController
  before_action :set_employee, only: [:show, :update]
  : <snip>
  def update
    if @employee.update_attributes(employee_params)
      head :no_content
    else
      render json: { errors: @employee.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private
    def set_employee
      @employee = Employee.find(params[:id])
    end
  : <snip>
end

動作確認

frontend と backend が正常に動作するか見てみましょう。

http://localhost:3000/#/employees/1/edit にアクセスすると次のようなフォームが表示されると思います。

image.png

無効な値や入力が必要な項目を空にして Commit ボタンを押すとエラーが表示されること、正しく入力すると値が更新された状態で詳細画面に遷移することを確認しましょう。

モデルを削除できるようにする

最後にモデルを削除できるようにします。

frontend 側では新しい画面は用意せずにボタンを押したら削除できるようにします。

backend 側にモデル削除用 API を作成する

config/routes.rb
Rails.application.routes.draw do
  : <snip>
  namespace :api, {format: 'json'} do
    namespace :v1 do
      resources :employees, only: [:index, :show, :create, :update, :destroy]
    end
  end
end
app/controllers/api/v1/employees_controller.rb
class Api::V1::EmployeesController < ApiController
  before_action :set_employee, only: [:show, :update, :destroy]
  : <snip>
  def destroy
    @employee.destroy!
    head :no_content
  end

  private
    def set_employee
      @employee = Employee.find(params[:id])
    end
  : <snip>
end

update アクションと同様に作成しました。
@employee.destroy! では destroy! メソッドを使うことで削除に失敗した場合に Exception を発生させて、rescue_from Exception で拾うようにしています。

続いて削除ボタンを用意します。
ここで、削除操作を行う場合は誤ってボタンを押してしまった場合に備えて確認モーダルを用意することにしましょう。
そこでまずは確認モーダルを作成することにします。
Vue 公式の sampleを参考にしました。

app/javascript/Modal.vue
<template>
  <transition name="modal">
    <div class="modal-mask">
      <div class="modal-wrapper">
        <div class="modal-container">

          <div class="modal-header">
            <slot name="header">
            </slot>
          </div>

          <div class="modal-body">
            <slot name="body">
            </slot>
          </div>

          <div class="modal-footer">
            <slot name="footer">
              <button class="modal-default-button" @click="$emit('ok')">
                OK
              </button>
              <button class="modal-default-button" @click="$emit('cancel')">
                Cancel
              </button>
            </slot>
          </div>
        </div>
      </div>
    </div>
  </transition>
</template>

<script>
export default {
}
</script>

<style scoped>
.modal-mask {
  position: fixed;
  z-index: 9998;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, .5);
  display: table;
  transition: opacity .3s ease;
}

.modal-wrapper {
  display: table-cell;
  vertical-align: middle;
}

.modal-container {
  width: 300px;
  margin: 0px auto;
  padding: 20px 30px;
  background-color: #fff;
  border-radius: 2px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .33);
  transition: all .3s ease;
  font-family: Helvetica, Arial, sans-serif;
}

.modal-header h3 {
  margin-top: 0;
  color: #42b983;
}

.modal-body {
  margin: 20px 0;
}

.modal-default-button {
  float: right;
}

/*
 * The following styles are auto-applied to elements with
 * transition="modal" when their visibility is toggled
 * by Vue.js.
 *
 * You can easily play with the modal transition by editing
 * these styles.
 */

.modal-enter {
  opacity: 0;
}

.modal-leave-active {
  opacity: 0;
}

.modal-enter .modal-container,
.modal-leave-active .modal-container {
  -webkit-transform: scale(1.1);
  transform: scale(1.1);
}
</style>

次に削除ボタンと作成したモーダルを一覧画面に表示させてみます。

app/javascript/EmployeeIndexPage.vue
<template>
  <div>
    <div v-if="errors.length != 0">
      <ul v-for="e in errors" :key="e">
        <li><font color="red">{{ e }}</font></li>
      </ul>
    </div>
    <table>
      <tbody>
        <tr>
          <th>ID</th>
          <th>name</th>
          <th>department</th>
          <th>gender</th>
          <th>actions</th>
        </tr>
        <tr v-for="e in employees" :key="e.id">
          <td><router-link :to="{ name: 'EmployeeDetailPage', params: { id: e.id } }">{{ e.id }}</router-link></td>
          <td>{{ e.name }}</td>
          <td>{{ e.department }}</td>
          <td>{{ e.gender }}</td>
          <td>
            <button @click="deleteTarget = e.id; showModal = true">Delete</button>
          </td>
        </tr>
      </tbody>
    </table>
    <modal v-if="showModal" @cancel="showModal = false" @ok="deleteEmployee(); showModal = false;">
      <div slot="body">Are you sure?</div>
    </modal>
  </div>
</template>

<script>
import axios from 'axios';

import Modal from 'Modal.vue'

export default {
  components: {
    Modal
  },
  data: function () {
    return {
      employees: [],
      showModal: false,
      deleteTarget: -1,
      errors: ''
    }
  },
  mounted () {
    this.updateEmployees();
  },
  methods: {
    deleteEmployee: function() {
      if (this.deleteTarget <= 0) {
        console.warn('deleteTarget should be grater than zero.');
        return;
      }

      axios
        .delete(`/api/v1/employees/${this.deleteTarget}`)
        .then(response => {
          this.deleteTarget = -1;
          this.updateEmployees();
        })
        .catch(error => {
          console.error(error);
          if (error.response.data && error.response.data.errors) {
            this.errors = error.response.data.errors;
          }
        });
    },
    updateEmployees: function() {
      axios
        .get('/api/v1/employees.json')
        .then(response => (this.employees = response.data))
    }
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
  text-align: center;
}
</style>

基本的には子コンポーネントを利用する方法でモーダルを利用することが出来ます。(v-ifは値がfalseの場合にはコンポーネントを非表示にし、trueの場合には表示するためのディレクティブです)

    <modal v-if="showModal" @cancel="showModal = false" @ok="deleteEmployee(); showModal = false;">
      <div slot="body">Are you sure?</div>
    </modal>

上記に書かれた通り、モーダルを呼び出す部分で slot という属性が出てきました。

スロットとは <modal></modal> に含まれる DOM を Modal コンポーネントの template で <slot> として参照できるようにする機能で、<div slot="body"></div> のように記述すると <slot name="body"> のように名前付きで呼び出すことが出来るようになります。
これによりモーダルの中身を呼び出し側で調整できるようになります。(参考)

動作確認

http://localhost:3000/ を表示すると Delete ボタンがモデルが表示された行ごとに表示されていること、ボタンを押すとモーダル画面が表示されて Are you sure? と表示されること、Cancel を押すとモーダルが閉じること、OK を押すとモデルが削除されることを確認してみて下さい。

image.png

※ ActiveAdmin を導入したことでスタイルがずれていますが、気になる方は上書きされないように設定してみて下さい。(参考)

最後に

backend に Ruby on Rails を使い、frontend に Vue.js を使って WEB アプリケーションを作りながら必要となる情報について紹介しました。

スタイルは全く考慮していないので気になる方は拡張してみて下さい。

おまけ

応用編

Runtime build を有効にする

config/webpack/environment.js に alias をつける。

config/webpack/environment.js
const { environment } = require('@rails/webpacker')
  : <snip>
const config = environment.toWebpackConfig()

config.resolve.alias = {
  // Vue の runtime build を有効にする
  'vue$': 'vue/dist/vue.esm.js'
}

environment.loaders.append('vue', vue)
module.exports = environment

イベントバス

子孫関係のコンポーネント間でイベントの通知と受け取りを行いたい場合、親→子→孫へとイベントの受け取りと通知処理を記述してもよいが、イベントを仲介する役割を持つ Vue コンポーネント(イベントバス)を利用するのが公式で推奨されている。(参考)

尚、$emit を使っても、呼び出した関数の戻り値を呼び出し元が受け取ることは出来ない。(参考)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Twitterでお菓子と一緒にメッセージを送れるサービスを二週間で開発、リリースした【個人開発】

こんにちは、Qiitaに初めて投稿するジフォと申します。

一年半前からWebを触り始めて、最近はVue.js/Nuxt.jsを使ってFirebaseをバックにWebサービスを作るのを趣味にしています。
今年度が大学一年生です。

今回、メッセージをお菓子のイラストと一緒にTwitterのリプライやDMで贈れる「chocotto」というサービスをリリースしました。
これが僕が作ったWebサービス二作目です。

この記事ではこのchocottoのサービスと技術の紹介と、開発中に直面した問題について解説したいと思います。

chocottoとは

chocottoは、TwitterのリプライやDMの機能を使って、お菓子のイラストと一緒にメッセージを送れるサービスです。
ギフトメッセージを作成すると、サーバーがOGP画像を生成してツイートもしくはDMを送信します。
ogp-image.png

お菓子は、チョコ、クッキー、キャンディ、マカロンの四種類から選べ、それぞれ十数種類ほど絵柄のバラエティが用意されています。
gifts.png

日時を指定してツイートやDMを予約送信できます。
中学生がバレンタインデーにチョコレートを贈る時間は、朝学校についたとき、昼休み、放課後のどれかだなーと思って、ショートカットを用意しておきました。
放課後に通知が来て「何かな~?」と確認する好きな人を眺めるときに使ってください。
datetime-shortcuts.png

誰が誰に何のギフトを贈ったか、まではOGP画像で出力されてしまいますが、メッセージ内容だけはプライべートに設定できます。
プライベートに設定されたメッセージは、宛先に指定されたユーザーでログインしないと表示されません。

公式アカウント僕のアカウントにメッセージを送ってみました。
全員に公開になっているので、下のリンクから見れます。
https://chocotto.co/messages/o1SNXSmYdo6SDNiHqVpk

誰でも気軽にお菓子を贈れるサービスです。ぜひ使ってみてください!
https://chocotto.co

chocottoで使った技術

chocottoではどういった技術を使っているのか、フロントエンドとバックエンドの両方について紹介します。

フロントエンド

chocottoのフロントエンドはNuxt.jsで構築されています。Nuxt.jsは、かの有名なJSフレームワークVue.jsを簡単にSSRできるようにした拡張フレームワークです。最近ロゴが刷新されてかっこよくなりましたね。
Vue.jsのようなSPAだと、遷移するときに変わる部分だけを書き変えることでパフォーマンスも通信量も良くできます。
しかし、OGPのようなmetaデータをTwitterのクローラーに読み込ませたいようなとき、クライアントで後から挿入するSPAはクローラーがJSを実行しないために正しく解釈されないので使うのが難しいです。
SSRするNuxt.jsなら、クライアントにデータが届いた時点で既にmeta情報が入っているので、Twitterのクローラーも正しく認識できます。

chocottoではOGPがかなり重要なのでNuxt.jsを採用しました。他にもVue.jsよりも使いやすい面が多々あって、ほんと神。

Tailwind CSS

tailwind-css.png
chocottoではCSSフレームワークのTailwind CSSを使っています。
TailwindはHTMLのクラスにmt-4とかflex items-centerとか指定するだけでCSSを簡単に適用できるフレームワークで、BootstrapやVuetifyのようなカスタムコンポーネントはありません。

CSSは<style></style>に全部書くべきだ!という意見もわかるのですが、繰り返さない場合やそこまで大きい部分ではないという場合にいちいちstyleタグまで見に行くのは面倒です。HTMLに直接書くことで、どんなCSSだったかを直接確認できるので非常に便利です。
ただ、適用するクラスの量が多くなってきたらクラスに分けないと、結果的に可読性が落ちるので要注意。

また、Tailwindはブレークポイントや色、文字サイズや横幅縦幅などの基準が決まっているので、全体のバランスを保てます。つまり、同じようなレベルのpタグに1.1remと1.2remをそれぞれ適用してしまってなんかバランス悪いということがなくなります。
もちろん、レスポンシブ対応が楽になるというのもあるのですが、こういったサイズを分けてくれることでバランス良く見えるというのがすごく大きなメリットだと感じます。

ちなみにCSSはSassのSCSS記法で書いています。SCSSならCSSとほぼ同じ構文で入れ子にできるのですごく良いです。

バックエンド

chocottoではFirebaseを使用しています。FirebaseはGoogleが提供しているmBaaS(mobile Backend as a Service)と呼ばれるものです。FirebaseにはDBや認証、ストレージなどがあるのですが、うち次のものを使っています。

  • Firestore - NoSQLなドキュメントデータベース。JSONのような形式でそのままぶち込めるのですごく使い勝手がいい。ルールも指定したら勝手に処理してくれるので楽。
  • Firebase Authentication - 認証機構。Twitterと連携して関数呼び出すだけで勝手にリダイレクトからログイン、ローカル保存まで全部やってくれます。
  • Firebase Cloud Functions - AWS Lambdaみたいなイベント駆動のサーバーレスなやつ。Node.jsで関数作ると、簡単にサーバーで処理できます。
  • Firebase Cloud Storage - AWS S3みたいなクラウドストレージ。

Firestoreは先月末にβを抜け出しGAに昇格しました。そして、Tokyoリージョンにもやってきたのでネックだったものが全部取っ払われました。
そのため、まだ開発途中だったのでプロジェクトをTokyoリージョンに引越しました。リージョンの変更はできないので、リリース前で良かったです。

Nuxt.jsのホスティングはCloud Functionsでやるのもなしではないのですが、コールドスタートがかなり痛いのでGoogle Cloud PlatformのGoogle App Engineを使っています。
どちらもスケーラブルなのですが、GAEは少なくとも一台は常に稼働させておくということができるので、一定時間アクセスがない時でもすぐにページを返せます。
Nuxt.jsのGAEホスティングはDMMさんのブログ記事がすごく参考になりますので、これに沿ってやるのがおすすめです。

OGP画像の生成

OGP画像はクライアント側とサーバー側のどちらで生成するのかというのはかなり重要です。
クライアント側で生成する場合、もちろんそれをサーバーもしくはクラウドストレージに保存しないといけないので、悪意のあるユーザーが自分の好きな画像をOGP画像に設定できてしまいます。これはちょっと良くないですね。
サーバー側で生成する場合、画像生成という重い処理がかさんでしまいます。

chocottoではサーバー側で生成する方法を選びました。悪用されたくないというのが非常に大きな理由です。
もちろん、一度サーバーの処理を挟むことで正しい画像かどうかなど判断できるのですが、面倒だったのでサーバーで作っちゃいました。

OGPの生成は、予約投稿の場合でもメッセージ作成時に行います。
node-canvasというnpmパッケージを使えば、サーバーサイドでcanvasを生成しそこでレンダリング、画像出力までできます。
今回はCloud Functionsで使うので、ソースのビルドが既に済んでいるnode-canvas-prebuiltというパッケージを使いました。

OGP画像の生成はhtml2canvasというHTMLとCSSをcanvasにレンダリングするパッケージを使うのが人気ですが、今回はサーバーサイドでDOMがないため使えません。
そのため、事前にIllustratorでひな形を作っておき、それをバックグラウンドに、動的な部分だけを淡々と描画していくことにしました。
慣れないIllustratorにてまどいながらなんとか作りました。。。

開発中に直面した問題たち

このサービスは1/30に思いついてバレンタインまでに間に合わせようと必死に作ったので、発案からリリースまで13日間で作ることができました。
しかし、間に挟んだ平日全てテスト期間で、ヒイヒイ言いながら開発する羽目になりました。
なんとかちゃんと動くところまで持ってきて、リリースできてよかったです。

さて、その開発中に悩みまくって何時間も費やした問題たちについて紹介したいと思います。

再読み込みするとasyncDataで認証情報がとってこれない問題

Firebase Authenticationは、ログイン情報をデフォルトでローカルストレージに保存しておくので、明示的にログアウトしない限りはセッションが切れてもログインが継続します。
しかし、なぜかログアウトしちゃうので不便でかなり困りました。

これは単純なミスで、ログイン情報をVuexに格納しておいて、Vuexに入っていたらログインしていると判定していたために、再読み込みしてVuexがリセットされるとログアウトしていると判断されるっていうだけでした。
解決方法として、vuex-persistedstateパッケージを使ってあげればOKです。勝手にローカルストレージから復活してくれます。

ただ、ローカルストレージから復活させるので、再読み込みしたページのasyncDataではサーバーなのでまだvuexが復活していません。(ローカルストレージにはサーバーからアクセスできません。)
chocottoでいうと、Twitterからプライベートメッセージのページに飛んだときに、vuexが復活してヘッダーに自分のアイコンが表示されているのに、ログインが必要ですと表示されてしまう、という問題が発生します。
そのため、asyncDataでvuexを利用しなければならなく、そこがランディングページになるような場合、一度リダイレクトページを挟む必要があります。

Twitterから遷移
→ 「/redirect?r=/path/to」へ遷移
→ mountedフックでroute.query.rに指定されたpathへredirectedクエリを付けて遷移
→ redirectedがあるのでvuexが復活したとみなし、処理開始

というような流れにすることで、完全に解決できます。

asyncDataでFirestoreドキュメントを取得するとエラーが発生してしまう問題

Firestoreからドキュメントを取得するとオブジェクトが返ってきます。これをasyncDataで取得してdataの中にぶち込むといったことをやるとき、注意しなければならないことがあります。

それは、ドキュメントの中に、Referenceが入っていると「Circular structure to JSON」というようなエラーが発生してしまうことです。
Circular structureというのはその名の通り循環オブジェクトのことで、MDNでは次のように紹介されています。

次のような循環構造体で、

var a = {};
var b = {}; 
a.child = b;
b.child = a;

JSON.stringify() は失敗します。

Nuxt.jsがサーバーからクライアントに転送するデータをJSONに変換する際に、Referenceオブジェクトは循環しているために、JSON.stringifyが失敗してしまうようです。

これを解決するには、Referenceをただのpath文字列に置き換えます。

Firestoreのクエリで取得するとき、forEachだと並列にならない問題

Firestoreでクエリを使って複数のドキュメントを取得するときdocRef.get()はPromiseを返すので、サブコレクションのドキュメントを取得したい場合、async/await関数をforEachに渡すと思います。
しかし、実際にはこれは並列処理になっておらず、forEachの内部でawaitしてしまっています。

これを解決するには、Array.mapを使います。これを使えば、docs配列の中身に対して全て処理を行った配列を生成できます。
ただ、async/awaitを使うと、Array.mapで返ってくる配列の中身は全部PromiseなのでPromise.allする必要があります。
async関数は速攻でPromiseを返してしまうので、Array.mapはそれをマップしてしまうということです。
以下のようなコードになります。

this.results = await Promise.all(querySnap.docs.map(async (messageSnap) => {
  let data = messageSnap.data()

  data.sub = (await messageSnap.ref.collection('sub').doc('doc').get()).data()

  return data
}))

しかし、マップ後の配列にこのドキュメントは入れたくないというような場合もあります。
この場合はとりあえずreturn nullしておいて、あとでArray.forEachでいらないドキュメントをArray.spliceすることで、待機時間の長い処理を並列にしながら分けられると思います。

npm twitter、DM送れない問題

この問題には結構悩まされました。
node.js環境でTwitterAPIを使う場合、このtwitterパッケージが人気なのですが、どうにもDMが送れません。

DM APIの仕様変更で使えなくなったのか、Issueが結構あるのですが修正されていないようです。
"Could not authenticate you." Direct Message Twitter
ここで代わりに提示されているのがtwitter-liteというパッケージでした。
このパッケージを使うことで解決します。

Typekit日本語フォント、webfontloaderで読み込めない問題

日本語フォントを含むTypekit(今はAdobe Fonts)のWebキットは、ページに含まれる文字だけをダウンロードして通信料を減らすダイナミックセットというものに自動で変更されます。
Nuxt.jsでTypekitやGoogle Fontsを使う場合はnuxt-webfontloaderというものがあるのですが、ダイナミックセットは読み込めないようで全くロードされません。

index.htmlのひな型はどこにも見当たらないので、nuxt.config.jsのheadプロパティでスクリプトを指定するしかなさそうですが、うまくいかなかったので諦めてGoogle Fontsだけ使用することにしました。
どうにかできないものでしょうか。。

まとめ

初めてQiitaで宣伝するサービスを作ったので緊張しています。
特に、Peingさんのアクセストークン事件もありましたので、かなり気を使ったつもりです。

バレンタインデーのことを意識してつくりましたが、バレンタインデー以外でも使えると思います。
ぜひ使ってみてください!

最後に、僕のTwitterもフォローしていただけると幸いです。
https://twitter.com/G4RDSjp

おまけ

(2/13 7:39追記)
Vue.js/Nuxt.jsで開発をする上で、パフォーマンスの改善やメンテナンスのしやすさの維持などは非常に重要です。
それらに役立つTipsを毎週メールで送ってくれるメールマガジンが最近始まって、結構おすすめです。
VueDoze
過去記事も全て見れます。

全て英語ですが、困った時ggると出てくるのはほとんどReditやGitHubばかりですので充分読めると思います。
普段英語の記事は読まない方も、日常的に英文を読む練習にもなりますからGoogle翻訳を使わずに読むといいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでちょっとSPAなWebアプリ作ってみたときのメモ

はじめに

SPAなWebアプリを作ってみたくなりVue.jsを勉強しながらやってみたので,その時のメモです.
基礎的なところまでは全体に結構簡単に進めることができて楽しかったのですが,それでもまぁ一から調べながらだったのでそれなりには時間がかかったのと,普段それほど頻繁にWebアプリのコードは書かないので,忘れてしまうともったいないと思って書き残します.

開発環境

今回の自分の開発環境は次のようになっています.OSはWindowsを使いましたがNode.js(npm)が使えれば何でもとくには関係ないと思います.

  • Windows 10 Pro
  • Node.js v10.15.0
  • npm 6.4.1

Vue CLIのインストール

今回の開発はVue CLIを使って行いました.適当にググってみるとVue CLIの2と3の情報があり使い方も違っているので注意です.今回はVue CLI 3を使っています.
npmを使ってグローバルにVue CLI 3をインストールします.

$ npm install -g @vue/cli
$ vue --version
3.4.0

プロジェクトの作成

インストールしたVue CLIを使ってプロジェクトを作成します.対話形式にいろいろと聞かれるので,作成するWebアプリの内容にあわせて選択してください.
今回はRouterVuexを使いたかったので,Manually select featuresからそれぞれ必要な項目を選択してセットアップしています.またlinter / formatterの項目ではなんとなくESLint + Standard configを選びました.

$ vue create app-name

プロジェクトの作成が終わったら,表示されている内容に従って次のようにコマンドを実行してhttp://localhost:8080/にアクセスすると次のようなWelcomeページが表示されます.

$ cd app-name
$ npm run serve

01.png

また,この状態でserveの他にも次のようなnpm-scriptsが登録されています.入門したての慣れない環境での開発でもlintがすぐ使っていけるのは助かりました.

package.json
"scripts": {
  "serve": "vue-cli-service serve",
  "build": "vue-cli-service build",
  "lint": "vue-cli-service lint"
}

Vuetifyの追加

せっかくWebアプリを作るならカッコいいデザインで作りたいです.VuetifyはVue.jsに対応したマテリアルデザインのCSSフレームワークです.UIコンポーネントがいろいろと揃っているのと,ドキュメントもしっかりとしているのでお手軽にカッコいい画面が作れます.

https://vuetifyjs.com/ja/

Vuetifyは既存のアプリに自前でnpm install,importして使っていくことも簡単にできますが,今回はVue CLIから追加して使いました.このときも対話形式となっていますが,今回はDefaultを選択しました.

$ vue add vuetify

インストールの他,Vuetifyのimportや必要なCSSの読み込みも行うようにファイルが更新されています.また先ほど確認したWelcomeページもVuetify仕様に更新されています.
これだけでVuetifyを使っていく準備はOKです.

01.png

Vue RouterとVuexを使う

はじめにプロジェクトを作成するときにRouterやVuexを選択していると,すぐに使っていける状態になっています.どちらもちょっとしたWebアプリを作るような段階からでも便利なものになっていると思うので,ぜひVue.jsの入門段階からでも触ってみるといいと思います.

Vue Router

Vue RouterはVue.js公式ルータです.複数のページの切り替えを簡単に実現することができます.
すでにrouter.jsがプロジェクト内に存在しているので,その中を見れば使い方はなんとなくわかるかと思います.pathで指定したURLとcomponentで指定したVueコンポーネントが結びつきます.nameは任意ですが名前を付けておくとその名前を利用して遷移先を定義することができます.

router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('./views/About.vue')
    }
  ]
})

その他の詳しい使い方はこちらで確認してください.
https://router.vuejs.org/ja/

Vuex

VuexはVue.jsアプリケーションの状態を保持するコンテナです.複数のVueコンポーネント間で共通のデータを扱うことができます.
通常は親子関係のあるVueコンポーネント間ではpropemitを使って情報を受け渡すことができますが,直接的な親子関係にないVueコンポーネント間では直接的には情報のやり取りが行えず構造が複雑になってしまいます.Vuexを使うとこれをシンプルに解決することができます.

こちらもすでにstore.jsがプロジェクト内に存在しています.簡単な例としてstatemutationsに実装を追加しています.stateに共有したいデータ,mutationsにそのデータの更新処理を定義します.

store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment (state) {
      state.count++
    }
  },
  actions: {
  }
})

stateの情報を取得するときにはstore.stateから,mutationsの処理を呼びさすときにはstore.commitを使って呼び出すことができます.

<template>
  <p>{{$store.state.count}}</p>
</template>

<script>
export default {
  mounted: function () {
    this.$store.commit('increment')
  }
}
</script>

その他の詳しい使い方はこちらで確認してください.
https://vuex.vuejs.org/ja/

Firebaseとの連携

FirebaseはいわゆるBaaSです.DBやホスティング,認証機能などが簡単に利用できます.Vue.jsで作ったアプリケーションにバックエンドの機能を簡単に付け加えることができて,お試しで作っているWebアプリにも便利です.
詳細は本家のドキュメントを見るといいと思いますが,ここでは簡単にホスティングについてをまとめます.

Firebaseのセットアップ

まずはFirebaseのサイトのトップにあるスタートガイドからGoogleアカウントでログインします.ログイン後に遷移するページからプロジェクトの追加を行います.プロジェクト名とID(URLに使われます),アナリティクスの地域などを設定してプロジェクトを作成します.
作成したプロジェクトのOverviewのページにある『開始するにはアプリを追加してください』のところの</>まるボタンを押して,Firebaseの初期化コードを確認しておきます.

01.png

コードの更新

Firebaseを使った開発に必要なパッケージのインストールを行います.firebaseはコード上でFirebaseを扱うためのパッケージです.firebase-toolsはFirebaseのCLIで開発で使用します.

$ npm install --save firebase
$ npm install --save-dev firebase-tools

次に前の工程で確認しておいたFirebaseの初期化コードを実装します.またサイト上の初期化コードはHTMLのScriptタグでfirebaseを読み込んで初期化処理をお行うようになっていましたが,今回はnpmでfirebaseをインストールしてmain.js上に初期化コードを書きました.

main.js
import Vue from 'vue'
import './plugins/vuetify'
import App from './App.vue'
import router from './router'
import store from './store'
import firebase from 'firebase'

Vue.config.productionTip = false

var config = {
  // サイトで確認した内容
}
firebase.initializeApp(config)

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

デプロイ

まずはデプロイするファイルを用意します.

$ npm run build

次にFirebase CLIを使ってFirebase関連のセットアップを行います.loginではFirebaseのサイトからプロジェクトの作成を行ったときのアカウントでログインします.initのときにはHostingを選択し,先ほどセットアップしたプロジェクトを選択してください.また公開ディレクトリ名はnpm buildしたときの出力先がデフォルトでdistディレクトリになっているのでpublicからdistに変更しました.その他はデフォルト設定のままで試しました.

$ npx firebase login
$ npx firebase init

また自分が試した時は,たまにinitでプロジェクトが選択できなかったため,後から次のコマンドでプロジェクトとの指定を行いました.

$ npx firebase use --add

最後にデプロイを実施します.

$ npx firebase deploy

デプロイに成功すると,Firebaseのサイトの作成したプロジェクトのOverviewページで,デプロイの履歴が残っています.
Hostingのページを見るとデプロイされたURLが載っているので,アクセスするとローカル環境で確認していた時と同様にWelcomeページが表示されます.

その他のFirebaseの詳しい使い方は本家ドキュメントで確認してください.DBや認証機能も割と手軽に扱うことができます.
https://firebase.google.com/docs/

Elecronを試してみる

最後におまけ要素ですが,Vue.jsで作ったWebアプリをElectronアプリにして動かしてみます.
ElectronはWindows/macOS/Linuxで実行できるデスクトップアプリをHTML+CSS+JavaScriptといったWeb技術で開発できるフレームワークです.有名どころだとAtomやVSCodeがElectronでできています.
Vue CLI 3を使うと簡単にElectronで動かすことができたので紹介します.

まずは次のコマンドでelectron-builderをインストールします.バージョンの選択がありましたが今回は4.0.0を選択しました.(自分の環境だと3はエラーが出てインストールできませんでした.)

$ vue add electron-builder

package.jsonを見るとnpm-scriptsにElectron関連のコマンドが追加になっています.electron:serveelectron:buildから起動や実行ファイルのビルドを行うことができます.

$ npm run electron:serve    # Electronで起動
$ npm run electron:build    # 実行ファイルの生成

01.png

おわりに

ここまでの準備ができれば,あとは工夫次第でいろいろなWebアプリを作っていけると思います.入門としては例えばチャットアプリやマークダウンエディタ,ToDoアプリなどがよくネット上にもチュートリアル形式なんかであがっていたりします.これらを参考にしながら自分のオリジナルのWebアプリを作っていくと面白いと思います.

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nuxt.js で Vueプロジェクトを作ってみる

はじめに

Nuxt使えばすっごく簡単にWebページ作れたよって話です。
Nuxt.js インストール のページに書いてあることをやってみたという記事です。
詳しい設定ファイルの内容や具体的なコードについては今回書いていません。

Nuxt.jsとは

公式サイト より

Nuxt.js は Vue アプリケーションを作成するフレームワークです。

Vueの設定を予めプリセットしてくれていたり、ルーティングの定義をファイル作成でやってくれたり、Vue で動くページを作るのがとても簡単・楽になる。

Nuxt導入

では実際に導入していきます。

環境

Node.jsをインストールしておいてください。

バージョンは以下で行いました。
* Node v10.15.0
* npm 6.4.1

導入作業

プロジェクトを作成したいディレクトリに移動し、コマンドを実行。

PS D:\git> npx create-nuxt-app <プロジェクト名>

プロジェクト名や使いたいサーバーフレームワーク、UIフレームワーク、テストフレームワークなどなど聞かれるので矢印キーで選択してエンターを押していく。

npx: installed 407 in 6.499s
> Generating Nuxt.js project in D:\git\vue_study
? Project name vue_study
? Project description My phenomenal Nuxt.js project
? Use a custom server framework none
? Choose features to install
? Use a custom UI framework vuetify
? Use a custom test framework none
? Choose rendering mode Single Page App
? Author name
? Choose a package manager npm

最後まで選ぶとプロジェクトの生成が始まる。

Reinitialized existing Git repository in D:/git/vue_study/.git/

> nodemon@1.18.10 postinstall D:\git\vue_study\node_modules\nodemon
> node bin/postinstall || exit 0

Love nodemon? You can now support the project via the open collective:
 > https://opencollective.com/nodemon/donate


> nuxt@2.4.3 postinstall D:\git\vue_study\node_modules\nuxt
> opencollective || exit 0


                                     :-:
                                   .==-+:
                                  .==. :+- .-=-
                                 .==.   :==++-+=.
                                :==.     -**: :+=.
                               :+-      :*+++. .++.
                              :+-      -*= .++: .=+.
                             -+:      =*-   .+*: .=+:
                            -+:     .=*-     .=*-  =+:
                          .==:     .+*:        -*-  -+-
                         .=+:.....:+*-.........:=*=..=*-
                         .-=------=++============++====:

                          Thanks for installing nuxtjs
                 Please consider donating to our open collective
                        to help us maintain this package.

                           Number of contributors: 167
                              Number of backers: 160
                            Annual budget: US$ 45,815
                           Current balance: US$ 12,364

             Donate: https://opencollective.com/nuxtjs/donate

npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})

added 996 packages from 596 contributors and audited 14057 packages in 32.898s
found 0 vulnerabilities

あとは見てれば終わります!

実行

npm コマンドを実行します。

npm install
npm run dev

これで必要なモジュールのインストールとタスクの実行が完了するので以下URLにアクセスすると。。。
http://localhost:3000/

こんな感じでページが表示されます。
とっても簡単ですね!
nuxt_ready.PNG

中身を少し見てみる

作成されたプロジェクトのファイルの中身を少し見てみましょう。
レイアウトに関する部分をピックアップします。

pages

この中にファイルを作成すると、勝手に vue-router の設定を生成してくれます。

pages
- index.vue
- inspire.vue

こういうファイルを作れば↓の2つのURLのページが生成されます。
http://localhost:3000/
http://localhost:3000/inspire

vue の場合に記載する以下を勝手にやってくれるということですね。

router: {
  routes: [
    {
      name: 'index',
      path: '/',
      component: 'pages/index.vue'
    },
    {
      name: 'inspire',
      path: '/inspire',
      component: 'pages/inspire.vue'
    }
 ]
}

layouts

この中に共通のレイアウトを定義します。
default.vue が共通レイアウトです。
この他にもエラー時のレイアウトを定義したりします。

components

vueコンポーネントはここに入れます。

感想・雑記

基本的なことは公式サイト に書いてあるのですが、実際どうなんだろうということでやってみました。
コマンドいくつかであっという間にできてとても簡単でした!

1からvueプロジェクト作る場合とも比べてルーター自動で定義してくれたり必要なモジュールインストールしてくれてたりと構築だけでなく実装面も楽ですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Algolia のフロント側を Vue.js + TypeScript で試す。無限スクロールしたい...

はじめに

Algolia で検索しながら無限スクロールがしたかったので、algolia に Vue.js + TypeScript で入門します。最終的に以下の gif のようなものができます。

algolia.gif

それとなくできたのですが、細かい部分が意図した動きにならず困っています。が、細かい部分なので今は無視しています。
公式サンプルはこちら infinite scroll なのですが、これも意図した動きをしてくれません。

何が困っているかというと、一度スクロールをすると data の page が this.page++; によって大きな数になるのですが、それ以降の検索でうまいことリセットされず、意図した検索結果が返ってこないのです。(最初の20件とかしか返さなくなる..)

僕の場合はこの困りごとを、以下のような方法でお茶を濁しています。これも完璧ではないのですが。

e.addEventListener("input", () => {
  this.page = 1 // まずはリセットして
  this.resetPage() // さらに足りなければ +1 するようの独自メソッド
})  

とはいえある程度うごくものなので、まとめておきます。

Step1. Vue CLI でアプリをつくります

まずはおもむろに、以下コマンドです。TypeScrip を選びましたので、以降 TS です。

$ vue create study-algolia
$ yarn serve

これで、めでたく無事エラーがでます。前は出なかったのになー。
https://github.com/webpack/webpack/issues/8768#issuecomment-462090153

仕方ないので、言われるままに以下

image.png

Step2. ふつうに algolia をつかってみる

vue でフロントを書くときに便利なツールとして vue-instantsearch が用意されていますので、これを使います。

$ yarn add vue-instantsearch

main.ts に以下を追記します。

main.ts
+ import InstantSearch from 'vue-instantsearch';
+ Vue.use(InstantSearch);

これで、 yarn serve すると以下のように怒られます。

error
Could not find a declaration file for module 'vue-instantsearch'.

Vue-instantsearch.d.ts を書きます。これで、解消します。

src/vue-instantsearch.t.ds
declare module 'vue-instantsearch'

App.vue を以下のようにすることでひとまず動きます。
cf. Create your first search experience

App.vue
<template>
  <ais-index
    app-id="latency"
    api-key="3d9875e51fbd20c7754e65422f7ce5e1"
    index-name="bestbuy"
  >
    <ais-search-box></ais-search-box>
    <ais-results>
      <template slot-scope="{ result }">
        <h2>
          <ais-highlight :result="result" attribute-name="name"></ais-highlight>
        </h2>
      </template>
    </ais-results>
  </ais-index>
</template>

こんな感じ。

image.png

ここまでで、ふつうに algolia を使うところまで出来ました。

Step3. 自分が用意したデータでインデックスをつくってみる

動作確認をしたいので、index データを自分でつくってみます。go で書きます。

main.go
import (
    "encoding/json"
    "github.com/algolia/algoliasearch-client-go/algoliasearch"
    "net/http"
)

func main() {
    client := algoliasearch.NewClient(
        "{Your Application ID}",
        "{Admin API Key}",
    )
    index := client.InitIndex("test_notes")
    str := `[{"id": 1, "content": "Hello world"}, {"id": 2, "content": "I like Vue.js"}]`
    var posts []algoliasearch.Object
    d := json.NewDecoder(strings.NewReader(str))
    d.Decode(&posts)
    index.AddObjects(posts)
}
$ go run main.go

これで、インデックスがつくられてダッシュボードからも確認が可能になります。

image.png

これを Vue.js 側から呼び出すには以下の部分を修正します。

App.vue
// 抜粋
<div id="app" class="container-fluid">
    <ais-index
      id="main"
      app-id="自分の" // <- ここ
      api-key="自分の" // <- ここ
      index-name="test_notes" // <- ここ
// 抜粋
    <ais-highlight :result="result" attribute-name="content"></ais-highlight> // <- ここ

image.png

良い感じに呼び出せています。

大量にデータを入れておきたいので、json ファイルから index を作れるように go のコードを変更しておきます。

main.go
func main() {
    client := algoliasearch.NewClient(
        "{Your Application ID}",
        "{Admin API Key}",
    )
    index := client.InitIndex("test_notes")
    jsonFile, _ := os.Open("notes.json")

    var posts []algoliasearch.Object
    byteValue, _ := ioutil.ReadAll(jsonFile)
    json.Unmarshal([]byte(byteValue), &posts)
    index.AddObjects(posts)
}

notes.json に json データを大量に書いて置いてください。無限スクロールが楽しめるように。

Step4. 無限スクロールに対応する

cf. Infinite scroll

$ yarn add vue-observe-visibility
main.ts
+ import VueObserveVisibility from 'vue-observe-visibility'
+ Vue.use(VueObserveVisibility)
App.vue
     index-name="test_notes"
+    :query-parameters="{'page': page}"
   >
     <ais-search-box></ais-search-box>
-    <ais-results>
+    <ais-results :stack="true">
       <template slot-scope="{ result }">
         <h2>
           <ais-highlight :result="result" attribute-name="content"></ais-highlight>
         </h2>
       </template>
     </ais-results>
+    <div v-observe-visibility="loadMore">Loading more...</div>
   </ais-index>
 </template>

以上で、無限スクロールに対応できました。

Step5. 検索のたびに page = 1 にリセットしてみる

調整をした App.vue の全体を記載しておきます。 mountedresetPage を追加しています。

これで冒頭の gif が得られます。

main.ts
import Vue from 'vue'
import App from './App.vue'
import InstantSearch from 'vue-instantsearch';
import VueObserveVisibility from 'vue-observe-visibility'

Vue.use(InstantSearch);
Vue.use(VueObserveVisibility)

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')
App.vue
<template>
  <div id="app" class="container-fluid">
    <ais-index
      id="main"
      app-id="自分の"
      api-key="自分の"
      index-name="test_notes"
      :query-parameters="{'page': page}"
    >
      <div class="row">
        <ais-search-box>
          <div class="input-group">
            <ais-input
              id="input"
              placeholder="Search product by name or reference..."
              :class-names="{
                'ais-input': 'form-control'
                }"
            ></ais-input>
          </div>
        </ais-search-box>
      </div>
      <div class="row">
        <ais-results :stack="true">
          <template scope="{ result }">
            <div class="content">
              <ais-highlight :result="result" attribute-name="content"></ais-highlight>
            </div>
          </template>
        </ais-results>
        <ais-no-results></ais-no-results>
        <div id="loadMore" v-observe-visibility="loadMore">Loading more...</div>
      </div>
    </ais-index>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component
export default class App extends Vue {
  page = 1;
  loadMore(isVisible: any) {
    if (isVisible) {
      this.page++;
    }
  }

  mounted() {
    const i = document.querySelector("#input");
    i!.addEventListener("input", () => {
      this.page = 1;
      this.resetPage();
    });
  }

  resetPage() {
    const e = document.querySelector("#loadMore");
    const topPosition = e!.getBoundingClientRect().top;
    const windowHeight = window.innerHeight;
    if (topPosition <= windowHeight) {
      this.page++;
    }
  }
}
</script>

<style>
#app {
  text-align: center;
}
#input {
  margin: 20px;
}
.content {
  height: 40px;
}
</style>

以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

algolia のフロント側を Vue.js + TypeScript で試す。無限スクロールしたい...

はじめに

Algolia で検索しながら無限スクロールがしたかったので、algolia に Vue.js + TypeScript で入門します。最終的に以下の gif のようなものができます。

algolia.gif

それとなくできたのですが、細かい部分が意図した動きにならず困っています。が、細かい部分なので今は無視しています。
公式サンプルはこちら infinite scroll なのですが、これも意図した動きをしてくれません。

何が困っているかというと、一度スクロールをすると data の page が this.page++; によって大きな数になるのですが、それ以降の検索でうまいことリセットされず、意図した検索結果が返ってこないのです。(最初の20件とかしか返さなくなる..)

僕の場合はこの困りごとを、以下のような方法でお茶を濁しています。これも完璧ではないのですが。

e.addEventListener("input", () => {
  this.page = 1 // まずはリセットして
  this.resetPage() // さらに足りなければ +1 するようの独自メソッド
})  

とはいえある程度うごくものなので、まとめておきます。

Step1. Vue CLI でアプリをつくります

まずはおもむろに、以下コマンドです。TypeScrip を選びましたので、以降 TS です。

$ vue create study-algolia
$ yarn serve

これで、めでたく無事エラーがでます。前は出なかったのになー。
https://github.com/webpack/webpack/issues/8768#issuecomment-462090153

仕方ないので、言われるままに以下

image.png

Step2. ふつうに algolia をつかってみる

vue でフロントを書くときに便利なツールとして vue-instantsearch が用意されていますので、これを使います。

$ yarn add vue-instantsearch

main.ts に以下を追記します。

main.ts
+ import InstantSearch from 'vue-instantsearch';
+ Vue.use(InstantSearch);

これで、 yarn serve すると以下のように怒られます。

error
Could not find a declaration file for module 'vue-instantsearch'.

Vue-instantsearch.d.ts を書きます。これで、解消します。

src/vue-instantsearch.t.ds
declare module 'vue-instantsearch'

App.vue を以下のようにすることでひとまず動きます。
cf. Create your first search experience

App.vue
<template>
  <ais-index
    app-id="latency"
    api-key="3d9875e51fbd20c7754e65422f7ce5e1"
    index-name="bestbuy"
  >
    <ais-search-box></ais-search-box>
    <ais-results>
      <template slot-scope="{ result }">
        <h2>
          <ais-highlight :result="result" attribute-name="name"></ais-highlight>
        </h2>
      </template>
    </ais-results>
  </ais-index>
</template>

こんな感じ。

image.png

ここまでで、ふつうに algolia を使うところまで出来ました。

Step3. 自分が用意したデータでインデックスをつくってみる

動作確認をしたいので、index データを自分でつくってみます。go で書きます。

main.go
import (
    "encoding/json"
    "github.com/algolia/algoliasearch-client-go/algoliasearch"
    "net/http"
)

func main() {
    client := algoliasearch.NewClient(
        "{Your Application ID}",
        "{Admin API Key}",
    )
    index := client.InitIndex("test_notes")
    str := `[{"id": 1, "content": "Hello world"}, {"id": 2, "content": "I like Vue.js"}]`
    var posts []algoliasearch.Object
    d := json.NewDecoder(strings.NewReader(str))
    d.Decode(&posts)
    index.AddObjects(posts)
}
$ go run main.go

これで、インデックスがつくられてダッシュボードからも確認が可能になります。

image.png

これを Vue.js 側から呼び出すには以下の部分を修正します。

App.vue
// 抜粋
<div id="app" class="container-fluid">
    <ais-index
      id="main"
      app-id="自分の" // <- ここ
      api-key="自分の" // <- ここ
      index-name="test_notes" // <- ここ
// 抜粋
    <ais-highlight :result="result" attribute-name="content"></ais-highlight> // <- ここ

image.png

良い感じに呼び出せています。

大量にデータを入れておきたいので、json ファイルから index を作れるように go のコードを変更しておきます。

main.go
func main() {
    client := algoliasearch.NewClient(
        "{Your Application ID}",
        "{Admin API Key}",
    )
    index := client.InitIndex("test_notes")
    jsonFile, _ := os.Open("notes.json")

    var posts []algoliasearch.Object
    byteValue, _ := ioutil.ReadAll(jsonFile)
    json.Unmarshal([]byte(byteValue), &posts)
    index.AddObjects(posts)
}

notes.json に json データを大量に書いて置いてください。無限スクロールが楽しめるように。

Step4. 無限スクロールに対応する

cf. Infinite scroll

$ yarn add vue-observe-visibility
main.ts
+ import VueObserveVisibility from 'vue-observe-visibility'
+ Vue.use(VueObserveVisibility)
App.vue
     index-name="test_notes"
+    :query-parameters="{'page': page}"
   >
     <ais-search-box></ais-search-box>
-    <ais-results>
+    <ais-results :stack="true">
       <template slot-scope="{ result }">
         <h2>
           <ais-highlight :result="result" attribute-name="content"></ais-highlight>
         </h2>
       </template>
     </ais-results>
+    <div v-observe-visibility="loadMore">Loading more...</div>
   </ais-index>
 </template>

以上で、無限スクロールに対応できました。

Step5. 検索のたびに page = 1 にリセットしてみる

調整をした App.vue の全体を記載しておきます。 mountedresetPage を追加しています。

これで冒頭の gif が得られます。

main.ts
import Vue from 'vue'
import App from './App.vue'
import InstantSearch from 'vue-instantsearch';
import VueObserveVisibility from 'vue-observe-visibility'

Vue.use(InstantSearch);
Vue.use(VueObserveVisibility)

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')
App.vue
<template>
  <div id="app" class="container-fluid">
    <ais-index
      id="main"
      app-id="自分の"
      api-key="自分の"
      index-name="test_notes"
      :query-parameters="{'page': page}"
    >
      <div class="row">
        <ais-search-box>
          <div class="input-group">
            <ais-input
              id="input"
              placeholder="Search product by name or reference..."
              :class-names="{
                'ais-input': 'form-control'
                }"
            ></ais-input>
          </div>
        </ais-search-box>
      </div>
      <div class="row">
        <ais-results :stack="true">
          <template scope="{ result }">
            <div class="content">
              <ais-highlight :result="result" attribute-name="content"></ais-highlight>
            </div>
          </template>
        </ais-results>
        <ais-no-results></ais-no-results>
        <div id="loadMore" v-observe-visibility="loadMore">Loading more...</div>
      </div>
    </ais-index>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component
export default class App extends Vue {
  page = 1;
  loadMore(isVisible: any) {
    if (isVisible) {
      this.page++;
    }
  }

  mounted() {
    const i = document.querySelector("#input");
    i!.addEventListener("input", () => {
      this.page = 1;
      this.resetPage();
    });
  }

  // page = 1 にして 20 件を表示するが、
  // それでも画面に div id="loadMore" が表示されている場合は page++ するため。
  // ここのロジックが中途半端...
  resetPage() {
    const e = document.querySelector("#loadMore");
    const topPosition = e!.getBoundingClientRect().top;
    const windowHeight = window.innerHeight;
    if (topPosition <= windowHeight) {
      this.page++;
    }
  }
}
</script>

<style>
#app {
  text-align: center;
}
#input {
  margin: 20px;
}
.content {
  height: 40px;
}
</style>

以上です。
algolia に入門してみましたが、とても使いやすい気がします。細かなところは、もっと深い部分をちゃんと読まないと。?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ネイティブアプリの緩やかな死とSPAのメリデメ比較

はじめに

タイトルは半分釣りですが、半分本気でそう考えています。
現在SPAでWebアプリを作る仕事をしていますが、遠くない未来、ネイティブアプリの時代は終わって、SPAで作られたWebアプリが主流になると思っています。
今回はネイティブアプリに変わるかもしれないSPAについて、具体的にどんなものか、ネイティブアプリと比較した場合のメリデメ等も考えていきたいと思います。

SPAとは何か?

SPAとは、SinglePageApplication(シングルページアプリケーション)の略称で、JavaScriptを使って、単一のページでコンテンツの切り替えを行う、Webアプリケーションを作る為の手法です。
私のポートフォリオサイトも、SPA(Nuxt.js)で作っています。
非同期通信を使って、ブラウザによるページ遷移を行わずに、コンテンツを切り替える事ができます。
裏側のHTMLはheadとCSSとJavaScriptしか存在せず、JavaScriptがDOMの切り替えを行なっています。

Pjaxと何が違うの?

少し話が脱線してしまいますが、非同期通信が出来るjQueryライブラリに、Pjaxというものがあります。
Pjaxは、URLを変更すると非同期でコンテンツを入れ替えてくれるライブラリで、SPAとは違い遷移先にページが存在しています(HTMLがある)。
SPAの場合、実際には遷移先にページが存在しておらず、JavaScriptでDOMとURLを動的に切り替えて、リクエストがあった差分のみを描画しています。

アプリ開発の現状

SPAは、非同期通信による滑らかな挙動、豊富なライブラリ、安全な認証等、ネイティブアプリと比較しても遜色ない環境が揃っていますが、まだ広く普及しているとは言い難いと思います。
裏側の管理画面や、単一ページをSPAで開発しているケースはありますが、アプリケーションそのものをSPAで開発しているプロダクトは、まだそう多くはないのではないでしょうか?
エンジニア的なコストはあまり詳しくはないですが、AWSやモジュール設計、セキュリティやパフォーマンスを意識した要件定義、およびコーディングが出来る人はそう多くありません。
デザイナー的にも、通常のWebアプリと同等の設計、つまりPC・タブレット・スマホ毎に設計が必要だったり、参考サイトやノウハウが少ない中での設計、ネイティブアプリと比べて足りないものを別のアプローチで補わないといけなかったりするので、要件定義や設計手法が手探りになる事が多いです。

SPAのメリット

ネイティブアプリと比較して、運用コストが低い

運用コストが低いというより、ネイティブアプリの運用コストが高すぎるのかもしれません。
後述する様にクロスプラットフォームである為、デバイスによって言語が違うということはありません。全てJavaScriptで動作するので、Webブラウザがあればすぐ使う事ができます。
デザインも、iOS・Androidで別に用意するという必要性はなく、またブラウザで動くアプリケーションなので、PCで使用する事も可能です。

ストアによる審査がない

Webアプリケーションなので、ネイティブアプリと違いストアによる審査が必要ありません。
プロダクトが完成したら、サーバーにプッシュして、デプロイするだけですぐに反映されます。リジェクトされる心配はありません。
また、ストアに支払う登録料も維持費も必要ありませんし、ストアによる高い決済手数料を気にする必要もありません。

ネイティブアプリに近い挙動を実現できる

SPAはJavaScriptを使って非同期によるページ遷移を行うので、ネイティブアプリに近い挙動を実現できます。
ネイティブアプリと完全に同じ事は出来ませんが、非同期による滑らかな体験は、従来のWebアプリケーションには無かった強力な武器となります。
データバインディングが出来るので、ネイティブアプリの様に、URLを変えずにタップしたら要素だけ変える、という様な事も可能です。

クロスプラットフォームである

SPAはブラウザで動くので、デバイスを選ばないクロスプラットフォームです。これもSPAの強力な武器となります。
基本的にネイティブアプリは、iOS / Androidで言語も開発チームも異なるので、チームは同じでも同時進行で開発する事はあまりないかと思います。
もちろん、ブラウザによる細かい挙動や仕様を考慮する必要性はありますが、iOS / Androidで同じプロダクトを別で作ることに比べたら、コストは微々たるものです。

豊富な開発環境

今ではネイティブアプリで使える環境が、SPAを使えばWebアプリケーションでも使える様になってきています。
例えば、私のサイトでも認証で使われているFirebase Authenticationは、ネイティブアプリでもWebでも使う事が出来ます。

SPAのデメリット

プッシュ通知が使えない

現状SPAの一番の課題になっているのは、このプッシュ通知が使えない事ではないでしょうか?
一応、WebPushという技術を使えば限定的に使う事はできますが、それでもiOS/Safariではサポートされておりませんので、必然的にiPhoneでは使えない事になります。
プッシュ通知が欲しい場合は、別のアプローチを試みる必要があります。

デザイナー・エンジニア共に経験者が少ない

SPAはまだ現状のアプリケーションの主流になりきれていないので、デザイナー・エンジニア共に経験者が少ないのが現状です。
業務ページ、単一ページをSPAで作成される事はあっても、プロダクトそのものにSPAが使われているケースは、国内ではまだ多くありません(海外の場合、Youtube・Google・JIRA等の前例はあります)。

高度なJavaScriptの技術が必要

正確にいうとデメリットではありませんが、SPAは今までサーバーサイドで行なっていた処理を、JavaScriptを使ってクライアントサイドで実行するので、必然的に高いJavaScriptの技術が必要になります。

SEO対策に注意が必要

現在はそうでもありませんが、SPAは裏側にHTMLが存在せず、JavaScriptを使って動的にDOMを切り替えるので、ほとんどがクローリングの対象外です。この問題を解決する為には、別途SEO対策をする必要があります。
Nuxt.jsであれば、modeを変更するだけで簡単にSEOで必要な要素を書き出してくれるので、現在ではあまり心配する必要はないかもしれません。

初期表示に時間がかかる

初期ページで必要なものを全て読み込む性質上、初期表示に時間がかかる事が多いです。

アナリティクスの設定が独特

慣れの問題なのですが、SPAでGoogleアナリティクスを導入する場合、公式から出ているライブラリを導入して、別途設定をする必要がある為、ネットによくある普通のサイトにアナリティクスを設定するやり方は、通用しない事がほとんどです。
また、通信が発生しない都合上通常のやり方ではイベントトラッキングが出来ないので、トラッキングさせるには、別途特殊な設定が必要だったりもします。

ジェスチャーが使えない

まだまだJavaScriptでは、ネイティブアプリの様な豊富なジェスチャーは使えないのが現状です。
ですが、ほとんどのジェスチャーはボタンアクションでサポート出来るので、あまり困る事もないですが。

ネイティブアプリのメリット

SPAのメリデメと反するものが多いですが、整理する為に改めて書いてみます。

プッシュ通知が使える

SPAと比較して、一番のメリットはこれではないでしょうか。
ネイティブアプリはプッシュ通知によるアプローチが出来るので、アプリはインストールしたけど使っていない様な休眠ユーザーを掘り起こしたり、PRしたいものを効率的にアプローチする事が出来ます。

ホーム画面に追加してもらえる

SPAとネイティブアプリで挙動が変わらないのであれば、インストールしてもらう事で生まれるメリットは、これではないでしょうか。
SPAでもホーム画面にアイコンを追加してもらう事は出来ますが、Androidはホーム画面にアイコンを追加する画面を出せるのでいいですが、iOSの場合はまだサポートされていませんので、ホーム画面に追加する場合、Safariから自分でやってもらうしか方法がありません。

ノウハウが豊富

ストアにネイティブアプリが豊富にあるので、参考事例を探すのに困る事はありません。

ジェスチャーが使える

ボタンアクションで賄えますが、表現が豊富であればアプローチできる幅が広がるので、ジェスチャーを使えるのは魅力的だと思います。

PCのデザインを作らなくていい

ネイティブアプリの場合スマホで動くので、PCのデザインを作らなくて良いのは、工数が一つ減ることになります。

ネイティブアプリのデメリット

運用コストがとてもかかる

これがネイティブアプリの一番のデメリットだと思います。
SPAが登場する以前は致し方なかったですが、SPAが出てきた今、Webアプリケーションとネイティブアプリの差異は、プッシュ通知以外ほぼ無いと言っても良いかもしれません。
そうなるとSPAと比較して、プラットフォームが異なるiOS/Androidでアプリを開発するのは、運用コストがもの凄くかかってきます。
ストアに支払う維持費や決済手数料もとても高いです。

インストールに時間がかかる

SPAの場合ネットに繋がないと使えませんが、裏を返せばそれは、インストールしなくてもページさえ開けば、すぐ使えるということです。
ネイティブアプリの場合インストールしないと使えないので、蓋を開けるまでどの様なアプリか分かりません。
よく分からないアプリを開く為に、わざわざインストールするでしょうか?
Webと違ってネイティブアプリの場合、広告を出してもその後「使ってもらう」為の工夫が必要なのです。

ストアの審査がある

ストアによる審査があるので、ネイティブアプリの場合本番に反映されるのが遅いです。

バージョン管理が大変

あまり詳しくは無いですが、アプリの場合バージョンを上げていかないとリジェクトされて更新が出来なくなるので、定期的にバージョンを上げていかないといけません。
SPAならサーバーにプッシュすればいいだけですが、アプリはそうもいかないみたいです。

まとめ

まとめますと、運用コストがめちゃくちゃかかるネイティブアプリより、今ならSPAで開発する方が遥かに効率的では?ということです。
SPAも開発コストがかかるので、ブログの様な直帰率が高いサービスにはオーバースペックかもしれませんが、滞在時間が長いアプリケーションにはとても適しています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

unserscore.js キーを 取得

キーを取得したい

let key = this.$_.findKey(this.$root.user.socials, {id:value.id});


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む